Skip to content

Commit e2bda11

Browse files
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 #16868.
1 parent 0db4180 commit e2bda11

2 files changed

Lines changed: 182 additions & 3 deletions

File tree

crates/uv-pep440/src/version_ranges.rs

Lines changed: 149 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,30 @@ impl From<VersionSpecifier> for Ranges<Version> {
5757
Self::from_range_bounds(version..upper)
5858
}
5959
Operator::LessThan => {
60+
// Per PEP 440: "The exclusive ordered comparison <V MUST NOT allow a
61+
// pre-release of the specified version unless the specified version is itself a
62+
// pre-release."
6063
if version.any_prerelease() {
64+
// If V is a pre-release, we allow pre-releases of the same version.
6165
Self::strictly_lower_than(version)
66+
} else if let Some(post) = version.post() {
67+
// If V is a post-release (e.g., `<0.12.0.post2`), we want to:
68+
// - Exclude pre-releases of the base version (e.g., `0.12.0a1`)
69+
// - Include the final release (e.g., `0.12.0`)
70+
// - Include earlier post-releases (e.g., `0.12.0.post1`)
71+
//
72+
// The range is: `(-∞, base.min0) ∪ [base, V.post)`
73+
// where `base` is the version without the post-release component.
74+
let base = version.clone().with_post(None);
75+
// Everything below the base version's pre-releases
76+
let lower = Self::strictly_lower_than(base.clone().with_min(Some(0)));
77+
// From base (inclusive) up to but not including V
78+
let upper = Self::from_range_bounds(base..version.with_post(Some(post)));
79+
lower.union(&upper)
6280
} else {
63-
// Per PEP 440: "The exclusive ordered comparison <V MUST NOT allow a
64-
// pre-release of the specified version unless the specified version is itself a
65-
// pre-release."
81+
// V is not a pre-release or post-release, so exclude pre-releases of the
82+
// specified version by using a "min" sentinel that sorts before all
83+
// pre-releases.
6684
Self::strictly_lower_than(version.with_min(Some(0)))
6785
}
6886
}
@@ -476,3 +494,131 @@ impl From<UpperBound> for Bound<Version> {
476494
bound.0
477495
}
478496
}
497+
498+
#[cfg(test)]
499+
mod tests {
500+
use super::*;
501+
502+
/// Test that `<V.postN` excludes pre-releases of the base version but includes
503+
/// earlier post-releases and the final release.
504+
///
505+
/// See: <https://github.com/astral-sh/uv/issues/16868>
506+
#[test]
507+
fn less_than_post_release() {
508+
let specifier: VersionSpecifier = "<0.12.0.post2".parse().unwrap();
509+
let range = Ranges::<Version>::from(specifier);
510+
511+
// Should include versions less than base release.
512+
let v = "0.11.0".parse::<Version>().unwrap();
513+
assert!(range.contains(&v), "should include 0.11.0");
514+
515+
// Should exclude pre-releases of the base release.
516+
let v = "0.12.0a1".parse::<Version>().unwrap();
517+
assert!(!range.contains(&v), "should exclude 0.12.0a1");
518+
519+
let v = "0.12.0b1".parse::<Version>().unwrap();
520+
assert!(!range.contains(&v), "should exclude 0.12.0b1");
521+
522+
let v = "0.12.0rc1".parse::<Version>().unwrap();
523+
assert!(!range.contains(&v), "should exclude 0.12.0rc1");
524+
525+
let v = "0.12.0.dev0".parse::<Version>().unwrap();
526+
assert!(!range.contains(&v), "should exclude 0.12.0.dev0");
527+
528+
// Should also exclude post-releases of pre-releases.
529+
let v = "0.12.0a1.post1".parse::<Version>().unwrap();
530+
assert!(!range.contains(&v), "should exclude 0.12.0a1.post1");
531+
532+
let v = "0.12.0b1.post1".parse::<Version>().unwrap();
533+
assert!(!range.contains(&v), "should exclude 0.12.0b1.post1");
534+
535+
// Should include the final release.
536+
let v = "0.12.0".parse::<Version>().unwrap();
537+
assert!(range.contains(&v), "should include 0.12.0");
538+
539+
// Should include earlier post-releases.
540+
let v = "0.12.0.post1".parse::<Version>().unwrap();
541+
assert!(range.contains(&v), "should include 0.12.0.post1");
542+
543+
// Should exclude the specified post-release.
544+
let v = "0.12.0.post2".parse::<Version>().unwrap();
545+
assert!(!range.contains(&v), "should exclude 0.12.0.post2");
546+
547+
// Should exclude later versions.
548+
let v = "0.13.0".parse::<Version>().unwrap();
549+
assert!(!range.contains(&v), "should exclude 0.13.0");
550+
}
551+
552+
/// Test that `<V` (non-post-release) correctly excludes pre-releases.
553+
#[test]
554+
fn less_than_final_release() {
555+
let specifier: VersionSpecifier = "<0.12.0".parse().unwrap();
556+
let range = Ranges::<Version>::from(specifier);
557+
558+
// Should include versions less than base release.
559+
let v = "0.11.0".parse::<Version>().unwrap();
560+
assert!(range.contains(&v), "should include 0.11.0");
561+
562+
// Should exclude pre-releases of the specified version.
563+
let v = "0.12.0a1".parse::<Version>().unwrap();
564+
assert!(!range.contains(&v), "should exclude 0.12.0a1");
565+
566+
let v = "0.12.0.dev0".parse::<Version>().unwrap();
567+
assert!(!range.contains(&v), "should exclude 0.12.0.dev0");
568+
569+
// Should exclude the specified version.
570+
let v = "0.12.0".parse::<Version>().unwrap();
571+
assert!(!range.contains(&v), "should exclude 0.12.0");
572+
573+
// Should exclude post-releases of the specified version.
574+
let v = "0.12.0.post1".parse::<Version>().unwrap();
575+
assert!(!range.contains(&v), "should exclude 0.12.0.post1");
576+
}
577+
578+
/// Test that `<V.preN` allows earlier pre-releases of the same version.
579+
#[test]
580+
fn less_than_pre_release() {
581+
let specifier: VersionSpecifier = "<0.12.0b1".parse().unwrap();
582+
let range = Ranges::<Version>::from(specifier);
583+
584+
// Should include earlier pre-releases.
585+
let v = "0.12.0a1".parse::<Version>().unwrap();
586+
assert!(range.contains(&v), "should include 0.12.0a1");
587+
588+
let v = "0.12.0.dev0".parse::<Version>().unwrap();
589+
assert!(range.contains(&v), "should include 0.12.0.dev0");
590+
591+
// Should exclude the specified pre-release and later.
592+
let v = "0.12.0b1".parse::<Version>().unwrap();
593+
assert!(!range.contains(&v), "should exclude 0.12.0b1");
594+
595+
let v = "0.12.0".parse::<Version>().unwrap();
596+
assert!(!range.contains(&v), "should exclude 0.12.0");
597+
}
598+
599+
/// Test the edge case where `<V.post0` still includes the final release.
600+
#[test]
601+
fn less_than_post_zero() {
602+
let specifier: VersionSpecifier = "<0.12.0.post0".parse().unwrap();
603+
let range = Ranges::<Version>::from(specifier);
604+
605+
// Should include versions less than base release.
606+
let v = "0.11.0".parse::<Version>().unwrap();
607+
assert!(range.contains(&v), "should include 0.11.0");
608+
609+
// Should exclude pre-releases of the base release.
610+
let v = "0.12.0a1".parse::<Version>().unwrap();
611+
assert!(!range.contains(&v), "should exclude 0.12.0a1");
612+
613+
// Should include the final release (0.12.0 < 0.12.0.post0).
614+
let v = "0.12.0".parse::<Version>().unwrap();
615+
assert!(range.contains(&v), "should include 0.12.0");
616+
617+
// Should exclude post0 and later.
618+
let v = "0.12.0.post0".parse::<Version>().unwrap();
619+
assert!(!range.contains(&v), "should exclude 0.12.0.post0");
620+
621+
let v = "0.12.0.post1".parse::<Version>().unwrap();
622+
assert!(!range.contains(&v), "should exclude 0.12.0.post1");
623+
}
624+
}

