@@ -167,11 +167,27 @@ class _UpperBound:
167167 ends earlier.
168168 """
169169
170- __slots__ = ("inclusive" , "version" )
170+ __slots__ = ("_excl_base" , " inclusive" , "version" )
171171
172- def __init__ (self , version : _VersionOrBoundary , inclusive : bool ) -> None :
172+ def __init__ (
173+ self ,
174+ version : _VersionOrBoundary ,
175+ inclusive : bool ,
176+ excl_base : Version | None = None ,
177+ ) -> None :
173178 self .version = version
174179 self .inclusive = inclusive
180+ # Set by ``<V.postN`` (non-prerelease) to record that
181+ # ``_compare_less_than`` excludes pre-releases of this base
182+ # release. Used by ``_interval_is_empty`` to discard intervals
183+ # containing only excluded pre-releases.
184+ self ._excl_base = excl_base
185+
186+ def with_excl (self , other : _UpperBound ) -> _UpperBound :
187+ """Return a copy carrying *other*'s exclusion metadata, if needed."""
188+ if self ._excl_base is not None or other ._excl_base is None :
189+ return self
190+ return _UpperBound (self .version , self .inclusive , other ._excl_base )
175191
176192 def __eq__ (self , other : object ) -> bool :
177193 if not isinstance (other , _UpperBound ):
@@ -212,6 +228,70 @@ def _interval_is_empty(lower: _LowerBound, upper: _UpperBound) -> bool:
212228 """Is the interval [lower, upper] empty?"""
213229 if lower .version is None or upper .version is None :
214230 return False
231+ if lower .version == upper .version :
232+ return not (lower .inclusive and upper .inclusive )
233+ if lower .version > upper .version :
234+ return True
235+ # ``<V.postN`` excludes pre-releases sharing V's base release, but
236+ # the interval model cannot split those out (pre-releases and
237+ # non-pre-releases are interleaved on the number line). When the
238+ # upper bound carries exclusion metadata and the interval is within
239+ # the base's range, check whether any non-pre-release version of
240+ # that base (the final release and each post-release below the
241+ # bound) still falls inside.
242+ if upper ._excl_base is not None :
243+ base = upper ._excl_base
244+ if not lower .version < base .__replace__ (dev = 0 , local = None ):
245+ return not _has_surviving_version (lower , upper , base )
246+ return False
247+
248+
249+ def _has_surviving_version (
250+ lower : _LowerBound , upper : _UpperBound , base : Version
251+ ) -> bool :
252+ """Is there a non-pre-release version of *base* inside [lower, upper]?
253+
254+ The non-pre-release versions are ``base, base.post0, base.post1, ...``.
255+ They are sorted, so we only need to find the first one at or above
256+ *lower* and check whether it is also at or below *upper*.
257+ """
258+ if _interval_contains (lower , upper , base ):
259+ return True
260+ # base is below lower. Derive which post-release is nearest.
261+ v = lower .version
262+ if isinstance (v , _BoundaryVersion ):
263+ # AFTER_LOCALS(base.postK) -> first candidate is post(K+1).
264+ k = (v .version .post + 1 ) if v .version .post is not None else 0
265+ elif isinstance (v , Version ) and v .post is not None :
266+ k = v .post
267+ else :
268+ k = 0
269+ candidate = base .__replace__ (post = k , local = None )
270+ if _interval_contains (lower , upper , candidate ):
271+ return True
272+ # If candidate was at the exclusive lower bound, try the next one.
273+ next_candidate = base .__replace__ (post = k + 1 , local = None )
274+ return _interval_contains (lower , upper , next_candidate )
275+
276+
277+ def _interval_contains (
278+ lower : _LowerBound , upper : _UpperBound , version : Version
279+ ) -> bool :
280+ """Is *version* inside the interval [lower, upper]?"""
281+ point_lo = _LowerBound (version , True )
282+ point_hi = _UpperBound (version , True )
283+ # version >= lower AND version <= upper
284+ return not (_bounds_empty (lower , point_hi ) or _bounds_empty (point_lo , upper ))
285+
286+
287+ def _bounds_empty (lower : _LowerBound , upper : _UpperBound ) -> bool :
288+ """Basic emptiness check (no survivor logic, avoids recursion).
289+
290+ Only called from ``_interval_contains`` with concrete versions,
291+ never with None (unbounded) endpoints.
292+ """
293+ assert lower .version is not None
294+ assert upper .version is not None
215295 if lower .version == upper .version :
216296 return not (lower .inclusive and upper .inclusive )
217297 return lower .version > upper .version
@@ -230,6 +310,8 @@ def _intersect_intervals(
230310
231311 lower = max (left_lower , right_lower )
232312 upper = min (left_upper , right_upper )
313+ # Propagate <V.postN exclusion metadata to the winning bound.
314+ upper = upper .with_excl (left_upper ).with_excl (right_upper )
233315
234316 if not _interval_is_empty (lower , upper ):
235317 result .append ((lower , upper ))
@@ -343,6 +425,18 @@ def _base_dev0(version: Version) -> Version:
343425 return Version .from_parts (epoch = version .epoch , release = version .release , dev = 0 )
344426
345427
428+ def _base_version (version : Version ) -> Version :
429+ """Strip pre/post/dev/local, keeping only epoch and release."""
430+ if (
431+ version .pre is None
432+ and version .post is None
433+ and version .dev is None
434+ and version .local is None
435+ ):
436+ return version
437+ return version .__replace__ (pre = None , post = None , dev = None , local = None )
438+
439+
346440class InvalidSpecifier (ValueError ):
347441 """
348442 Raised when attempting to create a :class:`Specifier` with a specifier
@@ -674,12 +768,17 @@ def _standard_intervals(self, op: str, ver_str: str) -> list[_SpecifierInterval]
674768
675769 if op == "<" :
676770 # <V excludes prereleases of V when V is not a prerelease.
677- # V.dev0 is the earliest prerelease of V regardless of
678- # whether V is a final, post, or pre-of-pre release.
771+ # V.dev0 is the earliest prerelease of V (final, post, etc.).
679772 bound = v if v .is_prerelease else v .__replace__ (dev = 0 , local = None )
680773 if bound <= _MIN_VERSION :
681774 return []
682- return [(_NEG_INF , _UpperBound (bound , False ))]
775+ # For <V.postN, tag the bound with the base release so
776+ # _interval_is_empty can detect intervals containing only
777+ # excluded pre-releases.
778+ excl_base = (
779+ _base_version (v ) if not v .is_prerelease and v .post is not None else None
780+ )
781+ return [(_NEG_INF , _UpperBound (bound , False , excl_base ))]
683782
684783 if op == "==" :
685784 # ==V (no local) matches V+local; ==V+local matches exactly.
0 commit comments