Skip to content

Commit

Permalink
Add support for virtual projects (#6585)
Browse files Browse the repository at this point in the history
## Summary

The basic idea here is: any project can either be a package, or not
("virtual").

If a project is virtual, we don't build or install it.

A project is virtual if either of the following are true:

- `tool.uv.virtual = true` is set.
- `[build-system]` is absent.

The concept of "virtual projects" only applies to workspace member right
now; it doesn't apply to `path` dependencies which are treated like
arbitrary Python source trees.

TODOs that should be resolved prior to merging:

- [ ] Documentation
- [ ] How do we reconcile this with "virtual workspace roots" which are
a little different -- they omit `[project]` entirely and don't even have
a name?
- [x] `uv init --virtual` should create a virtual project rather than a
virtual workspace.
- [x] Running `uv sync` in a virtual project after `uv init --virtual`
shows `Audited 0 packages in 0.01ms`, which is awkward. (See:
#6588.)

Closes #6511.
  • Loading branch information
charliermarsh authored Aug 27, 2024
1 parent 6d38d42 commit eb14056
Show file tree
Hide file tree
Showing 16 changed files with 1,709 additions and 146 deletions.
9 changes: 5 additions & 4 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2099,11 +2099,12 @@ pub struct InitArgs {
#[arg(long)]
pub name: Option<PackageName>,

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

Expand Down
4 changes: 4 additions & 0 deletions crates/uv-settings/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ pub struct Options {
#[serde(default, skip_serializing)]
#[cfg_attr(feature = "schemars", schemars(skip))]
managed: serde::de::IgnoredAny,

#[serde(default, skip_serializing)]
#[cfg_attr(feature = "schemars", schemars(skip))]
r#package: serde::de::IgnoredAny,
}

impl Options {
Expand Down
39 changes: 39 additions & 0 deletions crates/uv-workspace/src/pyproject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ pub struct PyProjectToml {
/// The raw unserialized document.
#[serde(skip)]
pub raw: String,

/// Used to determine whether a `build-system` is present.
#[serde(default, skip_serializing)]
build_system: Option<serde::de::IgnoredAny>,
}

impl PyProjectToml {
Expand All @@ -41,6 +45,23 @@ impl PyProjectToml {
let pyproject = toml::from_str(&raw)?;
Ok(PyProjectToml { raw, ..pyproject })
}

/// Returns `true` if the project should be considered a Python package, as opposed to a
/// non-package ("virtual") project.
pub fn is_package(&self) -> bool {
// If `tool.uv.package` is set, defer to that explicit setting.
if let Some(is_package) = self
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.package)
{
return is_package;
}

// Otherwise, a project is assumed to be a package if `build-system` is present.
self.build_system.is_some()
}
}

// Ignore raw document in comparison.
Expand Down Expand Up @@ -100,6 +121,24 @@ pub struct ToolUv {
"#
)]
pub managed: Option<bool>,
/// Whether the project should be considered a Python package, or a non-package ("virtual")
/// project.
///
/// Packages are built and installed into the virtual environment in editable mode and thus
/// require a build backend, while virtual projects are _not_ built or installed; instead, only
/// their dependencies are included in the virtual environment.
///
/// Creating a package requires that a `build-system` is present in the `pyproject.toml`, and
/// that the project adheres to a structure that adheres to the build backend's expectations
/// (e.g., a `src` layout).
#[option(
default = r#"true"#,
value_type = "bool",
example = r#"
package = false
"#
)]
pub package: Option<bool>,
/// The project's development dependencies. Development dependencies will be installed by
/// default in `uv run` and `uv sync`, but will not appear in the project's published metadata.
#[cfg_attr(
Expand Down
6 changes: 4 additions & 2 deletions crates/uv-workspace/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1339,15 +1339,15 @@ impl VirtualProject {
}
}

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

/// Returns `true` if the project is a virtual workspace.
/// Returns `true` if the project is a virtual workspace root.
pub fn is_virtual(&self) -> bool {
matches!(self, VirtualProject::Virtual(_))
}
Expand Down Expand Up @@ -1535,6 +1535,7 @@ mod tests {
"exclude": null
},
"managed": null,
"package": null,
"dev-dependencies": null,
"environments": null,
"override-dependencies": null,
Expand Down Expand Up @@ -1607,6 +1608,7 @@ mod tests {
"exclude": null
},
"managed": null,
"package": null,
"dev-dependencies": null,
"environments": null,
"override-dependencies": null,
Expand Down
126 changes: 62 additions & 64 deletions crates/uv/src/commands/project/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use uv_python::{
};
use uv_resolver::RequiresPython;
use uv_workspace::pyproject_mut::{DependencyTarget, PyProjectTomlMut};
use uv_workspace::{check_nested_workspaces, DiscoveryOptions, Workspace, WorkspaceError};
use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceError};

use crate::commands::project::find_requires_python;
use crate::commands::reporters::PythonDownloadReporter;
Expand Down Expand Up @@ -69,24 +69,21 @@ pub(crate) async fn init(
}
};

if r#virtual {
init_virtual_workspace(&path, no_workspace)?;
} else {
init_project(
&path,
&name,
no_readme,
python,
no_workspace,
python_preference,
python_downloads,
connectivity,
native_tls,
cache,
printer,
)
.await?;
}
init_project(
&path,
&name,
r#virtual,
no_readme,
python,
no_workspace,
python_preference,
python_downloads,
connectivity,
native_tls,
cache,
printer,
)
.await?;

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

