mirror of https://github.com/astral-sh/uv
Support cyclic dependencies in `uv tree` (#8564)
## Summary Closes https://github.com/astral-sh/uv/issues/8551.
This commit is contained in:
parent
5ef0e6c838
commit
f23d9c1a21
|
|
@ -1,9 +1,10 @@
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::collections::{BTreeSet, VecDeque};
|
use std::collections::{BTreeSet, VecDeque};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
|
use petgraph::graph::{EdgeIndex, NodeIndex};
|
||||||
use petgraph::prelude::EdgeRef;
|
use petgraph::prelude::EdgeRef;
|
||||||
use petgraph::visit::Dfs;
|
|
||||||
use petgraph::Direction;
|
use petgraph::Direction;
|
||||||
use rustc_hash::{FxHashMap, FxHashSet};
|
use rustc_hash::{FxHashMap, FxHashSet};
|
||||||
|
|
||||||
|
|
@ -11,7 +12,7 @@ use uv_configuration::DevGroupsManifest;
|
||||||
use uv_normalize::{ExtraName, GroupName, PackageName};
|
use uv_normalize::{ExtraName, GroupName, PackageName};
|
||||||
use uv_pypi_types::ResolverMarkerEnvironment;
|
use uv_pypi_types::ResolverMarkerEnvironment;
|
||||||
|
|
||||||
use crate::lock::{Dependency, PackageId};
|
use crate::lock::{Dependency, PackageId, Source};
|
||||||
use crate::Lock;
|
use crate::Lock;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
@ -24,6 +25,8 @@ pub struct TreeDisplay<'env> {
|
||||||
depth: usize,
|
depth: usize,
|
||||||
/// Whether to de-duplicate the displayed dependencies.
|
/// Whether to de-duplicate the displayed dependencies.
|
||||||
no_dedupe: bool,
|
no_dedupe: bool,
|
||||||
|
/// The packages considered as roots of the dependency tree.
|
||||||
|
roots: Vec<NodeIndex>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'env> TreeDisplay<'env> {
|
impl<'env> TreeDisplay<'env> {
|
||||||
|
|
@ -38,6 +41,38 @@ impl<'env> TreeDisplay<'env> {
|
||||||
no_dedupe: bool,
|
no_dedupe: bool,
|
||||||
invert: bool,
|
invert: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
// Identify the workspace members.
|
||||||
|
//
|
||||||
|
// The members are encoded directly in the lockfile, unless the workspace contains a
|
||||||
|
// single member at the root, in which case, we identify it by its source.
|
||||||
|
let members: FxHashSet<&PackageId> = if lock.members().is_empty() {
|
||||||
|
lock.packages
|
||||||
|
.iter()
|
||||||
|
.filter_map(|package| {
|
||||||
|
let (Source::Editable(path) | Source::Virtual(path)) = &package.id.source
|
||||||
|
else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
if path == Path::new("") {
|
||||||
|
Some(&package.id)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
lock.packages
|
||||||
|
.iter()
|
||||||
|
.filter_map(|package| {
|
||||||
|
if lock.members().contains(&package.id.name) {
|
||||||
|
Some(&package.id)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
// Create a graph.
|
// Create a graph.
|
||||||
let mut graph = petgraph::graph::Graph::<&PackageId, Edge, petgraph::Directed>::new();
|
let mut graph = petgraph::graph::Graph::<&PackageId, Edge, petgraph::Directed>::new();
|
||||||
|
|
||||||
|
|
@ -136,21 +171,14 @@ impl<'env> TreeDisplay<'env> {
|
||||||
|
|
||||||
// Step 1: Filter out packages that aren't reachable on this platform.
|
// Step 1: Filter out packages that aren't reachable on this platform.
|
||||||
if let Some(environment_markers) = markers {
|
if let Some(environment_markers) = markers {
|
||||||
let mut reachable = FxHashSet::default();
|
|
||||||
|
|
||||||
// Perform a DFS from the root nodes to find the reachable nodes, following only the
|
// Perform a DFS from the root nodes to find the reachable nodes, following only the
|
||||||
// production edges.
|
// production edges.
|
||||||
let mut stack = graph
|
let mut reachable = graph
|
||||||
.node_indices()
|
.node_indices()
|
||||||
.filter(|index| {
|
.filter(|index| members.contains(graph[*index]))
|
||||||
graph
|
.collect::<FxHashSet<_>>();
|
||||||
.edges_directed(*index, Direction::Incoming)
|
let mut stack = reachable.iter().copied().collect::<VecDeque<_>>();
|
||||||
.next()
|
|
||||||
.is_none()
|
|
||||||
})
|
|
||||||
.collect::<VecDeque<_>>();
|
|
||||||
while let Some(node) = stack.pop_front() {
|
while let Some(node) = stack.pop_front() {
|
||||||
reachable.insert(node);
|
|
||||||
for edge in graph.edges_directed(node, Direction::Outgoing) {
|
for edge in graph.edges_directed(node, Direction::Outgoing) {
|
||||||
if edge
|
if edge
|
||||||
.weight()
|
.weight()
|
||||||
|
|
@ -158,7 +186,9 @@ impl<'env> TreeDisplay<'env> {
|
||||||
.complexified_marker
|
.complexified_marker
|
||||||
.evaluate(environment_markers, &[])
|
.evaluate(environment_markers, &[])
|
||||||
{
|
{
|
||||||
stack.push_back(edge.target());
|
if reachable.insert(edge.target()) {
|
||||||
|
stack.push_back(edge.target());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -167,24 +197,16 @@ impl<'env> TreeDisplay<'env> {
|
||||||
graph.retain_nodes(|_, index| reachable.contains(&index));
|
graph.retain_nodes(|_, index| reachable.contains(&index));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Filter the graph to those that are reachable in production or development, if
|
// Step 2: Filter the graph to those that are reachable in production or development.
|
||||||
// `--no-dev` or `--only-dev` were specified, respectively.
|
|
||||||
{
|
{
|
||||||
let mut reachable = FxHashSet::default();
|
|
||||||
|
|
||||||
// Perform a DFS from the root nodes to find the reachable nodes, following only the
|
// Perform a DFS from the root nodes to find the reachable nodes, following only the
|
||||||
// production edges.
|
// production edges.
|
||||||
let mut stack = graph
|
let mut reachable = graph
|
||||||
.node_indices()
|
.node_indices()
|
||||||
.filter(|index| {
|
.filter(|index| members.contains(graph[*index]))
|
||||||
graph
|
.collect::<FxHashSet<_>>();
|
||||||
.edges_directed(*index, Direction::Incoming)
|
let mut stack = reachable.iter().copied().collect::<VecDeque<_>>();
|
||||||
.next()
|
|
||||||
.is_none()
|
|
||||||
})
|
|
||||||
.collect::<VecDeque<_>>();
|
|
||||||
while let Some(node) = stack.pop_front() {
|
while let Some(node) = stack.pop_front() {
|
||||||
reachable.insert(node);
|
|
||||||
for edge in graph.edges_directed(node, Direction::Outgoing) {
|
for edge in graph.edges_directed(node, Direction::Outgoing) {
|
||||||
let include = match edge.weight() {
|
let include = match edge.weight() {
|
||||||
Edge::Prod(_) => dev.prod(),
|
Edge::Prod(_) => dev.prod(),
|
||||||
|
|
@ -192,7 +214,9 @@ impl<'env> TreeDisplay<'env> {
|
||||||
Edge::Dev(group, _) => dev.iter().contains(*group),
|
Edge::Dev(group, _) => dev.iter().contains(*group),
|
||||||
};
|
};
|
||||||
if include {
|
if include {
|
||||||
stack.push_back(edge.target());
|
if reachable.insert(edge.target()) {
|
||||||
|
stack.push_back(edge.target());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -208,24 +232,62 @@ impl<'env> TreeDisplay<'env> {
|
||||||
|
|
||||||
// Step 4: Filter the graph to those nodes reachable from the target packages.
|
// Step 4: Filter the graph to those nodes reachable from the target packages.
|
||||||
if !packages.is_empty() {
|
if !packages.is_empty() {
|
||||||
let mut reachable = FxHashSet::default();
|
|
||||||
|
|
||||||
// Perform a DFS from the root nodes to find the reachable nodes.
|
// Perform a DFS from the root nodes to find the reachable nodes.
|
||||||
let mut dfs = Dfs {
|
let mut reachable = graph
|
||||||
stack: graph
|
.node_indices()
|
||||||
.node_indices()
|
.filter(|index| packages.contains(&graph[*index].name))
|
||||||
.filter(|index| packages.contains(&graph[*index].name))
|
.collect::<FxHashSet<_>>();
|
||||||
.collect::<Vec<_>>(),
|
let mut stack = reachable.iter().copied().collect::<VecDeque<_>>();
|
||||||
..Dfs::empty(&graph)
|
while let Some(node) = stack.pop_front() {
|
||||||
};
|
for edge in graph.edges_directed(node, Direction::Outgoing) {
|
||||||
while let Some(node) = dfs.next(&graph) {
|
if reachable.insert(edge.target()) {
|
||||||
reachable.insert(node);
|
stack.push_back(edge.target());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the unreachable nodes from the graph.
|
// Remove the unreachable nodes from the graph.
|
||||||
graph.retain_nodes(|_, index| reachable.contains(&index));
|
graph.retain_nodes(|_, index| reachable.contains(&index));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compute the list of roots.
|
||||||
|
let roots = {
|
||||||
|
let mut edges = vec![];
|
||||||
|
|
||||||
|
// Remove any cycles.
|
||||||
|
let feedback_set: Vec<EdgeIndex> = 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.
|
||||||
|
let mut roots = graph
|
||||||
|
.node_indices()
|
||||||
|
.filter(|index| {
|
||||||
|
graph
|
||||||
|
.edges_directed(*index, Direction::Incoming)
|
||||||
|
.next()
|
||||||
|
.is_none()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
// 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
|
||||||
|
};
|
||||||
|
|
||||||
// Re-create the inverse map.
|
// Re-create the inverse map.
|
||||||
{
|
{
|
||||||
inverse.clear();
|
inverse.clear();
|
||||||
|
|
@ -239,6 +301,7 @@ impl<'env> TreeDisplay<'env> {
|
||||||
inverse,
|
inverse,
|
||||||
depth,
|
depth,
|
||||||
no_dedupe,
|
no_dedupe,
|
||||||
|
roots,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -355,24 +418,9 @@ impl<'env> TreeDisplay<'env> {
|
||||||
let mut path = Vec::new();
|
let mut path = Vec::new();
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
let roots = {
|
for node in &self.roots {
|
||||||
let mut roots = self
|
|
||||||
.graph
|
|
||||||
.node_indices()
|
|
||||||
.filter(|index| {
|
|
||||||
self.graph
|
|
||||||
.edges_directed(*index, petgraph::Direction::Incoming)
|
|
||||||
.next()
|
|
||||||
.is_none()
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
roots.sort_by_key(|index| &self.graph[*index]);
|
|
||||||
roots
|
|
||||||
};
|
|
||||||
|
|
||||||
for node in roots {
|
|
||||||
path.clear();
|
path.clear();
|
||||||
lines.extend(self.visit(Node::Root(self.graph[node]), &mut visited, &mut path));
|
lines.extend(self.visit(Node::Root(self.graph[*node]), &mut visited, &mut path));
|
||||||
}
|
}
|
||||||
|
|
||||||
lines
|
lines
|
||||||
|
|
|
||||||
|
|
@ -835,3 +835,89 @@ fn group() -> Result<()> {
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cycle() -> 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 = ["testtools==2.3.0", "fixtures==3.0.0"]
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
uv_snapshot!(context.filters(), context.tree().arg("--universal"), @r###"
|
||||||
|
success: true
|
||||||
|
exit_code: 0
|
||||||
|
----- stdout -----
|
||||||
|
project v0.1.0
|
||||||
|
├── fixtures v3.0.0
|
||||||
|
│ ├── pbr v6.0.0
|
||||||
|
│ ├── six v1.16.0
|
||||||
|
│ └── testtools v2.3.0
|
||||||
|
│ ├── extras v1.0.0
|
||||||
|
│ ├── fixtures v3.0.0 (*)
|
||||||
|
│ ├── pbr v6.0.0
|
||||||
|
│ ├── python-mimeparse v1.6.0
|
||||||
|
│ ├── six v1.16.0
|
||||||
|
│ ├── traceback2 v1.4.0
|
||||||
|
│ │ └── linecache2 v1.0.0
|
||||||
|
│ └── unittest2 v1.1.0
|
||||||
|
│ ├── argparse v1.4.0
|
||||||
|
│ ├── six v1.16.0
|
||||||
|
│ └── traceback2 v1.4.0 (*)
|
||||||
|
└── testtools v2.3.0 (*)
|
||||||
|
(*) Package tree already displayed
|
||||||
|
|
||||||
|
----- stderr -----
|
||||||
|
Resolved 11 packages in [TIME]
|
||||||
|
"###
|
||||||
|
);
|
||||||
|
|
||||||
|
uv_snapshot!(context.filters(), context.tree().arg("--package").arg("traceback2").arg("--package").arg("six"), @r###"
|
||||||
|
success: true
|
||||||
|
exit_code: 0
|
||||||
|
----- stdout -----
|
||||||
|
six v1.16.0
|
||||||
|
traceback2 v1.4.0
|
||||||
|
└── linecache2 v1.0.0
|
||||||
|
|
||||||
|
----- stderr -----
|
||||||
|
Resolved 11 packages in [TIME]
|
||||||
|
"###
|
||||||
|
);
|
||||||
|
|
||||||
|
uv_snapshot!(context.filters(), context.tree().arg("--package").arg("traceback2").arg("--package").arg("six").arg("--invert"), @r###"
|
||||||
|
success: true
|
||||||
|
exit_code: 0
|
||||||
|
----- stdout -----
|
||||||
|
six v1.16.0
|
||||||
|
├── fixtures v3.0.0
|
||||||
|
│ ├── project v0.1.0
|
||||||
|
│ └── testtools v2.3.0
|
||||||
|
│ ├── fixtures v3.0.0 (*)
|
||||||
|
│ └── project v0.1.0
|
||||||
|
├── testtools v2.3.0 (*)
|
||||||
|
└── unittest2 v1.1.0
|
||||||
|
└── testtools v2.3.0 (*)
|
||||||
|
traceback2 v1.4.0
|
||||||
|
├── testtools v2.3.0 (*)
|
||||||
|
└── unittest2 v1.1.0 (*)
|
||||||
|
(*) Package tree already displayed
|
||||||
|
|
||||||
|
----- stderr -----
|
||||||
|
Resolved 11 packages in [TIME]
|
||||||
|
"###
|
||||||
|
);
|
||||||
|
|
||||||
|
// `uv tree` should update the lockfile
|
||||||
|
let lock = context.read("uv.lock");
|
||||||
|
assert!(!lock.is_empty());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue