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