diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 18c7fb921cfb..8c3d6021c1b5 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -15,7 +15,8 @@ use uv_static::EnvVars; use uv_warnings::{warn_user, warn_user_once}; use crate::pyproject::{ - Project, PyProjectToml, PyprojectTomlError, Source, Sources, ToolUvSources, ToolUvWorkspace, + Project, PyProjectToml, PyprojectTomlError, Source, Sources, ToolUv, ToolUvSources, + ToolUvWorkspace, }; #[derive(thiserror::Error, Debug)] @@ -570,6 +571,10 @@ impl Workspace { pyproject_toml: workspace_pyproject_toml, }) } + + fn shared_lock_members(&self) -> impl Iterator + '_ { + self.packages().values().filter(|p| !p.private_lock) + } } /// A project in a workspace. @@ -584,7 +589,6 @@ pub struct WorkspaceMember { /// The `pyproject.toml` of the project, found at `/pyproject.toml`. pyproject_toml: PyProjectToml, /// does this member require a private lock - #[allow(dead_code)] private_lock: bool, } @@ -1281,10 +1285,21 @@ impl VirtualProject { } pub fn packages_to_resolve(&self) -> impl Iterator + '_ { - self.workspace().packages().keys() + if let Some(project) = self.private_project() { + Either::Left(std::iter::once(&project.project.name)) + } else { + Either::Right( + self.workspace() + .shared_lock_members() + .map(|p| &p.project.name), + ) + } } pub fn packages_to_lock(&self) -> Vec { + if self.private_project().is_some() { + return vec![]; + } let mut members = self.packages_to_resolve().cloned().collect::>(); members.sort(); @@ -1300,7 +1315,12 @@ impl VirtualProject { /// Returns the set of requirements that include all packages in the workspace. pub fn members_requirements(&self) -> impl Iterator + '_ { - self.workspace().packages.values().filter_map(|member| { + let packages = if let Some(member) = self.private_project() { + Either::Left(std::iter::once(member)) + } else { + Either::Right(self.workspace().shared_lock_members()) + }; + packages.filter_map(|member| { let url = VerbatimUrl::from_absolute_path(&member.root) .expect("path is valid URL") .with_given(member.root.to_string_lossy()); @@ -1330,27 +1350,7 @@ impl VirtualProject { /// Returns the set of overrides for the workspace. pub fn overrides(&self) -> Vec { - let Some(overrides) = self - .workspace() - .pyproject_toml - .tool - .as_ref() - .and_then(|tool| tool.uv.as_ref()) - .and_then(|uv| uv.override_dependencies.as_ref()) - else { - return vec![]; - }; - - overrides - .iter() - .map(|requirement| { - Requirement::from( - requirement - .clone() - .with_origin(RequirementOrigin::Workspace), - ) - }) - .collect() + self.uv_dependencies(|uv| uv.override_dependencies.as_ref()) } /// Returns the set of supported environments for the workspace. @@ -1365,27 +1365,7 @@ impl VirtualProject { /// Returns the set of constraints for the workspace. pub fn constraints(&self) -> Vec { - let Some(constraints) = self - .workspace() - .pyproject_toml - .tool - .as_ref() - .and_then(|tool| tool.uv.as_ref()) - .and_then(|uv| uv.constraint_dependencies.as_ref()) - else { - return vec![]; - }; - - constraints - .iter() - .map(|requirement| { - Requirement::from( - requirement - .clone() - .with_origin(RequirementOrigin::Workspace), - ) - }) - .collect() + self.uv_dependencies(|uv| uv.constraint_dependencies.as_ref()) } /// The path to the workspace virtual environment. @@ -1456,8 +1436,14 @@ impl VirtualProject { } // Determine the default value - let project_env = from_project_environment_variable(self.workspace()) - .unwrap_or_else(|| self.workspace().install_path.join(".venv")); + let project_env = + from_project_environment_variable(self.workspace()).unwrap_or_else(|| { + if let Some(private_project) = self.private_project() { + private_project.root.join(".venv") + } else { + self.workspace().install_path().join(".venv") + } + }); // Warn if it conflicts with `VIRTUAL_ENV` if let Some(from_virtual_env) = from_virtual_env_variable() { @@ -1475,7 +1461,67 @@ impl VirtualProject { /// The path to the workspace lockfile pub fn lockfile(&self) -> PathBuf { - self.workspace().install_path().join("uv.lock") + if let Some(private_project) = self.private_project() { + private_project.root.join("uv.lock") + } else { + self.workspace().install_path().join("uv.lock") + } + } + + fn private_project(&self) -> Option<&WorkspaceMember> { + if let VirtualProject::Project(project) = self { + if let Some(project) = &self.workspace().packages.get(project.project_name()) { + if project.private_lock { + return Some(project); + } + } + } + None + } + + /// Gets dependencies defined in `[tool.uv]` + /// Tries private project first, then defaults to workspace. + fn uv_dependencies( + &self, + func: fn(&ToolUv) -> Option<&Vec>>, + ) -> Vec { + let dependencies_and_origin = if let Some(private_project) = self.private_project() { + private_project + .pyproject_toml + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(func) + .map(|dependencies| { + ( + dependencies, + RequirementOrigin::Project( + private_project.root.clone(), + private_project.project.name.clone(), + ), + ) + }) + } else { + None + } + .or(self + .workspace() + .pyproject_toml + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(func) + .map(|dependencies| (dependencies, RequirementOrigin::Workspace))); + + if let Some((dependencies, origin)) = dependencies_and_origin { + return dependencies + .iter() + .map(|requirement| { + Requirement::from(requirement.clone().with_origin(origin.clone())) + }) + .collect(); + } + vec![] } /// Determine a name for the environment, in order of preference: @@ -1484,14 +1530,20 @@ impl VirtualProject { /// 2) The name of the directory at the root of the workspace /// 3) No prompt pub fn venv_name(&self) -> Option { - self.workspace() - .pyproject_toml() + let (pyproject_toml, project_path) = if let Some(private_project) = self.private_project() { + (&private_project.pyproject_toml, private_project.root()) + } else { + ( + self.workspace().pyproject_toml(), + self.workspace().install_path(), + ) + }; + pyproject_toml .project .as_ref() .map(|p| p.name.to_string()) .or_else(|| { - self.workspace() - .install_path() + project_path .file_name() .map(|f| f.to_string_lossy().to_string()) }) @@ -1528,7 +1580,9 @@ impl<'env> InstallTarget<'env> { pub fn packages(&self) -> impl Iterator { match self { Self::Project(project) => Either::Left(std::iter::once(project.project_name())), - Self::NonProject(workspace) => Either::Right(workspace.packages().keys()), + Self::NonProject(workspace) => { + Either::Right(workspace.shared_lock_members().map(|p| &p.project.name)) + } Self::FrozenMember(_, package_name) => Either::Left(std::iter::once(*package_name)), } } diff --git a/crates/uv/tests/it/workspace.rs b/crates/uv/tests/it/workspace.rs index f66c856ece06..fb921f328b78 100644 --- a/crates/uv/tests/it/workspace.rs +++ b/crates/uv/tests/it/workspace.rs @@ -1721,3 +1721,67 @@ fn test_path_hopping() -> Result<()> { Ok(()) } + +#[test] +fn test_shared_libraries() { + let context = TestContext::new("3.12").with_filtered_counts(); + let work_dir = context.temp_dir.join("shared-lib-ws"); + copy_dir_ignore(workspaces_dir().join("shared-libs"), &work_dir) + .expect("Could not copy to temp dir"); + + uv_snapshot!(context.filters(), context.sync().env_remove("UV_EXCLUDE_NEWER") + .current_dir(&work_dir), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + shared-corelib==1.0.0 (from file://[TEMP_DIR]/shared-lib-ws/shared_corelib) + + shared-lib==1.0.0 (from file://[TEMP_DIR]/shared-lib-ws/shared_lib) + + six==1.16.0 + + tqdm==4.66.2 + "###); + + for (app_name, numpy_version) in [("app1", "2.0.0"), ("app2", "1.26.4")] { + let app_dir = work_dir.join(app_name); + + let mut filters = context.filters(); + + filters.push((app_name, "[app_name]")); + filters.push((numpy_version, "[numpy_version]")); + + insta::allow_duplicates! { + uv_snapshot!(filters, context.run().env_remove("UV_EXCLUDE_NEWER") + .arg(format!("{app_name}/app.py")) + .current_dir(&app_dir), @r###" + success: true + exit_code: 0 + ----- stdout ----- + shared-lib is loaded from [TEMP_DIR]/shared-lib-ws/shared_lib/shared_lib/__init__.py + six 1.16.0 + shared-corelib is loaded from [TEMP_DIR]/shared-lib-ws/shared_corelib/shared_corelib/__init__.py + tqdm 4.66.2 + numpy [numpy_version] + + ----- stderr ----- + warning: `VIRTUAL_ENV=[VENV]/` does not match the project environment path `.venv` and will be ignored + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + [app_name]==1.0.0 (from file://[TEMP_DIR]/shared-lib-ws/[app_name]) + + numpy==[numpy_version] + + shared-corelib==1.0.0 (from file://[TEMP_DIR]/shared-lib-ws/shared_corelib) + + shared-lib==1.0.0 (from file://[TEMP_DIR]/shared-lib-ws/shared_lib) + + six==1.16.0 + + tqdm==4.66.2 + "###); + } + } +} diff --git a/scripts/workspaces/shared-libs/README.md b/scripts/workspaces/shared-libs/README.md new file mode 100644 index 000000000000..4b9cdaf5595f --- /dev/null +++ b/scripts/workspaces/shared-libs/README.md @@ -0,0 +1,15 @@ +This is a simple workspace with 2 applications `app1` and `app2` and 2 libraries. + +The applications have conflicting dependencies: + +- `app1` depends on `numpy == 2.0.0` +- `app2` depends on `numpy == 1.26.4` + +Both `app1` and `app2` depend on another workspace member `shared_lib`, which in turn depends on +`shared_corelib`. + +The workspace will create 3 `uv.lock` and `.venv`: + +- in the root, containing `shared_lib` and `shared_corelib` +- in `app1`, with `app1`, `shared_lib`, `shared_corelib` +- in `app2`, with `app2`, `shared_lib`, `shared_corelib` diff --git a/scripts/workspaces/shared-libs/app1/app1/app.py b/scripts/workspaces/shared-libs/app1/app1/app.py new file mode 100644 index 000000000000..0dac9a1ad9aa --- /dev/null +++ b/scripts/workspaces/shared-libs/app1/app1/app.py @@ -0,0 +1,9 @@ +from shared_lib import hello_shared +import numpy as np + +def main(): + hello_shared() + print(f"numpy {np.__version__}") + +if __name__ == "__main__": + main() diff --git a/scripts/workspaces/shared-libs/app1/pyproject.toml b/scripts/workspaces/shared-libs/app1/pyproject.toml new file mode 100644 index 000000000000..069d583ce3a1 --- /dev/null +++ b/scripts/workspaces/shared-libs/app1/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "app1" +version = "1.0.0" +requires-python = ">=3.12" +dependencies=[ + "shared_lib", + "numpy == 2.0.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app1"] + +[tool.uv.sources] +shared_lib = { workspace = true } diff --git a/scripts/workspaces/shared-libs/app2/app2/app.py b/scripts/workspaces/shared-libs/app2/app2/app.py new file mode 100644 index 000000000000..0dac9a1ad9aa --- /dev/null +++ b/scripts/workspaces/shared-libs/app2/app2/app.py @@ -0,0 +1,9 @@ +from shared_lib import hello_shared +import numpy as np + +def main(): + hello_shared() + print(f"numpy {np.__version__}") + +if __name__ == "__main__": + main() diff --git a/scripts/workspaces/shared-libs/app2/pyproject.toml b/scripts/workspaces/shared-libs/app2/pyproject.toml new file mode 100644 index 000000000000..8df7303014fb --- /dev/null +++ b/scripts/workspaces/shared-libs/app2/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "app2" +version = "1.0.0" +requires-python = ">=3.12" +dependencies=[ + "shared_lib", + "numpy == 1.26.4", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app1"] + +[tool.uv.sources] +shared_lib = { workspace = true } diff --git a/scripts/workspaces/shared-libs/pyproject.toml b/scripts/workspaces/shared-libs/pyproject.toml new file mode 100644 index 000000000000..1c695fcb2cb1 --- /dev/null +++ b/scripts/workspaces/shared-libs/pyproject.toml @@ -0,0 +1,3 @@ +[tool.uv.workspace] +members = ["shared_*"] +private-members = ["app*"] diff --git a/scripts/workspaces/shared-libs/shared_corelib/pyproject.toml b/scripts/workspaces/shared-libs/shared_corelib/pyproject.toml new file mode 100644 index 000000000000..a88d2ce08f8e --- /dev/null +++ b/scripts/workspaces/shared-libs/shared_corelib/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "shared_corelib" +version = "1.0.0" +requires-python = ">=3.12" +dependencies = ["tqdm == 4.66.2"] + +[build-system] +requires = ["hatchling==1.24.2"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["shared_corelib"] + diff --git a/scripts/workspaces/shared-libs/shared_corelib/shared_corelib/__init__.py b/scripts/workspaces/shared-libs/shared_corelib/shared_corelib/__init__.py new file mode 100644 index 000000000000..4dc35aee3b81 --- /dev/null +++ b/scripts/workspaces/shared-libs/shared_corelib/shared_corelib/__init__.py @@ -0,0 +1,5 @@ +import tqdm + +def hello_shared_core(): + print(f"shared-corelib is loaded from {__file__}") + print(f"tqdm {tqdm.__version__}") diff --git a/scripts/workspaces/shared-libs/shared_lib/pyproject.toml b/scripts/workspaces/shared-libs/shared_lib/pyproject.toml new file mode 100644 index 000000000000..8cadd43233cd --- /dev/null +++ b/scripts/workspaces/shared-libs/shared_lib/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "shared_lib" +version = "1.0.0" +requires-python = ">=3.12" +dependencies=[ + "six == 1.16.0", + "shared_corelib", +] + +[build-system] +requires = ["hatchling==1.24.2"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["shared_lib"] + +[tool.uv.sources] +shared_corelib = { workspace = true } diff --git a/scripts/workspaces/shared-libs/shared_lib/shared_lib/__init__.py b/scripts/workspaces/shared-libs/shared_lib/shared_lib/__init__.py new file mode 100644 index 000000000000..946f3ba0f820 --- /dev/null +++ b/scripts/workspaces/shared-libs/shared_lib/shared_lib/__init__.py @@ -0,0 +1,7 @@ +import six +from shared_corelib import hello_shared_core + +def hello_shared(): + print(f"shared-lib is loaded from {__file__}") + print(f"six {six.__version__}") + hello_shared_core()