Skip to main content

uv_resolver/resolution/
display.rs

1use std::collections::BTreeSet;
2
3use owo_colors::OwoColorize;
4use petgraph::visit::EdgeRef;
5use petgraph::{Directed, Direction, Graph};
6use rustc_hash::{FxBuildHasher, FxHashMap};
7
8use uv_configuration::AnnotationStyle;
9use uv_distribution_types::{DistributionMetadata, Name, SourceAnnotation, SourceAnnotations};
10use uv_normalize::PackageName;
11use uv_pep508::MarkerTree;
12
13use crate::resolution::{RequirementsTxtDist, ResolutionGraphNode};
14use crate::{ResolverEnvironment, ResolverOutput};
15
16/// A [`std::fmt::Display`] implementation for the resolution graph.
17#[derive(Debug)]
18pub struct DisplayResolutionGraph<'a> {
19    /// The underlying graph.
20    resolution: &'a ResolverOutput,
21    /// The resolver marker environment, used to determine the markers that apply to each package.
22    env: &'a ResolverEnvironment,
23    /// The packages to exclude from the output.
24    no_emit_packages: &'a [PackageName],
25    /// Whether to include hashes in the output.
26    show_hashes: bool,
27    /// Whether to include extras in the output (e.g., `black[colorama]`).
28    include_extras: bool,
29    /// Whether to include environment markers in the output (e.g., `black ; sys_platform == "win32"`).
30    include_markers: bool,
31    /// Whether to include annotations in the output, to indicate which dependency or dependencies
32    /// requested each package.
33    include_annotations: bool,
34    /// Whether to include indexes in the output, to indicate which index was used for each package.
35    include_index_annotation: bool,
36    /// The style of annotation comments, used to indicate the dependencies that requested each
37    /// package.
38    annotation_style: AnnotationStyle,
39}
40
41#[derive(Debug)]
42enum DisplayResolutionGraphNode<'dist> {
43    Root,
44    Dist(RequirementsTxtDist<'dist>),
45}
46
47impl<'a> DisplayResolutionGraph<'a> {
48    /// Create a new [`DisplayResolutionGraph`] for the given graph.
49    ///
50    /// Note that this panics if any of the forks in the given resolver
51    /// output contain non-empty conflicting groups. That is, when using `uv
52    /// pip compile`, specifying conflicts is not supported because their
53    /// conditional logic cannot be encoded into a `requirements.txt`.
54    #[expect(clippy::fn_params_excessive_bools)]
55    pub fn new(
56        underlying: &'a ResolverOutput,
57        env: &'a ResolverEnvironment,
58        no_emit_packages: &'a [PackageName],
59        show_hashes: bool,
60        include_extras: bool,
61        include_markers: bool,
62        include_annotations: bool,
63        include_index_annotation: bool,
64        annotation_style: AnnotationStyle,
65    ) -> Self {
66        for fork_marker in &underlying.fork_markers {
67            assert!(
68                fork_marker.conflict().is_true(),
69                "found fork marker {fork_marker:?} with non-trivial conflicting marker, \
70                 cannot display resolver output with conflicts in requirements.txt format",
71            );
72        }
73        Self {
74            resolution: underlying,
75            env,
76            no_emit_packages,
77            show_hashes,
78            include_extras,
79            include_markers,
80            include_annotations,
81            include_index_annotation,
82            annotation_style,
83        }
84    }
85}
86
87/// Write the graph in the `{name}=={version}` format of requirements.txt that pip uses.
88impl std::fmt::Display for DisplayResolutionGraph<'_> {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        // Determine the annotation sources for each package.
91        let sources = if self.include_annotations {
92            let mut sources = SourceAnnotations::default();
93
94            for requirement in self.resolution.requirements.iter().filter(|requirement| {
95                requirement.evaluate_markers(self.env.marker_environment(), &[])
96            }) {
97                if let Some(origin) = &requirement.origin {
98                    sources.add(
99                        &requirement.name,
100                        SourceAnnotation::Requirement(origin.clone()),
101                    );
102                }
103            }
104
105            for requirement in self
106                .resolution
107                .constraints
108                .requirements()
109                .filter(|requirement| {
110                    requirement.evaluate_markers(self.env.marker_environment(), &[])
111                })
112            {
113                if let Some(origin) = &requirement.origin {
114                    sources.add(
115                        &requirement.name,
116                        SourceAnnotation::Constraint(origin.clone()),
117                    );
118                }
119            }
120
121            for requirement in
122                self.resolution
123                    .overrides
124                    .global_requirements()
125                    .filter(|requirement| {
126                        requirement.evaluate_markers(self.env.marker_environment(), &[])
127                    })
128            {
129                if let Some(origin) = &requirement.origin {
130                    sources.add(
131                        &requirement.name,
132                        SourceAnnotation::Override(origin.clone()),
133                    );
134                }
135            }
136
137            for edge in self.resolution.graph.edge_references() {
138                let (ResolutionGraphNode::Dist(parent), ResolutionGraphNode::Dist(dependency)) = (
139                    self.resolution.graph.node_weight(edge.source()).unwrap(),
140                    self.resolution.graph.node_weight(edge.target()).unwrap(),
141                ) else {
142                    continue;
143                };
144                for requirement in self
145                    .resolution
146                    .overrides
147                    .scoped_requirements_for(&parent.name, &parent.version)
148                    .filter(|requirement| requirement.name == dependency.name)
149                    .filter(|requirement| {
150                        requirement.evaluate_markers(self.env.marker_environment(), &[])
151                    })
152                {
153                    if let Some(origin) = &requirement.origin {
154                        sources.add(
155                            &requirement.name,
156                            SourceAnnotation::Override(origin.clone()),
157                        );
158                    }
159                }
160            }
161
162            sources
163        } else {
164            SourceAnnotations::default()
165        };
166
167        // Convert a [`petgraph::graph::Graph`] based on [`ResolutionGraphNode`] to a graph based on
168        // [`DisplayResolutionGraphNode`]. In other words: converts from [`AnnotatedDist`] to
169        // [`RequirementsTxtDist`].
170        //
171        // We assign each package its propagated markers: In `requirements.txt`, we want a flat list
172        // that for each package tells us if it should be installed on the current platform, without
173        // looking at which packages depend on it.
174        let graph = self.resolution.graph.map(
175            |_index, node| match node {
176                ResolutionGraphNode::Root => DisplayResolutionGraphNode::Root,
177                ResolutionGraphNode::Dist(dist) => {
178                    let dist = RequirementsTxtDist::from_annotated_dist(dist);
179                    DisplayResolutionGraphNode::Dist(dist)
180                }
181            },
182            // We can drop the edge markers, while retaining their existence and direction for the
183            // annotations.
184            |_index, _edge| (),
185        );
186
187        // Reduce the graph, removing or combining extras for a given package.
188        let graph = if self.include_extras {
189            combine_extras(&graph)
190        } else {
191            strip_extras(&graph)
192        };
193
194        // Collect all packages.
195        let mut nodes = graph
196            .node_indices()
197            .filter_map(|index| {
198                let dist = &graph[index];
199                let name = dist.name();
200                if self.no_emit_packages.contains(name) {
201                    return None;
202                }
203
204                Some((index, dist))
205            })
206            .collect::<Vec<_>>();
207
208        // Sort the nodes by name, but with editable packages first.
209        nodes.sort_unstable_by_key(|(index, node)| (node.to_comparator(), *index));
210
211        // Print out the dependency graph.
212        for (index, node) in nodes {
213            // Display the node itself.
214            let mut line = node
215                .to_requirements_txt(&self.resolution.requires_python, self.include_markers)
216                .to_string();
217
218            // Display the distribution hashes, if any.
219            let mut has_hashes = false;
220            if self.show_hashes {
221                for hash in node.hashes {
222                    has_hashes = true;
223                    line.push_str(" \\\n");
224                    line.push_str("    --hash=");
225                    line.push_str(&hash.to_string());
226                }
227            }
228
229            // Determine the annotation comment and separator (between comment and requirement).
230            let mut annotation = None;
231
232            // If enabled, include annotations to indicate the dependencies that requested each
233            // package (e.g., `# via mypy`).
234            if self.include_annotations {
235                // Display all dependents (i.e., all packages that depend on the current package).
236                let dependents = {
237                    let mut dependents = graph
238                        .edges_directed(index, Direction::Incoming)
239                        .map(|edge| &graph[edge.source()])
240                        .map(uv_distribution_types::Name::name)
241                        .collect::<Vec<_>>();
242                    dependents.sort_unstable();
243                    dependents.dedup();
244                    dependents
245                };
246
247                // Include all external sources (e.g., requirements files).
248                let default = BTreeSet::default();
249                let source = sources.get(node.name()).unwrap_or(&default);
250
251                match self.annotation_style {
252                    AnnotationStyle::Line => match dependents.as_slice() {
253                        [] if source.is_empty() => {}
254                        [] if source.len() == 1 => {
255                            let separator = if has_hashes { "\n    " } else { "  " };
256                            let comment = format!("# via {}", source.iter().next().unwrap())
257                                .green()
258                                .to_string();
259                            annotation = Some((separator, comment));
260                        }
261                        dependents => {
262                            let separator = if has_hashes { "\n    " } else { "  " };
263                            let dependents = dependents
264                                .iter()
265                                .map(ToString::to_string)
266                                .chain(source.iter().map(ToString::to_string))
267                                .collect::<Vec<_>>()
268                                .join(", ");
269                            let comment = format!("# via {dependents}").green().to_string();
270                            annotation = Some((separator, comment));
271                        }
272                    },
273                    AnnotationStyle::Split => match dependents.as_slice() {
274                        [] if source.is_empty() => {}
275                        [] if source.len() == 1 => {
276                            let separator = "\n";
277                            let comment = format!("    # via {}", source.iter().next().unwrap())
278                                .green()
279                                .to_string();
280                            annotation = Some((separator, comment));
281                        }
282                        [dependent] if source.is_empty() => {
283                            let separator = "\n";
284                            let comment = format!("    # via {dependent}").green().to_string();
285                            annotation = Some((separator, comment));
286                        }
287                        dependents => {
288                            let separator = "\n";
289                            let dependent = source
290                                .iter()
291                                .map(ToString::to_string)
292                                .chain(dependents.iter().map(ToString::to_string))
293                                .map(|name| format!("    #   {name}"))
294                                .collect::<Vec<_>>()
295                                .join("\n");
296                            let comment = format!("    # via\n{dependent}").green().to_string();
297                            annotation = Some((separator, comment));
298                        }
299                    },
300                }
301            }
302
303            if let Some((separator, comment)) = annotation {
304                // Assemble the line with the annotations and remove trailing whitespaces.
305                for line in format!("{line:24}{separator}{comment}").lines() {
306                    let line = line.trim_end();
307                    writeln!(f, "{line}")?;
308                }
309            } else {
310                // Write the line as is.
311                writeln!(f, "{line}")?;
312            }
313
314            // If enabled, include indexes to indicate which index was used for each package (e.g.,
315            // `# from https://pypi.org/simple`).
316            if self.include_index_annotation {
317                if let Some(index) = node.dist.index() {
318                    let url = index.without_credentials();
319                    writeln!(f, "{}", format!("    # from {url}").green())?;
320                }
321            }
322        }
323
324        Ok(())
325    }
326}
327
328/// We don't need the edge markers anymore since we switched to propagated markers.
329type IntermediatePetGraph<'dist> = Graph<DisplayResolutionGraphNode<'dist>, (), Directed>;
330
331type RequirementsTxtGraph<'dist> = Graph<RequirementsTxtDist<'dist>, (), Directed>;
332
333/// Reduce the graph, such that all nodes for a single package are combined, regardless of
334/// the extras, as long as they have the same version and markers.
335///
336/// For example, `flask` and `flask[dotenv]` should be reduced into a single `flask[dotenv]`
337/// node.
338///
339/// If the extras have different markers, they'll be treated as separate nodes. For example,
340/// `flask[dotenv] ; sys_platform == "win32"` and `flask[async] ; sys_platform == "linux"`
341/// would _not_ be combined.
342///
343/// We also remove the root node, to simplify the graph structure.
344fn combine_extras<'dist>(graph: &IntermediatePetGraph<'dist>) -> RequirementsTxtGraph<'dist> {
345    /// Return the key for a node.
346    fn version_marker<'dist>(dist: &'dist RequirementsTxtDist) -> (&'dist PackageName, MarkerTree) {
347        (dist.name(), dist.markers)
348    }
349
350    let mut next = RequirementsTxtGraph::with_capacity(graph.node_count(), graph.edge_count());
351    let mut inverse = FxHashMap::with_capacity_and_hasher(graph.node_count(), FxBuildHasher);
352
353    // Re-add the nodes to the reduced graph.
354    for index in graph.node_indices() {
355        let DisplayResolutionGraphNode::Dist(dist) = &graph[index] else {
356            continue;
357        };
358
359        // In the `requirements.txt` output, we want a flat installation list, so we need to use
360        // the reachability markers instead of the edge markers.
361        match inverse.entry(version_marker(dist)) {
362            std::collections::hash_map::Entry::Occupied(entry) => {
363                let index = *entry.get();
364                let node: &mut RequirementsTxtDist = &mut next[index];
365                node.extras.extend(dist.extras.iter().cloned());
366                node.extras.sort_unstable();
367                node.extras.dedup();
368            }
369            std::collections::hash_map::Entry::Vacant(entry) => {
370                let index = next.add_node(dist.clone());
371                entry.insert(index);
372            }
373        }
374    }
375
376    // Re-add the edges to the reduced graph.
377    for edge in graph.edge_indices() {
378        let (source, target) = graph.edge_endpoints(edge).unwrap();
379        let DisplayResolutionGraphNode::Dist(source_node) = &graph[source] else {
380            continue;
381        };
382        let DisplayResolutionGraphNode::Dist(target_node) = &graph[target] else {
383            continue;
384        };
385        let source = inverse[&version_marker(source_node)];
386        let target = inverse[&version_marker(target_node)];
387
388        next.update_edge(source, target, ());
389    }
390
391    next
392}
393
394/// Reduce the graph, such that all nodes for a single package are combined, with extras
395/// removed.
396///
397/// For example, `flask`, `flask[async]`, and `flask[dotenv]` should be reduced into a single
398/// `flask` node, with a conjunction of their markers.
399///
400/// We also remove the root node, to simplify the graph structure.
401fn strip_extras<'dist>(graph: &IntermediatePetGraph<'dist>) -> RequirementsTxtGraph<'dist> {
402    let mut next = RequirementsTxtGraph::with_capacity(graph.node_count(), graph.edge_count());
403    let mut inverse = FxHashMap::with_capacity_and_hasher(graph.node_count(), FxBuildHasher);
404
405    // Re-add the nodes to the reduced graph.
406    for index in graph.node_indices() {
407        let DisplayResolutionGraphNode::Dist(dist) = &graph[index] else {
408            continue;
409        };
410
411        // In the `requirements.txt` output, we want a flat installation list, so we need to use
412        // the reachability markers instead of the edge markers.
413        match inverse.entry(dist.version_id()) {
414            std::collections::hash_map::Entry::Occupied(entry) => {
415                let index = *entry.get();
416                let node: &mut RequirementsTxtDist = &mut next[index];
417                node.extras.clear();
418                // Consider:
419                // ```
420                // foo[bar]==1.0.0; sys_platform == 'linux'
421                // foo==1.0.0; sys_platform != 'linux'
422                // ```
423                // In this case, we want to write `foo==1.0.0; sys_platform == 'linux' or sys_platform == 'windows'`
424                node.markers = node.markers.or(dist.markers);
425            }
426            std::collections::hash_map::Entry::Vacant(entry) => {
427                let index = next.add_node(dist.clone());
428                entry.insert(index);
429            }
430        }
431    }
432
433    // Re-add the edges to the reduced graph.
434    for edge in graph.edge_indices() {
435        let (source, target) = graph.edge_endpoints(edge).unwrap();
436        let DisplayResolutionGraphNode::Dist(source_node) = &graph[source] else {
437            continue;
438        };
439        let DisplayResolutionGraphNode::Dist(target_node) = &graph[target] else {
440            continue;
441        };
442        let source = inverse[&source_node.version_id()];
443        let target = inverse[&target_node.version_id()];
444
445        next.update_edge(source, target, ());
446    }
447
448    next
449}