Skip to content

Commit

Permalink
Add support for virtual projects
Browse files Browse the repository at this point in the history
  • Loading branch information
charliermarsh committed Aug 27, 2024
1 parent 3f15f2d commit 79a875b
Show file tree
Hide file tree
Showing 16 changed files with 1,680 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#virtual: serde::de::IgnoredAny,
}

impl Options {
Expand Down
34 changes: 34 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,27 @@ impl PyProjectToml {
let pyproject = toml::from_str(&raw)?;
Ok(PyProjectToml { raw, ..pyproject })
}

/// Returns `true` if the project should be considered "virtual".
pub fn is_virtual(&self) -> bool {
// A project is virtual if `virtual = true` is set...
if self
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.r#virtual)
== Some(true)
{
return true;
}

// Or if `build-system` is not present.
if self.build_system.is_none() {
return true;
}

false
}
}

// Ignore raw document in comparison.
Expand Down Expand Up @@ -100,6 +125,15 @@ pub struct ToolUv {
"#
)]
pub managed: Option<bool>,
/// Whether the project should be considered "virtual".
#[option(
default = r#"true"#,
value_type = "bool",
example = r#"
virtual = false
"#
)]
pub r#virtual: 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,
"virtual": null,
"dev-dependencies": null,
"environments": null,
"override-dependencies": null,
Expand Down Expand Up @@ -1607,6 +1608,7 @@ mod tests {
"exclude": null
},
"managed": null,
"virtual": 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 virtual if it's explicitly marked as virtual, _or_ if it's missing a
// build system.
if package.pyproject_toml().is_virtual() {
Some(name)
} else {
None
}
})
.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 79a875b

Please sign in to comment.