Skip to content

Commit 4d3c1b3

Browse files
Prefer higher Python lower-bounds when forking (#10007)
## Summary With the advent of `--fork-strategy requires-python` (the default), we actually _want_ to solve higher lower-bound forks before lower lower-bound forks. The former ensures we get the most compatible versions, while the latter ensures we get fewer overall versions. These two strategies match up with `--fork-strategy`, but need to be respected as such. Closes #9998.
1 parent 8f88f98 commit 4d3c1b3

9 files changed

+487
-134
lines changed

crates/uv-resolver/src/resolver/mod.rs

Lines changed: 67 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -624,7 +624,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
624624
}
625625
}
626626
ForkedDependencies::Forked {
627-
forks,
627+
mut forks,
628628
diverging_packages,
629629
} => {
630630
debug!(
@@ -633,6 +633,28 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
633633
start.elapsed().as_secs_f32()
634634
);
635635

636+
// Prioritize the forks.
637+
match (self.options.fork_strategy, self.options.resolution_mode) {
638+
(ForkStrategy::Fewest, _) | (_, ResolutionMode::Lowest) => {
639+
// Prefer solving forks with lower Python bounds, since they're more
640+
// likely to produce solutions that work for forks with higher
641+
// Python bounds (whereas the inverse is not true).
642+
forks.sort_by(|a, b| {
643+
a.cmp_requires_python(b)
644+
.reverse()
645+
.then_with(|| a.cmp_upper_bounds(b))
646+
});
647+
}
648+
(ForkStrategy::RequiresPython, _) => {
649+
// Otherwise, prefer solving forks with higher Python bounds, since
650+
// we want to prioritize choosing the latest-compatible package
651+
// version for each Python version.
652+
forks.sort_by(|a, b| {
653+
a.cmp_requires_python(b).then_with(|| a.cmp_upper_bounds(b))
654+
});
655+
}
656+
}
657+
636658
for new_fork_state in self.forks_to_fork_states(
637659
state,
638660
&version,
@@ -2907,11 +2929,6 @@ impl Dependencies {
29072929
} else if forks.len() == 1 {
29082930
ForkedDependencies::Unforked(forks.pop().unwrap().dependencies)
29092931
} else {
2910-
// Prioritize the forks. Prefer solving forks with lower Python
2911-
// bounds, since they're more likely to produce solutions that work
2912-
// for forks with higher Python bounds (whereas the inverse is not
2913-
// true).
2914-
forks.sort();
29152932
ForkedDependencies::Forked {
29162933
forks,
29172934
diverging_packages: diverging_packages.into_iter().collect(),
@@ -3224,57 +3241,57 @@ impl Fork {
32243241
});
32253242
Some(self)
32263243
}
3227-
}
3228-
3229-
impl Eq for Fork {}
3230-
3231-
impl PartialEq for Fork {
3232-
fn eq(&self, other: &Fork) -> bool {
3233-
self.dependencies == other.dependencies && self.env == other.env
3234-
}
3235-
}
32363244

3237-
impl Ord for Fork {
3238-
fn cmp(&self, other: &Self) -> Ordering {
3239-
// A higher `requires-python` requirement indicates a _lower-priority_ fork. We'd prefer
3240-
// to solve `<3.7` before solving `>=3.7`, since the resolution produced by the former might
3241-
// work for the latter, but the inverse is unlikely to be true.
3245+
/// Compare forks, preferring forks with g `requires-python` requirements.
3246+
fn cmp_requires_python(&self, other: &Self) -> Ordering {
3247+
// A higher `requires-python` requirement indicates a _higher-priority_ fork.
3248+
//
3249+
// This ordering ensures that we prefer choosing the highest version for each fork based on
3250+
// its `requires-python` requirement.
3251+
//
3252+
// The reverse would prefer choosing fewer versions, at the cost of using older package
3253+
// versions on newer Python versions. For example, if reversed, we'd prefer to solve `<3.7
3254+
// before solving `>=3.7`, since the resolution produced by the former might work for the
3255+
// latter, but the inverse is unlikely to be true.
32423256
let self_bound = self.env.requires_python().unwrap_or_default();
32433257
let other_bound = other.env.requires_python().unwrap_or_default();
3258+
self_bound.lower().cmp(other_bound.lower())
3259+
}
32443260

3245-
other_bound.lower().cmp(self_bound.lower()).then_with(|| {
3246-
// If there's no difference, prioritize forks with upper bounds. We'd prefer to solve
3247-
// `numpy <= 2` before solving `numpy >= 1`, since the resolution produced by the former
3248-
// might work for the latter, but the inverse is unlikely to be true due to maximum
3249-
// version selection. (Selecting `numpy==2.0.0` would satisfy both forks, but selecting
3250-
// the latest `numpy` would not.)
3251-
let self_upper_bounds = self
3252-
.dependencies
3253-
.iter()
3254-
.filter(|dep| {
3255-
dep.version
3256-
.bounding_range()
3257-
.is_some_and(|(_, upper)| !matches!(upper, Bound::Unbounded))
3258-
})
3259-
.count();
3260-
let other_upper_bounds = other
3261-
.dependencies
3262-
.iter()
3263-
.filter(|dep| {
3264-
dep.version
3265-
.bounding_range()
3266-
.is_some_and(|(_, upper)| !matches!(upper, Bound::Unbounded))
3267-
})
3268-
.count();
3269-
3270-
self_upper_bounds.cmp(&other_upper_bounds)
3271-
})
3261+
/// Compare forks, preferring forks with upper bounds.
3262+
fn cmp_upper_bounds(&self, other: &Self) -> Ordering {
3263+
// We'd prefer to solve `numpy <= 2` before solving `numpy >= 1`, since the resolution
3264+
// produced by the former might work for the latter, but the inverse is unlikely to be true
3265+
// due to maximum version selection. (Selecting `numpy==2.0.0` would satisfy both forks, but
3266+
// selecting the latest `numpy` would not.)
3267+
let self_upper_bounds = self
3268+
.dependencies
3269+
.iter()
3270+
.filter(|dep| {
3271+
dep.version
3272+
.bounding_range()
3273+
.is_some_and(|(_, upper)| !matches!(upper, Bound::Unbounded))
3274+
})
3275+
.count();
3276+
let other_upper_bounds = other
3277+
.dependencies
3278+
.iter()
3279+
.filter(|dep| {
3280+
dep.version
3281+
.bounding_range()
3282+
.is_some_and(|(_, upper)| !matches!(upper, Bound::Unbounded))
3283+
})
3284+
.count();
3285+
3286+
self_upper_bounds.cmp(&other_upper_bounds)
32723287
}
32733288
}
32743289

3275-
impl PartialOrd for Fork {
3276-
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
3277-
Some(self.cmp(other))
3290+
impl Eq for Fork {}
3291+
3292+
impl PartialEq for Fork {
3293+
fn eq(&self, other: &Fork) -> bool {
3294+
self.dependencies == other.dependencies && self.env == other.env
32783295
}
32793296
}
32803297

crates/uv/tests/it/branching_urls.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ fn root_package_splits_but_transitive_conflict() -> Result<()> {
131131
----- stdout -----
132132
133133
----- stderr -----
134-
error: Requirements contain conflicting URLs for package `iniconfig` in split `python_full_version < '3.12'`:
134+
error: Requirements contain conflicting URLs for package `iniconfig` in split `python_full_version >= '3.12'`:
135135
- https://files.pythonhosted.org/packages/9b/dd/b3c12c6d707058fa947864b67f0c4e0c39ef8610988d7baea9578f3c48f3/iniconfig-1.1.1-py2.py3-none-any.whl
136136
- https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl
137137
"###
@@ -207,8 +207,8 @@ fn root_package_splits_transitive_too() -> Result<()> {
207207
version = 1
208208
requires-python = ">=3.11, <3.13"
209209
resolution-markers = [
210-
"python_full_version < '3.12'",
211210
"python_full_version >= '3.12'",
211+
"python_full_version < '3.12'",
212212
]
213213
214214
[options]
@@ -402,8 +402,8 @@ fn root_package_splits_other_dependencies_too() -> Result<()> {
402402
version = 1
403403
requires-python = ">=3.11, <3.13"
404404
resolution-markers = [
405-
"python_full_version < '3.12'",
406405
"python_full_version >= '3.12'",
406+
"python_full_version < '3.12'",
407407
]
408408
409409
[options]
@@ -563,8 +563,8 @@ fn branching_between_registry_and_direct_url() -> Result<()> {
563563
version = 1
564564
requires-python = ">=3.11, <3.13"
565565
resolution-markers = [
566-
"python_full_version < '3.12'",
567566
"python_full_version >= '3.12'",
567+
"python_full_version < '3.12'",
568568
]
569569
570570
[options]
@@ -648,8 +648,8 @@ fn branching_urls_of_different_sources_disjoint() -> Result<()> {
648648
version = 1
649649
requires-python = ">=3.11, <3.13"
650650
resolution-markers = [
651-
"python_full_version < '3.12'",
652651
"python_full_version >= '3.12'",
652+
"python_full_version < '3.12'",
653653
]
654654
655655
[options]

crates/uv/tests/it/lock.rs

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1860,9 +1860,9 @@ fn lock_conditional_dependency_extra() -> Result<()> {
18601860
version = 1
18611861
requires-python = ">=3.7"
18621862
resolution-markers = [
1863+
"python_full_version >= '3.10'",
18631864
"python_full_version >= '3.8' and python_full_version < '3.10'",
18641865
"python_full_version < '3.8'",
1865-
"python_full_version >= '3.10'",
18661866
]
18671867

18681868
[options]
@@ -2045,8 +2045,8 @@ fn lock_conditional_dependency_extra() -> Result<()> {
20452045
version = "2.2.1"
20462046
source = { registry = "https://pypi.org/simple" }
20472047
resolution-markers = [
2048-
"python_full_version >= '3.8' and python_full_version < '3.10'",
20492048
"python_full_version >= '3.10'",
2049+
"python_full_version >= '3.8' and python_full_version < '3.10'",
20502050
]
20512051
sdist = { url = "https://files.pythonhosted.org/packages/7a/50/7fd50a27caa0652cd4caf224aa87741ea41d3265ad13f010886167cfcc79/urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19", size = 291020 }
20522052
wheels = [
@@ -2961,8 +2961,8 @@ fn lock_partial_git() -> Result<()> {
29612961
version = 1
29622962
requires-python = ">=3.10"
29632963
resolution-markers = [
2964-
"python_full_version < '3.12'",
29652964
"python_full_version >= '3.12'",
2965+
"python_full_version < '3.12'",
29662966
]
29672967

29682968
[options]
@@ -5143,10 +5143,10 @@ fn lock_python_version_marker_complement() -> Result<()> {
51435143
version = 1
51445144
requires-python = ">=3.8"
51455145
resolution-markers = [
5146-
"python_full_version < '3.10'",
5147-
"python_full_version == '3.10'",
5148-
"python_full_version > '3.10' and python_full_version < '3.11'",
51495146
"python_full_version >= '3.11'",
5147+
"python_full_version > '3.10' and python_full_version < '3.11'",
5148+
"python_full_version == '3.10'",
5149+
"python_full_version < '3.10'",
51505150
]
51515151

51525152
[options]
@@ -12481,9 +12481,9 @@ fn lock_narrowed_python_version() -> Result<()> {
1248112481
version = 1
1248212482
requires-python = ">=3.7"
1248312483
resolution-markers = [
12484-
"python_full_version < '3.9'",
12485-
"python_full_version >= '3.9' and python_full_version < '3.11'",
1248612484
"python_full_version >= '3.11'",
12485+
"python_full_version >= '3.9' and python_full_version < '3.11'",
12486+
"python_full_version < '3.9'",
1248712487
]
1248812488

1248912489
[options]
@@ -13216,8 +13216,8 @@ fn lock_non_project_fork() -> Result<()> {
1321613216
version = 1
1321713217
requires-python = ">=3.10"
1321813218
resolution-markers = [
13219-
"python_full_version < '3.11'",
1322013219
"python_full_version >= '3.11'",
13220+
"python_full_version < '3.11'",
1322113221
]
1322213222

1322313223
[options]
@@ -15752,9 +15752,9 @@ fn lock_python_upper_bound() -> Result<()> {
1575215752
version = 1
1575315753
requires-python = ">=3.8"
1575415754
resolution-markers = [
15755-
"python_full_version >= '3.13'",
15756-
"python_full_version < '3.9'",
1575715755
"python_full_version >= '3.9' and python_full_version < '3.13'",
15756+
"python_full_version < '3.9'",
15757+
"python_full_version >= '3.13'",
1575815758
]
1575915759

1576015760
[options]
@@ -16983,8 +16983,8 @@ fn lock_change_requires_python() -> Result<()> {
1698316983
version = 1
1698416984
requires-python = ">=3.12"
1698516985
resolution-markers = [
16986-
"python_full_version < '3.13'",
1698716986
"python_full_version >= '3.13'",
16987+
"python_full_version < '3.13'",
1698816988
]
1698916989

1699016990
[options]
@@ -17092,9 +17092,9 @@ fn lock_change_requires_python() -> Result<()> {
1709217092
version = 1
1709317093
requires-python = ">=3.10"
1709417094
resolution-markers = [
17095-
"python_full_version < '3.12'",
17096-
"python_full_version == '3.12.*'",
1709717095
"python_full_version >= '3.13'",
17096+
"python_full_version == '3.12.*'",
17097+
"python_full_version < '3.12'",
1709817098
]
1709917099

1710017100
[options]

crates/uv/tests/it/lock_scenarios.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,9 +1047,9 @@ fn fork_incomplete_markers() -> Result<()> {
10471047
version = 1
10481048
requires-python = ">=3.8"
10491049
resolution-markers = [
1050-
"python_full_version < '3.10'",
1051-
"python_full_version == '3.10.*'",
10521050
"python_full_version >= '3.11'",
1051+
"python_full_version == '3.10.*'",
1052+
"python_full_version < '3.10'",
10531053
]
10541054
10551055
[[package]]
@@ -3117,9 +3117,9 @@ fn fork_overlapping_markers_basic() -> Result<()> {
31173117
version = 1
31183118
requires-python = ">=3.8"
31193119
resolution-markers = [
3120-
"python_full_version < '3.10'",
3121-
"python_full_version == '3.10.*'",
31223120
"python_full_version >= '3.11'",
3121+
"python_full_version == '3.10.*'",
3122+
"python_full_version < '3.10'",
31233123
]
31243124
31253125
[[package]]

0 commit comments

Comments
 (0)