Include conflicts defined in virtual workspace root (#18886)

## Summary

Closes https://github.com/astral-sh/uv/issues/18879.
This commit is contained in:
Charlie Marsh
2026-04-06 14:09:57 -04:00
committed by GitHub
parent d1db4ef72f
commit 8b0e9e5fb9
5 changed files with 341 additions and 27 deletions
+100 -20
View File
@@ -5,6 +5,7 @@ use petgraph::{
use rustc_hash::{FxHashMap, FxHashSet};
#[cfg(feature = "schemars")]
use std::borrow::Cow;
use std::fmt;
use std::{collections::BTreeSet, hash::Hash, rc::Rc};
use uv_normalize::{ExtraName, GroupName, PackageName};
@@ -542,6 +543,33 @@ impl hashbrown::Equivalent<ConflictKind> for ConflictKindRef<'_> {
}
}
#[derive(Debug, Clone)]
pub struct ConflictEntry {
package: Option<PackageName>,
extra: Option<ExtraName>,
group: Option<GroupName>,
}
impl fmt::Display for ConflictEntry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut parts = vec![];
if let Some(package) = &self.package {
parts.push(format!("package = \"{package}\""));
}
if let Some(extra) = &self.extra {
parts.push(format!("extra = \"{extra}\""));
}
if let Some(group) = &self.group {
parts.push(format!("group = \"{group}\""));
}
if parts.is_empty() {
write!(f, "{{}}")
} else {
write!(f, "{{ {} }}", parts.join(", "))
}
}
}
/// An error that occurs when the given conflicting set is invalid somehow.
#[derive(Debug, thiserror::Error)]
pub enum ConflictError {
@@ -556,8 +584,8 @@ pub enum ConflictError {
/// (This is only applicable when deserializing from the lock file.
/// When deserializing from `pyproject.toml`, the `package` field is
/// optional.)
#[error("Expected `package` field in conflicting entry")]
MissingPackage,
#[error("Expected `package` field in conflicting entry: {0}")]
MissingPackage(ConflictEntry),
/// An error that occurs when all of `package`, `extra` and `group` are missing.
#[error("Expected `package`, `extra` or `group` field in conflicting entry")]
MissingPackageAndExtraAndGroup,
@@ -582,6 +610,41 @@ pub enum ConflictError {
pub struct SchemaConflicts(Vec<SchemaConflictSet>);
impl SchemaConflicts {
fn to_conflicts_with_default_package(
&self,
package: Option<&PackageName>,
) -> Result<Conflicts, ConflictError> {
let mut conflicting = Conflicts::empty();
for tool_uv_set in &self.0 {
let mut set = vec![];
for item in &tool_uv_set.0 {
let package = item
.package
.as_ref()
.or(package)
.ok_or_else(|| ConflictError::MissingPackage(ConflictEntry::from(item)))?
.clone();
set.push(ConflictItem {
package,
kind: item.kind.clone(),
});
}
// OK because we guarantee that `SchemaConflictSet` is valid and
// there aren't any new errors that can occur here.
let set = ConflictSet::try_from(set).unwrap();
conflicting.push(set);
}
Ok(conflicting)
}
/// Convert the public schema "conflicting" type to our internal fully
/// resolved type.
///
/// Every conflict item must define `package` explicitly.
pub fn to_conflicts(&self) -> Result<Conflicts, ConflictError> {
self.to_conflicts_with_default_package(None)
}
/// Convert the public schema "conflicting" type to our internal fully
/// resolved type. Effectively, this pairs the corresponding package name
/// with each conflict.
@@ -590,23 +653,8 @@ impl SchemaConflicts {
/// then that takes precedence over the given package name, which is only
/// used when there is no explicit package name written.
pub fn to_conflicts_with_package_name(&self, package: &PackageName) -> Conflicts {
let mut conflicting = Conflicts::empty();
for tool_uv_set in &self.0 {
let mut set = vec![];
for item in &tool_uv_set.0 {
let package = item.package.clone().unwrap_or_else(|| package.clone());
set.push(ConflictItem {
package: package.clone(),
kind: item.kind.clone(),
});
}
// OK because we guarantee that
// `SchemaConflictingGroupList` is valid and there aren't
// any new errors that can occur here.
let set = ConflictSet::try_from(set).unwrap();
conflicting.push(set);
}
conflicting
self.to_conflicts_with_default_package(Some(package))
.expect("default package name should satisfy schema conflicts")
}
}
@@ -689,12 +737,44 @@ struct ConflictItemWire {
group: Option<GroupName>,
}
impl From<&SchemaConflictItem> for ConflictEntry {
fn from(item: &SchemaConflictItem) -> Self {
match &item.kind {
ConflictKind::Project => Self {
package: item.package.clone(),
extra: None,
group: None,
},
ConflictKind::Extra(extra) => Self {
package: item.package.clone(),
extra: Some(extra.clone()),
group: None,
},
ConflictKind::Group(group) => Self {
package: item.package.clone(),
extra: None,
group: Some(group.clone()),
},
}
}
}
impl From<&ConflictItemWire> for ConflictEntry {
fn from(item: &ConflictItemWire) -> Self {
Self {
package: item.package.clone(),
extra: item.extra.clone(),
group: item.group.clone(),
}
}
}
impl TryFrom<ConflictItemWire> for ConflictItem {
type Error = ConflictError;
fn try_from(wire: ConflictItemWire) -> Result<Self, ConflictError> {
let Some(package) = wire.package else {
return Err(ConflictError::MissingPackage);
return Err(ConflictError::MissingPackage(ConflictEntry::from(&wire)));
};
match (wire.extra, wire.group) {
(Some(_), Some(_)) => Err(ConflictError::FoundExtraAndGroup),
+17 -3
View File
@@ -15,7 +15,7 @@ use uv_fs::{CWD, Simplified};
use uv_normalize::{DEV_DEPENDENCIES, GroupName, PackageName};
use uv_pep440::VersionSpecifiers;
use uv_pep508::{MarkerTree, VerbatimUrl};
use uv_pypi_types::{Conflicts, SupportedEnvironments, VerbatimParsedUrl};
use uv_pypi_types::{ConflictError, Conflicts, SupportedEnvironments, VerbatimParsedUrl};
use uv_static::EnvVars;
use uv_warnings::warn_user_once;
@@ -81,6 +81,8 @@ pub enum WorkspaceError {
Io(#[from] std::io::Error),
#[error("Failed to parse: `{}`", _0.user_display())]
Toml(PathBuf, #[source] Box<PyprojectTomlError>),
#[error(transparent)]
Conflicts(#[from] ConflictError),
#[error("Failed to normalize workspace member path")]
Normalize(#[source] std::io::Error),
}
@@ -548,12 +550,24 @@ impl Workspace {
}
/// Returns the set of conflicts for the workspace.
pub fn conflicts(&self) -> Conflicts {
pub fn conflicts(&self) -> Result<Conflicts, WorkspaceError> {
let mut conflicting = Conflicts::empty();
if self.is_non_project() {
if let Some(root_conflicts) = self
.pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.conflicts.as_ref())
{
let mut root_conflicts = root_conflicts.to_conflicts()?;
conflicting.append(&mut root_conflicts);
}
}
for member in self.packages.values() {
conflicting.append(&mut member.pyproject_toml.conflicts());
}
conflicting
Ok(conflicting)
}
/// Returns an iterator over the `requires-python` values for each member of the workspace.
+1 -1
View File
@@ -541,7 +541,7 @@ async fn do_lock(
.collect::<Result<BTreeMap<_, _>, ProjectError>>()?;
// Collect the conflicts.
let mut conflicts = target.conflicts();
let mut conflicts = target.conflicts()?;
if let LockTarget::Workspace(workspace) = target {
if let Some(groups) = &workspace.pyproject_toml().dependency_groups {
if let Some(project) = &workspace.pyproject_toml().project {
@@ -207,10 +207,10 @@ impl<'lock> LockTarget<'lock> {
}
/// Returns the set of conflicts for the [`LockTarget`].
pub(crate) fn conflicts(self) -> Conflicts {
pub(crate) fn conflicts(self) -> Result<Conflicts, ProjectError> {
match self {
Self::Workspace(workspace) => workspace.conflicts(),
Self::Script(_) => Conflicts::empty(),
Self::Workspace(workspace) => Ok(workspace.conflicts()?),
Self::Script(_) => Ok(Conflicts::empty()),
}
}
+220
View File
@@ -18317,6 +18317,226 @@ fn lock_non_project_sources() -> Result<()> {
Ok(())
}
/// Lock a non-project workspace root with conflicts declared between members.
#[test]
fn lock_non_project_member_conflicts() -> Result<()> {
let context = uv_test::test_context!("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[tool.uv.workspace]
members = ["member-a", "member-b"]
"#,
)?;
let member_a = context.temp_dir.child("member-a");
member_a.create_dir_all()?;
member_a.child("pyproject.toml").write_str(
r#"
[project]
name = "member-a"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["sortedcontainers==2.3.0"]
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#,
)?;
let member_b = context.temp_dir.child("member-b");
member_b.create_dir_all()?;
member_b.child("pyproject.toml").write_str(
r#"
[project]
name = "member-b"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["sortedcontainers==2.4.0"]
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× No solution found when resolving dependencies:
Because member-a depends on sortedcontainers==2.3.0 and member-b depends on sortedcontainers==2.4.0, we can conclude that member-a and member-b are incompatible.
And because your workspace requires member-a and member-b, we can conclude that your workspace's requirements are unsatisfiable.
");
pyproject_toml.write_str(
r#"
[tool.uv.workspace]
members = ["member-a", "member-b"]
[tool.uv]
conflicts = [
[
{ package = "member-a" },
{ package = "member-b" },
],
]
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning.
Resolved 4 packages in [TIME]
");
let lock = context.read("uv.lock");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 3
requires-python = ">=3.12"
conflicts = [[
{ package = "member-a" },
{ package = "member-b" },
]]
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[manifest]
members = [
"member-a",
"member-b",
]
[[package]]
name = "member-a"
version = "0.1.0"
source = { editable = "member-a" }
dependencies = [
{ name = "sortedcontainers", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'project-8-member-a'" },
]
[package.metadata]
requires-dist = [{ name = "sortedcontainers", specifier = "==2.3.0" }]
[[package]]
name = "member-b"
version = "0.1.0"
source = { editable = "member-b" }
dependencies = [
{ name = "sortedcontainers", version = "2.4.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'project-8-member-b'" },
]
[package.metadata]
requires-dist = [{ name = "sortedcontainers", specifier = "==2.4.0" }]
[[package]]
name = "sortedcontainers"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/14/10/6a9481890bae97da9edd6e737c9c3dec6aea3fc2fa53b0934037b35c89ea/sortedcontainers-2.3.0.tar.gz", hash = "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1", size = 30509, upload-time = "2020-11-09T00:03:52.258Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/4d/a7046ae1a1a4cc4e9bbed194c387086f06b25038be596543d026946330c9/sortedcontainers-2.3.0-py2.py3-none-any.whl", hash = "sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f", size = 29479, upload-time = "2020-11-09T00:03:50.723Z" },
]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
]
"#
);
});
uv_snapshot!(context.filters(), context.lock().arg("--locked"), @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning.
Resolved 4 packages in [TIME]
");
uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--all-packages"), @"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Package `member-a` and package `member-b` are incompatible with the declared conflicts: {member-a, member-b}
");
Ok(())
}
/// Locking a non-project workspace root should reject conflicts that omit
/// `package = ...`, since there is no root project name to infer.
#[test]
fn lock_non_project_member_conflicts_missing_package() -> Result<()> {
let context = uv_test::test_context!("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[tool.uv.workspace]
members = ["member-a"]
[tool.uv]
conflicts = [
[
{ extra = "foo" },
{ package = "member-a" },
],
]
"#,
)?;
let member_a = context.temp_dir.child("member-a");
member_a.create_dir_all()?;
member_a.child("pyproject.toml").write_str(
r#"
[project]
name = "member-a"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r#"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Expected `package` field in conflicting entry: { extra = "foo" }
"#);
Ok(())
}
/// `coverage` defines a `toml` extra, but it doesn't enable any dependencies after Python 3.11.
#[test]
fn lock_dropped_dev_extra() -> Result<()> {