diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 2d2f27d01..63b59cf4e 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -2617,6 +2617,11 @@ impl Package { fn is_dynamic(&self) -> bool { self.id.version.is_none() } + + /// Returns the extras the package provides, if any. + pub fn provides_extras(&self) -> Option<&Vec> { + self.metadata.provides_extras.as_ref() + } } /// Attempts to construct a `VerbatimUrl` from the given normalized `Path`. diff --git a/crates/uv/src/commands/project/install_target.rs b/crates/uv/src/commands/project/install_target.rs index 066afe2cc..b9432e1ed 100644 --- a/crates/uv/src/commands/project/install_target.rs +++ b/crates/uv/src/commands/project/install_target.rs @@ -2,7 +2,9 @@ use std::borrow::Cow; use std::path::Path; use std::str::FromStr; -use itertools::Either; +use itertools::{Either, Itertools}; + +use uv_configuration::ExtrasSpecification; use uv_distribution_types::Index; use uv_normalize::PackageName; use uv_pypi_types::{LenientRequirement, VerbatimParsedUrl}; @@ -11,6 +13,8 @@ use uv_scripts::Pep723Script; use uv_workspace::pyproject::{DependencyGroupSpecifier, Source, Sources, ToolUvSources}; use uv_workspace::Workspace; +use crate::commands::project::ProjectError; + /// A target that can be installed from a lockfile. #[derive(Debug, Copy, Clone)] pub(crate) enum InstallTarget<'lock> { @@ -230,4 +234,68 @@ impl<'lock> InstallTarget<'lock> { ), } } + + /// 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(()); + } + Either::Left(extras.iter()) + } + ExtrasSpecification::Exclude(extras) => { + if extras.is_empty() { + return Ok(()); + } + Either::Right(extras.iter()) + } + _ => return Ok(()), + }; + + match self { + Self::Project { lock, .. } + | Self::Workspace { lock, .. } + | Self::NonProjectWorkspace { lock, .. } => { + let member_packages: Vec<&Package> = lock + .packages() + .iter() + .filter(|package| self.roots().contains(package.name())) + .collect(); + + // If `provides-extra` is not set in any package, do not perform the check, as this + // means that the lock file was generated on a version of uv that predates when the + // feature was added. + if !member_packages + .iter() + .any(|package| package.provides_extras().is_some()) + { + return Ok(()); + } + + for extra in extras { + if !member_packages.iter().any(|package| { + package + .provides_extras() + .is_some_and(|provides_extras| provides_extras.contains(extra)) + }) { + return match self { + Self::Project { .. } => { + Err(ProjectError::MissingExtraProject(extra.clone())) + } + _ => Err(ProjectError::MissingExtraWorkspace(extra.clone())), + }; + } + } + } + Self::Script { .. } => { + // We shouldn't get here if the list is empty so we can assume it isn't + let extra = extras.into_iter().next().expect("non-empty extras").clone(); + return Err(ProjectError::MissingExtraScript(extra)); + } + } + + Ok(()) + } } diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index df62a2530..cd78e99a1 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -23,7 +23,7 @@ use uv_distribution_types::{ use uv_fs::{LockedFile, 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}; @@ -152,6 +152,15 @@ pub(crate) enum ProjectError { #[error("Default group `{0}` (from `tool.uv.default-groups`) is not defined in the project's `dependency-groups` 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), diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 5dc241b0c..a87a62ede 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -587,6 +587,7 @@ pub(super) async fn do_sync( } // Validate that the set of requested extras and development groups are compatible. + target.validate_extras(extras)?; detect_conflicts(target.lock(), extras, dev)?; // Determine the markers to use for resolution. diff --git a/crates/uv/tests/it/lock_conflict.rs b/crates/uv/tests/it/lock_conflict.rs index e79f8a0b8..5124e0119 100644 --- a/crates/uv/tests/it/lock_conflict.rs +++ b/crates/uv/tests/it/lock_conflict.rs @@ -4440,11 +4440,18 @@ 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 ----- + Resolved 7 packages in [TIME] + 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 f42ff1ab2..56d165f15 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -2626,6 +2626,151 @@ 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 ----- + Resolved 4 packages in [TIME] + 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 ----- + Resolved 4 packages in [TIME] + 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 ----- + Resolved 1 package in [TIME] + 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 ----- + Resolved 1 package in [TIME] + error: Extra `baz` is not defined in the project's `optional-dependencies` table + "###); + + Ok(()) +} + +/// Ensures that we do not perform validation of extras against a lock file that was generated on a +/// version of uv that predates when `provides-extras` feature was added. +#[test] +fn sync_ignore_extras_check_when_no_provides_extras() -> 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"] + "#, + )?; + + // Write a lockfile that does not have `provides-extra`, simulating a version that predates when + // the feature was added. + context.temp_dir.child("uv.lock").write_str(indoc! {r#" + version = 1 + requires-python = ">=3.12" + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + + [package.optional-dependencies] + types = [ + { name = "sniffio" }, + ] + + [package.metadata] + requires-dist = [{ name = "sniffio", marker = "extra == 'types'", specifier = ">1" }] + + [[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 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, + ] + "#})?; + + // Requesting a non-existent extra should not fail, as no validation should be performed. + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra").arg("baz"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Audited in [TIME] + "###); + + Ok(()) +} + /// Regression test for . /// /// Previously, we would read metadata statically from pyproject.toml and write that to `uv.lock`. In @@ -5397,7 +5542,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 @@ -5409,6 +5554,139 @@ 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 ----- + Resolved 8 packages in [TIME] + 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 ----- + Resolved 8 packages in [TIME] + 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 a dynamic extra that doesn't exist in the child. + uv_snapshot!(context.filters(), context.sync().arg("--all-packages").arg("--extra").arg("foo"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Resolved 6 packages in [TIME] + error: Extra `foo` is not defined in any project's `optional-dependencies` table + "###); + Ok(()) }