Avoid installing duplicate dependencies across conflicting groups (#11653)

## Summary

We need to compute the set of activated groups prior to evaluating the
conflict markers on the groups' dependencies.

Closes https://github.com/astral-sh/uv/issues/11648.
This commit is contained in:
Charlie Marsh 2025-02-20 12:17:13 -08:00 committed by GitHub
parent 3365eb4a1c
commit b588a8ea2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 137 additions and 5 deletions

View file

@ -53,6 +53,43 @@ pub trait Installable<'lock> {
let root = petgraph.add_node(Node::Root);
// Determine the set of activated extras and groups, from the root.
//
// TODO(charlie): This isn't quite right. Below, when we add the dependency groups to the
// graph, we rely on the activated extras and dependency groups, to evaluate the conflict
// marker. But at that point, we don't know the full set of activated extras; this is only
// computed below. We somehow need to add the dependency groups _after_ we've computed all
// enabled extras, but the groups themselves could depend on the set of enabled extras.
if !self.lock().conflicts().is_empty() {
for root_name in self.roots() {
let dist = self
.lock()
.find_by_name(root_name)
.map_err(|_| LockErrorKind::MultipleRootPackages {
name: root_name.clone(),
})?
.ok_or_else(|| LockErrorKind::MissingRootPackage {
name: root_name.clone(),
})?;
// Track the activated extras.
if dev.prod() {
for extra in extras.extra_names(dist.optional_dependencies.keys()) {
activated_extras.push((&dist.id.name, extra));
}
}
// Track the activated groups.
for group in dist
.dependency_groups
.keys()
.filter(|group| dev.contains(group))
{
activated_groups.push((&dist.id.name, group));
}
}
}
// Add the workspace packages to the queue.
for root_name in self.roots() {
let dist = self
@ -77,12 +114,10 @@ pub trait Installable<'lock> {
petgraph.add_edge(root, index, Edge::Prod(MarkerTree::TRUE));
if dev.prod() {
// Push its dependencies on the queue and track
// activated extras.
// Push its dependencies onto the queue.
queue.push_back((dist, None));
for extra in extras.extra_names(dist.optional_dependencies.keys()) {
queue.push_back((dist, Some(extra)));
activated_extras.push((&dist.id.name, extra));
}
}
@ -99,10 +134,13 @@ pub trait Installable<'lock> {
})
.flatten()
{
if !dep.complexified_marker.evaluate_no_extras(marker_env) {
if !dep.complexified_marker.evaluate(
marker_env,
activated_extras.iter().copied(),
activated_groups.iter().copied(),
) {
continue;
}
activated_groups.push((&dist.id.name, group));
let dep_dist = self.lock().find_by_id(&dep.package_id);

View file

@ -7714,3 +7714,97 @@ fn unsupported_git_scheme() -> Result<()> {
"###);
Ok(())
}
/// See: <https://github.com/astral-sh/uv/issues/11648>
#[test]
fn multiple_group_conflicts() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
[dependency-groups]
foo = [
"iniconfig>=2",
]
bar = [
"iniconfig<2",
]
baz = [
"iniconfig",
]
[tool.uv]
conflicts = [
[
{ group = "foo" },
{ group = "bar" },
],
]
"#,
)?;
uv_snapshot!(context.filters(), context.sync(), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
Audited in [TIME]
"###);
uv_snapshot!(context.filters(), context.sync().arg("--group").arg("baz"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ iniconfig==2.0.0
"###);
uv_snapshot!(context.filters(), context.sync().arg("--group").arg("foo").arg("--group").arg("baz"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
Audited 1 package in [TIME]
"###);
uv_snapshot!(context.filters(), context.sync().arg("--group").arg("bar").arg("--group").arg("baz"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
Prepared 1 package in [TIME]
Uninstalled 1 package in [TIME]
Installed 1 package in [TIME]
- iniconfig==2.0.0
+ iniconfig==1.1.1
"###);
uv_snapshot!(context.filters(), context.sync().arg("--group").arg("foo").arg("--group").arg("bar"), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
error: Groups `foo` and `bar` are incompatible with the declared conflicts: {`project:foo`, `project:bar`}
"###);
Ok(())
}