@@ -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+ }
0 commit comments