diff --git a/crates/uv-configuration/src/dev.rs b/crates/uv-configuration/src/dev.rs index a8980c060..80ae4f064 100644 --- a/crates/uv-configuration/src/dev.rs +++ b/crates/uv-configuration/src/dev.rs @@ -346,6 +346,18 @@ impl DevGroupsManifest { self.spec.prod() } + /// Returns `true` if the group was enabled by default. + pub fn default(&self, group: &GroupName) -> bool { + if self.spec.contains(group) { + // If the group was explicitly requested, then it wasn't enabled by default. + false + } else { + // If the group was enabled, but wasn't explicitly requested, then it was enabled by + // default. + self.contains(group) + } + } + /// Returns `true` if the group is included in the manifest. pub fn contains(&self, group: &GroupName) -> bool { if self.spec.contains(group) { diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 4538b89b0..5dce4614b 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -81,26 +81,8 @@ pub(crate) enum ProjectError { #[error("The current Python platform is not compatible with the lockfile's supported environments: {0}")] LockedPlatformIncompatibility(String), - #[error( - "{} are incompatible with the declared conflicts: {{{}}}", - _1.iter().map(|conflict| { - match conflict { - ConflictPackage::Extra(ref extra) => format!("extra `{extra}`"), - ConflictPackage::Group(ref group) => format!("group `{group}`"), - } - }).collect::>().join(", "), - _0 - .iter() - .map(|item| { - match item.conflict() { - ConflictPackage::Extra(ref extra) => format!("`{}[{}]`", item.package(), extra), - ConflictPackage::Group(ref group) => format!("`{}:{}`", item.package(), group), - } - }) - .collect::>() - .join(", "), - )] - ConflictIncompatibility(ConflictSet, Vec), + #[error(transparent)] + Conflict(#[from] ConflictError), #[error("The requested interpreter resolved to Python {0}, which is incompatible with the project's Python requirement: `{1}`")] RequestedPythonProjectIncompatibility(Version, RequiresPython), @@ -227,6 +209,43 @@ pub(crate) enum ProjectError { Anyhow(#[from] anyhow::Error), } +#[derive(Debug)] +pub(crate) struct ConflictError { + /// The set from which the conflict was derived. + pub(crate) set: ConflictSet, + /// The items from the set that were enabled, and thus create the conflict. + pub(crate) conflicts: Vec, + /// The manifest of enabled dependency groups. + pub(crate) dev: DevGroupsManifest, +} + +impl std::fmt::Display for ConflictError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} are incompatible with the declared conflicts: {{{}}}", + self.conflicts + .iter() + .map(|conflict| match conflict { + ConflictPackage::Extra(ref extra) => format!("extra `{extra}`"), + ConflictPackage::Group(ref group) if self.dev.default(group) => + format!("group `{group}` (enabled by default)"), + ConflictPackage::Group(ref group) => format!("group `{group}`"), + }) + .join(", "), + self.set + .iter() + .map(|item| match item.conflict() { + ConflictPackage::Extra(ref extra) => format!("`{}[{}]`", item.package(), extra), + ConflictPackage::Group(ref group) => format!("`{}:{}`", item.package(), group), + }) + .join(", ") + ) + } +} + +impl std::error::Error for ConflictError {} + /// Compute the `Requires-Python` bound for the [`Workspace`]. /// /// For a [`Workspace`] with multiple packages, the `Requires-Python` bound is the union of the @@ -1662,10 +1681,11 @@ pub(crate) fn detect_conflicts( } } if conflicts.len() >= 2 { - return Err(ProjectError::ConflictIncompatibility( - set.clone(), + return Err(ProjectError::Conflict(ConflictError { + set: set.clone(), conflicts, - )); + dev: dev.clone(), + })); } } Ok(()) diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index c4a98331f..5f9ea2ac2 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -3765,6 +3765,197 @@ fn lock_conflicting_group_basic() -> Result<()> { Ok(()) } +/// This tests a case of specifying conflicting groups, where some of the conflicts are enabled by +/// default. +#[test] +fn lock_conflicting_group_default() -> Result<()> { + let context = TestContext::new("3.12"); + + // Tell uv about the conflicting groups, which forces it to resolve each in + // their own fork. + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + description = "Add your description here" + requires-python = ">=3.12" + + [tool.uv] + conflicts = [ + [ + { group = "project1" }, + { group = "project2" }, + ], + ] + default-groups = ["project1"] + + [dependency-groups] + project1 = ["sortedcontainers==2.3.0"] + project2 = ["sortedcontainers==2.4.0"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "###); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + resolution-markers = [ + ] + conflicts = [[ + { package = "project", group = "project1" }, + { package = "project", group = "project2" }, + ]] + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "project" + version = "0.1.0" + source = { editable = "." } + + [package.dev-dependencies] + project1 = [ + { name = "sortedcontainers", version = "2.3.0", source = { registry = "https://pypi.org/simple" } }, + ] + project2 = [ + { name = "sortedcontainers", version = "2.4.0", source = { registry = "https://pypi.org/simple" } }, + ] + + [package.metadata] + + [package.metadata.requires-dev] + project1 = [{ name = "sortedcontainers", specifier = "==2.3.0" }] + project2 = [{ name = "sortedcontainers", specifier = "==2.4.0" }] + + [[package]] + name = "sortedcontainers" + version = "2.3.0" + source = { registry = "https://pypi.org/simple" } + resolution-markers = [ + ] + sdist = { url = "https://files.pythonhosted.org/packages/14/10/6a9481890bae97da9edd6e737c9c3dec6aea3fc2fa53b0934037b35c89ea/sortedcontainers-2.3.0.tar.gz", hash = "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1", size = 30509 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/20/4d/a7046ae1a1a4cc4e9bbed194c387086f06b25038be596543d026946330c9/sortedcontainers-2.3.0-py2.py3-none-any.whl", hash = "sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f", size = 29479 }, + ] + + [[package]] + name = "sortedcontainers" + version = "2.4.0" + source = { registry = "https://pypi.org/simple" } + resolution-markers = [ + ] + sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 }, + ] + "### + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "###); + + // Install from the lockfile, which should include the `project1` group by default. + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + project==0.1.0 (from file://[TEMP_DIR]/) + + sortedcontainers==2.3.0 + "###); + + // Another install, but with one of the groups enabled. + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--group=project1"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Audited 2 packages in [TIME] + "###); + + // Another install, but with the other group enabled. This should error, since `project1` is + // enabled by default. + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--group=project2"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: group `project1` (enabled by default), group `project2` are incompatible with the declared conflicts: {`project:project1`, `project:project2`} + "###); + + // If the group is explicitly requested, we should still fail, but shouldn't mark it as + // "enabled by default". + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--group=project1").arg("--group=project2"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: group `project1`, group `project2` are incompatible with the declared conflicts: {`project:project1`, `project:project2`} + "###); + + // If we install via `--all-groups`, we should also avoid marking the group as "enabled by + // default". + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--all-groups"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: group `project1`, group `project2` are incompatible with the declared conflicts: {`project:project1`, `project:project2`} + "###); + + // Disabling the default group should succeed. + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--no-group=project1").arg("--group=project2"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + - sortedcontainers==2.3.0 + + sortedcontainers==2.4.0 + "###); + + Ok(()) +} + /// This tests a case where we declare an extra and a group as conflicting. #[test] fn lock_conflicting_mixed() -> Result<()> {