diff --git a/crates/uv-resolver/src/pubgrub/package.rs b/crates/uv-resolver/src/pubgrub/package.rs index 17f72da77..19fea7be8 100644 --- a/crates/uv-resolver/src/pubgrub/package.rs +++ b/crates/uv-resolver/src/pubgrub/package.rs @@ -169,6 +169,8 @@ impl PubGrubPackage { /// Returns the extra name associated with this PubGrub package, if it has /// one. + /// + /// Note that if this returns `Some`, then `dev` must return `None`. pub(crate) fn extra(&self) -> Option<&ExtraName> { match &**self { // A root can never be a dependency of another package, and a `Python` pubgrub @@ -186,14 +188,44 @@ impl PubGrubPackage { } } + /// Returns the dev (aka "group") name associated with this PubGrub + /// package, if it has one. + /// + /// Note that if this returns `Some`, then `extra` must return `None`. + pub(crate) fn dev(&self) -> Option<&GroupName> { + match &**self { + // A root can never be a dependency of another package, and a `Python` pubgrub + // package is never returned by `get_dependencies`. So these cases never occur. + PubGrubPackageInner::Root(_) + | PubGrubPackageInner::Python(_) + | PubGrubPackageInner::Package { dev: None, .. } + | PubGrubPackageInner::Extra { .. } + | PubGrubPackageInner::Marker { .. } => None, + PubGrubPackageInner::Package { + dev: Some(ref dev), .. + } + | PubGrubPackageInner::Dev { ref dev, .. } => Some(dev), + } + } + /// Extracts a possible conflicting group from this package. /// /// If this package can't possibly be classified as a conflicting group, /// then this returns `None`. pub(crate) fn conflicting_item(&self) -> Option> { let package = self.name_no_root()?; - let extra = self.extra()?; - Some(ConflictItemRef::from((package, extra))) + match (self.extra(), self.dev()) { + (None, None) => None, + (Some(extra), None) => Some(ConflictItemRef::from((package, extra))), + (None, Some(group)) => Some(ConflictItemRef::from((package, group))), + (Some(extra), Some(group)) => { + unreachable!( + "PubGrub package cannot have both an extra and a group, \ + but found extra=`{extra}` and group=`{group}` for \ + package `{package}`", + ) + } + } } /// Returns `true` if this PubGrub package is a proxy package. diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 201d6d26f..b1921b8ee 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -19,7 +19,7 @@ use uv_distribution_types::{ use uv_fs::{Simplified, CWD}; use uv_git::ResolvedRepositoryReference; use uv_installer::{SatisfiesResult, SitePackages}; -use uv_normalize::{ExtraName, GroupName, PackageName, DEV_DEPENDENCIES}; +use uv_normalize::{GroupName, PackageName, DEV_DEPENDENCIES}; use uv_pep440::{Version, VersionSpecifiers}; use uv_pep508::MarkerTreeContents; use uv_pypi_types::{ConflictPackage, ConflictSet, Conflicts, Requirement}; @@ -82,8 +82,13 @@ pub(crate) enum ProjectError { LockedPlatformIncompatibility(String), #[error( - "The requested extras ({}) are incompatible with the declared conflicting extra: {{{}}}", - _1.iter().map(|extra| format!("`{extra}`")).collect::>().join(", "), + "{} are incompatible with the declared conflicts: {{{}}}", + _1.iter().map(|conflict| { + match conflict { + ConflictPackage::Extra(ref extra) => format!("extra `{extra}`"), + ConflictPackage::Group(ref group) => format!("group `{group}`"), + } + }).collect::>().join(", "), _0 .iter() .map(|item| { @@ -95,7 +100,7 @@ pub(crate) enum ProjectError { .collect::>() .join(", "), )] - ExtraIncompatibility(ConflictSet, Vec), + ConflictIncompatibility(ConflictSet, Vec), #[error("The requested interpreter resolved to Python {0}, which is incompatible with the project's Python requirement: `{1}`")] RequestedPythonProjectIncompatibility(Version, RequiresPython), diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 233ffdcfc..abeb535cb 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -15,10 +15,11 @@ use uv_configuration::{ use uv_dispatch::BuildDispatch; use uv_distribution_types::{DirectorySourceDist, Dist, Index, ResolvedDist, SourceDist}; use uv_installer::SitePackages; -use uv_normalize::{ExtraName, PackageName}; +use uv_normalize::PackageName; use uv_pep508::{MarkerTree, Requirement, VersionOrUrl}; use uv_pypi_types::{ - LenientRequirement, ParsedArchiveUrl, ParsedGitUrl, ParsedUrl, VerbatimParsedUrl, + ConflictPackage, LenientRequirement, ParsedArchiveUrl, ParsedGitUrl, ParsedUrl, + VerbatimParsedUrl, }; use uv_python::{PythonDownloads, PythonEnvironment, PythonPreference, PythonRequest}; use uv_resolver::{FlatIndex, InstallTarget}; @@ -281,18 +282,36 @@ pub(super) async fn do_sync( )); } - // Validate that we aren't trying to install extras that are - // declared as conflicting. + // Validate that we aren't trying to install extras or groups that + // are declared as conflicting. Note that we need to collect all + // extras and groups that match in a particular set, since extras + // can be declared as conflicting with groups. So if extra `x` and + // group `g` are declared as conflicting, then enabling both of + // those should result in an error. let conflicts = target.lock().conflicts(); for set in conflicts.iter() { - let conflicting = set - .iter() - .filter_map(|item| item.extra()) - .filter(|extra| extras.contains(extra)) - .map(|extra| extra.clone()) - .collect::>(); - if conflicting.len() >= 2 { - return Err(ProjectError::ExtraIncompatibility(set.clone(), conflicting)); + let mut conflicts: Vec = vec![]; + for item in set.iter() { + if item + .extra() + .map(|extra| extras.contains(extra)) + .unwrap_or(false) + { + conflicts.push(item.conflict().clone()); + } + if item + .group() + .map(|group1| dev.iter().any(|group2| group1 == group2)) + .unwrap_or(false) + { + conflicts.push(item.conflict().clone()); + } + } + if conflicts.len() >= 2 { + return Err(ProjectError::ConflictIncompatibility( + set.clone(), + conflicts, + )); } } diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index d327a0d14..16a186716 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -2361,7 +2361,7 @@ fn lock_conflicting_extra_basic() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: The requested extras (`project1`, `project2`) are incompatible with the declared conflicting extra: {`project[project1]`, `project[project2]`} + error: extra `project1`, extra `project2` are incompatible with the declared conflicts: {`project[project1]`, `project[project2]`} "###); Ok(()) @@ -2595,7 +2595,7 @@ fn lock_conflicting_extra_multiple_not_conflicting1() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: The requested extras (`project1`, `project2`) are incompatible with the declared conflicting extra: {`project[project1]`, `project[project2]`} + error: extra `project1`, extra `project2` are incompatible with the declared conflicts: {`project[project1]`, `project[project2]`} "###); // project3/project4 conflict! uv_snapshot!( @@ -2607,7 +2607,7 @@ fn lock_conflicting_extra_multiple_not_conflicting1() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: The requested extras (`project3`, `project4`) are incompatible with the declared conflicting extra: {`project[project3]`, `project[project4]`} + error: extra `project3`, extra `project4` are incompatible with the declared conflicts: {`project[project3]`, `project[project4]`} "###); // ... but project1/project3 does not. uv_snapshot!(