diff --git a/crates/uv-resolver/src/lock/installable.rs b/crates/uv-resolver/src/lock/installable.rs index e301cd281..86f80bc87 100644 --- a/crates/uv-resolver/src/lock/installable.rs +++ b/crates/uv-resolver/src/lock/installable.rs @@ -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); diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 9f530ecd0..cc0c3267f 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -7714,3 +7714,97 @@ fn unsupported_git_scheme() -> Result<()> { "###); Ok(()) } + +/// See: +#[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(()) +}