mirror of
https://github.com/astral-sh/uv.git
synced 2025-11-02 04:48:18 +00:00
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:
parent
11cb0059c1
commit
b588054dfb
13 changed files with 401 additions and 171 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue