mirror of https://github.com/astral-sh/uv
Annotate default groups in conflict error messages (#9368)
## Summary We now tell the user if a group was enabled by default. Closes https://github.com/astral-sh/uv/issues/9366.
This commit is contained in:
parent
1744a9b0a1
commit
0ae9a8b742
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
@ -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
|
||||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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<()> {
|
||||
|
|
|
|||
Loading…
Reference in New Issue