From 10d5ac1e40c2631830bccce32d789e88cb9bfd98 Mon Sep 17 00:00:00 2001 From: Andrew Gallant Date: Thu, 14 Nov 2024 11:29:56 -0500 Subject: [PATCH 01/24] uv-pypi-types: rename ConflictPackage to ConflictKind This makes a lot more sense as a name IMO. And I think also works better for the immediate future, where I plan to add a `Project` kind. --- crates/uv-pypi-types/src/conflicts.rs | 132 ++++++++++++------------- crates/uv-resolver/src/lock/mod.rs | 8 +- crates/uv/src/commands/project/mod.rs | 14 +-- crates/uv/src/commands/project/sync.rs | 9 +- uv.schema.json | 16 ++- 5 files changed, 92 insertions(+), 87 deletions(-) diff --git a/crates/uv-pypi-types/src/conflicts.rs b/crates/uv-pypi-types/src/conflicts.rs index 57ed4851a5185..f7f75f65efe17 100644 --- a/crates/uv-pypi-types/src/conflicts.rs +++ b/crates/uv-pypi-types/src/conflicts.rs @@ -33,10 +33,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. @@ -84,11 +84,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) } } @@ -138,7 +138,7 @@ impl TryFrom> for ConflictSet { )] pub struct ConflictItem { package: PackageName, - conflict: ConflictPackage, + kind: ConflictKind, } impl ConflictItem { @@ -150,40 +150,40 @@ 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<(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 } } } @@ -194,7 +194,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> { @@ -206,40 +206,40 @@ 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, &'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 } } } @@ -253,18 +253,18 @@ impl<'a> hashbrown::Equivalent for ConflictItemRef<'a> { /// /// That is, either an extra or a group name. #[derive(Debug, Clone, Eq, Hash, PartialEq, PartialOrd, Ord, schemars::JsonSchema)] -pub enum ConflictPackage { +pub enum ConflictKind { Extra(ExtraName), Group(GroupName), } -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(_) => None, } } @@ -272,16 +272,16 @@ 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(_) => 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), } } } @@ -290,18 +290,18 @@ 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), } -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(_) => None, } } @@ -309,46 +309,46 @@ 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(_) => 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()), } } } -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<'a> PartialEq for ConflictPackageRef<'a> { - fn eq(&self, other: &ConflictPackage) -> bool { +impl<'a> PartialEq for ConflictKindRef<'a> { + 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<'a> hashbrown::Equivalent for ConflictPackageRef<'a> { - fn equivalent(&self, key: &ConflictPackage) -> bool { +impl<'a> hashbrown::Equivalent for ConflictKindRef<'a> { + fn equivalent(&self, key: &ConflictKind) -> bool { key.as_ref() == *self } } @@ -407,7 +407,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 @@ -454,7 +454,7 @@ pub struct SchemaConflictSet(Vec); )] pub struct SchemaConflictItem { package: Option, - conflict: ConflictPackage, + kind: ConflictKind, } impl<'de> serde::Deserialize<'de> for SchemaConflictSet { @@ -508,13 +508,13 @@ 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), @@ -533,11 +533,11 @@ impl TryFrom for SchemaConflictItem { (Some(_), Some(_)) => Err(ConflictError::FoundExtraAndGroup), (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), }), } } @@ -545,13 +545,13 @@ 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), diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 90d2a5b5ac051..95ab20e4d565f 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -40,7 +40,7 @@ use uv_pep440::Version; use uv_pep508::{split_scheme, MarkerEnvironment, MarkerTree, VerbatimUrl, VerbatimUrlError}; use uv_platform_tags::{TagCompatibility, TagPriority, Tags}; use uv_pypi_types::{ - redact_credentials, ConflictPackage, Conflicts, HashDigest, ParsedArchiveUrl, ParsedGitUrl, + redact_credentials, ConflictKind, Conflicts, HashDigest, ParsedArchiveUrl, ParsedGitUrl, Requirement, RequirementSource, }; use uv_types::{BuildContext, HashStrategy}; @@ -636,11 +636,11 @@ 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(ref extra) => { + match item.kind() { + ConflictKind::Extra(ref extra) => { table.insert("extra", Value::from(extra.to_string())); } - ConflictPackage::Group(ref group) => { + ConflictKind::Group(ref group) => { table.insert("group", Value::from(group.to_string())); } } diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 516fe62f5b456..1025425cb20c2 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -22,7 +22,7 @@ use uv_installer::{SatisfiesResult, SitePackages}; use uv_normalize::{GroupName, PackageName, DEV_DEPENDENCIES}; use uv_pep440::{Version, VersionSpecifiers}; use uv_pep508::MarkerTreeContents; -use uv_pypi_types::{ConflictPackage, ConflictSet, Conflicts, Requirement}; +use uv_pypi_types::{ConflictKind, ConflictSet, Conflicts, Requirement}; use uv_python::{ EnvironmentPreference, Interpreter, InvalidEnvironmentKind, PythonDownloads, PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, @@ -85,22 +85,22 @@ pub(crate) enum ProjectError { "{} are incompatible with the declared conflicts: {{{}}}", _1.iter().map(|conflict| { match conflict { - ConflictPackage::Extra(ref extra) => format!("extra `{extra}`"), - ConflictPackage::Group(ref group) => format!("group `{group}`"), + ConflictKind::Extra(ref extra) => format!("extra `{extra}`"), + ConflictKind::Group(ref group) => format!("group `{group}`"), } }).collect::>().join(", "), _0 .iter() .map(|item| { - match item.conflict() { - ConflictPackage::Extra(ref extra) => format!("`{}[{}]`", item.package(), extra), - ConflictPackage::Group(ref group) => format!("`{}:{}`", item.package(), group), + match item.kind() { + ConflictKind::Extra(ref extra) => format!("`{}[{}]`", item.package(), extra), + ConflictKind::Group(ref group) => format!("`{}:{}`", item.package(), group), } }) .collect::>() .join(", "), )] - ConflictIncompatibility(ConflictSet, Vec), + ConflictIncompatibility(ConflictSet, Vec), #[error("The requested interpreter resolved to Python {0}, which is incompatible with the project's Python requirement: `{1}`")] RequestedPythonProjectIncompatibility(Version, RequiresPython), diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index f9db6ad44c50b..70ad9b67ae889 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -20,8 +20,7 @@ use uv_installer::SitePackages; use uv_normalize::PackageName; use uv_pep508::{MarkerTree, Requirement, VersionOrUrl}; use uv_pypi_types::{ - ConflictPackage, LenientRequirement, ParsedArchiveUrl, ParsedGitUrl, ParsedUrl, - VerbatimParsedUrl, + ConflictKind, LenientRequirement, ParsedArchiveUrl, ParsedGitUrl, ParsedUrl, VerbatimParsedUrl, }; use uv_python::{PythonDownloads, PythonEnvironment, PythonPreference, PythonRequest}; use uv_resolver::{FlatIndex, InstallTarget}; @@ -292,21 +291,21 @@ pub(super) async fn do_sync( // those should result in an error. let conflicts = target.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()); + conflicts.push(item.kind().clone()); } if item .group() .map(|group1| dev.iter().any(|group2| group1 == group2)) .unwrap_or(false) { - conflicts.push(item.conflict().clone()); + conflicts.push(item.kind().clone()); } } if conflicts.len() >= 2 { diff --git a/uv.schema.json b/uv.schema.json index a58e60345829f..d3c092860fb7a 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -577,9 +577,15 @@ "$ref": "#/definitions/ConfigSettingValue" } }, - "ConflictPackage": { - "description": "The actual conflicting data for a package.\n\nThat is, either an extra or a group name.", + "ConflictKind": { + "description": "The actual conflicting data for a package.\n\nThat is, either an extra or a group name, or the entire project itself.", "oneOf": [ + { + "type": "string", + "enum": [ + "Project" + ] + }, { "type": "object", "required": [ @@ -1360,11 +1366,11 @@ "description": "Like [`ConflictItem`], but for deserialization in `pyproject.toml`.\n\nThe schema format is different from the in-memory format. Specifically, the schema format does not allow specifying the package name (or will make it optional in the future), where as the in-memory format needs the package name.", "type": "object", "required": [ - "conflict" + "kind" ], "properties": { - "conflict": { - "$ref": "#/definitions/ConflictPackage" + "kind": { + "$ref": "#/definitions/ConflictKind" }, "package": { "anyOf": [ From 9026ff23b383c58cf2cd653f1a102eb055bd6652 Mon Sep 17 00:00:00 2001 From: Andrew Gallant Date: Thu, 14 Nov 2024 11:39:06 -0500 Subject: [PATCH 02/24] uv-pypi-types: add ConflictKind::Project Basically, this new conflict kind means that the entire project conflicts with a dependency group or an extra. This just adds the variant. In the next commit, we'll actually make it work. --- crates/uv-pypi-types/src/conflicts.rs | 56 ++++++++++++++++++++++----- crates/uv-resolver/src/lock/mod.rs | 1 + crates/uv/src/commands/project/mod.rs | 2 + 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/crates/uv-pypi-types/src/conflicts.rs b/crates/uv-pypi-types/src/conflicts.rs index f7f75f65efe17..8bc8c7071c32c 100644 --- a/crates/uv-pypi-types/src/conflicts.rs +++ b/crates/uv-pypi-types/src/conflicts.rs @@ -173,6 +173,13 @@ impl ConflictItem { } } +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 kind = ConflictKind::Extra(extra); @@ -229,6 +236,13 @@ impl<'a> ConflictItemRef<'a> { } } +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 kind = ConflictKindRef::Extra(extra); @@ -251,11 +265,12 @@ impl<'a> hashbrown::Equivalent for ConflictItemRef<'a> { /// 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, schemars::JsonSchema)] pub enum ConflictKind { Extra(ExtraName), Group(GroupName), + Project, } impl ConflictKind { @@ -264,7 +279,7 @@ impl ConflictKind { pub fn extra(&self) -> Option<&ExtraName> { match *self { ConflictKind::Extra(ref extra) => Some(extra), - ConflictKind::Group(_) => None, + ConflictKind::Group(_) | ConflictKind::Project => None, } } @@ -273,7 +288,7 @@ impl ConflictKind { pub fn group(&self) -> Option<&GroupName> { match *self { ConflictKind::Group(ref group) => Some(group), - ConflictKind::Extra(_) => None, + ConflictKind::Extra(_) | ConflictKind::Project => None, } } @@ -282,6 +297,7 @@ impl ConflictKind { match *self { ConflictKind::Extra(ref extra) => ConflictKindRef::Extra(extra), ConflictKind::Group(ref group) => ConflictKindRef::Group(group), + ConflictKind::Project => ConflictKindRef::Project, } } } @@ -293,6 +309,7 @@ impl ConflictKind { pub enum ConflictKindRef<'a> { Extra(&'a ExtraName), Group(&'a GroupName), + Project, } impl<'a> ConflictKindRef<'a> { @@ -301,7 +318,7 @@ impl<'a> ConflictKindRef<'a> { pub fn extra(&self) -> Option<&'a ExtraName> { match *self { ConflictKindRef::Extra(extra) => Some(extra), - ConflictKindRef::Group(_) => None, + ConflictKindRef::Group(_) | ConflictKindRef::Project => None, } } @@ -310,7 +327,7 @@ impl<'a> ConflictKindRef<'a> { pub fn group(&self) -> Option<&'a GroupName> { match *self { ConflictKindRef::Group(group) => Some(group), - ConflictKindRef::Extra(_) => None, + ConflictKindRef::Extra(_) | ConflictKindRef::Project => None, } } @@ -319,6 +336,7 @@ impl<'a> ConflictKindRef<'a> { match *self { ConflictKindRef::Extra(extra) => ConflictKind::Extra(extra.clone()), ConflictKindRef::Group(group) => ConflictKind::Group(group.clone()), + ConflictKindRef::Project => ConflictKind::Project, } } } @@ -369,9 +387,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, @@ -498,8 +516,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))), } @@ -519,6 +537,11 @@ impl From for ConflictItemWire { extra: None, group: Some(group), }, + ConflictKind::Project => ConflictItemWire { + package: Some(item.package), + extra: None, + group: None, + }, } } } @@ -529,8 +552,16 @@ 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, kind: ConflictKind::Extra(extra), @@ -556,6 +587,11 @@ impl From for ConflictItemWire { extra: None, group: Some(group), }, + ConflictKind::Project => ConflictItemWire { + package: item.package, + extra: None, + group: None, + }, } } } diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 95ab20e4d565f..ec31abf2425b4 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -637,6 +637,7 @@ impl Lock { let mut table = InlineTable::new(); table.insert("package", Value::from(item.package().to_string())); match item.kind() { + ConflictKind::Project => {} ConflictKind::Extra(ref extra) => { table.insert("extra", Value::from(extra.to_string())); } diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 1025425cb20c2..f7a41898ad04b 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -85,6 +85,7 @@ pub(crate) enum ProjectError { "{} are incompatible with the declared conflicts: {{{}}}", _1.iter().map(|conflict| { match conflict { + ConflictKind::Project => format!("project"), ConflictKind::Extra(ref extra) => format!("extra `{extra}`"), ConflictKind::Group(ref group) => format!("group `{group}`"), } @@ -93,6 +94,7 @@ pub(crate) enum ProjectError { .iter() .map(|item| { match item.kind() { + ConflictKind::Project => format!("project"), ConflictKind::Extra(ref extra) => format!("`{}[{}]`", item.package(), extra), ConflictKind::Group(ref group) => format!("`{}:{}`", item.package(), group), } From 7fba6b5c91967627d358886549c3f83ea41a843f Mon Sep 17 00:00:00 2001 From: Andrew Gallant Date: Thu, 14 Nov 2024 12:04:18 -0500 Subject: [PATCH 03/24] uv-resolver: support project-level conflicts Supporting project level conflicts ends up being pretty tricky, mostly because depenedency groups are represented as dependencies of the project you're trying to declare a conflict for. So by filtering out the project in the fork for the conflicting group, you end up filtering out the group itself too. To work-around this, we add a `parent` field to `PubGrubDependency`, and use this to filter based on project conflicts. This lets us do "delayed" filtering by one level. The rest of the changes in this commit are for reporting errors when you try to activate the group without disabling the project. --- .../uv-resolver/src/pubgrub/dependencies.rs | 54 ++++- crates/uv-resolver/src/pubgrub/package.rs | 7 +- crates/uv-resolver/src/resolver/mod.rs | 17 +- crates/uv/src/commands/project/mod.rs | 4 +- crates/uv/src/commands/project/sync.rs | 18 +- crates/uv/tests/it/lock.rs | 190 ++++++++++++++++++ 6 files changed, 263 insertions(+), 27 deletions(-) diff --git a/crates/uv-resolver/src/pubgrub/dependencies.rs b/crates/uv-resolver/src/pubgrub/dependencies.rs index ee9ac491ebc8b..7946d57389189 100644 --- a/crates/uv-resolver/src/pubgrub/dependencies.rs +++ b/crates/uv-resolver/src/pubgrub/dependencies.rs @@ -6,8 +6,8 @@ use tracing::warn; use uv_normalize::{ExtraName, PackageName}; use uv_pep440::{Version, VersionSpecifiers}; use uv_pypi_types::{ - ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl, ParsedUrl, Requirement, - RequirementSource, VerbatimParsedUrl, + ConflictItemRef, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl, ParsedUrl, + Requirement, RequirementSource, VerbatimParsedUrl, }; use crate::pubgrub::{PubGrubPackage, PubGrubPackageInner}; @@ -17,6 +17,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, + /// The original version specifiers from the requirement. pub(crate) specifier: Option, @@ -29,8 +44,12 @@ pub(crate) struct PubGrubDependency { impl PubGrubDependency { pub(crate) fn from_requirement<'a>( requirement: &'a Requirement, - 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); // Add the package, plus any extra variants. iter::once(None) .chain(requirement.extras.clone().into_iter().map(Some)) @@ -45,7 +64,7 @@ impl PubGrubDependency { match &*package { PubGrubPackageInner::Package { name, .. } => { // Detect self-dependencies. - if source_name.is_some_and(|source_name| source_name == name) { + if parent_name.is_some_and(|parent_name| parent_name == name) { warn!("{name} has a dependency on itself"); return None; } @@ -53,6 +72,11 @@ impl PubGrubDependency { Some(PubGrubDependency { package: package.clone(), version: version.clone(), + parent: if is_normal_parent { + parent_name.cloned() + } else { + None + }, specifier, url, }) @@ -60,17 +84,23 @@ impl PubGrubDependency { PubGrubPackageInner::Marker { .. } => Some(PubGrubDependency { package: package.clone(), version: version.clone(), + parent: if is_normal_parent { + parent_name.cloned() + } else { + None + }, specifier, url, }), PubGrubPackageInner::Extra { name, .. } => { debug_assert!( - !source_name.is_some_and(|source_name| source_name == name), + !parent_name.is_some_and(|parent_name| parent_name == name), "extras not flattened for {name}" ); Some(PubGrubDependency { package: package.clone(), version: version.clone(), + parent: None, specifier, url, }) @@ -79,6 +109,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 19fea7be87c27..b9b0df76c765f 100644 --- a/crates/uv-resolver/src/pubgrub/package.rs +++ b/crates/uv-resolver/src/pubgrub/package.rs @@ -208,13 +208,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 a 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) => Some(ConflictItemRef::from(package)), (None, None) => None, (Some(extra), None) => Some(ConflictItemRef::from((package, extra))), (None, Some(group)) => Some(ConflictItemRef::from((package, group))), diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index abbf67f34374f..4e31e8f8704cf 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -554,6 +554,7 @@ impl ResolverState ResolverState ResolverState = requirements .iter() .flat_map(|requirement| { - PubGrubDependency::from_requirement(requirement, Some(name)) + PubGrubDependency::from_requirement(requirement, Some(package)) }) .collect(); @@ -1466,6 +1468,7 @@ impl ResolverState ResolverState ResolverState ResolverState) -> Fork { self.env = self.env.exclude_by_group(groups); 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() { + if let Some(conflicting_item) = dep.conflicting_item() { self.conflicts.remove(&conflicting_item); } false diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index f7a41898ad04b..b8883040b1efa 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -85,7 +85,7 @@ pub(crate) enum ProjectError { "{} are incompatible with the declared conflicts: {{{}}}", _1.iter().map(|conflict| { match conflict { - ConflictKind::Project => format!("project"), + ConflictKind::Project => "the project".to_string(), ConflictKind::Extra(ref extra) => format!("extra `{extra}`"), ConflictKind::Group(ref group) => format!("group `{group}`"), } @@ -94,7 +94,7 @@ pub(crate) enum ProjectError { .iter() .map(|item| { match item.kind() { - ConflictKind::Project => format!("project"), + ConflictKind::Project => "the project".to_string(), ConflictKind::Extra(ref extra) => format!("`{}[{}]`", item.package(), extra), ConflictKind::Group(ref group) => format!("`{}:{}`", item.package(), group), } diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 70ad9b67ae889..4802de4387e5a 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -293,18 +293,12 @@ pub(super) async fn do_sync( for set in conflicts.iter() { let mut conflicts: Vec = vec![]; for item in set.iter() { - if item - .extra() - .map(|extra| extras.contains(extra)) - .unwrap_or(false) - { - conflicts.push(item.kind().clone()); - } - if item - .group() - .map(|group1| dev.iter().any(|group2| group1 == group2)) - .unwrap_or(false) - { + let is_conflicting = match *item.kind() { + ConflictKind::Project => dev.prod(), + ConflictKind::Extra(ref extra) => extras.contains(extra), + ConflictKind::Group(ref group1) => dev.iter().any(|group2| group1 == group2), + }; + if is_conflicting { conflicts.push(item.kind().clone()); } } diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index c6e058677c110..40a06e1681be6 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -3756,6 +3756,196 @@ fn lock_conflicting_group_basic() -> Result<()> { Ok(()) } +/// This tests a "basic" case for specifying a group that conflicts with the +/// project itself. +#[test] +fn lock_conflicting_project_basic() -> 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 project:foo depends on sortedcontainers==2.4.0 and your project depends on project:foo, we can conclude that your project depends on sortedcontainers==2.4.0. + And because your project depends on sortedcontainers==2.3.0, 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 + requires-python = ">=3.12" + resolution-markers = [ + ] + 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" } }, + ] + + [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" } + resolution-markers = [ + ] + sdist = { url = "https://files.pythonhosted.org/packages/14/10/6a9481890bae97da9edd6e737c9c3dec6aea3fc2fa53b0934037b35c89ea/sortedcontainers-2.3.0.tar.gz", hash = "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1", size = 30509 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/20/4d/a7046ae1a1a4cc4e9bbed194c387086f06b25038be596543d026946330c9/sortedcontainers-2.3.0-py2.py3-none-any.whl", hash = "sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f", size = 29479 }, + ] + + [[package]] + name = "sortedcontainers" + version = "2.4.0" + source = { registry = "https://pypi.org/simple" } + resolution-markers = [ + ] + sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 }, + ] + "### + ); + }); + + // 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`, the project are incompatible with the declared conflicts: {`project:foo`, the 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 we declare an extra and a group as conflicting. #[test] fn lock_conflicting_mixed() -> Result<()> { From 6ca33c711520996323bd01d768e1a384c9e5e331 Mon Sep 17 00:00:00 2001 From: Andrew Gallant Date: Wed, 23 Jul 2025 13:56:43 -0400 Subject: [PATCH 04/24] Fully support project level conflicts There was a fair bit of support for project level conflicts missing from `UniversalMarker`. I think that's because my initial PR pre-dated the beefing up of `UniversalMarker`. --- crates/uv-resolver/src/lock/installable.rs | 5 + crates/uv-resolver/src/universal_marker.rs | 112 +++++++++--- crates/uv/tests/it/lock.rs | 197 ++++++++++++++++++++- 3 files changed, 279 insertions(+), 35 deletions(-) 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/universal_marker.rs b/crates/uv-resolver/src/universal_marker.rs index c6d09f8efaede..117c9a201492f 100644 --- a/crates/uv-resolver/src/universal_marker.rs +++ b/crates/uv-resolver/src/universal_marker.rs @@ -151,7 +151,7 @@ impl UniversalMarker { 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 => {} + ConflictKind::Project => self.assume_project(item.package()), } self.pep508 = self.marker.without_extras(); } @@ -165,16 +165,42 @@ impl UniversalMarker { 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 => {} + 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 @@ -186,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 @@ -198,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 @@ -210,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 @@ -254,6 +280,7 @@ impl UniversalMarker { pub(crate) fn evaluate( self, env: &MarkerEnvironment, + projects: impl Iterator, extras: impl Iterator, groups: impl Iterator, ) -> bool @@ -262,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 @@ -355,10 +388,20 @@ impl ConflictMarker { match *item.kind() { ConflictKind::Extra(ref extra) => ConflictMarker::extra(item.package(), extra), ConflictKind::Group(ref group) => ConflictMarker::group(item.package(), group), - ConflictKind::Project => ConflictMarker::TRUE, + 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 { @@ -522,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 }, } @@ -540,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(|_| { @@ -564,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`)"), @@ -595,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 { @@ -618,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/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 42efa179e5584..f80fb8cc98664 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -2704,7 +2704,7 @@ fn lock_dependency_non_existent_extra() -> Result<()> { /// This tests a "basic" case for specifying a group that conflicts with the /// project itself. #[test] -fn lock_conflicting_project_basic() -> Result<()> { +fn lock_conflicting_project_basic1() -> Result<()> { let context = TestContext::new("3.12"); // First we test that resolving a group with a dependency that conflicts @@ -2787,9 +2787,6 @@ fn lock_conflicting_project_basic() -> Result<()> { version = 1 revision = 2 requires-python = ">=3.12" - resolution-markers = [ - "python_version < '0'", - ] conflicts = [[ { package = "project", group = "foo" }, { package = "project" }, @@ -2803,7 +2800,7 @@ fn lock_conflicting_project_basic() -> Result<()> { version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "sortedcontainers", version = "2.3.0", source = { registry = "https://pypi.org/simple" } }, + { name = "sortedcontainers", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'project-7-project'" }, ] [package.dev-dependencies] @@ -2840,14 +2837,12 @@ fn lock_conflicting_project_basic() -> Result<()> { // Re-run with `--locked`. uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r" - success: false - exit_code: 1 + success: true + exit_code: 0 ----- stdout ----- ----- stderr ----- - warning: Ignoring existing lockfile due to fork markers not covering the supported environments: `python_version < '0'` vs `python_full_version >= '3.12'` Resolved 3 packages in [TIME] - The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`. "); // Install from the lockfile. @@ -2891,6 +2886,190 @@ fn lock_conflicting_project_basic() -> Result<()> { 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 the project are incompatible with the declared conflicts: {`example:foo`, the 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] + - 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<()> { From c379db0c4f8fa69216202a8469de9165625cb75d Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 23 Jul 2025 19:50:42 -0500 Subject: [PATCH 05/24] Update `detect_conflicts` to take a `InstallTarget` --- crates/uv/src/commands/project/export.rs | 6 +++--- crates/uv/src/commands/project/mod.rs | 12 ++++++++---- crates/uv/src/commands/project/sync.rs | 2 +- 3 files changed, 12 insertions(+), 8 deletions(-) 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/mod.rs b/crates/uv/src/commands/project/mod.rs index 4a2669737aa98..da24a407e66ff 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -31,13 +31,14 @@ use uv_pypi_types::{ConflictKind, ConflictSet, Conflicts}; use uv_python::{ EnvironmentPreference, Interpreter, InvalidEnvironmentKind, PythonDownloads, PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, PythonSource, PythonVariant, - PythonVersionFile, VersionFileDiscoveryOptions, VersionRequest, satisfies_python_preference, + PythonVersionFile, Target, VersionFileDiscoveryOptions, VersionRequest, + satisfies_python_preference, }; 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 +51,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; @@ -273,6 +275,7 @@ pub(crate) struct ConflictError { impl std::fmt::Display for ConflictError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + dbg!(&self.set, &self.conflicts); // Format the set itself. let set = self .set @@ -2483,7 +2486,7 @@ 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> { @@ -2493,6 +2496,7 @@ pub(crate) fn detect_conflicts( // 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 conflicts = lock.conflicts(); for set in conflicts.iter() { let mut conflicts: Vec = vec![]; 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)?; From d54792bcf1f57d1661a3d1458da959b509ea1c73 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 23 Jul 2025 20:19:13 -0500 Subject: [PATCH 06/24] Add `packages` utility to `InstallTarget` --- crates/uv-resolver/src/lock/mod.rs | 12 +++++- .../uv/src/commands/project/install_target.rs | 40 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index ad6363a361ea3..b100984ff5544 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -2872,6 +2872,11 @@ 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 + } } /// Attempts to construct a `VerbatimUrl` from the given normalized `Path`. @@ -4454,7 +4459,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` @@ -4539,6 +4544,11 @@ impl Dependency { table } + + /// Returns the package name of this dependency. + pub fn package_name(&self) -> &PackageName { + &self.package_id.name + } } impl Display for Dependency { diff --git a/crates/uv/src/commands/project/install_target.rs b/crates/uv/src/commands/project/install_target.rs index b0f20e76fe738..721f1dbd186b2 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::{BTreeSet, VecDeque}; use std::path::Path; use std::str::FromStr; @@ -369,4 +370,43 @@ impl<'lock> InstallTarget<'lock> { Ok(()) } + + /// Returns the names of all packages in the workspace that will be installed. + /// This includes the primary target(s) and any workspace members they depend on. + pub(crate) fn packages(&self) -> BTreeSet { + match self { + Self::Project { name, lock, .. } => { + // Start with the project itself + let mut packages = BTreeSet::new(); + packages.insert((*name).clone()); + + // Find all workspace member dependencies recursively + let members = lock.members(); + let mut queue = VecDeque::new(); + queue.push_back(*name); + + while let Some(pkg_name) = queue.pop_front() { + // Find the package by iterating through all packages + if let Some(package) = lock.packages().iter().find(|p| p.name() == pkg_name) { + for dep in package.dependencies() { + let dep_name = dep.package_name(); + if members.contains(dep_name) && packages.insert(dep_name.clone()) { + queue.push_back(dep_name); + } + } + } + } + + packages + } + Self::Workspace { lock, .. } | Self::NonProjectWorkspace { lock, .. } => { + // Return all workspace members + lock.members().clone() + } + Self::Script { .. } => { + // Scripts don't have workspace members + BTreeSet::new() + } + } + } } From 95b6b20fe30fedccd7846118b24300439133634a Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 23 Jul 2025 20:22:06 -0500 Subject: [PATCH 07/24] Do not include conflicts for packages that will not be installed --- crates/uv/src/commands/project/install_target.rs | 1 - crates/uv/src/commands/project/mod.rs | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/uv/src/commands/project/install_target.rs b/crates/uv/src/commands/project/install_target.rs index 721f1dbd186b2..e14bf336e642c 100644 --- a/crates/uv/src/commands/project/install_target.rs +++ b/crates/uv/src/commands/project/install_target.rs @@ -372,7 +372,6 @@ impl<'lock> InstallTarget<'lock> { } /// Returns the names of all packages in the workspace that will be installed. - /// This includes the primary target(s) and any workspace members they depend on. pub(crate) fn packages(&self) -> BTreeSet { match self { Self::Project { name, lock, .. } => { diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index da24a407e66ff..4be2aaf887a61 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -2497,10 +2497,15 @@ pub(crate) fn detect_conflicts( // group `g` are declared as conflicting, then enabling both of // those should result in an error. let lock = target.lock(); + let packages = target.packages(); let conflicts = lock.conflicts(); for set in conflicts.iter() { let mut conflicts: Vec = vec![]; for item in set.iter() { + if !packages.contains(item.package()) { + // Ignore items that are not in the install targets + continue; + } let is_conflicting = match item.kind() { ConflictKind::Project => groups.prod(), ConflictKind::Extra(extra) => extras.contains(extra), From 827708e67e8abdd17b615b14c603ec64f50e2943 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 24 Jul 2025 08:05:53 -0500 Subject: [PATCH 08/24] Add test cases, improve `packages()`, fix errors --- crates/uv-resolver/src/lock/mod.rs | 15 + .../uv/src/commands/project/install_target.rs | 99 +++++- crates/uv/src/commands/project/mod.rs | 30 +- crates/uv/tests/it/lock.rs | 324 ++++++++++++++++++ 4 files changed, 435 insertions(+), 33 deletions(-) diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index b100984ff5544..d44eb02436be2 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -2877,6 +2877,16 @@ impl 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`. @@ -4549,6 +4559,11 @@ impl 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/src/commands/project/install_target.rs b/crates/uv/src/commands/project/install_target.rs index e14bf336e642c..1281a7ad28850 100644 --- a/crates/uv/src/commands/project/install_target.rs +++ b/crates/uv/src/commands/project/install_target.rs @@ -1,5 +1,5 @@ use std::borrow::Cow; -use std::collections::{BTreeSet, VecDeque}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::path::Path; use std::str::FromStr; @@ -8,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; @@ -372,31 +372,94 @@ impl<'lock> InstallTarget<'lock> { } /// Returns the names of all packages in the workspace that will be installed. - pub(crate) fn packages(&self) -> BTreeSet { + pub(crate) fn packages( + &self, + extras: &ExtrasSpecification, + groups: &DependencyGroupsWithDefaults, + ) -> BTreeSet { match self { Self::Project { name, lock, .. } => { - // Start with the project itself - let mut packages = BTreeSet::new(); - packages.insert((*name).clone()); + // Collect the packages by name for 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 members = lock.members(); - let mut queue = VecDeque::new(); - queue.push_back(*name); - - while let Some(pkg_name) = queue.pop_front() { - // Find the package by iterating through all packages - if let Some(package) = lock.packages().iter().find(|p| p.name() == pkg_name) { - for dep in package.dependencies() { - let dep_name = dep.package_name(); - if members.contains(dep_name) && packages.insert(dep_name.clone()) { - queue.push_back(dep_name); + 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))); } } } } - packages + required_members } Self::Workspace { lock, .. } | Self::NonProjectWorkspace { lock, .. } => { // Return all workspace members diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 4be2aaf887a61..79bc0e0f16c2c 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -27,12 +27,11 @@ 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::{ConflictKind, ConflictSet, Conflicts}; +use uv_pypi_types::{ConflictItem, ConflictKind, ConflictSet, Conflicts}; use uv_python::{ EnvironmentPreference, Interpreter, InvalidEnvironmentKind, PythonDownloads, PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, PythonSource, PythonVariant, - PythonVersionFile, Target, VersionFileDiscoveryOptions, VersionRequest, - satisfies_python_preference, + PythonVersionFile, VersionFileDiscoveryOptions, VersionRequest, satisfies_python_preference, }; use uv_requirements::upgrade::{LockedRequirements, read_lock_requirements}; use uv_requirements::{NamedRequirementsResolver, RequirementsSpecification}; @@ -268,20 +267,19 @@ 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, } impl std::fmt::Display for ConflictError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - dbg!(&self.set, &self.conflicts); // Format the set itself. let set = self .set .iter() .map(|item| match item.kind() { - ConflictKind::Project => "the project".to_string(), + ConflictKind::Project => format!("{}", item.package()), ConflictKind::Extra(extra) => format!("`{}[{}]`", item.package(), extra), ConflictKind::Group(group) => format!("`{}:{}`", item.package(), group), }) @@ -291,7 +289,7 @@ impl std::fmt::Display for ConflictError { if self .conflicts .iter() - .all(|conflict| matches!(conflict, ConflictKind::Extra(..))) + .all(|conflict| matches!(conflict.kind(), ConflictKind::Extra(..))) { write!( f, @@ -299,7 +297,7 @@ impl std::fmt::Display for ConflictError { conjunction( self.conflicts .iter() - .map(|conflict| match conflict { + .map(|conflict| match conflict.kind() { ConflictKind::Extra(extra) => format!("`{extra}`"), ConflictKind::Group(..) | ConflictKind::Project => unreachable!(), }) @@ -309,7 +307,7 @@ impl std::fmt::Display for ConflictError { } else if self .conflicts .iter() - .all(|conflict| matches!(conflict, ConflictKind::Group(..))) + .all(|conflict| matches!(conflict.kind(), ConflictKind::Group(..))) { let conflict_source = if self.set.is_inferred_conflict() { "transitively inferred" @@ -322,7 +320,7 @@ impl std::fmt::Display for ConflictError { conjunction( self.conflicts .iter() - .map(|conflict| match conflict { + .map(|conflict| match conflict.kind() { ConflictKind::Group(group) if self.groups.contains_because_default(group) => format!("`{group}` (enabled by default)"), @@ -341,8 +339,10 @@ impl std::fmt::Display for ConflictError { .iter() .enumerate() .map(|(i, conflict)| { - let conflict = match conflict { - ConflictKind::Project => "the project".to_string(), + 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) => @@ -2497,10 +2497,10 @@ pub(crate) fn detect_conflicts( // group `g` are declared as conflicting, then enabling both of // those should result in an error. let lock = target.lock(); - let packages = target.packages(); + 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 !packages.contains(item.package()) { // Ignore items that are not in the install targets @@ -2512,7 +2512,7 @@ pub(crate) fn detect_conflicts( ConflictKind::Group(group1) => groups.contains(group1), }; if is_conflicting { - conflicts.push(item.kind().clone()); + conflicts.push(item.clone()); } } if conflicts.len() >= 2 { diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index f80fb8cc98664..2e2c52e03cb1d 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -2857,6 +2857,7 @@ fn lock_conflicting_project_basic1() -> Result<()> { + 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" @@ -2886,6 +2887,329 @@ fn lock_conflicting_project_basic1() -> Result<()> { 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 ----- + 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: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + 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" }, + { name = "subexample", editable = "subexample" }, + ] + + [[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" }] + "#)}); + + // Syncing should fail too + uv_snapshot!(context.filters(), context.sync(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + example==0.1.0 (from file://[TEMP_DIR]/) + + sortedcontainers==2.3.0 + "); + + Ok(()) +} + /// This tests another "basic" case for specifying a group that conflicts with /// the project itself. #[test] From 88eb0e94c4cc4f3b8fc46e778fe8f0eb56ef77c0 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 24 Jul 2025 12:38:46 -0500 Subject: [PATCH 09/24] Improve handling of package conflicts --- .../uv-resolver/src/resolver/environment.rs | 2 +- crates/uv-resolver/src/resolver/mod.rs | 15 +- crates/uv/tests/it/lock.rs | 194 ++++++++++++++++-- crates/uv/tests/it/lock_conflict.rs | 100 ++++----- 4 files changed, 245 insertions(+), 66 deletions(-) diff --git a/crates/uv-resolver/src/resolver/environment.rs b/crates/uv-resolver/src/resolver/environment.rs index 6e816f9911ce3..3184ea3009434 100644 --- a/crates/uv-resolver/src/resolver/environment.rs +++ b/crates/uv-resolver/src/resolver/environment.rs @@ -3,7 +3,7 @@ use tracing::trace; use uv_distribution_types::{RequiresPython, RequiresPythonRange}; use uv_pep440::VersionSpecifiers; use uv_pep508::{MarkerEnvironment, MarkerTree}; -use uv_pypi_types::{ConflictItem, ConflictItemRef, ResolverMarkerEnvironment}; +use uv_pypi_types::{ConflictItem, ConflictItemRef, ConflictKindRef, ResolverMarkerEnvironment}; use crate::pubgrub::{PubGrubDependency, PubGrubPackage}; use crate::resolver::ForkState; diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index c43bb2d0f23d4..a2ea83b2ce172 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; @@ -3637,7 +3637,7 @@ impl Forks { continue; } - // Create a fork that excludes ALL extras. + // Create a fork that excludes ALL conflicts. if let Some(fork_none) = fork.clone().filter(set.iter().cloned().map(Err)) { new.push(fork_none); } @@ -3770,6 +3770,17 @@ impl Fork { }; if self.env.included_by_group(conflicting_item) { return true; + } else { + match conflicting_item.kind() { + // We should not filter entire projects unless they're a top-level dependency + ConflictKindRef::Project => { + if dep.parent.is_some() { + return true; + } + } + ConflictKindRef::Group(_) => {} + ConflictKindRef::Extra(_) => {} + } } if let Some(conflicting_item) = dep.conflicting_item() { self.conflicts.remove(&conflicting_item); diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 2e2c52e03cb1d..c5f15ea4e4470 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -2768,14 +2768,14 @@ fn lock_conflicting_project_basic1() -> 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"); @@ -2866,7 +2866,7 @@ fn lock_conflicting_project_basic1() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Group `foo` and the project are incompatible with the declared conflicts: {`project:foo`, the project} + 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. @@ -2941,14 +2941,14 @@ fn lock_conflicting_workspace_members() -> Result<()> { )?; // Lock should succeed because we declared the conflict - 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 = context.read("uv.lock"); @@ -3118,6 +3118,82 @@ fn lock_conflicting_workspace_members_depends_direct() -> Result<()> { // This should fail to resolve, because these conflict uv_snapshot!(context.filters(), context.lock(), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × 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" }, + { 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 ----- @@ -3127,6 +3203,7 @@ fn lock_conflicting_workspace_members_depends_direct() -> Result<()> { "); let lock = context.read("uv.lock"); + insta::with_settings!({ filters => context.filters(), }, { @@ -3136,6 +3213,7 @@ fn lock_conflicting_workspace_members_depends_direct() -> Result<()> { revision = 2 requires-python = ">=3.12" conflicts = [[ + { package = "example", extra = "foo" }, { package = "example" }, { package = "subexample" }, ]] @@ -3154,14 +3232,15 @@ fn lock_conflicting_workspace_members_depends_direct() -> Result<()> { version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "sortedcontainers", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'project-7-example'" }, + { 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", editable = "subexample" }, + { name = "subexample", marker = "extra == 'foo'", editable = "subexample" }, ] + provides-extras = ["foo"] [[package]] name = "sortedcontainers" @@ -3186,27 +3265,116 @@ fn lock_conflicting_workspace_members_depends_direct() -> Result<()> { 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'" }, + { 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" }] - "#)}); + "# + ); + }); - // Syncing should fail too - uv_snapshot!(context.filters(), context.sync(), @r" + // Install from the lockfile + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- - Resolved 4 packages in [TIME] 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(()) +} + +/// Mar +#[test] +fn lock_conflicting_extras_depends() -> 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 = [] + + [project.optional-dependencies] + foo = ["sortedcontainers==2.3.0", "example[bar]"] + bar = ["sortedcontainers==2.4.0"] + + [tool.uv] + conflicts = [ + [ + { extra = "foo" }, + { extra = "bar" }, + ], + ] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + + [tool.setuptools.packages.find] + include = ["example"] + "#, + )?; + + // This should fail to resolve, because the extras are always required together + uv_snapshot!(context.filters(), context.lock(), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving dependencies: + ╰─▶ Because example[foo] depends on sortedcontainers==2.3.0 and sortedcontainers==2.4.0, we can conclude that example[foo]'s requirements are unsatisfiable. + And because your project requires example[foo], we can conclude that your project's requirements are unsatisfiable. + "); + Ok(()) } @@ -3373,7 +3541,7 @@ fn lock_conflicting_project_basic2() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Group `foo` and the project are incompatible with the declared conflicts: {`example:foo`, the project} + 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. 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(()) } From 3c1057d6a3a4e93d10cfd9acb0c898e656b659ea Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 24 Jul 2025 12:47:52 -0500 Subject: [PATCH 10/24] Log resolver environments --- crates/uv-resolver/src/error.rs | 3 + .../uv-resolver/src/resolver/environment.rs | 14 ++ crates/uv-resolver/src/resolver/mod.rs | 34 ++++ crates/uv/tests/it/lock.rs | 177 +++++++++++++++++- 4 files changed, 226 insertions(+), 2 deletions(-) diff --git a/crates/uv-resolver/src/error.rs b/crates/uv-resolver/src/error.rs index 0916f54ac1c83..b9b8c9c110d91 100644 --- a/crates/uv-resolver/src/error.rs +++ b/crates/uv-resolver/src/error.rs @@ -32,6 +32,7 @@ use crate::python_requirement::PythonRequirement; use crate::resolution::ConflictingDistributionError; use crate::resolver::{ MetadataUnavailable, ResolverEnvironment, UnavailablePackage, UnavailableReason, + trace_resolver_environment, }; use crate::{InMemoryIndex, Options}; @@ -476,6 +477,8 @@ impl std::fmt::Display for NoSolutionError { tags: self.tags.as_ref(), }; + trace_resolver_environment(&self.env); + // Transform the error tree for reporting let mut tree = self.error.clone(); simplify_derivation_tree_markers(&self.python_requirement, &mut tree); diff --git a/crates/uv-resolver/src/resolver/environment.rs b/crates/uv-resolver/src/resolver/environment.rs index 3184ea3009434..2cc5c1e5cc241 100644 --- a/crates/uv-resolver/src/resolver/environment.rs +++ b/crates/uv-resolver/src/resolver/environment.rs @@ -414,6 +414,20 @@ impl ResolverEnvironment { } } } + + pub(crate) fn includes(&self) -> Option<&Arc>> { + match self.kind { + Kind::Specific { .. } => None, + Kind::Universal { ref include, .. } => Some(include), + } + } + + pub(crate) fn excludes(&self) -> Option<&Arc>> { + match self.kind { + Kind::Specific { .. } => None, + Kind::Universal { ref exclude, .. } => Some(exclude), + } + } } /// A user visible representation of a resolver environment. diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index a2ea83b2ce172..91cf2be1b4de5 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -3663,6 +3663,9 @@ impl Forks { } forks = new; } + for fork in &forks { + trace_resolver_environment(&fork.env); + } Forks { forks, diverging_packages, @@ -3897,3 +3900,34 @@ struct ConflictTracker { /// Distilled from `culprit` for fast checking in the hot loop. deprioritize: Vec>, } + +/// When trace level logging is enabled, we emit the resolution environment +pub(crate) fn trace_resolver_environment(env: &ResolverEnvironment) { + if !tracing::enabled!(Level::TRACE) { + return; + } + + let markers = env + .marker_environment() + .map(|m| format!("{m:?}")) + .or_else(|| env.fork_markers().and_then(|m| m.try_to_string())); + + trace!( + r"Resolution environment {}{}{}", + if let Some(markers) = markers { + format!("for split `{markers}`") + } else { + "".to_string() + }, + if let Some(includes) = env.includes() { + format!("\n includes: `{includes:?}`") + } else { + "".to_string() + }, + if let Some(excludes) = env.excludes() { + format!("\n excludes: `{excludes:?}`") + } else { + "".to_string() + }, + ); +} diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index c5f15ea4e4470..8fa456c809d77 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -3193,14 +3193,187 @@ fn lock_conflicting_workspace_members_depends_direct_extra() -> Result<()> { )?; // This should succeed, because the conflict is optional - uv_snapshot!(context.filters(), context.lock(), @r" + uv_snapshot!(context.filters(), context.lock().arg("-vv"), @r#" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- + DEBUG uv [VERSION] ([COMMIT] DATE) + DEBUG Found workspace root: `[TEMP_DIR]/` + TRACE Discovering workspace members for: `[TEMP_DIR]/` + DEBUG Adding root workspace member: `[TEMP_DIR]/` + TRACE Processing workspace member: `subexample` + DEBUG Adding discovered workspace member: `[TEMP_DIR]/subexample` + DEBUG No Python version file found in workspace: [TEMP_DIR]/ + DEBUG Using Python request `>=3.12` from `requires-python` metadata + DEBUG Checking for Python environment at: `.venv` + TRACE Querying interpreter executable at [VENV]/bin/python3 + DEBUG The project environment's Python version satisfies the request: `Python >=3.12` + TRACE The project environment's Python version meets the Python requirement: `>=3.12` + TRACE The virtual environment's Python interpreter meets the Python preference: `prefer managed` + DEBUG Using request timeout of [TIME] + DEBUG Found static `pyproject.toml` for: example @ file://[TEMP_DIR]/ + DEBUG Found static `pyproject.toml` for: subexample @ file://[TEMP_DIR]/subexample + DEBUG Found workspace root: `[TEMP_DIR]/` + TRACE Cached workspace members for: `[TEMP_DIR]/` + TRACE Found `pyproject.toml` at: `[TEMP_DIR]/pyproject.toml` + DEBUG Found workspace root: `[TEMP_DIR]/` + TRACE Cached workspace members for: `[TEMP_DIR]/` + TRACE Performing lookahead for example[foo] @ file://[TEMP_DIR]/ + TRACE Performing lookahead for subexample @ file://[TEMP_DIR]/subexample + TRACE Performing lookahead for subexample @ file://[TEMP_DIR]/subexample ; extra == 'foo' + DEBUG Solving with installed Python version: 3.12.[X] + DEBUG Solving with target Python version: >=3.12 + TRACE Assigned packages: + TRACE Chose package for decision: root. remaining choices: + TRACE Resolution environment + includes: `{}` + excludes: `{ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }, ConflictItem { package: PackageName("example"), kind: Project }, ConflictItem { package: PackageName("subexample"), kind: Project }}` + TRACE Resolution environment + includes: `{ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }}` + excludes: `{ConflictItem { package: PackageName("subexample"), kind: Project }, ConflictItem { package: PackageName("example"), kind: Project }}` + TRACE Resolution environment + includes: `{ConflictItem { package: PackageName("example"), kind: Project }}` + excludes: `{ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }, ConflictItem { package: PackageName("subexample"), kind: Project }}` + TRACE Resolution environment + includes: `{ConflictItem { package: PackageName("subexample"), kind: Project }}` + excludes: `{ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }, ConflictItem { package: PackageName("example"), kind: Project }}` + DEBUG Pre-fork all marker environments took [TIME] + DEBUG Splitting resolution on root==0a0.dev0 over example into 4 resolutions with separate markers + DEBUG Adding direct dependency: example[foo]* + DEBUG Adding direct dependency: example* + DEBUG Adding direct dependency: subexample* + TRACE Assigned packages: root==0a0.dev0 + TRACE Chose package for decision: subexample. remaining choices: + DEBUG Searching for a compatible version of subexample @ file://[TEMP_DIR]/subexample (*) + TRACE Resolution environment + includes: `{ConflictItem { package: PackageName("subexample"), kind: Project }}` + excludes: `{ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }, ConflictItem { package: PackageName("example"), kind: Project }}` + DEBUG Adding direct dependency: sortedcontainers>=2.4.0, <2.4.0+ + TRACE Assigned packages: root==0a0.dev0, subexample==0.1.0 + TRACE Chose package for decision: sortedcontainers. remaining choices: + TRACE Fetching metadata for sortedcontainers from https://pypi.org/simple/sortedcontainers/ + TRACE No cache entry exists for [CACHE_DIR]/simple-v16/pypi/sortedcontainers.rkyv + DEBUG No cache entry for: https://pypi.org/simple/sortedcontainers/ + TRACE Sending fresh GET request for https://pypi.org/simple/sortedcontainers/ + TRACE Handling request for https://pypi.org/simple/sortedcontainers/ with authentication policy auto + TRACE Request for https://pypi.org/simple/sortedcontainers/ is unauthenticated, checking cache + TRACE No credentials in cache for URL https://pypi.org/simple/sortedcontainers/ + TRACE Attempting unauthenticated request for https://pypi.org/simple/sortedcontainers/ + TRACE Cached request https://pypi.org/simple/sortedcontainers/ is storable because its response has a 'public' cache-control directive + TRACE Received package metadata for: sortedcontainers + DEBUG Searching for a compatible version of sortedcontainers (>=2.4.0, <2.4.0+) + TRACE Selecting candidate for sortedcontainers with range >=2.4.0, <2.4.0+ with 40 remote versions + TRACE Selecting candidate for sortedcontainers with range >=2.4.0, <2.4.0+ with 40 remote versions + TRACE Found candidate for package sortedcontainers with range >=2.4.0, <2.4.0+ after 1 steps: 2.4.0 version + TRACE Returning candidate for package sortedcontainers with range >=2.4.0, <2.4.0+ after 1 steps + TRACE Found candidate for package sortedcontainers with range >=2.4.0, <2.4.0+ after 1 steps: 2.4.0 version + TRACE Returning candidate for package sortedcontainers with range >=2.4.0, <2.4.0+ after 1 steps + DEBUG Selecting: sortedcontainers==2.4.0 [compatible] (sortedcontainers-2.4.0-py2.py3-none-any.whl) + TRACE No cache entry exists for [CACHE_DIR]/wheels-v5/pypi/sortedcontainers/2.4.0-py2.py3-none-any.msgpack + DEBUG No cache entry for: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl.metadata + TRACE Sending fresh GET request for https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl.metadata + TRACE Handling request for https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl.metadata with authentication policy auto + TRACE Request for https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl.metadata is unauthenticated, checking cache + TRACE No credentials in cache for URL https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl.metadata + TRACE Attempting unauthenticated request for https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl.metadata + TRACE Cached request https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl.metadata is storable because its response has a 'public' cache-control directive + TRACE Received built distribution metadata for: sortedcontainers==2.4.0 + TRACE Resolution environment + includes: `{ConflictItem { package: PackageName("subexample"), kind: Project }}` + excludes: `{ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }, ConflictItem { package: PackageName("example"), kind: Project }}` + TRACE Assigned packages: root==0a0.dev0, subexample==0.1.0, sortedcontainers==2.4.0 + DEBUG Tried 2 versions: sortedcontainers 1, subexample 1 + DEBUG all marker environments resolution took [TIME] + TRACE Assigned packages: root==0a0.dev0 + TRACE Chose package for decision: example. remaining choices: + DEBUG Searching for a compatible version of example @ file://[TEMP_DIR]/ (*) + TRACE Resolution environment + includes: `{ConflictItem { package: PackageName("example"), kind: Project }}` + excludes: `{ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }, ConflictItem { package: PackageName("subexample"), kind: Project }}` + DEBUG Adding direct dependency: sortedcontainers>=2.3.0, <2.3.0+ + TRACE Assigned packages: root==0a0.dev0, example==0.1.0 + TRACE Chose package for decision: sortedcontainers. remaining choices: + TRACE Selecting candidate for sortedcontainers with range >=2.3.0, <2.3.0+ with 40 remote versions + DEBUG Searching for a compatible version of sortedcontainers (>=2.3.0, <2.3.0+) + TRACE Selecting candidate for sortedcontainers with range >=2.3.0, <2.3.0+ with 40 remote versions + TRACE Found candidate for package sortedcontainers with range >=2.3.0, <2.3.0+ after 1 steps: 2.3.0 version + TRACE Found candidate for package sortedcontainers with range >=2.3.0, <2.3.0+ after 1 steps: 2.3.0 version + TRACE Returning candidate for package sortedcontainers with range >=2.3.0, <2.3.0+ after 1 steps + TRACE Returning candidate for package sortedcontainers with range >=2.3.0, <2.3.0+ after 1 steps + DEBUG Selecting: sortedcontainers==2.3.0 [compatible] (sortedcontainers-2.3.0-py2.py3-none-any.whl) + TRACE No cache entry exists for [CACHE_DIR]/wheels-v5/pypi/sortedcontainers/2.3.0-py2.py3-none-any.msgpack + DEBUG No cache entry for: https://files.pythonhosted.org/packages/20/4d/a7046ae1a1a4cc4e9bbed194c387086f06b25038be596543d026946330c9/sortedcontainers-2.3.0-py2.py3-none-any.whl.metadata + TRACE Sending fresh GET request for https://files.pythonhosted.org/packages/20/4d/a7046ae1a1a4cc4e9bbed194c387086f06b25038be596543d026946330c9/sortedcontainers-2.3.0-py2.py3-none-any.whl.metadata + TRACE Handling request for https://files.pythonhosted.org/packages/20/4d/a7046ae1a1a4cc4e9bbed194c387086f06b25038be596543d026946330c9/sortedcontainers-2.3.0-py2.py3-none-any.whl.metadata with authentication policy auto + TRACE Request for https://files.pythonhosted.org/packages/20/4d/a7046ae1a1a4cc4e9bbed194c387086f06b25038be596543d026946330c9/sortedcontainers-2.3.0-py2.py3-none-any.whl.metadata is unauthenticated, checking cache + TRACE No credentials in cache for URL https://files.pythonhosted.org/packages/20/4d/a7046ae1a1a4cc4e9bbed194c387086f06b25038be596543d026946330c9/sortedcontainers-2.3.0-py2.py3-none-any.whl.metadata + TRACE Attempting unauthenticated request for https://files.pythonhosted.org/packages/20/4d/a7046ae1a1a4cc4e9bbed194c387086f06b25038be596543d026946330c9/sortedcontainers-2.3.0-py2.py3-none-any.whl.metadata + TRACE Cached request https://files.pythonhosted.org/packages/20/4d/a7046ae1a1a4cc4e9bbed194c387086f06b25038be596543d026946330c9/sortedcontainers-2.3.0-py2.py3-none-any.whl.metadata is storable because its response has a 'public' cache-control directive + TRACE Received built distribution metadata for: sortedcontainers==2.3.0 + TRACE Resolution environment + includes: `{ConflictItem { package: PackageName("example"), kind: Project }}` + excludes: `{ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }, ConflictItem { package: PackageName("subexample"), kind: Project }}` + TRACE Assigned packages: root==0a0.dev0, example==0.1.0, sortedcontainers==2.3.0 + DEBUG Tried 2 versions: example 1, sortedcontainers 1 + DEBUG all marker environments resolution took [TIME] + TRACE Assigned packages: root==0a0.dev0 + TRACE Chose package for decision: example[foo]. remaining choices: + DEBUG Searching for a compatible version of example @ file://[TEMP_DIR]/ (*) + DEBUG Adding direct dependency: example==0.1.0 + DEBUG Adding direct dependency: example[foo]==0.1.0 + TRACE Assigned packages: root==0a0.dev0 + TRACE Chose package for decision: example. remaining choices: example[foo] + DEBUG Searching for a compatible version of example @ file://[TEMP_DIR]/ (==0.1.0) + TRACE Resolution environment + includes: `{ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }}` + excludes: `{ConflictItem { package: PackageName("subexample"), kind: Project }, ConflictItem { package: PackageName("example"), kind: Project }}` + DEBUG Adding direct dependency: sortedcontainers>=2.3.0, <2.3.0+ + TRACE Assigned packages: root==0a0.dev0, example==0.1.0 + TRACE Chose package for decision: example[foo]. remaining choices: sortedcontainers + TRACE Selecting candidate for sortedcontainers with range >=2.3.0, <2.3.0+ with 40 remote versions + DEBUG Searching for a compatible version of example @ file://[TEMP_DIR]/ (==0.1.0) + TRACE Found candidate for package sortedcontainers with range >=2.3.0, <2.3.0+ after 1 steps: 2.3.0 version + TRACE Returning candidate for package sortedcontainers with range >=2.3.0, <2.3.0+ after 1 steps + TRACE Resolution environment + includes: `{ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }}` + excludes: `{ConflictItem { package: PackageName("subexample"), kind: Project }, ConflictItem { package: PackageName("example"), kind: Project }}` + TRACE Assigned packages: root==0a0.dev0, example==0.1.0, example[foo]==0.1.0 + TRACE Chose package for decision: sortedcontainers. remaining choices: + DEBUG Searching for a compatible version of sortedcontainers (>=2.3.0, <2.3.0+) + TRACE Using preference sortedcontainers 2.3.0 + DEBUG Selecting: sortedcontainers==2.3.0 [preference] (sortedcontainers-2.3.0-py2.py3-none-any.whl) + TRACE Resolution environment + includes: `{ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }}` + excludes: `{ConflictItem { package: PackageName("subexample"), kind: Project }, ConflictItem { package: PackageName("example"), kind: Project }}` + TRACE Assigned packages: root==0a0.dev0, example==0.1.0, example[foo]==0.1.0, sortedcontainers==2.3.0 + DEBUG Tried 2 versions: example 1, sortedcontainers 1 + DEBUG all marker environments resolution took [TIME] + TRACE Assigned packages: root==0a0.dev0 + DEBUG Tried 0 versions: + DEBUG all marker environments resolution took [TIME] + INFO Solved your requirements for 4 environments + TRACE Resolution: ResolverEnvironment { kind: Universal { initial_forks: [], markers: true, include: {ConflictItem { package: PackageName("subexample"), kind: Project }}, exclude: {ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }, ConflictItem { package: PackageName("example"), kind: Project }} } } + TRACE Resolution edge: ROOT -> subexample + TRACE Resolution edge: 0a0.dev0 -> 0.1.0 + TRACE Resolution edge: subexample -> sortedcontainers + TRACE Resolution edge: 0.1.0 -> 2.4.0 + TRACE Resolution: ResolverEnvironment { kind: Universal { initial_forks: [], markers: true, include: {ConflictItem { package: PackageName("example"), kind: Project }}, exclude: {ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }, ConflictItem { package: PackageName("subexample"), kind: Project }} } } + TRACE Resolution edge: ROOT -> example + TRACE Resolution edge: 0a0.dev0 -> 0.1.0 + TRACE Resolution edge: example -> sortedcontainers + TRACE Resolution edge: 0.1.0 -> 2.3.0 + TRACE Resolution: ResolverEnvironment { kind: Universal { initial_forks: [], markers: true, include: {ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }}, exclude: {ConflictItem { package: PackageName("subexample"), kind: Project }, ConflictItem { package: PackageName("example"), kind: Project }} } } + TRACE Resolution edge: ROOT -> example + TRACE Resolution edge: 0a0.dev0 -> 0.1.0 (extra: foo) + TRACE Resolution edge: ROOT -> example + TRACE Resolution edge: 0a0.dev0 -> 0.1.0 + TRACE Resolution edge: example -> sortedcontainers + TRACE Resolution edge: 0.1.0 -> 2.3.0 + TRACE Resolution: ResolverEnvironment { kind: Universal { initial_forks: [], markers: true, include: {}, exclude: {ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }, ConflictItem { package: PackageName("example"), kind: Project }, ConflictItem { package: PackageName("subexample"), kind: Project }} } } Resolved 4 packages in [TIME] - "); + "#); let lock = context.read("uv.lock"); From d46a0f248e808dceab761a8c83cc21df6f46d76b Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 24 Jul 2025 13:20:04 -0500 Subject: [PATCH 11/24] Remove unused dependency --- crates/uv-resolver/src/resolver/environment.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/uv-resolver/src/resolver/environment.rs b/crates/uv-resolver/src/resolver/environment.rs index 2cc5c1e5cc241..e23cf8c531be2 100644 --- a/crates/uv-resolver/src/resolver/environment.rs +++ b/crates/uv-resolver/src/resolver/environment.rs @@ -3,7 +3,7 @@ use tracing::trace; use uv_distribution_types::{RequiresPython, RequiresPythonRange}; use uv_pep440::VersionSpecifiers; use uv_pep508::{MarkerEnvironment, MarkerTree}; -use uv_pypi_types::{ConflictItem, ConflictItemRef, ConflictKindRef, ResolverMarkerEnvironment}; +use uv_pypi_types::{ConflictItem, ConflictItemRef, ResolverMarkerEnvironment}; use crate::pubgrub::{PubGrubDependency, PubGrubPackage}; use crate::resolver::ForkState; From 9105464ac65c4b07fdbb57d3c3b79ed53be46d8e Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 24 Jul 2025 14:27:37 -0500 Subject: [PATCH 12/24] Cleanup test --- crates/uv/tests/it/lock.rs | 186 ++----------------------------------- 1 file changed, 8 insertions(+), 178 deletions(-) diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 8fa456c809d77..fe133134b46d5 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -3155,9 +3155,12 @@ fn lock_conflicting_workspace_members_depends_direct_extra() -> Result<()> { [tool.uv] conflicts = [ [ - { package = "example" }, - { package = "example", extra = "foo"}, - { package = "subexample" }, + { 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" }, ], ] @@ -3193,187 +3196,14 @@ fn lock_conflicting_workspace_members_depends_direct_extra() -> Result<()> { )?; // This should succeed, because the conflict is optional - uv_snapshot!(context.filters(), context.lock().arg("-vv"), @r#" + uv_snapshot!(context.filters(), context.lock(), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- - DEBUG uv [VERSION] ([COMMIT] DATE) - DEBUG Found workspace root: `[TEMP_DIR]/` - TRACE Discovering workspace members for: `[TEMP_DIR]/` - DEBUG Adding root workspace member: `[TEMP_DIR]/` - TRACE Processing workspace member: `subexample` - DEBUG Adding discovered workspace member: `[TEMP_DIR]/subexample` - DEBUG No Python version file found in workspace: [TEMP_DIR]/ - DEBUG Using Python request `>=3.12` from `requires-python` metadata - DEBUG Checking for Python environment at: `.venv` - TRACE Querying interpreter executable at [VENV]/bin/python3 - DEBUG The project environment's Python version satisfies the request: `Python >=3.12` - TRACE The project environment's Python version meets the Python requirement: `>=3.12` - TRACE The virtual environment's Python interpreter meets the Python preference: `prefer managed` - DEBUG Using request timeout of [TIME] - DEBUG Found static `pyproject.toml` for: example @ file://[TEMP_DIR]/ - DEBUG Found static `pyproject.toml` for: subexample @ file://[TEMP_DIR]/subexample - DEBUG Found workspace root: `[TEMP_DIR]/` - TRACE Cached workspace members for: `[TEMP_DIR]/` - TRACE Found `pyproject.toml` at: `[TEMP_DIR]/pyproject.toml` - DEBUG Found workspace root: `[TEMP_DIR]/` - TRACE Cached workspace members for: `[TEMP_DIR]/` - TRACE Performing lookahead for example[foo] @ file://[TEMP_DIR]/ - TRACE Performing lookahead for subexample @ file://[TEMP_DIR]/subexample - TRACE Performing lookahead for subexample @ file://[TEMP_DIR]/subexample ; extra == 'foo' - DEBUG Solving with installed Python version: 3.12.[X] - DEBUG Solving with target Python version: >=3.12 - TRACE Assigned packages: - TRACE Chose package for decision: root. remaining choices: - TRACE Resolution environment - includes: `{}` - excludes: `{ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }, ConflictItem { package: PackageName("example"), kind: Project }, ConflictItem { package: PackageName("subexample"), kind: Project }}` - TRACE Resolution environment - includes: `{ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }}` - excludes: `{ConflictItem { package: PackageName("subexample"), kind: Project }, ConflictItem { package: PackageName("example"), kind: Project }}` - TRACE Resolution environment - includes: `{ConflictItem { package: PackageName("example"), kind: Project }}` - excludes: `{ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }, ConflictItem { package: PackageName("subexample"), kind: Project }}` - TRACE Resolution environment - includes: `{ConflictItem { package: PackageName("subexample"), kind: Project }}` - excludes: `{ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }, ConflictItem { package: PackageName("example"), kind: Project }}` - DEBUG Pre-fork all marker environments took [TIME] - DEBUG Splitting resolution on root==0a0.dev0 over example into 4 resolutions with separate markers - DEBUG Adding direct dependency: example[foo]* - DEBUG Adding direct dependency: example* - DEBUG Adding direct dependency: subexample* - TRACE Assigned packages: root==0a0.dev0 - TRACE Chose package for decision: subexample. remaining choices: - DEBUG Searching for a compatible version of subexample @ file://[TEMP_DIR]/subexample (*) - TRACE Resolution environment - includes: `{ConflictItem { package: PackageName("subexample"), kind: Project }}` - excludes: `{ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }, ConflictItem { package: PackageName("example"), kind: Project }}` - DEBUG Adding direct dependency: sortedcontainers>=2.4.0, <2.4.0+ - TRACE Assigned packages: root==0a0.dev0, subexample==0.1.0 - TRACE Chose package for decision: sortedcontainers. remaining choices: - TRACE Fetching metadata for sortedcontainers from https://pypi.org/simple/sortedcontainers/ - TRACE No cache entry exists for [CACHE_DIR]/simple-v16/pypi/sortedcontainers.rkyv - DEBUG No cache entry for: https://pypi.org/simple/sortedcontainers/ - TRACE Sending fresh GET request for https://pypi.org/simple/sortedcontainers/ - TRACE Handling request for https://pypi.org/simple/sortedcontainers/ with authentication policy auto - TRACE Request for https://pypi.org/simple/sortedcontainers/ is unauthenticated, checking cache - TRACE No credentials in cache for URL https://pypi.org/simple/sortedcontainers/ - TRACE Attempting unauthenticated request for https://pypi.org/simple/sortedcontainers/ - TRACE Cached request https://pypi.org/simple/sortedcontainers/ is storable because its response has a 'public' cache-control directive - TRACE Received package metadata for: sortedcontainers - DEBUG Searching for a compatible version of sortedcontainers (>=2.4.0, <2.4.0+) - TRACE Selecting candidate for sortedcontainers with range >=2.4.0, <2.4.0+ with 40 remote versions - TRACE Selecting candidate for sortedcontainers with range >=2.4.0, <2.4.0+ with 40 remote versions - TRACE Found candidate for package sortedcontainers with range >=2.4.0, <2.4.0+ after 1 steps: 2.4.0 version - TRACE Returning candidate for package sortedcontainers with range >=2.4.0, <2.4.0+ after 1 steps - TRACE Found candidate for package sortedcontainers with range >=2.4.0, <2.4.0+ after 1 steps: 2.4.0 version - TRACE Returning candidate for package sortedcontainers with range >=2.4.0, <2.4.0+ after 1 steps - DEBUG Selecting: sortedcontainers==2.4.0 [compatible] (sortedcontainers-2.4.0-py2.py3-none-any.whl) - TRACE No cache entry exists for [CACHE_DIR]/wheels-v5/pypi/sortedcontainers/2.4.0-py2.py3-none-any.msgpack - DEBUG No cache entry for: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl.metadata - TRACE Sending fresh GET request for https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl.metadata - TRACE Handling request for https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl.metadata with authentication policy auto - TRACE Request for https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl.metadata is unauthenticated, checking cache - TRACE No credentials in cache for URL https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl.metadata - TRACE Attempting unauthenticated request for https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl.metadata - TRACE Cached request https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl.metadata is storable because its response has a 'public' cache-control directive - TRACE Received built distribution metadata for: sortedcontainers==2.4.0 - TRACE Resolution environment - includes: `{ConflictItem { package: PackageName("subexample"), kind: Project }}` - excludes: `{ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }, ConflictItem { package: PackageName("example"), kind: Project }}` - TRACE Assigned packages: root==0a0.dev0, subexample==0.1.0, sortedcontainers==2.4.0 - DEBUG Tried 2 versions: sortedcontainers 1, subexample 1 - DEBUG all marker environments resolution took [TIME] - TRACE Assigned packages: root==0a0.dev0 - TRACE Chose package for decision: example. remaining choices: - DEBUG Searching for a compatible version of example @ file://[TEMP_DIR]/ (*) - TRACE Resolution environment - includes: `{ConflictItem { package: PackageName("example"), kind: Project }}` - excludes: `{ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }, ConflictItem { package: PackageName("subexample"), kind: Project }}` - DEBUG Adding direct dependency: sortedcontainers>=2.3.0, <2.3.0+ - TRACE Assigned packages: root==0a0.dev0, example==0.1.0 - TRACE Chose package for decision: sortedcontainers. remaining choices: - TRACE Selecting candidate for sortedcontainers with range >=2.3.0, <2.3.0+ with 40 remote versions - DEBUG Searching for a compatible version of sortedcontainers (>=2.3.0, <2.3.0+) - TRACE Selecting candidate for sortedcontainers with range >=2.3.0, <2.3.0+ with 40 remote versions - TRACE Found candidate for package sortedcontainers with range >=2.3.0, <2.3.0+ after 1 steps: 2.3.0 version - TRACE Found candidate for package sortedcontainers with range >=2.3.0, <2.3.0+ after 1 steps: 2.3.0 version - TRACE Returning candidate for package sortedcontainers with range >=2.3.0, <2.3.0+ after 1 steps - TRACE Returning candidate for package sortedcontainers with range >=2.3.0, <2.3.0+ after 1 steps - DEBUG Selecting: sortedcontainers==2.3.0 [compatible] (sortedcontainers-2.3.0-py2.py3-none-any.whl) - TRACE No cache entry exists for [CACHE_DIR]/wheels-v5/pypi/sortedcontainers/2.3.0-py2.py3-none-any.msgpack - DEBUG No cache entry for: https://files.pythonhosted.org/packages/20/4d/a7046ae1a1a4cc4e9bbed194c387086f06b25038be596543d026946330c9/sortedcontainers-2.3.0-py2.py3-none-any.whl.metadata - TRACE Sending fresh GET request for https://files.pythonhosted.org/packages/20/4d/a7046ae1a1a4cc4e9bbed194c387086f06b25038be596543d026946330c9/sortedcontainers-2.3.0-py2.py3-none-any.whl.metadata - TRACE Handling request for https://files.pythonhosted.org/packages/20/4d/a7046ae1a1a4cc4e9bbed194c387086f06b25038be596543d026946330c9/sortedcontainers-2.3.0-py2.py3-none-any.whl.metadata with authentication policy auto - TRACE Request for https://files.pythonhosted.org/packages/20/4d/a7046ae1a1a4cc4e9bbed194c387086f06b25038be596543d026946330c9/sortedcontainers-2.3.0-py2.py3-none-any.whl.metadata is unauthenticated, checking cache - TRACE No credentials in cache for URL https://files.pythonhosted.org/packages/20/4d/a7046ae1a1a4cc4e9bbed194c387086f06b25038be596543d026946330c9/sortedcontainers-2.3.0-py2.py3-none-any.whl.metadata - TRACE Attempting unauthenticated request for https://files.pythonhosted.org/packages/20/4d/a7046ae1a1a4cc4e9bbed194c387086f06b25038be596543d026946330c9/sortedcontainers-2.3.0-py2.py3-none-any.whl.metadata - TRACE Cached request https://files.pythonhosted.org/packages/20/4d/a7046ae1a1a4cc4e9bbed194c387086f06b25038be596543d026946330c9/sortedcontainers-2.3.0-py2.py3-none-any.whl.metadata is storable because its response has a 'public' cache-control directive - TRACE Received built distribution metadata for: sortedcontainers==2.3.0 - TRACE Resolution environment - includes: `{ConflictItem { package: PackageName("example"), kind: Project }}` - excludes: `{ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }, ConflictItem { package: PackageName("subexample"), kind: Project }}` - TRACE Assigned packages: root==0a0.dev0, example==0.1.0, sortedcontainers==2.3.0 - DEBUG Tried 2 versions: example 1, sortedcontainers 1 - DEBUG all marker environments resolution took [TIME] - TRACE Assigned packages: root==0a0.dev0 - TRACE Chose package for decision: example[foo]. remaining choices: - DEBUG Searching for a compatible version of example @ file://[TEMP_DIR]/ (*) - DEBUG Adding direct dependency: example==0.1.0 - DEBUG Adding direct dependency: example[foo]==0.1.0 - TRACE Assigned packages: root==0a0.dev0 - TRACE Chose package for decision: example. remaining choices: example[foo] - DEBUG Searching for a compatible version of example @ file://[TEMP_DIR]/ (==0.1.0) - TRACE Resolution environment - includes: `{ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }}` - excludes: `{ConflictItem { package: PackageName("subexample"), kind: Project }, ConflictItem { package: PackageName("example"), kind: Project }}` - DEBUG Adding direct dependency: sortedcontainers>=2.3.0, <2.3.0+ - TRACE Assigned packages: root==0a0.dev0, example==0.1.0 - TRACE Chose package for decision: example[foo]. remaining choices: sortedcontainers - TRACE Selecting candidate for sortedcontainers with range >=2.3.0, <2.3.0+ with 40 remote versions - DEBUG Searching for a compatible version of example @ file://[TEMP_DIR]/ (==0.1.0) - TRACE Found candidate for package sortedcontainers with range >=2.3.0, <2.3.0+ after 1 steps: 2.3.0 version - TRACE Returning candidate for package sortedcontainers with range >=2.3.0, <2.3.0+ after 1 steps - TRACE Resolution environment - includes: `{ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }}` - excludes: `{ConflictItem { package: PackageName("subexample"), kind: Project }, ConflictItem { package: PackageName("example"), kind: Project }}` - TRACE Assigned packages: root==0a0.dev0, example==0.1.0, example[foo]==0.1.0 - TRACE Chose package for decision: sortedcontainers. remaining choices: - DEBUG Searching for a compatible version of sortedcontainers (>=2.3.0, <2.3.0+) - TRACE Using preference sortedcontainers 2.3.0 - DEBUG Selecting: sortedcontainers==2.3.0 [preference] (sortedcontainers-2.3.0-py2.py3-none-any.whl) - TRACE Resolution environment - includes: `{ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }}` - excludes: `{ConflictItem { package: PackageName("subexample"), kind: Project }, ConflictItem { package: PackageName("example"), kind: Project }}` - TRACE Assigned packages: root==0a0.dev0, example==0.1.0, example[foo]==0.1.0, sortedcontainers==2.3.0 - DEBUG Tried 2 versions: example 1, sortedcontainers 1 - DEBUG all marker environments resolution took [TIME] - TRACE Assigned packages: root==0a0.dev0 - DEBUG Tried 0 versions: - DEBUG all marker environments resolution took [TIME] - INFO Solved your requirements for 4 environments - TRACE Resolution: ResolverEnvironment { kind: Universal { initial_forks: [], markers: true, include: {ConflictItem { package: PackageName("subexample"), kind: Project }}, exclude: {ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }, ConflictItem { package: PackageName("example"), kind: Project }} } } - TRACE Resolution edge: ROOT -> subexample - TRACE Resolution edge: 0a0.dev0 -> 0.1.0 - TRACE Resolution edge: subexample -> sortedcontainers - TRACE Resolution edge: 0.1.0 -> 2.4.0 - TRACE Resolution: ResolverEnvironment { kind: Universal { initial_forks: [], markers: true, include: {ConflictItem { package: PackageName("example"), kind: Project }}, exclude: {ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }, ConflictItem { package: PackageName("subexample"), kind: Project }} } } - TRACE Resolution edge: ROOT -> example - TRACE Resolution edge: 0a0.dev0 -> 0.1.0 - TRACE Resolution edge: example -> sortedcontainers - TRACE Resolution edge: 0.1.0 -> 2.3.0 - TRACE Resolution: ResolverEnvironment { kind: Universal { initial_forks: [], markers: true, include: {ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }}, exclude: {ConflictItem { package: PackageName("subexample"), kind: Project }, ConflictItem { package: PackageName("example"), kind: Project }} } } - TRACE Resolution edge: ROOT -> example - TRACE Resolution edge: 0a0.dev0 -> 0.1.0 (extra: foo) - TRACE Resolution edge: ROOT -> example - TRACE Resolution edge: 0a0.dev0 -> 0.1.0 - TRACE Resolution edge: example -> sortedcontainers - TRACE Resolution edge: 0.1.0 -> 2.3.0 - TRACE Resolution: ResolverEnvironment { kind: Universal { initial_forks: [], markers: true, include: {}, exclude: {ConflictItem { package: PackageName("example"), kind: Extra(ExtraName("foo")) }, ConflictItem { package: PackageName("example"), kind: Project }, ConflictItem { package: PackageName("subexample"), kind: Project }} } } Resolved 4 packages in [TIME] - "#); + "); let lock = context.read("uv.lock"); From 41b576b402dc0de5c2255e7a53437fdc27338658 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 24 Jul 2025 16:19:39 -0500 Subject: [PATCH 13/24] Add more test cases --- crates/uv/tests/it/lock.rs | 314 ++++++++++++++++++++++++++++++++++++- 1 file changed, 313 insertions(+), 1 deletion(-) diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index fe133134b46d5..d3b868559c455 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -3331,7 +3331,319 @@ fn lock_conflicting_workspace_members_depends_direct_extra() -> Result<()> { Ok(()) } -/// Mar +/// 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 ----- + × 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" + "#, + )?; + + // It's unclear if this should fail. We have an unconditional conflict via `example -> + // indirection[foo] -> subexample`, but... you can _use_ the workspace without `example` + // enabled. + 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" }, + { 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(()) +} + #[test] fn lock_conflicting_extras_depends() -> Result<()> { let context = TestContext::new("3.12"); From a4eff025d26c768eb7e8053a7fc125c15536854b Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 24 Jul 2025 16:22:20 -0500 Subject: [PATCH 14/24] CLippy --- crates/uv-resolver/src/resolver/mod.rs | 28 ++++++++++++++------------ 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index 91cf2be1b4de5..1b8a5c79aa5be 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -3773,17 +3773,16 @@ impl Fork { }; if self.env.included_by_group(conflicting_item) { return true; - } else { - match conflicting_item.kind() { - // We should not filter entire projects unless they're a top-level dependency - ConflictKindRef::Project => { - if dep.parent.is_some() { - return true; - } + } + match conflicting_item.kind() { + // We should not filter entire projects unless they're a top-level dependency + ConflictKindRef::Project => { + if dep.parent.is_some() { + return true; } - ConflictKindRef::Group(_) => {} - ConflictKindRef::Extra(_) => {} } + ConflictKindRef::Group(_) => {} + ConflictKindRef::Extra(_) => {} } if let Some(conflicting_item) = dep.conflicting_item() { self.conflicts.remove(&conflicting_item); @@ -3910,24 +3909,27 @@ pub(crate) fn trace_resolver_environment(env: &ResolverEnvironment) { let markers = env .marker_environment() .map(|m| format!("{m:?}")) - .or_else(|| env.fork_markers().and_then(|m| m.try_to_string())); + .or_else(|| { + env.fork_markers() + .and_then(uv_pep508::MarkerTree::try_to_string) + }); trace!( r"Resolution environment {}{}{}", if let Some(markers) = markers { format!("for split `{markers}`") } else { - "".to_string() + String::new() }, if let Some(includes) = env.includes() { format!("\n includes: `{includes:?}`") } else { - "".to_string() + String::new() }, if let Some(excludes) = env.excludes() { format!("\n excludes: `{excludes:?}`") } else { - "".to_string() + String::new() }, ); } From 3fea8066d40a437824350bcb9bb930261e9c9dbc Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 24 Jul 2025 16:36:04 -0500 Subject: [PATCH 15/24] Add another extra test case --- crates/uv/tests/it/lock.rs | 194 +++++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index d3b868559c455..edde399d5366f 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -3644,6 +3644,7 @@ fn lock_conflicting_workspace_members_depends_transitive_extra() -> Result<()> { Ok(()) } +/// The project declares conflicting extras, but one of the extras directly depends on the other. #[test] fn lock_conflicting_extras_depends() -> Result<()> { let context = TestContext::new("3.12"); @@ -3693,6 +3694,199 @@ fn lock_conflicting_extras_depends() -> Result<()> { Ok(()) } +/// Like [`lock_conflicting_extras_depends`], but the conflict between the extras is mediated by +/// another package +#[test] +fn lock_conflicting_extras_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 = [] + + [project.optional-dependencies] + foo = ["sortedcontainers==2.3.0", "indirection"] + bar = ["sortedcontainers==2.4.0"] + + [tool.uv] + conflicts = [ + [ + { extra = "foo" }, + { extra = "bar" }, + ], + ] + + [tool.uv.sources] + indirection = { workspace = true } + + [tool.uv.workspace] + members = ["indirection"] + + [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 = ["example[bar]"] + + [tool.uv.sources] + example = { workspace = true } + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + // This succeeds, though maybe it shouldn't? There's an unconditional conflict in `example[foo] + // -> indirection[bar] -> example[bar]`, which means `example[foo]` can never be used. It seems + // like this succeeds today because `example[foo]` may not be selected at install time. + uv_snapshot!(context.filters(), context.lock(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + 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 = "bar" }, + { package = "example", extra = "foo" }, + ]] + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [manifest] + members = [ + "example", + "indirection", + ] + + [[package]] + name = "example" + version = "0.1.0" + source = { editable = "." } + + [package.optional-dependencies] + bar = [ + { name = "sortedcontainers", version = "2.4.0", source = { registry = "https://pypi.org/simple" } }, + ] + foo = [ + { name = "indirection" }, + { name = "sortedcontainers", version = "2.3.0", source = { registry = "https://pypi.org/simple" } }, + ] + + [package.metadata] + requires-dist = [ + { name = "indirection", marker = "extra == 'foo'", editable = "indirection" }, + { name = "sortedcontainers", marker = "extra == 'bar'", specifier = "==2.4.0" }, + { name = "sortedcontainers", marker = "extra == 'foo'", specifier = "==2.3.0" }, + ] + provides-extras = ["foo", "bar"] + + [[package]] + name = "indirection" + version = "0.1.0" + source = { editable = "indirection" } + dependencies = [ + { name = "example" }, + { name = "example", extra = ["bar"], marker = "extra == 'extra-7-example-bar'" }, + ] + + [package.metadata] + requires-dist = [{ name = "example", extras = ["bar"], editable = "." }] + + [[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" }, + ] + "# + ); + }); + + // 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] + + example==0.1.0 (from file://[TEMP_DIR]/) + "); + + // Install with `foo` + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra").arg("foo"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Found conflicting extras `example[bar]` and `example[foo]` enabled simultaneously + "); + + // Install the child package + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--package").arg("indirection"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + indirection==0.1.0 (from file://[TEMP_DIR]/indirection) + + sortedcontainers==2.4.0 + "); + + Ok(()) +} + /// This tests another "basic" case for specifying a group that conflicts with /// the project itself. #[test] From 8e4147180b9ebc2b69237b2e1c429eca0262e501 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 24 Jul 2025 17:05:24 -0500 Subject: [PATCH 16/24] Remove test cases moved to https://github.com/astral-sh/uv/pull/14879 --- crates/uv/tests/it/lock.rs | 247 +------------------------------------ 1 file changed, 2 insertions(+), 245 deletions(-) diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index edde399d5366f..cafad47d48a50 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -3510,9 +3510,8 @@ fn lock_conflicting_workspace_members_depends_transitive_extra() -> Result<()> { "#, )?; - // It's unclear if this should fail. We have an unconditional conflict via `example -> - // indirection[foo] -> subexample`, but... you can _use_ the workspace without `example` - // enabled. + // 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 @@ -3644,248 +3643,6 @@ fn lock_conflicting_workspace_members_depends_transitive_extra() -> Result<()> { Ok(()) } -/// The project declares conflicting extras, but one of the extras directly depends on the other. -#[test] -fn lock_conflicting_extras_depends() -> 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 = [] - - [project.optional-dependencies] - foo = ["sortedcontainers==2.3.0", "example[bar]"] - bar = ["sortedcontainers==2.4.0"] - - [tool.uv] - conflicts = [ - [ - { extra = "foo" }, - { extra = "bar" }, - ], - ] - - [build-system] - requires = ["setuptools>=42"] - build-backend = "setuptools.build_meta" - - [tool.setuptools.packages.find] - include = ["example"] - "#, - )?; - - // This should fail to resolve, because the extras are always required together - uv_snapshot!(context.filters(), context.lock(), @r" - success: false - exit_code: 1 - ----- stdout ----- - - ----- stderr ----- - × No solution found when resolving dependencies: - ╰─▶ Because example[foo] depends on sortedcontainers==2.3.0 and sortedcontainers==2.4.0, we can conclude that example[foo]'s requirements are unsatisfiable. - And because your project requires example[foo], we can conclude that your project's requirements are unsatisfiable. - "); - - Ok(()) -} - -/// Like [`lock_conflicting_extras_depends`], but the conflict between the extras is mediated by -/// another package -#[test] -fn lock_conflicting_extras_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 = [] - - [project.optional-dependencies] - foo = ["sortedcontainers==2.3.0", "indirection"] - bar = ["sortedcontainers==2.4.0"] - - [tool.uv] - conflicts = [ - [ - { extra = "foo" }, - { extra = "bar" }, - ], - ] - - [tool.uv.sources] - indirection = { workspace = true } - - [tool.uv.workspace] - members = ["indirection"] - - [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 = ["example[bar]"] - - [tool.uv.sources] - example = { workspace = true } - - [build-system] - requires = ["setuptools>=42"] - build-backend = "setuptools.build_meta" - "#, - )?; - - // This succeeds, though maybe it shouldn't? There's an unconditional conflict in `example[foo] - // -> indirection[bar] -> example[bar]`, which means `example[foo]` can never be used. It seems - // like this succeeds today because `example[foo]` may not be selected at install time. - uv_snapshot!(context.filters(), context.lock(), @r" - success: true - exit_code: 0 - ----- stdout ----- - - ----- stderr ----- - 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 = "bar" }, - { package = "example", extra = "foo" }, - ]] - - [options] - exclude-newer = "2024-03-25T00:00:00Z" - - [manifest] - members = [ - "example", - "indirection", - ] - - [[package]] - name = "example" - version = "0.1.0" - source = { editable = "." } - - [package.optional-dependencies] - bar = [ - { name = "sortedcontainers", version = "2.4.0", source = { registry = "https://pypi.org/simple" } }, - ] - foo = [ - { name = "indirection" }, - { name = "sortedcontainers", version = "2.3.0", source = { registry = "https://pypi.org/simple" } }, - ] - - [package.metadata] - requires-dist = [ - { name = "indirection", marker = "extra == 'foo'", editable = "indirection" }, - { name = "sortedcontainers", marker = "extra == 'bar'", specifier = "==2.4.0" }, - { name = "sortedcontainers", marker = "extra == 'foo'", specifier = "==2.3.0" }, - ] - provides-extras = ["foo", "bar"] - - [[package]] - name = "indirection" - version = "0.1.0" - source = { editable = "indirection" } - dependencies = [ - { name = "example" }, - { name = "example", extra = ["bar"], marker = "extra == 'extra-7-example-bar'" }, - ] - - [package.metadata] - requires-dist = [{ name = "example", extras = ["bar"], editable = "." }] - - [[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" }, - ] - "# - ); - }); - - // 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] - + example==0.1.0 (from file://[TEMP_DIR]/) - "); - - // Install with `foo` - uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra").arg("foo"), @r" - success: false - exit_code: 2 - ----- stdout ----- - - ----- stderr ----- - error: Found conflicting extras `example[bar]` and `example[foo]` enabled simultaneously - "); - - // Install the child package - uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--package").arg("indirection"), @r" - success: true - exit_code: 0 - ----- stdout ----- - - ----- stderr ----- - Prepared 2 packages in [TIME] - Installed 2 packages in [TIME] - + indirection==0.1.0 (from file://[TEMP_DIR]/indirection) - + sortedcontainers==2.4.0 - "); - - Ok(()) -} /// This tests another "basic" case for specifying a group that conflicts with /// the project itself. From 05ce3456101f8d91fb569b7fa955b2c22195ab33 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 24 Jul 2025 17:07:18 -0500 Subject: [PATCH 17/24] Revert "Log resolver environments" This reverts commit 3c1057d6a3a4e93d10cfd9acb0c898e656b659ea. --- crates/uv-resolver/src/error.rs | 3 -- .../uv-resolver/src/resolver/environment.rs | 14 ------- crates/uv-resolver/src/resolver/mod.rs | 37 ------------------- 3 files changed, 54 deletions(-) diff --git a/crates/uv-resolver/src/error.rs b/crates/uv-resolver/src/error.rs index b9b8c9c110d91..0916f54ac1c83 100644 --- a/crates/uv-resolver/src/error.rs +++ b/crates/uv-resolver/src/error.rs @@ -32,7 +32,6 @@ use crate::python_requirement::PythonRequirement; use crate::resolution::ConflictingDistributionError; use crate::resolver::{ MetadataUnavailable, ResolverEnvironment, UnavailablePackage, UnavailableReason, - trace_resolver_environment, }; use crate::{InMemoryIndex, Options}; @@ -477,8 +476,6 @@ impl std::fmt::Display for NoSolutionError { tags: self.tags.as_ref(), }; - trace_resolver_environment(&self.env); - // Transform the error tree for reporting let mut tree = self.error.clone(); simplify_derivation_tree_markers(&self.python_requirement, &mut tree); diff --git a/crates/uv-resolver/src/resolver/environment.rs b/crates/uv-resolver/src/resolver/environment.rs index e23cf8c531be2..6e816f9911ce3 100644 --- a/crates/uv-resolver/src/resolver/environment.rs +++ b/crates/uv-resolver/src/resolver/environment.rs @@ -414,20 +414,6 @@ impl ResolverEnvironment { } } } - - pub(crate) fn includes(&self) -> Option<&Arc>> { - match self.kind { - Kind::Specific { .. } => None, - Kind::Universal { ref include, .. } => Some(include), - } - } - - pub(crate) fn excludes(&self) -> Option<&Arc>> { - match self.kind { - Kind::Specific { .. } => None, - Kind::Universal { ref exclude, .. } => Some(exclude), - } - } } /// A user visible representation of a resolver environment. diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index 1b8a5c79aa5be..ad92ef4a2fdf5 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -3663,9 +3663,6 @@ impl Forks { } forks = new; } - for fork in &forks { - trace_resolver_environment(&fork.env); - } Forks { forks, diverging_packages, @@ -3899,37 +3896,3 @@ struct ConflictTracker { /// Distilled from `culprit` for fast checking in the hot loop. deprioritize: Vec>, } - -/// When trace level logging is enabled, we emit the resolution environment -pub(crate) fn trace_resolver_environment(env: &ResolverEnvironment) { - if !tracing::enabled!(Level::TRACE) { - return; - } - - let markers = env - .marker_environment() - .map(|m| format!("{m:?}")) - .or_else(|| { - env.fork_markers() - .and_then(uv_pep508::MarkerTree::try_to_string) - }); - - trace!( - r"Resolution environment {}{}{}", - if let Some(markers) = markers { - format!("for split `{markers}`") - } else { - String::new() - }, - if let Some(includes) = env.includes() { - format!("\n includes: `{includes:?}`") - } else { - String::new() - }, - if let Some(excludes) = env.excludes() { - format!("\n excludes: `{excludes:?}`") - } else { - String::new() - }, - ); -} From a5ce1df308936cefae750a5fffbb85d55bef552c Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 24 Jul 2025 17:14:40 -0500 Subject: [PATCH 18/24] Update comments --- crates/uv/src/commands/project/install_target.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/uv/src/commands/project/install_target.rs b/crates/uv/src/commands/project/install_target.rs index 1281a7ad28850..9d64bbccb4930 100644 --- a/crates/uv/src/commands/project/install_target.rs +++ b/crates/uv/src/commands/project/install_target.rs @@ -372,6 +372,8 @@ impl<'lock> InstallTarget<'lock> { } /// 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, @@ -379,7 +381,7 @@ impl<'lock> InstallTarget<'lock> { ) -> BTreeSet { match self { Self::Project { name, lock, .. } => { - // Collect the packages by name for lookup + // Collect the packages by name for efficient lookup let packages = lock .packages() .iter() From fb0ca39c69bcd2aa24e129493d3f8a9dccef5f35 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 24 Jul 2025 17:21:26 -0500 Subject: [PATCH 19/24] Expand comment --- crates/uv-resolver/src/resolver/mod.rs | 5 ++--- crates/uv/tests/it/lock.rs | 1 - 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index ad92ef4a2fdf5..395fba523be58 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -3773,6 +3773,7 @@ impl Fork { } 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; @@ -3781,9 +3782,7 @@ impl Fork { ConflictKindRef::Group(_) => {} ConflictKindRef::Extra(_) => {} } - if let Some(conflicting_item) = dep.conflicting_item() { - self.conflicts.remove(&conflicting_item); - } + self.conflicts.remove(&conflicting_item); false }); Some(self) diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index cafad47d48a50..df7c135601425 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -3643,7 +3643,6 @@ fn lock_conflicting_workspace_members_depends_transitive_extra() -> Result<()> { Ok(()) } - /// This tests another "basic" case for specifying a group that conflicts with /// the project itself. #[test] From 49a75fa7975f4a46e08684255ddcad9a992bf40c Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 24 Jul 2025 18:06:35 -0500 Subject: [PATCH 20/24] Add preview warning --- crates/uv/src/commands/project/lock.rs | 17 ++++++++++++++++- crates/uv/tests/it/lock.rs | 5 +++++ 2 files changed, 21 insertions(+), 1 deletion(-) 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/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index df7c135601425..411e23e48f7f6 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -2947,6 +2947,7 @@ fn lock_conflicting_workspace_members() -> Result<()> { ----- 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] "); @@ -3123,6 +3124,7 @@ fn lock_conflicting_workspace_members_depends_direct() -> Result<()> { ----- 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. @@ -3202,6 +3204,7 @@ fn lock_conflicting_workspace_members_depends_direct_extra() -> Result<()> { ----- 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] "); @@ -3418,6 +3421,7 @@ fn lock_conflicting_workspace_members_depends_transitive() -> Result<()> { ----- 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. @@ -3518,6 +3522,7 @@ fn lock_conflicting_workspace_members_depends_transitive_extra() -> Result<()> { ----- 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] "); From 55360d3794209d1299d61b2c6a17c548d1f5c6b8 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Fri, 25 Jul 2025 15:27:59 -0500 Subject: [PATCH 21/24] Merge with preview flag work --- crates/uv-configuration/src/preview.rs | 3 +++ crates/uv/src/commands/project/lock.rs | 7 ++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/uv-configuration/src/preview.rs b/crates/uv-configuration/src/preview.rs index c8d67be5bf424..0bae9a772cdcf 100644 --- a/crates/uv-configuration/src/preview.rs +++ b/crates/uv-configuration/src/preview.rs @@ -14,6 +14,7 @@ bitflags::bitflags! { const JSON_OUTPUT = 1 << 2; const PYLOCK = 1 << 3; const ADD_BOUNDS = 1 << 4; + const PACKAGE_CONFLICTS = 1 << 5; } } @@ -28,6 +29,7 @@ impl PreviewFeatures { Self::JSON_OUTPUT => "json-output", Self::PYLOCK => "pylock", Self::ADD_BOUNDS => "add-bounds", + Self::PACKAGE_CONFLICTS => "package-conflicts", _ => panic!("`flag_as_str` can only be used for exactly one feature flag"), } } @@ -70,6 +72,7 @@ impl FromStr for PreviewFeatures { "json-output" => Self::JSON_OUTPUT, "pylock" => Self::PYLOCK, "add-bounds" => Self::ADD_BOUNDS, + "package-conflicts" => Self::PACKAGE_CONFLICTS, _ => { warn_user_once!("Unknown preview feature: `{part}`"); continue; diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 28a8ff96dffdf..0c7c98ea5c8a0 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -13,7 +13,7 @@ use uv_cache::Cache; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ Concurrency, Constraints, DependencyGroupsWithDefaults, DryRun, ExtrasSpecification, Preview, - Reinstall, Upgrade, + PreviewFeatures, Reinstall, Upgrade, }; use uv_dispatch::BuildDispatch; use uv_distribution::DistributionDatabase; @@ -477,14 +477,15 @@ async fn do_lock( } // Check if any conflicts contain project-level conflicts - if preview.is_disabled() { + if preview.is_enabled(PreviewFeatures::PACKAGE_CONFLICTS) { 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." + "Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", + PreviewFeatures::PACKAGE_CONFLICTS ); break; } From 7927fb3e58a8eb5230415dd2d2486b44d7ca5b19 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Fri, 25 Jul 2025 15:28:44 -0500 Subject: [PATCH 22/24] Add preview feature list --- crates/uv/tests/it/show_settings.rs | 4 ++-- docs/concepts/preview.md | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 65555ba56d393..a3c5c0fa7558f 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -7320,7 +7320,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS, ), }, python_preference: Managed, @@ -7524,7 +7524,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS, ), }, python_preference: Managed, diff --git a/docs/concepts/preview.md b/docs/concepts/preview.md index c84eda38091f7..1789b5d65e37a 100644 --- a/docs/concepts/preview.md +++ b/docs/concepts/preview.md @@ -63,6 +63,7 @@ The following preview features are available: - `add-bounds`: Allows configuring the [default bounds for `uv add`](../reference/settings.md#add-bounds) invocations. - `json-output`: Allows `--output-format json` for various uv commands. +- `package-conflicts`: Allows defining workspace conflicts at the package level. - `pylock`: Allows installing from `pylock.toml` files. - `python-install-default`: Allows [installing `python` and `python3` executables](./python-versions.md#installing-python-executables). From 1dc78400bc4c08f32c8110c29cb515dc656d2108 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Fri, 25 Jul 2025 15:45:53 -0500 Subject: [PATCH 23/24] Fixup check --- crates/uv/src/commands/project/lock.rs | 21 +++++++++------------ crates/uv/tests/it/lock.rs | 14 +++++++++----- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 0c7c98ea5c8a0..f0974ce87ff17 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -477,19 +477,16 @@ async fn do_lock( } // Check if any conflicts contain project-level conflicts - if preview.is_enabled(PreviewFeatures::PACKAGE_CONFLICTS) { - for set in conflicts.iter() { - if set - .iter() + if !preview.is_enabled(PreviewFeatures::PACKAGE_CONFLICTS) + && conflicts.iter().any(|set| { + set.iter() .any(|item| matches!(item.kind(), ConflictKind::Project)) - { - warn_user_once!( - "Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", - PreviewFeatures::PACKAGE_CONFLICTS - ); - break; - } - } + }) + { + warn_user_once!( + "Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", + PreviewFeatures::PACKAGE_CONFLICTS + ); } // Collect the list of supported environments. diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index c6cb59bec0f53..e868a773eedf9 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -2774,6 +2774,7 @@ fn lock_conflicting_project_basic1() -> Result<()> { ----- stdout ----- ----- stderr ----- + warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning. Resolved 3 packages in [TIME] "); @@ -2842,6 +2843,7 @@ fn lock_conflicting_project_basic1() -> Result<()> { ----- stdout ----- ----- stderr ----- + warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning. Resolved 3 packages in [TIME] "); @@ -2947,7 +2949,7 @@ fn lock_conflicting_workspace_members() -> Result<()> { ----- stdout ----- ----- stderr ----- - warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview` to disable this warning. + warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning. Resolved 4 packages in [TIME] "); @@ -3124,7 +3126,7 @@ fn lock_conflicting_workspace_members_depends_direct() -> Result<()> { ----- stdout ----- ----- stderr ----- - warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview` to disable this warning. + warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning. × No solution found when resolving dependencies: ╰─▶ 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. @@ -3204,7 +3206,7 @@ fn lock_conflicting_workspace_members_depends_direct_extra() -> Result<()> { ----- stdout ----- ----- stderr ----- - warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview` to disable this warning. + warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning. Resolved 4 packages in [TIME] "); @@ -3421,7 +3423,7 @@ fn lock_conflicting_workspace_members_depends_transitive() -> Result<()> { ----- stdout ----- ----- stderr ----- - warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview` to disable this warning. + warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning. × No solution found when resolving dependencies: ╰─▶ 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. @@ -3522,7 +3524,7 @@ fn lock_conflicting_workspace_members_depends_transitive_extra() -> Result<()> { ----- stdout ----- ----- stderr ----- - warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview` to disable this warning. + warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning. Resolved 5 packages in [TIME] "); @@ -3692,6 +3694,7 @@ fn lock_conflicting_project_basic2() -> Result<()> { ----- stdout ----- ----- stderr ----- + warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning. Resolved 5 packages in [TIME] "); @@ -3786,6 +3789,7 @@ fn lock_conflicting_project_basic2() -> Result<()> { ----- stdout ----- ----- stderr ----- + warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning. Resolved 5 packages in [TIME] "); From 92f7621c2f2b36848843ad8c739362e0dabec2b1 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Sat, 2 Aug 2025 07:58:38 -0500 Subject: [PATCH 24/24] Review --- crates/uv-resolver/src/pubgrub/dependencies.rs | 10 ++-------- crates/uv/src/commands/project/install_target.rs | 8 ++++---- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/crates/uv-resolver/src/pubgrub/dependencies.rs b/crates/uv-resolver/src/pubgrub/dependencies.rs index 525f69f1dda22..d40325698c7a2 100644 --- a/crates/uv-resolver/src/pubgrub/dependencies.rs +++ b/crates/uv-resolver/src/pubgrub/dependencies.rs @@ -27,7 +27,7 @@ pub(crate) struct PubGrubDependency { /// 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 + /// The main problem with dealing with project level conflicts is that if you /// declare a conflict between a package and a group, we represent that /// group as a dependency of that package. So if you filter out the package /// in a fork due to a conflict, you also filter out the group. Therefore, @@ -169,13 +169,7 @@ impl PubGrubDependency { /// 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 + self.package.conflicting_item() } } diff --git a/crates/uv/src/commands/project/install_target.rs b/crates/uv/src/commands/project/install_target.rs index 9d64bbccb4930..dc7f08658a043 100644 --- a/crates/uv/src/commands/project/install_target.rs +++ b/crates/uv/src/commands/project/install_target.rs @@ -378,7 +378,7 @@ impl<'lock> InstallTarget<'lock> { &self, extras: &ExtrasSpecification, groups: &DependencyGroupsWithDefaults, - ) -> BTreeSet { + ) -> BTreeSet<&PackageName> { match self { Self::Project { name, lock, .. } => { // Collect the packages by name for efficient lookup @@ -390,7 +390,7 @@ impl<'lock> InstallTarget<'lock> { // We'll include the project itself let mut required_members = BTreeSet::new(); - required_members.insert((*name).clone()); + required_members.insert(*name); // Find all workspace member dependencies recursively let mut queue: VecDeque<(&PackageName, Option<&ExtraName>)> = VecDeque::new(); @@ -429,7 +429,7 @@ impl<'lock> InstallTarget<'lock> { while let Some((pkg_name, extra)) = queue.pop_front() { if lock.members().contains(pkg_name) { - required_members.insert(pkg_name.clone()); + required_members.insert(pkg_name); } let Some(package) = packages.get(pkg_name) else { @@ -465,7 +465,7 @@ impl<'lock> InstallTarget<'lock> { } Self::Workspace { lock, .. } | Self::NonProjectWorkspace { lock, .. } => { // Return all workspace members - lock.members().clone() + lock.members().iter().collect() } Self::Script { .. } => { // Scripts don't have workspace members