Skip to content

Commit

Permalink
feat: error on non-existent extra from lock file (#11426)
Browse files Browse the repository at this point in the history
Closes #10597.

Recreated #10925 that got closed as
the base branch got merged.

Snapshot tests.

---------

Co-authored-by: Aria Desires <aria.desires@gmail.com>
  • Loading branch information
2 people authored and zanieb committed Feb 13, 2025
1 parent 2ec6a3e commit f8e272b
Show file tree
Hide file tree
Showing 6 changed files with 375 additions and 7 deletions.
5 changes: 5 additions & 0 deletions crates/uv-resolver/src/lock/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
70 changes: 69 additions & 1 deletion crates/uv/src/commands/project/install_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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> {
Expand Down Expand Up @@ -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(())
}
}
11 changes: 10 additions & 1 deletion crates/uv/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -151,6 +151,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),

Expand Down
1 change: 1 addition & 0 deletions crates/uv/src/commands/project/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,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.
Expand Down
15 changes: 11 additions & 4 deletions crates/uv/tests/it/lock_conflict.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 -----
Expand Down
Loading

0 comments on commit f8e272b

Please sign in to comment.