Skip to content

Commit 79a875b

Browse files
committed
Add support for virtual projects
1 parent 3f15f2d commit 79a875b

File tree

16 files changed

+1680
-146
lines changed

16 files changed

+1680
-146
lines changed

crates/uv-cli/src/lib.rs

+5-4
Original file line numberDiff line numberDiff line change
@@ -2099,11 +2099,12 @@ pub struct InitArgs {
20992099
#[arg(long)]
21002100
pub name: Option<PackageName>,
21012101

2102-
/// Create a virtual workspace instead of a project.
2102+
/// Create a virtual project, rather than a package.
21032103
///
2104-
/// A virtual workspace does not define project dependencies and cannot be
2105-
/// published. Instead, workspace members declare project dependencies.
2106-
/// Development dependencies may still be declared.
2104+
/// A virtual project is a project that is not intended to be built as a Python package,
2105+
/// such as a project that only contains scripts or other application code.
2106+
///
2107+
/// Virtual projects themselves are not installed into the Python environment.
21072108
#[arg(long)]
21082109
pub r#virtual: bool,
21092110

crates/uv-settings/src/settings.rs

+4
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ pub struct Options {
7070
#[serde(default, skip_serializing)]
7171
#[cfg_attr(feature = "schemars", schemars(skip))]
7272
managed: serde::de::IgnoredAny,
73+
74+
#[serde(default, skip_serializing)]
75+
#[cfg_attr(feature = "schemars", schemars(skip))]
76+
r#virtual: serde::de::IgnoredAny,
7377
}
7478

7579
impl Options {

crates/uv-workspace/src/pyproject.rs

+34
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ pub struct PyProjectToml {
3333
/// The raw unserialized document.
3434
#[serde(skip)]
3535
pub raw: String,
36+
37+
/// Used to determine whether a `build-system` is present.
38+
#[serde(default, skip_serializing)]
39+
build_system: Option<serde::de::IgnoredAny>,
3640
}
3741

3842
impl PyProjectToml {
@@ -41,6 +45,27 @@ impl PyProjectToml {
4145
let pyproject = toml::from_str(&raw)?;
4246
Ok(PyProjectToml { raw, ..pyproject })
4347
}
48+
49+
/// Returns `true` if the project should be considered "virtual".
50+
pub fn is_virtual(&self) -> bool {
51+
// A project is virtual if `virtual = true` is set...
52+
if self
53+
.tool
54+
.as_ref()
55+
.and_then(|tool| tool.uv.as_ref())
56+
.and_then(|uv| uv.r#virtual)
57+
== Some(true)
58+
{
59+
return true;
60+
}
61+
62+
// Or if `build-system` is not present.
63+
if self.build_system.is_none() {
64+
return true;
65+
}
66+
67+
false
68+
}
4469
}
4570

4671
// Ignore raw document in comparison.
@@ -100,6 +125,15 @@ pub struct ToolUv {
100125
"#
101126
)]
102127
pub managed: Option<bool>,
128+
/// Whether the project should be considered "virtual".
129+
#[option(
130+
default = r#"true"#,
131+
value_type = "bool",
132+
example = r#"
133+
virtual = false
134+
"#
135+
)]
136+
pub r#virtual: Option<bool>,
103137
/// The project's development dependencies. Development dependencies will be installed by
104138
/// default in `uv run` and `uv sync`, but will not appear in the project's published metadata.
105139
#[cfg_attr(

crates/uv-workspace/src/workspace.rs

+4-2
Original file line numberDiff line numberDiff line change
@@ -1339,15 +1339,15 @@ impl VirtualProject {
13391339
}
13401340
}
13411341

1342-
/// Return the [`PackageName`] of the project, if it's not a virtual workspace.
1342+
/// Return the [`PackageName`] of the project, if it's not a virtual workspace root.
13431343
pub fn project_name(&self) -> Option<&PackageName> {
13441344
match self {
13451345
VirtualProject::Project(project) => Some(project.project_name()),
13461346
VirtualProject::Virtual(_) => None,
13471347
}
13481348
}
13491349

