diff --git a/crates/uv-pypi-types/src/conflicts.rs b/crates/uv-pypi-types/src/conflicts.rs index 81064955a6092..4c79381dcf70e 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) -> ConflictItem { + let kind = ConflictKind::Project; + ConflictItem { package, kind } + } +} + impl From<(PackageName, ExtraName)> for ConflictItem { fn from((package, extra): (PackageName, ExtraName)) -> ConflictItem { - let conflict = ConflictPackage::Extra(extra); - ConflictItem { package, conflict } + let kind = ConflictKind::Extra(extra); + ConflictItem { package, kind } } } impl From<(PackageName, GroupName)> for ConflictItem { fn from((package, group): (PackageName, GroupName)) -> ConflictItem { - let conflict = ConflictPackage::Group(group); - ConflictItem { package, conflict } + let kind = ConflictKind::Group(group); + ConflictItem { 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) -> ConflictItemRef<'a> { + let kind = ConflictKindRef::Project; + ConflictItemRef { package, kind } + } +} + impl<'a> From<(&'a PackageName, &'a ExtraName)> for ConflictItemRef<'a> { fn from((package, extra): (&'a PackageName, &'a ExtraName)) -> ConflictItemRef<'a> { - 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)) -> ConflictItemRef<'a> { - 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 { - ConflictPackage::Extra(ref extra) => Some(extra), - ConflictPackage::Group(_) => None, + ConflictKind::Extra(ref extra) => Some(extra), + ConflictKind::Group(_) | ConflictKind::Project => None, } } @@ -460,16 +476,17 @@ impl ConflictPackage { /// group name. pub fn group(&self) -> Option<&GroupName> { match *self { - ConflictPackage::Group(ref group) => Some(group), - ConflictPackage::Extra(_) => None, + ConflictKind::Group(ref group) => Some(group), + ConflictKind::Extra(_) | ConflictKind::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 { - ConflictPackage::Extra(ref extra) => ConflictPackageRef::Extra(extra), - ConflictPackage::Group(ref group) => ConflictPackageRef::Group(group), + ConflictKind::Extra(ref extra) => ConflictKindRef::Extra(extra), + ConflictKind::Group(ref group) => ConflictKindRef::Group(group), + ConflictKind::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 { - ConflictPackageRef::Extra(extra) => Some(extra), - ConflictPackageRef::Group(_) => None, + ConflictKindRef::Extra(extra) => Some(extra), + ConflictKindRef::Group(_) | ConflictKindRef::Project => None, } } @@ -497,46 +515,47 @@ impl<'a> ConflictPackageRef<'a> { /// group name. pub fn group(&self) -> Option<&'a GroupName> { match *self { - ConflictPackageRef::Group(group) => Some(group), - ConflictPackageRef::Extra(_) => None, + ConflictKindRef::Group(group) => Some(group), + ConflictKindRef::Extra(_) | ConflictKindRef::Project => None, } } /// Converts this borrowed conflict to its owned variant. - pub fn to_owned(&self) -> ConflictPackage { + pub fn to_owned(&self) -> ConflictKind { match *self { - ConflictPackageRef::Extra(extra) => ConflictPackage::Extra(extra.clone()), - ConflictPackageRef::Group(group) => ConflictPackage::Group(group.clone()), + ConflictKindRef::Extra(extra) => ConflictKind::Extra(extra.clone()), + ConflictKindRef::Group(group) => ConflictKind::Group(group.clone()), + ConflictKindRef::Project => ConflictKind::Project, } } } -impl<'a> From<&'a ExtraName> for ConflictPackageRef<'a> { - fn from(extra: &'a ExtraName) -> ConflictPackageRef<'a> { - ConflictPackageRef::Extra(extra) +impl<'a> From<&'a ExtraName> for ConflictKindRef<'a> { + fn from(extra: &'a ExtraName) -> ConflictKindRef<'a> { + ConflictKindRef::Extra(extra) } } -impl<'a> From<&'a GroupName> for ConflictPackageRef<'a> { - fn from(group: &'a GroupName) -> ConflictPackageRef<'a> { - ConflictPackageRef::Group(group) +impl<'a> From<&'a GroupName> for ConflictKindRef<'a> { + fn from(group: &'a GroupName) -> ConflictKindRef<'a> { + ConflictKindRef::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(ConflictItem::from(package)), (Some(extra), None) => Ok(ConflictItem::from((package, extra))), (None, Some(group)) => Ok(ConflictItem::from((package, group))), } @@ -705,17 +724,22 @@ impl TryFrom for ConflictItem { impl From for ConflictItemWire { fn from(item: ConflictItem) -> ConflictItemWire { - match item.conflict { - ConflictPackage::Extra(extra) => ConflictItemWire { + match item.kind { + ConflictKind::Extra(extra) => ConflictItemWire { package: Some(item.package), extra: Some(extra), group: None, }, - ConflictPackage::Group(group) => ConflictItemWire { + ConflictKind::Group(group) => ConflictItemWire { package: Some(item.package), extra: None, group: Some(group), }, + ConflictKind::Project => ConflictItemWire { + 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(SchemaConflictItem { + package: Some(package), + kind: ConflictKind::Project, + }) + } (Some(extra), None) => Ok(SchemaConflictItem { package, - conflict: ConflictPackage::Extra(extra), + kind: ConflictKind::Extra(extra), }), (None, Some(group)) => Ok(SchemaConflictItem { 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) -> ConflictItemWire { - match item.conflict { - ConflictPackage::Extra(extra) => ConflictItemWire { + match item.kind { + ConflictKind::Extra(extra) => ConflictItemWire { package: item.package, extra: Some(extra), group: None, }, - ConflictPackage::Group(group) => ConflictItemWire { + ConflictKind::Group(group) => ConflictItemWire { package: item.package, extra: None, group: Some(group), }, + ConflictKind::Project => ConflictItemWire { + 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 63ffed9502fd5..47cd3c2db6840 100644 --- a/crates/uv-resolver/src/graph_ops.rs +++ b/crates/uv-resolver/src/graph_ops.rs @@ -205,7 +205,7 @@ pub(crate) fn simplify_conflict_markers( let mut new_set = FxHashSet::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 4851306dadff9..e209215313f7b 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 49cb851b32e11..d44eb02436be2 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -42,7 +42,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; @@ -852,11 +852,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())); } } @@ -2871,6 +2872,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`. @@ -4453,7 +4469,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` @@ -4538,6 +4554,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 f22754eaddfd1..525f69f1dda22 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 deal 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 { .. } => PubGrubDependency { package, version, + parent: if is_normal_parent { + parent_name.cloned() + } else { + None + }, url, }, PubGrubPackageInner::Marker { .. } => PubGrubDependency { 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}" ); } PubGrubDependency { 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}" ); } PubGrubDependency { package, version, + parent: None, url, } } @@ -135,6 +163,20 @@ 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> { + if let Some(conflict) = self.package.conflicting_item() { + return Some(conflict); + } + if let Some(ref parent) = self.parent { + return Some(ConflictItemRef::from(parent)); + } + None + } } /// 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 2e67a715aa2ae..0856be5ed2c23 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/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index c30c4e947cf04..395fba523be58 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; @@ -928,6 +928,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; } - if let Some(conflicting_item) = dep.package.conflicting_item() { - self.conflicts.remove(&conflicting_item); + 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 }); Some(self) diff --git a/crates/uv-resolver/src/resolver/system.rs b/crates/uv-resolver/src/resolver/system.rs index a815697da7dc2..a2b3e51667f51 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 { PubGrubDependency { 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 5e5c4f17b4f7e..117c9a201492f 100644 --- a/crates/uv-resolver/src/universal_marker.rs +++ b/crates/uv-resolver/src/universal_marker.rs @@ -9,7 +9,7 @@ use uv_pep508::{ ExtraOperator, MarkerEnvironment, MarkerEnvironmentBuilder, MarkerExpression, MarkerOperator, MarkerTree, }; -use uv_pypi_types::{ConflictItem, ConflictPackage, Conflicts}; +use uv_pypi_types::{ConflictItem, ConflictKind, Conflicts}; use crate::ResolveError; @@ -148,9 +148,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(); } @@ -161,18 +162,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 @@ -184,7 +212,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 @@ -196,7 +224,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 @@ -208,7 +236,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 @@ -252,6 +280,7 @@ impl UniversalMarker { pub(crate) fn evaluate( self, env: &MarkerEnvironment, + projects: impl Iterator, extras: impl Iterator, groups: impl Iterator, ) -> bool @@ -260,12 +289,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 the internal marker that combines both the PEP 508 @@ -350,12 +385,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) -> ConflictMarker { - match *item.conflict() { - ConflictPackage::Extra(ref extra) => ConflictMarker::extra(item.package(), extra), - ConflictPackage::Group(ref group) => ConflictMarker::group(item.package(), group), + match *item.kind() { + ConflictKind::Extra(ref extra) => ConflictMarker::extra(item.package(), extra), + ConflictKind::Group(ref group) => ConflictMarker::group(item.package(), group), + ConflictKind::Project => ConflictMarker::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) -> ConflictMarker { + 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); + ConflictMarker { 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) -> ConflictMarker { @@ -519,8 +565,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 }, } @@ -537,13 +592,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(|_| { @@ -561,22 +616,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`)"), @@ -592,6 +653,7 @@ impl<'a> ParsedRawExtra<'a> { } })?; match *self { + ParsedRawExtra::Project { .. } => Ok(ConflictItem::from(package)), ParsedRawExtra::Extra { extra, .. } => { let extra = ExtraName::from_str(extra).map_err(|name_error| { ResolveError::InvalidValueInConflictMarker { @@ -615,6 +677,7 @@ impl<'a> ParsedRawExtra<'a> { fn package(&self) -> &'a str { match *self { + ParsedRawExtra::Project { package, .. } => package, ParsedRawExtra::Extra { package, .. } => package, ParsedRawExtra::Group { package, .. } => package, } diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index c14bfd9048e1b..f699854f92a4b 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -209,9 +209,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)) => { @@ -261,6 +258,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 b0f20e76fe738..9d64bbccb4930 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 { + 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).clone()); + + // 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.clone()); + } + + 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().clone() + } + 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 706c865937cc5..bbc2a41ab86ce 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}; @@ -476,6 +476,21 @@ async fn do_lock( } } + // Check if any conflicts contain project-level conflicts + if preview.is_disabled() { + for set in conflicts.iter() { + if 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` to disable this warning." + ); + break; + } + } + } + // 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 becd2a26e2bc3..79bc0e0f16c2c 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; @@ -50,6 +50,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; @@ -266,7 +267,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, } @@ -277,9 +278,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(", "); @@ -287,7 +289,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, @@ -295,9 +297,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() ) @@ -305,7 +307,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" @@ -318,12 +320,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() ) @@ -337,14 +339,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) @@ -2481,31 +2486,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 adf3b61f2c950..cabcdf72be59a 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -604,7 +604,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 f4ccb7bf7c0fe..411e23e48f7f6 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -2701,6 +2701,1336 @@ 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 ----- + Resolved 3 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 2 + 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 ----- + 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` 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 = 2 + 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` to disable this warning. + × No solution found when resolving dependencies: + ╰─▶ 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` 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 = 2 + 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` to disable this warning. + × No solution found when resolving dependencies: + ╰─▶ 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` 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 = 2 + 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 ----- + Resolved 5 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 2 + 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 ----- + 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 = 2 + 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<()> { @@ -24959,7 +26289,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 ----- @@ -24969,7 +26299,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(()) } @@ -25096,7 +26426,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 ----- @@ -25106,7 +26436,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(()) } @@ -25130,7 +26460,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 ----- @@ -25140,7 +26470,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(()) } @@ -25267,7 +26597,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 ----- @@ -25277,7 +26607,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 d67736c88fcdc..48e6f43515a6a 100644 --- a/crates/uv/tests/it/lock_conflict.rs +++ b/crates/uv/tests/it/lock_conflict.rs @@ -1049,23 +1049,23 @@ 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#" @@ -1084,14 +1084,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" @@ -1126,17 +1126,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 ----- @@ -1147,7 +1147,7 @@ fn extra_unconditional() -> Result<()> { Installed 1 package in [TIME] - anyio==4.1.0 + anyio==4.2.0 - "###); + "); Ok(()) } @@ -1202,14 +1202,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 @@ -1425,26 +1425,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(()) } @@ -1711,14 +1711,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"); @@ -1866,14 +1866,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"); @@ -2398,14 +2398,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(); @@ -2881,7 +2881,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 ----- @@ -2891,7 +2891,7 @@ fn non_optional_dependency_extra() -> Result<()> { Prepared 1 package in [TIME] Installed 1 package in [TIME] + sniffio==1.3.1 - "###); + "); Ok(()) } @@ -2928,7 +2928,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 ----- @@ -2938,7 +2938,7 @@ fn non_optional_dependency_group() -> Result<()> { Prepared 1 package in [TIME] Installed 1 package in [TIME] + sniffio==1.3.1 - "###); + "); Ok(()) } @@ -2978,7 +2978,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 ----- @@ -2988,7 +2988,7 @@ fn non_optional_dependency_mixed() -> Result<()> { Prepared 1 package in [TIME] Installed 1 package in [TIME] + sniffio==1.3.1 - "###); + "); Ok(()) } @@ -3178,7 +3178,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 ----- @@ -3190,7 +3190,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!({ @@ -3605,7 +3605,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 ----- @@ -3617,7 +3617,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!({ @@ -3895,7 +3895,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 ----- @@ -3907,7 +3907,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!({ @@ -3996,7 +3996,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 ----- @@ -4008,9 +4008,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 ----- @@ -4021,9 +4021,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 ----- @@ -4031,7 +4031,7 @@ fn shared_dependency_extra() -> Result<()> { ----- stderr ----- Resolved 5 packages in [TIME] Audited 3 packages in [TIME] - "###); + "); Ok(()) } @@ -4070,7 +4070,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 ----- @@ -4082,7 +4082,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!({ @@ -4246,7 +4246,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 ----- @@ -4258,7 +4258,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!({ @@ -4351,7 +4351,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 ----- @@ -4363,9 +4363,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 ----- @@ -4376,9 +4376,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 ----- @@ -4386,7 +4386,7 @@ fn shared_dependency_mixed() -> Result<()> { ----- stderr ----- Resolved 5 packages in [TIME] Audited 3 packages in [TIME] - "###); + "); Ok(()) }