mirror of
https://github.com/astral-sh/uv.git
synced 2025-11-01 20:31:12 +00:00
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:
parent
49e10435f1
commit
b17a2ee61d
6 changed files with 375 additions and 7 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue