From e2eea6d7db7fefe6f486a5c8a9bd9e803efb15b8 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Sun, 26 Oct 2025 21:01:00 -0500 Subject: [PATCH] Fix root of `uv tree` when `--package` is used with circular dependencies (#15908) Closes #15907 Best viewed with https://github.com/astral-sh/uv/pull/15908/files?diff=unified&w=1 When `--package` is used, just use those as the roots rather than calculating them. I'm not sure if there will be undesirable side-effects, but it's the naive solution. --- crates/uv-resolver/src/lock/tree.rs | 78 ++++++++++++++--------- crates/uv/tests/it/tree.rs | 96 +++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 30 deletions(-) diff --git a/crates/uv-resolver/src/lock/tree.rs b/crates/uv-resolver/src/lock/tree.rs index 86847f8f1..d850c2f4c 100644 --- a/crates/uv-resolver/src/lock/tree.rs +++ b/crates/uv-resolver/src/lock/tree.rs @@ -362,40 +362,58 @@ impl<'env> TreeDisplay<'env> { // Compute the list of roots. let roots = { - let mut edges = vec![]; + // If specific packages were requested, use them as roots. + if !packages.is_empty() { + let mut roots = graph + .node_indices() + .filter(|index| { + let Node::Package(package_id) = graph[*index] else { + return false; + }; + packages.contains(&package_id.name) + }) + .collect::>(); - // Remove any cycles. - let feedback_set: Vec = petgraph::algo::greedy_feedback_arc_set(&graph) - .map(|e| e.id()) - .collect(); - for edge_id in feedback_set { - if let Some((source, target)) = graph.edge_endpoints(edge_id) { - if let Some(weight) = graph.remove_edge(edge_id) { - edges.push((source, target, weight)); + // Sort the roots. + roots.sort_by_key(|index| &graph[*index]); + + roots + } else { + let mut edges = vec![]; + + // Remove any cycles. + let feedback_set: Vec = petgraph::algo::greedy_feedback_arc_set(&graph) + .map(|e| e.id()) + .collect(); + for edge_id in feedback_set { + if let Some((source, target)) = graph.edge_endpoints(edge_id) { + if let Some(weight) = graph.remove_edge(edge_id) { + edges.push((source, target, weight)); + } } } + + // Find the root nodes: nodes with no incoming edges, or only an edge from the proxy. + let mut roots = graph + .node_indices() + .filter(|index| { + graph + .edges_directed(*index, Direction::Incoming) + .next() + .is_none() + }) + .collect::>(); + + // Sort the roots. + roots.sort_by_key(|index| &graph[*index]); + + // Re-add the removed edges. + for (source, target, weight) in edges { + graph.add_edge(source, target, weight); + } + + roots } - - // Find the root nodes: nodes with no incoming edges, or only an edge from the proxy. - let mut roots = graph - .node_indices() - .filter(|index| { - graph - .edges_directed(*index, Direction::Incoming) - .next() - .is_none() - }) - .collect::>(); - - // Sort the roots. - roots.sort_by_key(|index| &graph[*index]); - - // Re-add the removed edges. - for (source, target, weight) in edges { - graph.add_edge(source, target, weight); - } - - roots }; Self { diff --git a/crates/uv/tests/it/tree.rs b/crates/uv/tests/it/tree.rs index 7a1d92e09..b58313c6a 100644 --- a/crates/uv/tests/it/tree.rs +++ b/crates/uv/tests/it/tree.rs @@ -1703,3 +1703,99 @@ fn show_sizes() -> Result<()> { Ok(()) } + +#[test] +fn workspace_circular_dependencies() -> Result<()> { + let context = TestContext::new("3.12"); + + // Create workspace root + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [tool.uv.workspace] + members = ["packages/*"] + "#, + )?; + + // Create package-a that depends on package-b + let package_a_dir = context.temp_dir.child("packages").child("package-a"); + package_a_dir.create_dir_all()?; + let package_a_pyproject = package_a_dir.child("pyproject.toml"); + package_a_pyproject.write_str( + r#" + [project] + name = "package-a" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["package-b"] + + [tool.uv.sources] + package-b = { workspace = true } + "#, + )?; + + // Create package-b that depends on package-a (circular dependency) + let package_b_dir = context.temp_dir.child("packages").child("package-b"); + package_b_dir.create_dir_all()?; + let package_b_pyproject = package_b_dir.child("pyproject.toml"); + package_b_pyproject.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 } + "#, + )?; + + // Test that package-a is at the root when requested + uv_snapshot!(context.filters(), context.tree().arg("--package").arg("package-a"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + package-a v0.1.0 + └── package-b v0.1.0 + └── package-a v0.1.0 (*) + (*) Package tree already displayed + + ----- stderr ----- + Resolved 2 packages in [TIME] + "### + ); + + // Test that package-b is at the root when requested + uv_snapshot!(context.filters(), context.tree().arg("--package").arg("package-b"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + package-b v0.1.0 + └── package-a v0.1.0 + └── package-b v0.1.0 (*) + (*) Package tree already displayed + + ----- stderr ----- + Resolved 2 packages in [TIME] + "### + ); + + // Test that both packages are shown as roots when both are requested + uv_snapshot!(context.filters(), context.tree().arg("--package").arg("package-a").arg("--package").arg("package-b"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + package-a v0.1.0 + └── package-b v0.1.0 + └── package-a v0.1.0 (*) + package-b v0.1.0 (*) + (*) Package tree already displayed + + ----- stderr ----- + Resolved 2 packages in [TIME] + "### + ); + + Ok(()) +}