Skip to content

Commit

Permalink
Implement PEP 440-compliant local version semantics (#8797)
Browse files Browse the repository at this point in the history
## Summary

Implement a full working version of local version semantics. The (AFAIA)
major move towards this was implemented in #2430. This added support
such that the version specifier `torch==2.1.0+cpu` would install
`torch@2.1.0+cpu` and consider `torch@2.1.0+cpu` a valid way to satisfy
the requirement `torch==2.1.0` in further dependency resolution.

In this feature, we more fully support local version semantics. Namely,
we now allow `torch==2.1.0` to install `torch@2.1.0+cpu` regardless of
whether `torch@2.1.0` (no local tag) actually exists.

We do this by adding an internal-only `Max` value to local versions that
compare greater to all other local versions. Then we can translate
`torch==2.1.0` into bounds: greater than 2.1.0 with no local tag and
less than 2.1.0 with the `Max` local tag.

## Test Plan

Depends on astral-sh/packse#227.
  • Loading branch information
ericmarkmartin authored and charliermarsh committed Nov 6, 2024
1 parent 49a2460 commit 3d1f25f
Show file tree
Hide file tree
Showing 15 changed files with 631 additions and 840 deletions.
5 changes: 3 additions & 2 deletions crates/uv-pep440/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@
pub use version_ranges::{release_specifier_to_range, release_specifiers_to_ranges};
pub use {
version::{
LocalSegment, Operator, OperatorParseError, Prerelease, PrereleaseKind, Version,
VersionParseError, VersionPattern, VersionPatternParseError, MIN_VERSION,
LocalSegment, LocalVersion, LocalVersionSlice, Operator, OperatorParseError, Prerelease,
PrereleaseKind, Version, VersionParseError, VersionPattern, VersionPatternParseError,
MIN_VERSION,
},
version_specifier::{
VersionSpecifier, VersionSpecifierBuildError, VersionSpecifiers,
Expand Down
134 changes: 116 additions & 18 deletions crates/uv-pep440/src/version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,10 +388,10 @@ impl Version {

/// Returns the local segments in this version, if any exist.
#[inline]
pub fn local(&self) -> &[LocalSegment] {
pub fn local(&self) -> LocalVersionSlice {
match *self.inner {
VersionInner::Small { ref small } => small.local(),
VersionInner::Full { ref full } => &full.local,
VersionInner::Full { ref full } => full.local.as_slice(),
}
}

Expand Down Expand Up @@ -530,15 +530,28 @@ impl Version {
/// Set the local segments and return the updated version.
#[inline]
#[must_use]
pub fn with_local(mut self, value: Vec<LocalSegment>) -> Self {
pub fn with_local_segments(mut self, value: Vec<LocalSegment>) -> Self {
if value.is_empty() {
self.without_local()
} else {
self.make_full().local = value;
self.make_full().local = LocalVersion::Segments(value);
self
}
}

/// Set the local version and return the updated version.
#[inline]
#[must_use]
pub fn with_local(mut self, value: LocalVersion) -> Self {
match value {
LocalVersion::Segments(segments) => self.with_local_segments(segments),
LocalVersion::Max => {
self.make_full().local = value;
self
}
}
}

/// For PEP 440 specifier matching: "Except where specifically noted below,
/// local version identifiers MUST NOT be permitted in version specifiers,
/// and local version labels MUST be ignored entirely when checking if
Expand Down Expand Up @@ -615,7 +628,7 @@ impl Version {
pre: small.pre(),
post: small.post(),
dev: small.dev(),
local: vec![],
local: LocalVersion::Segments(vec![]),
};
*self = Self {
inner: Arc::new(VersionInner::Full { full }),
Expand Down Expand Up @@ -712,14 +725,12 @@ impl std::fmt::Display for Version {
let local = if self.local().is_empty() {
String::new()
} else {
format!(
"+{}",
self.local()
.iter()
.map(ToString::to_string)
.collect::<Vec<String>>()
.join(".")
)
match self.local() {
LocalVersionSlice::Segments(_) => {
format!("+{}", self.local())
}
LocalVersionSlice::Max => String::new(),
}
};
write!(f, "{epoch}{release}{pre}{post}{dev}{local}")
}
Expand Down Expand Up @@ -1195,10 +1206,10 @@ impl VersionSmall {

#[inline]
#[allow(clippy::unused_self)]
fn local(&self) -> &[LocalSegment] {
fn local(&self) -> LocalVersionSlice {
// A "small" version is never used if the version has a non-zero number
// of local segments.
&[]
LocalVersionSlice::Segments(&[])
}

#[inline]
Expand Down Expand Up @@ -1283,7 +1294,7 @@ struct VersionFull {
///
/// Local versions allow multiple segments separated by periods, such as `deadbeef.1.2.3`, see
/// [`LocalSegment`] for details on the semantics.
local: Vec<LocalSegment>,
local: LocalVersion,
/// An internal-only segment that does not exist in PEP 440, used to
/// represent the smallest possible version of a release, preceding any
/// `dev`, `pre`, `post` or releases.
Expand Down Expand Up @@ -1414,6 +1425,93 @@ impl std::fmt::Display for Prerelease {
}
}

/// Either a sequence of local segments or [`LocalVersion::Sentinel`], an internal-only value that
/// compares greater than all other local versions.
#[derive(Eq, PartialEq, Debug, Clone, Hash)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)
)]
#[cfg_attr(feature = "rkyv", rkyv(derive(Debug, Eq, PartialEq, PartialOrd, Ord)))]
pub enum LocalVersion {
/// A sequence of local segments.
Segments(Vec<LocalSegment>),
/// An internal-only value that compares greater to all other local versions.
Max,
}

/// Like [`LocalVersion`], but using a slice
#[derive(Eq, PartialEq, Debug, Clone, Hash)]
pub enum LocalVersionSlice<'a> {
/// Like [`LocalVersion::Segments`]
Segments(&'a [LocalSegment]),
/// Like [`LocalVersion::Sentinel`]
Max,
}

impl LocalVersion {
/// Convert the local version segments into a slice.
pub fn as_slice(&self) -> LocalVersionSlice<'_> {
match self {
LocalVersion::Segments(segments) => LocalVersionSlice::Segments(segments),
LocalVersion::Max => LocalVersionSlice::Max,
}
}

/// Clear the local version segments, if they exist.
pub fn clear(&mut self) {
match self {
Self::Segments(segments) => segments.clear(),
Self::Max => *self = Self::Segments(Vec::new()),
}
}
}

/// Output the local version identifier string.
///
/// [`LocalVersionSlice::Max`] maps to `"[max]"` which is otherwise an illegal local
/// version because `[` and `]` are not allowed.
impl std::fmt::Display for LocalVersionSlice<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LocalVersionSlice::Segments(segments) => {
for (i, segment) in segments.iter().enumerate() {
if i > 0 {
write!(f, ".")?;
}
write!(f, "{segment}")?;
}
Ok(())
}
LocalVersionSlice::Max => write!(f, "[max]"),
}
}
}

impl PartialOrd for LocalVersionSlice<'_> {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}

impl Ord for LocalVersionSlice<'_> {
fn cmp(&self, other: &Self) -> Ordering {
match (self, other) {
(LocalVersionSlice::Segments(lv1), LocalVersionSlice::Segments(lv2)) => lv1.cmp(lv2),
(LocalVersionSlice::Segments(_), LocalVersionSlice::Max) => Ordering::Less,
(LocalVersionSlice::Max, LocalVersionSlice::Segments(_)) => Ordering::Greater,
(LocalVersionSlice::Max, LocalVersionSlice::Max) => Ordering::Equal,
}
}
}

impl LocalVersionSlice<'_> {
/// Whether the local version is absent
pub fn is_empty(&self) -> bool {
matches!(self, Self::Segments(&[]))
}
}

/// A part of the [local version identifier](<https://peps.python.org/pep-0440/#local-version-identifiers>)
///
/// Local versions are a mess:
Expand Down Expand Up @@ -1855,7 +1953,7 @@ impl<'a> Parser<'a> {
.with_pre(self.pre)
.with_post(self.post)
.with_dev(self.dev)
.with_local(self.local);
.with_local(LocalVersion::Segments(self.local));
VersionPattern {
version,
wildcard: self.wildcard,
Expand Down Expand Up @@ -2326,7 +2424,7 @@ pub(crate) fn compare_release(this: &[u64], other: &[u64]) -> Ordering {
/// implementation
///
/// [pep440-suffix-ordering]: https://peps.python.org/pep-0440/#summary-of-permitted-suffixes-and-relative-ordering
fn sortable_tuple(version: &Version) -> (u64, u64, Option<u64>, u64, &[LocalSegment]) {
fn sortable_tuple(version: &Version) -> (u64, u64, Option<u64>, u64, LocalVersionSlice) {
// If the version is a "max" version, use a post version larger than any possible post version.
let post = if version.max().is_some() {
Some(u64::MAX)
Expand Down
Loading

0 comments on commit 3d1f25f

Please sign in to comment.