Skip to content

Commit 0b15684

Browse files
charliermarshkonstin
authored andcommitted
Support multiple extras in universal pip compile output (#8960)
## Summary We were making some incorrect assumptions in the extra-merging code for universal `pip compile`. This PR corrects those assumptions and adds a bunch of additional tests. Closes #8915.
1 parent 55a7ebf commit 0b15684

File tree

3 files changed

+479
-52
lines changed

3 files changed

+479
-52
lines changed

crates/uv-resolver/src/resolution/display.rs

Lines changed: 79 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ use petgraph::visit::EdgeRef;
55
use petgraph::Direction;
66
use rustc_hash::{FxBuildHasher, FxHashMap};
77

8-
use uv_distribution_types::{DistributionMetadata, Name, SourceAnnotation, SourceAnnotations};
8+
use uv_distribution_types::{
9+
DistributionMetadata, Name, SourceAnnotation, SourceAnnotations, VersionId,
10+
};
911
use uv_normalize::PackageName;
1012
use uv_pep508::MarkerTree;
1113

@@ -44,15 +46,6 @@ enum DisplayResolutionGraphNode<'dist> {
4446
Dist(RequirementsTxtDist<'dist>),
4547
}
4648

47-
impl DisplayResolutionGraphNode<'_> {
48-
fn markers(&self) -> &MarkerTree {
49-
match self {
50-
DisplayResolutionGraphNode::Root => &MarkerTree::TRUE,
51-
DisplayResolutionGraphNode::Dist(dist) => dist.markers,
52-
}
53-
}
54-
}
55-
5649
impl<'a> DisplayResolutionGraph<'a> {
5750
/// Create a new [`DisplayResolutionGraph`] for the given graph.
5851
#[allow(clippy::fn_params_excessive_bools)]
@@ -156,9 +149,12 @@ impl std::fmt::Display for DisplayResolutionGraph<'_> {
156149
|_index, _edge| (),
157150
);
158151

159-
// Reduce the graph, such that all nodes for a single package are combined, regardless of
160-
// the extras.
161-
let petgraph = combine_extras(&petgraph);
152+
// Reduce the graph, removing or combining extras for a given package.
153+
let petgraph = if self.include_extras {
154+
combine_extras(&petgraph)
155+
} else {
156+
strip_extras(&petgraph)
157+
};
162158

163159
// Collect all packages.
164160
let mut nodes = petgraph
@@ -181,11 +177,7 @@ impl std::fmt::Display for DisplayResolutionGraph<'_> {
181177
for (index, node) in nodes {
182178
// Display the node itself.
183179
let mut line = node
184-
.to_requirements_txt(
185-
&self.resolution.requires_python,
186-
self.include_extras,
187-
self.include_markers,
188-
)
180+
.to_requirements_txt(&self.resolution.requires_python, self.include_markers)
189181
.to_string();
190182

191183
// Display the distribution hashes, if any.
@@ -320,13 +312,22 @@ type RequirementsTxtGraph<'dist> =
320312
petgraph::graph::Graph<RequirementsTxtDist<'dist>, (), petgraph::Directed>;
321313

322314
/// Reduce the graph, such that all nodes for a single package are combined, regardless of
323-
/// the extras.
315+
/// the extras, as long as they have the same version and markers.
324316
///
325317
/// For example, `flask` and `flask[dotenv]` should be reduced into a single `flask[dotenv]`
326318
/// node.
327319
///
320+
/// If the extras have different markers, they'll be treated as separate nodes. For example,
321+
/// `flask[dotenv] ; sys_platform == "win32"` and `flask[async] ; sys_platform == "linux"`
322+
/// would _not_ be combined.
323+
///
328324
/// We also remove the root node, to simplify the graph structure.
329325
fn combine_extras<'dist>(graph: &IntermediatePetGraph<'dist>) -> RequirementsTxtGraph<'dist> {
326+
/// Return the key for a node.
327+
fn version_marker(dist: &RequirementsTxtDist) -> (VersionId, MarkerTree) {
328+
(dist.version_id(), dist.markers.clone())
329+
}
330+
330331
let mut next = RequirementsTxtGraph::with_capacity(graph.node_count(), graph.edge_count());
331332
let mut inverse = FxHashMap::with_capacity_and_hasher(graph.node_count(), FxBuildHasher);
332333

@@ -338,40 +339,68 @@ fn combine_extras<'dist>(graph: &IntermediatePetGraph<'dist>) -> RequirementsTxt
338339

339340
// In the `requirements.txt` output, we want a flat installation list, so we need to use
340341
// the reachability markers instead of the edge markers.
341-
// We use the markers of the base package: We know that each virtual extra package has an
342-
// edge to the base package, so we know that base package markers are more general than the
343-
// extra package markers (the extra package markers are a subset of the base package
344-
// markers).
345-
if let Some(index) = inverse.get(&dist.version_id()) {
346-
let node: &mut RequirementsTxtDist = &mut next[*index];
347-
node.extras.extend(dist.extras.iter().cloned());
348-
node.extras.sort_unstable();
349-
node.extras.dedup();
350-
} else {
351-
let version_id = dist.version_id();
352-
let dist = dist.clone();
353-
let index = next.add_node(dist);
354-
inverse.insert(version_id, index);
342+
match inverse.entry(version_marker(dist)) {
343+
std::collections::hash_map::Entry::Occupied(entry) => {
344+
let index = *entry.get();
345+
let node: &mut RequirementsTxtDist = &mut next[index];
346+
node.extras.extend(dist.extras.iter().cloned());
347+
node.extras.sort_unstable();
348+
node.extras.dedup();
349+
}
350+
std::collections::hash_map::Entry::Vacant(entry) => {
351+
let index = next.add_node(dist.clone());
352+
entry.insert(index);
353+
}
355354
}
356355
}
357356

