From e2bda1173e36a49a6d7bd70354ce6553d572a528 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 28 Nov 2025 09:50:49 -0500 Subject: [PATCH] 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. --- crates/uv-pep440/src/version_ranges.rs | 152 ++++++++++++++++++++++++- crates/uv/tests/it/pip_compile.rs | 33 ++++++ 2 files changed, 182 insertions(+), 3 deletions(-) diff --git a/crates/uv-pep440/src/version_ranges.rs b/crates/uv-pep440/src/version_ranges.rs index a586d1524..2fc090764 100644 --- a/crates/uv-pep440/src/version_ranges.rs +++ b/crates/uv-pep440/src/version_ranges.rs @@ -57,12 +57,30 @@ impl From for Ranges { Self::from_range_bounds(version..upper) } Operator::LessThan => { + // Per PEP 440: "The exclusive ordered comparison for Bound { bound.0 } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Test that ` + #[test] + fn less_than_post_release() { + let specifier: VersionSpecifier = "<0.12.0.post2".parse().unwrap(); + let range = Ranges::::from(specifier); + + // Should include versions less than base release. + let v = "0.11.0".parse::().unwrap(); + assert!(range.contains(&v), "should include 0.11.0"); + + // Should exclude pre-releases of the base release. + let v = "0.12.0a1".parse::().unwrap(); + assert!(!range.contains(&v), "should exclude 0.12.0a1"); + + let v = "0.12.0b1".parse::().unwrap(); + assert!(!range.contains(&v), "should exclude 0.12.0b1"); + + let v = "0.12.0rc1".parse::().unwrap(); + assert!(!range.contains(&v), "should exclude 0.12.0rc1"); + + let v = "0.12.0.dev0".parse::().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::().unwrap(); + assert!(!range.contains(&v), "should exclude 0.12.0a1.post1"); + + let v = "0.12.0b1.post1".parse::().unwrap(); + assert!(!range.contains(&v), "should exclude 0.12.0b1.post1"); + + // Should include the final release. + let v = "0.12.0".parse::().unwrap(); + assert!(range.contains(&v), "should include 0.12.0"); + + // Should include earlier post-releases. + let v = "0.12.0.post1".parse::().unwrap(); + assert!(range.contains(&v), "should include 0.12.0.post1"); + + // Should exclude the specified post-release. + let v = "0.12.0.post2".parse::().unwrap(); + assert!(!range.contains(&v), "should exclude 0.12.0.post2"); + + // Should exclude later versions. + let v = "0.13.0".parse::().unwrap(); + assert!(!range.contains(&v), "should exclude 0.13.0"); + } + + /// Test that `::from(specifier); + + // Should include versions less than base release. + let v = "0.11.0".parse::().unwrap(); + assert!(range.contains(&v), "should include 0.11.0"); + + // Should exclude pre-releases of the specified version. + let v = "0.12.0a1".parse::().unwrap(); + assert!(!range.contains(&v), "should exclude 0.12.0a1"); + + let v = "0.12.0.dev0".parse::().unwrap(); + assert!(!range.contains(&v), "should exclude 0.12.0.dev0"); + + // Should exclude the specified version. + let v = "0.12.0".parse::().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::().unwrap(); + assert!(!range.contains(&v), "should exclude 0.12.0.post1"); + } + + /// Test that `::from(specifier); + + // Should include earlier pre-releases. + let v = "0.12.0a1".parse::().unwrap(); + assert!(range.contains(&v), "should include 0.12.0a1"); + + let v = "0.12.0.dev0".parse::().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::().unwrap(); + assert!(!range.contains(&v), "should exclude 0.12.0b1"); + + let v = "0.12.0".parse::().unwrap(); + assert!(!range.contains(&v), "should exclude 0.12.0"); + } + + /// Test the edge case where `::from(specifier); + + // Should include versions less than base release. + let v = "0.11.0".parse::().unwrap(); + assert!(range.contains(&v), "should include 0.11.0"); + + // Should exclude pre-releases of the base release. + let v = "0.12.0a1".parse::().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::().unwrap(); + assert!(range.contains(&v), "should include 0.12.0"); + + // Should exclude post0 and later. + let v = "0.12.0.post0".parse::().unwrap(); + assert!(!range.contains(&v), "should exclude 0.12.0.post0"); + + let v = "0.12.0.post1".parse::().unwrap(); + assert!(!range.contains(&v), "should exclude 0.12.0.post1"); + } +} diff --git a/crates/uv/tests/it/pip_compile.rs b/crates/uv/tests/it/pip_compile.rs index 112b1bf68..d9bdeae71 100644 --- a/crates/uv/tests/it/pip_compile.rs +++ b/crates/uv/tests/it/pip_compile.rs @@ -17850,3 +17850,36 @@ fn credentials_from_subdirectory() -> Result<()> { Ok(()) } + +/// Install a package with a post-release version constraint. +/// +/// ` +#[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(()) +}