diff --git a/crates/uv-configuration/src/preview.rs b/crates/uv-configuration/src/preview.rs index 53fb462cf..44ba6ef31 100644 --- a/crates/uv-configuration/src/preview.rs +++ b/crates/uv-configuration/src/preview.rs @@ -14,7 +14,8 @@ bitflags::bitflags! { const JSON_OUTPUT = 1 << 2; const PYLOCK = 1 << 3; const ADD_BOUNDS = 1 << 4; - const EXTRA_BUILD_DEPENDENCIES = 1 << 5; + const PACKAGE_CONFLICTS = 1 << 5; + const EXTRA_BUILD_DEPENDENCIES = 1 << 6; } } @@ -29,6 +30,7 @@ impl PreviewFeatures { Self::JSON_OUTPUT => "json-output", Self::PYLOCK => "pylock", Self::ADD_BOUNDS => "add-bounds", + Self::PACKAGE_CONFLICTS => "package-conflicts", Self::EXTRA_BUILD_DEPENDENCIES => "extra-build-dependencies", _ => panic!("`flag_as_str` can only be used for exactly one feature flag"), } @@ -72,6 +74,7 @@ impl FromStr for PreviewFeatures { "json-output" => Self::JSON_OUTPUT, "pylock" => Self::PYLOCK, "add-bounds" => Self::ADD_BOUNDS, + "package-conflicts" => Self::PACKAGE_CONFLICTS, "extra-build-dependencies" => Self::EXTRA_BUILD_DEPENDENCIES, _ => { warn_user_once!("Unknown preview feature: `{part}`"); @@ -235,6 +238,10 @@ mod tests { assert_eq!(PreviewFeatures::JSON_OUTPUT.flag_as_str(), "json-output"); assert_eq!(PreviewFeatures::PYLOCK.flag_as_str(), "pylock"); assert_eq!(PreviewFeatures::ADD_BOUNDS.flag_as_str(), "add-bounds"); + assert_eq!( + PreviewFeatures::PACKAGE_CONFLICTS.flag_as_str(), + "package-conflicts" + ); assert_eq!( PreviewFeatures::EXTRA_BUILD_DEPENDENCIES.flag_as_str(), "extra-build-dependencies" diff --git a/crates/uv-pypi-types/src/conflicts.rs b/crates/uv-pypi-types/src/conflicts.rs index 0dcd1393d..2ce6f35e8 100644 --- a/crates/uv-pypi-types/src/conflicts.rs +++ b/crates/uv-pypi-types/src/conflicts.rs @@ -41,10 +41,10 @@ impl Conflicts { pub fn contains<'a>( &self, package: &PackageName, - conflict: impl Into>, + kind: impl Into>, ) -> bool { - let conflict = conflict.into(); - self.iter().any(|set| set.contains(package, conflict)) + let kind = kind.into(); + self.iter().any(|set| set.contains(package, kind)) } /// Returns true if there are no conflicts. @@ -106,7 +106,7 @@ impl Conflicts { for set in &self.0 { direct_conflict_sets.insert(set); for item in set.iter() { - let ConflictPackage::Group(group) = &item.conflict else { + let ConflictKind::Group(group) = &item.kind else { // TODO(john): Do we also want to handle extras here? continue; }; @@ -129,7 +129,7 @@ impl Conflicts { } let group_conflict_item = ConflictItem { package: package.clone(), - conflict: ConflictPackage::Group(group.clone()), + kind: ConflictKind::Group(group.clone()), }; let node_id = graph.add_node(FxHashSet::default()); group_node_idxs.insert(group, node_id); @@ -242,11 +242,11 @@ impl ConflictSet { pub fn contains<'a>( &self, package: &PackageName, - conflict: impl Into>, + kind: impl Into>, ) -> bool { - let conflict = conflict.into(); + let kind = kind.into(); self.iter() - .any(|set| set.package() == package && *set.conflict() == conflict) + .any(|set| set.package() == package && *set.kind() == kind) } /// Returns true if these conflicts contain any set that contains the given @@ -326,7 +326,7 @@ impl TryFrom> for ConflictSet { )] pub struct ConflictItem { package: PackageName, - conflict: ConflictPackage, + kind: ConflictKind, } impl ConflictItem { @@ -338,40 +338,47 @@ impl ConflictItem { /// Returns the package-specific conflict. /// /// i.e., Either an extra or a group name. - pub fn conflict(&self) -> &ConflictPackage { - &self.conflict + pub fn kind(&self) -> &ConflictKind { + &self.kind } /// Returns the extra name of this conflicting item. pub fn extra(&self) -> Option<&ExtraName> { - self.conflict.extra() + self.kind.extra() } /// Returns the group name of this conflicting item. pub fn group(&self) -> Option<&GroupName> { - self.conflict.group() + self.kind.group() } /// Returns this item as a new type with its fields borrowed. pub fn as_ref(&self) -> ConflictItemRef<'_> { ConflictItemRef { package: self.package(), - conflict: self.conflict.as_ref(), + kind: self.kind.as_ref(), } } } +impl From for ConflictItem { + fn from(package: PackageName) -> Self { + let kind = ConflictKind::Project; + Self { package, kind } + } +} + impl From<(PackageName, ExtraName)> for ConflictItem { fn from((package, extra): (PackageName, ExtraName)) -> Self { - let conflict = ConflictPackage::Extra(extra); - Self { package, conflict } + let kind = ConflictKind::Extra(extra); + Self { package, kind } } } impl From<(PackageName, GroupName)> for ConflictItem { fn from((package, group): (PackageName, GroupName)) -> Self { - let conflict = ConflictPackage::Group(group); - Self { package, conflict } + let kind = ConflictKind::Group(group); + Self { package, kind } } } @@ -382,7 +389,7 @@ impl From<(PackageName, GroupName)> for ConflictItem { #[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, PartialOrd, Ord)] pub struct ConflictItemRef<'a> { package: &'a PackageName, - conflict: ConflictPackageRef<'a>, + kind: ConflictKindRef<'a>, } impl<'a> ConflictItemRef<'a> { @@ -394,40 +401,47 @@ impl<'a> ConflictItemRef<'a> { /// Returns the package-specific conflict. /// /// i.e., Either an extra or a group name. - pub fn conflict(&self) -> ConflictPackageRef<'a> { - self.conflict + pub fn kind(&self) -> ConflictKindRef<'a> { + self.kind } /// Returns the extra name of this conflicting item. pub fn extra(&self) -> Option<&'a ExtraName> { - self.conflict.extra() + self.kind.extra() } /// Returns the group name of this conflicting item. pub fn group(&self) -> Option<&'a GroupName> { - self.conflict.group() + self.kind.group() } /// Converts this borrowed conflicting item to its owned variant. pub fn to_owned(&self) -> ConflictItem { ConflictItem { package: self.package().clone(), - conflict: self.conflict.to_owned(), + kind: self.kind.to_owned(), } } } +impl<'a> From<&'a PackageName> for ConflictItemRef<'a> { + fn from(package: &'a PackageName) -> Self { + let kind = ConflictKindRef::Project; + Self { package, kind } + } +} + impl<'a> From<(&'a PackageName, &'a ExtraName)> for ConflictItemRef<'a> { fn from((package, extra): (&'a PackageName, &'a ExtraName)) -> Self { - let conflict = ConflictPackageRef::Extra(extra); - ConflictItemRef { package, conflict } + let kind = ConflictKindRef::Extra(extra); + ConflictItemRef { package, kind } } } impl<'a> From<(&'a PackageName, &'a GroupName)> for ConflictItemRef<'a> { fn from((package, group): (&'a PackageName, &'a GroupName)) -> Self { - let conflict = ConflictPackageRef::Group(group); - ConflictItemRef { package, conflict } + let kind = ConflictKindRef::Group(group); + ConflictItemRef { package, kind } } } @@ -439,20 +453,22 @@ impl hashbrown::Equivalent for ConflictItemRef<'_> { /// The actual conflicting data for a package. /// -/// That is, either an extra or a group name. +/// That is, either an extra or a group name, or the entire project itself. #[derive(Debug, Clone, Eq, Hash, PartialEq, PartialOrd, Ord)] -pub enum ConflictPackage { +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum ConflictKind { Extra(ExtraName), Group(GroupName), + Project, } -impl ConflictPackage { +impl ConflictKind { /// If this conflict corresponds to an extra, then return the /// extra name. pub fn extra(&self) -> Option<&ExtraName> { match self { Self::Extra(extra) => Some(extra), - Self::Group(_) => None, + Self::Group(_) | Self::Project => None, } } @@ -461,15 +477,16 @@ impl ConflictPackage { pub fn group(&self) -> Option<&GroupName> { match self { Self::Group(group) => Some(group), - Self::Extra(_) => None, + Self::Extra(_) | Self::Project => None, } } /// Returns this conflict as a new type with its fields borrowed. - pub fn as_ref(&self) -> ConflictPackageRef<'_> { + pub fn as_ref(&self) -> ConflictKindRef<'_> { match self { - Self::Extra(extra) => ConflictPackageRef::Extra(extra), - Self::Group(group) => ConflictPackageRef::Group(group), + Self::Extra(extra) => ConflictKindRef::Extra(extra), + Self::Group(group) => ConflictKindRef::Group(group), + Self::Project => ConflictKindRef::Project, } } } @@ -478,18 +495,19 @@ impl ConflictPackage { /// /// That is, either a borrowed extra name or a borrowed group name. #[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, PartialOrd, Ord)] -pub enum ConflictPackageRef<'a> { +pub enum ConflictKindRef<'a> { Extra(&'a ExtraName), Group(&'a GroupName), + Project, } -impl<'a> ConflictPackageRef<'a> { +impl<'a> ConflictKindRef<'a> { /// If this conflict corresponds to an extra, then return the /// extra name. pub fn extra(&self) -> Option<&'a ExtraName> { match self { Self::Extra(extra) => Some(extra), - Self::Group(_) => None, + Self::Group(_) | Self::Project => None, } } @@ -498,45 +516,46 @@ impl<'a> ConflictPackageRef<'a> { pub fn group(&self) -> Option<&'a GroupName> { match self { Self::Group(group) => Some(group), - Self::Extra(_) => None, + Self::Extra(_) | Self::Project => None, } } /// Converts this borrowed conflict to its owned variant. - pub fn to_owned(&self) -> ConflictPackage { - match *self { - Self::Extra(extra) => ConflictPackage::Extra(extra.clone()), - Self::Group(group) => ConflictPackage::Group(group.clone()), + pub fn to_owned(&self) -> ConflictKind { + match self { + Self::Extra(extra) => ConflictKind::Extra((*extra).clone()), + Self::Group(group) => ConflictKind::Group((*group).clone()), + Self::Project => ConflictKind::Project, } } } -impl<'a> From<&'a ExtraName> for ConflictPackageRef<'a> { +impl<'a> From<&'a ExtraName> for ConflictKindRef<'a> { fn from(extra: &'a ExtraName) -> Self { Self::Extra(extra) } } -impl<'a> From<&'a GroupName> for ConflictPackageRef<'a> { +impl<'a> From<&'a GroupName> for ConflictKindRef<'a> { fn from(group: &'a GroupName) -> Self { Self::Group(group) } } -impl PartialEq for ConflictPackageRef<'_> { - fn eq(&self, other: &ConflictPackage) -> bool { +impl PartialEq for ConflictKindRef<'_> { + fn eq(&self, other: &ConflictKind) -> bool { other.as_ref() == *self } } -impl<'a> PartialEq> for ConflictPackage { - fn eq(&self, other: &ConflictPackageRef<'a>) -> bool { +impl<'a> PartialEq> for ConflictKind { + fn eq(&self, other: &ConflictKindRef<'a>) -> bool { self.as_ref() == *other } } -impl hashbrown::Equivalent for ConflictPackageRef<'_> { - fn equivalent(&self, key: &ConflictPackage) -> bool { +impl hashbrown::Equivalent for ConflictKindRef<'_> { + fn equivalent(&self, key: &ConflictKind) -> bool { key.as_ref() == *self } } @@ -557,9 +576,9 @@ pub enum ConflictError { /// optional.) #[error("Expected `package` field in conflicting entry")] MissingPackage, - /// An error that occurs when both `extra` and `group` are missing. - #[error("Expected `extra` or `group` field in conflicting entry")] - MissingExtraAndGroup, + /// An error that occurs when all of `package`, `extra` and `group` are missing. + #[error("Expected `package`, `extra` or `group` field in conflicting entry")] + MissingPackageAndExtraAndGroup, /// An error that occurs when both `extra` and `group` are present. #[error("Expected one of `extra` or `group` in conflicting entry, but found both")] FoundExtraAndGroup, @@ -596,7 +615,7 @@ impl SchemaConflicts { let package = item.package.clone().unwrap_or_else(|| package.clone()); set.push(ConflictItem { package: package.clone(), - conflict: item.conflict.clone(), + kind: item.kind.clone(), }); } // OK because we guarantee that @@ -635,7 +654,7 @@ pub struct SchemaConflictSet(Vec); )] pub struct SchemaConflictItem { package: Option, - conflict: ConflictPackage, + kind: ConflictKind, } #[cfg(feature = "schemars")] @@ -695,8 +714,8 @@ impl TryFrom for ConflictItem { return Err(ConflictError::MissingPackage); }; match (wire.extra, wire.group) { - (None, None) => Err(ConflictError::MissingExtraAndGroup), (Some(_), Some(_)) => Err(ConflictError::FoundExtraAndGroup), + (None, None) => Ok(Self::from(package)), (Some(extra), None) => Ok(Self::from((package, extra))), (None, Some(group)) => Ok(Self::from((package, group))), } @@ -705,17 +724,22 @@ impl TryFrom for ConflictItem { impl From for ConflictItemWire { fn from(item: ConflictItem) -> Self { - match item.conflict { - ConflictPackage::Extra(extra) => Self { + match item.kind { + ConflictKind::Extra(extra) => Self { package: Some(item.package), extra: Some(extra), group: None, }, - ConflictPackage::Group(group) => Self { + ConflictKind::Group(group) => Self { package: Some(item.package), extra: None, group: Some(group), }, + ConflictKind::Project => Self { + package: Some(item.package), + extra: None, + group: None, + }, } } } @@ -726,15 +750,23 @@ impl TryFrom for SchemaConflictItem { fn try_from(wire: ConflictItemWire) -> Result { let package = wire.package; match (wire.extra, wire.group) { - (None, None) => Err(ConflictError::MissingExtraAndGroup), (Some(_), Some(_)) => Err(ConflictError::FoundExtraAndGroup), + (None, None) => { + let Some(package) = package else { + return Err(ConflictError::MissingPackageAndExtraAndGroup); + }; + Ok(Self { + package: Some(package), + kind: ConflictKind::Project, + }) + } (Some(extra), None) => Ok(Self { package, - conflict: ConflictPackage::Extra(extra), + kind: ConflictKind::Extra(extra), }), (None, Some(group)) => Ok(Self { package, - conflict: ConflictPackage::Group(group), + kind: ConflictKind::Group(group), }), } } @@ -742,17 +774,22 @@ impl TryFrom for SchemaConflictItem { impl From for ConflictItemWire { fn from(item: SchemaConflictItem) -> Self { - match item.conflict { - ConflictPackage::Extra(extra) => Self { + match item.kind { + ConflictKind::Extra(extra) => Self { package: item.package, extra: Some(extra), group: None, }, - ConflictPackage::Group(group) => Self { + ConflictKind::Group(group) => Self { package: item.package, extra: None, group: Some(group), }, + ConflictKind::Project => Self { + package: item.package, + extra: None, + group: None, + }, } } } diff --git a/crates/uv-resolver/src/graph_ops.rs b/crates/uv-resolver/src/graph_ops.rs index 84143326b..81640148b 100644 --- a/crates/uv-resolver/src/graph_ops.rs +++ b/crates/uv-resolver/src/graph_ops.rs @@ -185,7 +185,7 @@ pub(crate) fn simplify_conflict_markers( let mut new_set = BTreeSet::default(); for item in set { for conflict_set in conflicts.iter() { - if !conflict_set.contains(item.package(), item.as_ref().conflict()) { + if !conflict_set.contains(item.package(), item.as_ref().kind()) { continue; } for conflict_item in conflict_set.iter() { diff --git a/crates/uv-resolver/src/lock/installable.rs b/crates/uv-resolver/src/lock/installable.rs index 4851306da..e20921531 100644 --- a/crates/uv-resolver/src/lock/installable.rs +++ b/crates/uv-resolver/src/lock/installable.rs @@ -48,6 +48,7 @@ pub trait Installable<'lock> { let mut queue: VecDeque<(&Package, Option<&ExtraName>)> = VecDeque::new(); let mut seen = FxHashSet::default(); + let mut activated_projects: Vec<&PackageName> = vec![]; let mut activated_extras: Vec<(&PackageName, &ExtraName)> = vec![]; let mut activated_groups: Vec<(&PackageName, &GroupName)> = vec![]; @@ -74,6 +75,7 @@ pub trait Installable<'lock> { // Track the activated extras. if dev.prod() { + activated_projects.push(&dist.id.name); for extra in extras.extra_names(dist.optional_dependencies.keys()) { activated_extras.push((&dist.id.name, extra)); } @@ -143,6 +145,7 @@ pub trait Installable<'lock> { { if !dep.complexified_marker.evaluate( marker_env, + activated_projects.iter().copied(), activated_extras.iter().copied(), activated_groups.iter().copied(), ) { @@ -367,6 +370,7 @@ pub trait Installable<'lock> { } if !dep.complexified_marker.evaluate( marker_env, + activated_projects.iter().copied(), activated_extras .iter() .chain(additional_activated_extras.iter()) @@ -454,6 +458,7 @@ pub trait Installable<'lock> { for dep in deps { if !dep.complexified_marker.evaluate( marker_env, + activated_projects.iter().copied(), activated_extras.iter().copied(), activated_groups.iter().copied(), ) { diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 5a7affdf2..4eda53081 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -41,7 +41,7 @@ use uv_platform_tags::{ AbiTag, IncompatibleTag, LanguageTag, PlatformTag, TagCompatibility, TagPriority, Tags, }; use uv_pypi_types::{ - ConflictPackage, Conflicts, HashAlgorithm, HashDigest, HashDigests, Hashes, ParsedArchiveUrl, + ConflictKind, Conflicts, HashAlgorithm, HashDigest, HashDigests, Hashes, ParsedArchiveUrl, ParsedGitUrl, }; use uv_redacted::DisplaySafeUrl; @@ -1026,11 +1026,12 @@ impl Lock { list.push(each_element_on_its_line_array(set.iter().map(|item| { let mut table = InlineTable::new(); table.insert("package", Value::from(item.package().to_string())); - match item.conflict() { - ConflictPackage::Extra(extra) => { + match item.kind() { + ConflictKind::Project => {} + ConflictKind::Extra(extra) => { table.insert("extra", Value::from(extra.to_string())); } - ConflictPackage::Group(group) => { + ConflictKind::Group(group) => { table.insert("group", Value::from(group.to_string())); } } @@ -3107,6 +3108,21 @@ impl Package { pub fn dependency_groups(&self) -> &BTreeMap> { &self.metadata.dependency_groups } + + /// Returns the dependencies of the package. + pub fn dependencies(&self) -> &[Dependency] { + &self.dependencies + } + + /// Returns the optional dependencies of the package. + pub fn optional_dependencies(&self) -> &BTreeMap> { + &self.optional_dependencies + } + + /// Returns the resolved PEP 735 dependency groups of the package. + pub fn resolved_dependency_groups(&self) -> &BTreeMap> { + &self.dependency_groups + } } /// Attempts to construct a `VerbatimUrl` from the given normalized `Path`. @@ -4657,7 +4673,7 @@ impl TryFrom for Wheel { /// A single dependency of a package in a lockfile. #[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -struct Dependency { +pub struct Dependency { package_id: PackageId, extra: BTreeSet, /// A marker simplified from the PEP 508 marker in `complexified_marker` @@ -4742,6 +4758,16 @@ impl Dependency { table } + + /// Returns the package name of this dependency. + pub fn package_name(&self) -> &PackageName { + &self.package_id.name + } + + /// Returns the extras specified on this dependency. + pub fn extra(&self) -> &BTreeSet { + &self.extra + } } impl Display for Dependency { diff --git a/crates/uv-resolver/src/pubgrub/dependencies.rs b/crates/uv-resolver/src/pubgrub/dependencies.rs index daea414b5..0091b789e 100644 --- a/crates/uv-resolver/src/pubgrub/dependencies.rs +++ b/crates/uv-resolver/src/pubgrub/dependencies.rs @@ -8,8 +8,8 @@ use uv_distribution_types::{Requirement, RequirementSource}; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::{Version, VersionSpecifiers}; use uv_pypi_types::{ - Conflicts, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl, ParsedUrl, - VerbatimParsedUrl, + ConflictItemRef, Conflicts, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl, + ParsedUrl, VerbatimParsedUrl, }; use crate::pubgrub::{PubGrubPackage, PubGrubPackageInner}; @@ -19,6 +19,21 @@ pub(crate) struct PubGrubDependency { pub(crate) package: PubGrubPackage, pub(crate) version: Ranges, + /// When the parent that created this dependency is a "normal" package + /// (non-extra non-group), this corresponds to its name. + /// + /// This is used to create project-level `ConflictItemRef` for a specific + /// package. In effect, this lets us "delay" filtering of project + /// dependencies when a conflict is declared between the project and a + /// group. + /// + /// The main problem with dealing with project level conflicts is that if you + /// declare a conflict between a package and a group, we represent that + /// group as a dependency of that package. So if you filter out the package + /// in a fork due to a conflict, you also filter out the group. Therefore, + /// we introduce this parent field to enable "delayed" filtering. + pub(crate) parent: Option, + /// This field is set if the [`Requirement`] had a URL. We still use a URL from [`Urls`] /// even if this field is None where there is an override with a URL or there is a different /// requirement or constraint for the same package that has a URL. @@ -30,8 +45,12 @@ impl PubGrubDependency { conflicts: &Conflicts, requirement: Cow<'a, Requirement>, dev: Option<&'a GroupName>, - source_name: Option<&'a PackageName>, + parent_package: Option<&'a PubGrubPackage>, ) -> impl Iterator + 'a { + let parent_name = parent_package.and_then(|package| package.name_no_root()); + let is_normal_parent = parent_package + .map(|pp| pp.extra().is_none() && pp.dev().is_none()) + .unwrap_or(false); let iter = if !requirement.extras.is_empty() { // This is crazy subtle, but if any of the extras in the // requirement are part of a declared conflict, then we @@ -80,50 +99,59 @@ impl PubGrubDependency { // Add the package, plus any extra variants. iter.map(move |(extra, group)| { - PubGrubRequirement::from_requirement(&requirement, extra, group) - }) - .map(move |requirement| { + let pubgrub_requirement = + PubGrubRequirement::from_requirement(&requirement, extra, group); let PubGrubRequirement { package, version, url, - } = requirement; + } = pubgrub_requirement; match &*package { PubGrubPackageInner::Package { .. } => Self { package, version, + parent: if is_normal_parent { + parent_name.cloned() + } else { + None + }, url, }, PubGrubPackageInner::Marker { .. } => Self { package, version, + parent: if is_normal_parent { + parent_name.cloned() + } else { + None + }, url, }, PubGrubPackageInner::Extra { name, .. } => { - // Detect self-dependencies. if dev.is_none() { debug_assert!( - source_name.is_none_or(|source_name| source_name != name), + parent_name.is_none_or(|parent_name| parent_name != name), "extras not flattened for {name}" ); } Self { package, version, + parent: None, url, } } PubGrubPackageInner::Dev { name, .. } => { - // Detect self-dependencies. if dev.is_none() { debug_assert!( - source_name.is_none_or(|source_name| source_name != name), + parent_name.is_none_or(|parent_name| parent_name != name), "group not flattened for {name}" ); } Self { package, version, + parent: None, url, } } @@ -135,6 +163,14 @@ impl PubGrubDependency { } }) } + + /// Extracts a possible conflicting item from this dependency. + /// + /// If this package can't possibly be classified as conflicting, then this + /// returns `None`. + pub(crate) fn conflicting_item(&self) -> Option> { + self.package.conflicting_item() + } } /// A PubGrub-compatible package and version range. diff --git a/crates/uv-resolver/src/pubgrub/package.rs b/crates/uv-resolver/src/pubgrub/package.rs index 86aa379bb..42cace0a4 100644 --- a/crates/uv-resolver/src/pubgrub/package.rs +++ b/crates/uv-resolver/src/pubgrub/package.rs @@ -214,14 +214,14 @@ impl PubGrubPackage { } } - /// Extracts a possible conflicting group from this package. + /// Extracts a possible conflicting item from this package. /// - /// If this package can't possibly be classified as a conflicting group, - /// then this returns `None`. + /// If this package can't possibly be classified as conflicting, then + /// this returns `None`. pub(crate) fn conflicting_item(&self) -> Option> { let package = self.name_no_root()?; match (self.extra(), self.dev()) { - (None, None) => None, + (None, None) => Some(ConflictItemRef::from(package)), (Some(extra), None) => Some(ConflictItemRef::from((package, extra))), (None, Some(group)) => Some(ConflictItemRef::from((package, group))), (Some(extra), Some(group)) => { diff --git a/crates/uv-resolver/src/resolver/environment.rs b/crates/uv-resolver/src/resolver/environment.rs index cab258aea..29581f72e 100644 --- a/crates/uv-resolver/src/resolver/environment.rs +++ b/crates/uv-resolver/src/resolver/environment.rs @@ -7,7 +7,7 @@ use tracing::trace; use uv_distribution_types::{RequiresPython, RequiresPythonRange}; use uv_pep440::VersionSpecifiers; use uv_pep508::{MarkerEnvironment, MarkerTree}; -use uv_pypi_types::{ConflictItem, ConflictItemRef, ConflictPackage, ResolverMarkerEnvironment}; +use uv_pypi_types::{ConflictItem, ConflictItemRef, ConflictKind, ResolverMarkerEnvironment}; use crate::pubgrub::{PubGrubDependency, PubGrubPackage}; use crate::resolver::ForkState; @@ -391,11 +391,12 @@ impl ResolverEnvironment { format!( "{}{}", conflict_item.package(), - match conflict_item.conflict() { - ConflictPackage::Extra(extra) => format!("[{extra}]"), - ConflictPackage::Group(group) => { + match conflict_item.kind() { + ConflictKind::Extra(extra) => format!("[{extra}]"), + ConflictKind::Group(group) => { format!("[group:{group}]") } + ConflictKind::Project => String::new(), } ) }; diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index eed7a3955..d0f43e9c9 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -35,7 +35,7 @@ use uv_pep508::{ MarkerEnvironment, MarkerExpression, MarkerOperator, MarkerTree, MarkerValueString, }; use uv_platform_tags::Tags; -use uv_pypi_types::{ConflictItem, ConflictItemRef, Conflicts, VerbatimParsedUrl}; +use uv_pypi_types::{ConflictItem, ConflictItemRef, ConflictKindRef, Conflicts, VerbatimParsedUrl}; use uv_types::{BuildContext, HashStrategy, InstalledPackagesProvider}; use uv_warnings::warn_user_once; @@ -946,6 +946,7 @@ impl ResolverState ResolverState ResolverState ResolverState ResolverState ResolverState Option { self.env = self.env.filter_by_group(rules)?; self.dependencies.retain(|dep| { - let Some(conflicting_item) = dep.package.conflicting_item() else { + let Some(conflicting_item) = dep.conflicting_item() else { return true; }; if self.env.included_by_group(conflicting_item) { return true; } + match conflicting_item.kind() { + // We should not filter entire projects unless they're a top-level dependency + // Otherwise, we'll fail to solve for children of the project, like extras + ConflictKindRef::Project => { + if dep.parent.is_some() { + return true; + } + } + ConflictKindRef::Group(_) => {} + ConflictKindRef::Extra(_) => {} + } self.conflicts.remove(&conflicting_item); false }); diff --git a/crates/uv-resolver/src/resolver/system.rs b/crates/uv-resolver/src/resolver/system.rs index b8965064c..bbb0983e3 100644 --- a/crates/uv-resolver/src/resolver/system.rs +++ b/crates/uv-resolver/src/resolver/system.rs @@ -48,6 +48,7 @@ impl From for PubGrubDependency { Self { package: PubGrubPackage::from(PubGrubPackageInner::System(value.name)), version: Ranges::singleton(value.version), + parent: None, url: None, } } diff --git a/crates/uv-resolver/src/universal_marker.rs b/crates/uv-resolver/src/universal_marker.rs index c40aaf77a..093253843 100644 --- a/crates/uv-resolver/src/universal_marker.rs +++ b/crates/uv-resolver/src/universal_marker.rs @@ -7,7 +7,7 @@ use rustc_hash::FxHashMap; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep508::{ExtraOperator, MarkerEnvironment, MarkerExpression, MarkerOperator, MarkerTree}; -use uv_pypi_types::{ConflictItem, ConflictPackage, Conflicts, Inference}; +use uv_pypi_types::{ConflictItem, ConflictKind, Conflicts, Inference}; use crate::ResolveError; @@ -173,9 +173,10 @@ impl UniversalMarker { /// This may simplify the conflicting marker component of this universal /// marker. pub(crate) fn assume_conflict_item(&mut self, item: &ConflictItem) { - match *item.conflict() { - ConflictPackage::Extra(ref extra) => self.assume_extra(item.package(), extra), - ConflictPackage::Group(ref group) => self.assume_group(item.package(), group), + match *item.kind() { + ConflictKind::Extra(ref extra) => self.assume_extra(item.package(), extra), + ConflictKind::Group(ref group) => self.assume_group(item.package(), group), + ConflictKind::Project => self.assume_project(item.package()), } self.pep508 = self.marker.without_extras(); } @@ -186,18 +187,45 @@ impl UniversalMarker { /// This may simplify the conflicting marker component of this universal /// marker. pub(crate) fn assume_not_conflict_item(&mut self, item: &ConflictItem) { - match *item.conflict() { - ConflictPackage::Extra(ref extra) => self.assume_not_extra(item.package(), extra), - ConflictPackage::Group(ref group) => self.assume_not_group(item.package(), group), + match *item.kind() { + ConflictKind::Extra(ref extra) => self.assume_not_extra(item.package(), extra), + ConflictKind::Group(ref group) => self.assume_not_group(item.package(), group), + ConflictKind::Project => self.assume_not_project(item.package()), } self.pep508 = self.marker.without_extras(); } + /// Assumes that the "production" dependencies for the given project are + /// activated. + /// + /// This may simplify the conflicting marker component of this universal + /// marker. + fn assume_project(&mut self, package: &PackageName) { + let extra = encode_project(package); + self.marker = self + .marker + .simplify_extras_with(|candidate| *candidate == extra); + self.pep508 = self.marker.without_extras(); + } + + /// Assumes that the "production" dependencies for the given project are + /// not activated. + /// + /// This may simplify the conflicting marker component of this universal + /// marker. + fn assume_not_project(&mut self, package: &PackageName) { + let extra = encode_project(package); + self.marker = self + .marker + .simplify_not_extras_with(|candidate| *candidate == extra); + self.pep508 = self.marker.without_extras(); + } + /// Assumes that a given extra for the given package is activated. /// /// This may simplify the conflicting marker component of this universal /// marker. - pub(crate) fn assume_extra(&mut self, package: &PackageName, extra: &ExtraName) { + fn assume_extra(&mut self, package: &PackageName, extra: &ExtraName) { let extra = encode_package_extra(package, extra); self.marker = self .marker @@ -209,7 +237,7 @@ impl UniversalMarker { /// /// This may simplify the conflicting marker component of this universal /// marker. - pub(crate) fn assume_not_extra(&mut self, package: &PackageName, extra: &ExtraName) { + fn assume_not_extra(&mut self, package: &PackageName, extra: &ExtraName) { let extra = encode_package_extra(package, extra); self.marker = self .marker @@ -221,7 +249,7 @@ impl UniversalMarker { /// /// This may simplify the conflicting marker component of this universal /// marker. - pub(crate) fn assume_group(&mut self, package: &PackageName, group: &GroupName) { + fn assume_group(&mut self, package: &PackageName, group: &GroupName) { let extra = encode_package_group(package, group); self.marker = self .marker @@ -233,7 +261,7 @@ impl UniversalMarker { /// /// This may simplify the conflicting marker component of this universal /// marker. - pub(crate) fn assume_not_group(&mut self, package: &PackageName, group: &GroupName) { + fn assume_not_group(&mut self, package: &PackageName, group: &GroupName) { let extra = encode_package_group(package, group); self.marker = self .marker @@ -277,6 +305,7 @@ impl UniversalMarker { pub(crate) fn evaluate( self, env: &MarkerEnvironment, + projects: impl Iterator, extras: impl Iterator, groups: impl Iterator, ) -> bool @@ -285,12 +314,18 @@ impl UniversalMarker { E: Borrow, G: Borrow, { + let projects = projects.map(|package| encode_project(package.borrow())); let extras = extras.map(|(package, extra)| encode_package_extra(package.borrow(), extra.borrow())); let groups = groups.map(|(package, group)| encode_package_group(package.borrow(), group.borrow())); - self.marker - .evaluate(env, &extras.chain(groups).collect::>()) + self.marker.evaluate( + env, + &projects + .chain(extras) + .chain(groups) + .collect::>(), + ) } /// Returns true if the marker always evaluates to true if the given set of extras is activated. @@ -392,12 +427,23 @@ impl ConflictMarker { /// Create a conflict marker that is true only when the given extra or /// group (for a specific package) is activated. pub fn from_conflict_item(item: &ConflictItem) -> Self { - match *item.conflict() { - ConflictPackage::Extra(ref extra) => Self::extra(item.package(), extra), - ConflictPackage::Group(ref group) => Self::group(item.package(), group), + match *item.kind() { + ConflictKind::Extra(ref extra) => Self::extra(item.package(), extra), + ConflictKind::Group(ref group) => Self::group(item.package(), group), + ConflictKind::Project => Self::project(item.package()), } } + /// Create a conflict marker that is true only when the production + /// dependencies for the given package are activated. + pub fn project(package: &PackageName) -> Self { + let operator = uv_pep508::ExtraOperator::Equal; + let name = uv_pep508::MarkerValueExtra::Extra(encode_project(package)); + let expr = uv_pep508::MarkerExpression::Extra { operator, name }; + let marker = MarkerTree::expression(expr); + Self { marker } + } + /// Create a conflict marker that is true only when the given extra for the /// given package is activated. pub fn extra(package: &PackageName, extra: &ExtraName) -> Self { @@ -504,9 +550,10 @@ impl std::fmt::Debug for ConflictMarker { /// Encodes the given conflict into a valid `extra` value in a PEP 508 marker. fn encode_conflict_item(conflict: &ConflictItem) -> ExtraName { - match conflict.conflict() { - ConflictPackage::Extra(extra) => encode_package_extra(conflict.package(), extra), - ConflictPackage::Group(group) => encode_package_group(conflict.package(), group), + match conflict.kind() { + ConflictKind::Extra(extra) => encode_package_extra(conflict.package(), extra), + ConflictKind::Group(group) => encode_package_group(conflict.package(), group), + ConflictKind::Project => encode_project(conflict.package()), } } @@ -535,8 +582,17 @@ fn encode_package_group(package: &PackageName, group: &GroupName) -> ExtraName { ExtraName::from_owned(format!("group-{package_len}-{package}-{group}")).unwrap() } +/// Encodes the given project package name into a valid `extra` value in a PEP +/// 508 marker. +fn encode_project(package: &PackageName) -> ExtraName { + // See `encode_package_extra`, the same considerations apply here. + let package_len = package.as_str().len(); + ExtraName::from_owned(format!("project-{package_len}-{package}")).unwrap() +} + #[derive(Debug)] enum ParsedRawExtra<'a> { + Project { package: &'a str }, Extra { package: &'a str, extra: &'a str }, Group { package: &'a str, group: &'a str }, } @@ -553,13 +609,13 @@ impl<'a> ParsedRawExtra<'a> { let Some((kind, tail)) = raw.split_once('-') else { return Err(mkerr( raw_extra, - "expected to find leading `extra-` or `group-`", + "expected to find leading `package`, `extra-` or `group-`", )); }; let Some((len, tail)) = tail.split_once('-') else { return Err(mkerr( raw_extra, - "expected to find `{number}-` after leading `extra-` or `group-`", + "expected to find `{number}-` after leading `package-`, `extra-` or `group-`", )); }; let len = len.parse::().map_err(|_| { @@ -577,22 +633,28 @@ impl<'a> ParsedRawExtra<'a> { ), )); }; - if !tail.starts_with('-') { - return Err(mkerr( - raw_extra, - format!("expected `-` after package name `{package}`"), - )); - } - let tail = &tail[1..]; match kind { - "extra" => Ok(ParsedRawExtra::Extra { - package, - extra: tail, - }), - "group" => Ok(ParsedRawExtra::Group { - package, - group: tail, - }), + "project" => Ok(ParsedRawExtra::Project { package }), + "extra" | "group" => { + if !tail.starts_with('-') { + return Err(mkerr( + raw_extra, + format!("expected `-` after package name `{package}`"), + )); + } + let tail = &tail[1..]; + if kind == "extra" { + Ok(ParsedRawExtra::Extra { + package, + extra: tail, + }) + } else { + Ok(ParsedRawExtra::Group { + package, + group: tail, + }) + } + } _ => Err(mkerr( raw_extra, format!("unrecognized kind `{kind}` (must be `extra` or `group`)"), @@ -608,6 +670,7 @@ impl<'a> ParsedRawExtra<'a> { } })?; match self { + Self::Project { .. } => Ok(ConflictItem::from(package)), Self::Extra { extra, .. } => { let extra = ExtraName::from_str(extra).map_err(|name_error| { ResolveError::InvalidValueInConflictMarker { @@ -631,6 +694,7 @@ impl<'a> ParsedRawExtra<'a> { fn package(&self) -> &'a str { match self { + Self::Project { package, .. } => package, Self::Extra { package, .. } => package, Self::Group { package, .. } => package, } @@ -719,6 +783,26 @@ pub(crate) fn resolve_conflicts( } } } + + // Search for the conflict item as a project. + if conflict_item.extra().is_none() && conflict_item.group().is_none() { + let package = conflict_item.package(); + let encoded = encode_project(package); + if encoded == *name { + match operator { + ExtraOperator::Equal => { + or.and(*conflict_marker); + found = true; + break; + } + ExtraOperator::NotEqual => { + or.and(conflict_marker.negate()); + found = true; + break; + } + } + } + } } // If we didn't find the marker in the list of known conflicts, assume it's always diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index 2f1b733d6..5641ade36 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -210,9 +210,6 @@ pub(crate) async fn export( Err(err) => return Err(err.into()), }; - // Validate that the set of requested extras and development groups are compatible. - detect_conflicts(&lock, &extras, &groups)?; - // Identify the installation target. let target = match &target { ExportTarget::Project(VirtualProject::Project(project)) => { @@ -262,6 +259,9 @@ pub(crate) async fn export( }, }; + // Validate that the set of requested extras and development groups are compatible. + detect_conflicts(&target, &extras, &groups)?; + // Validate that the set of requested extras and development groups are defined in the lockfile. target.validate_extras(&extras)?; target.validate_groups(&groups)?; diff --git a/crates/uv/src/commands/project/install_target.rs b/crates/uv/src/commands/project/install_target.rs index b0f20e76f..dc7f08658 100644 --- a/crates/uv/src/commands/project/install_target.rs +++ b/crates/uv/src/commands/project/install_target.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::path::Path; use std::str::FromStr; @@ -7,7 +8,7 @@ use rustc_hash::FxHashSet; use uv_configuration::{Constraints, DependencyGroupsWithDefaults, ExtrasSpecification}; use uv_distribution_types::Index; -use uv_normalize::PackageName; +use uv_normalize::{ExtraName, PackageName}; use uv_pypi_types::{DependencyGroupSpecifier, LenientRequirement, VerbatimParsedUrl}; use uv_resolver::{Installable, Lock, Package}; use uv_scripts::Pep723Script; @@ -369,4 +370,107 @@ impl<'lock> InstallTarget<'lock> { Ok(()) } + + /// Returns the names of all packages in the workspace that will be installed. + /// + /// Note this only includes workspace members. + pub(crate) fn packages( + &self, + extras: &ExtrasSpecification, + groups: &DependencyGroupsWithDefaults, + ) -> BTreeSet<&PackageName> { + match self { + Self::Project { name, lock, .. } => { + // Collect the packages by name for efficient lookup + let packages = lock + .packages() + .iter() + .map(|p| (p.name(), p)) + .collect::>(); + + // We'll include the project itself + let mut required_members = BTreeSet::new(); + required_members.insert(*name); + + // Find all workspace member dependencies recursively + let mut queue: VecDeque<(&PackageName, Option<&ExtraName>)> = VecDeque::new(); + let mut seen: FxHashSet<(&PackageName, Option<&ExtraName>)> = FxHashSet::default(); + + let Some(root_package) = packages.get(name) else { + return required_members; + }; + + if groups.prod() { + // Add the root package + queue.push_back((name, None)); + seen.insert((name, None)); + + // Add explicitly activated extras for the root package + for extra in extras.extra_names(root_package.optional_dependencies().keys()) { + if seen.insert((name, Some(extra))) { + queue.push_back((name, Some(extra))); + } + } + } + + // Add activated dependency groups for the root package + for (group_name, dependencies) in root_package.resolved_dependency_groups() { + if !groups.contains(group_name) { + continue; + } + for dependency in dependencies { + let name = dependency.package_name(); + queue.push_back((name, None)); + for extra in dependency.extra() { + queue.push_back((name, Some(extra))); + } + } + } + + while let Some((pkg_name, extra)) = queue.pop_front() { + if lock.members().contains(pkg_name) { + required_members.insert(pkg_name); + } + + let Some(package) = packages.get(pkg_name) else { + continue; + }; + + let Some(dependencies) = extra + .map(|extra_name| { + package + .optional_dependencies() + .get(extra_name) + .map(Vec::as_slice) + }) + .unwrap_or(Some(package.dependencies())) + else { + continue; + }; + + for dependency in dependencies { + let name = dependency.package_name(); + if seen.insert((name, None)) { + queue.push_back((name, None)); + } + for extra in dependency.extra() { + if seen.insert((name, Some(extra))) { + queue.push_back((name, Some(extra))); + } + } + } + } + + required_members + } + Self::Workspace { lock, .. } | Self::NonProjectWorkspace { lock, .. } => { + // Return all workspace members + lock.members().iter().collect() + } + Self::Script { .. } => { + // Scripts don't have workspace members + BTreeSet::new() + } + } + } } diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 0259b76d2..6c021bf14 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -24,7 +24,7 @@ use uv_distribution_types::{ use uv_git::ResolvedRepositoryReference; use uv_normalize::{GroupName, PackageName}; use uv_pep440::Version; -use uv_pypi_types::{Conflicts, SupportedEnvironments}; +use uv_pypi_types::{ConflictKind, Conflicts, SupportedEnvironments}; use uv_python::{Interpreter, PythonDownloads, PythonEnvironment, PythonPreference, PythonRequest}; use uv_requirements::ExtrasResolver; use uv_requirements::upgrade::{LockedRequirements, read_lock_requirements}; @@ -487,6 +487,19 @@ async fn do_lock( } } + // Check if any conflicts contain project-level conflicts + if !preview.is_enabled(PreviewFeatures::PACKAGE_CONFLICTS) + && conflicts.iter().any(|set| { + set.iter() + .any(|item| matches!(item.kind(), ConflictKind::Project)) + }) + { + warn_user_once!( + "Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", + PreviewFeatures::PACKAGE_CONFLICTS + ); + } + // Collect the list of supported environments. let environments = { let environments = target.environments(); diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index f67be45d6..279412ec4 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -27,7 +27,7 @@ use uv_installer::{SatisfiesResult, SitePackages}; use uv_normalize::{DEV_DEPENDENCIES, DefaultGroups, ExtraName, GroupName, PackageName}; use uv_pep440::{TildeVersionSpecifier, Version, VersionSpecifiers}; use uv_pep508::MarkerTreeContents; -use uv_pypi_types::{ConflictPackage, ConflictSet, Conflicts}; +use uv_pypi_types::{ConflictItem, ConflictKind, ConflictSet, Conflicts}; use uv_python::{ EnvironmentPreference, Interpreter, InvalidEnvironmentKind, PythonDownloads, PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, PythonSource, PythonVariant, @@ -36,8 +36,8 @@ use uv_python::{ use uv_requirements::upgrade::{LockedRequirements, read_lock_requirements}; use uv_requirements::{NamedRequirementsResolver, RequirementsSpecification}; use uv_resolver::{ - FlatIndex, Lock, OptionsBuilder, Preference, PythonRequirement, ResolverEnvironment, - ResolverOutput, + FlatIndex, Installable, Lock, OptionsBuilder, Preference, PythonRequirement, + ResolverEnvironment, ResolverOutput, }; use uv_scripts::Pep723ItemRef; use uv_settings::PythonInstallMirrors; @@ -52,6 +52,7 @@ use uv_workspace::{RequiresPythonSources, Workspace, WorkspaceCache}; use crate::commands::pip::loggers::{InstallLogger, ResolveLogger}; use crate::commands::pip::operations::{Changelog, Modifications}; +use crate::commands::project::install_target::InstallTarget; use crate::commands::reporters::{PythonDownloadReporter, ResolverReporter}; use crate::commands::{capitalize, conjunction, pip}; use crate::printer::Printer; @@ -274,7 +275,7 @@ pub(crate) struct ConflictError { /// The set from which the conflict was derived. pub(crate) set: ConflictSet, /// The items from the set that were enabled, and thus create the conflict. - pub(crate) conflicts: Vec, + pub(crate) conflicts: Vec, /// Enabled dependency groups with defaults applied. pub(crate) groups: DependencyGroupsWithDefaults, } @@ -285,9 +286,10 @@ impl std::fmt::Display for ConflictError { let set = self .set .iter() - .map(|item| match item.conflict() { - ConflictPackage::Extra(extra) => format!("`{}[{}]`", item.package(), extra), - ConflictPackage::Group(group) => format!("`{}:{}`", item.package(), group), + .map(|item| match item.kind() { + ConflictKind::Project => format!("{}", item.package()), + ConflictKind::Extra(extra) => format!("`{}[{}]`", item.package(), extra), + ConflictKind::Group(group) => format!("`{}:{}`", item.package(), group), }) .join(", "); @@ -295,7 +297,7 @@ impl std::fmt::Display for ConflictError { if self .conflicts .iter() - .all(|conflict| matches!(conflict, ConflictPackage::Extra(..))) + .all(|conflict| matches!(conflict.kind(), ConflictKind::Extra(..))) { write!( f, @@ -303,9 +305,9 @@ impl std::fmt::Display for ConflictError { conjunction( self.conflicts .iter() - .map(|conflict| match conflict { - ConflictPackage::Extra(extra) => format!("`{extra}`"), - ConflictPackage::Group(..) => unreachable!(), + .map(|conflict| match conflict.kind() { + ConflictKind::Extra(extra) => format!("`{extra}`"), + ConflictKind::Group(..) | ConflictKind::Project => unreachable!(), }) .collect() ) @@ -313,7 +315,7 @@ impl std::fmt::Display for ConflictError { } else if self .conflicts .iter() - .all(|conflict| matches!(conflict, ConflictPackage::Group(..))) + .all(|conflict| matches!(conflict.kind(), ConflictKind::Group(..))) { let conflict_source = if self.set.is_inferred_conflict() { "transitively inferred" @@ -326,12 +328,12 @@ impl std::fmt::Display for ConflictError { conjunction( self.conflicts .iter() - .map(|conflict| match conflict { - ConflictPackage::Group(group) + .map(|conflict| match conflict.kind() { + ConflictKind::Group(group) if self.groups.contains_because_default(group) => format!("`{group}` (enabled by default)"), - ConflictPackage::Group(group) => format!("`{group}`"), - ConflictPackage::Extra(..) => unreachable!(), + ConflictKind::Group(group) => format!("`{group}`"), + ConflictKind::Extra(..) | ConflictKind::Project => unreachable!(), }) .collect() ) @@ -345,14 +347,17 @@ impl std::fmt::Display for ConflictError { .iter() .enumerate() .map(|(i, conflict)| { - let conflict = match conflict { - ConflictPackage::Extra(extra) => format!("extra `{extra}`"), - ConflictPackage::Group(group) + let conflict = match conflict.kind() { + ConflictKind::Project => { + format!("package `{}`", conflict.package()) + } + ConflictKind::Extra(extra) => format!("extra `{extra}`"), + ConflictKind::Group(group) if self.groups.contains_because_default(group) => { format!("group `{group}` (enabled by default)") } - ConflictPackage::Group(group) => format!("group `{group}`"), + ConflictKind::Group(group) => format!("group `{group}`"), }; if i == 0 { capitalize(&conflict) @@ -2526,31 +2531,33 @@ pub(crate) fn default_dependency_groups( /// are declared as conflicting. #[allow(clippy::result_large_err)] pub(crate) fn detect_conflicts( - lock: &Lock, + target: &InstallTarget, extras: &ExtrasSpecification, groups: &DependencyGroupsWithDefaults, ) -> Result<(), ProjectError> { - // 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. + // 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 lock = target.lock(); + let packages = target.packages(extras, groups); let conflicts = lock.conflicts(); for set in conflicts.iter() { - let mut conflicts: Vec = vec![]; + 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 !packages.contains(item.package()) { + // Ignore items that are not in the install targets + continue; } - if item - .group() - .map(|group| groups.contains(group)) - .unwrap_or(false) - { - conflicts.push(item.conflict().clone()); + let is_conflicting = match item.kind() { + ConflictKind::Project => groups.prod(), + ConflictKind::Extra(extra) => extras.contains(extra), + ConflictKind::Group(group1) => groups.contains(group1), + }; + if is_conflicting { + conflicts.push(item.clone()); } } if conflicts.len() >= 2 { diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 416736bf1..1c6242a61 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -663,7 +663,7 @@ pub(super) async fn do_sync( } // Validate that the set of requested extras and development groups are compatible. - detect_conflicts(target.lock(), extras, groups)?; + detect_conflicts(&target, extras, groups)?; // Validate that the set of requested extras and development groups are defined in the lockfile. target.validate_extras(extras)?; diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 4c7ea7451..c85f7e673 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -2703,6 +2703,1340 @@ fn lock_dependency_non_existent_extra() -> Result<()> { Ok(()) } +/// This tests a "basic" case for specifying a group that conflicts with the +/// project itself. +#[test] +fn lock_conflicting_project_basic1() -> Result<()> { + let context = TestContext::new("3.12"); + + // First we test that resolving a group with a dependency that conflicts + // with the project fails. + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + description = "Add your description here" + requires-python = ">=3.12" + dependencies = ["sortedcontainers==2.3.0"] + + [dependency-groups] + foo = ["sortedcontainers==2.4.0"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving dependencies: + ╰─▶ Because your project depends on sortedcontainers==2.3.0 and project:foo depends on sortedcontainers==2.4.0, we can conclude that your project and project:foo are incompatible. + And because your project requires your project and project:foo, we can conclude that your project's requirements are unsatisfiable. + "); + + // And now with the same group configuration, we tell uv about the + // conflicts, which forces it to resolve each in their own fork. + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + description = "Add your description here" + requires-python = ">=3.12" + dependencies = ["sortedcontainers==2.3.0"] + + [tool.uv] + conflicts = [ + [ + { group = "foo" }, + { package = "project" }, + ], + ] + + [dependency-groups] + foo = ["sortedcontainers==2.4.0"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning. + Resolved 3 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + conflicts = [[ + { package = "project", group = "foo" }, + { package = "project" }, + ]] + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "project" + version = "0.1.0" + source = { editable = "." } + dependencies = [ + { name = "sortedcontainers", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'project-7-project'" }, + ] + + [package.dev-dependencies] + foo = [ + { name = "sortedcontainers", version = "2.4.0", source = { registry = "https://pypi.org/simple" } }, + ] + + [package.metadata] + requires-dist = [{ name = "sortedcontainers", specifier = "==2.3.0" }] + + [package.metadata.requires-dev] + foo = [{ name = "sortedcontainers", specifier = "==2.4.0" }] + + [[package]] + name = "sortedcontainers" + version = "2.3.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/14/10/6a9481890bae97da9edd6e737c9c3dec6aea3fc2fa53b0934037b35c89ea/sortedcontainers-2.3.0.tar.gz", hash = "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1", size = 30509, upload-time = "2020-11-09T00:03:52.258Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/20/4d/a7046ae1a1a4cc4e9bbed194c387086f06b25038be596543d026946330c9/sortedcontainers-2.3.0-py2.py3-none-any.whl", hash = "sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f", size = 29479, upload-time = "2020-11-09T00:03:50.723Z" }, + ] + + [[package]] + name = "sortedcontainers" + version = "2.4.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, + ] + "# + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning. + Resolved 3 packages in [TIME] + "); + + // Install from the lockfile. + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + project==0.1.0 (from file://[TEMP_DIR]/) + + sortedcontainers==2.3.0 + "###); + + // Another install, but with the group enabled, which + // should fail because it conflicts with the project. + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--group=foo"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Group `foo` and package `project` are incompatible with the declared conflicts: {`project:foo`, project} + "); + // Another install, but this time with `--only-group=foo`, + // which excludes the project and is thus okay. + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--only-group=foo"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Uninstalled 2 packages in [TIME] + Installed 1 package in [TIME] + - project==0.1.0 (from file://[TEMP_DIR]/) + - sortedcontainers==2.3.0 + + sortedcontainers==2.4.0 + "###); + + Ok(()) +} + +/// This tests a case where workspace members conflict with each other. +#[test] +fn lock_conflicting_workspace_members() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "example" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["sortedcontainers==2.3.0"] + + [tool.uv.workspace] + members = ["subexample"] + + [tool.uv] + conflicts = [ + [ + { package = "example" }, + { package = "subexample" }, + ], + ] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + + [tool.setuptools.packages.find] + include = ["example"] + "#, + )?; + + // Create the subproject + let subproject_dir = context.temp_dir.child("subexample"); + subproject_dir.create_dir_all()?; + + let sub_pyproject_toml = subproject_dir.child("pyproject.toml"); + sub_pyproject_toml.write_str( + r#" + [project] + name = "subexample" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["sortedcontainers==2.4.0"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + // Lock should succeed because we declared the conflict + uv_snapshot!(context.filters(), context.lock(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning. + Resolved 4 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + conflicts = [[ + { package = "example" }, + { package = "subexample" }, + ]] + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [manifest] + members = [ + "example", + "subexample", + ] + + [[package]] + name = "example" + version = "0.1.0" + source = { editable = "." } + dependencies = [ + { name = "sortedcontainers", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'project-7-example'" }, + ] + + [package.metadata] + requires-dist = [{ name = "sortedcontainers", specifier = "==2.3.0" }] + + [[package]] + name = "sortedcontainers" + version = "2.3.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/14/10/6a9481890bae97da9edd6e737c9c3dec6aea3fc2fa53b0934037b35c89ea/sortedcontainers-2.3.0.tar.gz", hash = "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1", size = 30509, upload-time = "2020-11-09T00:03:52.258Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/20/4d/a7046ae1a1a4cc4e9bbed194c387086f06b25038be596543d026946330c9/sortedcontainers-2.3.0-py2.py3-none-any.whl", hash = "sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f", size = 29479, upload-time = "2020-11-09T00:03:50.723Z" }, + ] + + [[package]] + name = "sortedcontainers" + version = "2.4.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, + ] + + [[package]] + name = "subexample" + version = "0.1.0" + source = { editable = "subexample" } + dependencies = [ + { name = "sortedcontainers", version = "2.4.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'project-10-subexample'" }, + ] + + [package.metadata] + requires-dist = [{ name = "sortedcontainers", specifier = "==2.4.0" }] + "# + ); + }); + + // Install from the lockfile + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + example==0.1.0 (from file://[TEMP_DIR]/) + + sortedcontainers==2.3.0 + "); + + // Install subexample without the root + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--package").arg("subexample"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 2 packages in [TIME] + Uninstalled 2 packages in [TIME] + Installed 2 packages in [TIME] + - example==0.1.0 (from file://[TEMP_DIR]/) + - sortedcontainers==2.3.0 + + sortedcontainers==2.4.0 + + subexample==0.1.0 (from file://[TEMP_DIR]/subexample) + "); + + // Attempt to install them together, i.e., with `--all-packages` + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--all-packages"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Package `example` and package `subexample` are incompatible with the declared conflicts: {example, subexample} + "); + + Ok(()) +} + +/// Like [`lock_conflicting_workspace_members`], but the root project depends on the conflicting +/// workspace member +#[test] +fn lock_conflicting_workspace_members_depends_direct() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "example" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["sortedcontainers==2.3.0", "subexample"] + + [tool.uv.workspace] + members = ["subexample"] + + [tool.uv] + conflicts = [ + [ + { package = "example" }, + { package = "subexample" }, + ], + ] + + [tool.uv.sources] + subexample = { workspace = true } + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + + [tool.setuptools.packages.find] + include = ["example"] + "#, + )?; + + // Create the subproject + let subproject_dir = context.temp_dir.child("subexample"); + subproject_dir.create_dir_all()?; + + let sub_pyproject_toml = subproject_dir.child("pyproject.toml"); + sub_pyproject_toml.write_str( + r#" + [project] + name = "subexample" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["sortedcontainers==2.4.0"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + // This should fail to resolve, because these conflict + uv_snapshot!(context.filters(), context.lock(), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning. + × No solution found when resolving dependencies for split (included: example; excluded: subexample): + ╰─▶ Because subexample depends on sortedcontainers==2.4.0 and example depends on sortedcontainers==2.3.0, we can conclude that example and subexample are incompatible. + And because example depends on subexample and your workspace requires example, we can conclude that your workspace's requirements are unsatisfiable. + "); + + Ok(()) +} + +/// Like [`lock_conflicting_workspace_members_depends_direct`], but the root project depends on the +/// conflicting workspace member via a direct optional dependency. +#[test] +fn lock_conflicting_workspace_members_depends_direct_extra() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "example" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["sortedcontainers==2.3.0"] + + [project.optional-dependencies] + foo = ["subexample"] + + [tool.uv.workspace] + members = ["subexample"] + + [tool.uv] + conflicts = [ + [ + { package = "example" }, + # TODO(zanieb): Technically, we shouldn't need to include the extra in the list of + # conflicts however, the resolver forking algorithm is not currently sophisticated + # enough to pick this up by itself + { package = "example", extra = "foo"}, + { package = "subexample" }, + ], + ] + + [tool.uv.sources] + subexample = { workspace = true } + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + + [tool.setuptools.packages.find] + include = ["example"] + "#, + )?; + + // Create the subproject + let subproject_dir = context.temp_dir.child("subexample"); + subproject_dir.create_dir_all()?; + + let sub_pyproject_toml = subproject_dir.child("pyproject.toml"); + sub_pyproject_toml.write_str( + r#" + [project] + name = "subexample" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["sortedcontainers==2.4.0"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + // This should succeed, because the conflict is optional + uv_snapshot!(context.filters(), context.lock(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning. + Resolved 4 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + conflicts = [[ + { package = "example", extra = "foo" }, + { package = "example" }, + { package = "subexample" }, + ]] + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [manifest] + members = [ + "example", + "subexample", + ] + + [[package]] + name = "example" + version = "0.1.0" + source = { editable = "." } + dependencies = [ + { name = "sortedcontainers", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-7-example-foo' or extra == 'project-7-example'" }, + ] + + [package.metadata] + requires-dist = [ + { name = "sortedcontainers", specifier = "==2.3.0" }, + { name = "subexample", marker = "extra == 'foo'", editable = "subexample" }, + ] + provides-extras = ["foo"] + + [[package]] + name = "sortedcontainers" + version = "2.3.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/14/10/6a9481890bae97da9edd6e737c9c3dec6aea3fc2fa53b0934037b35c89ea/sortedcontainers-2.3.0.tar.gz", hash = "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1", size = 30509, upload-time = "2020-11-09T00:03:52.258Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/20/4d/a7046ae1a1a4cc4e9bbed194c387086f06b25038be596543d026946330c9/sortedcontainers-2.3.0-py2.py3-none-any.whl", hash = "sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f", size = 29479, upload-time = "2020-11-09T00:03:50.723Z" }, + ] + + [[package]] + name = "sortedcontainers" + version = "2.4.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, + ] + + [[package]] + name = "subexample" + version = "0.1.0" + source = { editable = "subexample" } + dependencies = [ + { name = "sortedcontainers", version = "2.4.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'project-10-subexample' or (extra == 'extra-7-example-foo' and extra == 'project-7-example')" }, + ] + + [package.metadata] + requires-dist = [{ name = "sortedcontainers", specifier = "==2.4.0" }] + "# + ); + }); + + // Install from the lockfile + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + example==0.1.0 (from file://[TEMP_DIR]/) + + sortedcontainers==2.3.0 + "); + + // Attempt to install with the extra selected + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra").arg("foo"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Extra `foo` and package `example` are incompatible with the declared conflicts: {`example[foo]`, example, subexample} + "); + + // Install just the child package + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--package").arg("subexample"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 2 packages in [TIME] + Uninstalled 2 packages in [TIME] + Installed 2 packages in [TIME] + - example==0.1.0 (from file://[TEMP_DIR]/) + - sortedcontainers==2.3.0 + + sortedcontainers==2.4.0 + + subexample==0.1.0 (from file://[TEMP_DIR]/subexample) + "); + + // Install with just development dependencies + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--only-dev"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Uninstalled 2 packages in [TIME] + - sortedcontainers==2.4.0 + - subexample==0.1.0 (from file://[TEMP_DIR]/subexample) + "); + + Ok(()) +} + +/// Like [`lock_conflicting_workspace_members_depends_direct`], but the dependency is through an +/// intermediate package without conflict. +#[test] +fn lock_conflicting_workspace_members_depends_transitive() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "example" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["sortedcontainers==2.3.0", "indirection"] + + [tool.uv.workspace] + members = ["subexample", "indirection"] + + [tool.uv] + conflicts = [ + [ + { package = "example" }, + { package = "subexample" }, + ], + ] + + [tool.uv.sources] + subexample = { workspace = true } + indirection = { workspace = true } + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + + [tool.setuptools.packages.find] + include = ["example"] + "#, + )?; + + // Create the indirection subproject + let subproject_dir = context.temp_dir.child("indirection"); + subproject_dir.create_dir_all()?; + + let sub_pyproject_toml = subproject_dir.child("pyproject.toml"); + sub_pyproject_toml.write_str( + r#" + [project] + name = "indirection" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["subexample"] + + [tool.uv.sources] + subexample = { workspace = true } + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + // Create the incompatible subproject + let subproject_dir = context.temp_dir.child("subexample"); + subproject_dir.create_dir_all()?; + + let sub_pyproject_toml = subproject_dir.child("pyproject.toml"); + sub_pyproject_toml.write_str( + r#" + [project] + name = "subexample" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["sortedcontainers==2.4.0"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + // This should fail to resolve, because these conflict + uv_snapshot!(context.filters(), context.lock(), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning. + × No solution found when resolving dependencies for split (included: example; excluded: subexample): + ╰─▶ Because subexample depends on sortedcontainers==2.4.0 and indirection depends on subexample, we can conclude that indirection depends on sortedcontainers==2.4.0. + And because example depends on sortedcontainers==2.3.0, we can conclude that example and indirection are incompatible. + And because your workspace requires example and indirection, we can conclude that your workspace's requirements are unsatisfiable. + "); + + Ok(()) +} + +/// Like [`lock_conflicting_workspace_members_depends_transitive`], but the dependency is through an +/// intermediate package without conflict. +#[test] +fn lock_conflicting_workspace_members_depends_transitive_extra() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "example" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["sortedcontainers==2.3.0", "indirection[foo]"] + + [tool.uv.workspace] + members = ["subexample", "indirection"] + + [tool.uv] + conflicts = [ + [ + { package = "example" }, + { package = "subexample" }, + ], + ] + + [tool.uv.sources] + subexample = { workspace = true } + indirection = { workspace = true } + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + + [tool.setuptools.packages.find] + include = ["example"] + "#, + )?; + + // Create the indirection subproject + let subproject_dir = context.temp_dir.child("indirection"); + subproject_dir.create_dir_all()?; + + let sub_pyproject_toml = subproject_dir.child("pyproject.toml"); + sub_pyproject_toml.write_str( + r#" + [project] + name = "indirection" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [project.optional-dependencies] + foo = ["subexample"] + + [tool.uv.sources] + subexample = { workspace = true } + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + // Create the incompatible subproject + let subproject_dir = context.temp_dir.child("subexample"); + subproject_dir.create_dir_all()?; + + let sub_pyproject_toml = subproject_dir.child("pyproject.toml"); + sub_pyproject_toml.write_str( + r#" + [project] + name = "subexample" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["sortedcontainers==2.4.0"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + // This succeeds, but should fail. We have an unconditional conflict via `example -> + // indirection[foo] -> subexample`, so `example` is unusable. + uv_snapshot!(context.filters(), context.lock(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning. + Resolved 5 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + conflicts = [[ + { package = "example" }, + { package = "subexample" }, + ]] + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [manifest] + members = [ + "example", + "indirection", + "subexample", + ] + + [[package]] + name = "example" + version = "0.1.0" + source = { editable = "." } + dependencies = [ + { name = "indirection", extra = ["foo"], marker = "extra == 'project-7-example'" }, + { name = "sortedcontainers", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'project-7-example'" }, + ] + + [package.metadata] + requires-dist = [ + { name = "indirection", extras = ["foo"], editable = "indirection" }, + { name = "sortedcontainers", specifier = "==2.3.0" }, + ] + + [[package]] + name = "indirection" + version = "0.1.0" + source = { editable = "indirection" } + + [package.optional-dependencies] + foo = [ + { name = "subexample", marker = "extra == 'project-10-subexample'" }, + ] + + [package.metadata] + requires-dist = [{ name = "subexample", marker = "extra == 'foo'", editable = "subexample" }] + provides-extras = ["foo"] + + [[package]] + name = "sortedcontainers" + version = "2.3.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/14/10/6a9481890bae97da9edd6e737c9c3dec6aea3fc2fa53b0934037b35c89ea/sortedcontainers-2.3.0.tar.gz", hash = "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1", size = 30509, upload-time = "2020-11-09T00:03:52.258Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/20/4d/a7046ae1a1a4cc4e9bbed194c387086f06b25038be596543d026946330c9/sortedcontainers-2.3.0-py2.py3-none-any.whl", hash = "sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f", size = 29479, upload-time = "2020-11-09T00:03:50.723Z" }, + ] + + [[package]] + name = "sortedcontainers" + version = "2.4.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, + ] + + [[package]] + name = "subexample" + version = "0.1.0" + source = { editable = "subexample" } + dependencies = [ + { name = "sortedcontainers", version = "2.4.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'project-10-subexample'" }, + ] + + [package.metadata] + requires-dist = [{ name = "sortedcontainers", specifier = "==2.4.0" }] + "# + ); + }); + + // Install from the lockfile + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Package `example` and package `subexample` are incompatible with the declared conflicts: {example, subexample} + "); + + // Install with `--only-dev` + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--only-dev"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Audited in [TIME] + "); + + // Install just the child package + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--package").arg("subexample"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + sortedcontainers==2.4.0 + + subexample==0.1.0 (from file://[TEMP_DIR]/subexample) + "); + + Ok(()) +} + +/// This tests another "basic" case for specifying a group that conflicts with +/// the project itself. +#[test] +fn lock_conflicting_project_basic2() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "example" + version = "0.1.0" + description = "Add your description here" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [ + "anyio>=4.2.0", + ] + + [dependency-groups] + foo = [ + "anyio<4.2.0", + ] + + [tool.uv] + conflicts = [ + [ + { group = "foo" }, + { package = "example" }, + ], + ] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning. + Resolved 5 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + conflicts = [[ + { package = "example", group = "foo" }, + { package = "example" }, + ]] + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "anyio" + version = "4.1.0" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/6e/57/075e07fb01ae2b740289ec9daec670f60c06f62d04b23a68077fd5d73fab/anyio-4.1.0.tar.gz", hash = "sha256:5a0bec7085176715be77df87fc66d6c9d70626bd752fcc85f57cdbee5b3760da", size = 155773, upload-time = "2023-11-22T23:23:54.066Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/85/4f/d010eca6914703d8e6be222165d02c3e708ed909cdb2b7af3743667f302e/anyio-4.1.0-py3-none-any.whl", hash = "sha256:56a415fbc462291813a94528a779597226619c8e78af7de0507333f700011e5f", size = 83924, upload-time = "2023-11-22T23:23:52.595Z" }, + ] + + [[package]] + name = "anyio" + version = "4.3.0" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "idna", marker = "extra == 'project-7-example'" }, + { name = "sniffio", marker = "extra == 'project-7-example'" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6", size = 159642, upload-time = "2024-02-19T08:36:28.641Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8", size = 85584, upload-time = "2024-02-19T08:36:26.842Z" }, + ] + + [[package]] + name = "example" + version = "0.1.0" + source = { editable = "." } + dependencies = [ + { name = "anyio", version = "4.3.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'project-7-example'" }, + ] + + [package.dev-dependencies] + foo = [ + { name = "anyio", version = "4.1.0", source = { registry = "https://pypi.org/simple" } }, + ] + + [package.metadata] + requires-dist = [{ name = "anyio", specifier = ">=4.2.0" }] + + [package.metadata.requires-dev] + foo = [{ name = "anyio", specifier = "<4.2.0" }] + + [[package]] + name = "idna" + version = "3.6" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426, upload-time = "2023-11-25T15:40:54.902Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567, upload-time = "2023-11-25T15:40:52.604Z" }, + ] + + [[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, upload-time = "2024-02-25T23:20:04.057Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, + ] + "# + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning. + Resolved 5 packages in [TIME] + "); + + // Install from the lockfile. + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==4.3.0 + + example==0.1.0 (from file://[TEMP_DIR]/) + + idna==3.6 + + sniffio==1.3.1 + "); + // Another install, but with the group enabled, which + // should fail because it conflicts with the project. + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--group=foo"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Group `foo` and package `example` are incompatible with the declared conflicts: {`example:foo`, example} + "); + // Another install, but this time with `--only-group=foo`, + // which excludes the project and is thus okay. + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--only-group=foo"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Uninstalled 2 packages in [TIME] + Installed 1 package in [TIME] + - anyio==4.3.0 + + anyio==4.1.0 + - example==0.1.0 (from file://[TEMP_DIR]/) + "); + + Ok(()) +} + +/// This tests a case where we declare an extra and a group as conflicting. +#[test] +fn lock_conflicting_mixed() -> Result<()> { + let context = TestContext::new("3.12"); + + // First we test that resolving with a conflicting extra + // and group fails. + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + description = "Add your description here" + requires-python = ">=3.12" + + [dependency-groups] + project1 = ["sortedcontainers==2.3.0"] + + [project.optional-dependencies] + project2 = ["sortedcontainers==2.4.0"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving dependencies: + ╰─▶ Because project:project1 depends on sortedcontainers==2.3.0 and project[project2] depends on sortedcontainers==2.4.0, we can conclude that project:project1 and project[project2] are incompatible. + And because your project requires project[project2] and project:project1, we can conclude that your project's requirements are unsatisfiable. + "); + + // And now with the same extra/group configuration, we tell uv + // about the conflicting groups, which forces it to resolve each in + // their own fork. + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + description = "Add your description here" + requires-python = ">=3.12" + + [tool.uv] + conflicts = [ + [ + { group = "project1" }, + { extra = "project2" }, + ], + ] + + [dependency-groups] + project1 = ["sortedcontainers==2.3.0"] + + [project.optional-dependencies] + project2 = ["sortedcontainers==2.4.0"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "###); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + conflicts = [[ + { package = "project", extra = "project2" }, + { package = "project", group = "project1" }, + ]] + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "project" + version = "0.1.0" + source = { editable = "." } + + [package.optional-dependencies] + project2 = [ + { name = "sortedcontainers", version = "2.4.0", source = { registry = "https://pypi.org/simple" } }, + ] + + [package.dev-dependencies] + project1 = [ + { name = "sortedcontainers", version = "2.3.0", source = { registry = "https://pypi.org/simple" } }, + ] + + [package.metadata] + requires-dist = [{ name = "sortedcontainers", marker = "extra == 'project2'", specifier = "==2.4.0" }] + provides-extras = ["project2"] + + [package.metadata.requires-dev] + project1 = [{ name = "sortedcontainers", specifier = "==2.3.0" }] + + [[package]] + name = "sortedcontainers" + version = "2.3.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/14/10/6a9481890bae97da9edd6e737c9c3dec6aea3fc2fa53b0934037b35c89ea/sortedcontainers-2.3.0.tar.gz", hash = "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1", size = 30509, upload-time = "2020-11-09T00:03:52.258Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/20/4d/a7046ae1a1a4cc4e9bbed194c387086f06b25038be596543d026946330c9/sortedcontainers-2.3.0-py2.py3-none-any.whl", hash = "sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f", size = 29479, upload-time = "2020-11-09T00:03:50.723Z" }, + ] + + [[package]] + name = "sortedcontainers" + version = "2.4.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, + ] + "# + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "###); + + // Install from the lockfile. + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + project==0.1.0 (from file://[TEMP_DIR]/) + "###); + // Another install, but with the group enabled. + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--group=project1"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + sortedcontainers==2.3.0 + "###); + // Another install, but with the extra enabled. + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra=project2"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + - sortedcontainers==2.3.0 + + sortedcontainers==2.4.0 + "###); + // And finally, installing both the group and the extra should fail. + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--group=project1").arg("--extra=project2"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Extra `project2` and group `project1` are incompatible with the declared conflicts: {`project[project2]`, `project:project1`} + "); + + Ok(()) +} + /// Show updated dependencies on `lock --upgrade`. #[test] fn lock_upgrade_log() -> Result<()> { @@ -24979,7 +26313,7 @@ fn lock_self_incompatible() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.lock(), @r###" + uv_snapshot!(context.filters(), context.lock(), @r" success: false exit_code: 1 ----- stdout ----- @@ -24989,7 +26323,7 @@ fn lock_self_incompatible() -> Result<()> { ╰─▶ Because your project depends on itself at an incompatible version (project==0.2.0), we can conclude that your project's requirements are unsatisfiable. hint: The project `project` depends on itself at an incompatible version. This is likely a mistake. If you intended to depend on a third-party package named `project`, consider renaming the project `project` to avoid creating a conflict. - "###); + "); Ok(()) } @@ -25116,7 +26450,7 @@ fn lock_self_extra_to_same_extra_incompatible() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.lock(), @r###" + uv_snapshot!(context.filters(), context.lock(), @r" success: false exit_code: 1 ----- stdout ----- @@ -25126,7 +26460,7 @@ fn lock_self_extra_to_same_extra_incompatible() -> Result<()> { ╰─▶ Because project[foo] depends on itself at an incompatible version (project==0.2.0) and your project requires project[foo], we can conclude that your project's requirements are unsatisfiable. hint: The project `project` depends on itself at an incompatible version. This is likely a mistake. If you intended to depend on a third-party package named `project`, consider renaming the project `project` to avoid creating a conflict. - "###); + "); Ok(()) } @@ -25150,7 +26484,7 @@ fn lock_self_extra_to_other_extra_incompatible() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.lock(), @r###" + uv_snapshot!(context.filters(), context.lock(), @r" success: false exit_code: 1 ----- stdout ----- @@ -25160,7 +26494,7 @@ fn lock_self_extra_to_other_extra_incompatible() -> Result<()> { ╰─▶ Because project[foo] depends on itself at an incompatible version (project==0.2.0) and your project requires project[foo], we can conclude that your project's requirements are unsatisfiable. hint: The project `project` depends on itself at an incompatible version. This is likely a mistake. If you intended to depend on a third-party package named `project`, consider renaming the project `project` to avoid creating a conflict. - "###); + "); Ok(()) } @@ -25287,7 +26621,7 @@ fn lock_self_extra_incompatible() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.lock(), @r###" + uv_snapshot!(context.filters(), context.lock(), @r" success: false exit_code: 1 ----- stdout ----- @@ -25297,7 +26631,7 @@ fn lock_self_extra_incompatible() -> Result<()> { ╰─▶ Because project[foo] depends on itself at an incompatible version (project==0.2.0) and your project requires project[foo], we can conclude that your project's requirements are unsatisfiable. hint: The project `project` depends on itself at an incompatible version. This is likely a mistake. If you intended to depend on a third-party package named `project`, consider renaming the project `project` to avoid creating a conflict. - "###); + "); Ok(()) } diff --git a/crates/uv/tests/it/lock_conflict.rs b/crates/uv/tests/it/lock_conflict.rs index 5305289c4..e313410a5 100644 --- a/crates/uv/tests/it/lock_conflict.rs +++ b/crates/uv/tests/it/lock_conflict.rs @@ -1049,24 +1049,24 @@ fn extra_unconditional() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.lock(), @r###" + uv_snapshot!(context.filters(), context.lock(), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Resolved 6 packages in [TIME] - "###); + "); // This should error since we're enabling two conflicting extras. - uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###" + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Found conflicting extras `proxy1[extra1]` and `proxy1[extra2]` enabled simultaneously - "###); + "); root_pyproject_toml.write_str( r#" @@ -1085,14 +1085,14 @@ fn extra_unconditional() -> Result<()> { proxy1 = { workspace = true } "#, )?; - uv_snapshot!(context.filters(), context.lock(), @r###" + uv_snapshot!(context.filters(), context.lock(), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Resolved 6 packages in [TIME] - "###); + "); // This is fine because we are only enabling one // extra, and thus, there is no conflict. uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r" @@ -1127,17 +1127,17 @@ fn extra_unconditional() -> Result<()> { proxy1 = { workspace = true } "#, )?; - uv_snapshot!(context.filters(), context.lock(), @r###" + uv_snapshot!(context.filters(), context.lock(), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Resolved 6 packages in [TIME] - "###); + "); // This is fine because we are only enabling one // extra, and thus, there is no conflict. - uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###" + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r" success: true exit_code: 0 ----- stdout ----- @@ -1148,7 +1148,7 @@ fn extra_unconditional() -> Result<()> { Installed 1 package in [TIME] - anyio==4.1.0 + anyio==4.2.0 - "###); + "); Ok(()) } @@ -1203,14 +1203,14 @@ fn extra_unconditional_non_conflicting() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.lock(), @r###" + uv_snapshot!(context.filters(), context.lock(), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Resolved 5 packages in [TIME] - "###); + "); // This *should* install `anyio==4.1.0`, but when this // test was initially written, it didn't. This was because @@ -1426,26 +1426,26 @@ fn extra_unconditional_non_local_conflict() -> Result<()> { // that can never be installed! Namely, because two different // conflicting extras are enabled unconditionally in all // configurations. - uv_snapshot!(context.filters(), context.lock(), @r###" + uv_snapshot!(context.filters(), context.lock(), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Resolved 6 packages in [TIME] - "###); + "); // This should fail. If it doesn't and we generated a lock // file above, then this will likely result in the installation // of two different versions of the same package. - uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###" + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Found conflicting extras `c[x1]` and `c[x2]` enabled simultaneously - "###); + "); Ok(()) } @@ -1955,14 +1955,14 @@ fn group_basic() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.lock(), @r###" + uv_snapshot!(context.filters(), context.lock(), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Resolved 3 packages in [TIME] - "###); + "); let lock = context.read("uv.lock"); @@ -2110,14 +2110,14 @@ fn group_default() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.lock(), @r###" + uv_snapshot!(context.filters(), context.lock(), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Resolved 3 packages in [TIME] - "###); + "); let lock = context.read("uv.lock"); @@ -2642,14 +2642,14 @@ fn multiple_sources_index_disjoint_groups() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.lock(), @r###" + uv_snapshot!(context.filters(), context.lock(), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Resolved 4 packages in [TIME] - "###); + "); let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); @@ -3125,7 +3125,7 @@ fn non_optional_dependency_extra() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.sync(), @r###" + uv_snapshot!(context.filters(), context.sync(), @r" success: true exit_code: 0 ----- stdout ----- @@ -3135,7 +3135,7 @@ fn non_optional_dependency_extra() -> Result<()> { Prepared 1 package in [TIME] Installed 1 package in [TIME] + sniffio==1.3.1 - "###); + "); Ok(()) } @@ -3172,7 +3172,7 @@ fn non_optional_dependency_group() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.sync(), @r###" + uv_snapshot!(context.filters(), context.sync(), @r" success: true exit_code: 0 ----- stdout ----- @@ -3182,7 +3182,7 @@ fn non_optional_dependency_group() -> Result<()> { Prepared 1 package in [TIME] Installed 1 package in [TIME] + sniffio==1.3.1 - "###); + "); Ok(()) } @@ -3222,7 +3222,7 @@ fn non_optional_dependency_mixed() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.sync(), @r###" + uv_snapshot!(context.filters(), context.sync(), @r" success: true exit_code: 0 ----- stdout ----- @@ -3232,7 +3232,7 @@ fn non_optional_dependency_mixed() -> Result<()> { Prepared 1 package in [TIME] Installed 1 package in [TIME] + sniffio==1.3.1 - "###); + "); Ok(()) } @@ -3422,7 +3422,7 @@ fn shared_optional_dependency_group1() -> Result<()> { )?; // This shouldn't install two versions of `idna`, only one, `idna==3.5`. - uv_snapshot!(context.filters(), context.sync().arg("--group=baz").arg("--group=foo"), @r###" + uv_snapshot!(context.filters(), context.sync().arg("--group=baz").arg("--group=foo"), @r" success: true exit_code: 0 ----- stdout ----- @@ -3434,7 +3434,7 @@ fn shared_optional_dependency_group1() -> Result<()> { + anyio==4.3.0 + idna==3.5 + sniffio==1.3.1 - "###); + "); let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); insta::with_settings!({ @@ -3849,7 +3849,7 @@ fn shared_optional_dependency_group2() -> Result<()> { )?; // This shouldn't install two versions of `idna`, only one, `idna==3.5`. - uv_snapshot!(context.filters(), context.sync().arg("--group=bar"), @r###" + uv_snapshot!(context.filters(), context.sync().arg("--group=bar"), @r" success: true exit_code: 0 ----- stdout ----- @@ -3861,7 +3861,7 @@ fn shared_optional_dependency_group2() -> Result<()> { + anyio==4.3.0 + idna==3.6 + sniffio==1.3.1 - "###); + "); let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); insta::with_settings!({ @@ -4139,7 +4139,7 @@ fn shared_dependency_extra() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.sync(), @r###" + uv_snapshot!(context.filters(), context.sync(), @r" success: true exit_code: 0 ----- stdout ----- @@ -4151,7 +4151,7 @@ fn shared_dependency_extra() -> Result<()> { + anyio==4.3.0 + idna==3.6 + sniffio==1.3.1 - "###); + "); let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); insta::with_settings!({ @@ -4240,7 +4240,7 @@ fn shared_dependency_extra() -> Result<()> { // This shouldn't install two versions of `idna`, only one, `idna==3.5`. // So this should remove `idna==3.6` installed above. - uv_snapshot!(context.filters(), context.sync().arg("--extra=foo"), @r###" + uv_snapshot!(context.filters(), context.sync().arg("--extra=foo"), @r" success: true exit_code: 0 ----- stdout ----- @@ -4252,9 +4252,9 @@ fn shared_dependency_extra() -> Result<()> { Installed 1 package in [TIME] - idna==3.6 + idna==3.5 - "###); + "); - uv_snapshot!(context.filters(), context.sync().arg("--extra=bar"), @r###" + uv_snapshot!(context.filters(), context.sync().arg("--extra=bar"), @r" success: true exit_code: 0 ----- stdout ----- @@ -4265,9 +4265,9 @@ fn shared_dependency_extra() -> Result<()> { Installed 1 package in [TIME] - idna==3.5 + idna==3.6 - "###); + "); - uv_snapshot!(context.filters(), context.sync(), @r###" + uv_snapshot!(context.filters(), context.sync(), @r" success: true exit_code: 0 ----- stdout ----- @@ -4275,7 +4275,7 @@ fn shared_dependency_extra() -> Result<()> { ----- stderr ----- Resolved 5 packages in [TIME] Audited 3 packages in [TIME] - "###); + "); Ok(()) } @@ -4314,7 +4314,7 @@ fn shared_dependency_group() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.sync(), @r###" + uv_snapshot!(context.filters(), context.sync(), @r" success: true exit_code: 0 ----- stdout ----- @@ -4326,7 +4326,7 @@ fn shared_dependency_group() -> Result<()> { + anyio==4.3.0 + idna==3.6 + sniffio==1.3.1 - "###); + "); let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); insta::with_settings!({ @@ -4490,7 +4490,7 @@ fn shared_dependency_mixed() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.sync(), @r###" + uv_snapshot!(context.filters(), context.sync(), @r" success: true exit_code: 0 ----- stdout ----- @@ -4502,7 +4502,7 @@ fn shared_dependency_mixed() -> Result<()> { + anyio==4.3.0 + idna==3.6 + sniffio==1.3.1 - "###); + "); let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); insta::with_settings!({ @@ -4595,7 +4595,7 @@ fn shared_dependency_mixed() -> Result<()> { // This shouldn't install two versions of `idna`, only one, `idna==3.5`. // So this should remove `idna==3.6` installed above. - uv_snapshot!(context.filters(), context.sync().arg("--extra=foo"), @r###" + uv_snapshot!(context.filters(), context.sync().arg("--extra=foo"), @r" success: true exit_code: 0 ----- stdout ----- @@ -4607,9 +4607,9 @@ fn shared_dependency_mixed() -> Result<()> { Installed 1 package in [TIME] - idna==3.6 + idna==3.5 - "###); + "); - uv_snapshot!(context.filters(), context.sync().arg("--group=bar"), @r###" + uv_snapshot!(context.filters(), context.sync().arg("--group=bar"), @r" success: true exit_code: 0 ----- stdout ----- @@ -4620,9 +4620,9 @@ fn shared_dependency_mixed() -> Result<()> { Installed 1 package in [TIME] - idna==3.5 + idna==3.6 - "###); + "); - uv_snapshot!(context.filters(), context.sync(), @r###" + uv_snapshot!(context.filters(), context.sync(), @r" success: true exit_code: 0 ----- stdout ----- @@ -4630,7 +4630,7 @@ fn shared_dependency_mixed() -> Result<()> { ----- stderr ----- Resolved 5 packages in [TIME] Audited 3 packages in [TIME] - "###); + "); Ok(()) } diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index f4232e0a5..92fcc829f 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -7720,7 +7720,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | EXTRA_BUILD_DEPENDENCIES, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES, ), }, python_preference: Managed, @@ -7946,7 +7946,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | EXTRA_BUILD_DEPENDENCIES, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES, ), }, python_preference: Managed, diff --git a/docs/concepts/preview.md b/docs/concepts/preview.md index c84eda380..1789b5d65 100644 --- a/docs/concepts/preview.md +++ b/docs/concepts/preview.md @@ -63,6 +63,7 @@ The following preview features are available: - `add-bounds`: Allows configuring the [default bounds for `uv add`](../reference/settings.md#add-bounds) invocations. - `json-output`: Allows `--output-format json` for various uv commands. +- `package-conflicts`: Allows defining workspace conflicts at the package level. - `pylock`: Allows installing from `pylock.toml` files. - `python-install-default`: Allows [installing `python` and `python3` executables](./python-versions.md#installing-python-executables).