Allow earlier post releases with exclusive ordering (#16881)

## Summary

Given (e.g.) `<0.12.0.post2`, we need to omit pre-releases on `0.12.0`,
but include post-releases.

Closes https://github.com/astral-sh/uv/issues/16868.
This commit is contained in:
Charlie Marsh 2025-11-28 09:50:49 -05:00 committed by GitHub
parent 0db41803cd
commit e2bda1173e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 182 additions and 3 deletions

View File

@ -57,12 +57,30 @@ impl From<VersionSpecifier> for Ranges<Version> {
Self::from_range_bounds(version..upper)
}
Operator::LessThan => {
// Per PEP 440: "The exclusive ordered comparison <V MUST NOT allow a
// pre-release of the specified version unless the specified version is itself a
// pre-release."
if version.any_prerelease() {
// If V is a pre-release, we allow pre-releases of the same version.
Self::strictly_lower_than(version)
} else if let Some(post) = version.post() {
// If V is a post-release (e.g., `<0.12.0.post2`), we want to:
// - Exclude pre-releases of the base version (e.g., `0.12.0a1`)
// - Include the final release (e.g., `0.12.0`)
// - Include earlier post-releases (e.g., `0.12.0.post1`)
//
// The range is: `(-∞, base.min0) [base, V.post)`
// where `base` is the version without the post-release component.
let base = version.clone().with_post(None);
// Everything below the base version's pre-releases
let lower = Self::strictly_lower_than(base.clone().with_min(Some(0)));
// From base (inclusive) up to but not including V
let upper = Self::from_range_bounds(base..version.with_post(Some(post)));
lower.union(&upper)
} else {
// Per PEP 440: "The exclusive ordered comparison <V MUST NOT allow a
// pre-release of the specified version unless the specified version is itself a
// pre-release."
// V is not a pre-release or post-release, so exclude pre-releases of the
// specified version by using a "min" sentinel that sorts before all
// pre-releases.
Self::strictly_lower_than(version.with_min(Some(0)))
}
}
@ -476,3 +494,131 @@ impl From<UpperBound> for Bound<Version> {
bound.0
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Test that `<V.postN` excludes pre-releases of the base version but includes
/// earlier post-releases and the final release.
///
/// See: <https://github.com/astral-sh/uv/issues/16868>
#[test]
fn less_than_post_release() {
let specifier: VersionSpecifier = "<0.12.0.post2".parse().unwrap();
let range = Ranges::<Version>::from(specifier);
// Should include versions less than base release.
let v = "0.11.0".parse::<Version>().unwrap();
assert!(range.contains(&v), "should include 0.11.0");
// Should exclude pre-releases of the base release.
let v = "0.12.0a1".parse::<Version>().unwrap();
assert!(!range.contains(&v), "should exclude 0.12.0a1");
let v = "0.12.0b1".parse::<Version>().unwrap();
assert!(!range.contains(&v), "should exclude 0.12.0b1");
let v = "0.12.0rc1".parse::<Version>().unwrap();
assert!(!range.contains(&v), "should exclude 0.12.0rc1");
let v = "0.12.0.dev0".parse::<Version>().unwrap();
assert!(!range.contains(&v), "should exclude 0.12.0.dev0");
// Should also exclude post-releases of pre-releases.
let v = "0.12.0a1.post1".parse::<Version>().unwrap();
assert!(!range.contains(&v), "should exclude 0.12.0a1.post1");
let v = "0.12.0b1.post1".parse::<Version>().unwrap();
assert!(!range.contains(&v), "should exclude 0.12.0b1.post1");
// Should include the final release.
let v = "0.12.0".parse::<Version>().unwrap();
assert!(range.contains(&v), "should include 0.12.0");
// Should include earlier post-releases.
let v = "0.12.0.post1".parse::<Version>().unwrap();
assert!(range.contains(&v), "should include 0.12.0.post1");
// Should exclude the specified post-release.
let v = "0.12.0.post2".parse::<Version>().unwrap();
assert!(!range.contains(&v), "should exclude 0.12.0.post2");
// Should exclude later versions.
let v = "0.13.0".parse::<Version>().unwrap();
assert!(!range.contains(&v), "should exclude 0.13.0");
}
/// Test that `<V` (non-post-release) correctly excludes pre-releases.
#[test]
fn less_than_final_release() {
let specifier: VersionSpecifier = "<0.12.0".parse().unwrap();
let range = Ranges::<Version>::from(specifier);
// Should include versions less than base release.
let v = "0.11.0".parse::<Version>().unwrap();
assert!(range.contains(&v), "should include 0.11.0");
// Should exclude pre-releases of the specified version.
let v = "0.12.0a1".parse::<Version>().unwrap();
assert!(!range.contains(&v), "should exclude 0.12.0a1");
let v = "0.12.0.dev0".parse::<Version>().unwrap();
assert!(!range.contains(&v), "should exclude 0.12.0.dev0");
// Should exclude the specified version.
let v = "0.12.0".parse::<Version>().unwrap();
assert!(!range.contains(&v), "should exclude 0.12.0");
// Should exclude post-releases of the specified version.
let v = "0.12.0.post1".parse::<Version>().unwrap();
assert!(!range.contains(&v), "should exclude 0.12.0.post1");
}
/// Test that `<V.preN` allows earlier pre-releases of the same version.
#[test]
fn less_than_pre_release() {
let specifier: VersionSpecifier = "<0.12.0b1".parse().unwrap();
let range = Ranges::<Version>::from(specifier);
// Should include earlier pre-releases.
let v = "0.12.0a1".parse::<Version>().unwrap();
assert!(range.contains(&v), "should include 0.12.0a1");
let v = "0.12.0.dev0".parse::<Version>().unwrap();
assert!(range.contains(&v), "should include 0.12.0.dev0");
// Should exclude the specified pre-release and later.
let v = "0.12.0b1".parse::<Version>().unwrap();
assert!(!range.contains(&v), "should exclude 0.12.0b1");
let v = "0.12.0".parse::<Version>().unwrap();
assert!(!range.contains(&v), "should exclude 0.12.0");
}
/// Test the edge case where `<V.post0` still includes the final release.
#[test]
fn less_than_post_zero() {
let specifier: VersionSpecifier = "<0.12.0.post0".parse().unwrap();
let range = Ranges::<Version>::from(specifier);
// Should include versions less than base release.
let v = "0.11.0".parse::<Version>().unwrap();
assert!(range.contains(&v), "should include 0.11.0");
// Should exclude pre-releases of the base release.
let v = "0.12.0a1".parse::<Version>().unwrap();
assert!(!range.contains(&v), "should exclude 0.12.0a1");
// Should include the final release (0.12.0 < 0.12.0.post0).
let v = "0.12.0".parse::<Version>().unwrap();
assert!(range.contains(&v), "should include 0.12.0");
// Should exclude post0 and later.
let v = "0.12.0.post0".parse::<Version>().unwrap();
assert!(!range.contains(&v), "should exclude 0.12.0.post0");
let v = "0.12.0.post1".parse::<Version>().unwrap();
assert!(!range.contains(&v), "should exclude 0.12.0.post1");
}
}

View File

@ -17850,3 +17850,36 @@ fn credentials_from_subdirectory() -> Result<()> {
Ok(())
}
/// Install a package with a post-release version constraint.
///
/// `<V.postN` should include earlier post-releases but exclude pre-releases.
///
/// See: <https://github.com/astral-sh/uv/issues/16868>
#[test]
fn post_release_less_than() -> Result<()> {
let context = TestContext::new("3.10");
let requirements_in = context.temp_dir.child("requirements.in");
requirements_in.write_str("hidapi>=0.12.0.post1,<0.12.0.post2")?;
// The constraint `>=0.12.0.post1, <0.12.0.post2` should only match 0.12.0.post1.
uv_snapshot!(context.pip_compile()
.arg("requirements.in"), @r"
success: true
exit_code: 0
----- stdout -----
# This file was autogenerated by uv via the following command:
# uv pip compile --cache-dir [CACHE_DIR] requirements.in
hidapi==0.12.0.post1
# via -r requirements.in
setuptools==69.2.0
# via hidapi
----- stderr -----
Resolved 2 packages in [TIME]
"
);
Ok(())
}