Use structured wheel tags everywhere (#10542)

## Summary

This PR extends the thinking in #10525 to platform tags, and then uses
the structured tag enums everywhere, rather than passing around strings.
I think this is a big improvement! It means we're no longer doing ad hoc
tag parsing all over the place.
This commit is contained in:
Charlie Marsh 2025-01-13 20:39:39 -05:00 committed by GitHub
parent 2ffa31946d
commit 5c91217488
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1624 additions and 487 deletions

View file

@ -265,18 +265,6 @@ impl Lock {
.retain(|wheel| requires_python.matches_wheel_tag(&wheel.filename));
// Filter by platform tags.
// See https://github.com/pypi/warehouse/blob/ccff64920db7965078cf1fdb50f028e640328887/warehouse/forklift/legacy.py#L100-L169
// for a list of relevant platforms.
let linux_tags = [
"manylinux1_",
"manylinux2010_",
"manylinux2014_",
"musllinux_",
"manylinux_",
];
let windows_tags = ["win32", "win_arm64", "win_amd64", "win_ia64"];
locked_dist.wheels.retain(|wheel| {
// Naively, we'd check whether `platform_system == 'Linux'` is disjoint, or
// `os_name == 'posix'` is disjoint, or `sys_platform == 'linux'` is disjoint (each on its
@ -285,21 +273,23 @@ impl Lock {
// a single disjointness check with the intersection is sufficient, so we have one
// constant per platform.
let platform_tags = &wheel.filename.platform_tag;
if platform_tags.iter().all(|tag| {
linux_tags.into_iter().any(|linux_tag| {
// These two linux tags are allowed by warehouse.
tag.starts_with(linux_tag) || tag == "linux_armv6l" || tag == "linux_armv7l"
})
}) {
if platform_tags
.iter()
.all(uv_platform_tags::PlatformTag::is_linux_compatible)
{
!graph.graph[node_index].marker().is_disjoint(*LINUX_MARKERS)
} else if platform_tags
.iter()
.all(|tag| windows_tags.contains(&&**tag))
.all(uv_platform_tags::PlatformTag::is_windows_compatible)
{
// TODO(charlie): This omits `win_ia64`, which is accepted by Warehouse.
!graph.graph[node_index]
.marker()
.is_disjoint(*WINDOWS_MARKERS)
} else if platform_tags.iter().all(|tag| tag.starts_with("macosx_")) {
} else if platform_tags
.iter()
.all(uv_platform_tags::PlatformTag::is_macos_compatible)
{
!graph.graph[node_index].marker().is_disjoint(*MAC_MARKERS)
} else {
true

View file

@ -68,13 +68,16 @@ Ok(
version: "4.3.0",
build_tag: None,
python_tag: [
"py3",
Python {
major: 3,
minor: None,
},
],
abi_tag: [
"none",
None,
],
platform_tag: [
"any",
Any,
],
},
},

View file

@ -75,13 +75,16 @@ Ok(
version: "4.3.0",
build_tag: None,
python_tag: [
"py3",
Python {
major: 3,
minor: None,
},
],
abi_tag: [
"none",
None,
],
platform_tag: [
"any",
Any,
],
},
},

View file

@ -71,13 +71,16 @@ Ok(
version: "4.3.0",
build_tag: None,
python_tag: [
"py3",
Python {
major: 3,
minor: None,
},
],
abi_tag: [
"none",
None,
],
platform_tag: [
"any",
Any,
],
},
},

View file

@ -1,26 +1,13 @@
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet};
use std::ops::Bound;
use indexmap::IndexSet;
use itertools::Itertools;
use owo_colors::OwoColorize;
use pubgrub::{DerivationTree, Derived, External, Map, Range, ReportFormatter, Term};
use rustc_hash::FxHashMap;
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet};
use std::ops::Bound;
use std::str::FromStr;
use super::{PubGrubPackage, PubGrubPackageInner, PubGrubPython};
use crate::candidate_selector::CandidateSelector;
use crate::error::ErrorTree;
use crate::fork_indexes::ForkIndexes;
use crate::fork_urls::ForkUrls;
use crate::prerelease::AllowPrerelease;
use crate::python_requirement::{PythonRequirement, PythonRequirementSource};
use crate::resolver::{
MetadataUnavailable, UnavailablePackage, UnavailableReason, UnavailableVersion,
};
use crate::{
Flexibility, InMemoryIndex, Options, RequiresPython, ResolverEnvironment, VersionsResponse,
};
use uv_configuration::{IndexStrategy, NoBinary, NoBuild};
use uv_distribution_types::{
IncompatibleDist, IncompatibleSource, IncompatibleWheel, Index, IndexCapabilities,
@ -28,7 +15,21 @@ use uv_distribution_types::{
};
use uv_normalize::PackageName;
use uv_pep440::{Version, VersionSpecifiers};
use uv_platform_tags::{AbiTag, IncompatibleTag, LanguageTag, Tags};
use uv_platform_tags::{AbiTag, IncompatibleTag, LanguageTag, PlatformTag, Tags};
use crate::candidate_selector::CandidateSelector;
use crate::error::ErrorTree;
use crate::fork_indexes::ForkIndexes;
use crate::fork_urls::ForkUrls;
use crate::prerelease::AllowPrerelease;
use crate::pubgrub::{PubGrubPackage, PubGrubPackageInner, PubGrubPython};
use crate::python_requirement::{PythonRequirement, PythonRequirementSource};
use crate::resolver::{
MetadataUnavailable, UnavailablePackage, UnavailableReason, UnavailableVersion,
};
use crate::{
Flexibility, InMemoryIndex, Options, RequiresPython, ResolverEnvironment, VersionsResponse,
};
#[derive(Debug)]
pub(crate) struct PubGrubReportFormatter<'a> {
@ -755,11 +756,7 @@ impl PubGrubReportFormatter<'_> {
IncompatibleTag::Invalid => None,
IncompatibleTag::Python => {
// Return all available language tags.
let tags = prioritized
.python_tags()
.into_iter()
.filter_map(|tag| LanguageTag::from_str(tag).ok())
.collect::<BTreeSet<_>>();
let tags = prioritized.python_tags();
if tags.is_empty() {
None
} else {
@ -774,7 +771,6 @@ impl PubGrubReportFormatter<'_> {
let tags = prioritized
.abi_tags()
.into_iter()
.filter_map(|tag| AbiTag::from_str(tag).ok())
// Ignore `none`, which is universally compatible.
//
// As an example, `none` can appear here if we're solving for Python 3.13, and
@ -809,7 +805,7 @@ impl PubGrubReportFormatter<'_> {
let tags = prioritized
.platform_tags(self.tags?)
.into_iter()
.map(ToString::to_string)
.cloned()
.collect::<Vec<_>>();
if tags.is_empty() {
None
@ -1146,7 +1142,7 @@ pub(crate) enum PubGrubHint {
// excluded from `PartialEq` and `Hash`
version: Version,
// excluded from `PartialEq` and `Hash`
tags: Vec<String>,
tags: Vec<PlatformTag>,
},
}

View file

@ -7,7 +7,7 @@ use pubgrub::Range;
use uv_distribution_filename::WheelFilename;
use uv_pep440::{release_specifiers_to_ranges, Version, VersionSpecifier, VersionSpecifiers};
use uv_pep508::{MarkerExpression, MarkerTree, MarkerValueVersion};
use uv_platform_tags::AbiTag;
use uv_platform_tags::{AbiTag, LanguageTag};
/// The `Requires-Python` requirement specifier.
///
@ -381,53 +381,73 @@ impl RequiresPython {
/// 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" {
if *abi_tag == AbiTag::Abi3 {
// Universal tags are allowed.
true
} else if abi_tag == "none" {
} else if *abi_tag == AbiTag::None {
wheel.python_tag.iter().any(|python_tag| {
// Remove `py2-none-any` and `py27-none-any` and analogous `cp` and `pp` tags.
if python_tag.starts_with("py2")
|| python_tag.starts_with("cp2")
|| python_tag.starts_with("pp2")
{
if matches!(
python_tag,
LanguageTag::Python { major: 2, .. }
| LanguageTag::CPython {
python_version: (2, ..)
}
| LanguageTag::PyPy {
python_version: (2, ..)
}
| LanguageTag::GraalPy {
python_version: (2, ..)
}
| LanguageTag::Pyston {
python_version: (2, ..)
}
) {
return false;
}
// Remove (e.g.) `py312-none-any` if the specifier is `==3.10.*`. However,
// `py37-none-any` would be fine, since the `3.7` represents a lower bound.
if let Some(minor) = python_tag.strip_prefix("py3") {
let Ok(minor) = minor.parse::<u64>() else {
return true;
};
if let LanguageTag::Python {
major: 3,
minor: Some(minor),
} = python_tag
{
// Ex) If the wheel bound is `3.12`, then it doesn't match `<=3.10.`.
let wheel_bound = UpperBound(Bound::Included(Version::new([3, minor])));
let wheel_bound =
UpperBound(Bound::Included(Version::new([3, u64::from(*minor)])));
if wheel_bound > self.range.upper().major_minor() {
return false;
}
return true;
};
}
// Remove (e.g.) `cp36-none-any` or `cp312-none-any` if the specifier is
// `==3.10.*`, since these tags require an exact match.
if let Some(minor) = python_tag
.strip_prefix("cp3")
.or_else(|| python_tag.strip_prefix("pp3"))
if let LanguageTag::CPython {
python_version: (3, minor),
}
| LanguageTag::PyPy {
python_version: (3, minor),
}
| LanguageTag::GraalPy {
python_version: (3, minor),
}
| LanguageTag::Pyston {
python_version: (3, minor),
} = python_tag
{
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 = LowerBound(Bound::Included(Version::new([3, minor])));
let wheel_bound =
LowerBound(Bound::Included(Version::new([3, u64::from(*minor)])));
if wheel_bound < self.range.lower().major_minor() {
return false;
}
// Ex) If the wheel bound is `3.12`, then it doesn't match `<=3.10.`.
let wheel_bound = UpperBound(Bound::Included(Version::new([3, minor])));
let wheel_bound =
UpperBound(Bound::Included(Version::new([3, u64::from(*minor)])));
if wheel_bound > self.range.upper().major_minor() {
return false;
}
@ -438,50 +458,49 @@ impl RequiresPython {
// Unknown tags are allowed.
true
})
} else if abi_tag.starts_with("cp2") || abi_tag.starts_with("pypy2") {
} else if matches!(
abi_tag,
AbiTag::CPython {
python_version: (2, ..),
..
} | AbiTag::PyPy {
python_version: (2, ..),
..
} | AbiTag::GraalPy {
python_version: (2, ..),
..
} | AbiTag::Pyston {
python_version: (2, ..),
..
}
) {
// 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;
};
} else if let AbiTag::CPython {
python_version: (3, minor),
..
}
| AbiTag::PyPy {
python_version: (3, minor),
..
}
| AbiTag::GraalPy {
python_version: (3, minor),
..
}
| AbiTag::Pyston {
python_version: (3, minor),
..
} = abi_tag
{
// Ex) If the wheel bound is `3.6`, then it doesn't match `>=3.10`.
let wheel_bound = LowerBound(Bound::Included(Version::new([3, minor])));
let wheel_bound = LowerBound(Bound::Included(Version::new([3, u64::from(*minor)])));
if wheel_bound < self.range.lower().major_minor() {
return false;
}
// Ex) If the wheel bound is `3.12`, then it doesn't match `<=3.10.`.
let wheel_bound = UpperBound(Bound::Included(Version::new([3, minor])));
if wheel_bound > self.range.upper().major_minor() {
return false;
}
true
} 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;
};
// Ex) If the wheel bound is `3.6`, then it doesn't match `>=3.10`.
let wheel_bound = LowerBound(Bound::Included(Version::new([3, minor])));
if wheel_bound < self.range.lower().major_minor() {
return false;
}
// Ex) If the wheel bound is `3.12`, then it doesn't match `<=3.10.`.
let wheel_bound = UpperBound(Bound::Included(Version::new([3, minor])));
let wheel_bound = UpperBound(Bound::Included(Version::new([3, u64::from(*minor)])));
if wheel_bound > self.range.upper().major_minor() {
return false;
}