crates/uv/tests/it/pip_compile.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17850,3 +17850,36 @@ fn credentials_from_subdirectory() -> Result<()> {
1785017850

1785117851
Ok(())
1785217852
}
17853+
17854+
/// Install a package with a post-release version constraint.
17855+
///
17856+
/// `<V.postN` should include earlier post-releases but exclude pre-releases.
17857+
///
17858+
/// See: <https://github.com/astral-sh/uv/issues/16868>
17859+
#[test]
17860+
fn post_release_less_than() -> Result<()> {
17861+
let context = TestContext::new("3.10");
17862+
17863+
let requirements_in = context.temp_dir.child("requirements.in");
17864+
requirements_in.write_str("hidapi>=0.12.0.post1,<0.12.0.post2")?;
17865+
17866+
// The constraint `>=0.12.0.post1, <0.12.0.post2` should only match 0.12.0.post1.
17867+
uv_snapshot!(context.pip_compile()
17868+
.arg("requirements.in"), @r"
17869+
success: true
17870+
exit_code: 0
17871+
----- stdout -----
17872+
# This file was autogenerated by uv via the following command:
17873+
# uv pip compile --cache-dir [CACHE_DIR] requirements.in
17874+
hidapi==0.12.0.post1
17875+
# via -r requirements.in
17876+
setuptools==69.2.0
17877+
# via hidapi
17878+
17879+
----- stderr -----
17880+
Resolved 2 packages in [TIME]
17881+
"
17882+
);
17883+
17884+
Ok(())
17885+
}

0 commit comments

Comments
 (0)