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    #[allow(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 self
121                .resolution
122                .overrides
123                .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            sources
137        } else {
138            SourceAnnotations::default()
139        };
140
141        // Convert a [`petgraph::graph::Graph`] based on [`ResolutionGraphNode`] to a graph based on
142        // [`DisplayResolutionGraphNode`]. In other words: converts from [`AnnotatedDist`] to
143        // [`RequirementsTxtDist`].
144        //
145        // We assign each package its propagated markers: In `requirements.txt`, we want a flat list
146        // that for each package tells us if it should be installed on the current platform, without
147        // looking at which packages depend on it.
148        let graph = self.resolution.graph.map(
149            |_index, node| match node {
150                ResolutionGraphNode::Root => DisplayResolutionGraphNode::Root,
151                ResolutionGraphNode::Dist(dist) => {
152                    let dist = RequirementsTxtDist::from_annotated_dist(dist);
153                    DisplayResolutionGraphNode::Dist(dist)
154                }
155            },
156            // We can drop the edge markers, while retaining their existence and direction for the
157            // annotations.
158            |_index, _edge| (),
159        );
160
161        // Reduce the graph, removing or combining extras for a given package.
162        let graph = if self.include_extras {
163            combine_extras(&graph)
164        } else {
165            strip_extras(&graph)
166        };
167
168        // Collect all packages.
169        let mut nodes = graph
170            .node_indices()
171            .filter_map(|index| {
172                let dist = &graph[index];
173                let name = dist.name();
174                if self.no_emit_packages.contains(name) {
175                    return None;
176                }
177
178                Some((index, dist))
179            })
180            .collect::<Vec<_>>();
181
182        // Sort the nodes by name, but with editable packages first.
183        nodes.sort_unstable_by_key(|(index, node)| (node.to_comparator(), *index));
184
185        // Print out the dependency graph.
186        for (index, node) in nodes {
187            // Display the node itself.
188            let mut line = node
189                .to_requirements_txt(&self.resolution.requires_python, self.include_markers)
190                .to_string();
191
192            // Display the distribution hashes, if any.
193            let mut has_hashes = false;
194            if self.show_hashes {
195                for hash in node.hashes {
196                    has_hashes = true;
197                    line.push_str(" \\\n");
198                    line.push_str("    --hash=");
199                    line.push_str(&hash.to_string());
200                }
201            }
202
203            // Determine the annotation comment and separator (between comment and requirement).
204            let mut annotation = None;
205
206            // If enabled, include annotations to indicate the dependencies that requested each
207            // package (e.g., `# via mypy`).
208            if self.include_annotations {
209                // Display all dependents (i.e., all packages that depend on the current package).
210                let dependents = {
211                    let mut dependents = graph
212                        .edges_directed(index, Direction::Incoming)
213                        .map(|edge| &graph[edge.source()])
214                        .map(uv_distribution_types::Name::name)
215                        .collect::<Vec<_>>();
216                    dependents.sort_unstable();
217                    dependents.dedup();
218                    dependents
219                };
220
221                // Include all external sources (e.g., requirements files).
222                let default = BTreeSet::default();
223                let source = sources.get(node.name()).unwrap_or(&default);
224
225                match self.annotation_style {
226                    AnnotationStyle::Line => match dependents.as_slice() {
227                        [] if source.is_empty() => {}
228                        [] if source.len() == 1 => {
229                            let separator = if has_hashes { "\n    " } else { "  " };
230                            let comment = format!("# via {}", source.iter().next().unwrap())
231                                .green()
232                                .to_string();
233                            annotation = Some((separator, comment));
234                        }
235                        dependents => {
236                            let separator = if has_hashes { "\n    " } else { "  " };
237                            let dependents = dependents
238                                .iter()
239                                .map(ToString::to_string)
240                                .chain(source.iter().map(ToString::to_string))
241                                .collect::<Vec<_>>()
242                                .join(", ");
243                            let comment = format!("# via {dependents}").green().to_string();
244                            annotation = Some((separator, comment));
245                        }
246                    },
247                    AnnotationStyle::Split => match dependents.as_slice() {
248                        [] if source.is_empty() => {}
249                        [] if source.len() == 1 => {
250                            let separator = "\n";
251                            let comment = format!("    # via {}", source.iter().next().unwrap())
252                                .green()
253                                .to_string();
254                            annotation = Some((separator, comment));
255                        }
256                        [dependent] if source.is_empty() => {
257                            let separator = "\n";
258                            let comment = format!("    # via {dependent}").green().to_string();
259                            annotation = Some((separator, comment));
260                        }
261                        dependents => {
262                            let separator = "\n";
263                            let dependent = source
264                                .iter()
265                                .map(ToString::to_string)
266                                .chain(dependents.iter().map(ToString::to_string))
267                                .map(|name| format!("    #   {name}"))
268                                .collect::<Vec<_>>()
269                                .join("\n");
270                            let comment = format!("    # via\n{dependent}").green().to_string();
271                            annotation = Some((separator, comment));
272                        }
273                    },
274                }
275            }
276
277            if let Some((separator, comment)) = annotation {
278                // Assemble the line with the annotations and remove trailing whitespaces.
279                for line in format!("{line:24}{separator}{comment}").lines() {
280                    let line = line.trim_end();
281                    writeln!(f, "{line}")?;
282                }
283            } else {
284                // Write the line as is.
285                writeln!(f, "{line}")?;
286            }
287
288            // If enabled, include indexes to indicate which index was used for each package (e.g.,
289            // `# from https://pypi.org/simple`).
290            if self.include_index_annotation {
291                if let Some(index) = node.dist.index() {
292                    let url = index.without_credentials();
293                    writeln!(f, "{}", format!("    # from {url}").green())?;
294                }
295            }
296        }
297
298        Ok(())
299    }
300}
301
302/// Indicate the style of annotation comments, used to indicate the dependencies that requested each
303/// package.
304#[derive(Debug, Default, Copy, Clone, PartialEq, serde::Deserialize)]
305#[serde(deny_unknown_fields, rename_all = "kebab-case")]
306#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
307#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
308pub enum AnnotationStyle {
309    /// Render the annotations on a single, comma-separated line.
310    Line,
311    /// Render each annotation on its own line.
312    #[default]
313    Split,
314}
315
316/// We don't need the edge markers anymore since we switched to propagated markers.
317type IntermediatePetGraph<'dist> = Graph<DisplayResolutionGraphNode<'dist>, (), Directed>;
318
319type RequirementsTxtGraph<'dist> = Graph<RequirementsTxtDist<'dist>, (), Directed>;
320
321/// Reduce the graph, such that all nodes for a single package are combined, regardless of
322/// the extras, as long as they have the same version and markers.
323///
324/// For example, `flask` and `flask[dotenv]` should be reduced into a single `flask[dotenv]`
325/// node.
326///
327/// If the extras have different markers, they'll be treated as separate nodes. For example,
328/// `flask[dotenv] ; sys_platform == "win32"` and `flask[async] ; sys_platform == "linux"`
329/// would _not_ be combined.
330///
331/// We also remove the root node, to simplify the graph structure.
332fn combine_extras<'dist>(graph: &IntermediatePetGraph<'dist>) -> RequirementsTxtGraph<'dist> {
333    /// Return the key for a node.
334    fn version_marker<'dist>(dist: &'dist RequirementsTxtDist) -> (&'dist PackageName, MarkerTree) {
335        (dist.name(), dist.markers)
336    }
337
338    let mut next = RequirementsTxtGraph::with_capacity(graph.node_count(), graph.edge_count());
339    let mut inverse = FxHashMap::with_capacity_and_hasher(graph.node_count(), FxBuildHasher);
340
341    // Re-add the nodes to the reduced graph.
342    for index in graph.node_indices() {
343        let DisplayResolutionGraphNode::Dist(dist) = &graph[index] else {
344            continue;
345        };
346
347        // In the `requirements.txt` output, we want a flat installation list, so we need to use
348        // the reachability markers instead of the edge markers.
349        match inverse.entry(version_marker(dist)) {
350            std::collections::hash_map::Entry::Occupied(entry) => {
351                let index = *entry.get();
352                let node: &mut RequirementsTxtDist = &mut next[index];
353                node.extras.extend(dist.extras.iter().cloned());
354                node.extras.sort_unstable();
355                node.extras.dedup();
356            }
357            std::collections::hash_map::Entry::Vacant(entry) => {
358                let index = next.add_node(dist.clone());
359                entry.insert(index);
360            }
361        }
362    }
363
364    // Re-add the edges to the reduced graph.
365    for edge in graph.edge_indices() {
366        let (source, target) = graph.edge_endpoints(edge).unwrap();
367        let DisplayResolutionGraphNode::Dist(source_node) = &graph[source] else {
368            continue;
369        };
370        let DisplayResolutionGraphNode::Dist(target_node) = &graph[target] else {
371            continue;
372        };
373        let source = inverse[&version_marker(source_node)];
374        let target = inverse[&version_marker(target_node)];
375
376        next.update_edge(source, target, ());
377    }
378
379    next
380}
381
382/// Reduce the graph, such that all nodes for a single package are combined, with extras
383/// removed.
384///
385/// For example, `flask`, `flask[async]`, and `flask[dotenv]` should be reduced into a single
386/// `flask` node, with a conjunction of their markers.
387///
388/// We also remove the root node, to simplify the graph structure.
389fn strip_extras<'dist>(graph: &IntermediatePetGraph<'dist>) -> RequirementsTxtGraph<'dist> {
390    let mut next = RequirementsTxtGraph::with_capacity(graph.node_count(), graph.edge_count());
391    let mut inverse = FxHashMap::with_capacity_and_hasher(graph.node_count(), FxBuildHasher);
392
393    // Re-add the nodes to the reduced graph.
394    for index in graph.node_indices() {
395        let DisplayResolutionGraphNode::Dist(dist) = &graph[index] else {
396            continue;
397        };
398
399        // In the `requirements.txt` output, we want a flat installation list, so we need to use
400        // the reachability markers instead of the edge markers.
401        match inverse.entry(dist.version_id()) {
402            std::collections::hash_map::Entry::Occupied(entry) => {
403                let index = *entry.get();
404                let node: &mut RequirementsTxtDist = &mut next[index];
405                node.extras.clear();
406                // Consider:
407                // ```
408                // foo[bar]==1.0.0; sys_platform == 'linux'
409                // foo==1.0.0; sys_platform != 'linux'
410                // ```
411                // In this case, we want to write `foo==1.0.0; sys_platform == 'linux' or sys_platform == 'windows'`
412                node.markers.or(dist.markers);
413            }
414            std::collections::hash_map::Entry::Vacant(entry) => {
415                let index = next.add_node(dist.clone());
416                entry.insert(index);
417            }
418        }
419    }
420
421    // Re-add the edges to the reduced graph.
422    for edge in graph.edge_indices() {
423        let (source, target) = graph.edge_endpoints(edge).unwrap();
424        let DisplayResolutionGraphNode::Dist(source_node) = &graph[source] else {
425            continue;
426        };
427        let DisplayResolutionGraphNode::Dist(target_node) = &graph[target] else {
428            continue;
429        };
430        let source = inverse[&source_node.version_id()];
431        let target = inverse[&target_node.version_id()];
432
433        next.update_edge(source, target, ());
434    }
435
436    next
437}