Add support for package-level conflicts in workspaces (#14906)
Some checks are pending
CI / Determine changes (push) Waiting to run
CI / lint (push) Waiting to run
CI / cargo clippy | ubuntu (push) Blocked by required conditions
CI / cargo clippy | windows (push) Blocked by required conditions
CI / cargo dev generate-all (push) Blocked by required conditions
CI / cargo shear (push) Waiting to run
CI / cargo test | ubuntu (push) Blocked by required conditions
CI / cargo test | macos (push) Blocked by required conditions
CI / cargo test | windows (push) Blocked by required conditions
CI / check windows trampoline | aarch64 (push) Blocked by required conditions
CI / check windows trampoline | i686 (push) Blocked by required conditions
CI / check windows trampoline | x86_64 (push) Blocked by required conditions
CI / test windows trampoline | aarch64 (push) Blocked by required conditions
CI / test windows trampoline | i686 (push) Blocked by required conditions
CI / test windows trampoline | x86_64 (push) Blocked by required conditions
CI / typos (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / build binary | linux libc (push) Blocked by required conditions
CI / build binary | linux aarch64 (push) Blocked by required conditions
CI / build binary | linux musl (push) Blocked by required conditions
CI / build binary | macos aarch64 (push) Blocked by required conditions
CI / build binary | macos x86_64 (push) Blocked by required conditions
CI / build binary | windows x86_64 (push) Blocked by required conditions
CI / build binary | windows aarch64 (push) Blocked by required conditions
CI / build binary | msrv (push) Blocked by required conditions
CI / build binary | freebsd (push) Blocked by required conditions
CI / ecosystem test | pydantic/pydantic-core (push) Blocked by required conditions
CI / ecosystem test | prefecthq/prefect (push) Blocked by required conditions
CI / ecosystem test | pallets/flask (push) Blocked by required conditions
CI / smoke test | linux (push) Blocked by required conditions
CI / smoke test | linux aarch64 (push) Blocked by required conditions
CI / check system | alpine (push) Blocked by required conditions
CI / smoke test | macos (push) Blocked by required conditions
CI / smoke test | windows x86_64 (push) Blocked by required conditions
CI / smoke test | windows aarch64 (push) Blocked by required conditions
CI / integration test | conda on ubuntu (push) Blocked by required conditions
CI / integration test | deadsnakes python3.9 on ubuntu (push) Blocked by required conditions
CI / integration test | free-threaded on windows (push) Blocked by required conditions
CI / integration test | aarch64 windows implicit (push) Blocked by required conditions
CI / integration test | aarch64 windows explicit (push) Blocked by required conditions
CI / integration test | pypy on ubuntu (push) Blocked by required conditions
CI / integration test | pypy on windows (push) Blocked by required conditions
CI / integration test | graalpy on ubuntu (push) Blocked by required conditions
CI / integration test | graalpy on windows (push) Blocked by required conditions
CI / integration test | pyodide on ubuntu (push) Blocked by required conditions
CI / integration test | github actions (push) Blocked by required conditions
CI / integration test | free-threaded python on github actions (push) Blocked by required conditions
CI / integration test | determine publish changes (push) Blocked by required conditions
CI / integration test | registries (push) Blocked by required conditions
CI / integration test | uv publish (push) Blocked by required conditions
CI / integration test | uv_build (push) Blocked by required conditions
CI / check cache | ubuntu (push) Blocked by required conditions
CI / check cache | macos aarch64 (push) Blocked by required conditions
CI / check system | python on debian (push) Blocked by required conditions
CI / check system | python on fedora (push) Blocked by required conditions
CI / check system | python on ubuntu (push) Blocked by required conditions
CI / check system | python on rocky linux 8 (push) Blocked by required conditions
CI / check system | python on rocky linux 9 (push) Blocked by required conditions
CI / check system | graalpy on ubuntu (push) Blocked by required conditions
CI / check system | pypy on ubuntu (push) Blocked by required conditions
CI / check system | pyston (push) Blocked by required conditions
CI / check system | python on macos aarch64 (push) Blocked by required conditions
CI / check system | homebrew python on macos aarch64 (push) Blocked by required conditions
CI / check system | python3.9 via pyenv (push) Blocked by required conditions
CI / check system | conda3.8 on macos aarch64 (push) Blocked by required conditions
CI / check system | python on macos x86-64 (push) Blocked by required conditions
CI / check system | python3.10 on windows x86-64 (push) Blocked by required conditions
CI / check system | python3.10 on windows x86 (push) Blocked by required conditions
CI / check system | python3.13 on windows x86-64 (push) Blocked by required conditions
CI / check system | x86-64 python3.13 on windows aarch64 (push) Blocked by required conditions
CI / check system | aarch64 python3.13 on windows aarch64 (push) Blocked by required conditions
CI / check system | windows registry (push) Blocked by required conditions
CI / check system | python3.12 via chocolatey (push) Blocked by required conditions
CI / check system | python3.13 (push) Blocked by required conditions
CI / check system | conda3.11 on macos aarch64 (push) Blocked by required conditions
CI / check system | conda3.11 on linux x86-64 (push) Blocked by required conditions
CI / check system | conda3.8 on linux x86-64 (push) Blocked by required conditions
CI / check system | conda3.11 on windows x86-64 (push) Blocked by required conditions
CI / check system | conda3.8 on windows x86-64 (push) Blocked by required conditions
CI / check system | amazonlinux (push) Blocked by required conditions
CI / check system | embedded python3.10 on windows x86-64 (push) Blocked by required conditions
CI / benchmarks | walltime aarch64 linux (push) Blocked by required conditions
CI / benchmarks | instrumented (push) Blocked by required conditions
zizmor / Run zizmor (push) Waiting to run

Revives https://github.com/astral-sh/uv/pull/9130

Previously, we allowed scoping conflicting extras or groups to specific
packages, e.g. ,`{ package = "foo", extra = "bar" }` for a conflict in
`foo[bar]`. Now, we allow dropping the `extra` or `group` bit and using
`{ package = "foo" }` directly which declares a conflict with `foo`'s
production dependencies.

This means you can declare conflicts between workspace members, e.g.:

```
[tool.uv]
conflicts = [[{ package = "foo" }, { package = "bar" }]]
```

would not allow `foo` and `bar` to be installed at the same time.

Similarly, a conflict can be declared between a package and a group:

```
[tool.uv]
conflicts = [[{ package = "foo" }, { group = "lint" }]]
```

which would mean, e.g., that `--only-group lint` would be required for
the invocation.

As with our existing support for conflicting extras, there are
edge-cases here where the resolver will _not_ fail even if there are
conflicts that render a particular install target unusable. There's test
coverage for some of these. We'll still error at install-time when the
conflicting groups are selected. Due to the likelihood of bugs in this
feature, I've marked it as a preview feature.

I would not recommend reading the commits as there's some slop from not
wanting to rebase Andrew's branch.

---------

Co-authored-by: Andrew Gallant <andrew@astral.sh>
This commit is contained in:
Zanie Blue 2025-08-08 07:44:58 -05:00 committed by GitHub
parent a9302906ce
commit 8f71d239f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1913 additions and 240 deletions

View file

@ -14,7 +14,8 @@ bitflags::bitflags! {
const JSON_OUTPUT = 1 << 2; const JSON_OUTPUT = 1 << 2;
const PYLOCK = 1 << 3; const PYLOCK = 1 << 3;
const ADD_BOUNDS = 1 << 4; const ADD_BOUNDS = 1 << 4;
const EXTRA_BUILD_DEPENDENCIES = 1 << 5; const PACKAGE_CONFLICTS = 1 << 5;
const EXTRA_BUILD_DEPENDENCIES = 1 << 6;
} }
} }
@ -29,6 +30,7 @@ impl PreviewFeatures {
Self::JSON_OUTPUT => "json-output", Self::JSON_OUTPUT => "json-output",
Self::PYLOCK => "pylock", Self::PYLOCK => "pylock",
Self::ADD_BOUNDS => "add-bounds", Self::ADD_BOUNDS => "add-bounds",
Self::PACKAGE_CONFLICTS => "package-conflicts",
Self::EXTRA_BUILD_DEPENDENCIES => "extra-build-dependencies", Self::EXTRA_BUILD_DEPENDENCIES => "extra-build-dependencies",
_ => panic!("`flag_as_str` can only be used for exactly one feature flag"), _ => panic!("`flag_as_str` can only be used for exactly one feature flag"),
} }
@ -72,6 +74,7 @@ impl FromStr for PreviewFeatures {
"json-output" => Self::JSON_OUTPUT, "json-output" => Self::JSON_OUTPUT,
"pylock" => Self::PYLOCK, "pylock" => Self::PYLOCK,
"add-bounds" => Self::ADD_BOUNDS, "add-bounds" => Self::ADD_BOUNDS,
"package-conflicts" => Self::PACKAGE_CONFLICTS,
"extra-build-dependencies" => Self::EXTRA_BUILD_DEPENDENCIES, "extra-build-dependencies" => Self::EXTRA_BUILD_DEPENDENCIES,
_ => { _ => {
warn_user_once!("Unknown preview feature: `{part}`"); warn_user_once!("Unknown preview feature: `{part}`");
@ -235,6 +238,10 @@ mod tests {
assert_eq!(PreviewFeatures::JSON_OUTPUT.flag_as_str(), "json-output"); assert_eq!(PreviewFeatures::JSON_OUTPUT.flag_as_str(), "json-output");
assert_eq!(PreviewFeatures::PYLOCK.flag_as_str(), "pylock"); assert_eq!(PreviewFeatures::PYLOCK.flag_as_str(), "pylock");
assert_eq!(PreviewFeatures::ADD_BOUNDS.flag_as_str(), "add-bounds"); assert_eq!(PreviewFeatures::ADD_BOUNDS.flag_as_str(), "add-bounds");
assert_eq!(
PreviewFeatures::PACKAGE_CONFLICTS.flag_as_str(),
"package-conflicts"
);
assert_eq!( assert_eq!(
PreviewFeatures::EXTRA_BUILD_DEPENDENCIES.flag_as_str(), PreviewFeatures::EXTRA_BUILD_DEPENDENCIES.flag_as_str(),
"extra-build-dependencies" "extra-build-dependencies"

View file

@ -41,10 +41,10 @@ impl Conflicts {
pub fn contains<'a>( pub fn contains<'a>(
&self, &self,
package: &PackageName, package: &PackageName,
conflict: impl Into<ConflictPackageRef<'a>>, kind: impl Into<ConflictKindRef<'a>>,
) -> bool { ) -> bool {
let conflict = conflict.into(); let kind = kind.into();
self.iter().any(|set| set.contains(package, conflict)) self.iter().any(|set| set.contains(package, kind))
} }
/// Returns true if there are no conflicts. /// Returns true if there are no conflicts.
@ -106,7 +106,7 @@ impl Conflicts {
for set in &self.0 { for set in &self.0 {
direct_conflict_sets.insert(set); direct_conflict_sets.insert(set);
for item in set.iter() { for item in set.iter() {
let ConflictPackage::Group(group) = &item.conflict else { let ConflictKind::Group(group) = &item.kind else {
// TODO(john): Do we also want to handle extras here? // TODO(john): Do we also want to handle extras here?
continue; continue;
}; };
@ -129,7 +129,7 @@ impl Conflicts {
} }
let group_conflict_item = ConflictItem { let group_conflict_item = ConflictItem {
package: package.clone(), package: package.clone(),
conflict: ConflictPackage::Group(group.clone()), kind: ConflictKind::Group(group.clone()),
}; };
let node_id = graph.add_node(FxHashSet::default()); let node_id = graph.add_node(FxHashSet::default());
group_node_idxs.insert(group, node_id); group_node_idxs.insert(group, node_id);
@ -242,11 +242,11 @@ impl ConflictSet {
pub fn contains<'a>( pub fn contains<'a>(
&self, &self,
package: &PackageName, package: &PackageName,
conflict: impl Into<ConflictPackageRef<'a>>, kind: impl Into<ConflictKindRef<'a>>,
) -> bool { ) -> bool {
let conflict = conflict.into(); let kind = kind.into();
self.iter() self.iter()
.any(|set| set.package() == package && *set.conflict() == conflict) .any(|set| set.package() == package && *set.kind() == kind)
} }
/// Returns true if these conflicts contain any set that contains the given /// Returns true if these conflicts contain any set that contains the given
@ -326,7 +326,7 @@ impl TryFrom<Vec<ConflictItem>> for ConflictSet {
)] )]
pub struct ConflictItem { pub struct ConflictItem {
package: PackageName, package: PackageName,
conflict: ConflictPackage, kind: ConflictKind,
} }
impl ConflictItem { impl ConflictItem {
@ -338,40 +338,47 @@ impl ConflictItem {
/// Returns the package-specific conflict. /// Returns the package-specific conflict.
/// ///
/// i.e., Either an extra or a group name. /// i.e., Either an extra or a group name.
pub fn conflict(&self) -> &ConflictPackage { pub fn kind(&self) -> &ConflictKind {
&self.conflict &self.kind
} }
/// Returns the extra name of this conflicting item. /// Returns the extra name of this conflicting item.
pub fn extra(&self) -> Option<&ExtraName> { pub fn extra(&self) -> Option<&ExtraName> {
self.conflict.extra() self.kind.extra()
} }
/// Returns the group name of this conflicting item. /// Returns the group name of this conflicting item.
pub fn group(&self) -> Option<&GroupName> { pub fn group(&self) -> Option<&GroupName> {
self.conflict.group() self.kind.group()
} }
/// Returns this item as a new type with its fields borrowed. /// Returns this item as a new type with its fields borrowed.
pub fn as_ref(&self) -> ConflictItemRef<'_> { pub fn as_ref(&self) -> ConflictItemRef<'_> {
ConflictItemRef { ConflictItemRef {
package: self.package(), package: self.package(),
conflict: self.conflict.as_ref(), kind: self.kind.as_ref(),
} }
} }
} }
impl From<PackageName> for ConflictItem {
fn from(package: PackageName) -> Self {
let kind = ConflictKind::Project;
Self { package, kind }
}
}
impl From<(PackageName, ExtraName)> for ConflictItem { impl From<(PackageName, ExtraName)> for ConflictItem {
fn from((package, extra): (PackageName, ExtraName)) -> Self { fn from((package, extra): (PackageName, ExtraName)) -> Self {
let conflict = ConflictPackage::Extra(extra); let kind = ConflictKind::Extra(extra);
Self { package, conflict } Self { package, kind }
} }
} }
impl From<(PackageName, GroupName)> for ConflictItem { impl From<(PackageName, GroupName)> for ConflictItem {
fn from((package, group): (PackageName, GroupName)) -> Self { fn from((package, group): (PackageName, GroupName)) -> Self {
let conflict = ConflictPackage::Group(group); let kind = ConflictKind::Group(group);
Self { package, conflict } Self { package, kind }
} }
} }
@ -382,7 +389,7 @@ impl From<(PackageName, GroupName)> for ConflictItem {
#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, PartialOrd, Ord)]
pub struct ConflictItemRef<'a> { pub struct ConflictItemRef<'a> {
package: &'a PackageName, package: &'a PackageName,
conflict: ConflictPackageRef<'a>, kind: ConflictKindRef<'a>,
} }
impl<'a> ConflictItemRef<'a> { impl<'a> ConflictItemRef<'a> {
@ -394,40 +401,47 @@ impl<'a> ConflictItemRef<'a> {
/// Returns the package-specific conflict. /// Returns the package-specific conflict.
/// ///
/// i.e., Either an extra or a group name. /// i.e., Either an extra or a group name.
pub fn conflict(&self) -> ConflictPackageRef<'a> { pub fn kind(&self) -> ConflictKindRef<'a> {
self.conflict self.kind
} }
/// Returns the extra name of this conflicting item. /// Returns the extra name of this conflicting item.
pub fn extra(&self) -> Option<&'a ExtraName> { pub fn extra(&self) -> Option<&'a ExtraName> {
self.conflict.extra() self.kind.extra()
} }
/// Returns the group name of this conflicting item. /// Returns the group name of this conflicting item.
pub fn group(&self) -> Option<&'a GroupName> { pub fn group(&self) -> Option<&'a GroupName> {
self.conflict.group() self.kind.group()
} }
/// Converts this borrowed conflicting item to its owned variant. /// Converts this borrowed conflicting item to its owned variant.
pub fn to_owned(&self) -> ConflictItem { pub fn to_owned(&self) -> ConflictItem {
ConflictItem { ConflictItem {
package: self.package().clone(), package: self.package().clone(),
conflict: self.conflict.to_owned(), kind: self.kind.to_owned(),
} }
} }
} }
impl<'a> From<&'a PackageName> for ConflictItemRef<'a> {
fn from(package: &'a PackageName) -> Self {
let kind = ConflictKindRef::Project;
Self { package, kind }
}
}
impl<'a> From<(&'a PackageName, &'a ExtraName)> for ConflictItemRef<'a> { impl<'a> From<(&'a PackageName, &'a ExtraName)> for ConflictItemRef<'a> {
fn from((package, extra): (&'a PackageName, &'a ExtraName)) -> Self { fn from((package, extra): (&'a PackageName, &'a ExtraName)) -> Self {
let conflict = ConflictPackageRef::Extra(extra); let kind = ConflictKindRef::Extra(extra);
ConflictItemRef { package, conflict } ConflictItemRef { package, kind }
} }
} }
impl<'a> From<(&'a PackageName, &'a GroupName)> for ConflictItemRef<'a> { impl<'a> From<(&'a PackageName, &'a GroupName)> for ConflictItemRef<'a> {
fn from((package, group): (&'a PackageName, &'a GroupName)) -> Self { fn from((package, group): (&'a PackageName, &'a GroupName)) -> Self {
let conflict = ConflictPackageRef::Group(group); let kind = ConflictKindRef::Group(group);
ConflictItemRef { package, conflict } ConflictItemRef { package, kind }
} }
} }
@ -439,20 +453,22 @@ impl hashbrown::Equivalent<ConflictItem> for ConflictItemRef<'_> {
/// The actual conflicting data for a package. /// 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)] #[derive(Debug, Clone, Eq, Hash, PartialEq, PartialOrd, Ord)]
pub enum ConflictPackage { #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum ConflictKind {
Extra(ExtraName), Extra(ExtraName),
Group(GroupName), Group(GroupName),
Project,
} }
impl ConflictPackage { impl ConflictKind {
/// If this conflict corresponds to an extra, then return the /// If this conflict corresponds to an extra, then return the
/// extra name. /// extra name.
pub fn extra(&self) -> Option<&ExtraName> { pub fn extra(&self) -> Option<&ExtraName> {
match self { match self {
Self::Extra(extra) => Some(extra), Self::Extra(extra) => Some(extra),
Self::Group(_) => None, Self::Group(_) | Self::Project => None,
} }
} }
@ -461,15 +477,16 @@ impl ConflictPackage {
pub fn group(&self) -> Option<&GroupName> { pub fn group(&self) -> Option<&GroupName> {
match self { match self {
Self::Group(group) => Some(group), Self::Group(group) => Some(group),
Self::Extra(_) => None, Self::Extra(_) | Self::Project => None,
} }
} }
/// Returns this conflict as a new type with its fields borrowed. /// 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 { match self {
Self::Extra(extra) => ConflictPackageRef::Extra(extra), Self::Extra(extra) => ConflictKindRef::Extra(extra),
Self::Group(group) => ConflictPackageRef::Group(group), Self::Group(group) => ConflictKindRef::Group(group),
Self::Project => ConflictKindRef::Project,
} }
} }
} }
@ -478,18 +495,19 @@ impl ConflictPackage {
/// ///
/// That is, either a borrowed extra name or a borrowed group name. /// That is, either a borrowed extra name or a borrowed group name.
#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, PartialOrd, Ord)]
pub enum ConflictPackageRef<'a> { pub enum ConflictKindRef<'a> {
Extra(&'a ExtraName), Extra(&'a ExtraName),
Group(&'a GroupName), Group(&'a GroupName),
Project,
} }
impl<'a> ConflictPackageRef<'a> { impl<'a> ConflictKindRef<'a> {
/// If this conflict corresponds to an extra, then return the /// If this conflict corresponds to an extra, then return the
/// extra name. /// extra name.
pub fn extra(&self) -> Option<&'a ExtraName> { pub fn extra(&self) -> Option<&'a ExtraName> {
match self { match self {
Self::Extra(extra) => Some(extra), Self::Extra(extra) => Some(extra),
Self::Group(_) => None, Self::Group(_) | Self::Project => None,
} }
} }
@ -498,45 +516,46 @@ impl<'a> ConflictPackageRef<'a> {
pub fn group(&self) -> Option<&'a GroupName> { pub fn group(&self) -> Option<&'a GroupName> {
match self { match self {
Self::Group(group) => Some(group), Self::Group(group) => Some(group),
Self::Extra(_) => None, Self::Extra(_) | Self::Project => None,
} }
} }
/// Converts this borrowed conflict to its owned variant. /// Converts this borrowed conflict to its owned variant.
pub fn to_owned(&self) -> ConflictPackage { pub fn to_owned(&self) -> ConflictKind {
match *self { match self {
Self::Extra(extra) => ConflictPackage::Extra(extra.clone()), Self::Extra(extra) => ConflictKind::Extra((*extra).clone()),
Self::Group(group) => ConflictPackage::Group(group.clone()), Self::Group(group) => ConflictKind::Group((*group).clone()),
Self::Project => ConflictKind::Project,
} }
} }
} }
impl<'a> From<&'a ExtraName> for ConflictPackageRef<'a> { impl<'a> From<&'a ExtraName> for ConflictKindRef<'a> {
fn from(extra: &'a ExtraName) -> Self { fn from(extra: &'a ExtraName) -> Self {
Self::Extra(extra) Self::Extra(extra)
} }
} }
impl<'a> From<&'a GroupName> for ConflictPackageRef<'a> { impl<'a> From<&'a GroupName> for ConflictKindRef<'a> {
fn from(group: &'a GroupName) -> Self { fn from(group: &'a GroupName) -> Self {
Self::Group(group) Self::Group(group)
} }
} }
impl PartialEq<ConflictPackage> for ConflictPackageRef<'_> { impl PartialEq<ConflictKind> for ConflictKindRef<'_> {
fn eq(&self, other: &ConflictPackage) -> bool { fn eq(&self, other: &ConflictKind) -> bool {
other.as_ref() == *self other.as_ref() == *self
} }
} }
impl<'a> PartialEq<ConflictPackageRef<'a>> for ConflictPackage { impl<'a> PartialEq<ConflictKindRef<'a>> for ConflictKind {
fn eq(&self, other: &ConflictPackageRef<'a>) -> bool { fn eq(&self, other: &ConflictKindRef<'a>) -> bool {
self.as_ref() == *other self.as_ref() == *other
} }
} }
impl hashbrown::Equivalent<ConflictPackage> for ConflictPackageRef<'_> { impl hashbrown::Equivalent<ConflictKind> for ConflictKindRef<'_> {
fn equivalent(&self, key: &ConflictPackage) -> bool { fn equivalent(&self, key: &ConflictKind) -> bool {
key.as_ref() == *self key.as_ref() == *self
} }
} }
@ -557,9 +576,9 @@ pub enum ConflictError {
/// optional.) /// optional.)
#[error("Expected `package` field in conflicting entry")] #[error("Expected `package` field in conflicting entry")]
MissingPackage, MissingPackage,
/// An error that occurs when both `extra` and `group` are missing. /// An error that occurs when all of `package`, `extra` and `group` are missing.
#[error("Expected `extra` or `group` field in conflicting entry")] #[error("Expected `package`, `extra` or `group` field in conflicting entry")]
MissingExtraAndGroup, MissingPackageAndExtraAndGroup,
/// An error that occurs when both `extra` and `group` are present. /// An error that occurs when both `extra` and `group` are present.
#[error("Expected one of `extra` or `group` in conflicting entry, but found both")] #[error("Expected one of `extra` or `group` in conflicting entry, but found both")]
FoundExtraAndGroup, FoundExtraAndGroup,
@ -596,7 +615,7 @@ impl SchemaConflicts {
let package = item.package.clone().unwrap_or_else(|| package.clone()); let package = item.package.clone().unwrap_or_else(|| package.clone());
set.push(ConflictItem { set.push(ConflictItem {
package: package.clone(), package: package.clone(),
conflict: item.conflict.clone(), kind: item.kind.clone(),
}); });
} }
// OK because we guarantee that // OK because we guarantee that
@ -635,7 +654,7 @@ pub struct SchemaConflictSet(Vec<SchemaConflictItem>);
)] )]
pub struct SchemaConflictItem { pub struct SchemaConflictItem {
package: Option<PackageName>, package: Option<PackageName>,
conflict: ConflictPackage, kind: ConflictKind,
} }
#[cfg(feature = "schemars")] #[cfg(feature = "schemars")]
@ -695,8 +714,8 @@ impl TryFrom<ConflictItemWire> for ConflictItem {
return Err(ConflictError::MissingPackage); return Err(ConflictError::MissingPackage);
}; };
match (wire.extra, wire.group) { match (wire.extra, wire.group) {
(None, None) => Err(ConflictError::MissingExtraAndGroup),
(Some(_), Some(_)) => Err(ConflictError::FoundExtraAndGroup), (Some(_), Some(_)) => Err(ConflictError::FoundExtraAndGroup),
(None, None) => Ok(Self::from(package)),
(Some(extra), None) => Ok(Self::from((package, extra))), (Some(extra), None) => Ok(Self::from((package, extra))),
(None, Some(group)) => Ok(Self::from((package, group))), (None, Some(group)) => Ok(Self::from((package, group))),
} }
@ -705,17 +724,22 @@ impl TryFrom<ConflictItemWire> for ConflictItem {
impl From<ConflictItem> for ConflictItemWire { impl From<ConflictItem> for ConflictItemWire {
fn from(item: ConflictItem) -> Self { fn from(item: ConflictItem) -> Self {
match item.conflict { match item.kind {
ConflictPackage::Extra(extra) => Self { ConflictKind::Extra(extra) => Self {
package: Some(item.package), package: Some(item.package),
extra: Some(extra), extra: Some(extra),
group: None, group: None,
}, },
ConflictPackage::Group(group) => Self { ConflictKind::Group(group) => Self {
package: Some(item.package), package: Some(item.package),
extra: None, extra: None,
group: Some(group), group: Some(group),
}, },
ConflictKind::Project => Self {
package: Some(item.package),
extra: None,
group: None,
},
} }
} }
} }
@ -726,15 +750,23 @@ impl TryFrom<ConflictItemWire> for SchemaConflictItem {
fn try_from(wire: ConflictItemWire) -> Result<Self, ConflictError> { fn try_from(wire: ConflictItemWire) -> Result<Self, ConflictError> {
let package = wire.package; let package = wire.package;
match (wire.extra, wire.group) { match (wire.extra, wire.group) {
(None, None) => Err(ConflictError::MissingExtraAndGroup),
(Some(_), Some(_)) => Err(ConflictError::FoundExtraAndGroup), (Some(_), Some(_)) => Err(ConflictError::FoundExtraAndGroup),
(None, None) => {
let Some(package) = package else {
return Err(ConflictError::MissingPackageAndExtraAndGroup);
};
Ok(Self {
package: Some(package),
kind: ConflictKind::Project,
})
}
(Some(extra), None) => Ok(Self { (Some(extra), None) => Ok(Self {
package, package,
conflict: ConflictPackage::Extra(extra), kind: ConflictKind::Extra(extra),
}), }),
(None, Some(group)) => Ok(Self { (None, Some(group)) => Ok(Self {
package, package,
conflict: ConflictPackage::Group(group), kind: ConflictKind::Group(group),
}), }),
} }
} }
@ -742,17 +774,22 @@ impl TryFrom<ConflictItemWire> for SchemaConflictItem {
impl From<SchemaConflictItem> for ConflictItemWire { impl From<SchemaConflictItem> for ConflictItemWire {
fn from(item: SchemaConflictItem) -> Self { fn from(item: SchemaConflictItem) -> Self {
match item.conflict { match item.kind {
ConflictPackage::Extra(extra) => Self { ConflictKind::Extra(extra) => Self {
package: item.package, package: item.package,
extra: Some(extra), extra: Some(extra),
group: None, group: None,
}, },
ConflictPackage::Group(group) => Self { ConflictKind::Group(group) => Self {
package: item.package, package: item.package,
extra: None, extra: None,
group: Some(group), group: Some(group),
}, },
ConflictKind::Project => Self {
package: item.package,
extra: None,
group: None,
},
} }
} }
} }

View file

@ -185,7 +185,7 @@ pub(crate) fn simplify_conflict_markers(
let mut new_set = BTreeSet::default(); let mut new_set = BTreeSet::default();
for item in set { for item in set {
for conflict_set in conflicts.iter() { for conflict_set in conflicts.iter() {
if !conflict_set.contains(item.package(), item.as_ref().conflict()) { if !conflict_set.contains(item.package(), item.as_ref().kind()) {
continue; continue;
} }
for conflict_item in conflict_set.iter() { for conflict_item in conflict_set.iter() {

View file

@ -48,6 +48,7 @@ pub trait Installable<'lock> {
let mut queue: VecDeque<(&Package, Option<&ExtraName>)> = VecDeque::new(); let mut queue: VecDeque<(&Package, Option<&ExtraName>)> = VecDeque::new();
let mut seen = FxHashSet::default(); let mut seen = FxHashSet::default();
let mut activated_projects: Vec<&PackageName> = vec![];
let mut activated_extras: Vec<(&PackageName, &ExtraName)> = vec![]; let mut activated_extras: Vec<(&PackageName, &ExtraName)> = vec![];
let mut activated_groups: Vec<(&PackageName, &GroupName)> = vec![]; let mut activated_groups: Vec<(&PackageName, &GroupName)> = vec![];
@ -74,6 +75,7 @@ pub trait Installable<'lock> {
// Track the activated extras. // Track the activated extras.
if dev.prod() { if dev.prod() {
activated_projects.push(&dist.id.name);
for extra in extras.extra_names(dist.optional_dependencies.keys()) { for extra in extras.extra_names(dist.optional_dependencies.keys()) {
activated_extras.push((&dist.id.name, extra)); activated_extras.push((&dist.id.name, extra));
} }
@ -143,6 +145,7 @@ pub trait Installable<'lock> {
{ {
if !dep.complexified_marker.evaluate( if !dep.complexified_marker.evaluate(
marker_env, marker_env,
activated_projects.iter().copied(),
activated_extras.iter().copied(), activated_extras.iter().copied(),
activated_groups.iter().copied(), activated_groups.iter().copied(),
) { ) {
@ -367,6 +370,7 @@ pub trait Installable<'lock> {
} }
if !dep.complexified_marker.evaluate( if !dep.complexified_marker.evaluate(
marker_env, marker_env,
activated_projects.iter().copied(),
activated_extras activated_extras
.iter() .iter()
.chain(additional_activated_extras.iter()) .chain(additional_activated_extras.iter())
@ -454,6 +458,7 @@ pub trait Installable<'lock> {
for dep in deps { for dep in deps {
if !dep.complexified_marker.evaluate( if !dep.complexified_marker.evaluate(
marker_env, marker_env,
activated_projects.iter().copied(),
activated_extras.iter().copied(), activated_extras.iter().copied(),
activated_groups.iter().copied(), activated_groups.iter().copied(),
) { ) {

View file

@ -41,7 +41,7 @@ use uv_platform_tags::{
AbiTag, IncompatibleTag, LanguageTag, PlatformTag, TagCompatibility, TagPriority, Tags, AbiTag, IncompatibleTag, LanguageTag, PlatformTag, TagCompatibility, TagPriority, Tags,
}; };
use uv_pypi_types::{ use uv_pypi_types::{
ConflictPackage, Conflicts, HashAlgorithm, HashDigest, HashDigests, Hashes, ParsedArchiveUrl, ConflictKind, Conflicts, HashAlgorithm, HashDigest, HashDigests, Hashes, ParsedArchiveUrl,
ParsedGitUrl, ParsedGitUrl,
}; };
use uv_redacted::DisplaySafeUrl; use uv_redacted::DisplaySafeUrl;
@ -1026,11 +1026,12 @@ impl Lock {
list.push(each_element_on_its_line_array(set.iter().map(|item| { list.push(each_element_on_its_line_array(set.iter().map(|item| {
let mut table = InlineTable::new(); let mut table = InlineTable::new();
table.insert("package", Value::from(item.package().to_string())); table.insert("package", Value::from(item.package().to_string()));
match item.conflict() { match item.kind() {
ConflictPackage::Extra(extra) => { ConflictKind::Project => {}
ConflictKind::Extra(extra) => {
table.insert("extra", Value::from(extra.to_string())); table.insert("extra", Value::from(extra.to_string()));
} }
ConflictPackage::Group(group) => { ConflictKind::Group(group) => {
table.insert("group", Value::from(group.to_string())); table.insert("group", Value::from(group.to_string()));
} }
} }
@ -3107,6 +3108,21 @@ impl Package {
pub fn dependency_groups(&self) -> &BTreeMap<GroupName, BTreeSet<Requirement>> { pub fn dependency_groups(&self) -> &BTreeMap<GroupName, BTreeSet<Requirement>> {
&self.metadata.dependency_groups &self.metadata.dependency_groups
} }
/// Returns the dependencies of the package.
pub fn dependencies(&self) -> &[Dependency] {
&self.dependencies
}
/// Returns the optional dependencies of the package.
pub fn optional_dependencies(&self) -> &BTreeMap<ExtraName, Vec<Dependency>> {
&self.optional_dependencies
}
/// Returns the resolved PEP 735 dependency groups of the package.
pub fn resolved_dependency_groups(&self) -> &BTreeMap<GroupName, Vec<Dependency>> {
&self.dependency_groups
}
} }
/// Attempts to construct a `VerbatimUrl` from the given normalized `Path`. /// Attempts to construct a `VerbatimUrl` from the given normalized `Path`.
@ -4657,7 +4673,7 @@ impl TryFrom<WheelWire> for Wheel {
/// A single dependency of a package in a lockfile. /// A single dependency of a package in a lockfile.
#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
struct Dependency { pub struct Dependency {
package_id: PackageId, package_id: PackageId,
extra: BTreeSet<ExtraName>, extra: BTreeSet<ExtraName>,
/// A marker simplified from the PEP 508 marker in `complexified_marker` /// A marker simplified from the PEP 508 marker in `complexified_marker`
@ -4742,6 +4758,16 @@ impl Dependency {
table table
} }
/// Returns the package name of this dependency.
pub fn package_name(&self) -> &PackageName {
&self.package_id.name
}
/// Returns the extras specified on this dependency.
pub fn extra(&self) -> &BTreeSet<ExtraName> {
&self.extra
}
} }
impl Display for Dependency { impl Display for Dependency {

View file

@ -8,8 +8,8 @@ use uv_distribution_types::{Requirement, RequirementSource};
use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_normalize::{ExtraName, GroupName, PackageName};
use uv_pep440::{Version, VersionSpecifiers}; use uv_pep440::{Version, VersionSpecifiers};
use uv_pypi_types::{ use uv_pypi_types::{
Conflicts, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl, ParsedUrl, ConflictItemRef, Conflicts, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl,
VerbatimParsedUrl, ParsedUrl, VerbatimParsedUrl,
}; };
use crate::pubgrub::{PubGrubPackage, PubGrubPackageInner}; use crate::pubgrub::{PubGrubPackage, PubGrubPackageInner};
@ -19,6 +19,21 @@ pub(crate) struct PubGrubDependency {
pub(crate) package: PubGrubPackage, pub(crate) package: PubGrubPackage,
pub(crate) version: Ranges<Version>, pub(crate) version: Ranges<Version>,
/// When the parent that created this dependency is a "normal" package
/// (non-extra non-group), this corresponds to its name.
///
/// This is used to create project-level `ConflictItemRef` for a specific
/// package. In effect, this lets us "delay" filtering of project
/// dependencies when a conflict is declared between the project and a
/// group.
///
/// The main problem with dealing with project level conflicts is that if you
/// declare a conflict between a package and a group, we represent that
/// group as a dependency of that package. So if you filter out the package
/// in a fork due to a conflict, you also filter out the group. Therefore,
/// we introduce this parent field to enable "delayed" filtering.
pub(crate) parent: Option<PackageName>,
/// This field is set if the [`Requirement`] had a URL. We still use a URL from [`Urls`] /// This field is set if the [`Requirement`] had a URL. We still use a URL from [`Urls`]
/// even if this field is None where there is an override with a URL or there is a different /// even if this field is None where there is an override with a URL or there is a different
/// requirement or constraint for the same package that has a URL. /// requirement or constraint for the same package that has a URL.
@ -30,8 +45,12 @@ impl PubGrubDependency {
conflicts: &Conflicts, conflicts: &Conflicts,
requirement: Cow<'a, Requirement>, requirement: Cow<'a, Requirement>,
dev: Option<&'a GroupName>, dev: Option<&'a GroupName>,
source_name: Option<&'a PackageName>, parent_package: Option<&'a PubGrubPackage>,
) -> impl Iterator<Item = Self> + 'a { ) -> impl Iterator<Item = Self> + 'a {
let parent_name = parent_package.and_then(|package| package.name_no_root());
let is_normal_parent = parent_package
.map(|pp| pp.extra().is_none() && pp.dev().is_none())
.unwrap_or(false);
let iter = if !requirement.extras.is_empty() { let iter = if !requirement.extras.is_empty() {
// This is crazy subtle, but if any of the extras in the // This is crazy subtle, but if any of the extras in the
// requirement are part of a declared conflict, then we // requirement are part of a declared conflict, then we
@ -80,50 +99,59 @@ impl PubGrubDependency {
// Add the package, plus any extra variants. // Add the package, plus any extra variants.
iter.map(move |(extra, group)| { iter.map(move |(extra, group)| {
PubGrubRequirement::from_requirement(&requirement, extra, group) let pubgrub_requirement =
}) PubGrubRequirement::from_requirement(&requirement, extra, group);
.map(move |requirement| {
let PubGrubRequirement { let PubGrubRequirement {
package, package,
version, version,
url, url,
} = requirement; } = pubgrub_requirement;
match &*package { match &*package {
PubGrubPackageInner::Package { .. } => Self { PubGrubPackageInner::Package { .. } => Self {
package, package,
version, version,
parent: if is_normal_parent {
parent_name.cloned()
} else {
None
},
url, url,
}, },
PubGrubPackageInner::Marker { .. } => Self { PubGrubPackageInner::Marker { .. } => Self {
package, package,
version, version,
parent: if is_normal_parent {
parent_name.cloned()
} else {
None
},
url, url,
}, },
PubGrubPackageInner::Extra { name, .. } => { PubGrubPackageInner::Extra { name, .. } => {
// Detect self-dependencies.
if dev.is_none() { if dev.is_none() {
debug_assert!( debug_assert!(
source_name.is_none_or(|source_name| source_name != name), parent_name.is_none_or(|parent_name| parent_name != name),
"extras not flattened for {name}" "extras not flattened for {name}"
); );
} }
Self { Self {
package, package,
version, version,
parent: None,
url, url,
} }
} }
PubGrubPackageInner::Dev { name, .. } => { PubGrubPackageInner::Dev { name, .. } => {
// Detect self-dependencies.
if dev.is_none() { if dev.is_none() {
debug_assert!( debug_assert!(
source_name.is_none_or(|source_name| source_name != name), parent_name.is_none_or(|parent_name| parent_name != name),
"group not flattened for {name}" "group not flattened for {name}"
); );
} }
Self { Self {
package, package,
version, version,
parent: None,
url, url,
} }
} }
@ -135,6 +163,14 @@ impl PubGrubDependency {
} }
}) })
} }
/// Extracts a possible conflicting item from this dependency.
///
/// If this package can't possibly be classified as conflicting, then this
/// returns `None`.
pub(crate) fn conflicting_item(&self) -> Option<ConflictItemRef<'_>> {
self.package.conflicting_item()
}
} }
/// A PubGrub-compatible package and version range. /// A PubGrub-compatible package and version range.

View file

@ -214,14 +214,14 @@ impl PubGrubPackage {
} }
} }
/// Extracts a possible conflicting group from this package. /// Extracts a possible conflicting item from this package.
/// ///
/// If this package can't possibly be classified as a conflicting group, /// If this package can't possibly be classified as conflicting, then
/// then this returns `None`. /// this returns `None`.
pub(crate) fn conflicting_item(&self) -> Option<ConflictItemRef<'_>> { pub(crate) fn conflicting_item(&self) -> Option<ConflictItemRef<'_>> {
let package = self.name_no_root()?; let package = self.name_no_root()?;
match (self.extra(), self.dev()) { match (self.extra(), self.dev()) {
(None, None) => None, (None, None) => Some(ConflictItemRef::from(package)),
(Some(extra), None) => Some(ConflictItemRef::from((package, extra))), (Some(extra), None) => Some(ConflictItemRef::from((package, extra))),
(None, Some(group)) => Some(ConflictItemRef::from((package, group))), (None, Some(group)) => Some(ConflictItemRef::from((package, group))),
(Some(extra), Some(group)) => { (Some(extra), Some(group)) => {

View file

@ -7,7 +7,7 @@ use tracing::trace;
use uv_distribution_types::{RequiresPython, RequiresPythonRange}; use uv_distribution_types::{RequiresPython, RequiresPythonRange};
use uv_pep440::VersionSpecifiers; use uv_pep440::VersionSpecifiers;
use uv_pep508::{MarkerEnvironment, MarkerTree}; use uv_pep508::{MarkerEnvironment, MarkerTree};
use uv_pypi_types::{ConflictItem, ConflictItemRef, ConflictPackage, ResolverMarkerEnvironment}; use uv_pypi_types::{ConflictItem, ConflictItemRef, ConflictKind, ResolverMarkerEnvironment};
use crate::pubgrub::{PubGrubDependency, PubGrubPackage}; use crate::pubgrub::{PubGrubDependency, PubGrubPackage};
use crate::resolver::ForkState; use crate::resolver::ForkState;
@ -391,11 +391,12 @@ impl ResolverEnvironment {
format!( format!(
"{}{}", "{}{}",
conflict_item.package(), conflict_item.package(),
match conflict_item.conflict() { match conflict_item.kind() {
ConflictPackage::Extra(extra) => format!("[{extra}]"), ConflictKind::Extra(extra) => format!("[{extra}]"),
ConflictPackage::Group(group) => { ConflictKind::Group(group) => {
format!("[group:{group}]") format!("[group:{group}]")
} }
ConflictKind::Project => String::new(),
} }
) )
}; };

View file

@ -35,7 +35,7 @@ use uv_pep508::{
MarkerEnvironment, MarkerExpression, MarkerOperator, MarkerTree, MarkerValueString, MarkerEnvironment, MarkerExpression, MarkerOperator, MarkerTree, MarkerValueString,
}; };
use uv_platform_tags::Tags; 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_types::{BuildContext, HashStrategy, InstalledPackagesProvider};
use uv_warnings::warn_user_once; use uv_warnings::warn_user_once;
@ -946,6 +946,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
let PubGrubDependency { let PubGrubDependency {
package, package,
version: _, version: _,
parent: _,
url: _, url: _,
} = dependency; } = dependency;
let url = package.name().and_then(|name| state.fork_urls.get(name)); let url = package.name().and_then(|name| state.fork_urls.get(name));
@ -1750,7 +1751,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
&self.conflicts, &self.conflicts,
requirement, requirement,
None, None,
None, Some(package),
) )
}) })
.collect() .collect()
@ -1866,7 +1867,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
&self.conflicts, &self.conflicts,
requirement, requirement,
dev.as_ref(), dev.as_ref(),
Some(name), Some(package),
) )
}) })
.chain(system_dependencies) .chain(system_dependencies)
@ -1890,6 +1891,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
marker, marker,
}), }),
version: Range::singleton(version.clone()), version: Range::singleton(version.clone()),
parent: None,
url: None, url: None,
}) })
.collect(), .collect(),
@ -1917,6 +1919,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
marker, marker,
}), }),
version: Range::singleton(version.clone()), version: Range::singleton(version.clone()),
parent: None,
url: None, url: None,
}) })
}) })
@ -1938,6 +1941,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
marker, marker,
}), }),
version: Range::singleton(version.clone()), version: Range::singleton(version.clone()),
parent: None,
url: None, url: None,
}) })
.collect(), .collect(),
@ -2801,6 +2805,7 @@ impl ForkState {
let PubGrubDependency { let PubGrubDependency {
package, package,
version, version,
parent: _,
url, url,
} = dependency; } = dependency;
@ -2872,6 +2877,7 @@ impl ForkState {
let PubGrubDependency { let PubGrubDependency {
package, package,
version, version,
parent: _,
url: _, url: _,
} = dependency; } = dependency;
(package, version) (package, version)
@ -3654,7 +3660,7 @@ impl Forks {
continue; 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)) { if let Some(fork_none) = fork.clone().filter(set.iter().cloned().map(Err)) {
new.push(fork_none); new.push(fork_none);
} }
@ -3740,7 +3746,7 @@ impl Fork {
/// Add a dependency to this fork. /// Add a dependency to this fork.
fn add_dependency(&mut self, dep: PubGrubDependency) { fn add_dependency(&mut self, dep: PubGrubDependency) {
if let Some(conflicting_item) = dep.package.conflicting_item() { if let Some(conflicting_item) = dep.conflicting_item() {
self.conflicts.insert(conflicting_item.to_owned()); self.conflicts.insert(conflicting_item.to_owned());
} }
self.dependencies.push(dep); self.dependencies.push(dep);
@ -3757,7 +3763,7 @@ impl Fork {
if self.env.included_by_marker(marker) { if self.env.included_by_marker(marker) {
return true; return true;
} }
if let Some(conflicting_item) = dep.package.conflicting_item() { if let Some(conflicting_item) = dep.conflicting_item() {
self.conflicts.remove(&conflicting_item); self.conflicts.remove(&conflicting_item);
} }
false false
@ -3782,12 +3788,23 @@ impl Fork {
) -> Option<Self> { ) -> Option<Self> {
self.env = self.env.filter_by_group(rules)?; self.env = self.env.filter_by_group(rules)?;
self.dependencies.retain(|dep| { self.dependencies.retain(|dep| {
let Some(conflicting_item) = dep.package.conflicting_item() else { let Some(conflicting_item) = dep.conflicting_item() else {
return true; return true;
}; };
if self.env.included_by_group(conflicting_item) { if self.env.included_by_group(conflicting_item) {
return true; return true;
} }
match conflicting_item.kind() {
// We should not filter entire projects unless they're a top-level dependency
// Otherwise, we'll fail to solve for children of the project, like extras
ConflictKindRef::Project => {
if dep.parent.is_some() {
return true;
}
}
ConflictKindRef::Group(_) => {}
ConflictKindRef::Extra(_) => {}
}
self.conflicts.remove(&conflicting_item); self.conflicts.remove(&conflicting_item);
false false
}); });

View file

@ -48,6 +48,7 @@ impl From<SystemDependency> for PubGrubDependency {
Self { Self {
package: PubGrubPackage::from(PubGrubPackageInner::System(value.name)), package: PubGrubPackage::from(PubGrubPackageInner::System(value.name)),
version: Ranges::singleton(value.version), version: Ranges::singleton(value.version),
parent: None,
url: None, url: None,
} }
} }

View file

@ -7,7 +7,7 @@ use rustc_hash::FxHashMap;
use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_normalize::{ExtraName, GroupName, PackageName};
use uv_pep508::{ExtraOperator, MarkerEnvironment, MarkerExpression, MarkerOperator, MarkerTree}; use uv_pep508::{ExtraOperator, MarkerEnvironment, MarkerExpression, MarkerOperator, MarkerTree};
use uv_pypi_types::{ConflictItem, ConflictPackage, Conflicts, Inference}; use uv_pypi_types::{ConflictItem, ConflictKind, Conflicts, Inference};
use crate::ResolveError; use crate::ResolveError;
@ -173,9 +173,10 @@ impl UniversalMarker {
/// This may simplify the conflicting marker component of this universal /// This may simplify the conflicting marker component of this universal
/// marker. /// marker.
pub(crate) fn assume_conflict_item(&mut self, item: &ConflictItem) { pub(crate) fn assume_conflict_item(&mut self, item: &ConflictItem) {
match *item.conflict() { match *item.kind() {
ConflictPackage::Extra(ref extra) => self.assume_extra(item.package(), extra), ConflictKind::Extra(ref extra) => self.assume_extra(item.package(), extra),
ConflictPackage::Group(ref group) => self.assume_group(item.package(), group), ConflictKind::Group(ref group) => self.assume_group(item.package(), group),
ConflictKind::Project => self.assume_project(item.package()),
} }
self.pep508 = self.marker.without_extras(); self.pep508 = self.marker.without_extras();
} }
@ -186,18 +187,45 @@ impl UniversalMarker {
/// This may simplify the conflicting marker component of this universal /// This may simplify the conflicting marker component of this universal
/// marker. /// marker.
pub(crate) fn assume_not_conflict_item(&mut self, item: &ConflictItem) { pub(crate) fn assume_not_conflict_item(&mut self, item: &ConflictItem) {
match *item.conflict() { match *item.kind() {
ConflictPackage::Extra(ref extra) => self.assume_not_extra(item.package(), extra), ConflictKind::Extra(ref extra) => self.assume_not_extra(item.package(), extra),
ConflictPackage::Group(ref group) => self.assume_not_group(item.package(), group), ConflictKind::Group(ref group) => self.assume_not_group(item.package(), group),
ConflictKind::Project => self.assume_not_project(item.package()),
} }
self.pep508 = self.marker.without_extras(); 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. /// Assumes that a given extra for the given package is activated.
/// ///
/// This may simplify the conflicting marker component of this universal /// This may simplify the conflicting marker component of this universal
/// marker. /// 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); let extra = encode_package_extra(package, extra);
self.marker = self self.marker = self
.marker .marker
@ -209,7 +237,7 @@ impl UniversalMarker {
/// ///
/// This may simplify the conflicting marker component of this universal /// This may simplify the conflicting marker component of this universal
/// marker. /// 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); let extra = encode_package_extra(package, extra);
self.marker = self self.marker = self
.marker .marker
@ -221,7 +249,7 @@ impl UniversalMarker {
/// ///
/// This may simplify the conflicting marker component of this universal /// This may simplify the conflicting marker component of this universal
/// marker. /// 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); let extra = encode_package_group(package, group);
self.marker = self self.marker = self
.marker .marker
@ -233,7 +261,7 @@ impl UniversalMarker {
/// ///
/// This may simplify the conflicting marker component of this universal /// This may simplify the conflicting marker component of this universal
/// marker. /// 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); let extra = encode_package_group(package, group);
self.marker = self self.marker = self
.marker .marker
@ -277,6 +305,7 @@ impl UniversalMarker {
pub(crate) fn evaluate<P, E, G>( pub(crate) fn evaluate<P, E, G>(
self, self,
env: &MarkerEnvironment, env: &MarkerEnvironment,
projects: impl Iterator<Item = P>,
extras: impl Iterator<Item = (P, E)>, extras: impl Iterator<Item = (P, E)>,
groups: impl Iterator<Item = (P, G)>, groups: impl Iterator<Item = (P, G)>,
) -> bool ) -> bool
@ -285,12 +314,18 @@ impl UniversalMarker {
E: Borrow<ExtraName>, E: Borrow<ExtraName>,
G: Borrow<GroupName>, G: Borrow<GroupName>,
{ {
let projects = projects.map(|package| encode_project(package.borrow()));
let extras = let extras =
extras.map(|(package, extra)| encode_package_extra(package.borrow(), extra.borrow())); extras.map(|(package, extra)| encode_package_extra(package.borrow(), extra.borrow()));
let groups = let groups =
groups.map(|(package, group)| encode_package_group(package.borrow(), group.borrow())); groups.map(|(package, group)| encode_package_group(package.borrow(), group.borrow()));
self.marker self.marker.evaluate(
.evaluate(env, &extras.chain(groups).collect::<Vec<ExtraName>>()) env,
&projects
.chain(extras)
.chain(groups)
.collect::<Vec<ExtraName>>(),
)
} }
/// Returns true if the marker always evaluates to true if the given set of extras is activated. /// Returns true if the marker always evaluates to true if the given set of extras is activated.
@ -392,12 +427,23 @@ impl ConflictMarker {
/// Create a conflict marker that is true only when the given extra or /// Create a conflict marker that is true only when the given extra or
/// group (for a specific package) is activated. /// group (for a specific package) is activated.
pub fn from_conflict_item(item: &ConflictItem) -> Self { pub fn from_conflict_item(item: &ConflictItem) -> Self {
match *item.conflict() { match *item.kind() {
ConflictPackage::Extra(ref extra) => Self::extra(item.package(), extra), ConflictKind::Extra(ref extra) => Self::extra(item.package(), extra),
ConflictPackage::Group(ref group) => Self::group(item.package(), group), ConflictKind::Group(ref group) => Self::group(item.package(), group),
ConflictKind::Project => Self::project(item.package()),
} }
} }
/// Create a conflict marker that is true only when the production
/// dependencies for the given package are activated.
pub fn project(package: &PackageName) -> Self {
let operator = uv_pep508::ExtraOperator::Equal;
let name = uv_pep508::MarkerValueExtra::Extra(encode_project(package));
let expr = uv_pep508::MarkerExpression::Extra { operator, name };
let marker = MarkerTree::expression(expr);
Self { marker }
}
/// Create a conflict marker that is true only when the given extra for the /// Create a conflict marker that is true only when the given extra for the
/// given package is activated. /// given package is activated.
pub fn extra(package: &PackageName, extra: &ExtraName) -> Self { pub fn extra(package: &PackageName, extra: &ExtraName) -> Self {
@ -504,9 +550,10 @@ impl std::fmt::Debug for ConflictMarker {
/// Encodes the given conflict into a valid `extra` value in a PEP 508 marker. /// Encodes the given conflict into a valid `extra` value in a PEP 508 marker.
fn encode_conflict_item(conflict: &ConflictItem) -> ExtraName { fn encode_conflict_item(conflict: &ConflictItem) -> ExtraName {
match conflict.conflict() { match conflict.kind() {
ConflictPackage::Extra(extra) => encode_package_extra(conflict.package(), extra), ConflictKind::Extra(extra) => encode_package_extra(conflict.package(), extra),
ConflictPackage::Group(group) => encode_package_group(conflict.package(), group), ConflictKind::Group(group) => encode_package_group(conflict.package(), group),
ConflictKind::Project => encode_project(conflict.package()),
} }
} }
@ -535,8 +582,17 @@ fn encode_package_group(package: &PackageName, group: &GroupName) -> ExtraName {
ExtraName::from_owned(format!("group-{package_len}-{package}-{group}")).unwrap() 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)] #[derive(Debug)]
enum ParsedRawExtra<'a> { enum ParsedRawExtra<'a> {
Project { package: &'a str },
Extra { package: &'a str, extra: &'a str }, Extra { package: &'a str, extra: &'a str },
Group { package: &'a str, group: &'a str }, Group { package: &'a str, group: &'a str },
} }
@ -553,13 +609,13 @@ impl<'a> ParsedRawExtra<'a> {
let Some((kind, tail)) = raw.split_once('-') else { let Some((kind, tail)) = raw.split_once('-') else {
return Err(mkerr( return Err(mkerr(
raw_extra, 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 { let Some((len, tail)) = tail.split_once('-') else {
return Err(mkerr( return Err(mkerr(
raw_extra, 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::<usize>().map_err(|_| { let len = len.parse::<usize>().map_err(|_| {
@ -577,22 +633,28 @@ impl<'a> ParsedRawExtra<'a> {
), ),
)); ));
}; };
if !tail.starts_with('-') {
return Err(mkerr(
raw_extra,
format!("expected `-` after package name `{package}`"),
));
}
let tail = &tail[1..];
match kind { match kind {
"extra" => Ok(ParsedRawExtra::Extra { "project" => Ok(ParsedRawExtra::Project { package }),
package, "extra" | "group" => {
extra: tail, if !tail.starts_with('-') {
}), return Err(mkerr(
"group" => Ok(ParsedRawExtra::Group { raw_extra,
package, format!("expected `-` after package name `{package}`"),
group: tail, ));
}), }
let tail = &tail[1..];
if kind == "extra" {
Ok(ParsedRawExtra::Extra {
package,
extra: tail,
})
} else {
Ok(ParsedRawExtra::Group {
package,
group: tail,
})
}
}
_ => Err(mkerr( _ => Err(mkerr(
raw_extra, raw_extra,
format!("unrecognized kind `{kind}` (must be `extra` or `group`)"), format!("unrecognized kind `{kind}` (must be `extra` or `group`)"),
@ -608,6 +670,7 @@ impl<'a> ParsedRawExtra<'a> {
} }
})?; })?;
match self { match self {
Self::Project { .. } => Ok(ConflictItem::from(package)),
Self::Extra { extra, .. } => { Self::Extra { extra, .. } => {
let extra = ExtraName::from_str(extra).map_err(|name_error| { let extra = ExtraName::from_str(extra).map_err(|name_error| {
ResolveError::InvalidValueInConflictMarker { ResolveError::InvalidValueInConflictMarker {
@ -631,6 +694,7 @@ impl<'a> ParsedRawExtra<'a> {
fn package(&self) -> &'a str { fn package(&self) -> &'a str {
match self { match self {
Self::Project { package, .. } => package,
Self::Extra { package, .. } => package, Self::Extra { package, .. } => package,
Self::Group { package, .. } => package, Self::Group { package, .. } => package,
} }
@ -719,6 +783,26 @@ pub(crate) fn resolve_conflicts(
} }
} }
} }
// Search for the conflict item as a project.
if conflict_item.extra().is_none() && conflict_item.group().is_none() {
let package = conflict_item.package();
let encoded = encode_project(package);
if encoded == *name {
match operator {
ExtraOperator::Equal => {
or.and(*conflict_marker);
found = true;
break;
}
ExtraOperator::NotEqual => {
or.and(conflict_marker.negate());
found = true;
break;
}
}
}
}
} }
// If we didn't find the marker in the list of known conflicts, assume it's always // If we didn't find the marker in the list of known conflicts, assume it's always

View file

@ -210,9 +210,6 @@ pub(crate) async fn export(
Err(err) => return Err(err.into()), 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. // Identify the installation target.
let target = match &target { let target = match &target {
ExportTarget::Project(VirtualProject::Project(project)) => { ExportTarget::Project(VirtualProject::Project(project)) => {
@ -262,6 +259,9 @@ pub(crate) async fn export(
}, },
}; };
// Validate that the set of requested extras and development groups are compatible.
detect_conflicts(&target, &extras, &groups)?;
// Validate that the set of requested extras and development groups are defined in the lockfile. // Validate that the set of requested extras and development groups are defined in the lockfile.
target.validate_extras(&extras)?; target.validate_extras(&extras)?;
target.validate_groups(&groups)?; target.validate_groups(&groups)?;

View file

@ -1,4 +1,5 @@
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::path::Path; use std::path::Path;
use std::str::FromStr; use std::str::FromStr;
@ -7,7 +8,7 @@ use rustc_hash::FxHashSet;
use uv_configuration::{Constraints, DependencyGroupsWithDefaults, ExtrasSpecification}; use uv_configuration::{Constraints, DependencyGroupsWithDefaults, ExtrasSpecification};
use uv_distribution_types::Index; use uv_distribution_types::Index;
use uv_normalize::PackageName; use uv_normalize::{ExtraName, PackageName};
use uv_pypi_types::{DependencyGroupSpecifier, LenientRequirement, VerbatimParsedUrl}; use uv_pypi_types::{DependencyGroupSpecifier, LenientRequirement, VerbatimParsedUrl};
use uv_resolver::{Installable, Lock, Package}; use uv_resolver::{Installable, Lock, Package};
use uv_scripts::Pep723Script; use uv_scripts::Pep723Script;
@ -369,4 +370,107 @@ impl<'lock> InstallTarget<'lock> {
Ok(()) Ok(())
} }
/// Returns the names of all packages in the workspace that will be installed.
///
/// Note this only includes workspace members.
pub(crate) fn packages(
&self,
extras: &ExtrasSpecification,
groups: &DependencyGroupsWithDefaults,
) -> BTreeSet<&PackageName> {
match self {
Self::Project { name, lock, .. } => {
// Collect the packages by name for efficient lookup
let packages = lock
.packages()
.iter()
.map(|p| (p.name(), p))
.collect::<BTreeMap<_, _>>();
// We'll include the project itself
let mut required_members = BTreeSet::new();
required_members.insert(*name);
// Find all workspace member dependencies recursively
let mut queue: VecDeque<(&PackageName, Option<&ExtraName>)> = VecDeque::new();
let mut seen: FxHashSet<(&PackageName, Option<&ExtraName>)> = FxHashSet::default();
let Some(root_package) = packages.get(name) else {
return required_members;
};
if groups.prod() {
// Add the root package
queue.push_back((name, None));
seen.insert((name, None));
// Add explicitly activated extras for the root package
for extra in extras.extra_names(root_package.optional_dependencies().keys()) {
if seen.insert((name, Some(extra))) {
queue.push_back((name, Some(extra)));
}
}
}
// Add activated dependency groups for the root package
for (group_name, dependencies) in root_package.resolved_dependency_groups() {
if !groups.contains(group_name) {
continue;
}
for dependency in dependencies {
let name = dependency.package_name();
queue.push_back((name, None));
for extra in dependency.extra() {
queue.push_back((name, Some(extra)));
}
}
}
while let Some((pkg_name, extra)) = queue.pop_front() {
if lock.members().contains(pkg_name) {
required_members.insert(pkg_name);
}
let Some(package) = packages.get(pkg_name) else {
continue;
};
let Some(dependencies) = extra
.map(|extra_name| {
package
.optional_dependencies()
.get(extra_name)
.map(Vec::as_slice)
})
.unwrap_or(Some(package.dependencies()))
else {
continue;
};
for dependency in dependencies {
let name = dependency.package_name();
if seen.insert((name, None)) {
queue.push_back((name, None));
}
for extra in dependency.extra() {
if seen.insert((name, Some(extra))) {
queue.push_back((name, Some(extra)));
}
}
}
}
required_members
}
Self::Workspace { lock, .. } | Self::NonProjectWorkspace { lock, .. } => {
// Return all workspace members
lock.members().iter().collect()
}
Self::Script { .. } => {
// Scripts don't have workspace members
BTreeSet::new()
}
}
}
} }

View file

@ -24,7 +24,7 @@ use uv_distribution_types::{
use uv_git::ResolvedRepositoryReference; use uv_git::ResolvedRepositoryReference;
use uv_normalize::{GroupName, PackageName}; use uv_normalize::{GroupName, PackageName};
use uv_pep440::Version; 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_python::{Interpreter, PythonDownloads, PythonEnvironment, PythonPreference, PythonRequest};
use uv_requirements::ExtrasResolver; use uv_requirements::ExtrasResolver;
use uv_requirements::upgrade::{LockedRequirements, read_lock_requirements}; use uv_requirements::upgrade::{LockedRequirements, read_lock_requirements};
@ -487,6 +487,19 @@ async fn do_lock(
} }
} }
// Check if any conflicts contain project-level conflicts
if !preview.is_enabled(PreviewFeatures::PACKAGE_CONFLICTS)
&& conflicts.iter().any(|set| {
set.iter()
.any(|item| matches!(item.kind(), ConflictKind::Project))
})
{
warn_user_once!(
"Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.",
PreviewFeatures::PACKAGE_CONFLICTS
);
}
// Collect the list of supported environments. // Collect the list of supported environments.
let environments = { let environments = {
let environments = target.environments(); let environments = target.environments();

View file

@ -27,7 +27,7 @@ use uv_installer::{SatisfiesResult, SitePackages};
use uv_normalize::{DEV_DEPENDENCIES, DefaultGroups, ExtraName, GroupName, PackageName}; use uv_normalize::{DEV_DEPENDENCIES, DefaultGroups, ExtraName, GroupName, PackageName};
use uv_pep440::{TildeVersionSpecifier, Version, VersionSpecifiers}; use uv_pep440::{TildeVersionSpecifier, Version, VersionSpecifiers};
use uv_pep508::MarkerTreeContents; use uv_pep508::MarkerTreeContents;
use uv_pypi_types::{ConflictPackage, ConflictSet, Conflicts}; use uv_pypi_types::{ConflictItem, ConflictKind, ConflictSet, Conflicts};
use uv_python::{ use uv_python::{
EnvironmentPreference, Interpreter, InvalidEnvironmentKind, PythonDownloads, PythonEnvironment, EnvironmentPreference, Interpreter, InvalidEnvironmentKind, PythonDownloads, PythonEnvironment,
PythonInstallation, PythonPreference, PythonRequest, PythonSource, PythonVariant, PythonInstallation, PythonPreference, PythonRequest, PythonSource, PythonVariant,
@ -36,8 +36,8 @@ use uv_python::{
use uv_requirements::upgrade::{LockedRequirements, read_lock_requirements}; use uv_requirements::upgrade::{LockedRequirements, read_lock_requirements};
use uv_requirements::{NamedRequirementsResolver, RequirementsSpecification}; use uv_requirements::{NamedRequirementsResolver, RequirementsSpecification};
use uv_resolver::{ use uv_resolver::{
FlatIndex, Lock, OptionsBuilder, Preference, PythonRequirement, ResolverEnvironment, FlatIndex, Installable, Lock, OptionsBuilder, Preference, PythonRequirement,
ResolverOutput, ResolverEnvironment, ResolverOutput,
}; };
use uv_scripts::Pep723ItemRef; use uv_scripts::Pep723ItemRef;
use uv_settings::PythonInstallMirrors; use uv_settings::PythonInstallMirrors;
@ -52,6 +52,7 @@ use uv_workspace::{RequiresPythonSources, Workspace, WorkspaceCache};
use crate::commands::pip::loggers::{InstallLogger, ResolveLogger}; use crate::commands::pip::loggers::{InstallLogger, ResolveLogger};
use crate::commands::pip::operations::{Changelog, Modifications}; use crate::commands::pip::operations::{Changelog, Modifications};
use crate::commands::project::install_target::InstallTarget;
use crate::commands::reporters::{PythonDownloadReporter, ResolverReporter}; use crate::commands::reporters::{PythonDownloadReporter, ResolverReporter};
use crate::commands::{capitalize, conjunction, pip}; use crate::commands::{capitalize, conjunction, pip};
use crate::printer::Printer; use crate::printer::Printer;
@ -274,7 +275,7 @@ pub(crate) struct ConflictError {
/// The set from which the conflict was derived. /// The set from which the conflict was derived.
pub(crate) set: ConflictSet, pub(crate) set: ConflictSet,
/// The items from the set that were enabled, and thus create the conflict. /// The items from the set that were enabled, and thus create the conflict.
pub(crate) conflicts: Vec<ConflictPackage>, pub(crate) conflicts: Vec<ConflictItem>,
/// Enabled dependency groups with defaults applied. /// Enabled dependency groups with defaults applied.
pub(crate) groups: DependencyGroupsWithDefaults, pub(crate) groups: DependencyGroupsWithDefaults,
} }
@ -285,9 +286,10 @@ impl std::fmt::Display for ConflictError {
let set = self let set = self
.set .set
.iter() .iter()
.map(|item| match item.conflict() { .map(|item| match item.kind() {
ConflictPackage::Extra(extra) => format!("`{}[{}]`", item.package(), extra), ConflictKind::Project => format!("{}", item.package()),
ConflictPackage::Group(group) => format!("`{}:{}`", item.package(), group), ConflictKind::Extra(extra) => format!("`{}[{}]`", item.package(), extra),
ConflictKind::Group(group) => format!("`{}:{}`", item.package(), group),
}) })
.join(", "); .join(", ");
@ -295,7 +297,7 @@ impl std::fmt::Display for ConflictError {
if self if self
.conflicts .conflicts
.iter() .iter()
.all(|conflict| matches!(conflict, ConflictPackage::Extra(..))) .all(|conflict| matches!(conflict.kind(), ConflictKind::Extra(..)))
{ {
write!( write!(
f, f,
@ -303,9 +305,9 @@ impl std::fmt::Display for ConflictError {
conjunction( conjunction(
self.conflicts self.conflicts
.iter() .iter()
.map(|conflict| match conflict { .map(|conflict| match conflict.kind() {
ConflictPackage::Extra(extra) => format!("`{extra}`"), ConflictKind::Extra(extra) => format!("`{extra}`"),
ConflictPackage::Group(..) => unreachable!(), ConflictKind::Group(..) | ConflictKind::Project => unreachable!(),
}) })
.collect() .collect()
) )
@ -313,7 +315,7 @@ impl std::fmt::Display for ConflictError {
} else if self } else if self
.conflicts .conflicts
.iter() .iter()
.all(|conflict| matches!(conflict, ConflictPackage::Group(..))) .all(|conflict| matches!(conflict.kind(), ConflictKind::Group(..)))
{ {
let conflict_source = if self.set.is_inferred_conflict() { let conflict_source = if self.set.is_inferred_conflict() {
"transitively inferred" "transitively inferred"
@ -326,12 +328,12 @@ impl std::fmt::Display for ConflictError {
conjunction( conjunction(
self.conflicts self.conflicts
.iter() .iter()
.map(|conflict| match conflict { .map(|conflict| match conflict.kind() {
ConflictPackage::Group(group) ConflictKind::Group(group)
if self.groups.contains_because_default(group) => if self.groups.contains_because_default(group) =>
format!("`{group}` (enabled by default)"), format!("`{group}` (enabled by default)"),
ConflictPackage::Group(group) => format!("`{group}`"), ConflictKind::Group(group) => format!("`{group}`"),
ConflictPackage::Extra(..) => unreachable!(), ConflictKind::Extra(..) | ConflictKind::Project => unreachable!(),
}) })
.collect() .collect()
) )
@ -345,14 +347,17 @@ impl std::fmt::Display for ConflictError {
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, conflict)| { .map(|(i, conflict)| {
let conflict = match conflict { let conflict = match conflict.kind() {
ConflictPackage::Extra(extra) => format!("extra `{extra}`"), ConflictKind::Project => {
ConflictPackage::Group(group) format!("package `{}`", conflict.package())
}
ConflictKind::Extra(extra) => format!("extra `{extra}`"),
ConflictKind::Group(group)
if self.groups.contains_because_default(group) => if self.groups.contains_because_default(group) =>
{ {
format!("group `{group}` (enabled by default)") format!("group `{group}` (enabled by default)")
} }
ConflictPackage::Group(group) => format!("group `{group}`"), ConflictKind::Group(group) => format!("group `{group}`"),
}; };
if i == 0 { if i == 0 {
capitalize(&conflict) capitalize(&conflict)
@ -2526,31 +2531,33 @@ pub(crate) fn default_dependency_groups(
/// are declared as conflicting. /// are declared as conflicting.
#[allow(clippy::result_large_err)] #[allow(clippy::result_large_err)]
pub(crate) fn detect_conflicts( pub(crate) fn detect_conflicts(
lock: &Lock, target: &InstallTarget,
extras: &ExtrasSpecification, extras: &ExtrasSpecification,
groups: &DependencyGroupsWithDefaults, groups: &DependencyGroupsWithDefaults,
) -> Result<(), ProjectError> { ) -> Result<(), ProjectError> {
// Note that we need to collect all extras and groups that match in // Validate that we aren't trying to install extras or groups that
// a particular set, since extras can be declared as conflicting with // are declared as conflicting. Note that we need to collect all
// groups. So if extra `x` and group `g` are declared as conflicting, // extras and groups that match in a particular set, since extras
// then enabling both of those should result in an error. // can be declared as conflicting with groups. So if extra `x` and
// group `g` are declared as conflicting, then enabling both of
// those should result in an error.
let lock = target.lock();
let packages = target.packages(extras, groups);
let conflicts = lock.conflicts(); let conflicts = lock.conflicts();
for set in conflicts.iter() { for set in conflicts.iter() {
let mut conflicts: Vec<ConflictPackage> = vec![]; let mut conflicts: Vec<ConflictItem> = vec![];
for item in set.iter() { for item in set.iter() {
if item if !packages.contains(item.package()) {
.extra() // Ignore items that are not in the install targets
.map(|extra| extras.contains(extra)) continue;
.unwrap_or(false)
{
conflicts.push(item.conflict().clone());
} }
if item let is_conflicting = match item.kind() {
.group() ConflictKind::Project => groups.prod(),
.map(|group| groups.contains(group)) ConflictKind::Extra(extra) => extras.contains(extra),
.unwrap_or(false) ConflictKind::Group(group1) => groups.contains(group1),
{ };
conflicts.push(item.conflict().clone()); if is_conflicting {
conflicts.push(item.clone());
} }
} }
if conflicts.len() >= 2 { if conflicts.len() >= 2 {

View file

@ -663,7 +663,7 @@ pub(super) async fn do_sync(
} }
// Validate that the set of requested extras and development groups are compatible. // 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. // Validate that the set of requested extras and development groups are defined in the lockfile.
target.validate_extras(extras)?; target.validate_extras(extras)?;

File diff suppressed because it is too large Load diff

View file

@ -1049,24 +1049,24 @@ fn extra_unconditional() -> Result<()> {
"#, "#,
)?; )?;
uv_snapshot!(context.filters(), context.lock(), @r###" uv_snapshot!(context.filters(), context.lock(), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
Resolved 6 packages in [TIME] Resolved 6 packages in [TIME]
"###); ");
// This should error since we're enabling two conflicting extras. // 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 success: false
exit_code: 2 exit_code: 2
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
error: Found conflicting extras `proxy1[extra1]` and `proxy1[extra2]` enabled simultaneously error: Found conflicting extras `proxy1[extra1]` and `proxy1[extra2]` enabled simultaneously
"###); ");
root_pyproject_toml.write_str( root_pyproject_toml.write_str(
r#" r#"
@ -1085,14 +1085,14 @@ fn extra_unconditional() -> Result<()> {
proxy1 = { workspace = true } proxy1 = { workspace = true }
"#, "#,
)?; )?;
uv_snapshot!(context.filters(), context.lock(), @r###" uv_snapshot!(context.filters(), context.lock(), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
Resolved 6 packages in [TIME] Resolved 6 packages in [TIME]
"###); ");
// This is fine because we are only enabling one // This is fine because we are only enabling one
// extra, and thus, there is no conflict. // 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"
@ -1127,17 +1127,17 @@ fn extra_unconditional() -> Result<()> {
proxy1 = { workspace = true } proxy1 = { workspace = true }
"#, "#,
)?; )?;
uv_snapshot!(context.filters(), context.lock(), @r###" uv_snapshot!(context.filters(), context.lock(), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
Resolved 6 packages in [TIME] Resolved 6 packages in [TIME]
"###); ");
// This is fine because we are only enabling one // This is fine because we are only enabling one
// extra, and thus, there is no conflict. // 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 success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
@ -1148,7 +1148,7 @@ fn extra_unconditional() -> Result<()> {
Installed 1 package in [TIME] Installed 1 package in [TIME]
- anyio==4.1.0 - anyio==4.1.0
+ anyio==4.2.0 + anyio==4.2.0
"###); ");
Ok(()) Ok(())
} }
@ -1203,14 +1203,14 @@ fn extra_unconditional_non_conflicting() -> Result<()> {
"#, "#,
)?; )?;
uv_snapshot!(context.filters(), context.lock(), @r###" uv_snapshot!(context.filters(), context.lock(), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
Resolved 5 packages in [TIME] Resolved 5 packages in [TIME]
"###); ");
// This *should* install `anyio==4.1.0`, but when this // This *should* install `anyio==4.1.0`, but when this
// test was initially written, it didn't. This was because // test was initially written, it didn't. This was because
@ -1426,26 +1426,26 @@ fn extra_unconditional_non_local_conflict() -> Result<()> {
// that can never be installed! Namely, because two different // that can never be installed! Namely, because two different
// conflicting extras are enabled unconditionally in all // conflicting extras are enabled unconditionally in all
// configurations. // configurations.
uv_snapshot!(context.filters(), context.lock(), @r###" uv_snapshot!(context.filters(), context.lock(), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
Resolved 6 packages in [TIME] Resolved 6 packages in [TIME]
"###); ");
// This should fail. If it doesn't and we generated a lock // This should fail. If it doesn't and we generated a lock
// file above, then this will likely result in the installation // file above, then this will likely result in the installation
// of two different versions of the same package. // 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 success: false
exit_code: 2 exit_code: 2
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
error: Found conflicting extras `c[x1]` and `c[x2]` enabled simultaneously error: Found conflicting extras `c[x1]` and `c[x2]` enabled simultaneously
"###); ");
Ok(()) Ok(())
} }
@ -1955,14 +1955,14 @@ fn group_basic() -> Result<()> {
"#, "#,
)?; )?;
uv_snapshot!(context.filters(), context.lock(), @r###" uv_snapshot!(context.filters(), context.lock(), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
Resolved 3 packages in [TIME] Resolved 3 packages in [TIME]
"###); ");
let lock = context.read("uv.lock"); let lock = context.read("uv.lock");
@ -2110,14 +2110,14 @@ fn group_default() -> Result<()> {
"#, "#,
)?; )?;
uv_snapshot!(context.filters(), context.lock(), @r###" uv_snapshot!(context.filters(), context.lock(), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
Resolved 3 packages in [TIME] Resolved 3 packages in [TIME]
"###); ");
let lock = context.read("uv.lock"); let lock = context.read("uv.lock");
@ -2642,14 +2642,14 @@ fn multiple_sources_index_disjoint_groups() -> Result<()> {
"#, "#,
)?; )?;
uv_snapshot!(context.filters(), context.lock(), @r###" uv_snapshot!(context.filters(), context.lock(), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
Resolved 4 packages in [TIME] Resolved 4 packages in [TIME]
"###); ");
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap();
@ -3125,7 +3125,7 @@ fn non_optional_dependency_extra() -> Result<()> {
"#, "#,
)?; )?;
uv_snapshot!(context.filters(), context.sync(), @r###" uv_snapshot!(context.filters(), context.sync(), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
@ -3135,7 +3135,7 @@ fn non_optional_dependency_extra() -> Result<()> {
Prepared 1 package in [TIME] Prepared 1 package in [TIME]
Installed 1 package in [TIME] Installed 1 package in [TIME]
+ sniffio==1.3.1 + sniffio==1.3.1
"###); ");
Ok(()) Ok(())
} }
@ -3172,7 +3172,7 @@ fn non_optional_dependency_group() -> Result<()> {
"#, "#,
)?; )?;
uv_snapshot!(context.filters(), context.sync(), @r###" uv_snapshot!(context.filters(), context.sync(), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
@ -3182,7 +3182,7 @@ fn non_optional_dependency_group() -> Result<()> {
Prepared 1 package in [TIME] Prepared 1 package in [TIME]
Installed 1 package in [TIME] Installed 1 package in [TIME]
+ sniffio==1.3.1 + sniffio==1.3.1
"###); ");
Ok(()) Ok(())
} }
@ -3222,7 +3222,7 @@ fn non_optional_dependency_mixed() -> Result<()> {
"#, "#,
)?; )?;
uv_snapshot!(context.filters(), context.sync(), @r###" uv_snapshot!(context.filters(), context.sync(), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
@ -3232,7 +3232,7 @@ fn non_optional_dependency_mixed() -> Result<()> {
Prepared 1 package in [TIME] Prepared 1 package in [TIME]
Installed 1 package in [TIME] Installed 1 package in [TIME]
+ sniffio==1.3.1 + sniffio==1.3.1
"###); ");
Ok(()) Ok(())
} }
@ -3422,7 +3422,7 @@ fn shared_optional_dependency_group1() -> Result<()> {
)?; )?;
// This shouldn't install two versions of `idna`, only one, `idna==3.5`. // 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 success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
@ -3434,7 +3434,7 @@ fn shared_optional_dependency_group1() -> Result<()> {
+ anyio==4.3.0 + anyio==4.3.0
+ idna==3.5 + idna==3.5
+ sniffio==1.3.1 + sniffio==1.3.1
"###); ");
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap();
insta::with_settings!({ insta::with_settings!({
@ -3849,7 +3849,7 @@ fn shared_optional_dependency_group2() -> Result<()> {
)?; )?;
// This shouldn't install two versions of `idna`, only one, `idna==3.5`. // 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 success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
@ -3861,7 +3861,7 @@ fn shared_optional_dependency_group2() -> Result<()> {
+ anyio==4.3.0 + anyio==4.3.0
+ idna==3.6 + idna==3.6
+ sniffio==1.3.1 + sniffio==1.3.1
"###); ");
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap();
insta::with_settings!({ insta::with_settings!({
@ -4139,7 +4139,7 @@ fn shared_dependency_extra() -> Result<()> {
"#, "#,
)?; )?;
uv_snapshot!(context.filters(), context.sync(), @r###" uv_snapshot!(context.filters(), context.sync(), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
@ -4151,7 +4151,7 @@ fn shared_dependency_extra() -> Result<()> {
+ anyio==4.3.0 + anyio==4.3.0
+ idna==3.6 + idna==3.6
+ sniffio==1.3.1 + sniffio==1.3.1
"###); ");
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap();
insta::with_settings!({ insta::with_settings!({
@ -4240,7 +4240,7 @@ fn shared_dependency_extra() -> Result<()> {
// This shouldn't install two versions of `idna`, only one, `idna==3.5`. // This shouldn't install two versions of `idna`, only one, `idna==3.5`.
// So this should remove `idna==3.6` installed above. // 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 success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
@ -4252,9 +4252,9 @@ fn shared_dependency_extra() -> Result<()> {
Installed 1 package in [TIME] Installed 1 package in [TIME]
- idna==3.6 - idna==3.6
+ idna==3.5 + 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 success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
@ -4265,9 +4265,9 @@ fn shared_dependency_extra() -> Result<()> {
Installed 1 package in [TIME] Installed 1 package in [TIME]
- idna==3.5 - idna==3.5
+ idna==3.6 + idna==3.6
"###); ");
uv_snapshot!(context.filters(), context.sync(), @r###" uv_snapshot!(context.filters(), context.sync(), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
@ -4275,7 +4275,7 @@ fn shared_dependency_extra() -> Result<()> {
----- stderr ----- ----- stderr -----
Resolved 5 packages in [TIME] Resolved 5 packages in [TIME]
Audited 3 packages in [TIME] Audited 3 packages in [TIME]
"###); ");
Ok(()) Ok(())
} }
@ -4314,7 +4314,7 @@ fn shared_dependency_group() -> Result<()> {
"#, "#,
)?; )?;
uv_snapshot!(context.filters(), context.sync(), @r###" uv_snapshot!(context.filters(), context.sync(), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
@ -4326,7 +4326,7 @@ fn shared_dependency_group() -> Result<()> {
+ anyio==4.3.0 + anyio==4.3.0
+ idna==3.6 + idna==3.6
+ sniffio==1.3.1 + sniffio==1.3.1
"###); ");
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap();
insta::with_settings!({ insta::with_settings!({
@ -4490,7 +4490,7 @@ fn shared_dependency_mixed() -> Result<()> {
"#, "#,
)?; )?;
uv_snapshot!(context.filters(), context.sync(), @r###" uv_snapshot!(context.filters(), context.sync(), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
@ -4502,7 +4502,7 @@ fn shared_dependency_mixed() -> Result<()> {
+ anyio==4.3.0 + anyio==4.3.0
+ idna==3.6 + idna==3.6
+ sniffio==1.3.1 + sniffio==1.3.1
"###); ");
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap();
insta::with_settings!({ insta::with_settings!({
@ -4595,7 +4595,7 @@ fn shared_dependency_mixed() -> Result<()> {
// This shouldn't install two versions of `idna`, only one, `idna==3.5`. // This shouldn't install two versions of `idna`, only one, `idna==3.5`.
// So this should remove `idna==3.6` installed above. // 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 success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
@ -4607,9 +4607,9 @@ fn shared_dependency_mixed() -> Result<()> {
Installed 1 package in [TIME] Installed 1 package in [TIME]
- idna==3.6 - idna==3.6
+ idna==3.5 + 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 success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
@ -4620,9 +4620,9 @@ fn shared_dependency_mixed() -> Result<()> {
Installed 1 package in [TIME] Installed 1 package in [TIME]
- idna==3.5 - idna==3.5
+ idna==3.6 + idna==3.6
"###); ");
uv_snapshot!(context.filters(), context.sync(), @r###" uv_snapshot!(context.filters(), context.sync(), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
@ -4630,7 +4630,7 @@ fn shared_dependency_mixed() -> Result<()> {
----- stderr ----- ----- stderr -----
Resolved 5 packages in [TIME] Resolved 5 packages in [TIME]
Audited 3 packages in [TIME] Audited 3 packages in [TIME]
"###); ");
Ok(()) Ok(())
} }

View file

@ -7720,7 +7720,7 @@ fn preview_features() {
show_settings: true, show_settings: true,
preview: Preview { preview: Preview {
flags: PreviewFeatures( flags: PreviewFeatures(
PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | EXTRA_BUILD_DEPENDENCIES, PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES,
), ),
}, },
python_preference: Managed, python_preference: Managed,
@ -7946,7 +7946,7 @@ fn preview_features() {
show_settings: true, show_settings: true,
preview: Preview { preview: Preview {
flags: PreviewFeatures( flags: PreviewFeatures(
PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | EXTRA_BUILD_DEPENDENCIES, PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES,
), ),
}, },
python_preference: Managed, python_preference: Managed,

View file

@ -63,6 +63,7 @@ The following preview features are available:
- `add-bounds`: Allows configuring the - `add-bounds`: Allows configuring the
[default bounds for `uv add`](../reference/settings.md#add-bounds) invocations. [default bounds for `uv add`](../reference/settings.md#add-bounds) invocations.
- `json-output`: Allows `--output-format json` for various uv commands. - `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. - `pylock`: Allows installing from `pylock.toml` files.
- `python-install-default`: Allows - `python-install-default`: Allows
[installing `python` and `python3` executables](./python-versions.md#installing-python-executables). [installing `python` and `python3` executables](./python-versions.md#installing-python-executables).