diff --git a/crates/distribution-filename/src/build_tag.rs b/crates/distribution-filename/src/build_tag.rs new file mode 100644 index 000000000..d315cbf13 --- /dev/null +++ b/crates/distribution-filename/src/build_tag.rs @@ -0,0 +1,63 @@ +use std::num::ParseIntError; +use std::str::FromStr; +use std::sync::Arc; + +#[derive(thiserror::Error, Debug)] +pub enum BuildTagError { + #[error("must not be empty")] + Empty, + #[error("must start with a digit")] + NoLeadingDigit, + #[error(transparent)] + ParseInt(#[from] ParseIntError), +} + +/// The optional build tag for a wheel: +/// +/// > Must start with a digit. Acts as a tie-breaker if two wheel file names are the same in all +/// > other respects (i.e. name, version, and other tags). Sort as an empty tuple if unspecified, +/// > else sort as a two-item tuple with the first item being the initial digits as an int, and the +/// > second item being the remainder of the tag as a str. +/// +/// See: +#[derive( + Debug, + Clone, + Eq, + PartialEq, + Hash, + Ord, + PartialOrd, + rkyv::Archive, + rkyv::Deserialize, + rkyv::Serialize, +)] +#[archive(check_bytes)] +#[archive_attr(derive(Debug))] +pub struct BuildTag(u32, Option>); + +impl FromStr for BuildTag { + type Err = BuildTagError; + + fn from_str(s: &str) -> Result { + // A build tag must not be empty. + if s.is_empty() { + return Err(BuildTagError::Empty); + } + + // A build tag must start with a digit. + let (prefix, suffix) = match s.find(|c: char| !c.is_ascii_digit()) { + // Ex) `abc` + Some(0) => return Err(BuildTagError::NoLeadingDigit), + // Ex) `123abc` + Some(split) => { + let (prefix, suffix) = s.split_at(split); + (prefix, Some(suffix)) + } + // Ex) `123` + None => (s, None), + }; + + Ok(BuildTag(prefix.parse::()?, suffix.map(Arc::from))) + } +} diff --git a/crates/distribution-filename/src/lib.rs b/crates/distribution-filename/src/lib.rs index 174f78043..0e1c11d02 100644 --- a/crates/distribution-filename/src/lib.rs +++ b/crates/distribution-filename/src/lib.rs @@ -3,9 +3,11 @@ use std::fmt::{Display, Formatter}; use std::str::FromStr; use uv_normalize::PackageName; +pub use build_tag::{BuildTag, BuildTagError}; pub use source_dist::{SourceDistExtension, SourceDistFilename, SourceDistFilenameError}; pub use wheel::{WheelFilename, WheelFilenameError}; +mod build_tag; mod source_dist; mod wheel; diff --git a/crates/distribution-filename/src/snapshots/distribution_filename__wheel__tests__ok_build_tag.snap b/crates/distribution-filename/src/snapshots/distribution_filename__wheel__tests__ok_build_tag.snap index 0f699a196..785d1055c 100644 --- a/crates/distribution-filename/src/snapshots/distribution_filename__wheel__tests__ok_build_tag.snap +++ b/crates/distribution-filename/src/snapshots/distribution_filename__wheel__tests__ok_build_tag.snap @@ -1,6 +1,6 @@ --- source: crates/distribution-filename/src/wheel.rs -expression: "WheelFilename::from_str(\"foo-1.2.3-build-python-abi-platform.whl\")" +expression: "WheelFilename::from_str(\"foo-1.2.3-12-python-abi-platform.whl\")" --- Ok( WheelFilename { @@ -8,6 +8,12 @@ Ok( "foo", ), version: "1.2.3", + build_tag: Some( + BuildTag( + 12, + None, + ), + ), python_tag: [ "python", ], diff --git a/crates/distribution-filename/src/snapshots/distribution_filename__wheel__tests__ok_multiple_tags.snap b/crates/distribution-filename/src/snapshots/distribution_filename__wheel__tests__ok_multiple_tags.snap index 28aa7ec79..105da9b0c 100644 --- a/crates/distribution-filename/src/snapshots/distribution_filename__wheel__tests__ok_multiple_tags.snap +++ b/crates/distribution-filename/src/snapshots/distribution_filename__wheel__tests__ok_multiple_tags.snap @@ -8,6 +8,7 @@ Ok( "foo", ), version: "1.2.3", + build_tag: None, python_tag: [ "ab", "cd", diff --git a/crates/distribution-filename/src/snapshots/distribution_filename__wheel__tests__ok_single_tags.snap b/crates/distribution-filename/src/snapshots/distribution_filename__wheel__tests__ok_single_tags.snap index cc9aeef57..7700e2b45 100644 --- a/crates/distribution-filename/src/snapshots/distribution_filename__wheel__tests__ok_single_tags.snap +++ b/crates/distribution-filename/src/snapshots/distribution_filename__wheel__tests__ok_single_tags.snap @@ -8,6 +8,7 @@ Ok( "foo", ), version: "1.2.3", + build_tag: None, python_tag: [ "foo", ], diff --git a/crates/distribution-filename/src/wheel.rs b/crates/distribution-filename/src/wheel.rs index 30cfa2d32..7e8a1aff6 100644 --- a/crates/distribution-filename/src/wheel.rs +++ b/crates/distribution-filename/src/wheel.rs @@ -9,12 +9,15 @@ use pep440_rs::{Version, VersionParseError}; use platform_tags::{TagCompatibility, Tags}; use uv_normalize::{InvalidNameError, PackageName}; +use crate::{BuildTag, BuildTagError}; + #[derive(Debug, Clone, Eq, PartialEq, Hash, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)] #[archive(check_bytes)] #[archive_attr(derive(Debug))] pub struct WheelFilename { pub name: PackageName, pub version: Version, + pub build_tag: Option, pub python_tag: Vec, pub abi_tag: Vec, pub platform_tag: Vec, @@ -57,16 +60,6 @@ impl WheelFilename { compatible_tags.compatibility(&self.python_tag, &self.abi_tag, &self.platform_tag) } - /// Get the tag for this wheel. - pub fn get_tag(&self) -> String { - format!( - "{}-{}-{}", - self.python_tag.join("."), - self.abi_tag.join("."), - self.platform_tag.join(".") - ) - } - /// The wheel filename without the extension. pub fn stem(&self) -> String { format!( @@ -82,6 +75,16 @@ impl WheelFilename { Self::parse(stem, stem) } + /// Get the tag for this wheel. + fn get_tag(&self) -> String { + format!( + "{}-{}-{}", + self.python_tag.join("."), + self.abi_tag.join("."), + self.platform_tag.join(".") + ) + } + /// Parse a wheel filename from the stem (e.g., `foo-1.2.3-py3-none-any`). /// /// The originating `filename` is used for high-fidelity error messages. @@ -129,7 +132,7 @@ impl WheelFilename { )); }; - let (name, version, python_tag, abi_tag, platform_tag) = + let (name, version, build_tag, python_tag, abi_tag, platform_tag) = if let Some(platform_tag) = parts.next() { if parts.next().is_some() { return Err(WheelFilenameError::InvalidWheelFileName( @@ -140,6 +143,7 @@ impl WheelFilename { ( name, version, + Some(build_tag_or_python_tag), python_tag_or_abi_tag, abi_tag_or_platform_tag, platform_tag, @@ -148,6 +152,7 @@ impl WheelFilename { ( name, version, + None, build_tag_or_python_tag, python_tag_or_abi_tag, abi_tag_or_platform_tag, @@ -158,9 +163,16 @@ impl WheelFilename { .map_err(|err| WheelFilenameError::InvalidPackageName(filename.to_string(), err))?; let version = Version::from_str(version) .map_err(|err| WheelFilenameError::InvalidVersion(filename.to_string(), err))?; + let build_tag = build_tag + .map(|build_tag| { + BuildTag::from_str(build_tag) + .map_err(|err| WheelFilenameError::InvalidBuildTag(filename.to_string(), err)) + }) + .transpose()?; Ok(Self { name, version, + build_tag, 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(), @@ -214,10 +226,12 @@ impl Serialize for WheelFilename { pub enum WheelFilenameError { #[error("The wheel filename \"{0}\" is invalid: {1}")] InvalidWheelFileName(String, String), - #[error("The wheel filename \"{0}\" has an invalid version part: {1}")] + #[error("The wheel filename \"{0}\" has an invalid version: {1}")] InvalidVersion(String, VersionParseError), #[error("The wheel filename \"{0}\" has an invalid package name")] InvalidPackageName(String, InvalidNameError), + #[error("The wheel filename \"{0}\" has an invalid build tag: {1}")] + InvalidBuildTag(String, BuildTagError), } #[cfg(test)] @@ -276,7 +290,13 @@ mod tests { #[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 part: expected version to start with a number, but no leading ASCII digits were found"###); + 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"###); + } + + #[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"###); } #[test] @@ -294,7 +314,7 @@ mod tests { #[test] fn ok_build_tag() { insta::assert_debug_snapshot!(WheelFilename::from_str( - "foo-1.2.3-build-python-abi-platform.whl" + "foo-1.2.3-12-python-abi-platform.whl" )); } diff --git a/crates/distribution-types/src/prioritized_distribution.rs b/crates/distribution-types/src/prioritized_distribution.rs index 592907ede..258776863 100644 --- a/crates/distribution-types/src/prioritized_distribution.rs +++ b/crates/distribution-types/src/prioritized_distribution.rs @@ -1,3 +1,4 @@ +use distribution_filename::BuildTag; use std::fmt::{Display, Formatter}; use pep440_rs::VersionSpecifiers; @@ -134,7 +135,7 @@ impl Display for IncompatibleDist { #[derive(Debug, Clone, PartialEq, Eq)] pub enum WheelCompatibility { Incompatible(IncompatibleWheel), - Compatible(HashComparison, TagPriority), + Compatible(HashComparison, TagPriority, Option), } #[derive(Debug, PartialEq, Eq, Clone)] @@ -245,7 +246,7 @@ impl PrioritizedDist { // source distribution with a matching hash over a wheel with a mismatched hash. When // the outcomes are equivalent (e.g., both have a matching hash), prefer the wheel. ( - Some((wheel, WheelCompatibility::Compatible(wheel_hash, tag_priority))), + Some((wheel, WheelCompatibility::Compatible(wheel_hash, tag_priority, ..))), Some((sdist, SourceDistCompatibility::Compatible(sdist_hash))), ) => { if sdist_hash > wheel_hash { @@ -262,7 +263,7 @@ impl PrioritizedDist { } } // Prefer the highest-priority, platform-compatible wheel. - (Some((wheel, WheelCompatibility::Compatible(_, tag_priority))), _) => { + (Some((wheel, WheelCompatibility::Compatible(_, tag_priority, ..))), _) => { Some(CompatibleDist::CompatibleWheel { wheel, priority: *tag_priority, @@ -309,7 +310,7 @@ impl PrioritizedDist { .best_wheel_index .map(|i| &self.0.wheels[i]) .and_then(|(_, compatibility)| match compatibility { - WheelCompatibility::Compatible(_, _) => None, + WheelCompatibility::Compatible(_, _, _) => None, WheelCompatibility::Incompatible(incompatibility) => Some(incompatibility), }) } @@ -415,7 +416,7 @@ impl<'a> CompatibleDist<'a> { impl WheelCompatibility { pub fn is_compatible(&self) -> bool { - matches!(self, Self::Compatible(_, _)) + matches!(self, Self::Compatible(_, _, _)) } /// Return `true` if the current compatibility is more compatible than another. @@ -424,12 +425,14 @@ impl WheelCompatibility { /// Compatible wheel ordering is determined by tag priority. pub fn is_more_compatible(&self, other: &Self) -> bool { match (self, other) { - (Self::Compatible(_, _), Self::Incompatible(_)) => true, + (Self::Compatible(_, _, _), Self::Incompatible(_)) => true, ( - Self::Compatible(hash, tag_priority), - Self::Compatible(other_hash, other_tag_priority), - ) => (hash, tag_priority) > (other_hash, other_tag_priority), - (Self::Incompatible(_), Self::Compatible(_, _)) => false, + Self::Compatible(hash, tag_priority, build_tag), + Self::Compatible(other_hash, other_tag_priority, other_build_tag), + ) => { + (hash, tag_priority, build_tag) > (other_hash, other_tag_priority, other_build_tag) + } + (Self::Incompatible(_), Self::Compatible(_, _, _)) => false, (Self::Incompatible(incompatibility), Self::Incompatible(other_incompatibility)) => { incompatibility.is_more_compatible(other_incompatibility) } diff --git a/crates/uv-cache/src/lib.rs b/crates/uv-cache/src/lib.rs index 49775811f..8b021a71c 100644 --- a/crates/uv-cache/src/lib.rs +++ b/crates/uv-cache/src/lib.rs @@ -614,7 +614,7 @@ impl CacheBucket { Self::FlatIndex => "flat-index-v0", Self::Git => "git-v0", Self::Interpreter => "interpreter-v1", - Self::Simple => "simple-v7", + Self::Simple => "simple-v8", Self::Wheels => "wheels-v1", Self::Archive => "archive-v0", } diff --git a/crates/uv-resolver/src/flat_index.rs b/crates/uv-resolver/src/flat_index.rs index 00c116b1d..a1ee58e8e 100644 --- a/crates/uv-resolver/src/flat_index.rs +++ b/crates/uv-resolver/src/flat_index.rs @@ -175,7 +175,7 @@ impl FlatIndex { TagCompatibility::Compatible(priority) => priority, }; - // Check if hashes line up + // Check if hashes line up. let hash = if let HashPolicy::Validate(required) = hasher.get_package(&filename.name) { if hashes.is_empty() { HashComparison::Missing @@ -188,7 +188,10 @@ impl FlatIndex { HashComparison::Matched }; - WheelCompatibility::Compatible(hash, priority) + // Break ties with the build tag. + let build_tag = filename.build_tag.clone(); + + WheelCompatibility::Compatible(hash, priority, build_tag) } /// Get the [`FlatDistributions`] for the given package name. diff --git a/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__hash_optional_missing.snap b/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__hash_optional_missing.snap index 866a2ad1f..8f12896fe 100644 --- a/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__hash_optional_missing.snap +++ b/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__hash_optional_missing.snap @@ -56,6 +56,7 @@ Ok( "anyio", ), version: "4.3.0", + build_tag: None, python_tag: [ "py3", ], diff --git a/crates/uv-resolver/src/version_map.rs b/crates/uv-resolver/src/version_map.rs index 6449ab919..8c7813422 100644 --- a/crates/uv-resolver/src/version_map.rs +++ b/crates/uv-resolver/src/version_map.rs @@ -554,7 +554,10 @@ impl VersionMapLazy { } }; - WheelCompatibility::Compatible(hash, priority) + // Break ties with the build tag. + let build_tag = filename.build_tag.clone(); + + WheelCompatibility::Compatible(hash, priority, build_tag) } }