diff --git a/crates/uv/src/commands/project/install_target.rs b/crates/uv/src/commands/project/install_target.rs index b9432e1ed5b8..95980ea31880 100644 --- a/crates/uv/src/commands/project/install_target.rs +++ b/crates/uv/src/commands/project/install_target.rs @@ -2,7 +2,8 @@ use std::borrow::Cow; use std::path::Path; use std::str::FromStr; -use itertools::{Either, Itertools}; +use itertools::Either; +use rustc_hash::FxHashSet; use uv_configuration::ExtrasSpecification; use uv_distribution_types::Index; @@ -258,10 +259,11 @@ impl<'lock> InstallTarget<'lock> { Self::Project { lock, .. } | Self::Workspace { lock, .. } | Self::NonProjectWorkspace { lock, .. } => { + let roots = self.roots().collect::>(); let member_packages: Vec<&Package> = lock .packages() .iter() - .filter(|package| self.roots().contains(package.name())) + .filter(|package| roots.contains(package.name())) .collect(); // If `provides-extra` is not set in any package, do not perform the check, as this @@ -274,12 +276,14 @@ impl<'lock> InstallTarget<'lock> { return Ok(()); } + // Collect all known extras from the member packages. + let known_extras = member_packages + .iter() + .flat_map(|package| package.provides_extras().into_iter().flatten()) + .collect::>(); + for extra in extras { - if !member_packages.iter().any(|package| { - package - .provides_extras() - .is_some_and(|provides_extras| provides_extras.contains(extra)) - }) { + if !known_extras.contains(extra) { return match self { Self::Project { .. } => { Err(ProjectError::MissingExtraProject(extra.clone())) diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index de98e2957c91..de933e0ad02b 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -2771,6 +2771,161 @@ fn sync_ignore_extras_check_when_no_provides_extras() -> Result<()> { Ok(()) } +#[test] +fn sync_non_existent_extra_workspace_member() -> Result<()> { + let context = TestContext::new("3.12"); + + 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 = ["child"] + + [project.optional-dependencies] + types = ["sniffio>1"] + + [tool.uv.workspace] + members = ["child"] + + [tool.uv.sources] + child = { workspace = true } + "#, + )?; + + context + .temp_dir + .child("child") + .child("pyproject.toml") + .write_str( + r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.12" + + [project.optional-dependencies] + async = ["anyio>3"] + "#, + )?; + + context.lock().assert().success(); + + // Requesting an extra that only exists in the child should fail. + uv_snapshot!(context.filters(), context.sync().arg("--extra").arg("async"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + error: Extra `async` is not defined in the project's `optional-dependencies` table + "###); + + // Unless we sync from the child directory. + uv_snapshot!(context.filters(), context.sync().arg("--package").arg("child").arg("--extra").arg("async"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + sniffio==1.3.1 + "###); + + Ok(()) +} + +#[test] +fn sync_non_existent_extra_non_project_workspace() -> 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 = ["child", "other"] + "#, + )?; + + context + .temp_dir + .child("child") + .child("pyproject.toml") + .write_str( + r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.12" + + [project.optional-dependencies] + async = ["anyio>3"] + "#, + )?; + + context + .temp_dir + .child("other") + .child("pyproject.toml") + .write_str( + r#" + [project] + name = "other" + version = "0.1.0" + requires-python = ">=3.12" + "#, + )?; + + context.lock().assert().success(); + + // Requesting an extra that only exists in the child should succeed, since we sync all members + // by default. + uv_snapshot!(context.filters(), context.sync().arg("--extra").arg("async"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + sniffio==1.3.1 + "###); + + // Syncing from the child should also succeed. + uv_snapshot!(context.filters(), context.sync().arg("--package").arg("child").arg("--extra").arg("async"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + Audited 3 packages in [TIME] + "###); + + // Syncing from an unrelated child should fail. + uv_snapshot!(context.filters(), context.sync().arg("--package").arg("other").arg("--extra").arg("async"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + error: Extra `async` is not defined in the project's `optional-dependencies` table + "###); + + Ok(()) +} + /// Regression test for . /// /// Previously, we would read metadata statically from pyproject.toml and write that to `uv.lock`. In