Always use release-only comparisons for requires-python (#4794)

## Summary

There are a few ideas at play here:

1. pip always strips versions to the release when evaluating against a
`Requires-Python`, so we now do the same. That means, e.g., using
`3.13.0b0` will be accepted by a project with `Requires-Python: >=
3.13`, which does _not_ adhere to PEP 440 semantics but is somewhat
intuitive.
2. Because we know we'll only be evaluating against release-only
versions, we can use different semantics in PubGrub that let us collapse
ranges. For example, `python_version >= '3.10' or python_version <
'3.10'` can be collapsed to the truthy marker.

Closes https://github.com/astral-sh/uv/issues/4714.
Closes https://github.com/astral-sh/uv/issues/4272.
Closes https://github.com/astral-sh/uv/issues/4719.
This commit is contained in:
Charlie Marsh 2024-07-04 16:06:52 -04:00 committed by GitHub
parent 11cb0059c1
commit b588054dfb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 401 additions and 171 deletions

View file

@ -86,10 +86,25 @@ fn string_is_disjoint(this: &MarkerExpression, other: &MarkerExpression) -> bool
pub(crate) fn requires_python_marker(tree: &MarkerTree) -> Option<RequiresPythonBound> {
match tree {
MarkerTree::Expression(MarkerExpression::Version {
key: MarkerValueVersion::PythonFullVersion | MarkerValueVersion::PythonVersion,
key: MarkerValueVersion::PythonFullVersion,
specifier,
}) => {
let specifier = PubGrubSpecifier::try_from(specifier).ok()?;
// Simplify using PEP 440 semantics.
let specifier = PubGrubSpecifier::from_pep440_specifier(specifier).ok()?;
// Convert to PubGrub range and perform a union.
let range = PubGrubRange::from(specifier);
let (lower, _) = range.iter().next()?;
// Extract the lower bound.
Some(RequiresPythonBound::new(lower.clone()))
}
MarkerTree::Expression(MarkerExpression::Version {
key: MarkerValueVersion::PythonVersion,
specifier,
}) => {
// Simplify using release-only semantics, since `python_version` is always `major.minor`.
let specifier = PubGrubSpecifier::from_release_specifier(specifier).ok()?;
// Convert to PubGrub range and perform a union.
let range = PubGrubRange::from(specifier);
@ -348,7 +363,26 @@ fn keyed_range(expr: &MarkerExpression) -> Option<(&MarkerValueVersion, PubGrubR
_ => return None,
};
let pubgrub_specifier = PubGrubSpecifier::try_from(&specifier).ok()?;
// Simplify using either PEP 440 or release-only semantics.
let pubgrub_specifier = match expr {
MarkerExpression::Version {
key: MarkerValueVersion::PythonVersion,
..
} => PubGrubSpecifier::from_release_specifier(&specifier).ok()?,
MarkerExpression::Version {
key: MarkerValueVersion::PythonFullVersion,
..
} => PubGrubSpecifier::from_pep440_specifier(&specifier).ok()?,
MarkerExpression::VersionInverted {
key: MarkerValueVersion::PythonVersion,
..
} => PubGrubSpecifier::from_release_specifier(&specifier).ok()?,
MarkerExpression::VersionInverted {
key: MarkerValueVersion::PythonFullVersion,
..
} => PubGrubSpecifier::from_pep440_specifier(&specifier).ok()?,
_ => return None,
};
Some((key, pubgrub_specifier.into()))
}
@ -403,7 +437,7 @@ mod tests {
// a quirk of how pubgrub works, but this is considered part of normalization
assert_marker_equal(
"python_version > '3.17.post4' or python_version > '3.18.post4'",
"python_version >= '3.17.post5'",
"python_version > '3.17'",
);
assert_marker_equal(

View file

@ -179,13 +179,15 @@ impl PubGrubRequirement {
.map(|specifier| {
Locals::map(expected, specifier)
.map_err(ResolveError::InvalidVersion)
.and_then(|specifier| Ok(PubGrubSpecifier::try_from(&specifier)?))
.and_then(|specifier| {
Ok(PubGrubSpecifier::from_pep440_specifier(&specifier)?)
})
})
.fold_ok(Range::full(), |range, specifier| {
range.intersection(&specifier.into())
})?
} else {
PubGrubSpecifier::try_from(specifier)?.into()
PubGrubSpecifier::from_pep440_specifiers(specifier)?.into()
};
let requirement = Self {

View file

@ -35,26 +35,26 @@ impl From<PubGrubSpecifier> for Range<Version> {
}
}
impl TryFrom<&VersionSpecifiers> for PubGrubSpecifier {
type Error = PubGrubSpecifierError;
/// Convert a PEP 440 specifier to a PubGrub-compatible version range.
fn try_from(specifiers: &VersionSpecifiers) -> Result<Self, PubGrubSpecifierError> {
impl PubGrubSpecifier {
/// Convert [`VersionSpecifiers`] to a PubGrub-compatible version range, using PEP 440
/// semantics.
pub(crate) fn from_pep440_specifiers(
specifiers: &VersionSpecifiers,
) -> Result<Self, PubGrubSpecifierError> {
let range = specifiers
.iter()
.map(crate::pubgrub::PubGrubSpecifier::try_from)
.map(Self::from_pep440_specifier)
.fold_ok(Range::full(), |range, specifier| {
range.intersection(&specifier.into())
})?;
Ok(Self(range))
}
}
impl TryFrom<&VersionSpecifier> for PubGrubSpecifier {
type Error = PubGrubSpecifierError;
/// Convert a PEP 440 specifier to a PubGrub-compatible version range.
fn try_from(specifier: &VersionSpecifier) -> Result<Self, PubGrubSpecifierError> {
/// Convert the [`VersionSpecifier`] to a PubGrub-compatible version range, using PEP 440
/// semantics.
pub(crate) fn from_pep440_specifier(
specifier: &VersionSpecifier,
) -> Result<Self, PubGrubSpecifierError> {
let ranges = match specifier.operator() {
Operator::Equal => {
let version = specifier.version().clone();
@ -147,4 +147,104 @@ impl TryFrom<&VersionSpecifier> for PubGrubSpecifier {
Ok(Self(ranges))
}
/// Convert the [`VersionSpecifiers`] to a PubGrub-compatible version range, using release-only
/// semantics.
///
/// Assumes that the range will only be tested against versions that consist solely of release
/// segments (e.g., `3.12.0`, but not `3.12.0b1`).
///
/// These semantics are used for testing Python compatibility (e.g., `requires-python` against
/// the user's installed Python version). In that context, it's more intuitive that `3.13.0b0`
/// is allowed for projects that declare `requires-python = ">3.13"`.
///
/// See: <https://github.com/pypa/pip/blob/a432c7f4170b9ef798a15f035f5dfdb4cc939f35/src/pip/_internal/resolution/resolvelib/candidates.py#L540>
pub(crate) fn from_release_specifiers(
specifiers: &VersionSpecifiers,
) -> Result<Self, PubGrubSpecifierError> {
let range = specifiers
.iter()
.map(Self::from_release_specifier)
.fold_ok(Range::full(), |range, specifier| {
range.intersection(&specifier.into())
})?;
Ok(Self(range))
}
/// Convert the [`VersionSpecifier`] to a PubGrub-compatible version range, using release-only
/// semantics.
///
/// Assumes that the range will only be tested against versions that consist solely of release
/// segments (e.g., `3.12.0`, but not `3.12.0b1`).
///
/// These semantics are used for testing Python compatibility (e.g., `requires-python` against
/// the user's installed Python version). In that context, it's more intuitive that `3.13.0b0`
/// is allowed for projects that declare `requires-python = ">3.13"`.
///
/// See: <https://github.com/pypa/pip/blob/a432c7f4170b9ef798a15f035f5dfdb4cc939f35/src/pip/_internal/resolution/resolvelib/candidates.py#L540>
pub(crate) fn from_release_specifier(
specifier: &VersionSpecifier,
) -> Result<Self, PubGrubSpecifierError> {
let ranges = match specifier.operator() {
Operator::Equal => {
let version = specifier.version().only_release();
Range::singleton(version)
}
Operator::ExactEqual => {
let version = specifier.version().only_release();
Range::singleton(version)
}
Operator::NotEqual => {
let version = specifier.version().only_release();
Range::singleton(version).complement()
}
Operator::TildeEqual => {
let [rest @ .., last, _] = specifier.version().release() else {
return Err(PubGrubSpecifierError::InvalidTildeEquals(specifier.clone()));
};
let upper = Version::new(rest.iter().chain([&(last + 1)]));
let version = specifier.version().only_release();
Range::from_range_bounds(version..upper)
}
Operator::LessThan => {
let version = specifier.version().only_release();
Range::strictly_lower_than(version)
}
Operator::LessThanEqual => {
let version = specifier.version().only_release();
Range::lower_than(version)
}
Operator::GreaterThan => {
let version = specifier.version().only_release();
Range::strictly_higher_than(version)
}
Operator::GreaterThanEqual => {
let version = specifier.version().only_release();
Range::higher_than(version)
}
Operator::EqualStar => {
let low = specifier.version().only_release();
let high = {
let mut high = low.clone();
let mut release = high.release().to_vec();
*release.last_mut().unwrap() += 1;
high = high.with_release(release);
high
};
Range::from_range_bounds(low..high)
}
Operator::NotEqualStar => {
let low = specifier.version().only_release();
let high = {
let mut high = low.clone();
let mut release = high.release().to_vec();
*release.last_mut().unwrap() += 1;
high = high.with_release(release);
high
};
Range::from_range_bounds(low..high).complement()
}
};
Ok(Self(ranges))
}
}

View file

@ -1,5 +1,5 @@
use pep440_rs::VersionSpecifiers;
use pep508_rs::{MarkerTree, StringVersion};
use pep440_rs::{Version, VersionSpecifiers};
use pep508_rs::MarkerTree;
use uv_python::{Interpreter, PythonVersion};
use crate::{RequiresPython, RequiresPythonBound};
@ -7,7 +7,7 @@ use crate::{RequiresPython, RequiresPythonBound};
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct PythonRequirement {
/// The installed version of Python.
installed: StringVersion,
installed: Version,
/// The target version of Python; that is, the version of Python for which we are resolving
/// dependencies. This is typically the same as the installed version, but may be different
/// when specifying an alternate Python version for the resolution.
@ -21,11 +21,10 @@ impl PythonRequirement {
/// [`PythonVersion`].
pub fn from_python_version(interpreter: &Interpreter, python_version: &PythonVersion) -> Self {
Self {
installed: interpreter.python_full_version().clone(),
target: Some(PythonTarget::Version(StringVersion {
string: python_version.to_string(),
version: python_version.python_full_version(),
})),
installed: interpreter.python_full_version().version.only_release(),
target: Some(PythonTarget::Version(
python_version.python_full_version().only_release(),
)),
}
}
@ -36,7 +35,7 @@ impl PythonRequirement {
requires_python: &RequiresPython,
) -> Self {
Self {
installed: interpreter.python_full_version().clone(),
installed: interpreter.python_full_version().version.only_release(),
target: Some(PythonTarget::RequiresPython(requires_python.clone())),
}
}
@ -44,7 +43,7 @@ impl PythonRequirement {
/// Create a [`PythonRequirement`] to resolve against an [`Interpreter`].
pub fn from_interpreter(interpreter: &Interpreter) -> Self {
Self {
installed: interpreter.python_full_version().clone(),
installed: interpreter.python_full_version().version.only_release(),
target: None,
}
}
@ -63,7 +62,7 @@ impl PythonRequirement {
}
/// Return the installed version of Python.
pub fn installed(&self) -> &StringVersion {
pub fn installed(&self) -> &Version {
&self.installed
}
@ -91,7 +90,7 @@ pub enum PythonTarget {
///
/// The use of a separate enum variant allows us to use a verbatim representation when reporting
/// back to the user.
Version(StringVersion),
Version(Version),
/// The [`PythonTarget`] specifier is a set of version specifiers, as extracted from the
/// `Requires-Python` field in a `pyproject.toml` or `METADATA` file.
RequiresPython(RequiresPython),

View file

@ -38,7 +38,8 @@ pub struct RequiresPython {
impl RequiresPython {
/// Returns a [`RequiresPython`] to express `>=` equality with the given version.
pub fn greater_than_equal_version(version: Version) -> Self {
pub fn greater_than_equal_version(version: &Version) -> Self {
let version = version.only_release();
Self {
specifiers: VersionSpecifiers::from(VersionSpecifier::greater_than_equal_version(
version.clone(),
@ -56,7 +57,7 @@ impl RequiresPython {
// Convert to PubGrub range and perform a union.
let range = specifiers
.into_iter()
.map(crate::pubgrub::PubGrubSpecifier::try_from)
.map(crate::pubgrub::PubGrubSpecifier::from_release_specifiers)
.fold_ok(None, |range: Option<Range<Version>>, requires_python| {
if let Some(range) = range {
Some(range.union(&requires_python.into()))
@ -99,7 +100,8 @@ impl RequiresPython {
/// Returns `true` if the `Requires-Python` is compatible with the given version.
pub fn contains(&self, version: &Version) -> bool {
self.specifiers.contains(version)
let version = version.only_release();
self.specifiers.contains(&version)
}
/// Returns `true` if the `Requires-Python` is compatible with the given version specifiers.
@ -109,7 +111,7 @@ impl RequiresPython {
/// provided range. However, `>=3.9` would not be considered compatible, as the
/// `Requires-Python` includes Python 3.8, but `>=3.9` does not.
pub fn is_contained_by(&self, target: &VersionSpecifiers) -> bool {
let Ok(target) = crate::pubgrub::PubGrubSpecifier::try_from(target) else {
let Ok(target) = crate::pubgrub::PubGrubSpecifier::from_release_specifiers(target) else {
return false;
};
let target = target
@ -122,57 +124,18 @@ impl RequiresPython {
// `>=3.7`.
//
// That is: `version_lower` should be less than or equal to `requires_python_lower`.
//
// When comparing, we also limit the comparison to the release segment, ignoring
// pre-releases and such. This may or may not be correct.
//
// Imagine `target_lower` is `3.13.0b1`, and `requires_python_lower` is `3.13`.
// That would be fine, since we're saying we support `3.13.0` and later, and `target_lower`
// supports more than that.
//
// Next, imagine `requires_python_lower` is `3.13.0b1`, and `target_lower` is `3.13`.
// Technically, that would _not_ be fine, since we're saying we support `3.13.0b1` and
// later, but `target_lower` does not support that. For example, `target_lower` does not
// support `3.13.0b1`, `3.13.0rc1`, etc.
//
// In practice, this is most relevant for cases like: `requires_python = "==3.8.*"`, with
// `target = ">=3.8"`. In this case, `requires_python_lower` is actually `3.8.0.dev0`,
// because `==3.8.*` allows development and pre-release versions. So there are versions we
// want to support that aren't explicitly supported by `target`, which does _not_ include
// pre-releases.
//
// Since this is a fairly common `Requires-Python` specifier, we handle it pragmatically
// by only enforcing Python compatibility at the patch-release level.
//
// There are some potentially-bad outcomes here. For example, maybe the user _did_ request
// `>=3.13.0b1`. In that case, maybe we _shouldn't_ allow resolution that only support
// `3.13.0` and later, because we're saying we support the beta releases, but the dependency
// does not. But, it's debatable.
//
// If this scheme proves problematic, we could explore using different semantics when
// converting to PubGrub. For example, we could parse `==3.8.*` as `>=3.8,<3.9`. But this
// too could be problematic. Imagine that the user requests `>=3.8.0b0`, and the target
// declares `==3.8.*`. In this case, we _do_ want to allow resolution, because the target
// is saying it supports all versions of `3.8`, including pre-releases. But under those
// modified parsing semantics, we would fail. (You could argue, though, that users declaring
// `==3.8.*` are not intending to support pre-releases, and so failing there is fine, but
// it's also incorrect in its own way.)
//
// Alternatively, we could vary the semantics depending on whether or not the user included
// a pre-release in their specifier, enforcing pre-release compatibility only if the user
// explicitly requested it.
match (target, self.bound.as_ref()) {
(Bound::Included(target_lower), Bound::Included(requires_python_lower)) => {
target_lower.release() <= requires_python_lower.release()
target_lower <= requires_python_lower
}
(Bound::Excluded(target_lower), Bound::Included(requires_python_lower)) => {
target_lower.release() < requires_python_lower.release()
target_lower < requires_python_lower
}
(Bound::Included(target_lower), Bound::Excluded(requires_python_lower)) => {
target_lower.release() <= requires_python_lower.release()
target_lower <= requires_python_lower
}
(Bound::Excluded(target_lower), Bound::Excluded(requires_python_lower)) => {
target_lower.release() < requires_python_lower.release()
target_lower < requires_python_lower
}
// If the dependency has no lower bound, then it supports all versions.
(Bound::Unbounded, _) => true,
@ -267,7 +230,7 @@ impl<'de> serde::Deserialize<'de> for RequiresPython {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let specifiers = VersionSpecifiers::deserialize(deserializer)?;
let bound = RequiresPythonBound(
crate::pubgrub::PubGrubSpecifier::try_from(&specifiers)
crate::pubgrub::PubGrubSpecifier::from_release_specifiers(&specifiers)
.map_err(serde::de::Error::custom)?
.iter()
.next()
@ -283,7 +246,11 @@ pub struct RequiresPythonBound(Bound<Version>);
impl RequiresPythonBound {
pub fn new(bound: Bound<Version>) -> Self {
Self(bound)
Self(match bound {
Bound::Included(version) => Bound::Included(version.only_release()),
Bound::Excluded(version) => Bound::Excluded(version.only_release()),
Bound::Unbounded => Bound::Unbounded,
})
}
}

View file

@ -856,7 +856,6 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
}
// The version is incompatible due to its Python requirement.
// STOPSHIP(charlie): Merge markers into `python_requirement`.
if let Some(requires_python) = metadata.requires_python.as_ref() {
if let Some(target) = python_requirement.target() {
if !target.is_compatible_with(requires_python) {
@ -1934,14 +1933,14 @@ impl ForkState {
) -> Result<(), ResolveError> {
// Incompatible requires-python versions are special in that we track
// them as incompatible dependencies instead of marking the package version
// as unavailable directly
// as unavailable directly.
if let UnavailableVersion::IncompatibleDist(
IncompatibleDist::Source(IncompatibleSource::RequiresPython(requires_python, kind))
| IncompatibleDist::Wheel(IncompatibleWheel::RequiresPython(requires_python, kind)),
) = reason
{
let python_version: Range<Version> =
PubGrubSpecifier::try_from(&requires_python)?.into();
PubGrubSpecifier::from_release_specifiers(&requires_python)?.into();
let package = &self.next;
self.pubgrub