uv_resolver/lock/
tree.rs

1use std::collections::{BTreeSet, VecDeque};
2use std::fmt::Write;
3
4use either::Either;
5use itertools::Itertools;
6use owo_colors::OwoColorize;
7use petgraph::graph::{EdgeIndex, NodeIndex};
8use petgraph::prelude::EdgeRef;
9use petgraph::{Direction, Graph};
10use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
11
12use uv_configuration::DependencyGroupsWithDefaults;
13use uv_console::human_readable_bytes;
14use uv_normalize::{ExtraName, GroupName, PackageName};
15use uv_pep440::Version;
16use uv_pep508::MarkerTree;
17use uv_pypi_types::ResolverMarkerEnvironment;
18
19use crate::lock::PackageId;
20use crate::{Lock, PackageMap};
21
22#[derive(Debug)]
23pub struct TreeDisplay<'env> {
24    /// The constructed dependency graph.
25    graph: petgraph::graph::Graph<Node<'env>, Edge<'env>, petgraph::Directed>,
26    /// The packages considered as roots of the dependency tree.
27    roots: Vec<NodeIndex>,
28    /// The latest known version of each package.
29    latest: &'env PackageMap<Version>,
30    /// Maximum display depth of the dependency tree.
31    depth: usize,
32    /// Whether to de-duplicate the displayed dependencies.
33    no_dedupe: bool,
34    /// Reference to the lock to look up additional metadata (e.g., wheel sizes).
35    lock: &'env Lock,
36    /// Whether to show sizes in the rendered output.
37    show_sizes: bool,
38}
39
40impl<'env> TreeDisplay<'env> {
41    /// Create a new [`DisplayDependencyGraph`] for the set of installed packages.
42    pub fn new(
43        lock: &'env Lock,
44        markers: Option<&'env ResolverMarkerEnvironment>,
45        latest: &'env PackageMap<Version>,
46        depth: usize,
47        prune: &[PackageName],
48        packages: &[PackageName],
49        groups: &DependencyGroupsWithDefaults,
50        no_dedupe: bool,
51        invert: bool,
52        show_sizes: bool,
53    ) -> Self {
54        // Identify any workspace members.
55        //
56        // These include:
57        // - The members listed in the lockfile.
58        // - The root package, if it's not in the list of members. (The root package is omitted from
59        //   the list of workspace members for single-member workspaces with a `[project]` section,
60        //   to avoid cluttering the lockfile.
61        let members: BTreeSet<&PackageId> = if lock.members().is_empty() {
62            lock.root().into_iter().map(|package| &package.id).collect()
63        } else {
64            lock.packages
65                .iter()
66                .filter_map(|package| {
67                    if lock.members().contains(&package.id.name) {
68                        Some(&package.id)
69                    } else {
70                        None
71                    }
72                })
73                .collect()
74        };
75
76        // Create a graph.
77        let size_guess = lock.packages.len();
78        let mut graph =
79            Graph::<Node, Edge, petgraph::Directed>::with_capacity(size_guess, size_guess);
80        let mut inverse = FxHashMap::with_capacity_and_hasher(size_guess, FxBuildHasher);
81        let mut queue: VecDeque<(&PackageId, Option<&ExtraName>)> = VecDeque::new();
82        let mut seen = FxHashSet::default();
83
84        let root = graph.add_node(Node::Root);
85
86        // Add the root packages to the graph.
87        for id in members.iter().copied() {
88            if prune.contains(&id.name) {
89                continue;
90            }
91
92            let dist = lock.find_by_id(id);
93
94            // Add the workspace package to the graph. Under `--only-group`, the workspace member
95            // may not be installed, but it's still relevant for the dependency tree, since we want
96            // to show the connection from the workspace package to the enabled dependency groups.
97            let index = *inverse
98                .entry(id)
99                .or_insert_with(|| graph.add_node(Node::Package(id)));
100
101            // Add an edge from the root.
102            graph.add_edge(root, index, Edge::Prod(None));
103
104            if groups.prod() {
105                // Push its dependencies on the queue.
106                if seen.insert((id, None)) {
107                    queue.push_back((id, None));
108                }
109
110                // Push any extras on the queue.
111                for extra in dist.optional_dependencies.keys() {
112                    if seen.insert((id, Some(extra))) {
113                        queue.push_back((id, Some(extra)));
114                    }
115                }
116            }
117
118            // Add any development dependencies.
119            for (group, dep) in dist
120                .dependency_groups
121                .iter()
122                .filter_map(|(group, deps)| {
123                    if groups.contains(group) {
124                        Some(deps.iter().map(move |dep| (group, dep)))
125                    } else {
126                        None
127                    }
128                })
129                .flatten()
130            {
131                if prune.contains(&dep.package_id.name) {
132                    continue;
133                }
134
135                if markers
136                    .is_some_and(|markers| !dep.complexified_marker.evaluate_no_extras(markers))
137                {
138                    continue;
139                }
140
141                // Add the dependency to the graph and get its index.
142                let dep_index = *inverse
143                    .entry(&dep.package_id)
144                    .or_insert_with(|| graph.add_node(Node::Package(&dep.package_id)));
145
146                // Add an edge from the workspace package.
147                graph.add_edge(index, dep_index, Edge::Dev(group, Some(&dep.extra)));
148
149                // Push its dependencies on the queue.
150                if seen.insert((&dep.package_id, None)) {
151                    queue.push_back((&dep.package_id, None));
152                }
153                for extra in &dep.extra {
154                    if seen.insert((&dep.package_id, Some(extra))) {
155                        queue.push_back((&dep.package_id, Some(extra)));
156                    }
157                }
158            }
159        }
160
161        // Identify any packages that are connected directly to the synthetic root node, i.e.,
162        // requirements that are attached to the workspace itself.
163        //
164        // These include
165        // - `[dependency-groups]` dependencies for workspaces whose roots do not include a
166        //    `[project]` table, since those roots are not workspace members, but they _can_ define
167        //    dependencies.
168        // - `dependencies` in PEP 723 scripts.
169        {
170            // Index the lockfile by name.
171            let by_name: FxHashMap<_, Vec<_>> = {
172                lock.packages().iter().fold(
173                    FxHashMap::with_capacity_and_hasher(lock.len(), FxBuildHasher),
174                    |mut map, package| {
175                        map.entry(&package.id.name).or_default().push(package);
176                        map
177                    },
178                )
179            };
180
181            // Identify any requirements attached to the workspace itself.
182            for requirement in lock.requirements() {
183                for package in by_name.get(&requirement.name).into_iter().flatten() {
184                    // Determine whether this entry is "relevant" for the requirement, by intersecting
185                    // the markers.
186                    let marker = if package.fork_markers.is_empty() {
187                        requirement.marker
188                    } else {
189                        let mut combined = MarkerTree::FALSE;
190                        for fork_marker in &package.fork_markers {
191                            combined.or(fork_marker.pep508());
192                        }
193                        combined.and(requirement.marker);
194                        combined
195                    };
196                    if marker.is_false() {
197                        continue;
198                    }
199                    if markers.is_some_and(|markers| !marker.evaluate(markers, &[])) {
200                        continue;
201                    }
202                    // Add the package to the graph.
203                    let index = inverse
204                        .entry(&package.id)
205                        .or_insert_with(|| graph.add_node(Node::Package(&package.id)));
206
207                    // Add an edge from the root.
208                    graph.add_edge(root, *index, Edge::Prod(None));
209
210                    // Push its dependencies on the queue.
211                    if seen.insert((&package.id, None)) {
212                        queue.push_back((&package.id, None));
213                    }
214                }
215            }
216
217            // Identify any dependency groups attached to the workspace itself.
218            for (group, requirements) in lock.dependency_groups() {
219                for requirement in requirements {
220                    for package in by_name.get(&requirement.name).into_iter().flatten() {
221                        // Determine whether this entry is "relevant" for the requirement, by intersecting
222                        // the markers.
223                        let marker = if package.fork_markers.is_empty() {
224                            requirement.marker
225                        } else {
226                            let mut combined = MarkerTree::FALSE;
227                            for fork_marker in &package.fork_markers {
228                                combined.or(fork_marker.pep508());
229                            }
230                            combined.and(requirement.marker);
231                            combined
232                        };
233                        if marker.is_false() {
234                            continue;
235                        }
236                        if markers.is_some_and(|markers| !marker.evaluate(markers, &[])) {
237                            continue;
238                        }
239                        // Add the package to the graph.
240                        let index = inverse
241                            .entry(&package.id)
242                            .or_insert_with(|| graph.add_node(Node::Package(&package.id)));
243
244                        // Add an edge from the root.
245                        graph.add_edge(root, *index, Edge::Dev(group, None));
246
247                        // Push its dependencies on the queue.
248                        if seen.insert((&package.id, None)) {
249                            queue.push_back((&package.id, None));
250                        }
251                    }
252                }
253            }
254        }
255
256        // Create all the relevant nodes.
257        while let Some((id, extra)) = queue.pop_front() {
258            let index = inverse[&id];
259            let package = lock.find_by_id(id);
260
261            let deps = if let Some(extra) = extra {
262                Either::Left(
263                    package
264                        .optional_dependencies
265                        .get(extra)
266                        .into_iter()
267                        .flatten(),
268                )
269            } else {
270                Either::Right(package.dependencies.iter())
271            };
272
273            for dep in deps {
274                if prune.contains(&dep.package_id.name) {
275                    continue;
276                }
277
278                if markers
279                    .is_some_and(|markers| !dep.complexified_marker.evaluate_no_extras(markers))
280                {
281                    continue;
282                }
283
284                // Add the dependency to the graph.
285                let dep_index = *inverse
286                    .entry(&dep.package_id)
287                    .or_insert_with(|| graph.add_node(Node::Package(&dep.package_id)));
288
289                // Add an edge from the workspace package.
290                graph.add_edge(
291                    index,
292                    dep_index,
293                    if let Some(extra) = extra {
294                        Edge::Optional(extra, Some(&dep.extra))
295                    } else {
296                        Edge::Prod(Some(&dep.extra))
297                    },
298                );
299
300                // Push its dependencies on the queue.
301                if seen.insert((&dep.package_id, None)) {
302                    queue.push_back((&dep.package_id, None));
303                }
304                for extra in &dep.extra {
305                    if seen.insert((&dep.package_id, Some(extra))) {
306                        queue.push_back((&dep.package_id, Some(extra)));
307                    }
308                }
309            }
310        }
311
312        // Filter the graph to remove any unreachable nodes.
313        {
314            let mut reachable = graph
315                .node_indices()
316                .filter(|index| match graph[*index] {
317                    Node::Package(package_id) => members.contains(package_id),
318                    Node::Root => true,
319                })
320                .collect::<FxHashSet<_>>();
321            let mut stack = reachable.iter().copied().collect::<VecDeque<_>>();
322            while let Some(node) = stack.pop_front() {
323                for edge in graph.edges_directed(node, Direction::Outgoing) {
324                    if reachable.insert(edge.target()) {
325                        stack.push_back(edge.target());
326                    }
327                }
328            }
329
330            // Remove the unreachable nodes from the graph.
331            graph.retain_nodes(|_, index| reachable.contains(&index));
332        }
333
334        // Reverse the graph.
335        if invert {
336            graph.reverse();
337        }
338
339        // Filter the graph to those nodes reachable from the target packages.
340        if !packages.is_empty() {
341            let mut reachable = graph
342                .node_indices()
343                .filter(|index| {
344                    let Node::Package(package_id) = graph[*index] else {
345                        return false;
346                    };
347                    packages.contains(&package_id.name)
348                })
349                .collect::<FxHashSet<_>>();
350            let mut stack = reachable.iter().copied().collect::<VecDeque<_>>();
351            while let Some(node) = stack.pop_front() {
352                for edge in graph.edges_directed(node, Direction::Outgoing) {
353                    if reachable.insert(edge.target()) {
354                        stack.push_back(edge.target());
355                    }
356                }
357            }
358
359            // Remove the unreachable nodes from the graph.
360            graph.retain_nodes(|_, index| reachable.contains(&index));
361        }
362
363        // Compute the list of roots.
364        let roots = {
365            // If specific packages were requested, use them as roots.
366            if !packages.is_empty() {
367                let mut roots = graph
368                    .node_indices()
369                    .filter(|index| {
370                        let Node::Package(package_id) = graph[*index] else {
371                            return false;
372                        };
373                        packages.contains(&package_id.name)
374                    })
375                    .collect::<Vec<_>>();
376
377                // Sort the roots.
378                roots.sort_by_key(|index| &graph[*index]);
379
380                roots
381            } else {
382                let mut edges = vec![];
383
384                // Remove any cycles.
385                let feedback_set: Vec<EdgeIndex> = petgraph::algo::greedy_feedback_arc_set(&graph)
386                    .map(|e| e.id())
387                    .collect();
388                for edge_id in feedback_set {
389                    if let Some((source, target)) = graph.edge_endpoints(edge_id) {
390                        if let Some(weight) = graph.remove_edge(edge_id) {
391                            edges.push((source, target, weight));
392                        }
393                    }
394                }
395
396                // Find the root nodes: nodes with no incoming edges, or only an edge from the proxy.
397                let mut roots = graph
398                    .node_indices()
399                    .filter(|index| {
400                        graph
401                            .edges_directed(*index, Direction::Incoming)
402                            .next()
403                            .is_none()
404                    })
405                    .collect::<Vec<_>>();
406
407                // Sort the roots.
408                roots.sort_by_key(|index| &graph[*index]);
409
410                // Re-add the removed edges.
411                for (source, target, weight) in edges {
412                    graph.add_edge(source, target, weight);
413                }
414
415                roots
416            }
417        };
418
419        Self {
420            graph,
421            roots,
422            latest,
423            depth,
424            no_dedupe,
425            lock,
426            show_sizes,
427        }
428    }
429
430    /// Perform a depth-first traversal of the given package and its dependencies.
431    fn visit(
432        &'env self,
433        cursor: Cursor,
434        visited: &mut FxHashMap<&'env PackageId, Vec<&'env PackageId>>,
435        path: &mut Vec<&'env PackageId>,
436    ) -> Vec<String> {
437        // Short-circuit if the current path is longer than the provided depth.
438        if path.len() > self.depth {
439            return Vec::new();
440        }
441
442        let Node::Package(package_id) = self.graph[cursor.node()] else {
443            return Vec::new();
444        };
445        let edge = cursor.edge().map(|edge_id| &self.graph[edge_id]);
446
447        let line = {
448            let mut line = format!("{}", package_id.name);
449
450            if let Some(extras) = edge.and_then(Edge::extras) {
451                if !extras.is_empty() {
452                    line.push('[');
453                    line.push_str(extras.iter().join(", ").as_str());
454                    line.push(']');
455                }
456            }
457
458            if let Some(version) = package_id.version.as_ref() {
459                line.push(' ');
460                line.push('v');
461                let _ = write!(line, "{version}");
462            }
463
464            if let Some(edge) = edge {
465                match edge {
466                    Edge::Prod(_) => {}
467                    Edge::Optional(extra, _) => {
468                        let _ = write!(line, " (extra: {extra})");
469                    }
470                    Edge::Dev(group, _) => {
471                        let _ = write!(line, " (group: {group})");
472                    }
473                }
474            }
475
476            // Append compressed wheel size, if available in the lockfile.
477            // Keep it simple: use the first wheel entry that includes a size.
478            if self.show_sizes {
479                let package = self.lock.find_by_id(package_id);
480                if let Some(size_bytes) = package.wheels.iter().find_map(|wheel| wheel.size) {
481                    let (bytes, unit) = human_readable_bytes(size_bytes);
482                    line.push(' ');
483                    line.push_str(format!("{}", format!("({bytes:.1}{unit})").dimmed()).as_str());
484                }
485            }
486
487            line
488        };
489
490        // Skip the traversal if:
491        // 1. The package is in the current traversal path (i.e., a dependency cycle).
492        // 2. The package has been visited and de-duplication is enabled (default).
493        if let Some(requirements) = visited.get(package_id) {
494            if !self.no_dedupe || path.contains(&package_id) {
495                return if requirements.is_empty() {
496                    vec![line]
497                } else {
498                    vec![format!("{line} (*)")]
499                };
500            }
501        }
502
503        // Incorporate the latest version of the package, if known.
504        let line = if let Some(version) = self.latest.get(package_id) {
505            format!("{line} {}", format!("(latest: v{version})").bold().cyan())
506        } else {
507            line
508        };
509
510        let mut dependencies = self
511            .graph
512            .edges_directed(cursor.node(), Direction::Outgoing)
513            .filter_map(|edge| match self.graph[edge.target()] {
514                Node::Root => None,
515                Node::Package(_) => Some(Cursor::new(edge.target(), edge.id())),
516            })
517            .collect::<Vec<_>>();
518        dependencies.sort_by_key(|cursor| {
519            let node = &self.graph[cursor.node()];
520            let edge = cursor
521                .edge()
522                .map(|edge_id| &self.graph[edge_id])
523                .map(Edge::kind);
524            (edge, node)
525        });
526
527        let mut lines = vec![line];
528
529        // Keep track of the dependency path to avoid cycles.
530        visited.insert(
531            package_id,
532            dependencies
533                .iter()
534                .filter_map(|node| match self.graph[node.node()] {
535                    Node::Package(package_id) => Some(package_id),
536                    Node::Root => None,
537                })
538                .collect(),
539        );
540        path.push(package_id);
541
542        for (index, dep) in dependencies.iter().enumerate() {
543            // For sub-visited packages, add the prefix to make the tree display user-friendly.
544            // The key observation here is you can group the tree as follows when you're at the
545            // root of the tree:
546            // root_package
547            // ├── level_1_0          // Group 1
548            // │   ├── level_2_0      ...
549            // │   │   ├── level_3_0  ...
550            // │   │   └── level_3_1  ...
551            // │   └── level_2_1      ...
552            // ├── level_1_1          // Group 2
553            // │   ├── level_2_2      ...
554            // │   └── level_2_3      ...
555            // └── level_1_2          // Group 3
556            //     └── level_2_4      ...
557            //
558            // The lines in Group 1 and 2 have `├── ` at the top and `|   ` at the rest while
559            // those in Group 3 have `└── ` at the top and `    ` at the rest.
560            // This observation is true recursively even when looking at the subtree rooted
561            // at `level_1_0`.
562            let (prefix_top, prefix_rest) = if dependencies.len() - 1 == index {
563                ("└── ", "    ")
564            } else {
565                ("├── ", "│   ")
566            };
567            for (visited_index, visited_line) in self.visit(*dep, visited, path).iter().enumerate()
568            {
569                let prefix = if visited_index == 0 {
570                    prefix_top
571                } else {
572                    prefix_rest
573                };
574                lines.push(format!("{prefix}{visited_line}"));
575            }
576        }
577
578        path.pop();
579
580        lines
581    }
582
583    /// Depth-first traverse the nodes to render the tree.
584    fn render(&self) -> Vec<String> {
585        let mut path = Vec::new();
586        let mut lines = Vec::with_capacity(self.graph.node_count());
587        let mut visited =
588            FxHashMap::with_capacity_and_hasher(self.graph.node_count(), FxBuildHasher);
589
590        for node in &self.roots {
591            match self.graph[*node] {
592                Node::Root => {
593                    for edge in self.graph.edges_directed(*node, Direction::Outgoing) {
594                        let node = edge.target();
595                        path.clear();
596                        lines.extend(self.visit(
597                            Cursor::new(node, edge.id()),
598                            &mut visited,
599                            &mut path,
600                        ));
601                    }
602                }
603                Node::Package(_) => {
604                    path.clear();
605                    lines.extend(self.visit(Cursor::root(*node), &mut visited, &mut path));
606                }
607            }
608        }
609
610        lines
611    }
612}
613
614#[derive(Debug, Clone, PartialEq, Eq, Ord, PartialOrd)]
615enum Node<'env> {
616    /// The synthetic root node.
617    Root,
618    /// A package in the dependency graph.
619    Package(&'env PackageId),
620}
621
622#[derive(Debug, Clone, PartialEq, Eq, Ord, PartialOrd)]
623enum Edge<'env> {
624    Prod(Option<&'env BTreeSet<ExtraName>>),
625    Optional(&'env ExtraName, Option<&'env BTreeSet<ExtraName>>),
626    Dev(&'env GroupName, Option<&'env BTreeSet<ExtraName>>),
627}
628
629impl<'env> Edge<'env> {
630    fn extras(&self) -> Option<&'env BTreeSet<ExtraName>> {
631        match self {
632            Self::Prod(extras) => *extras,
633            Self::Optional(_, extras) => *extras,
634            Self::Dev(_, extras) => *extras,
635        }
636    }
637
638    fn kind(&self) -> EdgeKind<'env> {
639        match self {
640            Self::Prod(_) => EdgeKind::Prod,
641            Self::Optional(extra, _) => EdgeKind::Optional(extra),
642            Self::Dev(group, _) => EdgeKind::Dev(group),
643        }
644    }
645}
646
647#[derive(Debug, Clone, PartialEq, Eq, Ord, PartialOrd)]
648enum EdgeKind<'env> {
649    Prod,
650    Optional(&'env ExtraName),
651    Dev(&'env GroupName),
652}
653
654/// A node in the dependency graph along with the edge that led to it, or `None` for root nodes.
655#[derive(Debug, Copy, Clone, PartialEq, Eq, Ord, PartialOrd)]
656struct Cursor(NodeIndex, Option<EdgeIndex>);
657
658impl Cursor {
659    /// Create a [`Cursor`] representing a node in the dependency tree.
660    fn new(node: NodeIndex, edge: EdgeIndex) -> Self {
661        Self(node, Some(edge))
662    }
663
664    /// Create a [`Cursor`] representing a root node in the dependency tree.
665    fn root(node: NodeIndex) -> Self {
666        Self(node, None)
667    }
668
669    /// Return the [`NodeIndex`] of the node.
670    fn node(&self) -> NodeIndex {
671        self.0
672    }
673
674    /// Return the [`EdgeIndex`] of the edge that led to the node, if any.
675    fn edge(&self) -> Option<EdgeIndex> {
676        self.1
677    }
678}
679
680impl std::fmt::Display for TreeDisplay<'_> {
681    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
682        use owo_colors::OwoColorize;
683
684        let mut deduped = false;
685        for line in self.render() {
686            deduped |= line.contains('*');
687            writeln!(f, "{line}")?;
688        }
689
690        if deduped {
691            let message = if self.no_dedupe {
692                "(*) Package tree is a cycle and cannot be shown".italic()
693            } else {
694                "(*) Package tree already displayed".italic()
695            };
696            writeln!(f, "{message}")?;
697        }
698
699        Ok(())
700    }
701}