From a237f7ed22206db907ecf106eb9373081a9ef048 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 17 Jun 2026 15:41:44 -0500 Subject: [PATCH 1/2] Preserve attribution in inverted dependency trees --- crates/uv-resolver/src/lock/tree.rs | 187 +++++++++++---- crates/uv/tests/project/tree.rs | 354 +++++++++++++++++++++++++++- 2 files changed, 493 insertions(+), 48 deletions(-) diff --git a/crates/uv-resolver/src/lock/tree.rs b/crates/uv-resolver/src/lock/tree.rs index 9dbe953d9c783..e9bacb5726f2f 100644 --- a/crates/uv-resolver/src/lock/tree.rs +++ b/crates/uv-resolver/src/lock/tree.rs @@ -18,7 +18,7 @@ use uv_pep508::MarkerTree; use uv_pypi_types::ResolverMarkerEnvironment; use crate::lock::{Package, PackageId}; -use crate::{Lock, PackageMap}; +use crate::{ConflictMarker, Lock, PackageMap, UniversalMarker}; #[derive(Debug)] pub struct TreeDisplay<'env> { @@ -38,6 +38,8 @@ pub struct TreeDisplay<'env> { lock: &'env Lock, /// Whether to show sizes in the rendered output. show_sizes: bool, + /// The marker constraints imposed by declared conflicting extras and groups. + conflict_marker: UniversalMarker, } impl<'env> TreeDisplay<'env> { @@ -76,6 +78,13 @@ impl<'env> TreeDisplay<'env> { .collect() }; + // Conflict extras and groups are encoded as marker expressions. Include the declared + // mutual-exclusion constraints when checking whether a universal path is satisfiable. + let conflict_marker = UniversalMarker::new( + MarkerTree::TRUE, + ConflictMarker::from_conflicts(lock.conflicts()), + ); + // Create a graph. let size_guess = lock.packages.len(); let mut graph = @@ -102,7 +111,7 @@ impl<'env> TreeDisplay<'env> { .or_insert_with(|| graph.add_node(Node::Package(id))); // Add an edge from the root. - graph.add_edge(root, index, Edge::Prod(None)); + graph.add_edge(root, index, Edge::Prod(None, UniversalMarker::TRUE)); if groups.prod() { // Push its dependencies on the queue. @@ -150,7 +159,11 @@ impl<'env> TreeDisplay<'env> { graph.add_edge( index, dep_index, - Edge::Dev(group, Some(RequestedExtras::Dependency(&dep.extra))), + Edge::Dev( + group, + Some(RequestedExtras::Dependency(&dep.extra)), + dep.complexified_marker, + ), ); // Push its dependencies on the queue. @@ -215,9 +228,10 @@ impl<'env> TreeDisplay<'env> { graph.add_edge( root, *index, - Edge::Prod(Some(RequestedExtras::Requirement( - requirement.extras.as_ref(), - ))), + Edge::Prod( + Some(RequestedExtras::Requirement(requirement.extras.as_ref())), + UniversalMarker::from_combined(marker), + ), ); // Push its dependencies on the queue. @@ -269,6 +283,7 @@ impl<'env> TreeDisplay<'env> { Edge::Dev( group, Some(RequestedExtras::Requirement(requirement.extras.as_ref())), + UniversalMarker::from_combined(marker), ), ); @@ -324,9 +339,16 @@ impl<'env> TreeDisplay<'env> { index, dep_index, if let Some(extra) = extra { - Edge::Optional(extra, Some(RequestedExtras::Dependency(&dep.extra))) + Edge::Optional( + extra, + Some(RequestedExtras::Dependency(&dep.extra)), + dep.complexified_marker, + ) } else { - Edge::Prod(Some(RequestedExtras::Dependency(&dep.extra))) + Edge::Prod( + Some(RequestedExtras::Dependency(&dep.extra)), + dep.complexified_marker, + ) }, ); @@ -446,6 +468,7 @@ impl<'env> TreeDisplay<'env> { invert, lock, show_sizes, + conflict_marker, } } @@ -471,6 +494,7 @@ impl<'env> TreeDisplay<'env> { let visited_node = VisitedNode { package_id, expanded_extras: expanded_extras.clone(), + marker: self.invert.then_some(cursor.marker()), }; let line = { @@ -492,11 +516,11 @@ impl<'env> TreeDisplay<'env> { if let Some(edge) = edge { match edge { - Edge::Prod(_) => {} - Edge::Optional(extra, _) => { + Edge::Prod(..) => {} + Edge::Optional(extra, ..) => { let _ = write!(line, " (extra: {extra})"); } - Edge::Dev(group, _) => { + Edge::Dev(group, ..) => { let _ = write!(line, " (group: {group})"); } } @@ -538,25 +562,51 @@ impl<'env> TreeDisplay<'env> { line }; - let mut dependencies = self - .graph - .edges_directed(cursor.node(), Direction::Outgoing) - .filter_map(|edge| match self.graph[edge.target()] { - Node::Root => None, - Node::Package(_) => { - // Only include extra-conditional dependencies if the activating extra is - // enabled in the current context. - if !self.invert - && let Edge::Optional(required_extra, _) = &self.graph[edge.id()] - { - if !expanded_extras.contains(required_extra) { - return None; + let mut dependencies = if self.invert && edge.is_some_and(Edge::is_dev) { + // A member's dependency group is activated for the root member. It is not part of the + // member when that member is installed as another package's dependency. + Vec::new() + } else { + self.graph + .edges_directed(cursor.node(), Direction::Outgoing) + .filter_map(|edge| match self.graph[edge.target()] { + Node::Root => None, + Node::Package(_) => { + let edge_kind = &self.graph[edge.id()]; + + if self.invert { + // If the path to the target requires an extra on this package, only + // follow consumers that activate that extra. + if !expanded_extras.is_empty() + && edge_kind.extras().is_none_or(|extras| { + !expanded_extras.iter().all(|extra| extras.contains(extra)) + }) + { + return None; + } + + // A package node can appear in several universal marker branches. Do + // not join incoming and outgoing edges that cannot coexist. + let mut marker = cursor.marker(); + marker.and(edge_kind.marker()); + if marker.is_false() { + return None; + } + Some(Cursor::new(edge.target(), edge.id(), marker)) + } else { + // Only include extra-conditional dependencies if the activating extra + // is enabled in the current context. + if let Edge::Optional(required_extra, ..) = edge_kind + && !expanded_extras.contains(required_extra) + { + return None; + } + Some(Cursor::new(edge.target(), edge.id(), UniversalMarker::TRUE)) } } - Some(Cursor::new(edge.target(), edge.id())) - } - }) - .collect::>(); + }) + .collect::>() + }; dependencies.sort_by_key(|cursor| { let node = &self.graph[cursor.node()]; let edge = cursor @@ -639,7 +689,7 @@ impl<'env> TreeDisplay<'env> { let node = edge.target(); path.clear(); lines.extend(self.visit( - Cursor::new(node, edge.id()), + Cursor::new(node, edge.id(), self.conflict_marker), &mut visited, &mut path, )); @@ -647,7 +697,11 @@ impl<'env> TreeDisplay<'env> { } Node::Package(_) => { path.clear(); - lines.extend(self.visit(Cursor::root(*node), &mut visited, &mut path)); + lines.extend(self.visit( + Cursor::root(*node, self.conflict_marker), + &mut visited, + &mut path, + )); } } } @@ -662,10 +716,9 @@ impl<'env> TreeDisplay<'env> { edge: Option<&Edge<'env>>, ) -> BTreeSet<&'env ExtraName> { if self.invert { - // In inverted mode, optional edges are reverse "required by extra" relationships. - // They do not select this package's outgoing dependencies, so de-dupe stays - // package-only. - return BTreeSet::default(); + // In inverted mode, an optional edge records the extra that must have been activated + // on this package for the path to exist. + return edge.and_then(Edge::required_extra).into_iter().collect(); } let Some(requested_extras) = edge.and_then(Edge::extras) else { @@ -684,6 +737,7 @@ impl<'env> TreeDisplay<'env> { struct VisitedNode<'env> { package_id: &'env PackageId, expanded_extras: BTreeSet<&'env ExtraName>, + marker: Option, } #[derive(Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] @@ -696,23 +750,52 @@ enum Node<'env> { #[derive(Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] enum Edge<'env> { - Prod(Option>), - Optional(&'env ExtraName, Option>), - Dev(&'env GroupName, Option>), + Prod(Option>, UniversalMarker), + Optional( + &'env ExtraName, + Option>, + UniversalMarker, + ), + Dev( + &'env GroupName, + Option>, + UniversalMarker, + ), } impl<'env> Edge<'env> { fn extras(&self) -> Option> { match self { - Self::Prod(extras) | Self::Optional(_, extras) | Self::Dev(_, extras) => *extras, + Self::Prod(extras, _) => *extras, + Self::Optional(_, extras, _) => *extras, + Self::Dev(_, extras, _) => *extras, + } + } + + fn required_extra(&self) -> Option<&'env ExtraName> { + match self { + Self::Optional(extra, ..) => Some(extra), + Self::Prod(..) | Self::Dev(..) => None, } } + fn marker(&self) -> UniversalMarker { + match self { + Self::Prod(_, marker) | Self::Optional(_, _, marker) | Self::Dev(_, _, marker) => { + *marker + } + } + } + + fn is_dev(&self) -> bool { + matches!(self, Self::Dev(..)) + } + fn kind(&self) -> EdgeKind<'env> { match self { - Self::Prod(_) => EdgeKind::Prod, - Self::Optional(extra, _) => EdgeKind::Optional(extra), - Self::Dev(group, _) => EdgeKind::Dev(group), + Self::Prod(..) => EdgeKind::Prod, + Self::Optional(extra, ..) => EdgeKind::Optional(extra), + Self::Dev(group, ..) => EdgeKind::Dev(group), } } } @@ -744,6 +827,13 @@ impl Ord for RequestedExtras<'_> { } impl<'env> RequestedExtras<'env> { + fn contains(self, extra: &ExtraName) -> bool { + match self { + Self::Dependency(extras) => extras.contains(extra), + Self::Requirement(extras) => extras.contains(extra), + } + } + fn is_empty(self) -> bool { match self { Self::Dependency(extras) => extras.is_empty(), @@ -768,17 +858,17 @@ enum EdgeKind<'env> { /// A node in the dependency graph along with the edge that led to it, or `None` for root nodes. #[derive(Debug, Copy, Clone, PartialEq, Eq, Ord, PartialOrd)] -struct Cursor(NodeIndex, Option); +struct Cursor(NodeIndex, Option, UniversalMarker); impl Cursor { /// Create a [`Cursor`] representing a node in the dependency tree. - fn new(node: NodeIndex, edge: EdgeIndex) -> Self { - Self(node, Some(edge)) + fn new(node: NodeIndex, edge: EdgeIndex, marker: UniversalMarker) -> Self { + Self(node, Some(edge), marker) } /// Create a [`Cursor`] representing a root node in the dependency tree. - fn root(node: NodeIndex) -> Self { - Self(node, None) + fn root(node: NodeIndex, marker: UniversalMarker) -> Self { + Self(node, None, marker) } /// Return the [`NodeIndex`] of the node. @@ -790,6 +880,11 @@ impl Cursor { fn edge(&self) -> Option { self.1 } + + /// Return the marker context accumulated along the path to this node. + fn marker(&self) -> UniversalMarker { + self.2 + } } impl std::fmt::Display for TreeDisplay<'_> { diff --git a/crates/uv/tests/project/tree.rs b/crates/uv/tests/project/tree.rs index 7555e2119de90..6515e2f5b0110 100644 --- a/crates/uv/tests/project/tree.rs +++ b/crates/uv/tests/project/tree.rs @@ -766,7 +766,8 @@ fn optional_dependencies_inverted() -> Result<()> { └── project[dotenv] v0.1.0 colorama v0.4.6 └── click v8.1.7 - └── flask v3.0.2 (*) + └── flask v3.0.2 + └── project[dotenv] v0.1.0 idna v3.6 └── anyio v4.3.0 └── project v0.1.0 (extra: async) @@ -780,7 +781,8 @@ fn optional_dependencies_inverted() -> Result<()> { └── werkzeug v3.0.1 └── flask v3.0.2 (*) python-dotenv v1.0.1 - └── flask v3.0.2 (extra: dotenv) (*) + └── flask v3.0.2 (extra: dotenv) + └── project[dotenv] v0.1.0 sniffio v1.3.1 └── anyio v4.3.0 (*) (*) Package tree already displayed @@ -1640,6 +1642,354 @@ fn workspace_dev() -> Result<()> { Ok(()) } +/// An inverted tree should only follow consumers that activate the extra required by the path. +#[test] +fn invert_preserves_extra_attribution() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + context.temp_dir.child("pyproject.toml").write_str( + r#" + [tool.uv.workspace] + members = ["packages/*"] + "#, + )?; + + let package_a = context.temp_dir.child("packages").child("package-a"); + package_a.child("pyproject.toml").write_str( + r#" + [project] + name = "package-a" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["package-p[feature]"] + + [tool.uv.sources] + package-p = { workspace = true } + "#, + )?; + + let package_b = context.temp_dir.child("packages").child("package-b"); + package_b.child("pyproject.toml").write_str( + r#" + [project] + name = "package-b" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["package-p"] + + [tool.uv.sources] + package-p = { workspace = true } + "#, + )?; + + let package_p = context.temp_dir.child("packages").child("package-p"); + package_p.child("pyproject.toml").write_str( + r#" + [project] + name = "package-p" + version = "0.1.0" + requires-python = ">=3.12" + + [project.optional-dependencies] + feature = ["package-x"] + + [tool.uv.sources] + package-x = { workspace = true } + "#, + )?; + + let package_x = context.temp_dir.child("packages").child("package-x"); + package_x.child("pyproject.toml").write_str( + r#" + [project] + name = "package-x" + version = "0.1.0" + requires-python = ">=3.12" + "#, + )?; + + uv_snapshot!(context.filters(), context.tree().arg("--universal").arg("--invert").arg("--package").arg("package-x"), @" + success: true + exit_code: 0 + ----- stdout ----- + package-x v0.1.0 + └── package-p v0.1.0 (extra: feature) + └── package-a[feature] v0.1.0 + + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + Ok(()) +} + +/// Member dependency groups describe the root member, not consumers of that member. +#[test] +fn invert_preserves_dependency_group_attribution() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + context.temp_dir.child("pyproject.toml").write_str( + r#" + [tool.uv.workspace] + members = ["packages/*"] + "#, + )?; + + let package_a = context.temp_dir.child("packages").child("package-a"); + package_a.child("pyproject.toml").write_str( + r#" + [project] + name = "package-a" + version = "0.1.0" + requires-python = ">=3.12" + + [dependency-groups] + dev = ["package-x"] + + [tool.uv.sources] + package-x = { workspace = true } + "#, + )?; + + let package_b = context.temp_dir.child("packages").child("package-b"); + package_b.child("pyproject.toml").write_str( + r#" + [project] + name = "package-b" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["package-a"] + + [tool.uv.sources] + package-a = { workspace = true } + "#, + )?; + + let package_x = context.temp_dir.child("packages").child("package-x"); + package_x.child("pyproject.toml").write_str( + r#" + [project] + name = "package-x" + version = "0.1.0" + requires-python = ">=3.12" + "#, + )?; + + uv_snapshot!(context.filters(), context.tree().arg("--universal").arg("--invert").arg("--package").arg("package-x"), @" + success: true + exit_code: 0 + ----- stdout ----- + package-x v0.1.0 + └── package-a v0.1.0 (group: dev) + + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + Ok(()) +} + +/// Universal inverted trees should not join edges from mutually exclusive environments. +#[test] +fn invert_preserves_marker_attribution() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + context.temp_dir.child("pyproject.toml").write_str( + r#" + [tool.uv.workspace] + members = ["packages/*"] + "#, + )?; + + let package_a = context.temp_dir.child("packages").child("package-a"); + package_a.child("pyproject.toml").write_str( + r#" + [project] + name = "package-a" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["package-p; sys_platform == 'win32'"] + + [tool.uv.sources] + package-p = { workspace = true } + "#, + )?; + + let package_p = context.temp_dir.child("packages").child("package-p"); + package_p.child("pyproject.toml").write_str( + r#" + [project] + name = "package-p" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["package-x; sys_platform == 'linux'"] + + [tool.uv.sources] + package-x = { workspace = true } + "#, + )?; + + let package_b = context.temp_dir.child("packages").child("package-b"); + package_b.child("pyproject.toml").write_str( + r#" + [project] + name = "package-b" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["package-p; sys_platform == 'linux'"] + + [tool.uv.sources] + package-p = { workspace = true } + "#, + )?; + + let package_x = context.temp_dir.child("packages").child("package-x"); + package_x.child("pyproject.toml").write_str( + r#" + [project] + name = "package-x" + version = "0.1.0" + requires-python = ">=3.12" + "#, + )?; + + uv_snapshot!(context.filters(), context.tree().arg("--universal").arg("--invert").arg("--package").arg("package-x"), @" + success: true + exit_code: 0 + ----- stdout ----- + package-x v0.1.0 + └── package-p v0.1.0 + └── package-b v0.1.0 + + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + Ok(()) +} + +/// Declared conflicts are world knowledge for the encoded extra and group markers. +#[test] +fn invert_preserves_conflict_marker_attribution() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + context.temp_dir.child("pyproject.toml").write_str( + r#" + [tool.uv.workspace] + members = ["packages/*"] + + [tool.uv] + conflicts = [ + [ + { package = "a", extra = "bar" }, + { package = "p", extra = "foo" }, + ], + ] + "#, + )?; + + let package_a = context.temp_dir.child("packages").child("a"); + package_a.child("pyproject.toml").write_str( + r#" + [project] + name = "a" + version = "0.1.0" + requires-python = ">=3.12" + + [project.optional-dependencies] + bar = ["p[foo]"] + + [tool.uv.sources] + p = { workspace = true } + "#, + )?; + + let package_p = context.temp_dir.child("packages").child("p"); + package_p.child("pyproject.toml").write_str( + r#" + [project] + name = "p" + version = "0.1.0" + requires-python = ">=3.12" + + [project.optional-dependencies] + foo = ["x"] + + [tool.uv.sources] + x = { workspace = true } + "#, + )?; + + let package_x = context.temp_dir.child("packages").child("x"); + package_x.child("pyproject.toml").write_str( + r#" + [project] + name = "x" + version = "0.1.0" + requires-python = ">=3.12" + "#, + )?; + + // Preserve both encoded conflict conditions in the lock. Resolving this project would simplify + // the impossible `a[bar] -> p[foo]` edge before `uv tree` can exercise path attribution. + context.temp_dir.child("uv.lock").write_str( + r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + conflicts = [[ + { package = "a", extra = "bar" }, + { package = "p", extra = "foo" }, + ]] + + [manifest] + members = [ + "a", + "p", + "x", + ] + + [[package]] + name = "a" + version = "0.1.0" + source = { virtual = "packages/a" } + + [package.optional-dependencies] + bar = [ + { name = "p", extra = ["foo"], marker = "extra == 'extra-1-a-bar'" }, + ] + + [[package]] + name = "p" + version = "0.1.0" + source = { editable = "packages/p" } + + [package.optional-dependencies] + foo = [ + { name = "x", marker = "extra == 'extra-1-p-foo'" }, + ] + + [[package]] + name = "x" + version = "0.1.0" + source = { editable = "packages/x" } + "#, + )?; + + uv_snapshot!(context.filters(), context.tree().arg("--frozen").arg("--universal").arg("--invert").arg("--package").arg("x"), @" + success: true + exit_code: 0 + ----- stdout ----- + x v0.1.0 + └── p v0.1.0 (extra: foo) + + ----- stderr ----- + + "); + + Ok(()) +} + #[test] fn non_project() -> Result<()> { let context = uv_test::test_context!("3.12"); From e5f055b309a5afb79b2812016ddc6b17bfb24765 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 18 Jun 2026 08:30:43 -0500 Subject: [PATCH 2/2] Test inverted marker-split versions --- crates/uv/tests/project/tree.rs | 84 +++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/crates/uv/tests/project/tree.rs b/crates/uv/tests/project/tree.rs index 6515e2f5b0110..b1d0f0c784eb6 100644 --- a/crates/uv/tests/project/tree.rs +++ b/crates/uv/tests/project/tree.rs @@ -1868,6 +1868,90 @@ fn invert_preserves_marker_attribution() -> Result<()> { Ok(()) } +/// Universal inverted trees should include every version that directly depends on a package in a +/// satisfiable marker environment. +#[test] +fn invert_preserves_marker_split_versions() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + context.temp_dir.child("pyproject.toml").write_str( + r#" + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.12" + dependencies = [ + "bar==1.0.0; sys_platform == 'win32'", + "bar==2.0.0; sys_platform != 'win32'", + ] + "#, + )?; + + context.temp_dir.child("uv.lock").write_str( + r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + resolution-markers = [ + "sys_platform == 'win32'", + "sys_platform != 'win32'", + ] + + [[package]] + name = "bar" + version = "1.0.0" + source = { registry = "https://pypi.org/simple" } + resolution-markers = [ + "sys_platform == 'win32'", + ] + dependencies = [ + { name = "baz", marker = "sys_platform == 'win32'" }, + ] + + [[package]] + name = "bar" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + resolution-markers = [ + "sys_platform != 'win32'", + ] + dependencies = [ + { name = "baz", marker = "sys_platform != 'win32'" }, + ] + + [[package]] + name = "baz" + version = "1.0.0" + source = { registry = "https://pypi.org/simple" } + + [[package]] + name = "foo" + version = "1.0.0" + source = { virtual = "." } + dependencies = [ + { name = "bar", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'win32'" }, + { name = "bar", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'win32'" }, + ] + "#, + )?; + + uv_snapshot!(context.filters(), context.tree().arg("--frozen").arg("--universal").arg("--invert").arg("--package").arg("baz"), @" + success: true + exit_code: 0 + ----- stdout ----- + baz v1.0.0 + ├── bar v1.0.0 + │ └── foo v1.0.0 + └── bar v2.0.0 + └── foo v1.0.0 + + ----- stderr ----- + + "); + + Ok(()) +} + /// Declared conflicts are world knowledge for the encoded extra and group markers. #[test] fn invert_preserves_conflict_marker_attribution() -> Result<()> {