From 8b0e9e5fb997cbdc2f4f58b43cd805d22e57bd1b Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 6 Apr 2026 14:09:57 -0400 Subject: [PATCH] Include conflicts defined in virtual workspace root (#18886) ## Summary Closes https://github.com/astral-sh/uv/issues/18879. --- crates/uv-pypi-types/src/conflicts.rs | 120 ++++++++-- crates/uv-workspace/src/workspace.rs | 20 +- crates/uv/src/commands/project/lock.rs | 2 +- crates/uv/src/commands/project/lock_target.rs | 6 +- crates/uv/tests/it/lock.rs | 220 ++++++++++++++++++ 5 files changed, 341 insertions(+), 27 deletions(-) diff --git a/crates/uv-pypi-types/src/conflicts.rs b/crates/uv-pypi-types/src/conflicts.rs index dbc469846..a838cabf3 100644 --- a/crates/uv-pypi-types/src/conflicts.rs +++ b/crates/uv-pypi-types/src/conflicts.rs @@ -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 for ConflictKindRef<'_> { } } +#[derive(Debug, Clone)] +pub struct ConflictEntry { + package: Option, + extra: Option, + group: Option, +} + +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); impl SchemaConflicts { + fn to_conflicts_with_default_package( + &self, + package: Option<&PackageName>, + ) -> Result { + 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 { + 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, } +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 for ConflictItem { type Error = ConflictError; fn try_from(wire: ConflictItemWire) -> Result { 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), diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index c404878df..70757f4ac 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -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), + #[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 { 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. diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 0067b7322..5c4819611 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -541,7 +541,7 @@ async fn do_lock( .collect::, 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 { diff --git a/crates/uv/src/commands/project/lock_target.rs b/crates/uv/src/commands/project/lock_target.rs index 1c4624e27..655c7dc0f 100644 --- a/crates/uv/src/commands/project/lock_target.rs +++ b/crates/uv/src/commands/project/lock_target.rs @@ -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 { match self { - Self::Workspace(workspace) => workspace.conflicts(), - Self::Script(_) => Conflicts::empty(), + Self::Workspace(workspace) => Ok(workspace.conflicts()?), + Self::Script(_) => Ok(Conflicts::empty()), } } diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 3f17ce7a4..22055de7b 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -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<()> {