Skip to content

Commit ae659c8

Browse files
authored
Stable order for virtual packages (#10024)
uv gives priorities to packages by package name, not by virtual package (`PubGrubPackage`). pubgrub otoh when prioritizing order the virtual packages. When the order of virtual packages changes, uv changes its resolutions and error messages. This means uv was depending on implementation details of pubgrub's prioritization caching. This broke with pubgrub-rs/pubgrub#299, which added a tiebreaker term that made pubgrub's sorting deterministic given a deterministic ordering of allocating the packages (which happens the first time pubgrub sees a package). The new custom tiebreaker decreases the difference to upstream pubgrub.
1 parent ff86029 commit ae659c8

File tree

8 files changed

+179
-63
lines changed

8 files changed

+179
-63
lines changed

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ petgraph = { version = "0.6.5" }
130130
platform-info = { version = "2.0.3" }
131131
proc-macro2 = { version = "1.0.86" }
132132
procfs = { version = "0.17.0", default-features = false, features = ["flate2"] }
133-
pubgrub = { git = "https://github.com/astral-sh/pubgrub", rev = "05e8d12cea8d72c6d2d017900e478d0abd28fef4" }
133+
pubgrub = { git = "https://github.com/astral-sh/pubgrub", rev = "648aa343486e5529953153781fc86025c73c4a61" }
134134
quote = { version = "1.0.37" }
135135
rayon = { version = "1.10.0" }
136136
reflink-copy = { version = "0.1.19" }
@@ -175,7 +175,7 @@ unicode-width = { version = "0.1.13" }
175175
unscanny = { version = "0.1.0" }
176176
url = { version = "2.5.2", features = ["serde"] }
177177
urlencoding = { version = "2.1.3" }
178-
version-ranges = { git = "https://github.com/astral-sh/pubgrub", rev = "05e8d12cea8d72c6d2d017900e478d0abd28fef4" }
178+
version-ranges = { git = "https://github.com/astral-sh/pubgrub", rev = "648aa343486e5529953153781fc86025c73c4a61" }
179179
walkdir = { version = "2.5.0" }
180180
which = { version = "7.0.0", features = ["regex"] }
181181
windows-registry = { version = "0.3.0" }

crates/uv-resolver/src/dependency_provider.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ impl DependencyProvider for UvDependencyProvider {
1717
type V = Version;
1818
type VS = Range<Version>;
1919
type M = UnavailableReason;
20-
type Priority = Option<PubGrubPriority>;
20+
/// Main priority and tiebreak for virtual packages
21+
type Priority = (Option<PubGrubPriority>, u32);
2122
type Err = Infallible;
2223

2324
fn prioritize(

crates/uv-resolver/src/pubgrub/priority.rs

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ use crate::pubgrub::PubGrubPackageInner;
2020
///
2121
/// See: <https://github.com/pypa/pip/blob/ef78c129b1a966dbbbdb8ebfffc43723e89110d1/src/pip/_internal/resolution/resolvelib/provider.py#L120>
2222
#[derive(Clone, Debug, Default)]
23-
pub(crate) struct PubGrubPriorities(FxHashMap<PackageName, PubGrubPriority>);
23+
pub(crate) struct PubGrubPriorities {
24+
package_priority: FxHashMap<PackageName, PubGrubPriority>,
25+
virtual_package_tiebreaker: FxHashMap<PubGrubPackage, u32>,
26+
}
2427

2528
impl PubGrubPriorities {
2629
/// Add a [`PubGrubPackage`] to the priority map.
@@ -30,14 +33,22 @@ impl PubGrubPriorities {
3033
version: &Range<Version>,
3134
urls: &ForkUrls,
3235
) {
33-
let next = self.0.len();
36+
if !self.virtual_package_tiebreaker.contains_key(package) {
37+
self.virtual_package_tiebreaker.insert(
38+
package.clone(),
39+
u32::try_from(self.virtual_package_tiebreaker.len())
40+
.expect("Less than 2**32 packages"),
41+
);
42+
}
43+
44+
let next = self.package_priority.len();
3445
// The root package and Python constraints have no explicit priority, the root package is
3546
// always first and the Python version (range) is fixed.
3647
let Some(name) = package.name_no_root() else {
3748
return;
3849
};
3950

40-
match self.0.entry(name.clone()) {
51+
match self.package_priority.entry(name.clone()) {
4152
std::collections::hash_map::Entry::Occupied(mut entry) => {
4253
// Preserve the original index.
4354
let index = Self::get_index(&entry).unwrap_or(next);
@@ -92,15 +103,21 @@ impl PubGrubPriorities {
92103
}
93104

94105
/// Return the [`PubGrubPriority`] of the given package, if it exists.
95-
pub(crate) fn get(&self, package: &PubGrubPackage) -> Option<PubGrubPriority> {
96-
match &**package {
106+
pub(crate) fn get(&self, package: &PubGrubPackage) -> (Option<PubGrubPriority>, u32) {
107+
let package_priority = match &**package {
97108
PubGrubPackageInner::Root(_) => Some(PubGrubPriority::Root),
98109
PubGrubPackageInner::Python(_) => Some(PubGrubPriority::Root),
99-
PubGrubPackageInner::Marker { name, .. } => self.0.get(name).copied(),
100-
PubGrubPackageInner::Extra { name, .. } => self.0.get(name).copied(),
101-
PubGrubPackageInner::Dev { name, .. } => self.0.get(name).copied(),
102-
PubGrubPackageInner::Package { name, .. } => self.0.get(name).copied(),
103-
}
110+
PubGrubPackageInner::Marker { name, .. } => self.package_priority.get(name).copied(),
111+
PubGrubPackageInner::Extra { name, .. } => self.package_priority.get(name).copied(),
112+
PubGrubPackageInner::Dev { name, .. } => self.package_priority.get(name).copied(),
113+
PubGrubPackageInner::Package { name, .. } => self.package_priority.get(name).copied(),
114+
};
115+
let virtual_package_tiebreaker = self
116+
.virtual_package_tiebreaker
117+
.get(package)
118+
.copied()
119+
.unwrap_or_default();
120+
(package_priority, virtual_package_tiebreaker)
104121
}
105122

106123
/// Mark a package as prioritized by setting it to [`PubGrubPriority::ConflictEarly`], if it
@@ -109,7 +126,7 @@ impl PubGrubPriorities {
109126
/// Returns whether the priority was changed, i.e., it's the first time we hit this condition
110127
/// for the package.
111128
pub(crate) fn mark_conflict_early(&mut self, package: &PubGrubPackage) -> bool {
112-
let next = self.0.len();
129+
let next = self.package_priority.len();
113130
let Some(name) = package.name_no_root() else {
114131
// Not a correctness bug
115132
if cfg!(debug_assertions) {
@@ -118,7 +135,7 @@ impl PubGrubPriorities {
118135
return false;
119136
}
120137
};
121-
match self.0.entry(name.clone()) {
138+
match self.package_priority.entry(name.clone()) {
122139
std::collections::hash_map::Entry::Occupied(mut entry) => {
123140
if matches!(
124141
entry.get(),
@@ -144,7 +161,7 @@ impl PubGrubPriorities {
144161
/// Returns whether the priority was changed, i.e., it's the first time this package was
145162
/// marked as conflicting above the threshold.
146163
pub(crate) fn mark_conflict_late(&mut self, package: &PubGrubPackage) -> bool {
147-
let next = self.0.len();
164+
let next = self.package_priority.len();
148165
let Some(name) = package.name_no_root() else {
149166
// Not a correctness bug
150167
if cfg!(debug_assertions) {
@@ -153,7 +170,7 @@ impl PubGrubPriorities {
153170
return false;
154171
}
155172
};
156-
match self.0.entry(name.clone()) {
173+
match self.package_priority.entry(name.clone()) {
157174
std::collections::hash_map::Entry::Occupied(mut entry) => {
158175
// The ConflictEarly` match avoids infinite loops.
159176
if matches!(

crates/uv/tests/it/lock_conflict.rs

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,8 @@ fn extra_basic_three_extras() -> Result<()> {
250250
251251
----- stderr -----
252252
× No solution found when resolving dependencies:
253-
╰─▶ Because project[project3] depends on sortedcontainers==2.4.0 and project[extra1] depends on sortedcontainers==2.2.0, we can conclude that project[extra1] and project[project3] are incompatible.
254-
And because your project requires project[extra1] and project[project3], we can conclude that your project's requirements are unsatisfiable.
253+
╰─▶ Because project[project3] depends on sortedcontainers==2.4.0 and project[extra2] depends on sortedcontainers==2.3.0, we can conclude that project[extra2] and project[project3] are incompatible.
254+
And because your project requires project[extra2] and project[project3], we can conclude that your project's requirements are unsatisfiable.
255255
"###);
256256

257257
// And now with the same extra configuration, we tell uv about
@@ -538,8 +538,8 @@ fn extra_multiple_not_conflicting2() -> Result<()> {
538538
539539
----- stderr -----
540540
× No solution found when resolving dependencies:
541-
╰─▶ Because project[project4] depends on sortedcontainers==2.4.0 and project[extra1] depends on sortedcontainers==2.3.0, we can conclude that project[extra1] and project[project4] are incompatible.
542-
And because your project requires project[extra1] and project[project4], we can conclude that your project's requirements are unsatisfiable.
541+
╰─▶ Because project[project4] depends on sortedcontainers==2.4.0 and project[project3] depends on sortedcontainers==2.3.0, we can conclude that project[project3] and project[project4] are incompatible.
542+
And because your project requires project[project3] and project[project4], we can conclude that your project's requirements are unsatisfiable.
543543
"###);
544544

545545
// If we define extra1/extra2 as conflicting and project3/project4
@@ -1289,10 +1289,8 @@ fn extra_nested_across_workspace() -> Result<()> {
12891289
12901290
----- stderr -----
12911291
× No solution found when resolving dependencies:
1292-
╰─▶ Because dummy[extra2] depends on proxy1[extra2] and only proxy1[extra2]==0.1.0 is available, we can conclude that dummy[extra2] depends on proxy1[extra2]==0.1.0. (1)
1293-
1294-
Because proxy1[extra2]==0.1.0 depends on anyio==4.2.0 and proxy1[extra1]==0.1.0 depends on anyio==4.1.0, we can conclude that proxy1[extra1]==0.1.0 and proxy1[extra2]==0.1.0 are incompatible.
1295-
And because we know from (1) that dummy[extra2] depends on proxy1[extra2]==0.1.0, we can conclude that dummy[extra2] and proxy1[extra1]==0.1.0 are incompatible.
1292+
╰─▶ Because dummy[extra2] depends on proxy1[extra2] and only proxy1[extra2]==0.1.0 is available, we can conclude that dummy[extra2] depends on proxy1[extra2]==0.1.0.
1293+
And because proxy1[extra2]==0.1.0 depends on anyio==4.2.0 and proxy1[extra1]==0.1.0 depends on anyio==4.1.0, we can conclude that proxy1[extra1]==0.1.0 and dummy[extra2] are incompatible.
12961294
And because only proxy1[extra1]==0.1.0 is available and dummysub[extra1] depends on proxy1[extra1], we can conclude that dummysub[extra1] and dummy[extra2] are incompatible.
12971295
And because your workspace requires dummy[extra2] and dummysub[extra1], we can conclude that your workspace's requirements are unsatisfiable.
12981296
"###);
@@ -1795,7 +1793,7 @@ fn mixed() -> Result<()> {
17951793
17961794
----- stderr -----
17971795
× No solution found when resolving dependencies:
1798-
╰─▶ Because project[extra1] depends on sortedcontainers==2.4.0 and project:group1 depends on sortedcontainers==2.3.0, we can conclude that project:group1 and project[extra1] are incompatible.
1796+
╰─▶ Because project:group1 depends on sortedcontainers==2.3.0 and project[extra1] depends on sortedcontainers==2.4.0, we can conclude that project[extra1] and project:group1 are incompatible.
17991797
And because your project requires project[extra1] and project:group1, we can conclude that your project's requirements are unsatisfiable.
18001798
"###);
18011799

crates/uv/tests/it/pip_install_scenarios.rs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -334,10 +334,11 @@ fn dependency_excludes_non_contiguous_range_of_compatible_versions() {
334334
335335
----- stderr -----
336336
× No solution found when resolving dependencies:
337-
╰─▶ Because only the following versions of package-a are available:
337+
╰─▶ Because package-a==1.0.0 depends on package-b==1.0.0 and only the following versions of package-a are available:
338338
package-a==1.0.0
339-
package-a>2.0.0,<=3.0.0
340-
and package-a==1.0.0 depends on package-b==1.0.0, we can conclude that package-a<2.0.0 depends on package-b==1.0.0. (1)
339+
package-a>2.0.0
340+
we can conclude that package-a<2.0.0 depends on package-b==1.0.0.
341+
And because only package-a<=3.0.0 is available, we can conclude that package-a<2.0.0 depends on package-b==1.0.0. (1)
341342
342343
Because only the following versions of package-c are available:
343344
package-c==1.0.0
@@ -445,10 +446,11 @@ fn dependency_excludes_range_of_compatible_versions() {
445446
446447
----- stderr -----
447448
× No solution found when resolving dependencies:
448-
╰─▶ Because only the following versions of package-a are available:
449+
╰─▶ Because package-a==1.0.0 depends on package-b==1.0.0 and only the following versions of package-a are available:
449450
package-a==1.0.0
450-
package-a>2.0.0,<=3.0.0
451-
and package-a==1.0.0 depends on package-b==1.0.0, we can conclude that package-a<2.0.0 depends on package-b==1.0.0. (1)
451+
package-a>2.0.0
452+
we can conclude that package-a<2.0.0 depends on package-b==1.0.0.
453+
And because only package-a<=3.0.0 is available, we can conclude that package-a<2.0.0 depends on package-b==1.0.0. (1)
452454
453455
Because only the following versions of package-c are available:
454456
package-c==1.0.0

0 commit comments

Comments
 (0)