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

@ -20,8 +20,9 @@ uv-normalize = { workspace = true }
uv-pep440 = { workspace = true }
uv-platform-tags = { workspace = true }
rkyv = { workspace = true }
rkyv = { workspace = true, features = ["smallvec-1"] }
serde = { workspace = true }
smallvec = { workspace = true }
thiserror = { workspace = true }
url = { workspace = true }

View file

@ -7,7 +7,7 @@ pub use build_tag::{BuildTag, BuildTagError};
pub use egg::{EggInfoFilename, EggInfoFilenameError};
pub use extension::{DistExtension, ExtensionError, SourceDistExtension};
pub use source_dist::{SourceDistFilename, SourceDistFilenameError};
pub use wheel::{WheelFilename, WheelFilenameError};
pub use wheel::{TagSet, WheelFilename, WheelFilenameError};
mod build_tag;
mod egg;

View file

@ -1,6 +1,6 @@
---
source: crates/uv-distribution-filename/src/wheel.rs
expression: "WheelFilename::from_str(\"foo-1.2.3-202206090410-python-abi-platform.whl\")"
expression: "WheelFilename::from_str(\"foo-1.2.3-202206090410-py3-none-any.whl\")"
---
Ok(
WheelFilename {
@ -15,13 +15,16 @@ Ok(
),
),
python_tag: [
"python",
Python {
major: 3,
minor: None,
},
],
abi_tag: [
"abi",
None,
],
platform_tag: [
"platform",
Any,
],
},
)

View file