1350-
/// Returns `true` if the project is a virtual workspace.
1350+
/// Returns `true` if the project is a virtual workspace root.
13511351
pub fn is_virtual(&self) -> bool {
13521352
matches!(self, VirtualProject::Virtual(_))
13531353
}
@@ -1535,6 +1535,7 @@ mod tests {
15351535
"exclude": null
15361536
},
15371537
"managed": null,
1538+
"virtual": null,
15381539
"dev-dependencies": null,
15391540
"environments": null,
15401541
"override-dependencies": null,
@@ -1607,6 +1608,7 @@ mod tests {
16071608
"exclude": null
16081609
},
16091610
"managed": null,
1611+
"virtual": null,
16101612
"dev-dependencies": null,
16111613
"environments": null,
16121614
"override-dependencies": null,

crates/uv/src/commands/project/init.rs

+62-64
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use uv_python::{
1515
};
1616
use uv_resolver::RequiresPython;
1717
use uv_workspace::pyproject_mut::{DependencyTarget, PyProjectTomlMut};
18-
use uv_workspace::{check_nested_workspaces, DiscoveryOptions, Workspace, WorkspaceError};
18+
use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceError};
1919

2020
use crate::commands::project::find_requires_python;
2121
use crate::commands::reporters::PythonDownloadReporter;
@@ -69,24 +69,21 @@ pub(crate) async fn init(
6969
}
7070
};
7171

72-
if r#virtual {
73-
init_virtual_workspace(&path, no_workspace)?;
74-
} else {
75-
init_project(
76-
&path,
77-
&name,
78-
no_readme,
79-
python,
80-
no_workspace,
81-
python_preference,
82-
python_downloads,
83-
connectivity,
84-
native_tls,
85-
cache,
86-
printer,
87-
)
88-
.await?;
89-
}
72+
init_project(
73+
&path,
74+
&name,
75+
r#virtual,
76+
no_readme,
77+
python,
78+
no_workspace,
79+
python_preference,
80+
python_downloads,
81+
connectivity,
82+
native_tls,
83+
cache,
84+
printer,
85+
)
86+
.await?;
9087

