feat: error on non-existent extra from lock file (#11426)

Closes #10597.

Recreated https://github.com/astral-sh/uv/pull/10925 that got closed as
the base branch got merged.

Snapshot tests.

---------

Co-authored-by: Aria Desires <aria.desires@gmail.com>
This commit is contained in:
Mathieu Kniewallner 2025-02-12 18:29:09 +01:00 committed by Zanie Blue
parent 49e10435f1
commit b17a2ee61d
6 changed files with 375 additions and 7 deletions

View file

@ -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<ExtraName>> {
self.metadata.provides_extras.as_ref()
}
}
/// Attempts to construct a `VerbatimUrl` from the given normalized `Path`.

View file

@ -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(())
}
}

View file

@ -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),

View file

@ -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.

View file

@ -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 -----

View file

@ -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 <https://github.com/astral-sh/uv/issues/6316>.
///
/// 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(())
}