From cd40a3452295a8d4b6af69206c43282096507c89 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 17 Jul 2025 13:38:02 -0500 Subject: [PATCH] Build and install workspace members that are dependencies by default (#14663) Regardless of the presence of a build system, as in https://github.com/astral-sh/uv/pull/14413 --------- Co-authored-by: John Mumm --- .../uv-distribution/src/metadata/lowering.rs | 8 +- crates/uv-platform-tags/src/tags.rs | 2 +- crates/uv-resolver/src/lock/mod.rs | 6 +- crates/uv-workspace/src/pyproject.rs | 8 +- crates/uv-workspace/src/workspace.rs | 125 ++++- crates/uv/src/commands/build_frontend.rs | 4 +- crates/uv/src/commands/project/lock.rs | 4 + crates/uv/src/commands/project/lock_target.rs | 14 +- crates/uv/src/commands/project/sync.rs | 2 +- crates/uv/tests/it/edit.rs | 20 +- crates/uv/tests/it/lock.rs | 459 +++++++++++++++++- crates/uv/tests/it/lock_conflict.rs | 40 +- crates/uv/tests/it/pip_compile.rs | 12 +- crates/uv/tests/it/sync.rs | 100 +++- docs/concepts/projects/dependencies.md | 54 ++- 15 files changed, 791 insertions(+), 67 deletions(-) diff --git a/crates/uv-distribution/src/metadata/lowering.rs b/crates/uv-distribution/src/metadata/lowering.rs index c05ac4779..a8e899bb4 100644 --- a/crates/uv-distribution/src/metadata/lowering.rs +++ b/crates/uv-distribution/src/metadata/lowering.rs @@ -306,7 +306,10 @@ impl LoweredRequirement { }, url, } - } else if member.pyproject_toml().is_package() { + } else if member + .pyproject_toml() + .is_package(!workspace.is_required_member(&requirement.name)) + { RequirementSource::Directory { install_path: install_path.into_boxed_path(), url, @@ -736,7 +739,8 @@ fn path_source( fs_err::read_to_string(&pyproject_path) .ok() .and_then(|contents| PyProjectToml::from_string(contents).ok()) - .and_then(|pyproject_toml| pyproject_toml.tool_uv_package()) + // We don't require a build system for path dependencies + .map(|pyproject_toml| pyproject_toml.is_package(false)) .unwrap_or(true) }); diff --git a/crates/uv-platform-tags/src/tags.rs b/crates/uv-platform-tags/src/tags.rs index 7381f5dd5..f2c6d6cbb 100644 --- a/crates/uv-platform-tags/src/tags.rs +++ b/crates/uv-platform-tags/src/tags.rs @@ -771,7 +771,7 @@ mod tests { /// A reference list can be generated with: /// ```text /// $ python -c "from packaging import tags; [print(tag) for tag in tags.platform_tags()]"` - /// ```` + /// ``` #[test] fn test_platform_tags_manylinux() { let tags = compatible_tags(&Platform::new( diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 7cbac67df..49cb851b3 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -1255,6 +1255,7 @@ impl Lock { root: &Path, packages: &BTreeMap, members: &[PackageName], + required_members: &BTreeSet, requirements: &[Requirement], constraints: &[Requirement], overrides: &[Requirement], @@ -1282,7 +1283,10 @@ impl Lock { // Validate that the member sources have not changed (e.g., that they've switched from // virtual to non-virtual or vice versa). for (name, member) in packages { - let expected = !member.pyproject_toml().is_package(); + // We don't require a build system, if the workspace member is a dependency + let expected = !member + .pyproject_toml() + .is_package(!required_members.contains(name)); let actual = self .find_by_name(name) .ok() diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index aa64c601e..4a994b801 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -66,7 +66,7 @@ pub struct PyProjectToml { /// Used to determine whether a `build-system` section is present. #[serde(default, skip_serializing)] - build_system: Option, + pub build_system: Option, } impl PyProjectToml { @@ -81,18 +81,18 @@ impl PyProjectToml { /// 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 { + pub fn is_package(&self, require_build_system: bool) -> bool { // If `tool.uv.package` is set, defer to that explicit setting. if let Some(is_package) = self.tool_uv_package() { return is_package; } // Otherwise, a project is assumed to be a package if `build-system` is present. - self.build_system.is_some() + self.build_system.is_some() || !require_build_system } /// Returns the value of `tool.uv.package` if set. - pub fn tool_uv_package(&self) -> Option { + fn tool_uv_package(&self) -> Option { self.tool .as_ref() .and_then(|tool| tool.uv.as_ref()) diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 8d09554d9..09f2b692a 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -20,7 +20,7 @@ use uv_warnings::warn_user_once; use crate::dependency_groups::{DependencyGroupError, FlatDependencyGroup, FlatDependencyGroups}; use crate::pyproject::{ - Project, PyProjectToml, PyprojectTomlError, Sources, ToolUvSources, ToolUvWorkspace, + Project, PyProjectToml, PyprojectTomlError, Source, Sources, ToolUvSources, ToolUvWorkspace, }; type WorkspaceMembers = Arc>; @@ -109,6 +109,8 @@ pub struct Workspace { install_path: PathBuf, /// The members of the workspace. packages: WorkspaceMembers, + /// The workspace members that are required by other members. + required_members: BTreeSet, /// The sources table from the workspace `pyproject.toml`. /// /// This table is overridden by the project sources. @@ -260,6 +262,7 @@ impl Workspace { pyproject_toml: PyProjectToml, ) -> Option { let mut packages = self.packages; + let member = Arc::make_mut(&mut packages).get_mut(package_name)?; if member.root == self.install_path { @@ -279,17 +282,33 @@ impl Workspace { // Set the `pyproject.toml` for the member. member.pyproject_toml = pyproject_toml; + // Recompute required_members with the updated data + let required_members = Self::collect_required_members( + &packages, + &workspace_sources, + &workspace_pyproject_toml, + ); + Some(Self { pyproject_toml: workspace_pyproject_toml, sources: workspace_sources, packages, + required_members, ..self }) } else { // Set the `pyproject.toml` for the member. member.pyproject_toml = pyproject_toml; - Some(Self { packages, ..self }) + // Recompute required_members with the updated member data + let required_members = + Self::collect_required_members(&packages, &self.sources, &self.pyproject_toml); + + Some(Self { + packages, + required_members, + ..self + }) } } @@ -303,7 +322,7 @@ impl Workspace { /// Returns the set of all workspace members. pub fn members_requirements(&self) -> impl Iterator + '_ { - self.packages.values().filter_map(|member| { + self.packages.iter().filter_map(|(name, member)| { let url = VerbatimUrl::from_absolute_path(&member.root) .expect("path is valid URL") .with_given(member.root.to_string_lossy()); @@ -312,7 +331,10 @@ impl Workspace { extras: Box::new([]), groups: Box::new([]), marker: MarkerTree::TRUE, - source: if member.pyproject_toml.is_package() { + source: if member + .pyproject_toml() + .is_package(!self.is_required_member(name)) + { RequirementSource::Directory { install_path: member.root.clone().into_boxed_path(), editable: Some(true), @@ -332,9 +354,65 @@ impl Workspace { }) } + /// The workspace members that are required my another member of the workspace. + pub fn required_members(&self) -> &BTreeSet { + &self.required_members + } + + /// Compute the workspace members that are required by another member of the workspace. + /// + /// N.B. this checks if a workspace member is required by inspecting `tool.uv.source` entries, + /// but does not actually check if the source is _used_, which could result in false positives + /// but is easier to compute. + fn collect_required_members( + packages: &BTreeMap, + sources: &BTreeMap, + pyproject_toml: &PyProjectToml, + ) -> BTreeSet { + sources + .iter() + .filter(|(name, _)| { + pyproject_toml + .project + .as_ref() + .is_none_or(|project| project.name != **name) + }) + .chain( + packages + .iter() + .filter_map(|(name, member)| { + member + .pyproject_toml + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.sources.as_ref()) + .map(ToolUvSources::inner) + .map(move |sources| { + sources + .iter() + .filter(move |(source_name, _)| name != *source_name) + }) + }) + .flatten(), + ) + .filter_map(|(package, sources)| { + sources + .iter() + .any(|source| matches!(source, Source::Workspace { .. })) + .then_some(package.clone()) + }) + .collect() + } + + /// Whether a given workspace member is required by another member. + pub fn is_required_member(&self, name: &PackageName) -> bool { + self.required_members().contains(name) + } + /// Returns the set of all workspace member dependency groups. pub fn group_requirements(&self) -> impl Iterator + '_ { - self.packages.values().filter_map(|member| { + self.packages.iter().filter_map(|(name, member)| { let url = VerbatimUrl::from_absolute_path(&member.root) .expect("path is valid URL") .with_given(member.root.to_string_lossy()); @@ -368,7 +446,10 @@ impl Workspace { extras: Box::new([]), groups: groups.into_boxed_slice(), marker: MarkerTree::TRUE, - source: if member.pyproject_toml.is_package() { + source: if member + .pyproject_toml() + .is_package(!self.is_required_member(name)) + { RequirementSource::Directory { install_path: member.root.clone().into_boxed_path(), editable: Some(true), @@ -746,9 +827,16 @@ impl Workspace { .and_then(|uv| uv.index) .unwrap_or_default(); + let required_members = Self::collect_required_members( + &workspace_members, + &workspace_sources, + &workspace_pyproject_toml, + ); + Ok(Workspace { install_path: workspace_root, packages: workspace_members, + required_members, sources: workspace_sources, indexes: workspace_indexes, pyproject_toml: workspace_pyproject_toml, @@ -1232,15 +1320,23 @@ impl ProjectWorkspace { project.name.clone(), current_project, )])); + let workspace_sources = BTreeMap::default(); + let required_members = Workspace::collect_required_members( + ¤t_project_as_members, + &workspace_sources, + project_pyproject_toml, + ); + return Ok(Self { project_root: project_path.clone(), project_name: project.name.clone(), workspace: Workspace { install_path: project_path.clone(), packages: current_project_as_members, + required_members, // There may be package sources, but we don't need to duplicate them into the // workspace sources. - sources: BTreeMap::default(), + sources: workspace_sources, indexes: Vec::default(), pyproject_toml: project_pyproject_toml.clone(), }, @@ -1692,6 +1788,7 @@ mod tests { "pyproject_toml": "[PYPROJECT_TOML]" } }, + "required_members": [], "sources": {}, "indexes": [], "pyproject_toml": { @@ -1745,6 +1842,7 @@ mod tests { "pyproject_toml": "[PYPROJECT_TOML]" } }, + "required_members": [], "sources": {}, "indexes": [], "pyproject_toml": { @@ -1825,6 +1923,10 @@ mod tests { "pyproject_toml": "[PYPROJECT_TOML]" } }, + "required_members": [ + "bird-feeder", + "seeds" + ], "sources": { "bird-feeder": [ { @@ -1946,6 +2048,10 @@ mod tests { "pyproject_toml": "[PYPROJECT_TOML]" } }, + "required_members": [ + "bird-feeder", + "seeds" + ], "sources": {}, "indexes": [], "pyproject_toml": { @@ -2013,6 +2119,7 @@ mod tests { "pyproject_toml": "[PYPROJECT_TOML]" } }, + "required_members": [], "sources": {}, "indexes": [], "pyproject_toml": { @@ -2147,6 +2254,7 @@ mod tests { "pyproject_toml": "[PYPROJECT_TOML]" } }, + "required_members": [], "sources": {}, "indexes": [], "pyproject_toml": { @@ -2254,6 +2362,7 @@ mod tests { "pyproject_toml": "[PYPROJECT_TOML]" } }, + "required_members": [], "sources": {}, "indexes": [], "pyproject_toml": { @@ -2375,6 +2484,7 @@ mod tests { "pyproject_toml": "[PYPROJECT_TOML]" } }, + "required_members": [], "sources": {}, "indexes": [], "pyproject_toml": { @@ -2470,6 +2580,7 @@ mod tests { "pyproject_toml": "[PYPROJECT_TOML]" } }, + "required_members": [], "sources": {}, "indexes": [], "pyproject_toml": { diff --git a/crates/uv/src/commands/build_frontend.rs b/crates/uv/src/commands/build_frontend.rs index fd6ed73d7..a830f7aef 100644 --- a/crates/uv/src/commands/build_frontend.rs +++ b/crates/uv/src/commands/build_frontend.rs @@ -263,7 +263,7 @@ async fn build_impl( .get(package) .ok_or_else(|| anyhow::anyhow!("Package `{package}` not found in workspace"))?; - if !package.pyproject_toml().is_package() { + if !package.pyproject_toml().is_package(true) { let name = &package.project().name; let pyproject_toml = package.root().join("pyproject.toml"); return Err(anyhow::anyhow!( @@ -300,7 +300,7 @@ async fn build_impl( let packages: Vec<_> = workspace .packages() .values() - .filter(|package| package.pyproject_toml().is_package()) + .filter(|package| package.pyproject_toml().is_package(true)) .map(|package| AnnotatedSource { source: Source::Directory(Cow::Borrowed(package.root())), package: Some(package.project().name.clone()), diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 833e59a13..e23bd97c2 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -444,6 +444,7 @@ async fn do_lock( // Collect the requirements, etc. let members = target.members(); let packages = target.packages(); + let required_members = target.required_members(); let requirements = target.requirements(); let overrides = target.overrides(); let constraints = target.constraints(); @@ -693,6 +694,7 @@ async fn do_lock( target.install_path(), packages, &members, + required_members, &requirements, &dependency_groups, &constraints, @@ -906,6 +908,7 @@ impl ValidatedLock { install_path: &Path, packages: &BTreeMap, members: &[PackageName], + required_members: &BTreeSet, requirements: &[Requirement], dependency_groups: &BTreeMap>, constraints: &[Requirement], @@ -1117,6 +1120,7 @@ impl ValidatedLock { install_path, packages, members, + required_members, requirements, constraints, overrides, diff --git a/crates/uv/src/commands/project/lock_target.rs b/crates/uv/src/commands/project/lock_target.rs index 4618b3b84..55a726bf4 100644 --- a/crates/uv/src/commands/project/lock_target.rs +++ b/crates/uv/src/commands/project/lock_target.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use itertools::Either; @@ -154,6 +154,18 @@ impl<'lock> LockTarget<'lock> { } } + /// Return the set of required workspace members, i.e., those that are required by other + /// members. + pub(crate) fn required_members(self) -> &'lock BTreeSet { + match self { + Self::Workspace(workspace) => workspace.required_members(), + Self::Script(_) => { + static EMPTY: BTreeSet = BTreeSet::new(); + &EMPTY + } + } + } + /// Returns the set of supported environments for the [`LockTarget`]. pub(crate) fn environments(self) -> Option<&'lock SupportedEnvironments> { match self { diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 40aa1b352..8d2dd9629 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -117,7 +117,7 @@ pub(crate) async fn sync( // TODO(lucab): improve warning content // if project.workspace().pyproject_toml().has_scripts() - && !project.workspace().pyproject_toml().is_package() + && !project.workspace().pyproject_toml().is_package(true) { warn_user!( "Skipping installation of entry points (`project.scripts`) because this project is not packaged; to install entry points, set `tool.uv.package = true` or define a `build-system`" diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index 70b8d6e50..aa494435c 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -10362,7 +10362,7 @@ fn add_self() -> Result<()> { filters => context.filters(), }, { assert_snapshot!( - pyproject_toml, @r###" + pyproject_toml, @r#" [project] name = "anyio" version = "0.1.0" @@ -10377,7 +10377,7 @@ fn add_self() -> Result<()> { [tool.uv.sources] anyio = { workspace = true } - "### + "# ); }); @@ -10398,7 +10398,7 @@ fn add_self() -> Result<()> { filters => context.filters(), }, { assert_snapshot!( - pyproject_toml, @r###" + pyproject_toml, @r#" [project] name = "anyio" version = "0.1.0" @@ -10418,7 +10418,7 @@ fn add_self() -> Result<()> { dev = [ "anyio[types]", ] - "### + "# ); }); @@ -13173,7 +13173,9 @@ fn add_path_with_existing_workspace() -> Result<()> { ----- stderr ----- Added `dep` to workspace members Resolved 3 packages in [TIME] - Audited in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + dep==0.1.0 (from file://[TEMP_DIR]/dep) "); let pyproject_toml = context.read("pyproject.toml"); @@ -13250,7 +13252,9 @@ fn add_path_with_workspace() -> Result<()> { ----- stderr ----- Added `dep` to workspace members Resolved 2 packages in [TIME] - Audited in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + dep==0.1.0 (from file://[TEMP_DIR]/dep) "); let pyproject_toml = context.read("pyproject.toml"); @@ -13316,7 +13320,9 @@ fn add_path_within_workspace_defaults_to_workspace() -> Result<()> { ----- stderr ----- Added `dep` to workspace members Resolved 2 packages in [TIME] - Audited in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + dep==0.1.0 (from file://[TEMP_DIR]/dep) "); let pyproject_toml = context.read("pyproject.toml"); diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 75d81b4c0..ff9b711b7 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -12064,10 +12064,6 @@ fn lock_remove_member() -> Result<()> { requires-python = ">=3.12" dependencies = ["leaf"] - [build-system] - requires = ["setuptools>=42"] - build-backend = "setuptools.build_meta" - [tool.uv.workspace] members = ["leaf"] @@ -12130,7 +12126,7 @@ fn lock_remove_member() -> Result<()> { [[package]] name = "leaf" version = "0.1.0" - source = { virtual = "leaf" } + source = { editable = "leaf" } dependencies = [ { name = "anyio" }, ] @@ -12141,13 +12137,13 @@ fn lock_remove_member() -> Result<()> { [[package]] name = "project" version = "0.1.0" - source = { editable = "." } + source = { virtual = "." } dependencies = [ { name = "leaf" }, ] [package.metadata] - requires-dist = [{ name = "leaf", virtual = "leaf" }] + requires-dist = [{ name = "leaf", editable = "leaf" }] [[package]] name = "sniffio" @@ -12162,16 +12158,124 @@ fn lock_remove_member() -> Result<()> { }); // Re-run with `--locked`. - uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###" + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Resolved 5 packages in [TIME] - "###); + "); - // Remove the member. + // Remove the member as a dependency (retain it as a workspace member) + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [tool.uv.workspace] + members = ["leaf"] + + [tool.uv.sources] + leaf = { workspace = true } + "#, + )?; + + // Re-run with `--locked`. This should fail. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`. + "); + + // Re-run without `--locked`. + uv_snapshot!(context.filters(), context.lock(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 2 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [manifest] + members = [ + "leaf", + "project", + ] + + [[package]] + name = "anyio" + version = "4.3.0" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6", size = 159642, upload-time = "2024-02-19T08:36:28.641Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8", size = 85584, upload-time = "2024-02-19T08:36:26.842Z" }, + ] + + [[package]] + name = "idna" + version = "3.6" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426, upload-time = "2023-11-25T15:40:54.902Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567, upload-time = "2023-11-25T15:40:52.604Z" }, + ] + + [[package]] + name = "leaf" + version = "0.1.0" + source = { editable = "leaf" } + dependencies = [ + { name = "anyio" }, + ] + + [package.metadata] + requires-dist = [{ name = "anyio", specifier = ">3" }] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + + [[package]] + name = "sniffio" + version = "1.3.1" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, + ] + "# + ); + }); + + // Remove the member entirely pyproject_toml.write_str( r#" [project] @@ -12238,7 +12342,7 @@ fn lock_remove_member() -> Result<()> { /// This test would fail if we didn't write the list of workspace members to the lockfile, since /// we wouldn't be able to determine that a new member was added. #[test] -fn lock_add_member() -> Result<()> { +fn lock_add_member_with_build_system() -> Result<()> { let context = TestContext::new("3.12"); // Create a workspace, but don't add the member. @@ -12449,6 +12553,339 @@ fn lock_add_member() -> Result<()> { Ok(()) } +#[test] +fn lock_add_member_without_build_system() -> Result<()> { + let context = TestContext::new("3.12"); + + // Create a workspace, but don't add the member. + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [tool.uv.workspace] + members = [] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + "###); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 2 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + "# + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + "###); + + // Create a workspace member. + let leaf = context.temp_dir.child("leaf"); + leaf.child("pyproject.toml").write_str( + r#" + [project] + name = "leaf" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio>3"] + "#, + )?; + + // Add the member to the workspace, but not as a dependency of the root. + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [tool.uv.workspace] + members = ["leaf"] + "#, + )?; + + // Re-run with `--locked`. This should fail. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`. + "); + + // Re-run with `--offline`. This should also fail, during the resolve phase. + uv_snapshot!(context.filters(), context.lock().arg("--locked").arg("--offline").arg("--no-cache"), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving dependencies: + ╰─▶ Because anyio was not found in the cache and leaf depends on anyio>3, we can conclude that leaf's requirements are unsatisfiable. + And because your workspace requires leaf, we can conclude that your workspace's requirements are unsatisfiable. + + hint: Packages were unavailable because the network was disabled. When the network is disabled, registry packages may only be read from the cache. + "###); + + // Re-run without `--locked`. + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + Added anyio v4.3.0 + Added idna v3.6 + Added leaf v0.1.0 + Added sniffio v1.3.1 + "###); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + "###); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 2 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [manifest] + members = [ + "leaf", + "project", + ] + + [[package]] + name = "anyio" + version = "4.3.0" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6", size = 159642, upload-time = "2024-02-19T08:36:28.641Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8", size = 85584, upload-time = "2024-02-19T08:36:26.842Z" }, + ] + + [[package]] + name = "idna" + version = "3.6" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426, upload-time = "2023-11-25T15:40:54.902Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567, upload-time = "2023-11-25T15:40:52.604Z" }, + ] + + [[package]] + name = "leaf" + version = "0.1.0" + source = { virtual = "leaf" } + dependencies = [ + { name = "anyio" }, + ] + + [package.metadata] + requires-dist = [{ name = "anyio", specifier = ">3" }] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + + [[package]] + name = "sniffio" + version = "1.3.1" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, + ] + "# + ); + }); + + // Add the member to the workspace, as a dependency of the root. + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["leaf"] + + [tool.uv.workspace] + members = ["leaf"] + + [tool.uv.sources] + leaf = { workspace = true } + "#, + )?; + + // Re-run with `--locked`. This should fail. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`. + "); + + // Re-run without `--locked`. + uv_snapshot!(context.filters(), context.lock(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + "); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + // It should change from a virtual to an editable source + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 2 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [manifest] + members = [ + "leaf", + "project", + ] + + [[package]] + name = "anyio" + version = "4.3.0" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6", size = 159642, upload-time = "2024-02-19T08:36:28.641Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8", size = 85584, upload-time = "2024-02-19T08:36:26.842Z" }, + ] + + [[package]] + name = "idna" + version = "3.6" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426, upload-time = "2023-11-25T15:40:54.902Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567, upload-time = "2023-11-25T15:40:52.604Z" }, + ] + + [[package]] + name = "leaf" + version = "0.1.0" + source = { editable = "leaf" } + dependencies = [ + { name = "anyio" }, + ] + + [package.metadata] + requires-dist = [{ name = "anyio", specifier = ">3" }] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "leaf" }, + ] + + [package.metadata] + requires-dist = [{ name = "leaf", editable = "leaf" }] + + [[package]] + name = "sniffio" + version = "1.3.1" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, + ] + "# + ); + }); + + Ok(()) +} + /// Lock a `pyproject.toml`, then add a dependency that's already included in the resolution. /// In theory, we shouldn't need to re-resolve, but based on our current strategy, we don't accept /// the existing lockfile. diff --git a/crates/uv/tests/it/lock_conflict.rs b/crates/uv/tests/it/lock_conflict.rs index bf1bc1eac..d67736c88 100644 --- a/crates/uv/tests/it/lock_conflict.rs +++ b/crates/uv/tests/it/lock_conflict.rs @@ -1094,18 +1094,19 @@ fn extra_unconditional() -> Result<()> { "###); // This is fine because we are only enabling one // extra, and thus, there is no conflict. - uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###" + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- - Prepared 3 packages in [TIME] - Installed 3 packages in [TIME] + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + anyio==4.1.0 + idna==3.6 + + proxy1==0.1.0 (from file://[TEMP_DIR]/proxy1) + sniffio==1.3.1 - "###); + "); // And same thing for the other extra. root_pyproject_toml.write_str( @@ -1215,18 +1216,19 @@ fn extra_unconditional_non_conflicting() -> Result<()> { // `uv sync` wasn't correctly propagating extras in a way // that would satisfy the conflict markers that got added // to the `proxy1[extra1]` dependency. - uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###" + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- - Prepared 3 packages in [TIME] - Installed 3 packages in [TIME] + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + anyio==4.1.0 + idna==3.6 + + proxy1==0.1.0 (from file://[TEMP_DIR]/proxy1) + sniffio==1.3.1 - "###); + "); Ok(()) } @@ -1301,16 +1303,17 @@ fn extra_unconditional_in_optional() -> Result<()> { "###); // This should install `sortedcontainers==2.3.0`. - uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra=x1"), @r###" + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra=x1"), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- - Prepared 1 package in [TIME] - Installed 1 package in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + proxy1==0.1.0 (from file://[TEMP_DIR]/proxy1) + sortedcontainers==2.3.0 - "###); + "); // This should install `sortedcontainers==2.4.0`. uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra=x2"), @r###" @@ -4460,19 +4463,20 @@ conflicts = [ error: Extra `x2` is not defined in the project's `optional-dependencies` table "###); - uv_snapshot!(context.filters(), context.sync(), @r###" + uv_snapshot!(context.filters(), context.sync(), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Resolved 7 packages in [TIME] - Prepared 3 packages in [TIME] - Installed 3 packages in [TIME] + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + anyio==4.3.0 + idna==3.6 + + proxy1==0.1.0 (from file://[TEMP_DIR]/proxy1) + sniffio==1.3.1 - "###); + "); let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); insta::with_settings!({ @@ -4558,14 +4562,14 @@ conflicts = [ requires-dist = [ { name = "anyio", specifier = ">=4" }, { name = "idna", marker = "extra == 'x1'", specifier = "==3.6" }, - { name = "proxy1", virtual = "proxy1" }, + { name = "proxy1", editable = "proxy1" }, ] provides-extras = ["x1"] [[package]] name = "proxy1" version = "0.1.0" - source = { virtual = "proxy1" } + source = { editable = "proxy1" } [package.optional-dependencies] x2 = [ diff --git a/crates/uv/tests/it/pip_compile.rs b/crates/uv/tests/it/pip_compile.rs index ac3549874..69da12fd6 100644 --- a/crates/uv/tests/it/pip_compile.rs +++ b/crates/uv/tests/it/pip_compile.rs @@ -15772,18 +15772,18 @@ fn project_and_group_workspace_inherit() -> Result<()> { ----- stdout ----- # This file was autogenerated by uv via the following command: # uv pip compile --cache-dir [CACHE_DIR] --group packages/mysubproject/pyproject.toml:foo + -e file://[TEMP_DIR]/packages/pytest + # via mysubproject (packages/mysubproject/pyproject.toml:foo) + -e file://[TEMP_DIR]/packages/sniffio + # via + # mysubproject (packages/mysubproject/pyproject.toml:foo) + # anyio anyio==4.3.0 # via mysubproject (packages/mysubproject/pyproject.toml:foo) idna==3.6 # via anyio iniconfig==2.0.0 # via mysubproject (packages/mysubproject/pyproject.toml:foo) - pytest @ file://[TEMP_DIR]/packages/pytest - # via mysubproject (packages/mysubproject/pyproject.toml:foo) - sniffio @ file://[TEMP_DIR]/packages/sniffio - # via - # mysubproject (packages/mysubproject/pyproject.toml:foo) - # anyio ----- stderr ----- Resolved 5 packages in [TIME] diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index bb3546e22..5a8d79447 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -3565,6 +3565,101 @@ fn sync_ignore_extras_check_when_no_provides_extras() -> Result<()> { Ok(()) } +#[test] +fn sync_workspace_members_with_transitive_dependencies() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [tool.uv.workspace] + members = [ + "packages/*", + ] + "#, + )?; + + let packages = context.temp_dir.child("packages"); + packages.create_dir_all()?; + + // Create three workspace members with transitive dependency from + // pkg-c -> pkg-b -> pkg-a + let pkg_a = packages.child("pkg-a"); + pkg_a.create_dir_all()?; + let pkg_a_pyproject_toml = pkg_a.child("pyproject.toml"); + pkg_a_pyproject_toml.write_str( + r#" + [project] + name = "pkg-a" + version = "0.0.1" + requires-python = ">=3.12" + dependencies = ["anyio"] + "#, + )?; + + let pkg_b = packages.child("pkg-b"); + pkg_b.create_dir_all()?; + let pkg_b_pyproject_toml = pkg_b.child("pyproject.toml"); + pkg_b_pyproject_toml.write_str( + r#" + [project] + name = "pkg-b" + version = "0.0.1" + requires-python = ">=3.12" + dependencies = ["pkg-a"] + + [tool.uv.sources] + pkg-a = { workspace = true } + "#, + )?; + + let pkg_c = packages.child("pkg-c"); + pkg_c.create_dir_all()?; + let pkg_c_pyproject_toml = pkg_c.child("pyproject.toml"); + pkg_c_pyproject_toml.write_str( + r#" + [project] + name = "pkg-c" + version = "0.0.1" + requires-python = ">=3.12" + dependencies = ["pkg-b"] + + [tool.uv.sources] + pkg-b = { workspace = true } + "#, + )?; + + // Syncing should build the two transitive dependencies pkg-a and pkg-b, + // but not pkg-c, which is not a dependency. + uv_snapshot!(context.filters(), context.sync(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 6 packages in [TIME] + Prepared 5 packages in [TIME] + Installed 5 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + pkg-a==0.0.1 (from file://[TEMP_DIR]/packages/pkg-a) + + pkg-b==0.0.1 (from file://[TEMP_DIR]/packages/pkg-b) + + sniffio==1.3.1 + "); + + // The lockfile should be valid. + uv_snapshot!(context.filters(), context.lock().arg("--check"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 6 packages in [TIME] + "); + + Ok(()) +} + #[test] fn sync_non_existent_extra_workspace_member() -> Result<()> { let context = TestContext::new("3.12"); @@ -3626,9 +3721,10 @@ fn sync_non_existent_extra_workspace_member() -> Result<()> { ----- stderr ----- Resolved 5 packages in [TIME] - Prepared 3 packages in [TIME] - Installed 3 packages in [TIME] + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + anyio==4.3.0 + + child==0.1.0 (from file://[TEMP_DIR]/child) + idna==3.6 + sniffio==1.3.1 "); diff --git a/docs/concepts/projects/dependencies.md b/docs/concepts/projects/dependencies.md index bf11e7174..52a71fd04 100644 --- a/docs/concepts/projects/dependencies.md +++ b/docs/concepts/projects/dependencies.md @@ -808,9 +808,9 @@ $ uv add --no-editable ./path/foo uv allows dependencies to be "virtual", in which the dependency itself is not installed as a [package](./config.md#project-packaging), but its dependencies are. -By default, only workspace members without build systems declared are virtual. +By default, dependencies are never virtual. -A dependency with a [`path` source](#path) is not virtual unless it explicitly sets +A dependency with a [`path` source](#path) can be virtual if it explicitly sets [`tool.uv.package = false`](../../reference/settings.md#package). Unlike working _in_ the dependent project with uv, the package will be built even if a [build system](./config.md#build-systems) is not declared. @@ -825,8 +825,8 @@ dependencies = ["bar"] bar = { path = "../projects/bar", package = false } ``` -Similarly, if a dependency sets `tool.uv.package = false`, it can be overridden by declaring -`package = true` on the source: +If a dependency sets `tool.uv.package = false`, it can be overridden by declaring `package = true` +on the source: ```toml title="pyproject.toml" [project] @@ -836,6 +836,52 @@ dependencies = ["bar"] bar = { path = "../projects/bar", package = true } ``` +Similarly, a dependency with a [`workspace` source](#workspace-member) can be virtual if it +explicitly sets [`tool.uv.package = false`](../../reference/settings.md#package). The workspace +member will be built even if a [build system](./config.md#build-systems) is not declared. + +Workspace members that are _not_ dependencies can be virtual by default, e.g., if the parent +`pyproject.toml` is: + +```toml title="pyproject.toml" +[project] +name = "parent" +version = "1.0.0" +dependencies = [] + +[tool.uv.workspace] +members = ["child"] +``` + +And the child `pyproject.toml` excluded a build system: + +```toml title="pyproject.toml" +[project] +name = "child" +version = "1.0.0" +dependencies = ["anyio"] +``` + +Then the `child` workspace member would not be installed, but the transitive dependency `anyio` +would be. + +In contrast, if the parent declared a dependency on `child`: + +```toml title="pyproject.toml" +[project] +name = "parent" +version = "1.0.0" +dependencies = ["child"] + +[tool.uv.sources] +child = { workspace = true } + +[tool.uv.workspace] +members = ["child"] +``` + +Then `child` would be built and installed. + ## Dependency specifiers uv uses standard