9188
// Create the `README.md` if it does not already exist.
9289
if !no_readme {
@@ -126,29 +123,12 @@ pub(crate) async fn init(
126123
Ok(ExitStatus::Success)
127124
}
128125

129-
/// Initialize a virtual workspace at the given path.
130-
fn init_virtual_workspace(path: &Path, no_workspace: bool) -> Result<()> {
131-
// Ensure that we aren't creating a nested workspace.
132-
if !no_workspace {
133-
check_nested_workspaces(path, &DiscoveryOptions::default());
134-
}
135-
136-
// Create the `pyproject.toml`.
137-
let pyproject = indoc::indoc! {r"
138-
[tool.uv.workspace]
139-
members = []
140-
"};
141-
142-
fs_err::create_dir_all(path)?;
143-
fs_err::write(path.join("pyproject.toml"), pyproject)?;
144-
145-
Ok(())
146-
}
147-
148126
/// Initialize a project (and, implicitly, a workspace root) at the given path.
127+
#[allow(clippy::fn_params_excessive_bools)]
149128
async fn init_project(
150129
path: &Path,
151130
name: &PackageName,
131+
r#virtual: bool,
152132
no_readme: bool,
153133
python: Option<String>,
154134
no_workspace: bool,
@@ -265,38 +245,56 @@ async fn init_project(
265245
RequiresPython::greater_than_equal_version(&interpreter.python_minor_version())
266246
};
267247

268-
// Create the `pyproject.toml`.
269-
let pyproject = indoc::formatdoc! {r#"
270-
[project]
271-
name = "{name}"
272-
version = "0.1.0"
273-
description = "Add your description here"{readme}
274-
requires-python = "{requires_python}"
275-
dependencies = []
248+
if r#virtual {
249+
// Create the `pyproject.toml`, but omit `[build-system]`.
250+
let pyproject = indoc::formatdoc! {r#"
251+
[project]
252+
name = "{name}"
253+
version = "0.1.0"
254+
description = "Add your description here"{readme}
255+
requires-python = "{requires_python}"
256+
dependencies = []
257+
"#,
258+
readme = if no_readme { "" } else { "\nreadme = \"README.md\"" },
259+
requires_python = requires_python.specifiers(),
260+
};
276261

277-
[build-system]
278-
requires = ["hatchling"]
279-
build-backend = "hatchling.build"
280-
"#,
281-
readme = if no_readme { "" } else { "\nreadme = \"README.md\"" },
282-
requires_python = requires_python.specifiers(),
283-
};
262+
fs_err::create_dir_all(path)?;
263+
fs_err::write(path.join("pyproject.toml"), pyproject)?;
264+
} else {
265+
// Create the `pyproject.toml`.
266+
let pyproject = indoc::formatdoc! {r#"
267+
[project]
268+
name = "{name}"
269+
version = "0.1.0"
270+
description = "Add your description here"{readme}
271+
requires-python = "{requires_python}"
272+
dependencies = []
273+
274+
[build-system]
275+
requires = ["hatchling"]
276+
build-backend = "hatchling.build"
277+
"#,
278+
readme = if no_readme { "" } else { "\nreadme = \"README.md\"" },
279+
requires_python = requires_python.specifiers(),
280+
};
284281

285-
fs_err::create_dir_all(path)?;
286-
fs_err::write(path.join("pyproject.toml"), pyproject)?;
282+
fs_err::create_dir_all(path)?;
283+
fs_err::write(path.join("pyproject.toml"), pyproject)?;
287284

288-
// Create `src/{name}/__init__.py`, if it doesn't exist already.
289-
let src_dir = path.join("src").join(&*name.as_dist_info_name());
290-
let init_py = src_dir.join("__init__.py");
291-
if !init_py.try_exists()? {
292-
fs_err::create_dir_all(&src_dir)?;
293-
fs_err::write(
294-
init_py,
295-
indoc::formatdoc! {r#"
285+
// Create `src/{name}/__init__.py`, if it doesn't exist already.
286+
let src_dir = path.join("src").join(&*name.as_dist_info_name());
287+
let init_py = src_dir.join("__init__.py");
288+
if !init_py.try_exists()? {
289+
fs_err::create_dir_all(&src_dir)?;
290+
fs_err::write(
291+
init_py,
292+
indoc::formatdoc! {r#"
296293
def hello() -> str:
297294
return "Hello from {name}!"
298295
"#},
299-
)?;
296+
)?;
297+
}
300298
}
301299

302300
if let Some(workspace) = workspace {

crates/uv/src/commands/project/sync.rs

+35
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
use anyhow::{Context, Result};
22
use itertools::Itertools;
3+
use rustc_hash::FxHashSet;
4+
5+
use distribution_types::Name;
36
use pep508_rs::MarkerTree;
47
use uv_auth::store_credentials_from_url;
58
use uv_cache::Cache;
@@ -195,6 +198,9 @@ pub(super) async fn do_sync(
195198
// Read the lockfile.
196199
let resolution = lock.to_resolution(project, &markers, tags, extras, &dev)?;
197200

201+
// Always skip virtual projects, which shouldn't be built or installed.
202+
let resolution = apply_no_virtual_project(resolution, project);
203+
198204
// Filter resolution based on install-specific options.
199205
let resolution = install_options.filter_resolution(resolution, project);
200206

@@ -289,3 +295,32 @@ pub(super) async fn do_sync(
289295

290296
Ok(())
291297
}
298+
299+
/// Filter out any virtual workspace members.
300+
fn apply_no_virtual_project(
301+
resolution: distribution_types::Resolution,
302+
project: &VirtualProject,
303+
) -> distribution_types::Resolution {
304+
let VirtualProject::Project(project) = project else {
305+
// If the project is _only_ a virtual workspace root, we don't need to filter it out.
306+
return resolution;
307+
};
308+
309+
let virtual_members = project
310+
.workspace()
311+
.packages()
312+
.iter()
313+
.filter_map(|(name, package)| {
314+
// A project is virtual if it's explicitly marked as virtual, _or_ if it's missing a
315+
// build system.
316+
if package.pyproject_toml().is_virtual() {
317+
Some(name)
318+
} else {
319+
None
320+
}
321+
})
322+
.collect::<FxHashSet<_>>();
323+
324+
// Remove any virtual members from the resolution.
325+
resolution.filter(|dist| !virtual_members.contains(dist.name()))
326+
}

crates/uv/tests/common/mod.rs

+3-2
Original file line numberDiff line numberDiff line change
@@ -1110,8 +1110,9 @@ pub fn make_project(dir: &Path, name: &str, body: &str) -> anyhow::Result<()> {
11101110
{body}
11111111
11121112
[build-system]
1113-
requires = ["flit_core>=3.8,<4"]
1114-
build-backend = "flit_core.buildapi"
1113+
requires = ["setuptools>=42", "wheel"]
1114+
build-backend = "setuptools.build_meta"
1115+
11151116
"#
11161117
};
11171118
fs_err::create_dir_all(dir)?;

0 commit comments

Comments
 (0)