diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 483169d7edf75..9e081ada2a7c9 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -94,6 +94,16 @@ impl PyProjectToml { .is_some_and(|project| project.version.is_none()) } + /// Returns `true` if the key is set dynamically. + pub fn is_key_dynamic(&self, key: &str) -> bool { + self.project.as_ref().is_some_and(|project| { + project + .dynamic + .as_ref() + .is_some_and(|dynamic| dynamic.iter().any(|field| field == key)) + }) + } + /// Returns whether the project manifest contains any script table. pub fn has_scripts(&self) -> bool { if let Some(ref project) = self.project { @@ -221,6 +231,8 @@ pub struct Project { pub dependencies: Option>, /// The optional dependencies of the project. pub optional_dependencies: Option>>, + /// The dynamic attributes of the project. + pub dynamic: Option>, /// Used to determine whether a `gui-scripts` section is present. #[serde(default, skip_serializing)] @@ -266,6 +278,7 @@ impl TryFrom for Project { requires_python: value.requires_python, dependencies: value.dependencies, optional_dependencies: value.optional_dependencies, + dynamic: value.dynamic, gui_scripts: value.gui_scripts, scripts: value.scripts, }) diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index b974ab01832ba..90f2020a30492 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -8,7 +8,7 @@ use rustc_hash::FxHashSet; use tracing::{debug, trace, warn}; use uv_distribution_types::Index; use uv_fs::{Simplified, CWD}; -use uv_normalize::{GroupName, PackageName, DEV_DEPENDENCIES}; +use uv_normalize::{ExtraName, GroupName, PackageName, DEV_DEPENDENCIES}; use uv_pep440::VersionSpecifiers; use uv_pep508::{MarkerTree, VerbatimUrl}; use uv_pypi_types::{ @@ -526,6 +526,38 @@ impl Workspace { .collect() } + /// Returns the set of all non-dynamic extras defined in the workspace. + pub fn extras(&self) -> BTreeSet<&ExtraName> { + self.pyproject_toml + .project + .as_ref() + .and_then(|project| project.optional_dependencies.as_ref()) + .iter() + .flat_map(|extras| extras.keys()) + .chain( + self.packages + .values() + .filter_map(|member| { + member + .pyproject_toml + .project + .as_ref() + .map_or_else(|| None, |project| project.optional_dependencies.as_ref()) + }) + .flat_map(|extras| extras.keys()), + ) + .collect() + } + + /// Whether at least one project in the workspace sets the key dynamically. + pub fn uses_dynamic_key(&self, key: &str) -> bool { + self.pyproject_toml.is_key_dynamic(key) + || self + .packages + .values() + .any(|member| member.pyproject_toml.is_key_dynamic(key)) + } + /// The path to the workspace root, the directory containing the top level `pyproject.toml` with /// the `uv.tool.workspace`, or the `pyproject.toml` in an implicit single workspace project. pub fn install_path(&self) -> &PathBuf { @@ -1573,7 +1605,8 @@ mod tests { "dependencies": [ "anyio>=4.3.0,<5" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "pyproject_toml": "[PYPROJECT_TOML]" } @@ -1588,7 +1621,8 @@ mod tests { "dependencies": [ "anyio>=4.3.0,<5" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "tool": null, "dependency-groups": null @@ -1626,7 +1660,8 @@ mod tests { "dependencies": [ "anyio>=4.3.0,<5" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "pyproject_toml": "[PYPROJECT_TOML]" } @@ -1641,7 +1676,8 @@ mod tests { "dependencies": [ "anyio>=4.3.0,<5" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "tool": null, "dependency-groups": null @@ -1679,7 +1715,8 @@ mod tests { "bird-feeder", "tqdm>=4,<5" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "pyproject_toml": "[PYPROJECT_TOML]" }, @@ -1693,7 +1730,8 @@ mod tests { "anyio>=4.3.0,<5", "seeds" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "pyproject_toml": "[PYPROJECT_TOML]" }, @@ -1706,7 +1744,8 @@ mod tests { "dependencies": [ "idna==3.6" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "pyproject_toml": "[PYPROJECT_TOML]" } @@ -1730,7 +1769,8 @@ mod tests { "bird-feeder", "tqdm>=4,<5" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "tool": { "uv": { @@ -1796,7 +1836,8 @@ mod tests { "bird-feeder", "tqdm>=4,<5" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "pyproject_toml": "[PYPROJECT_TOML]" }, @@ -1810,7 +1851,8 @@ mod tests { "anyio>=4.3.0,<5", "seeds" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "pyproject_toml": "[PYPROJECT_TOML]" }, @@ -1823,7 +1865,8 @@ mod tests { "dependencies": [ "idna==3.6" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "pyproject_toml": "[PYPROJECT_TOML]" } @@ -1886,7 +1929,8 @@ mod tests { "dependencies": [ "tqdm>=4,<5" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "pyproject_toml": "[PYPROJECT_TOML]" } @@ -1901,7 +1945,8 @@ mod tests { "dependencies": [ "tqdm>=4,<5" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "tool": null, "dependency-groups": null @@ -2006,7 +2051,8 @@ mod tests { "dependencies": [ "tqdm>=4,<5" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "pyproject_toml": "[PYPROJECT_TOML]" }, @@ -2019,7 +2065,8 @@ mod tests { "dependencies": [ "idna==3.6" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "pyproject_toml": "[PYPROJECT_TOML]" } @@ -2034,7 +2081,8 @@ mod tests { "dependencies": [ "tqdm>=4,<5" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "tool": { "uv": { @@ -2109,7 +2157,8 @@ mod tests { "dependencies": [ "tqdm>=4,<5" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "pyproject_toml": "[PYPROJECT_TOML]" }, @@ -2122,7 +2171,8 @@ mod tests { "dependencies": [ "idna==3.6" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "pyproject_toml": "[PYPROJECT_TOML]" } @@ -2137,7 +2187,8 @@ mod tests { "dependencies": [ "tqdm>=4,<5" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "tool": { "uv": { @@ -2213,7 +2264,8 @@ mod tests { "dependencies": [ "tqdm>=4,<5" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "pyproject_toml": "[PYPROJECT_TOML]" }, @@ -2226,7 +2278,8 @@ mod tests { "dependencies": [ "anyio>=4.3.0,<5" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "pyproject_toml": "[PYPROJECT_TOML]" }, @@ -2239,7 +2292,8 @@ mod tests { "dependencies": [ "idna==3.6" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "pyproject_toml": "[PYPROJECT_TOML]" } @@ -2254,7 +2308,8 @@ mod tests { "dependencies": [ "tqdm>=4,<5" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "tool": { "uv": { @@ -2330,7 +2385,8 @@ mod tests { "dependencies": [ "tqdm>=4,<5" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "pyproject_toml": "[PYPROJECT_TOML]" } @@ -2345,7 +2401,8 @@ mod tests { "dependencies": [ "tqdm>=4,<5" ], - "optional-dependencies": null + "optional-dependencies": null, + "dynamic": null }, "tool": { "uv": { diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index e60ede5b7e8b8..271fae9763e84 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -24,8 +24,8 @@ use crate::commands::project::install_target::InstallTarget; use crate::commands::project::lock::{do_safe_lock, LockMode}; use crate::commands::project::lock_target::LockTarget; use crate::commands::project::{ - default_dependency_groups, detect_conflicts, DependencyGroupsTarget, ProjectError, - ProjectInterpreter, ScriptInterpreter, + default_dependency_groups, detect_conflicts, ProjectError, ProjectInterpreter, + ScriptInterpreter, SpecificationTarget, }; use crate::commands::{diagnostics, ExitStatus, OutputWriter}; use crate::printer::Printer; @@ -108,22 +108,23 @@ pub(crate) async fn export( ExportTarget::Project(project) }; - // Validate that any referenced dependency groups are defined in the workspace. + // Validate that any referenced dependency groups and extras are defined in the workspace. if !frozen { let target = match &target { ExportTarget::Project(VirtualProject::Project(project)) => { if all_packages { - DependencyGroupsTarget::Workspace(project.workspace()) + SpecificationTarget::Workspace(project.workspace()) } else { - DependencyGroupsTarget::Project(project) + SpecificationTarget::Project(project) } } ExportTarget::Project(VirtualProject::NonProject(workspace)) => { - DependencyGroupsTarget::Workspace(workspace) + SpecificationTarget::Workspace(workspace) } - ExportTarget::Script(_) => DependencyGroupsTarget::Script, + ExportTarget::Script(_) => SpecificationTarget::Script, }; - target.validate(&dev)?; + target.validate_dependency_groups(&dev)?; + target.validate_extras(&extras)?; } // Determine the default groups to include. diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index d426435016874..53409bc3b6241 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -21,7 +21,7 @@ use uv_distribution_types::{ use uv_fs::{Simplified, CWD}; use uv_git::ResolvedRepositoryReference; use uv_installer::{SatisfiesResult, SitePackages}; -use uv_normalize::{GroupName, PackageName, DEV_DEPENDENCIES}; +use uv_normalize::{ExtraName, GroupName, PackageName, DEV_DEPENDENCIES}; use uv_pep440::{Version, VersionSpecifiers}; use uv_pep508::MarkerTreeContents; use uv_pypi_types::{ConflictPackage, ConflictSet, Conflicts, Requirement}; @@ -149,6 +149,15 @@ pub(crate) enum ProjectError { #[error("Default group `{0}` (from `tool.uv.default-groups`) is not defined in the project's `dependency-group` table")] MissingDefaultGroup(GroupName), + #[error("Extra `{0}` is not defined in the project's `optional-dependencies` table")] + MissingExtraProject(ExtraName), + + #[error("Extra `{0}` is not defined in any project's `optional-dependencies` table")] + MissingExtraWorkspace(ExtraName), + + #[error("PEP 723 scripts do not support optional dependencies, but extra `{0}` was specified")] + MissingExtraScript(ExtraName), + #[error("Supported environments must be disjoint, but the following markers overlap: `{0}` and `{1}`.\n\n{hint}{colon} replace `{1}` with `{2}`.", hint = "hint".bold().cyan(), colon = ":".bold())] OverlappingMarkers(String, String, String), @@ -1744,19 +1753,22 @@ pub(crate) async fn init_script_python_requirement( } #[derive(Debug, Copy, Clone)] -pub(crate) enum DependencyGroupsTarget<'env> { - /// The dependency groups can be defined in any workspace member. +pub(crate) enum SpecificationTarget<'env> { + /// The specification applies to any workspace member. Workspace(&'env Workspace), - /// The dependency groups must be defined in the target project. + /// The specification only applies to the target project. Project(&'env ProjectWorkspace), - /// The dependency groups must be defined in the target script. + /// The specification only applies to the target script. Script, } -impl DependencyGroupsTarget<'_> { +impl SpecificationTarget<'_> { /// Validate the dependency groups requested by the [`DevGroupsSpecification`]. #[allow(clippy::result_large_err)] - pub(crate) fn validate(self, dev: &DevGroupsSpecification) -> Result<(), ProjectError> { + pub(crate) fn validate_dependency_groups( + self, + dev: &DevGroupsSpecification, + ) -> Result<(), ProjectError> { for group in dev .groups() .into_iter() @@ -1788,6 +1800,77 @@ impl DependencyGroupsTarget<'_> { } Ok(()) } + + /// Validate the extras requested by the [`ExtrasSpecification`]. + #[allow(clippy::result_large_err)] + pub(crate) fn validate_extras(self, extras: &ExtrasSpecification) -> Result<(), ProjectError> { + let extras = match extras { + ExtrasSpecification::Some(extras) => { + if extras.is_empty() { + return Ok(()); + } + extras + } + ExtrasSpecification::Exclude(extras) => { + if extras.is_empty() { + return Ok(()); + } + &Vec::from_iter(extras.clone()) + } + _ => return Ok(()), + }; + + match self { + Self::Workspace(workspace) => { + // If at least one project in the workspace uses dynamic extras, the list of extras + // cannot be determined, so we cannot check if they are valid. + if workspace.uses_dynamic_key("optional-dependencies") { + return Ok(()); + } + + let workspace_extras = workspace.extras(); + + for extra in extras { + // The extra must be defined in the workspace. + if !workspace_extras.contains(extra) { + return Err(ProjectError::MissingExtraWorkspace(extra.clone())); + } + } + } + Self::Project(project) => { + // If the project uses dynamic extras, the list of extras cannot be determined, so + // we cannot check if they are valid. + if project + .current_project() + .pyproject_toml() + .is_key_dynamic("optional-dependencies") + { + return Ok(()); + } + + let optional_dependencies = project + .current_project() + .pyproject_toml() + .project + .as_ref() + .map_or_else(BTreeMap::new, |project| { + project.clone().optional_dependencies.unwrap_or_default() + }); + + for extra in extras { + // The extra must be defined in the target project. + if !optional_dependencies.contains_key(extra) { + return Err(ProjectError::MissingExtraProject(extra.clone())); + } + } + } + Self::Script => { + return Err(ProjectError::MissingExtraScript(extras[0].clone())); + } + } + + Ok(()) + } } /// Returns the default dependency groups from the [`PyProjectToml`]. diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 65ef8b76fb724..2f4a4301fcbc7 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -47,8 +47,8 @@ use crate::commands::project::install_target::InstallTarget; use crate::commands::project::lock::LockMode; use crate::commands::project::lock_target::LockTarget; use crate::commands::project::{ - default_dependency_groups, validate_project_requires_python, DependencyGroupsTarget, - EnvironmentSpecification, ProjectError, ScriptInterpreter, WorkspacePython, + default_dependency_groups, validate_project_requires_python, EnvironmentSpecification, + ProjectError, ScriptInterpreter, SpecificationTarget, WorkspacePython, }; use crate::commands::reporters::PythonDownloadReporter; use crate::commands::{diagnostics, project, ExitStatus}; @@ -683,21 +683,23 @@ pub(crate) async fn run( .map(|lock| (lock, project.workspace().install_path().to_owned())); } } else { - // Validate that any referenced dependency groups are defined in the workspace. + // Validate that any referenced dependency groups and extras are defined in the + // workspace. if !frozen { let target = match &project { VirtualProject::Project(project) => { if all_packages { - DependencyGroupsTarget::Workspace(project.workspace()) + SpecificationTarget::Workspace(project.workspace()) } else { - DependencyGroupsTarget::Project(project) + SpecificationTarget::Project(project) } } VirtualProject::NonProject(workspace) => { - DependencyGroupsTarget::Workspace(workspace) + SpecificationTarget::Workspace(workspace) } }; - target.validate(&dev)?; + target.validate_dependency_groups(&dev)?; + target.validate_extras(&extras)?; } // Determine the default groups to include. diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 8558e07b4f78f..6ffc077721c70 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -32,7 +32,7 @@ use crate::commands::pip::operations::Modifications; use crate::commands::project::install_target::InstallTarget; use crate::commands::project::lock::{do_safe_lock, LockMode}; use crate::commands::project::{ - default_dependency_groups, detect_conflicts, DependencyGroupsTarget, ProjectError, + default_dependency_groups, detect_conflicts, ProjectError, SpecificationTarget, }; use crate::commands::{diagnostics, project, ExitStatus}; use crate::printer::Printer; @@ -87,19 +87,20 @@ pub(crate) async fn sync( VirtualProject::discover(project_dir, &DiscoveryOptions::default()).await? }; - // Validate that any referenced dependency groups are defined in the workspace. + // Validate that any referenced dependency groups and extras are defined in the workspace. if !frozen { let target = match &project { VirtualProject::Project(project) => { if all_packages { - DependencyGroupsTarget::Workspace(project.workspace()) + SpecificationTarget::Workspace(project.workspace()) } else { - DependencyGroupsTarget::Project(project) + SpecificationTarget::Project(project) } } - VirtualProject::NonProject(workspace) => DependencyGroupsTarget::Workspace(workspace), + VirtualProject::NonProject(workspace) => SpecificationTarget::Workspace(workspace), }; - target.validate(&dev)?; + target.validate_dependency_groups(&dev)?; + target.validate_extras(&extras)?; } // Determine the default groups to include. diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index fc9d274686f27..260ceb0d33b45 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -25,8 +25,8 @@ use crate::commands::pip::resolution_markers; use crate::commands::project::lock::{do_safe_lock, LockMode}; use crate::commands::project::lock_target::LockTarget; use crate::commands::project::{ - default_dependency_groups, DependencyGroupsTarget, ProjectError, ProjectInterpreter, - ScriptInterpreter, + default_dependency_groups, ProjectError, ProjectInterpreter, ScriptInterpreter, + SpecificationTarget, }; use crate::commands::reporters::LatestVersionReporter; use crate::commands::{diagnostics, ExitStatus}; @@ -76,10 +76,10 @@ pub(crate) async fn tree( // Validate that any referenced dependency groups are defined in the target. if !frozen { let target = match &target { - LockTarget::Workspace(workspace) => DependencyGroupsTarget::Workspace(workspace), - LockTarget::Script(..) => DependencyGroupsTarget::Script, + LockTarget::Workspace(workspace) => SpecificationTarget::Workspace(workspace), + LockTarget::Script(..) => SpecificationTarget::Script, }; - target.validate(&dev)?; + target.validate_dependency_groups(&dev)?; } // Determine the default groups to include. diff --git a/crates/uv/tests/it/lock_conflict.rs b/crates/uv/tests/it/lock_conflict.rs index a0143635f2cdb..c06a3bf098358 100644 --- a/crates/uv/tests/it/lock_conflict.rs +++ b/crates/uv/tests/it/lock_conflict.rs @@ -4413,11 +4413,17 @@ conflicts = [ "#, )?; - // I believe there are multiple valid solutions here, but the main - // thing is that `x2` should _not_ activate the `idna==3.4` dependency - // in `proxy1`. The `--extra=x2` should be a no-op, since there is no - // `x2` extra in the top level `pyproject.toml`. + // Error out, as x2 extra is only on the child. uv_snapshot!(context.filters(), context.sync().arg("--extra=x2"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Extra `x2` is not defined in the project's `optional-dependencies` table + "###); + + uv_snapshot!(context.filters(), context.sync(), @r###" success: true exit_code: 0 ----- stdout ----- diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 9ee7cb2381671..b9bcaed8de55f 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -2402,6 +2402,88 @@ fn sync_group_self() -> Result<()> { Ok(()) } +#[test] +fn sync_non_existent_extra() -> 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" + + [project.optional-dependencies] + types = ["sniffio>1"] + async = ["anyio>3"] + "#, + )?; + + context.lock().assert().success(); + + // Requesting a non-existent extra should fail. + uv_snapshot!(context.filters(), context.sync().arg("--extra").arg("baz"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Extra `baz` is not defined in the project's `optional-dependencies` table + "###); + + // Excluding a non-existing extra when requesting all extras should fail. + uv_snapshot!(context.filters(), context.sync().arg("--all-extras").arg("--no-extra").arg("baz"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Extra `baz` is not defined in the project's `optional-dependencies` table + "###); + + Ok(()) +} + +#[test] +fn sync_non_existent_extra_no_optional_dependencies() -> 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" + "#, + )?; + + context.lock().assert().success(); + + // Requesting a non-existent extra should fail. + uv_snapshot!(context.filters(), context.sync().arg("--extra").arg("baz"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Extra `baz` is not defined in the project's `optional-dependencies` table + "###); + + // Excluding a non-existing extra when requesting all extras should fail. + uv_snapshot!(context.filters(), context.sync().arg("--all-extras").arg("--no-extra").arg("baz"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Extra `baz` 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 @@ -4888,7 +4970,7 @@ fn sync_all_extras() -> Result<()> { + typing-extensions==4.10.0 "###); - // Sync all extras. + // Sync all extras excluding an extra that exists in both the parent and child. uv_snapshot!(context.filters(), context.sync().arg("--all-packages").arg("--all-extras").arg("--no-extra").arg("types"), @r###" success: true exit_code: 0 @@ -4900,6 +4982,138 @@ fn sync_all_extras() -> Result<()> { - typing-extensions==4.10.0 "###); + // Sync an extra that doesn't exist. + uv_snapshot!(context.filters(), context.sync().arg("--all-packages").arg("--extra").arg("foo"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Extra `foo` is not defined in any project's `optional-dependencies` table + "###); + + // Sync all extras excluding an extra that doesn't exist. + uv_snapshot!(context.filters(), context.sync().arg("--all-packages").arg("--all-extras").arg("--no-extra").arg("foo"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Extra `foo` is not defined in any project's `optional-dependencies` table + "###); + + Ok(()) +} + +/// Sync all members in a workspace with dynamic extras. +#[test] +fn sync_all_extras_dynamic() -> 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"] + async = ["anyio>3"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + + [tool.uv.workspace] + members = ["child"] + + [tool.uv.sources] + child = { workspace = true } + "#, + )?; + context + .temp_dir + .child("src") + .child("project") + .child("__init__.py") + .touch()?; + + // Add a workspace member. + let child = context.temp_dir.child("child"); + child.child("pyproject.toml").write_str( + r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.12" + dynamic = ["optional-dependencies"] + + [tool.setuptools.dynamic.optional-dependencies] + dev = { file = "requirements-dev.txt" } + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + child + .child("src") + .child("child") + .child("__init__.py") + .touch()?; + + child + .child("requirements-dev.txt") + .write_str("typing-extensions==4.10.0")?; + + // Generate a lockfile. + context.lock().assert().success(); + + // Sync an extra that exists in the parent. + uv_snapshot!(context.filters(), context.sync().arg("--all-packages").arg("--extra").arg("types"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 6 packages in [TIME] + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + child==0.1.0 (from file://[TEMP_DIR]/child) + + project==0.1.0 (from file://[TEMP_DIR]/) + + sniffio==1.3.1 + "###); + + // Sync a dynamic extra that exists in the child. + uv_snapshot!(context.filters(), context.sync().arg("--all-packages").arg("--extra").arg("dev"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 6 packages in [TIME] + Prepared 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + - sniffio==1.3.1 + + typing-extensions==4.10.0 + "###); + + // Sync dependencies, ignoring non-existing extras as the child uses dynamic extras. + uv_snapshot!(context.filters(), context.sync().arg("--all-packages").arg("--extra").arg("foo"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 6 packages in [TIME] + Uninstalled 1 package in [TIME] + - typing-extensions==4.10.0 + "###); + Ok(()) }