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 https://github.com/astral-sh/uv/issues/9998.
This commit is contained in:
Charlie Marsh 2024-12-18 16:54:56 -05:00 committed by GitHub
parent 8f88f98350
commit 4d3c1b325e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 487 additions and 134 deletions

View file

@ -624,7 +624,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
}
}
ForkedDependencies::Forked {
forks,
mut forks,
diverging_packages,
} => {
debug!(
@ -633,6 +633,28 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
start.elapsed().as_secs_f32()
);
// Prioritize the forks.
match (self.options.fork_strategy, self.options.resolution_mode) {
(ForkStrategy::Fewest, _) | (_, ResolutionMode::Lowest) => {
// Prefer solving forks with lower Python bounds, since they're more
// likely to produce solutions that work for forks with higher
// Python bounds (whereas the inverse is not true).
forks.sort_by(|a, b| {
a.cmp_requires_python(b)
.reverse()
.then_with(|| a.cmp_upper_bounds(b))
});
}
(ForkStrategy::RequiresPython, _) => {
// Otherwise, prefer solving forks with higher Python bounds, since
// we want to prioritize choosing the latest-compatible package
// version for each Python version.
forks.sort_by(|a, b| {
a.cmp_requires_python(b).then_with(|| a.cmp_upper_bounds(b))
});
}
}
for new_fork_state in self.forks_to_fork_states(
state,
&version,
@ -2907,11 +2929,6 @@ impl Dependencies {
} else if forks.len() == 1 {
ForkedDependencies::Unforked(forks.pop().unwrap().dependencies)
} else {
// Prioritize the forks. Prefer solving forks with lower Python
// bounds, since they're more likely to produce solutions that work
// for forks with higher Python bounds (whereas the inverse is not
// true).
forks.sort();
ForkedDependencies::Forked {
forks,
diverging_packages: diverging_packages.into_iter().collect(),
@ -3224,6 +3241,50 @@ impl Fork {
});
Some(self)
}
/// Compare forks, preferring forks with g `requires-python` requirements.
fn cmp_requires_python(&self, other: &Self) -> Ordering {
// A higher `requires-python` requirement indicates a _higher-priority_ fork.
//
// This ordering ensures that we prefer choosing the highest version for each fork based on
// its `requires-python` requirement.
//
// The reverse would prefer choosing fewer versions, at the cost of using older package
// versions on newer Python versions. For example, if reversed, we'd prefer to solve `<3.7
// before solving `>=3.7`, since the resolution produced by the former might work for the
// latter, but the inverse is unlikely to be true.
let self_bound = self.env.requires_python().unwrap_or_default();
let other_bound = other.env.requires_python().unwrap_or_default();
self_bound.lower().cmp(other_bound.lower())
}
/// Compare forks, preferring forks with upper bounds.
fn cmp_upper_bounds(&self, other: &Self) -> Ordering {
// We'd prefer to solve `numpy <= 2` before solving `numpy >= 1`, since the resolution
// produced by the former might work for the latter, but the inverse is unlikely to be true
// due to maximum version selection. (Selecting `numpy==2.0.0` would satisfy both forks, but
// selecting the latest `numpy` would not.)
let self_upper_bounds = self
.dependencies
.iter()
.filter(|dep| {
dep.version
.bounding_range()
.is_some_and(|(_, upper)| !matches!(upper, Bound::Unbounded))
})
.count();
let other_upper_bounds = other
.dependencies
.iter()
.filter(|dep| {
dep.version
.bounding_range()
.is_some_and(|(_, upper)| !matches!(upper, Bound::Unbounded))
})
.count();
self_upper_bounds.cmp(&other_upper_bounds)
}
}
impl Eq for Fork {}
@ -3234,50 +3295,6 @@ impl PartialEq for Fork {
}
}
impl Ord for Fork {
fn cmp(&self, other: &Self) -> Ordering {
// A higher `requires-python` requirement indicates a _lower-priority_ fork. We'd prefer
// to solve `<3.7` before solving `>=3.7`, since the resolution produced by the former might
// work for the latter, but the inverse is unlikely to be true.
let self_bound = self.env.requires_python().unwrap_or_default();
let other_bound = other.env.requires_python().unwrap_or_default();
other_bound.lower().cmp(self_bound.lower()).then_with(|| {
// If there's no difference, prioritize forks with upper bounds. We'd prefer to solve
// `numpy <= 2` before solving `numpy >= 1`, since the resolution produced by the former
// might work for the latter, but the inverse is unlikely to be true due to maximum
// version selection. (Selecting `numpy==2.0.0` would satisfy both forks, but selecting
// the latest `numpy` would not.)
let self_upper_bounds = self
.dependencies
.iter()
.filter(|dep| {
dep.version
.bounding_range()
.is_some_and(|(_, upper)| !matches!(upper, Bound::Unbounded))
})
.count();
let other_upper_bounds = other
.dependencies
.iter()
.filter(|dep| {
dep.version
.bounding_range()
.is_some_and(|(_, upper)| !matches!(upper, Bound::Unbounded))
})
.count();
self_upper_bounds.cmp(&other_upper_bounds)
})
}
}
impl PartialOrd for Fork {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
/// Returns an error if a conflicting extra is found in the given requirements.
///
/// Specifically, if there is any conflicting extra (just one is enough) that