Skip to content

Commit

Permalink
Annotate default groups in conflict error messages
Browse files Browse the repository at this point in the history
  • Loading branch information
charliermarsh committed Nov 22, 2024
1 parent 79e9f18 commit 428b977
Show file tree
Hide file tree
Showing 3 changed files with 246 additions and 23 deletions.
12 changes: 12 additions & 0 deletions crates/uv-configuration/src/dev.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
66 changes: 43 additions & 23 deletions crates/uv/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<String>>().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::<Vec<String>>()
.join(", "),
)]
ConflictIncompatibility(ConflictSet, Vec<ConflictPackage>),
#[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),
Expand Down Expand Up @@ -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<ConflictPackage>,
/// 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
Expand Down Expand Up @@ -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(())
Expand Down
191 changes: 191 additions & 0 deletions crates/uv/tests/it/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 defualt

Check warning on line 3886 in crates/uv/tests/it/lock.rs

View workflow job for this annotation

GitHub Actions / typos

"defualt" should be "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<()> {
Expand Down

0 comments on commit 428b977

Please sign in to comment.