Skip to main content

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