Match wheel tags against Requires-Python major-minor (#5289)

## Summary

Given `Requires-Python: ">=3.12.3"`, we were rejecting wheels like
`dearpygui-1.11.1-cp312-cp312-win_amd64.whl`, since `3.12.0` is not
included in `>=3.12.3`. We instead need to test against the major-minor
version of `Requires-Python`.

The easiest way to do this, I think, is the use the `RequiresPython`
struct, which has a single bound that we can truncate the major-minor.
This also means that we now allow
`dearpygui-1.11.1-cp312-cp312-win_amd64.whl` for specifiers like
`Requires-Python: "==3.10.*"`. This is incorrect on the surface, but it
does match our semantics for `Requires-Python` elsewhere: we treat it as
a lower bound.

Closes https://github.com/astral-sh/uv/issues/5287.
This commit is contained in:
Charlie Marsh 2024-07-22 10:33:53 -04:00 committed by GitHub
parent 8f26f379d1
commit b6f470416e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 183 additions and 113 deletions

View file

@ -5,6 +5,7 @@ use std::ops::Deref;
use itertools::Itertools;
use pubgrub::range::Range;
use distribution_filename::WheelFilename;
use pep440_rs::{Operator, Version, VersionSpecifier, VersionSpecifiers};
use pep508_rs::{MarkerExpression, MarkerTree, MarkerValueVersion};
@ -160,6 +161,23 @@ impl RequiresPython {
&self.bound
}
/// Returns the [`RequiresPythonBound`] truncated to the major and minor version.
pub fn bound_major_minor(&self) -> RequiresPythonBound {
match self.bound.as_ref() {
// Ex) `>=3.10.1` -> `>=3.10`
Bound::Included(version) => RequiresPythonBound(Bound::Included(Version::new(
version.release().iter().take(2),
))),
// Ex) `>3.10.1` -> `>=3.10`
// This is unintuitive, but `>3.10.1` does indicate that _some_ version of Python 3.10
// is supported.
Bound::Excluded(version) => RequiresPythonBound(Bound::Included(Version::new(
version.release().iter().take(2),
))),
Bound::Unbounded => RequiresPythonBound(Bound::Unbounded),
}
}
/// Returns this `Requires-Python` specifier as an equivalent marker
/// expression utilizing the `python_version` marker field.
///
@ -217,6 +235,74 @@ impl RequiresPython {
MarkerTree::Expression(expr_python_full_version),
])
}
/// Returns `false` if the wheel's tags state it can't be used in the given Python version
/// range.
///
/// It is meant to filter out clearly unusable wheels with perfect specificity and acceptable
/// sensitivity, we return `true` if the tags are unknown.
pub fn matches_wheel_tag(&self, wheel: &WheelFilename) -> bool {
wheel.abi_tag.iter().any(|abi_tag| {
if abi_tag == "abi3" {
// Universal tags are allowed.
true
} else if abi_tag == "none" {
wheel.python_tag.iter().any(|python_tag| {
// Remove `py2-none-any` and `py27-none-any`.
if python_tag.starts_with("py2") {
return false;
}
// Remove (e.g.) `cp36-none-any` if the specifier is `==3.10.*`.
let Some(minor) = python_tag
.strip_prefix("cp3")
.or_else(|| python_tag.strip_prefix("pp3"))
.or_else(|| python_tag.strip_prefix("py3"))
else {
return true;
};
let Ok(minor) = minor.parse::<u64>() else {
return true;
};
// Ex) If the wheel bound is `3.6`, then it doesn't match `>=3.10`.
let wheel_bound =
RequiresPythonBound(Bound::Included(Version::new([3, minor])));
wheel_bound >= self.bound_major_minor()
})
} else if abi_tag.starts_with("cp2") || abi_tag.starts_with("pypy2") {
// Python 2 is never allowed.
false
} else if let Some(minor_no_dot_abi) = abi_tag.strip_prefix("cp3") {
// Remove ABI tags, both old (dmu) and future (t, and all other letters).
let minor_not_dot = minor_no_dot_abi.trim_matches(char::is_alphabetic);
let Ok(minor) = minor_not_dot.parse::<u64>() else {
// Unknown version pattern are allowed.
return true;
};
let wheel_bound = RequiresPythonBound(Bound::Included(Version::new([3, minor])));
wheel_bound >= self.bound_major_minor()
} else if let Some(minor_no_dot_abi) = abi_tag.strip_prefix("pypy3") {
// Given `pypy39_pp73`, we just removed `pypy3`, now we remove `_pp73` ...
let Some((minor_not_dot, _)) = minor_no_dot_abi.split_once('_') else {
// Unknown version pattern are allowed.
return true;
};
// ... and get `9`.
let Ok(minor) = minor_not_dot.parse::<u64>() else {
// Unknown version pattern are allowed.
return true;
};
let wheel_bound = RequiresPythonBound(Bound::Included(Version::new([3, minor])));
wheel_bound >= self.bound_major_minor()
} else {
// Unknown python tag -> allowed.
true
}
})
}
}
impl std::fmt::Display for RequiresPython {
@ -295,3 +381,81 @@ impl Ord for RequiresPythonBound {
}
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use distribution_filename::WheelFilename;
use pep440_rs::VersionSpecifiers;
use crate::RequiresPython;
#[test]
fn requires_python_included() {
let version_specifiers = VersionSpecifiers::from_str("==3.10.*").unwrap();
let requires_python = RequiresPython::union(std::iter::once(&version_specifiers))
.unwrap()
.unwrap();
let wheel_names = &[
"bcrypt-4.1.3-cp37-abi3-macosx_10_12_universal2.whl",
"black-24.4.2-cp310-cp310-win_amd64.whl",
"black-24.4.2-cp310-none-win_amd64.whl",
"cbor2-5.6.4-py3-none-any.whl",
"watchfiles-0.22.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl",
"dearpygui-1.11.1-cp312-cp312-win_amd64.whl",
];
for wheel_name in wheel_names {
assert!(
requires_python.matches_wheel_tag(&WheelFilename::from_str(wheel_name).unwrap()),
"{wheel_name}"
);
}
let version_specifiers = VersionSpecifiers::from_str(">=3.12.3").unwrap();
let requires_python = RequiresPython::union(std::iter::once(&version_specifiers))
.unwrap()
.unwrap();
let wheel_names = &["dearpygui-1.11.1-cp312-cp312-win_amd64.whl"];
for wheel_name in wheel_names {
assert!(
requires_python.matches_wheel_tag(&WheelFilename::from_str(wheel_name).unwrap()),
"{wheel_name}"
);
}
}
#[test]
fn requires_python_dropped() {
let version_specifiers = VersionSpecifiers::from_str("==3.10.*").unwrap();
let requires_python = RequiresPython::union(std::iter::once(&version_specifiers))
.unwrap()
.unwrap();
let wheel_names = &[
"PySocks-1.7.1-py27-none-any.whl",
"black-24.4.2-cp39-cp39-win_amd64.whl",
"psutil-6.0.0-cp36-cp36m-win32.whl",
"pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl",
"torch-1.10.0-cp36-none-macosx_10_9_x86_64.whl",
"torch-1.10.0-py36-none-macosx_10_9_x86_64.whl",
];
for wheel_name in wheel_names {
assert!(
!requires_python.matches_wheel_tag(&WheelFilename::from_str(wheel_name).unwrap()),
"{wheel_name}"
);
}
let version_specifiers = VersionSpecifiers::from_str(">=3.12.3").unwrap();
let requires_python = RequiresPython::union(std::iter::once(&version_specifiers))
.unwrap()
.unwrap();
let wheel_names = &["dearpygui-1.11.1-cp310-cp310-win_amd64.whl"];
for wheel_name in wheel_names {
assert!(
!requires_python.matches_wheel_tag(&WheelFilename::from_str(wheel_name).unwrap()),
"{wheel_name}"
);
}
}
}