/// Initialize a virtual workspace at the given path.
fn init_virtual_workspace(path: &Path, no_workspace: bool) -> Result<()> {
// Ensure that we aren't creating a nested workspace.
if !no_workspace {
check_nested_workspaces(path, &DiscoveryOptions::default());
}

// Create the `pyproject.toml`.
let pyproject = indoc::indoc! {r"
[tool.uv.workspace]
members = []
"};

fs_err::create_dir_all(path)?;
fs_err::write(path.join("pyproject.toml"), pyproject)?;

Ok(())
}

/// Initialize a project (and, implicitly, a workspace root) at the given path.
#[allow(clippy::fn_params_excessive_bools)]
async fn init_project(
path: &Path,
name: &PackageName,
r#virtual: bool,
no_readme: bool,
python: Option<String>,
no_workspace: bool,
Expand Down Expand Up @@ -265,38 +245,56 @@ async fn init_project(
RequiresPython::greater_than_equal_version(&interpreter.python_minor_version())
};

// Create the `pyproject.toml`.
let pyproject = indoc::formatdoc! {r#"
[project]
name = "{name}"
version = "0.1.0"
description = "Add your description here"{readme}
requires-python = "{requires_python}"
dependencies = []
if r#virtual {
// Create the `pyproject.toml`, but omit `[build-system]`.
let pyproject = indoc::formatdoc! {r#"
[project]
name = "{name}"
version = "0.1.0"
description = "Add your description here"{readme}
requires-python = "{requires_python}"
dependencies = []
"#,
readme = if no_readme { "" } else { "\nreadme = \"README.md\"" },
requires_python = requires_python.specifiers(),
};

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
"#,
readme = if no_readme { "" } else { "\nreadme = \"README.md\"" },
requires_python = requires_python.specifiers(),
};
fs_err::create_dir_all(path)?;
fs_err::write(path.join("pyproject.toml"), pyproject)?;
} else {
// Create the `pyproject.toml`.
let pyproject = indoc::formatdoc! {r#"
[project]
name = "{name}"
version = "0.1.0"
description = "Add your description here"{readme}
requires-python = "{requires_python}"
dependencies = []
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
"#,
readme = if no_readme { "" } else { "\nreadme = \"README.md\"" },
requires_python = requires_python.specifiers(),
};

fs_err::create_dir_all(path)?;
fs_err::write(path.join("pyproject.toml"), pyproject)?;
fs_err::create_dir_all(path)?;
fs_err::write(path.join("pyproject.toml"), pyproject)?;

// Create `src/{name}/__init__.py`, if it doesn't exist already.
let src_dir = path.join("src").join(&*name.as_dist_info_name());
let init_py = src_dir.join("__init__.py");
if !init_py.try_exists()? {
fs_err::create_dir_all(&src_dir)?;
fs_err::write(
init_py,
indoc::formatdoc! {r#"
// Create `src/{name}/__init__.py`, if it doesn't exist already.
let src_dir = path.join("src").join(&*name.as_dist_info_name());
let init_py = src_dir.join("__init__.py");
if !init_py.try_exists()? {
fs_err::create_dir_all(&src_dir)?;
fs_err::write(
init_py,
indoc::formatdoc! {r#"
def hello() -> str:
return "Hello from {name}!"
"#},
)?;
)?;
}
}

if let Some(workspace) = workspace {
Expand Down
35 changes: 35 additions & 0 deletions crates/uv/src/commands/project/sync.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
use anyhow::{Context, Result};
use itertools::Itertools;
use rustc_hash::FxHashSet;

use distribution_types::Name;
use pep508_rs::MarkerTree;
use uv_auth::store_credentials_from_url;
use uv_cache::Cache;
Expand Down Expand Up @@ -195,6 +198,9 @@ pub(super) async fn do_sync(
// Read the lockfile.
let resolution = lock.to_resolution(project, &markers, tags, extras, &dev)?;

// Always skip virtual projects, which shouldn't be built or installed.
let resolution = apply_no_virtual_project(resolution, project);

// Filter resolution based on install-specific options.
let resolution = install_options.filter_resolution(resolution, project);

Expand Down Expand Up @@ -289,3 +295,32 @@ pub(super) async fn do_sync(

Ok(())
}

/// Filter out any virtual workspace members.
fn apply_no_virtual_project(
resolution: distribution_types::Resolution,
project: &VirtualProject,
) -> distribution_types::Resolution {
let VirtualProject::Project(project) = project else {
// If the project is _only_ a virtual workspace root, we don't need to filter it out.
return resolution;
};

let virtual_members = project
.workspace()
.packages()
.iter()
.filter_map(|(name, package)| {
// A project is a package if it's explicitly marked as such, _or_ if a build system is
// present.
if package.pyproject_toml().is_package() {
None
} else {
Some(name)
}
})
.collect::<FxHashSet<_>>();

// Remove any virtual members from the resolution.
resolution.filter(|dist| !virtual_members.contains(dist.name()))
}
5 changes: 3 additions & 2 deletions crates/uv/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1110,8 +1110,9 @@ pub fn make_project(dir: &Path, name: &str, body: &str) -> anyhow::Result<()> {
{body}
[build-system]
requires = ["flit_core>=3.8,<4"]
build-backend = "flit_core.buildapi"
requires = ["setuptools>=42", "wheel"]
build-backend = "setuptools.build_meta"
"#
};
fs_err::create_dir_all(dir)?;
Expand Down
Loading

0 comments on commit eb14056

Please sign in to comment.