358-
// Verify that the package markers are more general than the extra markers.
359-
if cfg!(debug_assertions) {
360-
for index in graph.node_indices() {
361-
let DisplayResolutionGraphNode::Dist(dist) = &graph[index] else {
362-
continue;
363-
};
364-
let combined_markers = next[inverse[&dist.version_id()]].markers.clone();
365-
let mut package_markers = combined_markers.clone();
366-
package_markers.or(graph[index].markers().clone());
367-
assert_eq!(
368-
package_markers,
369-
combined_markers,
370-
"{} {:?} {:?}",
371-
dist.version_id(),
372-
dist.extras,
373-
dist.markers.try_to_string()
374-
);
357+
// Re-add the edges to the reduced graph.
358+
for edge in graph.edge_indices() {
359+
let (source, target) = graph.edge_endpoints(edge).unwrap();
360+
let DisplayResolutionGraphNode::Dist(source_node) = &graph[source] else {
361+
continue;
362+
};
363+
let DisplayResolutionGraphNode::Dist(target_node) = &graph[target] else {
364+
continue;
365+
};
366+
let source = inverse[&version_marker(source_node)];
367+
let target = inverse[&version_marker(target_node)];
368+
369+
next.update_edge(source, target, ());
370+
}
371+
372+
next
373+
}
374+
375+
/// Reduce the graph, such that all nodes for a single package are combined, with extras
376+
/// removed.
377+
///
378+
/// For example, `flask`, `flask[async]`, and `flask[dotenv]` should be reduced into a single
379+
/// `flask` node, with a conjunction of their markers.
380+
///
381+
/// We also remove the root node, to simplify the graph structure.
382+
fn strip_extras<'dist>(graph: &IntermediatePetGraph<'dist>) -> RequirementsTxtGraph<'dist> {
383+
let mut next = RequirementsTxtGraph::with_capacity(graph.node_count(), graph.edge_count());
384+
let mut inverse = FxHashMap::with_capacity_and_hasher(graph.node_count(), FxBuildHasher);
385+
386+
// Re-add the nodes to the reduced graph.
387+
for index in graph.node_indices() {
388+
let DisplayResolutionGraphNode::Dist(dist) = &graph[index] else {
389+
continue;
390+
};
391+
392+
// In the `requirements.txt` output, we want a flat installation list, so we need to use
393+
// the reachability markers instead of the edge markers.
394+
match inverse.entry(dist.version_id()) {
395+
std::collections::hash_map::Entry::Occupied(entry) => {
396+
let index = *entry.get();
397+
let node: &mut RequirementsTxtDist = &mut next[index];
398+
node.extras.clear();
399+
}
400+
std::collections::hash_map::Entry::Vacant(entry) => {
401+
let index = next.add_node(dist.clone());
402+
entry.insert(index);
403+
}
375404
}
376405
}
377406

crates/uv-resolver/src/resolution/requirements_txt.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ impl<'dist> RequirementsTxtDist<'dist> {
3535
pub(crate) fn to_requirements_txt(
3636
&self,
3737
requires_python: &RequiresPython,
38-
include_extras: bool,
3938
include_markers: bool,
4039
) -> Cow<str> {
4140
// If the URL is editable, write it as an editable requirement.
@@ -106,7 +105,7 @@ impl<'dist> RequirementsTxtDist<'dist> {
106105
}
107106
}
108107

109-
if self.extras.is_empty() || !include_extras {
108+
if self.extras.is_empty() {
110109
if let Some(markers) = SimplifiedMarkerTree::new(requires_python, self.markers.clone())
111110
.try_to_string()
112111
.filter(|_| include_markers)
@@ -141,6 +140,8 @@ impl<'dist> RequirementsTxtDist<'dist> {
141140
}
142141
}
143142

143+
/// Convert the [`RequirementsTxtDist`] to a comparator that can be used to sort the requirements
144+
/// in a `requirements.txt` file.
144145
pub(crate) fn to_comparator(&self) -> RequirementsTxtComparator {
145146
if self.dist.is_editable() {
146147
if let VersionOrUrlRef::Url(url) = self.dist.version_or_url() {
@@ -153,12 +154,14 @@ impl<'dist> RequirementsTxtDist<'dist> {
153154
name: self.name(),
154155
version: self.version,
155156
url: Some(url.verbatim()),
157+
extras: &self.extras,
156158
}
157159
} else {
158160
RequirementsTxtComparator::Name {
159161
name: self.name(),
160162
version: self.version,
161163
url: None,
164+
extras: &self.extras,
162165
}
163166
}
164167
}
@@ -178,15 +181,18 @@ impl<'dist> RequirementsTxtDist<'dist> {
178181
}
179182
}
180183

184+
/// A comparator for sorting requirements in a `requirements.txt` file.
181185
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
182186
pub(crate) enum RequirementsTxtComparator<'a> {
187+
/// Sort by URL for editable requirements.
183188
Url(Cow<'a, str>),
184189
/// In universal mode, we can have multiple versions for a package, so we track the version and
185190
/// the URL (for non-index packages) to have a stable sort for those, too.
186191
Name {
187192
name: &'a PackageName,
188193
version: &'a Version,
189194
url: Option<Cow<'a, str>>,
195+
extras: &'a [ExtraName],
190196
},
191197
}
192198

0 commit comments

Comments
 (0)