@ -1,6 +1,6 @@
---
source: crates/uv-distribution-filename/src/wheel.rs
expression: "WheelFilename::from_str(\"foo-1.2.3-ab.cd.ef-gh-ij.kl.mn.op.qr.st.whl\")"
expression: "WheelFilename::from_str(\"foo-1.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl\")"
---
Ok(
WheelFilename {
@ -10,20 +10,31 @@ Ok(
version: "1.2.3",
build_tag: None,
python_tag: [
"ab",
"cd",
"ef",
CPython {
python_version: (
3,
11,
),
},
],
abi_tag: [
"gh",
CPython {
gil_disabled: false,
python_version: (
3,
11,
),
},
],
platform_tag: [
"ij",
"kl",
"mn",
"op",
"qr",
"st",
Manylinux {
major: 2,
minor: 17,
arch: X86_64,
},
Manylinux2014 {
arch: X86_64,
},
],
},
)

View file

@ -1,6 +1,6 @@
---
source: crates/uv-distribution-filename/src/wheel.rs
expression: "WheelFilename::from_str(\"foo-1.2.3-foo-bar-baz.whl\")"
expression: "WheelFilename::from_str(\"foo-1.2.3-py3-none-any.whl\")"
---
Ok(
WheelFilename {
@ -10,13 +10,16 @@ Ok(
version: "1.2.3",
build_tag: None,
python_tag: [
"foo",
Python {
major: 3,
minor: None,
},
],
abi_tag: [
"bar",
None,
],
platform_tag: [
"baz",
Any,
],
},
)

View file

@ -7,10 +7,19 @@ use url::Url;
use uv_normalize::{InvalidNameError, PackageName};
use uv_pep440::{Version, VersionParseError};
use uv_platform_tags::{TagCompatibility, Tags};
use uv_platform_tags::{
AbiTag, LanguageTag, ParseAbiTagError, ParseLanguageTagError, ParsePlatformTagError,
PlatformTag, TagCompatibility, Tags,
};
use crate::{BuildTag, BuildTagError};
/// A [`SmallVec`] type for storing tags.
///
/// Wheels tend to include a single language, ABI, and platform tag, so we use a [`SmallVec`] with a
/// capacity of 1 to optimize for this common case.
pub type TagSet<T> = smallvec::SmallVec<[T; 1]>;
#[derive(
Debug,
Clone,
@ -28,9 +37,9 @@ pub struct WheelFilename {
pub name: PackageName,
pub version: Version,
pub build_tag: Option<BuildTag>,
pub python_tag: Vec<String>,
pub abi_tag: Vec<String>,
pub platform_tag: Vec<String>,
pub python_tag: TagSet<LanguageTag>,
pub abi_tag: TagSet<AbiTag>,
pub platform_tag: TagSet<PlatformTag>,
}
impl FromStr for WheelFilename {
@ -87,12 +96,32 @@ impl WheelFilename {
/// Get the tag for this wheel.
fn get_tag(&self) -> String {
format!(
"{}-{}-{}",
self.python_tag.join("."),
self.abi_tag.join("."),
self.platform_tag.join(".")
)
if let ([python_tag], [abi_tag], [platform_tag]) = (
self.python_tag.as_slice(),
self.abi_tag.as_slice(),
self.platform_tag.as_slice(),
) {
format!("{python_tag}-{abi_tag}-{platform_tag}",)
} else {
format!(
"{}-{}-{}",
self.python_tag
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("."),
self.abi_tag
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("."),
self.platform_tag
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("."),
)
}
}
/// Parse a wheel filename from the stem (e.g., `foo-1.2.3-py3-none-any`).
@ -177,11 +206,21 @@ impl WheelFilename {
name,
version,
build_tag,
// TODO(charlie): Consider storing structured tags here. We need to benchmark to
// understand whether it's impactful.
python_tag: python_tag.split('.').map(String::from).collect(),
abi_tag: abi_tag.split('.').map(String::from).collect(),
platform_tag: platform_tag.split('.').map(String::from).collect(),
python_tag: python_tag
.split('.')
.map(LanguageTag::from_str)
.collect::<Result<_, _>>()
.map_err(|err| WheelFilenameError::InvalidLanguageTag(filename.to_string(), err))?,
abi_tag: abi_tag
.split('.')
.map(AbiTag::from_str)
.collect::<Result<_, _>>()
.map_err(|err| WheelFilenameError::InvalidAbiTag(filename.to_string(), err))?,
platform_tag: platform_tag
.split('.')
.map(PlatformTag::from_str)
.collect::<Result<_, _>>()
.map_err(|err| WheelFilenameError::InvalidPlatformTag(filename.to_string(), err))?,
})
}
}
@ -238,6 +277,12 @@ pub enum WheelFilenameError {
InvalidPackageName(String, InvalidNameError),
#[error("The wheel filename \"{0}\" has an invalid build tag: {1}")]
InvalidBuildTag(String, BuildTagError),
#[error("The wheel filename \"{0}\" has an invalid language tag: {1}")]
InvalidLanguageTag(String, ParseLanguageTagError),
#[error("The wheel filename \"{0}\" has an invalid ABI tag: {1}")]
InvalidAbiTag(String, ParseAbiTagError),
#[error("The wheel filename \"{0}\" has an invalid platform tag: {1}")]
InvalidPlatformTag(String, ParsePlatformTagError),
}
#[cfg(test)]
@ -264,63 +309,63 @@ mod tests {
#[test]
fn err_2_part_no_pythontag() {
let err = WheelFilename::from_str("foo-version.whl").unwrap_err();
insta::assert_snapshot!(err, @r###"The wheel filename "foo-version.whl" is invalid: Must have a Python tag"###);
let err = WheelFilename::from_str("foo-1.2.3.whl").unwrap_err();
insta::assert_snapshot!(err, @r###"The wheel filename "foo-1.2.3.whl" is invalid: Must have a Python tag"###);
}
#[test]
fn err_3_part_no_abitag() {
let err = WheelFilename::from_str("foo-version-python.whl").unwrap_err();
insta::assert_snapshot!(err, @r###"The wheel filename "foo-version-python.whl" is invalid: Must have an ABI tag"###);
let err = WheelFilename::from_str("foo-1.2.3-py3.whl").unwrap_err();
insta::assert_snapshot!(err, @r###"The wheel filename "foo-1.2.3-py3.whl" is invalid: Must have an ABI tag"###);
}
#[test]
fn err_4_part_no_platformtag() {
let err = WheelFilename::from_str("foo-version-python-abi.whl").unwrap_err();
insta::assert_snapshot!(err, @r###"The wheel filename "foo-version-python-abi.whl" is invalid: Must have a platform tag"###);
let err = WheelFilename::from_str("foo-1.2.3-py3-none.whl").unwrap_err();
insta::assert_snapshot!(err, @r###"The wheel filename "foo-1.2.3-py3-none.whl" is invalid: Must have a platform tag"###);
}
#[test]
fn err_too_many_parts() {
let err =
WheelFilename::from_str("foo-1.2.3-build-python-abi-platform-oops.whl").unwrap_err();
insta::assert_snapshot!(err, @r###"The wheel filename "foo-1.2.3-build-python-abi-platform-oops.whl" is invalid: Must have 5 or 6 components, but has more"###);
WheelFilename::from_str("foo-1.2.3-202206090410-py3-none-any-whoops.whl").unwrap_err();
insta::assert_snapshot!(err, @r###"The wheel filename "foo-1.2.3-202206090410-py3-none-any-whoops.whl" is invalid: Must have 5 or 6 components, but has more"###);
}
#[test]
fn err_invalid_package_name() {
let err = WheelFilename::from_str("f!oo-1.2.3-python-abi-platform.whl").unwrap_err();
insta::assert_snapshot!(err, @r###"The wheel filename "f!oo-1.2.3-python-abi-platform.whl" has an invalid package name"###);
let err = WheelFilename::from_str("f!oo-1.2.3-py3-none-any.whl").unwrap_err();
insta::assert_snapshot!(err, @r###"The wheel filename "f!oo-1.2.3-py3-none-any.whl" has an invalid package name"###);
}
#[test]
fn err_invalid_version() {
let err = WheelFilename::from_str("foo-x.y.z-python-abi-platform.whl").unwrap_err();
insta::assert_snapshot!(err, @r###"The wheel filename "foo-x.y.z-python-abi-platform.whl" has an invalid version: expected version to start with a number, but no leading ASCII digits were found"###);
let err = WheelFilename::from_str("foo-x.y.z-py3-none-any.whl").unwrap_err();
insta::assert_snapshot!(err, @r###"The wheel filename "foo-x.y.z-py3-none-any.whl" has an invalid version: expected version to start with a number, but no leading ASCII digits were found"###);
}
#[test]
fn err_invalid_build_tag() {
let err = WheelFilename::from_str("foo-1.2.3-tag-python-abi-platform.whl").unwrap_err();
insta::assert_snapshot!(err, @r###"The wheel filename "foo-1.2.3-tag-python-abi-platform.whl" has an invalid build tag: must start with a digit"###);
let err = WheelFilename::from_str("foo-1.2.3-tag-py3-none-any.whl").unwrap_err();
insta::assert_snapshot!(err, @r###"The wheel filename "foo-1.2.3-tag-py3-none-any.whl" has an invalid build tag: must start with a digit"###);
}
#[test]
fn ok_single_tags() {
insta::assert_debug_snapshot!(WheelFilename::from_str("foo-1.2.3-foo-bar-baz.whl"));
insta::assert_debug_snapshot!(WheelFilename::from_str("foo-1.2.3-py3-none-any.whl"));
}
#[test]
fn ok_multiple_tags() {
insta::assert_debug_snapshot!(WheelFilename::from_str(
"foo-1.2.3-ab.cd.ef-gh-ij.kl.mn.op.qr.st.whl"
"foo-1.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"
));
}
#[test]
fn ok_build_tag() {
insta::assert_debug_snapshot!(WheelFilename::from_str(
"foo-1.2.3-202206090410-python-abi-platform.whl"
"foo-1.2.3-202206090410-py3-none-any.whl"
));
}