diff --git a/benchmarks/specifiers.py b/benchmarks/specifiers.py index 23b6bb8cf..4e55290f6 100644 --- a/benchmarks/specifiers.py +++ b/benchmarks/specifiers.py @@ -60,16 +60,16 @@ def _make_cold(self, spec: SpecifierSet) -> None: spec._canonicalized = False if hasattr(spec, "_resolved_ops"): spec._resolved_ops = None - if hasattr(spec, "_ranges"): - spec._ranges = None + if hasattr(spec, "_range_cache"): + spec._range_cache = None if hasattr(spec, "_is_unsatisfiable"): spec._is_unsatisfiable = None for sp in spec._specs: sp._spec_version = None if hasattr(sp, "_wildcard_split"): sp._wildcard_split = None - if hasattr(sp, "_ranges"): - sp._ranges = None + if hasattr(sp, "_range_cache"): + sp._range_cache = None @add_attributes(pretty_name="SpecifierSet constructor") def time_constructor(self) -> None: diff --git a/docs/index.rst b/docs/index.rst index 9cd58567c..368694bc5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -25,6 +25,7 @@ The ``packaging`` library uses calendar-based versioning (``YY.N``). version specifiers + ranges markers licenses requirements diff --git a/docs/ranges.rst b/docs/ranges.rst new file mode 100644 index 000000000..3fe587f7a --- /dev/null +++ b/docs/ranges.rst @@ -0,0 +1,157 @@ +Ranges +====== + +.. versionadded:: 26.3 + +A :class:`~packaging.ranges.VersionRange` represents the set of +:class:`~packaging.version.Version` values matched by a +:class:`~packaging.specifiers.Specifier` or +:class:`~packaging.specifiers.SpecifierSet`. Unlike a +:class:`~packaging.specifiers.SpecifierSet`, ranges are closed under +intersection, union, and complement, so questions like "do these two +constraints overlap?" or "is this constraint a subset of that one?" +reduce to direct set operations. + +Constructing a range +-------------------- + +Build a range from a :class:`Specifier` or :class:`SpecifierSet` +using :meth:`~Specifier.to_range`: + +.. doctest:: + + >>> from packaging.ranges import VersionRange + >>> from packaging.specifiers import Specifier, SpecifierSet + >>> r = SpecifierSet(">=1.0,<2.0").to_range() + >>> "1.5" in r + True + >>> "2.0" in r + False + +The classmethods :meth:`VersionRange.from_specifier` and +:meth:`VersionRange.from_specifier_set` produce the same results and +are useful when only a :class:`VersionRange` reference is in scope. + +Three factories return common identity ranges: + +.. doctest:: + + >>> VersionRange.empty().is_empty + True + >>> "1.5" in VersionRange.full() + True + >>> "1.0" in VersionRange.singleton("1.0") + True + +Calling ``VersionRange()`` directly raises :exc:`TypeError`; use one +of the factories above. + +Set algebra +----------- + +:class:`VersionRange` supports intersection, union, and complement +via the :meth:`~VersionRange.intersection`, +:meth:`~VersionRange.union`, and :meth:`~VersionRange.complement` +methods, or the ``&``, ``|``, and ``~`` operator aliases. Every +operation returns a new range; operands are not mutated. + +.. doctest:: + + >>> ge1 = SpecifierSet(">=1.0").to_range() + >>> lt2 = SpecifierSet("<2.0").to_range() + >>> "1.5" in (ge1 & lt2) + True + >>> "2.5" in (ge1 | lt2) + True + >>> # Double-complement is the original range. + >>> ~~ge1 == ge1 + True + >>> # A range and its complement are always disjoint. + >>> bool(ge1 & ~ge1) + False + +Set operations answer overlap and subset questions directly: + +.. doctest:: + + >>> a = SpecifierSet(">=1.0,<2.0").to_range() + >>> b = SpecifierSet(">=1.5,<3.0").to_range() + >>> # Do these constraints overlap? + >>> bool(a & b) + True + >>> # Is *a* entirely contained in *b*? + >>> (a & b) == a + False + >>> narrow = SpecifierSet(">=1.0,<1.5").to_range() + >>> wide = SpecifierSet(">=1.0,<2.0").to_range() + >>> (narrow & wide) == narrow + True + +Membership and filtering +------------------------ + +``in`` and :meth:`~VersionRange.filter` mirror :class:`SpecifierSet`'s +:meth:`~SpecifierSet.__contains__` and :meth:`~SpecifierSet.filter`, +including the PEP 440 pre-release behaviour: with +``prereleases=None`` (the default), pre-releases are buffered and +emitted only when the iterable contains no in-range final release. + +.. doctest:: + + >>> from packaging.version import Version + >>> r = SpecifierSet(">=1.0,<2.0").to_range() + >>> "1.5" in r + True + >>> Version("1.5") in r + True + >>> list(r.filter(["0.9", "1.5", "2.0"])) + ['1.5'] + +Converting back to a SpecifierSet +--------------------------------- + +:meth:`~VersionRange.to_specifier_set` returns a single +:class:`SpecifierSet` whose :meth:`~SpecifierSet.to_range` yields the +same range, or ``None`` if no such single set exists. Redundant +specifiers are dropped, which makes the round-trip a useful +normalisation step: + +.. doctest:: + + >>> r = SpecifierSet(">=1.0,<2.0,!=1.5").to_range() + >>> str(r.to_specifier_set()) + '!=1.5,<2.0,>=1.0' + >>> # ``>2`` is subsumed by ``>=3``; ``!=1.0`` is outside ``>=3``. + >>> str(SpecifierSet("!=1.0,>2,>=3").to_range().to_specifier_set()) + '>=3' + +PEP 440 specifier sets are not closed under union, so the disjoint +union of two intervals returns ``None``; +:meth:`~VersionRange.to_specifier_sets` returns one +:class:`SpecifierSet` per interval: + +.. doctest:: + + >>> r = ( + ... SpecifierSet(">=1.0,<2.0").to_range() + ... | SpecifierSet(">=3.0,<4.0").to_range() + ... ) + >>> r.to_specifier_set() is None + True + >>> [str(s) for s in r.to_specifier_sets()] + ['<2.0,>=1.0', '<4.0,>=3.0'] + +The empty range round-trips through ``SpecifierSet("<0")`` (``<0`` +excludes the smallest possible PEP 440 version, ``0.dev0``): + +.. doctest:: + + >>> VersionRange.empty().to_specifier_set() == SpecifierSet("<0") + True + +Reference +--------- + +.. autoclass:: packaging.ranges.VersionRange + :members: + :special-members: __contains__, __bool__, __eq__, __hash__, __repr__ diff --git a/src/packaging/_ranges.py b/src/packaging/_ranges.py deleted file mode 100644 index 5ab4c41e6..000000000 --- a/src/packaging/_ranges.py +++ /dev/null @@ -1,665 +0,0 @@ -# This file is dual licensed under the terms of the Apache License, Version -# 2.0, and the BSD License. See the LICENSE file in the root of this repository -# for complete details. -"""Private version-range helpers used by :mod:`packaging.specifiers`.""" - -from __future__ import annotations - -import enum -import functools -from typing import ( - TYPE_CHECKING, - Any, - Final, -) - -from .version import InvalidVersion, Version - -if TYPE_CHECKING: - from collections.abc import Callable, Iterable, Iterator, Sequence - from typing import Union - -__all__ = [ - "FULL_RANGE", - "filter_by_ranges", - "intersect_ranges", - "ranges_are_prerelease_only", - "standard_ranges", - "wildcard_ranges", -] - -#: The smallest possible PEP 440 version. No valid version is less than this. -_MIN_VERSION: Final[Version] = Version("0.dev0") - - -class _BoundaryKind(enum.Enum): - """Where a boundary marker sits in the version ordering.""" - - AFTER_LOCALS = enum.auto() # after V+local, before V.post0 - AFTER_POSTS = enum.auto() # after V.postN, before next release - - -@functools.total_ordering -class _BoundaryVersion: - """A point on the version line between two real PEP 440 versions. - - Relative to a base version V:: - - V < V+local < AFTER_LOCALS(V) < V.post0 < AFTER_POSTS(V) - - AFTER_LOCALS is the upper bound of ``<=V``, ``==V``, ``!=V`` (no - local), and the lower bound of the upper-side range of ``!=V``. - AFTER_POSTS is the lower bound of ``>V`` (V final or pre-release), - excluding V's post-releases per PEP 440. - """ - - __slots__ = ( - "_cached_dev", - "_cached_epoch", - "_cached_post", - "_cached_pre", - "_cached_trimmed_release", - "_kind", - "version", - ) - - def __init__(self, version: Version, kind: _BoundaryKind) -> None: - self.version = version - self._kind = kind - self._cached_trimmed_release = trim_release(version.release) - self._cached_epoch = version.epoch - self._cached_pre = version.pre - self._cached_post = version.post - self._cached_dev = version.dev - - def _is_family(self, other: Version) -> bool: - """Is ``other`` a version that this boundary sorts above?""" - if other.epoch != self._cached_epoch: - return False - # Inline release-trim comparison: other.release matches the - # trimmed release iff its leading slice is equal and any extra - # components are zero. Avoids trim_release's tuple allocation. - other_release = other.release - trimmed_release = self._cached_trimmed_release - trimmed_length = len(trimmed_release) - if len(other_release) < trimmed_length: - return False - if other_release[:trimmed_length] != trimmed_release: - return False - for i in range(trimmed_length, len(other_release)): - if other_release[i] != 0: - return False - if other.pre != self._cached_pre: - return False - if self._kind == _BoundaryKind.AFTER_LOCALS: - # Local family: same public version, any local label. - return other.post == self._cached_post and other.dev == self._cached_dev - # Post family: V itself + any post-release of V. - return other.dev == self._cached_dev or other.post is not None - - def __eq__(self, other: object) -> bool: - if isinstance(other, _BoundaryVersion): - return self.version == other.version and self._kind == other._kind - return NotImplemented - - def __lt__(self, other: _BoundaryVersion | Version) -> bool: - if isinstance(other, _BoundaryVersion): - if self.version != other.version: - return self.version < other.version - return self._kind.value < other._kind.value # pragma: no cover - # boundary < other_version iff V < other AND other not in family. - # The cheap V >= other path short-circuits before the family check. - if not (self.version < other): - return False - return not self._is_family(other) - - def __gt__(self, other: _BoundaryVersion | Version) -> bool: - # Defined directly to bypass functools.total_ordering's - # NotImplemented round-trip on reflected ``Version < boundary``. - if isinstance(other, _BoundaryVersion): - if self.version != other.version: - return self.version > other.version - return self._kind.value > other._kind.value - if self.version >= other: - return True - return self._is_family(other) - - def __hash__(self) -> int: - return hash((self.version, self._kind)) - - def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.version!r}, {self._kind.name})" - - -if TYPE_CHECKING: - _VersionOrBoundary = Union[Version, _BoundaryVersion, None] - - -@functools.total_ordering -class _LowerBound: - """Lower bound of a version range. - - A version *v* of ``None`` means unbounded below (-inf). - At equal versions, ``[v`` sorts before ``(v`` because an inclusive - bound starts earlier. - """ - - __slots__ = ("_above", "inclusive", "version") - - def __init__(self, version: _VersionOrBoundary, inclusive: bool) -> None: - self.version = version - self.inclusive = inclusive - # Pre-bind a predicate "is parsed at or above this lower - # bound?" for the hot filter / contains loops. One direct - # call per check, no operator-dispatch chain. - if version is None: - self._above: Callable[[Version], bool] | None = None - elif isinstance(version, _BoundaryVersion): - # >V produces an AFTER_POSTS lower bound; the upper-side - # range of !=V produces an AFTER_LOCALS lower bound. - if version._kind == _BoundaryKind.AFTER_POSTS: - self._above = _make_above_after_posts(version.version) - else: - self._above = _make_above_after_locals(version.version) - elif inclusive: - self._above = version.__le__ - else: - self._above = version.__lt__ - - def __eq__(self, other: object) -> bool: - if not isinstance(other, _LowerBound): - return NotImplemented # pragma: no cover - return self.version == other.version and self.inclusive == other.inclusive - - def __lt__(self, other: _LowerBound) -> bool: - if not isinstance(other, _LowerBound): # pragma: no cover - return NotImplemented - # -inf < anything (except -inf itself). - if self.version is None: - return other.version is not None - if other.version is None: - return False - if self.version != other.version: - return self.version < other.version - # [v < (v: inclusive starts earlier. - return self.inclusive and not other.inclusive - - def __hash__(self) -> int: - return hash((self.version, self.inclusive)) - - def __repr__(self) -> str: - bracket = "[" if self.inclusive else "(" - return f"<{self.__class__.__name__} {bracket}{self.version!r}>" - - -@functools.total_ordering -class _UpperBound: - """Upper bound of a version range. - - A version *v* of ``None`` means unbounded above (+inf). - At equal versions, ``v)`` sorts before ``v]`` because an exclusive - bound ends earlier. - """ - - __slots__ = ("_below", "inclusive", "version") - - def __init__(self, version: _VersionOrBoundary, inclusive: bool) -> None: - self.version = version - self.inclusive = inclusive - # Pre-bind a predicate "is parsed at or below this upper - # bound?". See _LowerBound for the rationale. - if version is None: - self._below: Callable[[Version], bool] | None = None - elif isinstance(version, _BoundaryVersion): - # Standard specifiers only ever produce AFTER_LOCALS upper - # bounds (from <=V / ==V / !=V with no local). - if version._kind == _BoundaryKind.AFTER_LOCALS: - self._below = _make_below_after_locals(version.version) - else: # pragma: no cover (AFTER_POSTS upper not produced by specifiers) - self._below = version.__ge__ - elif inclusive: - self._below = version.__ge__ - else: - self._below = version.__gt__ - - def __eq__(self, other: object) -> bool: - if not isinstance(other, _UpperBound): - return NotImplemented # pragma: no cover - return self.version == other.version and self.inclusive == other.inclusive - - def __lt__(self, other: _UpperBound) -> bool: - if not isinstance(other, _UpperBound): # pragma: no cover - return NotImplemented - # Nothing < +inf (except +inf itself). - if self.version is None: - return False - if other.version is None: - return True - if self.version != other.version: - return self.version < other.version - # v) < v]: exclusive ends earlier. - return not self.inclusive and other.inclusive - - def __hash__(self) -> int: - return hash((self.version, self.inclusive)) - - def __repr__(self) -> str: - bracket = "]" if self.inclusive else ")" - return f"<{self.__class__.__name__} {self.version!r}{bracket}>" - - -if TYPE_CHECKING: - #: A single contiguous version range, as a (lower, upper) pair. - VersionRange = tuple[_LowerBound, _UpperBound] - - -_NEG_INF: Final[_LowerBound] = _LowerBound(None, False) -_POS_INF: Final[_UpperBound] = _UpperBound(None, False) -FULL_RANGE: Final[tuple[VersionRange]] = ((_NEG_INF, _POS_INF),) - - -def trim_release(release: tuple[int, ...]) -> tuple[int, ...]: - """Strip trailing zeros from a release tuple for normalized comparison.""" - end = len(release) - while end > 1 and release[end - 1] == 0: - end -= 1 - return release if end == len(release) else release[:end] - - -def _next_prefix_dev0(version: Version) -> Version: - """Smallest version in the next prefix: 1.2 -> 1.3.dev0.""" - release = (*version.release[:-1], version.release[-1] + 1) - return Version.from_parts(epoch=version.epoch, release=release, dev=0) - - -def _base_dev0(version: Version) -> Version: - """The .dev0 of a version's base release: 1.2 -> 1.2.dev0.""" - return Version.from_parts(epoch=version.epoch, release=version.release, dev=0) - - -def _coerce_version(version: Version | str) -> Version | None: - if not isinstance(version, Version): - try: - version = Version(version) - except InvalidVersion: - return None - return version - - -def _make_above_after_posts(version: Version) -> Callable[[Version], bool]: - """Predicate ``parsed > AFTER_POSTS(V)`` for a lower bound. - - Per PEP 440, ``>V`` excludes V's post-releases unless V is itself - a post-release. AFTER_POSTS sits above V and every V.postN (with - or without local), and just below the next release. - """ - version_ge = version.__ge__ - version_epoch = version.epoch - version_pre = version.pre - version_dev = version.dev - version_release_trimmed = trim_release(version.release) - trimmed_length = len(version_release_trimmed) - - def above(parsed: Version) -> bool: - if version_ge(parsed): - return False - # parsed > V cmpkey-wise: above the boundary iff NOT in V's - # post family. - if parsed.epoch != version_epoch: - return True - parsed_release = parsed.release - if len(parsed_release) < trimmed_length: - return True - if parsed_release[:trimmed_length] != version_release_trimmed: - return True - for i in range(trimmed_length, len(parsed_release)): - if parsed_release[i] != 0: - return True - if parsed.pre != version_pre: - return True - # In post family iff: same dev as V (covers V itself + V+local), - # or any post-release (covers V.postN + V.postN+local). - if parsed.dev == version_dev or parsed.post is not None: - return False - # Different dev with no post means parsed sorts before V - # cmpkey-wise, in which case version_ge returned True already. - return False # pragma: no cover - - return above - - -def _make_above_after_locals(version: Version) -> Callable[[Version], bool]: - """Predicate ``parsed > AFTER_LOCALS(V)`` for a lower bound. - - Used by the upper-side range of ``!=V`` (when V has no local - segment). AFTER_LOCALS sits above V and every ``V+local`` but - just below ``V.post0``. - """ - version_ge = version.__ge__ - version_epoch = version.epoch - version_pre = version.pre - version_post = version.post - version_dev = version.dev - version_release_trimmed = trim_release(version.release) - trimmed_length = len(version_release_trimmed) - - def above(parsed: Version) -> bool: - if version_ge(parsed): - return False - # parsed > V cmpkey-wise: above the boundary iff NOT in V's - # local family (same public version, any local segment). - if parsed.epoch != version_epoch: - return True - parsed_release = parsed.release - if len(parsed_release) < trimmed_length: - return True - if parsed_release[:trimmed_length] != version_release_trimmed: - return True - for i in range(trimmed_length, len(parsed_release)): - if parsed_release[i] != 0: - return True - if parsed.pre != version_pre: - return True - if parsed.post != version_post: - return True - return parsed.dev != version_dev - - return above - - -def _make_below_after_locals(version: Version) -> Callable[[Version], bool]: - """Predicate ``parsed <= AFTER_LOCALS(V)`` for an upper bound. - - Used by ``<=V``, ``==V``, ``!=V`` (no local). ``parsed`` is at or - below the boundary when it is at or below V cmpkey-wise, or when - it is in V's local family. - """ - version_ge = version.__ge__ - version_epoch = version.epoch - version_pre = version.pre - version_post = version.post - version_dev = version.dev - version_release_trimmed = trim_release(version.release) - trimmed_length = len(version_release_trimmed) - - def below(parsed: Version) -> bool: - if version_ge(parsed): - return True - # parsed > V cmpkey-wise: below the boundary iff in V's local - # family. - if parsed.epoch != version_epoch: - return False - parsed_release = parsed.release - if len(parsed_release) < trimmed_length: - return False - if parsed_release[:trimmed_length] != version_release_trimmed: - return False - for i in range(trimmed_length, len(parsed_release)): - if parsed_release[i] != 0: - return False - if parsed.pre != version_pre: - return False - if parsed.post != version_post: - return False - return parsed.dev == version_dev - - return below - - -def _range_is_empty(lower: _LowerBound, upper: _UpperBound) -> bool: - """True when the range defined by *lower* and *upper* contains no versions.""" - if lower.version is None or upper.version is None: - return False - if lower.version == upper.version: - return not (lower.inclusive and upper.inclusive) - return lower.version > upper.version - - -def intersect_ranges( - left: Sequence[VersionRange], - right: Sequence[VersionRange], -) -> list[VersionRange]: - """Intersect two sorted, non-overlapping range lists (two-pointer merge).""" - result: list[VersionRange] = [] - left_index = right_index = 0 - while left_index < len(left) and right_index < len(right): - left_lower, left_upper = left[left_index] - right_lower, right_upper = right[right_index] - - lower = max(left_lower, right_lower) - upper = min(left_upper, right_upper) - - if not _range_is_empty(lower, upper): - result.append((lower, upper)) - - # Advance whichever side has the smaller upper bound. - if left_upper < right_upper: - left_index += 1 - else: - right_index += 1 - - return result - - -def filter_by_ranges( - ranges: Sequence[VersionRange], - iterable: Iterable[Any], - key: Callable[[Any], Version | str] | None, - prereleases: bool | None, -) -> Iterator[Any]: - """Filter *iterable* against precomputed version *ranges*. - - With ``prereleases=None``, the PEP 440 default applies: pre-releases - are excluded unless no final matches, in which case buffered - pre-releases come out at the end. - """ - if prereleases is None: - # PEP 440 default: yield finals immediately; buffer - # pre-releases until at least one final has been emitted. - prerelease_buffer: list[Any] = [] - found_final = False - - if len(ranges) == 1: - lower, upper = ranges[0] - above = lower._above - below = upper._below - for item in iterable: - parsed = _coerce_version(item if key is None else key(item)) - if parsed is None: - continue - if above is not None and not above(parsed): - continue - if below is not None and not below(parsed): - continue - if parsed.is_prerelease: - if not found_final: - prerelease_buffer.append(item) - else: - found_final = True - yield item - if not found_final: - yield from prerelease_buffer - return - - for item in iterable: - parsed = _coerce_version(item if key is None else key(item)) - if parsed is None: - continue - for lower, upper in ranges: - above = lower._above - if above is not None and not above(parsed): - break - below = upper._below - if below is None or below(parsed): - if parsed.is_prerelease: - if not found_final: - prerelease_buffer.append(item) - else: - found_final = True - yield item - break - if not found_final: - yield from prerelease_buffer - return - - exclude_prereleases = prereleases is False - - if len(ranges) == 1: - # Hot path: most specifiers and small SpecifierSets reduce to - # a single contiguous range. - lower, upper = ranges[0] - above = lower._above - below = upper._below - for item in iterable: - parsed = _coerce_version(item if key is None else key(item)) - if parsed is None: - continue - if exclude_prereleases and parsed.is_prerelease: - continue - if above is not None and not above(parsed): - continue - if below is None or below(parsed): - yield item - return - - for item in iterable: - parsed = _coerce_version(item if key is None else key(item)) - if parsed is None: - continue - if exclude_prereleases and parsed.is_prerelease: - continue - for lower, upper in ranges: - above = lower._above - if above is not None and not above(parsed): - break - below = upper._below - if below is None or below(parsed): - yield item - break - - -def _lowest_release_at_or_above( - value: _VersionOrBoundary, -) -> Version | None: - """Smallest non-pre-release version at or above *value*, or None.""" - if value is None: - return None - if isinstance(value, _BoundaryVersion): - inner_version = value.version - if inner_version.is_prerelease: - # AFTER_LOCALS(1.0a1) -> nearest non-pre is 1.0 - return inner_version.__replace__(pre=None, dev=None, local=None) - # AFTER_LOCALS(1.0) -> nearest non-pre is 1.0.post0 - # AFTER_LOCALS(1.0.post0) -> nearest non-pre is 1.0.post1 - next_post = (inner_version.post + 1) if inner_version.post is not None else 0 - return inner_version.__replace__(post=next_post, local=None) - if not value.is_prerelease: - return value - # Strip pre/dev to get the final or post-release form. - return value.__replace__(pre=None, dev=None, local=None) - - -def ranges_are_prerelease_only(ranges: Sequence[VersionRange]) -> bool: - """True when every range in *ranges* contains only pre-releases. - - Used to detect unsatisfiable specifier sets when ``prereleases=False``: - if every range is pre-release-only, every contained version is excluded. - """ - for lower, upper in ranges: - nearest = _lowest_release_at_or_above(lower.version) - if nearest is None: - return False - if upper.version is None or nearest < upper.version: - return False - if nearest == upper.version and upper.inclusive: - return False - return True - - -def wildcard_ranges(op: str, base: Version) -> list[VersionRange]: - """Ranges for ==V.* and !=V.*. - - ==1.2.* -> [1.2.dev0, 1.3.dev0); !=1.2.* -> complement. - """ - lower = _base_dev0(base) - upper = _next_prefix_dev0(base) - if op == "==": - return [(_LowerBound(lower, True), _UpperBound(upper, False))] - # != - return [ - (_NEG_INF, _UpperBound(lower, False)), - (_LowerBound(upper, True), _POS_INF), - ] - - -def standard_ranges(op: str, version: Version, has_local: bool) -> list[VersionRange]: - """Ranges for the standard PEP 440 operators (no wildcard, no ===). - - *has_local* indicates whether the spec string included a ``+local`` - segment; relevant only for ``==`` / ``!=`` to decide whether the - upper bound includes V's local family. - """ - if op == ">=": - return [(_LowerBound(version, True), _POS_INF)] - - if op == "<=": - return [ - ( - _NEG_INF, - _UpperBound( - _BoundaryVersion(version, _BoundaryKind.AFTER_LOCALS), True - ), - ) - ] - - if op == ">": - if version.dev is not None: - # >V.devN: dev versions have no post-releases, so the - # next real version is V.dev(N+1). - lower_bound = version.__replace__(dev=version.dev + 1, local=None) - return [(_LowerBound(lower_bound, True), _POS_INF)] - if version.post is not None: - # >V.postN: next real version is V.post(N+1).dev0. - lower_bound = version.__replace__(post=version.post + 1, dev=0, local=None) - return [(_LowerBound(lower_bound, True), _POS_INF)] - # >V (final or pre-release V): exclude V itself, V+local, and - # every V.postN per PEP 440. - return [ - ( - _LowerBound( - _BoundaryVersion(version, _BoundaryKind.AFTER_POSTS), False - ), - _POS_INF, - ) - ] - - if op == "<": - # list[str]: + return __all__ + + +def trim_release(release: tuple[int, ...]) -> tuple[int, ...]: + """Strip trailing zeros from a release tuple.""" + end = len(release) + while end > 1 and release[end - 1] == 0: + end -= 1 + return release if end == len(release) else release[:end] + + +def coerce_version(version: Version | str) -> Version | None: + """Parse *version*; ``None`` if invalid.""" + if not isinstance(version, Version): + try: + version = Version(version) + except InvalidVersion: + return None + return version diff --git a/src/packaging/ranges.py b/src/packaging/ranges.py new file mode 100644 index 000000000..75510465c --- /dev/null +++ b/src/packaging/ranges.py @@ -0,0 +1,1795 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. +"""Public :class:`VersionRange` API and supporting range helpers. + +The :class:`VersionRange` class exposes a set-algebra view of the +versions accepted by a :class:`~packaging.specifiers.Specifier` or +:class:`~packaging.specifiers.SpecifierSet`. Private helpers in this +module also drive the range-filter hot path used by +:meth:`Specifier.contains` / :meth:`Specifier.filter` and +:meth:`SpecifierSet.contains` / :meth:`SpecifierSet.filter`. + +.. testsetup:: + + from packaging.ranges import VersionRange + from packaging.specifiers import Specifier, SpecifierSet + from packaging.version import Version +""" + +from __future__ import annotations + +import enum +import functools +import typing +from typing import ( + TYPE_CHECKING, + Any, + Final, + Union, +) + +from ._version_utils import coerce_version, trim_release +from .version import InvalidVersion, Version + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable, Iterator, Sequence + + from .specifiers import Specifier, SpecifierSet + + +__all__ = ["VersionRange"] + + +def __dir__() -> list[str]: + return __all__ + + +#: The smallest possible PEP 440 version. No valid version is less than this. +_MIN_VERSION: Final[Version] = Version("0.dev0") + +#: Packed pickle form of a single bound: ``(version_str_or_None, +#: inclusive, kind_or_None)``. Uses only strings, bools, and ``None`` +#: so the format stays stable across packaging releases. +_PackedBound = tuple[Union[str, None], bool, Union[str, None]] + + +def _next_prefix_dev0(version: Version) -> Version: + """Smallest version in the next prefix: ``1.2 -> 1.3.dev0``.""" + release = (*version.release[:-1], version.release[-1] + 1) + return Version.from_parts(epoch=version.epoch, release=release, dev=0) + + +def _base_dev0(version: Version) -> Version: + """The ``.dev0`` of a version's base release: ``1.2 -> 1.2.dev0``.""" + return Version.from_parts(epoch=version.epoch, release=version.release, dev=0) + + +class _BoundaryKind(enum.Enum): + """Where a boundary marker sits in the version ordering.""" + + AFTER_LOCALS = enum.auto() # after V+local, before V.post0 + AFTER_POSTS = enum.auto() # after V.postN, before next release + + +@functools.total_ordering +class _BoundaryVersion: + """A synthetic point between two real PEP 440 versions. + + PEP 440 specifier semantics imply boundaries between real versions + (``<=1.0`` includes ``1.0+local``; ``>1.0`` excludes ``1.0.post0``). + Relative to a base version V:: + + V < V+local < AFTER_LOCALS(V) < V.post0 < AFTER_POSTS(V) + + AFTER_LOCALS is the upper bound of ``<=V``, ``==V``, ``!=V`` (no + local), and the lower bound of the upper-side range of ``!=V``. + AFTER_POSTS is the lower bound of ``>V`` (V final or pre-release), + excluding V's post-releases per PEP 440. + """ + + __slots__ = ( + "_cached_dev", + "_cached_epoch", + "_cached_post", + "_cached_pre", + "_cached_trimmed_release", + "_kind", + "version", + ) + + def __init__(self, version: Version, kind: _BoundaryKind) -> None: + self.version = version + self._kind = kind + self._cached_trimmed_release = trim_release(version.release) + self._cached_epoch = version.epoch + self._cached_pre = version.pre + self._cached_post = version.post + self._cached_dev = version.dev + + def _is_family(self, other: Version) -> bool: + """Is ``other`` a version that this boundary sorts above?""" + if other.epoch != self._cached_epoch: + return False + # Inline release-trim comparison: other.release matches the + # trimmed release iff its leading slice is equal and any extra + # components are zero. Avoids trim_release's tuple allocation. + other_release = other.release + trimmed_release = self._cached_trimmed_release + trimmed_length = len(trimmed_release) + if len(other_release) < trimmed_length: + return False + if other_release[:trimmed_length] != trimmed_release: + return False + for i in range(trimmed_length, len(other_release)): + if other_release[i] != 0: + return False + if other.pre != self._cached_pre: + return False + if self._kind == _BoundaryKind.AFTER_LOCALS: + # Local family: same public version, any local label. + return other.post == self._cached_post and other.dev == self._cached_dev + # Post family: V itself + any post-release of V. + return other.dev == self._cached_dev or other.post is not None + + def __eq__(self, other: object) -> bool: + if isinstance(other, _BoundaryVersion): + return self.version == other.version and self._kind == other._kind + return NotImplemented + + def __lt__(self, other: _BoundaryVersion | Version) -> bool: + if isinstance(other, _BoundaryVersion): + if self.version != other.version: + return self.version < other.version + return self._kind.value < other._kind.value # pragma: no cover + # boundary < other_version iff V < other AND other not in family. + # The cheap V >= other path short-circuits before the family check. + if not (self.version < other): + return False + return not self._is_family(other) + + def __gt__(self, other: _BoundaryVersion | Version) -> bool: + # Defined directly to bypass functools.total_ordering's + # NotImplemented round-trip on reflected ``Version < boundary``. + if isinstance(other, _BoundaryVersion): + if self.version != other.version: + return self.version > other.version + return self._kind.value > other._kind.value + if self.version >= other: + return True + return self._is_family(other) + + def __hash__(self) -> int: + return hash((self.version, self._kind)) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.version!r}, {self._kind.name})" + + +if TYPE_CHECKING: + _VersionOrBoundary = Union[Version, _BoundaryVersion, None] + + +def _make_above_after_posts(version: Version) -> Callable[[Version], bool]: + """Predicate ``parsed > AFTER_POSTS(v)`` for a lower bound. + + Per PEP 440, ``>V`` excludes V's post-releases unless V is itself + a post-release. AFTER_POSTS sits above V and every V.postN (with + or without local), and just below the next release. + """ + version_ge = version.__ge__ + version_epoch = version.epoch + version_pre = version.pre + version_dev = version.dev + version_release_trimmed = trim_release(version.release) + trimmed_length = len(version_release_trimmed) + + def above(parsed: Version) -> bool: + if version_ge(parsed): + return False + # parsed > v cmpkey-wise: above the boundary iff NOT in v's + # post family. + if parsed.epoch != version_epoch: + return True + parsed_release = parsed.release + if len(parsed_release) < trimmed_length: + return True + if parsed_release[:trimmed_length] != version_release_trimmed: + return True + for i in range(trimmed_length, len(parsed_release)): + if parsed_release[i] != 0: + return True + if parsed.pre != version_pre: + return True + # In post family iff: same dev as v (covers v itself + v+local), + # or any post-release (covers v.postN + v.postN+local). + if parsed.dev == version_dev or parsed.post is not None: + return False + # Different dev with no post means parsed sorts before v + # cmpkey-wise, in which case version_ge returned True already. + return False # pragma: no cover + + return above + + +def _make_above_after_locals(version: Version) -> Callable[[Version], bool]: + """Predicate ``parsed > AFTER_LOCALS(v)`` for a lower bound. + + Used by the upper-side range of ``!=v`` (when *v* has no local + segment). AFTER_LOCALS sits above v and every ``v+local`` but + just below ``v.post0``. + """ + version_ge = version.__ge__ + version_epoch = version.epoch + version_pre = version.pre + version_post = version.post + version_dev = version.dev + version_release_trimmed = trim_release(version.release) + trimmed_length = len(version_release_trimmed) + + def above(parsed: Version) -> bool: + if version_ge(parsed): + return False + # parsed > v cmpkey-wise: above the boundary iff NOT in v's + # local family (same public version, any local segment). + if parsed.epoch != version_epoch: + return True + parsed_release = parsed.release + if len(parsed_release) < trimmed_length: + return True + if parsed_release[:trimmed_length] != version_release_trimmed: + return True + for i in range(trimmed_length, len(parsed_release)): + if parsed_release[i] != 0: + return True + if parsed.pre != version_pre: + return True + if parsed.post != version_post: + return True + return parsed.dev != version_dev + + return above + + +def _make_below_after_locals(version: Version) -> Callable[[Version], bool]: + """Predicate ``parsed <= AFTER_LOCALS(v)`` for an upper bound. + + Used by ``<=v``, ``==v``, ``!=v`` (no local). ``parsed`` is at or + below the boundary when it is at or below v cmpkey-wise, or when + it is in v's local family. + """ + version_ge = version.__ge__ + version_epoch = version.epoch + version_pre = version.pre + version_post = version.post + version_dev = version.dev + version_release_trimmed = trim_release(version.release) + trimmed_length = len(version_release_trimmed) + + def below(parsed: Version) -> bool: + if version_ge(parsed): + return True + # parsed > v cmpkey-wise: below the boundary iff in v's local + # family. + if parsed.epoch != version_epoch: + return False + parsed_release = parsed.release + if len(parsed_release) < trimmed_length: + return False + if parsed_release[:trimmed_length] != version_release_trimmed: + return False + for i in range(trimmed_length, len(parsed_release)): + if parsed_release[i] != 0: + return False + if parsed.pre != version_pre: + return False + if parsed.post != version_post: + return False + return parsed.dev == version_dev + + return below + + +def _make_below_after_posts(version: Version) -> Callable[[Version], bool]: + """Predicate ``parsed <= AFTER_POSTS(v)`` for an upper bound. + + Mirror of :func:`_make_above_after_posts`. Produced only by + :meth:`VersionRange.complement` of a range whose lower bound is + AFTER_POSTS(v). ``parsed`` is at or below the boundary when it is + at or below v cmpkey-wise, or when it is in v's post family. + """ + version_ge = version.__ge__ + version_epoch = version.epoch + version_pre = version.pre + version_dev = version.dev + version_release_trimmed = trim_release(version.release) + trimmed_length = len(version_release_trimmed) + + def below(parsed: Version) -> bool: + if version_ge(parsed): + return True + # parsed > v cmpkey-wise: below the boundary iff in v's post family. + if parsed.epoch != version_epoch: + return False + parsed_release = parsed.release + if len(parsed_release) < trimmed_length: + return False + if parsed_release[:trimmed_length] != version_release_trimmed: + return False + for i in range(trimmed_length, len(parsed_release)): + if parsed_release[i] != 0: + return False + if parsed.pre != version_pre: + return False + # Same dev as v with no post means parsed sorts <= v already + # (handled by version_ge above); reach here only with parsed.post set. + return parsed.dev == version_dev or parsed.post is not None + + return below + + +@functools.total_ordering +class _LowerBound: + """Lower bound of a version range. + + A ``version`` of ``None`` is unbounded below (-inf). At equal + versions, ``[v`` sorts before ``(v`` (inclusive starts earlier). + """ + + __slots__ = ("_above", "inclusive", "version") + + def __init__(self, version: _VersionOrBoundary, inclusive: bool) -> None: + self.version = version + self.inclusive = inclusive + # Pre-bind a predicate "is parsed at or above this lower + # bound?" for the hot filter / contains loops. One direct + # call per check, no operator-dispatch chain. + if version is None: + self._above: Callable[[Version], bool] | None = None + elif isinstance(version, _BoundaryVersion): + # >v produces an AFTER_POSTS lower bound; the upper-side + # range of !=v produces an AFTER_LOCALS lower bound. + if version._kind == _BoundaryKind.AFTER_POSTS: + self._above = _make_above_after_posts(version.version) + else: + self._above = _make_above_after_locals(version.version) + elif inclusive: + self._above = version.__le__ + else: + self._above = version.__lt__ + + def __eq__(self, other: object) -> bool: + if not isinstance(other, _LowerBound): + return NotImplemented # pragma: no cover + return self.version == other.version and self.inclusive == other.inclusive + + def __lt__(self, other: _LowerBound) -> bool: + if not isinstance(other, _LowerBound): # pragma: no cover + return NotImplemented + # -inf < anything (except -inf itself). + if self.version is None: + return other.version is not None + if other.version is None: + return False + if self.version != other.version: + return self.version < other.version + # ``[v < (v``: inclusive starts earlier. + return self.inclusive and not other.inclusive + + def __hash__(self) -> int: + return hash((self.version, self.inclusive)) + + def __repr__(self) -> str: + bracket = "[" if self.inclusive else "(" + return f"<{self.__class__.__name__} {bracket}{self.version!r}>" + + +@functools.total_ordering +class _UpperBound: + """Upper bound of a version range. + + A ``version`` of ``None`` is unbounded above (+inf). At equal + versions, ``v)`` sorts before ``v]`` (exclusive ends earlier). + """ + + __slots__ = ("_below", "inclusive", "version") + + def __init__(self, version: _VersionOrBoundary, inclusive: bool) -> None: + self.version = version + self.inclusive = inclusive + # Pre-bind a predicate "is parsed at or below this upper + # bound?". See _LowerBound for the rationale. + if version is None: + self._below: Callable[[Version], bool] | None = None + elif isinstance(version, _BoundaryVersion): + # Standard specifiers only ever produce AFTER_LOCALS upper + # bounds (from <=v / ==v / !=v with no local). Complement + # reverses bound roles, so a range whose lower bound is + # AFTER_POSTS(v) becomes an upper bound after complementing. + if version._kind == _BoundaryKind.AFTER_LOCALS: + self._below = _make_below_after_locals(version.version) + else: + self._below = _make_below_after_posts(version.version) + elif inclusive: + self._below = version.__ge__ + else: + self._below = version.__gt__ + + def __eq__(self, other: object) -> bool: + if not isinstance(other, _UpperBound): + return NotImplemented # pragma: no cover + return self.version == other.version and self.inclusive == other.inclusive + + def __lt__(self, other: _UpperBound) -> bool: + if not isinstance(other, _UpperBound): # pragma: no cover + return NotImplemented + # Nothing < +inf (except +inf itself). + if self.version is None: + return False + if other.version is None: + return True + if self.version != other.version: + return self.version < other.version + # ``v) < v]``: exclusive ends earlier. + return not self.inclusive and other.inclusive + + def __hash__(self) -> int: + return hash((self.version, self.inclusive)) + + def __repr__(self) -> str: + bracket = "]" if self.inclusive else ")" + return f"<{self.__class__.__name__} {self.version!r}{bracket}>" + + +if TYPE_CHECKING: + #: A single contiguous version range, as a (lower, upper) pair. + _VersionRange = tuple[_LowerBound, _UpperBound] + + +_NEG_INF = _LowerBound(None, False) +_POS_INF = _UpperBound(None, False) +_FULL_RANGE: tuple[_VersionRange] = ((_NEG_INF, _POS_INF),) + + +def _range_is_empty(lower: _LowerBound, upper: _UpperBound) -> bool: + """True when the range ``(lower, upper)`` contains no versions.""" + if lower.version is None or upper.version is None: + return False + if lower.version == upper.version: + return not (lower.inclusive and upper.inclusive) + return lower.version > upper.version + + +def _intersect_ranges( + left: Sequence[_VersionRange], + right: Sequence[_VersionRange], +) -> list[_VersionRange]: + """Intersect two sorted, non-overlapping range lists (two-pointer merge).""" + result: list[_VersionRange] = [] + left_index = right_index = 0 + while left_index < len(left) and right_index < len(right): + left_lower, left_upper = left[left_index] + right_lower, right_upper = right[right_index] + + lower = max(left_lower, right_lower) + upper = min(left_upper, right_upper) + + if not _range_is_empty(lower, upper): + result.append((lower, upper)) + + # Advance whichever side has the smaller upper bound. + if left_upper < right_upper: + left_index += 1 + else: + right_index += 1 + + return result + + +def _union_ranges( + left: Sequence[_VersionRange], + right: Sequence[_VersionRange], +) -> list[_VersionRange]: + """Union two sorted, non-overlapping range lists. + + Linear merge over the two pre-sorted inputs followed by a single + coalescing pass: adjacent or overlapping ranges collapse so the + result is itself sorted and non-overlapping. + """ + if not left: + return list(right) + if not right: + return list(left) + + # Merge two sorted lists by lower bound (linear, no resort). + merged_input: list[_VersionRange] = [] + left_index = right_index = 0 + while left_index < len(left) and right_index < len(right): + if left[left_index][0] <= right[right_index][0]: + merged_input.append(left[left_index]) + left_index += 1 + else: + merged_input.append(right[right_index]) + right_index += 1 + merged_input.extend(left[left_index:]) + merged_input.extend(right[right_index:]) + + merged: list[_VersionRange] = [merged_input[0]] + for lower, upper in merged_input[1:]: + prev_lower, prev_upper = merged[-1] + + # Adjacent ranges merge when the previous upper sits at or past + # the new lower; +inf/-inf short-circuits collapse the + # unbounded cases. + if prev_upper.version is None: + overlaps = True + elif lower.version is None: + overlaps = True # pragma: no cover (merged_input sorted by lower) + elif prev_upper.version > lower.version: + overlaps = True + elif prev_upper.version == lower.version: + overlaps = prev_upper.inclusive or lower.inclusive + else: + overlaps = False + + if overlaps: + new_upper = max(prev_upper, upper) + merged[-1] = (prev_lower, new_upper) + else: + merged.append((lower, upper)) + + return merged + + +def _complement_ranges( + ranges: Sequence[_VersionRange], +) -> list[_VersionRange]: + """Complement a sorted, non-overlapping range list. + + Yields the gaps between ranges plus a leading gap before the first + range and a trailing gap after the last. Bound inclusivity flips + so complement-of-complement round-trips back to the input. + """ + if not ranges: + return list(_FULL_RANGE) + + result: list[_VersionRange] = [] + prev_upper: _UpperBound | None = None + + for lower, upper in ranges: + if prev_upper is None: + # Leading gap from -inf up to the first range's lower. + if lower.version is not None: + gap_upper = _UpperBound(lower.version, not lower.inclusive) + result.append((_NEG_INF, gap_upper)) + else: + gap_lower = _LowerBound(prev_upper.version, not prev_upper.inclusive) + gap_upper = _UpperBound(lower.version, not lower.inclusive) + # Adjacent ranges in the input are non-touching by + # construction, so the gap between them is non-empty. + if not _range_is_empty(gap_lower, gap_upper): # pragma: no branch + result.append((gap_lower, gap_upper)) + prev_upper = upper + + # Trailing gap from the final range's upper to +inf. + if prev_upper is not None and prev_upper.version is not None: + gap_lower = _LowerBound(prev_upper.version, not prev_upper.inclusive) + result.append((gap_lower, _POS_INF)) + + return result + + +def _filter_by_ranges( + ranges: Sequence[_VersionRange], + iterable: Iterable[Any], + key: Callable[[Any], Version | str] | None, + prereleases: bool | None, +) -> Iterator[Any]: + """Filter *iterable* against precomputed version *ranges*. + + With ``prereleases=None``, the PEP 440 default applies: pre-releases + are excluded unless no final matches, in which case buffered + pre-releases come out at the end. + """ + if prereleases is None: + # PEP 440 default: yield finals immediately; buffer + # pre-releases until at least one final has been emitted. + nonfinal_buffer: list[Any] = [] + found_final = False + + if len(ranges) == 1: + lower, upper = ranges[0] + above = lower._above + below = upper._below + for item in iterable: + parsed = coerce_version(item if key is None else key(item)) + if parsed is None: + continue + if above is not None and not above(parsed): + continue + if below is not None and not below(parsed): + continue + if parsed.is_prerelease: + if not found_final: + nonfinal_buffer.append(item) + else: + found_final = True + yield item + if not found_final: + yield from nonfinal_buffer + return + + for item in iterable: + parsed = coerce_version(item if key is None else key(item)) + if parsed is None: + continue + for lower, upper in ranges: + above = lower._above + if above is not None and not above(parsed): + break + below = upper._below + if below is None or below(parsed): + if parsed.is_prerelease: + if not found_final: + nonfinal_buffer.append(item) + else: + found_final = True + yield item + break + if not found_final: + yield from nonfinal_buffer + return + + exclude_prereleases = prereleases is False + + if len(ranges) == 1: + # Hot path: most specifiers and small SpecifierSets reduce to + # a single contiguous range. + lower, upper = ranges[0] + above = lower._above + below = upper._below + for item in iterable: + parsed = coerce_version(item if key is None else key(item)) + if parsed is None: + continue + if exclude_prereleases and parsed.is_prerelease: + continue + if above is not None and not above(parsed): + continue + if below is None or below(parsed): + yield item + return + + for item in iterable: + parsed = coerce_version(item if key is None else key(item)) + if parsed is None: + continue + if exclude_prereleases and parsed.is_prerelease: + continue + for lower, upper in ranges: + above = lower._above + if above is not None and not above(parsed): + break + below = upper._below + if below is None or below(parsed): + yield item + break + + +def _matches_bounds_only( + bounds: Sequence[_VersionRange], + item: Version, +) -> bool: + """Pure-bounds membership check for a parsed Version.""" + if not bounds: + return False + if len(bounds) == 1: + lower, upper = bounds[0] + above = lower._above + if above is not None and not above(item): + return False + below = upper._below + return below is None or below(item) + for lower, upper in bounds: + above = lower._above + if above is not None and not above(item): + return False + below = upper._below + if below is None or below(item): + return True + return False + + +def _bound_match_string(bounds: Sequence[_VersionRange], s: str) -> bool: + """Bound-only check for the case-folded string *s*. + + Full-range bounds admit any string. Other shapes require *s* to + parse and fall inside the intervals. + """ + if tuple(bounds) == _FULL_RANGE: + return True + parsed = coerce_version(s) + if parsed is None: + return False + return _matches_bounds_only(bounds, parsed) + + +def _lowest_release_at_or_above( + value: Version | _BoundaryVersion | None, +) -> Version | None: + """Smallest non-pre-release version at or above *value*, or None.""" + if value is None: + return None + if isinstance(value, _BoundaryVersion): + inner_version = value.version + if inner_version.is_prerelease: + # AFTER_LOCALS(1.0a1) -> nearest non-pre is 1.0 + return inner_version.__replace__(pre=None, dev=None, local=None) + # AFTER_LOCALS(1.0) -> nearest non-pre is 1.0.post0 + # AFTER_LOCALS(1.0.post0) -> nearest non-pre is 1.0.post1 + next_post = (inner_version.post + 1) if inner_version.post is not None else 0 + return inner_version.__replace__(post=next_post, local=None) + if not value.is_prerelease: + return value + # Strip pre/dev to get the final or post-release form. + return value.__replace__(pre=None, dev=None, local=None) + + +def _ranges_are_prerelease_only(ranges: Sequence[_VersionRange]) -> bool: + """``True`` when every range in *ranges* contains only pre-releases. + + Used to detect unsatisfiable specifier sets when ``prereleases=False``: + if every range is pre-release-only, every contained version is excluded. + """ + for lower, upper in ranges: + nearest = _lowest_release_at_or_above(lower.version) + if nearest is None: + return False + if upper.version is None or nearest < upper.version: + return False + if nearest == upper.version and upper.inclusive: + return False + return True + + +def _wildcard_ranges(op: str, base: Version) -> list[_VersionRange]: + """Ranges for ``==V.*`` and ``!=V.*``. + + ``==1.2.*`` -> ``[1.2.dev0, 1.3.dev0)``; ``!=1.2.*`` -> complement. + """ + lower = _base_dev0(base) + upper = _next_prefix_dev0(base) + if op == "==": + return [(_LowerBound(lower, True), _UpperBound(upper, False))] + # != + return [ + (_NEG_INF, _UpperBound(lower, False)), + (_LowerBound(upper, True), _POS_INF), + ] + + +def _standard_ranges(op: str, version: Version, has_local: bool) -> list[_VersionRange]: + """Ranges for the standard PEP 440 operators (no wildcard, no ===). + + *has_local* indicates whether the spec string included a ``+local`` + segment; relevant only for ``==`` / ``!=`` to decide whether the + upper bound includes V's local family. + """ + if op == ">=": + return [(_LowerBound(version, True), _POS_INF)] + + if op == "<=": + return [ + ( + _NEG_INF, + _UpperBound( + _BoundaryVersion(version, _BoundaryKind.AFTER_LOCALS), True + ), + ) + ] + + if op == ">": + if version.dev is not None: + # >V.devN: dev versions have no post-releases, so the + # next real version is V.dev(N+1). + lower_bound = version.__replace__(dev=version.dev + 1, local=None) + return [(_LowerBound(lower_bound, True), _POS_INF)] + if version.post is not None: + # >V.postN: next real version is V.post(N+1).dev0. + lower_bound = version.__replace__(post=version.post + 1, dev=0, local=None) + return [(_LowerBound(lower_bound, True), _POS_INF)] + # >V (final or pre-release V): exclude V itself, V+local, and + # every V.postN per PEP 440. + return [ + ( + _LowerBound( + _BoundaryVersion(version, _BoundaryKind.AFTER_POSTS), False + ), + _POS_INF, + ) + ] + + if op == "<": + # str: + if bound.version is None: + return "(-inf" + bracket = "[" if bound.inclusive else "(" + inner = ( + bound.version.version + if isinstance(bound.version, _BoundaryVersion) + else bound.version + ) + return f"{bracket}{inner}" + + +def _format_upper(bound: _UpperBound) -> str: + if bound.version is None: + return "+inf)" + bracket = "]" if bound.inclusive else ")" + inner = ( + bound.version.version + if isinstance(bound.version, _BoundaryVersion) + else bound.version + ) + return f"{inner}{bracket}" + + +def _pack_bound(bound: _LowerBound | _UpperBound) -> _PackedBound: + """Serialize a bound to a primitive triple. See _PackedBound.""" + bound_version = bound.version + if bound_version is None: + return (None, bound.inclusive, None) + if isinstance(bound_version, _BoundaryVersion): + return (str(bound_version.version), bound.inclusive, bound_version._kind.name) + return (str(bound_version), bound.inclusive, None) + + +def _unpack_bound( + cls: type[_LowerBound | _UpperBound], + packed: _PackedBound, +) -> _LowerBound | _UpperBound: + """Reverse of _pack_bound.""" + version_str, inclusive, kind_name = packed + if version_str is None: + return cls(None, inclusive) + base = Version(version_str) + if kind_name is not None: + return cls(_BoundaryVersion(base, _BoundaryKind[kind_name]), inclusive) + return cls(base, inclusive) + + +def _restore_version_range( + packed_bounds: tuple[tuple[_PackedBound, _PackedBound], ...], + arbitrary: str | None = None, + admit: tuple[str, ...] | None = None, + reject: tuple[str, ...] | None = None, +) -> VersionRange: + """Pickle restorer; bypasses the ``__new__`` guard via ``_build``. + + The ``arbitrary`` arg is the pre-admit/reject slot from earlier + betas. New pickles pass ``admit`` and ``reject`` instead. The + matched set is preserved either way. + """ + bounds = tuple( + ( + typing.cast("_LowerBound", _unpack_bound(_LowerBound, lower)), + typing.cast("_UpperBound", _unpack_bound(_UpperBound, upper)), + ) + for lower, upper in packed_bounds + ) + if admit is not None or reject is not None: + return VersionRange._build( + bounds, + admit=frozenset(admit or ()), + reject=frozenset(reject or ()), + ) + if arbitrary is None: + return VersionRange._build(bounds) + # Legacy ``arbitrary`` matched ``{arbitrary}`` if the literal was + # in bounds, empty otherwise. + literal_lower = arbitrary.lower() + legacy_range = VersionRange._build(bounds) + if literal_lower in legacy_range: + return VersionRange._build((), admit=frozenset({literal_lower})) + return VersionRange._build(()) + + +# VersionRange to SpecifierSet conversion is partial: not every range +# has a SpecifierSet form. Examples that have no single specifier: +# - PEP 440 ``=V`` (which keeps those pre-releases) has no +# single specifier. +# - PEP 440 ``==V`` matches ``V+local`` too, so the strict singleton +# ``[V, V]`` produced by :meth:`VersionRange.singleton` has none. +# - Disjoint unions whose gap is not a complete ``==V.*`` family or a +# ``==V`` family cannot be expressed as ``base & !=...``. + + +def _is_dev0_version(v: Version) -> bool: + """``True`` when *v* is exactly ``X[.Y]*.dev0``: the form `` list[str] | _NotEncodable: + """Encode a lower bound as a list of specifier fragments. + + ``[]`` for ``-inf``, one or more fragments otherwise, or + ``_NOT_ENCODABLE`` when the shape has no specifier form. + AFTER_LOCALS lower bounds emit two fragments (``>=V`` plus + ``!=V``) since the boundary excludes V and every V+local. + """ + lower_version = lower.version + if lower_version is None: + return [] + if isinstance(lower_version, _BoundaryVersion): + if lower_version._kind == _BoundaryKind.AFTER_POSTS and not lower.inclusive: + return [f">{lower_version.version}"] + if lower_version._kind == _BoundaryKind.AFTER_LOCALS: + # Strictly above V's local family. ``>=V,!=V`` produces + # ``[V, +inf)`` minus ``[V, AFTER_LOCALS(V)]``, leaving + # ``(AFTER_LOCALS(V), +inf)``. + return [f">={lower_version.version}", f"!={lower_version.version}"] + # AFTER_POSTS lower with inclusive=True is unreachable from + # any specifier or set-algebra operation; defensive guard. + return _NOT_ENCODABLE # pragma: no cover + if lower.inclusive: + return [f">={lower_version}"] + return _NOT_ENCODABLE + + +def _encode_upper(upper: _UpperBound) -> list[str] | _NotEncodable: + """Encode an upper bound as a list of specifier fragments. + + ``[]`` for ``+inf``, one or more fragments otherwise, or + ``_NOT_ENCODABLE`` when the shape has no specifier form. + """ + upper_version = upper.version + if upper_version is None: + return [] + if isinstance(upper_version, _BoundaryVersion): + if upper_version._kind == _BoundaryKind.AFTER_LOCALS and upper.inclusive: + return [f"<={upper_version.version}"] + return _NOT_ENCODABLE + if not upper.inclusive: + if _is_dev0_version(upper_version): + # list[str] | None: + """Encode one interval as a list of specifier fragments, or ``None``. + + Special-cases ``[V, V]`` (singleton interval) when V carries a + local segment: ``==V+local`` matches only that literal, so the + interval round-trips. Without a local, no specifier form exists + (``==V`` is wider since it also matches ``V+local``). + """ + if ( + lower.version is not None + and upper.version is not None + and not isinstance(lower.version, _BoundaryVersion) + and not isinstance(upper.version, _BoundaryVersion) + and lower.inclusive + and upper.inclusive + and lower.version == upper.version + and lower.version.local is not None + ): + return [f"=={lower.version}"] + lower_parts = _encode_lower(lower) + if isinstance(lower_parts, _NotEncodable): + return None + upper_parts = _encode_upper(upper) + if isinstance(upper_parts, _NotEncodable): + return None + return lower_parts + upper_parts + + +def _detect_not_equal( + left_upper: _UpperBound, + right_lower: _LowerBound, +) -> Version | None: + """If ``[..., V (excl)] [AFTER_LOCALS(V) (excl), ...]`` matches, return V. + + The gap shape ``!=V`` produces when intersected with surrounding + bounds. Only ``!=V`` pattern that can appear inside a multi-interval + range. + """ + if isinstance(left_upper.version, _BoundaryVersion): + return None + if left_upper.version is None or left_upper.inclusive: + return None + if not isinstance(right_lower.version, _BoundaryVersion): + return None + if right_lower.version._kind != _BoundaryKind.AFTER_LOCALS: + return None + if right_lower.inclusive: + # AFTER_LOCALS lower with inclusive=True does not arise from + # any specifier or set-algebra operation; defensive guard. + return None # pragma: no cover + if right_lower.version.version != left_upper.version: + # The ``!=V`` pattern is contiguous; mismatched V means a union + # of unrelated ranges. Defensive. + return None # pragma: no cover + return left_upper.version + + +def _detect_not_equal_wildcard( + left_upper: _UpperBound, + right_lower: _LowerBound, +) -> Version | None: + """If ``[..., V.dev0 (excl)] [V_next.dev0 (incl), ...]`` matches, return V. + + The gap shape ``!=V.*`` produces. ``V`` and ``V_next`` share an + epoch and a release prefix differing only in the final component + being incremented by one. Returns the prefix version (without the + synthetic ``.dev0``) so the caller can write ``!=V.*``. + """ + left_upper_v = left_upper.version + right_lower_v = right_lower.version + if isinstance(left_upper_v, _BoundaryVersion) or isinstance( + right_lower_v, _BoundaryVersion + ): + return None + if left_upper_v is None or right_lower_v is None: + # First-interval upper or last-interval lower at infinity means + # the interval is the universe and no second interval exists. + return None # pragma: no cover + if left_upper.inclusive or not right_lower.inclusive: + return None + if not (_is_dev0_version(left_upper_v) and _is_dev0_version(right_lower_v)): + return None + if left_upper_v.epoch != right_lower_v.epoch: + return None + left_release = left_upper_v.release + right_release = right_lower_v.release + if len(left_release) != len(right_release) or not left_release: + return None + # All components except the last must match; the last increments by 1. + if left_release[:-1] != right_release[:-1]: + return None + if right_release[-1] != left_release[-1] + 1: + return None + return left_upper_v.__replace__(dev=None) + + +class VersionRange: + """A set of :class:`~packaging.version.Version` values, expressed as + a union of disjoint intervals on the PEP 440 version ordering. + + Construct with :meth:`from_specifier` / :meth:`from_specifier_set`, + or via :meth:`Specifier.to_range` / :meth:`SpecifierSet.to_range`. + Compose with :meth:`intersection`, :meth:`union`, :meth:`complement` + (and the ``&`` / ``|`` / ``~`` operator aliases). + + >>> r = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + >>> "1.5" in r + True + >>> "2.0" in r + False + >>> bool(VersionRange.from_specifier_set(SpecifierSet(">=2.0,<1.0"))) + False + + PEP 440's ``===`` operator matches a candidate string verbatim + (case-insensitive) rather than a set of :class:`Version` values. + Ranges built from ``===`` specifiers still support membership, + set operations, and conversion back to a :class:`SpecifierSet`; + matching follows the literal-equality rule instead of the + version-ordering rule. + """ + + __slots__ = ("_admit", "_bounds", "_is_simple", "_reject") + _bounds: tuple[_VersionRange, ...] + #: Whether :meth:`filter` can dispatch straight to the bounds-only + #: filter: no admit/reject literals and bounds aren't the full range. + _is_simple: bool + #: Case-folded strings the range admits in addition to its bounds. + #: ``===wat`` produces ``_admit = {"wat"}``. + _admit: frozenset[str] + #: Case-folded strings the range rejects. Overrides ``_admit`` and + #: ``_bounds``. Populated by :meth:`complement` of a range whose + #: ``_admit`` was non-empty. + _reject: frozenset[str] + + def __new__(cls, *args: object, **kwargs: object) -> VersionRange: # noqa: PYI034 + raise TypeError( + "cannot create 'VersionRange' instances directly; use " + "VersionRange.from_specifier(), " + "VersionRange.from_specifier_set(), " + "Specifier.to_range(), or SpecifierSet.to_range() instead" + ) + + @classmethod + def _build( + cls, + bounds: tuple[_VersionRange, ...], + admit: frozenset[str] = frozenset(), + reject: frozenset[str] = frozenset(), + ) -> VersionRange: + """Internal factory; bypasses :meth:`__new__`. + + Drops admit literals already covered by bounds and reject + literals already outside bounds. Reject wins over admit on + overlap. + """ + if admit and reject: + admit = admit - reject + if admit: + admit = frozenset(s for s in admit if not _bound_match_string(bounds, s)) + if reject: + reject = frozenset(s for s in reject if _bound_match_string(bounds, s)) + instance = object.__new__(cls) + instance._bounds = bounds + instance._admit = admit + instance._reject = reject + # Pure-bound range: filter can skip the admission dispatch. + instance._is_simple = not admit and not reject and bounds != _FULL_RANGE + return instance + + def _has_literals(self) -> bool: + """``True`` when ``_admit`` or ``_reject`` is non-empty.""" + return bool(self._admit) or bool(self._reject) + + @classmethod + def empty(cls) -> VersionRange: + """Return the empty range. No version satisfies it. + + >>> VersionRange.empty().is_empty + True + >>> "1.0" in VersionRange.empty() + False + """ + return cls._build(()) + + @classmethod + def full(cls) -> VersionRange: + """Return the full range. Every PEP 440 version satisfies it. + + >>> "1.0" in VersionRange.full() + True + >>> VersionRange.full().is_empty + False + """ + return cls._build(_FULL_RANGE) + + @classmethod + def singleton(cls, version: Version | str) -> VersionRange: + """Return the range that contains only *version*. + + >>> r = VersionRange.singleton("1.2.3") + >>> "1.2.3" in r + True + >>> "1.2.4" in r + False + + :raises packaging.version.InvalidVersion: if *version* is a + string that does not parse as a PEP 440 version. + """ + if not isinstance(version, Version): + version = Version(version) + lower = _LowerBound(version, True) + upper = _UpperBound(version, True) + return cls._build(((lower, upper),)) + + def intersection(self, other: VersionRange) -> VersionRange: + """Range containing exactly the versions in both *self* and *other*. + + >>> a = VersionRange.from_specifier_set(SpecifierSet(">=1.0")) + >>> b = VersionRange.from_specifier_set(SpecifierSet("<2.0")) + >>> ab = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + >>> a.intersection(b) == ab + True + """ + if not self._has_literals() and not other._has_literals(): + return self._build(tuple(_intersect_ranges(self._bounds, other._bounds))) + new_bounds = tuple(_intersect_ranges(self._bounds, other._bounds)) + return self._combine_literals(other, new_bounds, intersect=True) + + def union(self, other: VersionRange) -> VersionRange: + """Range containing every version in *self* or *other*. + + >>> a = VersionRange.singleton("1.0") + >>> b = VersionRange.singleton("2.0") + >>> "1.0" in a.union(b) and "2.0" in a.union(b) + True + >>> "1.5" in a.union(b) + False + """ + if not self._has_literals() and not other._has_literals(): + return self._build(tuple(_union_ranges(self._bounds, other._bounds))) + new_bounds = tuple(_union_ranges(self._bounds, other._bounds)) + return self._combine_literals(other, new_bounds, intersect=False) + + def complement(self) -> VersionRange: + """Range containing every version *not* in *self*. + + >>> r = VersionRange.from_specifier(Specifier(">=1.0")) + >>> "0.5" in r.complement() + True + >>> "1.5" in r.complement() + False + >>> r.complement().complement() == r + True + """ + if not self._has_literals(): + return self._build(tuple(_complement_ranges(self._bounds))) + # Swap the admit and reject sets, complement the bounds. + # ``_build`` drops anything now redundant against the new bounds. + return self._build( + tuple(_complement_ranges(self._bounds)), + admit=self._reject, + reject=self._admit, + ) + + def _combine_literals( + self, + other: VersionRange, + new_bounds: tuple[_VersionRange, ...], + *, + intersect: bool, + ) -> VersionRange: + """Resolve admit/reject for ``self & other`` or ``self | other``. + + The bound-only result is already in *new_bounds*. For each + literal seen on either side, decide whether the combined + predicate (AND for intersection, OR for union) admits it, then + record an explicit admit or reject when the new bounds would + give the wrong answer on their own. + """ + admits: set[str] = set() + rejects: set[str] = set() + for literal in self._admit | self._reject | other._admit | other._reject: + self_in = self._matches_literal(literal) + other_in = other._matches_literal(literal) + want = (self_in and other_in) if intersect else (self_in or other_in) + bound_in = _bound_match_string(new_bounds, literal) + if want and not bound_in: + admits.add(literal) + elif not want and bound_in: + rejects.add(literal) + return self._build( + new_bounds, admit=frozenset(admits), reject=frozenset(rejects) + ) + + def _matches_literal(self, literal: str) -> bool: + """Whether *literal* (case-folded) matches this range's predicate.""" + if literal in self._reject: + return False + if literal in self._admit: + return True + return _bound_match_string(self._bounds, literal) + + def __and__(self, other: object) -> VersionRange: + """Operator alias for :meth:`intersection`.""" + if not isinstance(other, VersionRange): + return NotImplemented + return self.intersection(other) + + def __or__(self, other: object) -> VersionRange: + """Operator alias for :meth:`union`.""" + if not isinstance(other, VersionRange): + return NotImplemented + return self.union(other) + + def __invert__(self) -> VersionRange: + """Operator alias for :meth:`complement`.""" + return self.complement() + + def filter( + self, + iterable: Iterable[Any], + key: Callable[[Any], Version | str] | None = None, + prereleases: bool | None = None, + ) -> Iterator[Any]: + """Yield items from *iterable* whose version falls inside the range. + + With *prereleases* ``None`` the PEP 440 default applies: + pre-releases are buffered and only emitted if no final release + in *iterable* is in range. + + Filtering matches :class:`SpecifierSet.filter` for the same + :class:`Specifier` / :class:`SpecifierSet`, including + :class:`SpecifierSet("")`'s admission of unparsable strings + and the case-insensitive literal match for ``===``. + + >>> r = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + >>> list(r.filter(["0.9", "1.5", "2.0"])) + ['1.5'] + """ + if self._is_simple: + return _filter_by_ranges(self._bounds, iterable, key, prereleases) + return self._filter_with_admission(iterable, key, prereleases) + + def _filter_with_admission( + self, + iterable: Iterable[Any], + key: Callable[[Any], Version | str] | None, + prereleases: bool | None, + ) -> Iterator[Any]: + """Filter for ranges that admit unparsable strings. + + Used by ``===`` ranges (literal admit/reject) and the full-range + carve-out. Same PEP 440 pre-release buffering for both, with a + different admission check. + """ + admit_set = self._admit + reject_set = self._reject + full_bounds = self._bounds == _FULL_RANGE + + def admit(item: Any) -> tuple[bool, Version | None]: # noqa: ANN401 + raw: Version | str = item if key is None else key(item) + raw_lower = str(raw).lower() + if reject_set and raw_lower in reject_set: + return False, None + if admit_set and raw_lower in admit_set: + return True, coerce_version(raw) + parsed = coerce_version(raw) + if parsed is None: + return full_bounds, None + if not full_bounds and not self._matches_bounds(parsed): + return False, None + return True, parsed + + if prereleases is True: + for item in iterable: + ok, _ = admit(item) + if ok: + yield item + return + + if prereleases is False: + for item in iterable: + ok, parsed = admit(item) + if not ok: + continue + if parsed is not None and parsed.is_prerelease: + continue + yield item + return + + # PEP 440 default: yield finals immediately; buffer the rest + # until we know whether any final exists. + all_nonfinal: list[Any] = [] + arbitrary_strings: list[Any] = [] + found_final = False + for item in iterable: + ok, parsed = admit(item) + if not ok: + continue + if parsed is None: + if found_final: + yield item + else: + arbitrary_strings.append(item) + all_nonfinal.append(item) + continue + if not parsed.is_prerelease: + if not found_final: + yield from arbitrary_strings + arbitrary_strings.clear() + found_final = True + yield item + continue + if not found_final: + all_nonfinal.append(item) + if not found_final: + yield from all_nonfinal + + @classmethod + def from_specifier(cls, specifier: Specifier) -> VersionRange: + """Return the :class:`VersionRange` accepted by *specifier*. + + Results are cached on the *specifier* instance. + + >>> isinstance(VersionRange.from_specifier(Specifier(">=1.0")), VersionRange) + True + """ + cached = specifier._range_cache + if cached is not None: + return cached + + op = specifier.operator + if op == "===": + arb_result = cls._build((), admit=frozenset({specifier.version.lower()})) + specifier._range_cache = arb_result + return arb_result + + ver_str = specifier.version + result: VersionRange + if ver_str.endswith(".*"): + base = specifier._require_spec_version(ver_str[:-2]) + result = cls._build(tuple(_wildcard_ranges(op, base))) + else: + version = specifier._require_spec_version(ver_str) + has_local = "+" in ver_str + result = cls._build(tuple(_standard_ranges(op, version, has_local))) + + specifier._range_cache = result + return result + + @classmethod + def from_specifier_set(cls, specifier_set: SpecifierSet) -> VersionRange: + """Return the :class:`VersionRange` accepted by *specifier_set*. + + The intersection of every specifier in the set. An empty + :class:`SpecifierSet` yields the unbounded range; an + unsatisfiable set yields an empty :class:`VersionRange`. + Results are cached on the *specifier_set* instance. + + >>> isinstance( + ... VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")), + ... VersionRange, + ... ) + True + >>> VersionRange.from_specifier_set(SpecifierSet(">=2.0,<1.0")).is_empty + True + """ + cached = specifier_set._range_cache + if cached is not None: + return cached + + # Hot path: a single rangelike spec needs no partitioning or + # intersection; its own range is the answer. + specs = specifier_set._specs + if len(specs) == 1 and not specifier_set._has_arbitrary: + single = cls.from_specifier(specs[0]) + specifier_set._range_cache = single + return single + + # ``===`` literals are handled separately from rangelike specs: + # the rangelike specs build the bounds, and a single literal + # is admitted only if it also satisfies those bounds. + arbitrary_specs = [s for s in specs if s.operator == "==="] + rangelike_specs = [s for s in specs if s.operator != "==="] + + if not rangelike_specs: + rangelike_result: VersionRange = cls._build(_FULL_RANGE) + else: + tmp: VersionRange | None = None + for s in rangelike_specs: + sub = cls.from_specifier(s) + if tmp is None: + tmp = sub + else: + tmp = tmp.intersection(sub) + if tmp.is_empty: + break + assert tmp is not None + rangelike_result = tmp + + if not arbitrary_specs: + specifier_set._range_cache = rangelike_result + return rangelike_result + + # Each ``===L_i`` requires the candidate's string to equal L_i. + # Distinct literals can never all match, so the result is empty. + literals_lower = {s.version.lower() for s in arbitrary_specs} + result: VersionRange + if len(literals_lower) > 1: + result = cls._build(()) + else: + (literal_lower,) = literals_lower + if literal_lower in rangelike_result: + result = cls._build((), admit=frozenset({literal_lower})) + else: + result = cls._build(()) + + specifier_set._range_cache = result + return result + + def to_specifier_set(self) -> SpecifierSet | None: + """Return a single :class:`SpecifierSet` whose + :meth:`from_specifier_set` yields *self*, or ``None`` if no + such set exists. + + :class:`SpecifierSet` cannot express every range. PEP 440's + operator set has no syntax for the strict singleton ``{V}`` or + for the bounds produced by complementing ``>V``; for those + ranges the result is ``None``. Use :meth:`to_specifier_sets` + when a tuple of specifier sets is acceptable. The empty range + maps to ``SpecifierSet("<0")`` (``<0`` excludes ``0.dev0``, + the smallest PEP 440 version); the full range maps to + ``SpecifierSet("")``. + + >>> r = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + >>> str(r.to_specifier_set()) + '<2.0,>=1.0' + >>> VersionRange.singleton("1.5").to_specifier_set() is None + True + """ + # Local import avoids the circular .specifiers <-> .ranges load. + from .specifiers import SpecifierSet # noqa: PLC0415 + + if self._reject: + # No PEP 440 operator excludes a literal string while + # admitting other versions. + return None + if self._admit: + return self._admit_to_specifier_set() + if self.is_empty: + # ``<0`` parses to upper = 0.dev0 (excl), the smallest + # possible PEP 440 version, so the range contains nothing. + return SpecifierSet("<0") + if self._bounds == _FULL_RANGE: + return SpecifierSet("") + + # Walk left-to-right, merging adjacent intervals whose gap is + # a ``!=V`` or ``!=V.*`` exclusion. The merged outer bounds + # plus the chain of ``!=`` fragments form a single SpecifierSet. + bounds = list(self._bounds) + outer_lower = bounds[0][0] + outer_upper = bounds[0][1] + exclusions: list[str] = [] + for next_lower, next_upper in bounds[1:]: + not_equal = _detect_not_equal(outer_upper, next_lower) + not_equal_wildcard = _detect_not_equal_wildcard(outer_upper, next_lower) + if not_equal is not None: + exclusions.append(f"!={not_equal}") + elif not_equal_wildcard is not None: + exclusions.append(f"!={not_equal_wildcard}.*") + else: + return None + outer_upper = next_upper + + outer_parts = _encode_interval(outer_lower, outer_upper) + if outer_parts is None: + return None + return SpecifierSet(",".join(outer_parts + exclusions)) + + def to_specifier_sets(self) -> tuple[SpecifierSet, ...] | None: + """Return a tuple of :class:`SpecifierSet` whose union equals + *self*, or ``None`` if no such tuple exists. + + Looser than :meth:`to_specifier_set`: a range that fits a + single :class:`SpecifierSet` returns a one-tuple, otherwise + each interval encodes separately. ``None`` only for ranges + whose individual intervals still have no PEP 440 specifier + (for example the singleton produced by :meth:`singleton`). + + >>> r = ( + ... VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + ... | VersionRange.from_specifier_set(SpecifierSet(">=3.0,<4.0")) + ... ) + >>> [str(s) for s in r.to_specifier_sets()] + ['<2.0,>=1.0', '<4.0,>=3.0'] + >>> VersionRange.singleton("1.5").to_specifier_sets() is None + True + """ + from .specifiers import SpecifierSet # noqa: PLC0415 + + if self._reject: + return None + if self._admit: + single = self._admit_to_specifier_set() + if single is None: + return None + return (single,) + if self.is_empty: + return (SpecifierSet("<0"),) + if self._bounds == _FULL_RANGE: + return (SpecifierSet(""),) + + # Prefer the single-set form when it exists; that catches + # multi-interval ``!=V`` / ``!=V.*`` patterns the per-interval + # encoder rejects. + single = self.to_specifier_set() + if single is not None: + return (single,) + + out: list[SpecifierSet] = [] + for lower, upper in self._bounds: + parts = _encode_interval(lower, upper) + if parts is None: + return None + out.append(SpecifierSet(",".join(parts))) + return tuple(out) + + def _admit_to_specifier_set(self) -> SpecifierSet | None: + """Encode a single ``===L`` range as ``SpecifierSet("===L")``. + + Returns ``None`` for shapes PEP 440 cannot express: multiple + admit literals (no ``=== A or === B`` syntax), or admit + combined with a non-empty bound set. + """ + from .specifiers import SpecifierSet # noqa: PLC0415 + + if len(self._admit) != 1 or self._bounds: + return None + (literal,) = self._admit + return SpecifierSet(f"==={literal}") + + def __reduce__(self) -> tuple[object, ...]: + # Pickle to a primitive form (see ``_PackedBound``). The legacy + # ``arbitrary`` slot is kept for older restorer signatures. + return ( + _restore_version_range, + ( + tuple( + (_pack_bound(lower), _pack_bound(upper)) + for lower, upper in self._bounds + ), + None, + tuple(sorted(self._admit)), + tuple(sorted(self._reject)), + ), + ) + + @property + def is_empty(self) -> bool: + """``True`` if no version or string satisfies this range. + + >>> VersionRange.from_specifier_set(SpecifierSet(">=2,<1")).is_empty + True + >>> VersionRange.from_specifier_set(SpecifierSet(">=1,<2")).is_empty + False + """ + return not self._bounds and not self._admit + + @property + def is_prerelease_only(self) -> bool: + """``True`` when every match is a PEP 440 pre-release. + + Used by :meth:`SpecifierSet.is_unsatisfiable` to detect sets + that admit no candidate under the default ``prereleases=False`` + reading. Returns ``False`` for the empty range. + + >>> r = VersionRange.from_specifier_set(SpecifierSet(">=1.0a1,<1.0rc1")) + >>> r.is_prerelease_only + True + >>> VersionRange.from_specifier(Specifier(">=1.0")).is_prerelease_only + False + """ + if self.is_empty: + return False + if self._reject: + return False + for literal in self._admit: + parsed = coerce_version(literal) + if parsed is None or not parsed.is_prerelease: + return False + if self._bounds: + return _ranges_are_prerelease_only(self._bounds) + return True + + def __bool__(self) -> bool: + """``False`` when the range is empty, ``True`` otherwise. + + >>> bool(VersionRange.from_specifier_set(SpecifierSet(">=1,<2"))) + True + >>> bool(VersionRange.from_specifier_set(SpecifierSet(">=2,<1"))) + False + """ + return bool(self._bounds) or bool(self._admit) + + def __contains__(self, item: Version | str) -> bool: + """Return whether *item* is contained in this range. + + Unparsable strings do not match, except where + :class:`SpecifierSet` would also match: the full range admits + any string, and a ``===`` range admits items whose string + equals the literal case-insensitively. + + >>> r = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + >>> "1.5" in r + True + >>> "2.0" in r + False + """ + if self._admit or self._reject: + item_str = str(item).lower() + if item_str in self._reject: + return False + if item_str in self._admit: + return True + if self._bounds == _FULL_RANGE: + # ``SpecifierSet("")`` admits any string. Match that. + return True + if not isinstance(item, Version): + try: + item = Version(item) + except InvalidVersion: + return False + return self._matches_bounds(item) + + def _matches_bounds(self, item: Version) -> bool: + """Bound-only membership check; ignores admit/reject.""" + return _matches_bounds_only(self._bounds, item) + + def __eq__(self, other: object) -> bool: + """Structural equality. Two ranges are equal when they admit + exactly the same set of versions and strings. + + >>> a = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + >>> b = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + >>> a == b + True + """ + if not isinstance(other, VersionRange): + return NotImplemented + return ( + self._bounds == other._bounds + and self._admit == other._admit + and self._reject == other._reject + ) + + def __hash__(self) -> int: + if not self._admit and not self._reject: + return hash(self._bounds) + return hash((self._bounds, self._admit, self._reject)) + + def __repr__(self) -> str: + """Human-readable representation. Internal layout, debugging only. + + >>> VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + + >>> VersionRange.from_specifier_set(SpecifierSet("")) + + >>> VersionRange.from_specifier_set(SpecifierSet(">=2.0,<1.0")) + + >>> VersionRange.from_specifier(Specifier("===wat")) + + """ + if self._bounds: + bound_body = " | ".join( + f"{_format_lower(lower)}, {_format_upper(upper)}" + for lower, upper in self._bounds + ) + else: + bound_body = "(empty)" if not self._admit else "" + parts: list[str] = [] + if bound_body: + parts.append(bound_body) + if self._admit: + parts.append("{" + ", ".join(sorted(self._admit)) + "}") + body = " | ".join(parts) if parts else "(empty)" + if self._reject: + body = f"{body} \\ {{{', '.join(sorted(self._reject))}}}" + return f"<{self.__class__.__name__} {body!r}>" diff --git a/src/packaging/specifiers.py b/src/packaging/specifiers.py index bfd45ee58..770723af2 100644 --- a/src/packaging/specifiers.py +++ b/src/packaging/specifiers.py @@ -4,6 +4,7 @@ """ .. testsetup:: + from packaging.ranges import VersionRange from packaging.specifiers import Specifier, SpecifierSet, InvalidSpecifier from packaging.version import Version """ @@ -23,17 +24,10 @@ Union, ) -from ._ranges import ( - FULL_RANGE, - filter_by_ranges, - intersect_ranges, - ranges_are_prerelease_only, - standard_ranges, - trim_release, - wildcard_ranges, -) +from ._version_utils import coerce_version, trim_release +from .ranges import VersionRange from .utils import canonicalize_version -from .version import InvalidVersion, Version +from .version import Version if sys.version_info >= (3, 10): from typing import TypeGuard # pragma: no cover @@ -41,9 +35,7 @@ from typing_extensions import TypeGuard if TYPE_CHECKING: - from collections.abc import Iterable, Iterator, Sequence - - from ._ranges import VersionRange + from collections.abc import Iterable, Iterator __all__ = [ @@ -76,15 +68,6 @@ def _validate_pre(pre: object, /) -> TypeGuard[bool | None]: UnparsedVersionVar = TypeVar("UnparsedVersionVar", bound=UnparsedVersion) -def _coerce_version(version: UnparsedVersion) -> Version | None: - if not isinstance(version, Version): - try: - version = Version(version) - except InvalidVersion: - return None - return version - - # Operators whose result is just a direct Version comparison, given a parsed # item with no local. ``<=``/``==``/``!=`` need that no-local guard because # PEP 440 strips locals on those; ``>=`` works regardless. @@ -241,7 +224,7 @@ class Specifier(BaseSpecifier): __slots__ = ( "_prereleases", - "_ranges", + "_range_cache", "_spec", "_spec_version", ) @@ -386,15 +369,15 @@ def __init__(self, spec: str = "", prereleases: bool | None = None) -> None: # Specifier version cache self._spec_version: tuple[str, Version] | None = None - # Version range cache (populated by _to_ranges) - self._ranges: Sequence[VersionRange] | None = None + # VersionRange cache (populated by VersionRange.from_specifier) + self._range_cache: VersionRange | None = None def _get_spec_version(self, version: str) -> Version | None: """One element cache, as only one spec Version is needed per Specifier.""" if self._spec_version is not None and self._spec_version[0] == version: return self._spec_version[1] - version_specifier = _coerce_version(version) + version_specifier = coerce_version(version) if version_specifier is None: return None @@ -411,30 +394,13 @@ def _require_spec_version(self, version: str) -> Version: assert spec_version is not None return spec_version - def _to_ranges(self) -> Sequence[VersionRange]: - """Convert this specifier to sorted, non-overlapping version ranges. + @property + def _range(self) -> VersionRange: + """The :class:`VersionRange` accepted by this specifier. - Each standard operator maps to one or two ranges. ``===`` is - modeled as full range (actual check done separately). Cached. + Computed lazily; cached on the instance. """ - if self._ranges is not None: - return self._ranges - - op = self.operator - ver_str = self.version - - if op == "===": - result: Sequence[VersionRange] = FULL_RANGE - elif ver_str.endswith(".*"): - base = self._require_spec_version(ver_str[:-2]) - result = wildcard_ranges(op, base) - else: - v = self._require_spec_version(ver_str) - has_local = "+" in ver_str - result = standard_ranges(op, v, has_local) - - self._ranges = result - return result + return VersionRange.from_specifier(self) @property def prereleases(self) -> bool | None: @@ -477,7 +443,7 @@ def __getstate__(self) -> tuple[tuple[str, str], bool | None]: def __setstate__(self, state: object) -> None: # Always discard cached values - they will be recomputed on demand. self._spec_version = None - self._ranges = None + self._range_cache = None if isinstance(state, tuple): if len(state) == 2: @@ -649,7 +615,7 @@ def contains(self, item: UnparsedVersion, prereleases: bool | None = None) -> bo if self._spec[0] == "===": return bool(list(self.filter([item], prereleases=prereleases))) - parsed = _coerce_version(item) + parsed = coerce_version(item) if parsed is None: # Standard operators never match an unparsable input. return False @@ -665,10 +631,24 @@ def contains(self, item: UnparsedVersion, prereleases: bool | None = None) -> bo return False return match - # Pass the already-parsed Version so filter_by_ranges doesn't + # Pass the already-parsed Version so VersionRange.filter doesn't # re-coerce it. return bool(list(self.filter([parsed], prereleases=prereleases))) + def to_range(self) -> VersionRange: + """The :class:`VersionRange` accepted by this specifier. + + For ``===`` the returned range matches the literal string + case-insensitively; no PEP 440 :class:`Version` other than the + literal itself is contained. + + >>> isinstance(Specifier(">=1.0").to_range(), VersionRange) + True + >>> "wat" in Specifier("===wat").to_range() + True + """ + return VersionRange.from_specifier(self) + @typing.overload def filter( self, @@ -720,47 +700,32 @@ def filter( ... key=lambda x: x["ver"])) [{'ver': '1.3'}] """ + # Inlined ``_resolve_prereleases`` for hot-path performance. if prereleases is None: if self._prereleases is not None: prereleases = self._prereleases elif self.prereleases: prereleases = True - - if self.operator == "===": - spec_lower = self.version.lower() - matches = ( - item - for item in iterable - if str(item if key is None else key(item)).lower() == spec_lower - ) - return _apply_prereleases_filter(matches, key, prereleases) - - ranges = self._ranges - if ranges is None: - ranges = self._to_ranges() - return filter_by_ranges(ranges, iterable, key, prereleases) - - -def _apply_prereleases_filter( - matches: Iterable[Any], - key: Callable[[Any], UnparsedVersion] | None, - prereleases: bool | None, -) -> Iterator[Any]: - """Apply ``prereleases=`` handling to an already-matched iterable. - - ``None`` means PEP 440 default (buffer pre-releases until a final - appears); ``True`` yields everything; ``False`` drops pre-releases. - """ - if prereleases is None: - return _pep440_filter_prereleases(matches, key) - if prereleases: - return iter(matches) - return ( - item - for item in matches - if (parsed := _coerce_version(item if key is None else key(item))) is None - or not parsed.is_prerelease - ) + version_range = self._range_cache + if version_range is None: + version_range = VersionRange.from_specifier(self) + return version_range.filter(iterable, key, prereleases) + + def _resolve_prereleases(self, prereleases: bool | None) -> bool | None: + """Resolve ``prereleases`` for :meth:`filter` / :meth:`contains`. + + Explicit caller argument wins; otherwise the constructor value + ``self._prereleases``; otherwise auto-detected ``self.prereleases`` + only when True (an auto-detected False does not propagate, so + non-pre specifiers keep PEP 440 default behaviour). + """ + if prereleases is not None: + return prereleases + if self._prereleases is not None: + return self._prereleases + if self.prereleases: + return True + return None class SpecifierSet(BaseSpecifier): @@ -787,7 +752,7 @@ class SpecifierSet(BaseSpecifier): "_has_arbitrary", "_is_unsatisfiable", "_prereleases", - "_ranges", + "_range_cache", "_specs", ) @@ -814,10 +779,7 @@ def __init__( """ if isinstance(specifiers, str): - # Split on `,` to break each individual specifier into its own item, and - # strip each item to remove leading/trailing whitespace. split_specifiers = [s.strip() for s in specifiers.split(",") if s.strip()] - self._specs: tuple[Specifier, ...] = tuple(map(Specifier, split_specifiers)) # Fast substring check; avoids iterating parsed specs. self._has_arbitrary = "===" in specifiers @@ -829,10 +791,7 @@ def __init__( self._canonicalized = len(self._specs) <= 1 self._is_unsatisfiable: bool | None = None - self._ranges: Sequence[VersionRange] | None = None - - # Store our prereleases value so we can use it later to determine if - # we accept prereleases or not. + self._range_cache: VersionRange | None = None self._prereleases = prereleases def _canonical_specs(self) -> tuple[Specifier, ...]: @@ -841,7 +800,7 @@ def _canonical_specs(self) -> tuple[Specifier, ...]: self._specs = tuple(dict.fromkeys(sorted(self._specs, key=str))) self._canonicalized = True self._is_unsatisfiable = None - self._ranges = None + self._range_cache = None return self._specs @property @@ -868,6 +827,7 @@ def prereleases(self) -> bool | None: def prereleases(self, value: bool | None) -> None: self._prereleases = value self._is_unsatisfiable = None + self._range_cache = None def __getstate__(self) -> tuple[tuple[Specifier, ...], bool | None]: # Return state as a 2-item tuple for compactness: @@ -877,8 +837,8 @@ def __getstate__(self) -> tuple[tuple[Specifier, ...], bool | None]: def __setstate__(self, state: object) -> None: # Always discard cached values - they will be recomputed on demand. - self._ranges = None self._is_unsatisfiable = None + self._range_cache = None if isinstance(state, tuple): if len(state) == 2: @@ -989,7 +949,6 @@ def __and__(self, other: SpecifierSet | str) -> SpecifierSet: specifier._canonicalized = len(specifier._specs) <= 1 specifier._has_arbitrary = self._has_arbitrary or other._has_arbitrary - # Combine prerelease settings: use common or non-None value if self._prereleases is None or self._prereleases == other._prereleases: specifier._prereleases = other._prereleases elif other._prereleases is None: @@ -1041,29 +1000,13 @@ def __iter__(self) -> Iterator[Specifier]: """ return iter(self._specs) - def _get_ranges(self) -> Sequence[VersionRange]: - """Intersect all specifiers into a single sequence of version ranges. + @property + def _range(self) -> VersionRange: + """The intersection of every specifier's :class:`VersionRange`. - Empty when unsatisfiable. Callers must ensure ``self._specs`` - is non-empty. + Computed lazily; cached on the instance. """ - if self._ranges is not None: - return self._ranges - - result: Sequence[VersionRange] | None = None - for s in self._specs: - sub = s._to_ranges() - if result is None: - result = sub - else: - result = intersect_ranges(result, sub) - if not result: - break - - if result is None: # pragma: no cover - raise RuntimeError("_get_ranges called with no specs") - self._ranges = result - return result + return VersionRange.from_specifier_set(self) def is_unsatisfiable(self) -> bool: """Check whether this specifier set can never be satisfied. @@ -1087,55 +1030,40 @@ def is_unsatisfiable(self) -> bool: self._is_unsatisfiable = False return False - result = not self._get_ranges() - - if not result: - result = self._check_arbitrary_unsatisfiable() - - if not result and self.prereleases is False: - result = ranges_are_prerelease_only(self._get_ranges()) - + # An empty combined range covers contradicting bounds and + # disagreeing === literals; only-pre-release matches still + # count as unsatisfiable when prereleases=False. + range_ = self._range + if range_.is_empty: + self._is_unsatisfiable = True + return True + if self.prereleases is not False: + self._is_unsatisfiable = False + return False + result = range_.is_prerelease_only self._is_unsatisfiable = result return result - def _check_arbitrary_unsatisfiable(self) -> bool: - """Check === (arbitrary equality) specs for unsatisfiability. - - === uses case-insensitive string comparison, so the only candidate - that can match ``===V`` is the literal string V. This method - checks whether that candidate is excluded by other specifiers. - """ - arbitrary = [s for s in self._specs if s.operator == "==="] - if not arbitrary: - return False - - # Multiple === must agree on the same string (case-insensitive). - first = arbitrary[0].version.lower() - if any(s.version.lower() != first for s in arbitrary[1:]): - return True - - # The sole candidate is the === version string. Check whether - # it can satisfy every standard spec. - candidate = _coerce_version(arbitrary[0].version) - - # With prereleases=False, a prerelease candidate is excluded - # by contains() before the === string check even runs. - if ( - self.prereleases is False - and candidate is not None - and candidate.is_prerelease - ): - return True - - standard = [s for s in self._specs if s.operator != "==="] - if not standard: - return False + def to_range(self) -> VersionRange: + """The :class:`VersionRange` accepted by this specifier set. - if candidate is None: - # Unparsable string cannot satisfy any standard spec. - return True + The intersection of every specifier in the set. An empty + :class:`SpecifierSet` yields the unbounded range; an + unsatisfiable set yields an empty :class:`VersionRange`. Sets + containing ``===`` produce a range whose only matching items + are the literal strings (case-insensitive) that satisfy every + rangelike specifier in the set as well. - return not all(s.contains(candidate) for s in standard) + >>> isinstance(SpecifierSet(">=1.0,<2.0").to_range(), VersionRange) + True + >>> SpecifierSet(">=1.0,<2.0").to_range().is_empty + False + >>> SpecifierSet(">=2.0,<1.0").to_range().is_empty + True + >>> "wat" in SpecifierSet("===wat").to_range() + True + """ + return VersionRange.from_specifier_set(self) def __contains__(self, item: UnparsedVersion) -> bool: """Return whether or not the item is contained in this specifier. @@ -1190,7 +1118,7 @@ def contains( >>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.3.0a1", prereleases=True) True """ - version = _coerce_version(item) + version = coerce_version(item) if version is not None and installed and version.is_prerelease: prereleases = True @@ -1203,12 +1131,12 @@ def contains( check_item = version # Fast path: skip the intersected-range build while every spec - # answers directly. Once ``_ranges`` is set the cached range + # answers directly. Once ``_range_cache`` is set the cached range # path beats re-iterating specs, so fall through then. A local # on ``version`` needs PEP 440 stripping that the range path # applies. if ( - self._ranges is None + self._range_cache is None and version is not None and not self._has_arbitrary and version.local is None @@ -1230,6 +1158,15 @@ def contains( return bool(list(self.filter([check_item], prereleases=prereleases))) + def _resolve_prereleases(self, prereleases: bool | None) -> bool | None: + """Resolve ``prereleases`` for :meth:`filter` / :meth:`contains`. + + Explicit caller argument wins; otherwise ``self.prereleases``. + """ + if prereleases is not None: + return prereleases + return self.prereleases + @typing.overload def filter( self, @@ -1293,72 +1230,10 @@ def filter( >>> list(SpecifierSet("").filter(["1.3", "1.5a1"], prereleases=True)) ['1.3', '1.5a1'] """ - # Determine if we're forcing a prerelease or not, if we're not forcing - # one for this particular filter call, then we'll use whatever the - # SpecifierSet thinks for whether or not we should support prereleases. + # Inlined ``_resolve_prereleases`` for hot-path performance. if prereleases is None and self.prereleases is not None: prereleases = self.prereleases - - if self._specs: - if self._has_arbitrary: - # Slow path for === - specs = self._specs - matches = ( - item - for item in iterable - if all( - s.contains(item if key is None else key(item), prereleases=True) - for s in specs - ) - ) - return _apply_prereleases_filter(matches, key, prereleases) - - ranges = self._ranges - if ranges is None: - ranges = self._get_ranges() - return filter_by_ranges(ranges, iterable, key, prereleases) - - # Empty SpecifierSet. - return _apply_prereleases_filter(iterable, key, prereleases) - - -def _pep440_filter_prereleases( - iterable: Iterable[Any], key: Callable[[Any], UnparsedVersion] | None -) -> Iterator[Any]: - """Filter per PEP 440: exclude prereleases unless no finals exist.""" - # Two lists used: - # * all_nonfinal to preserve order if no finals exist - # * arbitrary_strings for streaming when first final found - all_nonfinal: list[Any] = [] - arbitrary_strings: list[Any] = [] - - found_final = False - for item in iterable: - parsed = _coerce_version(item if key is None else key(item)) - - if parsed is None: - # Arbitrary strings are always included as it is not - # possible to determine if they are prereleases, - # and they have already passed all specifiers. - if found_final: - yield item - else: - arbitrary_strings.append(item) - all_nonfinal.append(item) - continue - - if not parsed.is_prerelease: - # Final release found - flush arbitrary strings, then yield - if not found_final: - yield from arbitrary_strings - found_final = True - yield item - continue - - # Prerelease - buffer if no finals yet, otherwise skip - if not found_final: - all_nonfinal.append(item) - - # No finals found - yield all buffered items - if not found_final: - yield from all_nonfinal + version_range = self._range_cache + if version_range is None: + version_range = VersionRange.from_specifier_set(self) + return version_range.filter(iterable, key, prereleases) diff --git a/tests/property/strategies.py b/tests/property/strategies.py index f9bb603b5..88face503 100644 --- a/tests/property/strategies.py +++ b/tests/property/strategies.py @@ -10,7 +10,7 @@ from packaging.specifiers import SpecifierSet from packaging.version import Version -SETTINGS = settings(max_examples=300, deadline=None) +SETTINGS = settings(max_examples=200, deadline=None) # PEP 440 versions covering the major forms. VERSION_POOL = [ @@ -133,7 +133,11 @@ def versions_with_local(draw: st.DrawFn) -> Version: @st.composite def specifier_sets(draw: st.DrawFn) -> SpecifierSet: - """Generate a random SpecifierSet from common operator/version pairs.""" + """Random SpecifierSet over ``>= <= > < == !=`` and ``major.minor``. + + Narrow on purpose. Tests that need wildcards, locals, pre/post/dev + on the RHS, epochs, or ``===`` should use :func:`rich_specifier_sets`. + """ num = draw(st.integers(min_value=1, max_value=3)) parts: list[str] = [] for _ in range(num): @@ -144,6 +148,58 @@ def specifier_sets(draw: st.DrawFn) -> SpecifierSet: return SpecifierSet(",".join(parts)) +_ordered_ops = st.sampled_from([">=", "<=", ">", "<"]) +_equality_ops = st.sampled_from(["==", "!="]) + + +@st.composite +def pep440_specifier_strings( + draw: st.DrawFn, + *, + include_arbitrary: bool = False, +) -> str: + """One specifier string covering the full PEP 440 surface. + + Includes pre/post/dev/local-bearing RHS versions, epochs, multi- + segment release tuples, ``==V.*`` / ``!=V.*`` wildcards, and + optionally ``===L``. + """ + shape = draw(st.sampled_from(["ordered", "equality", "wildcard", "compatible"])) + if include_arbitrary and draw(st.booleans()): + shape = "arbitrary" + if shape == "ordered": + # ``>= <= > <`` reject ``+local`` on the RHS. + return f"{draw(_ordered_ops)}{draw(pep440_versions(include_local=False))}" + if shape == "equality": + return f"{draw(_equality_ops)}{draw(pep440_versions())}" + if shape == "wildcard": + # ``==V.*`` / ``!=V.*`` take a release-only RHS. + return f"{draw(_equality_ops)}{draw(release_versions())}.*" + if shape == "compatible": + return f"~={draw(multi_segment_versions())}" + # ``===L`` with a parseable literal. Unparsable literals like + # ``===wat`` are skipped: De Morgan can fail when an unparsable + # ``===`` interacts with a non-full rangelike, since the bound + # universe is parseable Versions but the literal universe is + # all strings. + return f"==={draw(pep440_versions())}" + + +@st.composite +def rich_specifier_sets( + draw: st.DrawFn, + *, + include_arbitrary: bool = False, +) -> SpecifierSet: + """1-3 specifiers from :func:`pep440_specifier_strings`, joined.""" + num = draw(st.integers(min_value=1, max_value=3)) + parts = [ + draw(pep440_specifier_strings(include_arbitrary=include_arbitrary)) + for _ in range(num) + ] + return SpecifierSet(",".join(parts)) + + @st.composite def related_version_triple( draw: st.DrawFn, diff --git a/tests/property/test_ranges_pep440_extended.py b/tests/property/test_ranges_pep440_extended.py new file mode 100644 index 000000000..8a886d890 --- /dev/null +++ b/tests/property/test_ranges_pep440_extended.py @@ -0,0 +1,196 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +"""Set-algebra invariants re-run on the full PEP 440 specifier surface. + +Uses :func:`tests.property.strategies.rich_specifier_sets`, which +adds wildcards, ``~=``, locals, pre/post/dev RHS, epochs, multi- +segment release tuples, and optionally ``===``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from hypothesis import given + +from packaging.ranges import VersionRange + +from .strategies import SETTINGS, VERSION_POOL, rich_specifier_sets + +if TYPE_CHECKING: + from packaging.specifiers import SpecifierSet + +pytestmark = pytest.mark.property + + +@given(spec_set=rich_specifier_sets()) +@SETTINGS +def test_double_complement_identity_rich(spec_set: SpecifierSet) -> None: + r = VersionRange.from_specifier_set(spec_set) + assert r.complement().complement() == r + + +@given(spec_set=rich_specifier_sets()) +@SETTINGS +def test_complement_partitions_rich(spec_set: SpecifierSet) -> None: + r = VersionRange.from_specifier_set(spec_set) + c = r.complement() + assert r.intersection(c).is_empty + assert r.union(c) == VersionRange.full() + + +@given(a=rich_specifier_sets(), b=rich_specifier_sets()) +@SETTINGS +def test_de_morgan_intersect_rich(a: SpecifierSet, b: SpecifierSet) -> None: + ra = VersionRange.from_specifier_set(a) + rb = VersionRange.from_specifier_set(b) + assert ra.intersection(rb).complement() == ra.complement().union(rb.complement()) + + +@given(a=rich_specifier_sets(), b=rich_specifier_sets()) +@SETTINGS +def test_de_morgan_union_rich(a: SpecifierSet, b: SpecifierSet) -> None: + ra = VersionRange.from_specifier_set(a) + rb = VersionRange.from_specifier_set(b) + assert ra.union(rb).complement() == ra.complement().intersection(rb.complement()) + + +@given(spec_set=rich_specifier_sets()) +@SETTINGS +def test_idempotence_rich(spec_set: SpecifierSet) -> None: + r = VersionRange.from_specifier_set(spec_set) + assert r.union(r) == r + assert r.intersection(r) == r + + +@given(a=rich_specifier_sets(), b=rich_specifier_sets()) +@SETTINGS +def test_intersect_commutative_rich(a: SpecifierSet, b: SpecifierSet) -> None: + ra = VersionRange.from_specifier_set(a) + rb = VersionRange.from_specifier_set(b) + assert ra.intersection(rb) == rb.intersection(ra) + + +@given(a=rich_specifier_sets(), b=rich_specifier_sets()) +@SETTINGS +def test_union_commutative_rich(a: SpecifierSet, b: SpecifierSet) -> None: + ra = VersionRange.from_specifier_set(a) + rb = VersionRange.from_specifier_set(b) + assert ra.union(rb) == rb.union(ra) + + +@given(a=rich_specifier_sets(), b=rich_specifier_sets(), c=rich_specifier_sets()) +@SETTINGS +def test_intersect_associative_rich( + a: SpecifierSet, b: SpecifierSet, c: SpecifierSet +) -> None: + ra = VersionRange.from_specifier_set(a) + rb = VersionRange.from_specifier_set(b) + rc = VersionRange.from_specifier_set(c) + assert ra.intersection(rb).intersection(rc) == ra.intersection(rb.intersection(rc)) + + +@given(a=rich_specifier_sets(), b=rich_specifier_sets(), c=rich_specifier_sets()) +@SETTINGS +def test_union_associative_rich( + a: SpecifierSet, b: SpecifierSet, c: SpecifierSet +) -> None: + ra = VersionRange.from_specifier_set(a) + rb = VersionRange.from_specifier_set(b) + rc = VersionRange.from_specifier_set(c) + assert ra.union(rb).union(rc) == ra.union(rb.union(rc)) + + +@given(spec_set=rich_specifier_sets()) +@SETTINGS +def test_membership_consistent_with_complement_rich( + spec_set: SpecifierSet, +) -> None: + r = VersionRange.from_specifier_set(spec_set) + c = r.complement() + for v in VERSION_POOL: + assert (v in c) == (v not in r) + + +@given(a=rich_specifier_sets(), b=rich_specifier_sets()) +@SETTINGS +def test_membership_consistent_with_intersect_rich( + a: SpecifierSet, b: SpecifierSet +) -> None: + ra = VersionRange.from_specifier_set(a) + rb = VersionRange.from_specifier_set(b) + inter = ra.intersection(rb) + for v in VERSION_POOL: + assert (v in inter) == ((v in ra) and (v in rb)) + + +@given(a=rich_specifier_sets(), b=rich_specifier_sets()) +@SETTINGS +def test_membership_consistent_with_union_rich( + a: SpecifierSet, b: SpecifierSet +) -> None: + ra = VersionRange.from_specifier_set(a) + rb = VersionRange.from_specifier_set(b) + union = ra.union(rb) + for v in VERSION_POOL: + assert (v in union) == ((v in ra) or (v in rb)) + + +@given(spec_set=rich_specifier_sets(include_arbitrary=True)) +@SETTINGS +def test_double_complement_with_arbitrary(spec_set: SpecifierSet) -> None: + r = VersionRange.from_specifier_set(spec_set) + assert r.complement().complement() == r + + +@given(spec_set=rich_specifier_sets(include_arbitrary=True)) +@SETTINGS +def test_complement_partitions_with_arbitrary(spec_set: SpecifierSet) -> None: + r = VersionRange.from_specifier_set(spec_set) + c = r.complement() + assert r.intersection(c).is_empty + assert r.union(c) == VersionRange.full() + + +@given( + a=rich_specifier_sets(include_arbitrary=True), + b=rich_specifier_sets(include_arbitrary=True), +) +@SETTINGS +def test_de_morgan_intersect_with_arbitrary(a: SpecifierSet, b: SpecifierSet) -> None: + ra = VersionRange.from_specifier_set(a) + rb = VersionRange.from_specifier_set(b) + assert ra.intersection(rb).complement() == ra.complement().union(rb.complement()) + + +@given( + a=rich_specifier_sets(include_arbitrary=True), + b=rich_specifier_sets(include_arbitrary=True), +) +@SETTINGS +def test_de_morgan_union_with_arbitrary(a: SpecifierSet, b: SpecifierSet) -> None: + ra = VersionRange.from_specifier_set(a) + rb = VersionRange.from_specifier_set(b) + assert ra.union(rb).complement() == ra.complement().intersection(rb.complement()) + + +@given(spec_set=rich_specifier_sets(include_arbitrary=True)) +@SETTINGS +def test_idempotence_with_arbitrary(spec_set: SpecifierSet) -> None: + r = VersionRange.from_specifier_set(spec_set) + assert r.union(r) == r + assert r.intersection(r) == r + + +@given(spec_set=rich_specifier_sets(include_arbitrary=True)) +@SETTINGS +def test_membership_consistent_with_complement_arbitrary( + spec_set: SpecifierSet, +) -> None: + r = VersionRange.from_specifier_set(spec_set) + c = r.complement() + for v in VERSION_POOL: + assert (v in c) == (v not in r) diff --git a/tests/property/test_ranges_pubgrub.py b/tests/property/test_ranges_pubgrub.py new file mode 100644 index 000000000..a2290077e --- /dev/null +++ b/tests/property/test_ranges_pubgrub.py @@ -0,0 +1,378 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +"""Property tests verifying ``VersionRange`` satisfies PubGrub's invariants. + +Each test class quotes the relevant paragraph verbatim from one of: + +* `solver.md`_, the Dart pub specification (`Definitions Term`_, + `Definitions Incompatibility`_). +* `Pubgrub blog post`_, Natalie Weizenbaum, 2018. + +Unicode set-theoretic operators in the quotations are preserved as +written; the per-file ``noqa`` overrides silence ruff's ambiguous-glyph +warning for those quotations only. + +.. _solver.md: https://github.com/dart-lang/pub/blob/master/doc/solver.md +.. _Definitions Term: + https://github.com/dart-lang/pub/blob/master/doc/solver.md#term +.. _Definitions Incompatibility: + https://github.com/dart-lang/pub/blob/master/doc/solver.md#incompatibility +.. _Pubgrub blog post: https://nex3.medium.com/pubgrub-2fb6470504f +""" + +# ruff: noqa: RUF002, RUF003, E501 +# RUF002 / RUF003: ambiguous Unicode in docstrings and comments, the +# spec quotations preserve set-theoretic operators verbatim. +# E501: spec quotations are reproduced as written; wrapping them would +# harm searchability against the upstream documents. + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from hypothesis import given + +from packaging.ranges import VersionRange + +from .strategies import SETTINGS, VERSION_POOL, pep440_versions, specifier_sets + +if TYPE_CHECKING: + from packaging.specifiers import SpecifierSet + from packaging.version import Version + +pytestmark = pytest.mark.property + + +def _to_range(spec_set: SpecifierSet) -> VersionRange: + """Lift a non-``===`` SpecifierSet into a VersionRange.""" + r = VersionRange.from_specifier_set(spec_set) + assert r is not None + return r + + +def _term_set(range_: VersionRange, *, positive: bool) -> VersionRange: + """Set view of a PubGrub term: ``R`` if positive, ``~R`` if negative.""" + return range_ if positive else range_.complement() + + +def _is_subset(a: VersionRange, b: VersionRange) -> bool: + """``A ⊆ B`` iff ``A & B == A``.""" + return (a & b) == a + + +def _is_disjoint(a: VersionRange, b: VersionRange) -> bool: + """``A ∩ B == ∅``.""" + return (a & b).is_empty + + +class TestQuoteTermAsStatement: + """solver.md § Term, paragraph 1: + + > "The fundamental unit on which Pubgrub operates is a Term, which + > represents a statement about a package that may be true or false + > for a given selection of package versions. For example, foo + > ^1.0.0 is a term that's true if foo 1.2.3 is selected and false + > if foo 2.3.4 is selected. Conversely, not foo ^1.0.0 is false if + > foo 1.2.3 is selected and true if foo 2.3.4 is selected or if no + > version of foo is selected at all." + """ + + @given(spec_set=specifier_sets()) + @SETTINGS + def test_positive_and_negative_polarities_disagree_pointwise( + self, spec_set: SpecifierSet + ) -> None: + """``v in R`` iff ``v not in ~R`` for every PEP 440 version.""" + r = _to_range(spec_set) + positive = _term_set(r, positive=True) + negative = _term_set(r, positive=False) + for v in VERSION_POOL: + assert (v in positive) is not (v in negative) + + +class TestQuoteTermsDenoteSets: + """solver.md § Term, paragraph 4: + + > "Terms can be viewed as denoting sets of allowed versions, with + > negative terms denoting the complement of the corresponding + > positive term. Set relations and operations can be defined + > accordingly." + """ + + @given(spec_set=specifier_sets()) + @SETTINGS + def test_double_negation_returns_original_term( + self, spec_set: SpecifierSet + ) -> None: + """``not not T == T``.""" + r = _to_range(spec_set) + assert _term_set(_term_set(r, positive=False), positive=False) == r + + +class TestQuoteSetOperationExamples: + """solver.md § Term, paragraph 5: + + > "* foo ^1.0.0 ∪ foo ^2.0.0 is foo >=1.0.0 <3.0.0. + > * foo >=1.0.0 ∩ not foo >=2.0.0 is foo ^1.0.0. + > * foo ^1.0.0 \\ foo ^1.5.0 is foo >=1.0.0 <1.5.0." + """ + + @given(a=specifier_sets(), b=specifier_sets()) + @SETTINGS + def test_set_difference_equals_intersection_with_complement( + self, a: SpecifierSet, b: SpecifierSet + ) -> None: + """``A \\ B`` (versions in A but not B) equals ``A ∩ ~B``.""" + ra, rb = _to_range(a), _to_range(b) + difference = ra & rb.complement() + for v in VERSION_POOL: + assert (v in difference) == (v in ra and v not in rb) + + @given(a=specifier_sets(), b=specifier_sets()) + @SETTINGS + def test_intersection_with_negation_excludes_negated_range( + self, a: SpecifierSet, b: SpecifierSet + ) -> None: + """``v in A ∩ not B`` iff ``v in A`` and ``v not in B``.""" + ra, rb = _to_range(a), _to_range(b) + result = ra & rb.complement() + for v in VERSION_POOL: + assert (v in result) == ((v in ra) and (v not in rb)) + + +class TestQuoteSatisfiesAndContradictsIdentities: + """solver.md § Term, paragraph 6: + + > "This turns out to be useful for computing satisfaction and + > contradiction. Given a term t and a set of terms S, we have the + > following identities: + > * S satisfies t if and only if ⋂S ⊆ t. + > * S contradicts t if and only if ⋂S is disjoint with t." + """ + + @given(s=specifier_sets(), t=specifier_sets()) + @SETTINGS + def test_satisfies_iff_subset_positive( + self, s: SpecifierSet, t: SpecifierSet + ) -> None: + """``S`` satisfies positive term ``T`` iff ``⋂S ⊆ T``.""" + rs, rt = _to_range(s), _to_range(t) + is_subset = _is_subset(rs, rt) + pointwise_subset = all((v not in rs) or (v in rt) for v in VERSION_POOL) + # VERSION_POOL is finite, so pointwise can be spuriously True; + # the structural ``is_subset`` is the strong claim. + if is_subset: + assert pointwise_subset + + @given(s=specifier_sets(), t=specifier_sets()) + @SETTINGS + def test_contradicts_iff_disjoint_positive( + self, s: SpecifierSet, t: SpecifierSet + ) -> None: + """``S`` contradicts positive term ``T`` iff ``⋂S ∩ T == ∅``.""" + rs, rt = _to_range(s), _to_range(t) + is_disjoint = _is_disjoint(rs, rt) + pointwise_disjoint = all((v not in rs) or (v not in rt) for v in VERSION_POOL) + if is_disjoint: + assert pointwise_disjoint + + @given(s=specifier_sets(), t=specifier_sets()) + @SETTINGS + def test_satisfies_iff_subset_negative( + self, s: SpecifierSet, t: SpecifierSet + ) -> None: + """``S`` satisfies negative term ``not T`` iff ``⋂S ⊆ ~T``.""" + rs, rt = _to_range(s), _to_range(t) + neg_t = _term_set(rt, positive=False) + assert _is_subset(rs, neg_t) == _is_disjoint(rs, rt) + + @given(s=specifier_sets(), t=specifier_sets()) + @SETTINGS + def test_contradicts_iff_disjoint_negative( + self, s: SpecifierSet, t: SpecifierSet + ) -> None: + """``S`` contradicts negative term ``not T`` iff ``⋂S ⊆ T``.""" + rs, rt = _to_range(s), _to_range(t) + neg_t = _term_set(rt, positive=False) + assert _is_disjoint(rs, neg_t) == _is_subset(rs, rt) + + +class TestQuoteTrichotomy: + """solver.md § Term, paragraph 2: + + > "We say that a set of terms S 'satisfies' a term t if t must be + > true whenever every term in S is true. Conversely, S + > 'contradicts' t if t must be false whenever every term in S is + > true. If neither of these is true, we say that S is + > 'inconclusive' for t. As a shorthand, we say that a term v + > satisfies or contradicts t if {v} satisfies or contradicts it." + """ + + @given(s=specifier_sets(), t=specifier_sets()) + @SETTINGS + def test_satisfies_and_contradicts_mutually_exclusive_on_non_empty( + self, s: SpecifierSet, t: SpecifierSet + ) -> None: + """For non-empty ``⋂S``, ``S`` cannot both satisfy and contradict ``t``.""" + rs, rt = _to_range(s), _to_range(t) + if rs.is_empty: + return + satisfies = _is_subset(rs, rt) + contradicts = _is_disjoint(rs, rt) + assert not (satisfies and contradicts) + + @given(s=specifier_sets(), t=specifier_sets()) + @SETTINGS + def test_empty_intersection_is_both_satisfies_and_contradicts( + self, s: SpecifierSet, t: SpecifierSet + ) -> None: + """An unsatisfiable ``S`` (``⋂S == ∅``) is vacuously both.""" + rs, rt = _to_range(s), _to_range(t) + if not rs.is_empty: + return + assert _is_subset(rs, rt) + assert _is_disjoint(rs, rt) + + @given(spec_set=specifier_sets()) + @SETTINGS + def test_singleton_shorthand(self, spec_set: SpecifierSet) -> None: + """``{v}`` satisfies ``t`` iff ``v in t``; trichotomy collapses on singletons.""" + rt = _to_range(spec_set) + for v in VERSION_POOL: + singleton = VersionRange.singleton(v) + satisfies = _is_subset(singleton, rt) + contradicts = _is_disjoint(singleton, rt) + assert satisfies == (v in rt) + assert contradicts == (v not in rt) + assert satisfies != contradicts + + +class TestQuoteIncompatibilityNormalisation: + """solver.md § Incompatibility, paragraph 2: + + > "Incompatibilities are normalized so that at most one term refers + > to any given package name. For example, {foo >=1.0.0, foo + > <2.0.0} is normalized to {foo ^1.0.0}. Derived incompatibilities + > with more than one term are also normalized to remove positive + > terms referring to the root package, since these terms will + > always be satisfied." + """ + + @given(a=specifier_sets(), b=specifier_sets(), c=specifier_sets()) + @SETTINGS + def test_three_term_merge_is_order_independent( + self, a: SpecifierSet, b: SpecifierSet, c: SpecifierSet + ) -> None: + """Merging ``{R1, R2, R3}`` over the same package is order-free.""" + ra, rb, rc = _to_range(a), _to_range(b), _to_range(c) + m1 = ra & rb & rc + m2 = ra & rc & rb + m3 = rb & ra & rc + m4 = rb & rc & ra + m5 = rc & ra & rb + m6 = rc & rb & ra + assert m1 == m2 == m3 == m4 == m5 == m6 + + +class TestQuoteBlogPostSatisfaction: + """nex3.medium.com/pubgrub, § "So What Does PubGrub Do?": + + > "A term is satisfied if it matches the version of the package + > that's selected. menu ≥1.1.0 is satisfied if menu 1.2.0 is + > selected, and not dropdown ≥2.0.0 is satisfied if dropdown 1.8.0 + > is selected (or if no version of dropdown is selected at all)." + """ + + @given(spec_set=specifier_sets(), v=pep440_versions()) + @SETTINGS + def test_concrete_version_satisfies_negative_term_iff_outside_range( + self, spec_set: SpecifierSet, v: Version + ) -> None: + """Negative ``not T`` satisfied by ``v`` iff ``v not in T``.""" + rt = _to_range(spec_set) + negative_set = _term_set(rt, positive=False) + assert (v in negative_set) == (v not in rt) + + +class TestQuoteConcreteExamples: + """solver.md § Term, paragraph 3: + + > "* {foo >=1.0.0, foo <2.0.0} satisfies foo ^1.0.0, + > * foo ^1.5.0 contradicts not foo ^1.0.0, + > * and foo ^1.0.0 is inconclusive for foo ^1.5.0." + """ + + @given(spec_set=specifier_sets()) + @SETTINGS + def test_self_subset_satisfies_self(self, spec_set: SpecifierSet) -> None: + """``R`` satisfies ``R``.""" + r = _to_range(spec_set) + assert _is_subset(r, r) + + @given(a=specifier_sets(), b=specifier_sets()) + @SETTINGS + def test_subset_implies_contradicts_negation( + self, a: SpecifierSet, b: SpecifierSet + ) -> None: + """``A ⊆ B`` implies ``A & ~B == ∅``.""" + ra, rb = _to_range(a), _to_range(b) + if not _is_subset(ra, rb): + return + assert _is_disjoint(ra, rb.complement()) + + @given(a=specifier_sets(), b=specifier_sets()) + @SETTINGS + def test_strict_superset_is_inconclusive_for_subset( + self, a: SpecifierSet, b: SpecifierSet + ) -> None: + """``B ⊊ A`` ⇒ ``A`` neither satisfies nor contradicts ``B``.""" + ra, rb = _to_range(a), _to_range(b) + if rb.is_empty or not _is_subset(rb, ra) or ra == rb: + return + assert not _is_subset(ra, rb) + assert not _is_disjoint(ra, rb) + + +class TestQuoteSetSatisfiesIncompatibility: + """solver.md § Incompatibility, paragraph 3: + + > "We say that a set of terms S satisfies an incompatibility I if + > S satisfies every term in I. We say that S contradicts I if S + > contradicts at least one term in I. If S satisfies all but one + > of I's terms and is inconclusive for the remaining term, we say + > S 'almost satisfies' I and we call the remaining term the + > 'unsatisfied term'." + """ + + @given(s=specifier_sets(), t1=specifier_sets(), t2=specifier_sets()) + @SETTINGS + def test_satisfies_two_term_incompatibility_iff_subset_of_intersection( + self, s: SpecifierSet, t1: SpecifierSet, t2: SpecifierSet + ) -> None: + """``S satisfies {t1, t2}`` iff ``⋂S ⊆ t1 ∩ t2``.""" + rs, r1, r2 = _to_range(s), _to_range(t1), _to_range(t2) + sat_universal = _is_subset(rs, r1) and _is_subset(rs, r2) + sat_via_intersection = _is_subset(rs, r1 & r2) + assert sat_universal == sat_via_intersection + + @given(s=specifier_sets(), t1=specifier_sets(), t2=specifier_sets()) + @SETTINGS + def test_contradicts_two_term_incompatibility_iff_disjoint_with_either( + self, s: SpecifierSet, t1: SpecifierSet, t2: SpecifierSet + ) -> None: + """``S contradicts {t1, t2}`` iff ``⋂S ∩ t1 == ∅`` OR ``⋂S ∩ t2 == ∅``.""" + rs, r1, r2 = _to_range(s), _to_range(t1), _to_range(t2) + cont_existential = _is_disjoint(rs, r1) or _is_disjoint(rs, r2) + # Per-term subset-of-complement form. Strictly stronger than + # ``⋂S ⊆ (~t1 ∪ ~t2)`` (the pointwise existential). + cont_existential_alt = _is_subset(rs, r1.complement()) or _is_subset( + rs, r2.complement() + ) + assert cont_existential == cont_existential_alt + # Incompatibility is a *set* of terms, so the disjunction is + # order-free. + cont_reversed = _is_disjoint(rs, r2) or _is_disjoint(rs, r1) + assert cont_existential == cont_reversed diff --git a/tests/property/test_ranges_set_algebra.py b/tests/property/test_ranges_set_algebra.py new file mode 100644 index 000000000..941ec104d --- /dev/null +++ b/tests/property/test_ranges_set_algebra.py @@ -0,0 +1,249 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +"""Property tests for ``VersionRange`` Boolean lattice laws. + +Identity, idempotence, commutativity, associativity, distributivity, +double-complement, De Morgan, and consistency with ``__contains__``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from hypothesis import given + +from packaging.ranges import VersionRange +from packaging.specifiers import SpecifierSet + +from .strategies import SETTINGS, VERSION_POOL, pep440_versions, specifier_sets + +if TYPE_CHECKING: + from packaging.version import Version + +pytestmark = pytest.mark.property + + +def _to_range(spec_set: SpecifierSet) -> VersionRange: + """Lift a non-``===`` SpecifierSet into a VersionRange.""" + r = VersionRange.from_specifier_set(spec_set) + assert r is not None + return r + + +@given(spec_set=specifier_sets()) +@SETTINGS +def test_intersect_with_unbounded_is_identity(spec_set: SpecifierSet) -> None: + r = _to_range(spec_set) + u = VersionRange.full() + assert r.intersection(u) == r + assert u.intersection(r) == r + + +@given(spec_set=specifier_sets()) +@SETTINGS +def test_union_with_empty_is_identity(spec_set: SpecifierSet) -> None: + r = _to_range(spec_set) + e = VersionRange.empty() + assert r.union(e) == r + assert e.union(r) == r + + +@given(spec_set=specifier_sets()) +@SETTINGS +def test_intersect_with_empty_is_empty(spec_set: SpecifierSet) -> None: + r = _to_range(spec_set) + e = VersionRange.empty() + assert r.intersection(e) == e + assert e.intersection(r) == e + + +@given(spec_set=specifier_sets()) +@SETTINGS +def test_union_with_unbounded_is_unbounded(spec_set: SpecifierSet) -> None: + r = _to_range(spec_set) + u = VersionRange.full() + assert r.union(u) == u + assert u.union(r) == u + + +@given(spec_set=specifier_sets()) +@SETTINGS +def test_idempotence(spec_set: SpecifierSet) -> None: + r = _to_range(spec_set) + assert r.union(r) == r + assert r.intersection(r) == r + + +@given(a=specifier_sets(), b=specifier_sets()) +@SETTINGS +def test_union_commutative(a: SpecifierSet, b: SpecifierSet) -> None: + ra, rb = _to_range(a), _to_range(b) + assert ra.union(rb) == rb.union(ra) + + +@given(a=specifier_sets(), b=specifier_sets()) +@SETTINGS +def test_intersect_commutative(a: SpecifierSet, b: SpecifierSet) -> None: + ra, rb = _to_range(a), _to_range(b) + assert ra.intersection(rb) == rb.intersection(ra) + + +@given(a=specifier_sets(), b=specifier_sets(), c=specifier_sets()) +@SETTINGS +def test_union_associative(a: SpecifierSet, b: SpecifierSet, c: SpecifierSet) -> None: + ra, rb, rc = _to_range(a), _to_range(b), _to_range(c) + assert ra.union(rb).union(rc) == ra.union(rb.union(rc)) + + +@given(a=specifier_sets(), b=specifier_sets(), c=specifier_sets()) +@SETTINGS +def test_intersect_associative( + a: SpecifierSet, b: SpecifierSet, c: SpecifierSet +) -> None: + ra, rb, rc = _to_range(a), _to_range(b), _to_range(c) + assert ra.intersection(rb).intersection(rc) == ra.intersection(rb.intersection(rc)) + + +@given(spec_set=specifier_sets()) +@SETTINGS +def test_double_complement_identity(spec_set: SpecifierSet) -> None: + r = _to_range(spec_set) + assert r.complement().complement() == r + + +@given(spec_set=specifier_sets()) +@SETTINGS +def test_complement_partitions(spec_set: SpecifierSet) -> None: + r = _to_range(spec_set) + c = r.complement() + # r and ~r are disjoint and together cover the universe. + assert r.intersection(c).is_empty + assert r.union(c) == VersionRange.full() + + +@given(a=specifier_sets(), b=specifier_sets()) +@SETTINGS +def test_de_morgan_intersect(a: SpecifierSet, b: SpecifierSet) -> None: + ra, rb = _to_range(a), _to_range(b) + assert (ra.intersection(rb)).complement() == ra.complement().union(rb.complement()) + + +@given(a=specifier_sets(), b=specifier_sets()) +@SETTINGS +def test_de_morgan_union(a: SpecifierSet, b: SpecifierSet) -> None: + ra, rb = _to_range(a), _to_range(b) + assert (ra.union(rb)).complement() == ra.complement().intersection(rb.complement()) + + +@given(a=specifier_sets(), b=specifier_sets(), c=specifier_sets()) +@SETTINGS +def test_intersect_distributes_over_union( + a: SpecifierSet, b: SpecifierSet, c: SpecifierSet +) -> None: + ra, rb, rc = _to_range(a), _to_range(b), _to_range(c) + lhs = ra.intersection(rb.union(rc)) + rhs = ra.intersection(rb).union(ra.intersection(rc)) + assert lhs == rhs + + +@given(a=specifier_sets(), b=specifier_sets(), c=specifier_sets()) +@SETTINGS +def test_union_distributes_over_intersect( + a: SpecifierSet, b: SpecifierSet, c: SpecifierSet +) -> None: + ra, rb, rc = _to_range(a), _to_range(b), _to_range(c) + lhs = ra.union(rb.intersection(rc)) + rhs = ra.union(rb).intersection(ra.union(rc)) + assert lhs == rhs + + +@given(spec_set=specifier_sets()) +@SETTINGS +def test_operator_aliases(spec_set: SpecifierSet) -> None: + """Operators mirror the named methods.""" + r = _to_range(spec_set) + other = _to_range(SpecifierSet(">=1.0,<2.0")) + assert (r & other) == r.intersection(other) + assert (r | other) == r.union(other) + assert (~r) == r.complement() + + +@given(a=specifier_sets(), b=specifier_sets()) +@SETTINGS +def test_membership_consistent_with_intersect(a: SpecifierSet, b: SpecifierSet) -> None: + """``v in (a & b)`` iff ``v in a`` AND ``v in b`` for every version.""" + ra, rb = _to_range(a), _to_range(b) + intersection = ra.intersection(rb) + for v in VERSION_POOL: + assert (v in intersection) == ((v in ra) and (v in rb)) + + +@given(a=specifier_sets(), b=specifier_sets()) +@SETTINGS +def test_membership_consistent_with_union(a: SpecifierSet, b: SpecifierSet) -> None: + """``v in (a | b)`` iff ``v in a`` OR ``v in b`` for every version.""" + ra, rb = _to_range(a), _to_range(b) + union = ra.union(rb) + for v in VERSION_POOL: + assert (v in union) == ((v in ra) or (v in rb)) + + +@given(spec_set=specifier_sets()) +@SETTINGS +def test_membership_consistent_with_complement(spec_set: SpecifierSet) -> None: + """``v in ~r`` iff ``v not in r`` for every version.""" + r = _to_range(spec_set) + c = r.complement() + for v in VERSION_POOL: + assert (v in c) == (v not in r) + + +@given(spec_set=specifier_sets()) +@SETTINGS +def test_exact_singleton_membership(spec_set: SpecifierSet) -> None: + """``VersionRange.singleton(v)`` contains only ``v`` and no other version.""" + r = _to_range(spec_set) + for v in VERSION_POOL: + exact = VersionRange.singleton(v) + assert v in exact + # ``v`` is in (r & exact) iff v in r. + assert (v in r.intersection(exact)) == (v in r) + + +@given(spec_set=specifier_sets()) +@SETTINGS +def test_hash_equality_consistency(spec_set: SpecifierSet) -> None: + """Equal ranges have equal hashes; usable as dict/set keys.""" + r1 = _to_range(spec_set) + r2 = _to_range(spec_set) + assert r1 == r2 + assert hash(r1) == hash(r2) + + +@given(spec_set=specifier_sets()) +@SETTINGS +def test_complement_is_empty_iff_unbounded(spec_set: SpecifierSet) -> None: + """``~r`` is empty exactly when ``r`` covers everything.""" + r = _to_range(spec_set) + if r.complement().is_empty: + assert r == VersionRange.full() + if r == VersionRange.full(): + assert r.complement().is_empty + + +@given(versions=specifier_sets(), v=pep440_versions()) +@SETTINGS +def test_exact_equals_singleton_intersection( + versions: SpecifierSet, v: Version +) -> None: + """``r & exact(v)`` is non-empty iff v is in r, and equals exact(v) when so.""" + r = _to_range(versions) + e = VersionRange.singleton(v) + inter = r.intersection(e) + if v in r: + assert inter == e + else: + assert inter.is_empty diff --git a/tests/property/test_ranges_to_specifier_set.py b/tests/property/test_ranges_to_specifier_set.py new file mode 100644 index 000000000..0a75f01ee --- /dev/null +++ b/tests/property/test_ranges_to_specifier_set.py @@ -0,0 +1,110 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +"""Property tests for ``VersionRange.to_specifier_set`` round-tripping. + +The conversion is partial (not every range is specifier-expressible), +but when it succeeds it must round-trip exactly. ``None`` is allowed; +silent semantic drift is not. +""" + +from __future__ import annotations + +from functools import reduce +from typing import TYPE_CHECKING + +import pytest +from hypothesis import given + +from packaging.ranges import VersionRange + +from .strategies import SETTINGS, specifier_sets + +if TYPE_CHECKING: + from packaging.specifiers import SpecifierSet + +pytestmark = pytest.mark.property + + +@given(spec_set=specifier_sets()) +@SETTINGS +def test_specifier_derived_ranges_always_have_a_specifier_set( + spec_set: SpecifierSet, +) -> None: + """Specifier-derived ranges always re-encode (incl. ``<0`` for empty).""" + r = VersionRange.from_specifier_set(spec_set) + assert r is not None + converted = r.to_specifier_set() + assert converted is not None, ( + f"specifier-derived range {r!r} should always re-encode " + f"(input was {spec_set!r})" + ) + assert VersionRange.from_specifier_set(converted) == r + + +@given(spec_set=specifier_sets()) +@SETTINGS +def test_to_specifier_sets_round_trips_when_not_none( + spec_set: SpecifierSet, +) -> None: + """If ``to_specifier_sets`` succeeds, the union of its elements equals ``r``.""" + r = VersionRange.from_specifier_set(spec_set) + assert r is not None + converted = r.to_specifier_sets() + if converted is None: + return + assert converted, "to_specifier_sets must return a non-empty tuple" + union = reduce( + VersionRange.union, + (VersionRange.from_specifier_set(s) for s in converted), + ) + assert union == r + + +@given(a=specifier_sets(), b=specifier_sets()) +@SETTINGS +def test_intersection_round_trips_when_not_none( + a: SpecifierSet, b: SpecifierSet +) -> None: + """SpecifierSet is closed under intersection.""" + ra = VersionRange.from_specifier_set(a) + rb = VersionRange.from_specifier_set(b) + assert ra is not None + assert rb is not None + inter = ra & rb + converted = inter.to_specifier_set() + assert converted is not None + assert VersionRange.from_specifier_set(converted) == inter + + +@given(a=specifier_sets(), b=specifier_sets()) +@SETTINGS +def test_to_specifier_sets_handles_union_when_intervals_are_specifier_shaped( + a: SpecifierSet, b: SpecifierSet +) -> None: + """Per-interval encoding succeeds for unions of specifier-derived ranges.""" + ra = VersionRange.from_specifier_set(a) + rb = VersionRange.from_specifier_set(b) + assert ra is not None + assert rb is not None + u = ra | rb + converted = u.to_specifier_sets() + assert converted is not None + union = reduce( + VersionRange.union, + (VersionRange.from_specifier_set(s) for s in converted), + ) + assert union == u + + +@given(spec_set=specifier_sets()) +@SETTINGS +def test_to_specifier_set_implies_to_specifier_sets( + spec_set: SpecifierSet, +) -> None: + """``to_specifier_set is not None`` ⇒ ``to_specifier_sets is not None``.""" + r = VersionRange.from_specifier_set(spec_set) + assert r is not None + if r.to_specifier_set() is not None: + assert r.to_specifier_sets() is not None diff --git a/tests/test_ranges.py b/tests/test_ranges.py new file mode 100644 index 000000000..e4e5e8478 --- /dev/null +++ b/tests/test_ranges.py @@ -0,0 +1,1285 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +from __future__ import annotations + +import pickle + +import pytest + +from packaging.ranges import ( + VersionRange, + _BoundaryKind, + _BoundaryVersion, + _restore_version_range, +) +from packaging.specifiers import Specifier, SpecifierSet +from packaging.version import Version + + +class TestDirectConstructionForbidden: + def test_call_raises_type_error(self) -> None: + with pytest.raises(TypeError, match="cannot create 'VersionRange' instances"): + VersionRange() + + def test_call_with_args_raises_type_error(self) -> None: + with pytest.raises(TypeError): + VersionRange("anything") + + def test_call_with_kwargs_raises_type_error(self) -> None: + with pytest.raises(TypeError): + VersionRange(bounds=()) + + def test_subclass_call_raises_too(self) -> None: + # __new__ raises before any subclass __init__ runs. + class Sub(VersionRange): + pass + + with pytest.raises(TypeError): + Sub() + + +class TestToRangeMethods: + """``Specifier.to_range`` and ``SpecifierSet.to_range`` are + convenience methods that delegate to the corresponding + :class:`VersionRange` classmethod factories. They must produce the + same result as the factories.""" + + def test_specifier_to_range(self) -> None: + spec = Specifier(">=1.0") + method_result = spec.to_range() + factory_result = VersionRange.from_specifier(spec) + assert method_result == factory_result + assert method_result is not None + assert "1.5" in method_result + + def test_specifier_set_to_range(self) -> None: + ss = SpecifierSet(">=1.0,<2.0") + method_result = ss.to_range() + factory_result = VersionRange.from_specifier_set(ss) + # The factories share a per-SpecifierSet cache; both must + # return the *same object*. + assert method_result is factory_result + assert method_result is not None + assert "1.5" in method_result + + def test_specifier_set_to_range_empty(self) -> None: + r = SpecifierSet("").to_range() + assert r is not None + assert "0.1" in r + + def test_specifier_set_to_range_unsatisfiable(self) -> None: + r = SpecifierSet(">=2,<1").to_range() + assert r is not None + assert r.is_empty + + +class TestFromSpecifier: + def test_returns_version_range(self) -> None: + r = VersionRange.from_specifier(Specifier(">=1.0")) + assert isinstance(r, VersionRange) + assert "1.0" in r + assert "0.5" not in r + + def test_arbitrary_returns_carve_out_range(self) -> None: + r = VersionRange.from_specifier(Specifier("===wat")) + assert isinstance(r, VersionRange) + assert r._admit == frozenset({"wat"}) + assert "wat" in r + assert "WAT" in r + assert "other" not in r + + r = VersionRange.from_specifier(Specifier("===1.0")) + assert isinstance(r, VersionRange) + assert r._admit == frozenset({"1.0"}) + assert "1.0" in r + assert "1.0+local" not in r # === is exact, unlike == + + def test_arbitrary_supports_set_algebra(self) -> None: + # ``===`` ranges propagate through the lattice on a slow path. + # The result may not have a SpecifierSet representation, but + # the operations always succeed. + wat = VersionRange.from_specifier(Specifier("===wat")) + full = VersionRange.full() + assert wat.intersection(full) == wat + assert wat.union(full) == full + comp = wat.complement() + assert "wat" not in comp + assert "1.0" in comp + assert comp.complement() == wat + + def test_unsatisfiable_returns_empty_range(self) -> None: + # `` None: + r = VersionRange.from_specifier(Specifier("==1.2.*")) + assert isinstance(r, VersionRange) + assert "1.2" in r + assert "1.2.3" in r + assert "1.3" not in r + assert "1.1" not in r + + def test_compatible_release(self) -> None: + r = VersionRange.from_specifier(Specifier("~=1.2.3")) + assert isinstance(r, VersionRange) + assert "1.2.3" in r + assert "1.2.99" in r + assert "1.3" not in r + + def test_not_equal_disjoint(self) -> None: + r = VersionRange.from_specifier(Specifier("!=1.5")) + assert isinstance(r, VersionRange) + assert "1.4" in r + assert "1.5" not in r + assert "1.6" in r + + +class TestFromSpecifierSet: + def test_simple(self) -> None: + r = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + assert isinstance(r, VersionRange) + assert "1.5" in r + assert "2.0" not in r + + def test_arbitrary_returns_carve_out_range(self) -> None: + r = VersionRange.from_specifier_set(SpecifierSet("===wat")) + assert isinstance(r, VersionRange) + assert r._admit == frozenset({"wat"}) + assert "wat" in r + assert "other" not in r + + # ``wat`` does not parse, so the conjunction with ``>=1`` is + # empty and the literal tag is dropped (matched set is the + # source of truth, not the spec text). Order does not matter. + for spec in ("===wat,>=1", ">=1,===wat"): + r = VersionRange.from_specifier_set(SpecifierSet(spec)) + assert isinstance(r, VersionRange) + assert r._admit == frozenset() + assert r.is_empty + + def test_empty_specifier_set_is_full_range(self) -> None: + r = VersionRange.from_specifier_set(SpecifierSet("")) + assert isinstance(r, VersionRange) + assert "0.1" in r + assert "999.0" in r + + def test_unsatisfiable_returns_empty_range(self) -> None: + r = VersionRange.from_specifier_set(SpecifierSet(">=2,<1")) + assert isinstance(r, VersionRange) + assert r.is_empty + + def test_intersection_via_combine(self) -> None: + ss = SpecifierSet(">=1.0,<3.0") & SpecifierSet(">=2.0,<4.0") + r = VersionRange.from_specifier_set(ss) + assert isinstance(r, VersionRange) + assert "2.5" in r + assert "1.5" not in r + assert "3.5" not in r + + def test_caching_returns_same_object(self) -> None: + ss = SpecifierSet(">=1.0,<2.0") + first = VersionRange.from_specifier_set(ss) + second = VersionRange.from_specifier_set(ss) + assert first is second + + def test_caching_for_arbitrary_returns_same_object(self) -> None: + ss = SpecifierSet("===wat") + first = VersionRange.from_specifier_set(ss) + second = VersionRange.from_specifier_set(ss) + assert first is second + assert first is not None + assert first._admit == frozenset({"wat"}) + + def test_cache_invalidates_on_canonicalize(self) -> None: + # Iterating the iterable-built SpecifierSet triggers + # canonicalization, which must invalidate the cache. + ss = SpecifierSet([Specifier(">=1.0"), Specifier("<2.0"), Specifier(">=1.0")]) + first = VersionRange.from_specifier_set(ss) + str(ss) # force canonicalization + second = VersionRange.from_specifier_set(ss) + assert first == second + + def test_cache_invalidates_on_prereleases_setter(self) -> None: + # The range does not depend on prereleases, so the recomputed + # result is structurally equal. + ss = SpecifierSet(">=1.0") + first = VersionRange.from_specifier_set(ss) + ss.prereleases = True + assert VersionRange.from_specifier_set(ss) == first + + def test_combination_with_arbitrary_returns_carve_out(self) -> None: + a = SpecifierSet(">=1.0") + b = SpecifierSet("===wat") + r = VersionRange.from_specifier_set(a & b) + assert isinstance(r, VersionRange) + assert r._admit == frozenset() + assert r.is_empty + + +class TestContains: + def test_simple_lower_inclusive(self) -> None: + r = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + assert r is not None + assert "1.0" in r + assert "1.5" in r + assert "2.0" not in r + assert "0.9" not in r + + def test_simple_upper_inclusive(self) -> None: + r = VersionRange.from_specifier_set(SpecifierSet(">1.0,<=2.0")) + assert r is not None + assert "1.0" not in r + assert "1.5" in r + assert "2.0" in r + assert "2.0.1" not in r + + def test_disjoint_excluded(self) -> None: + r = VersionRange.from_specifier_set(SpecifierSet(">=1.0,!=1.5")) + assert r is not None + assert "1.0" in r + assert "1.4" in r + assert "1.5" not in r + assert "1.6" in r + + def test_empty_range_contains_nothing(self) -> None: + r = VersionRange.from_specifier_set(SpecifierSet(">=2.0,<1.0")) + assert r is not None + assert "0.5" not in r + assert "1.5" not in r + assert "2.5" not in r + + def test_full_range_contains_anything_parseable(self) -> None: + r = VersionRange.from_specifier_set(SpecifierSet("")) + assert r is not None + assert "0.1" in r + assert "999.0" in r + assert "1.0a1" in r + + def test_unparsable_string_not_contained(self) -> None: + r = VersionRange.from_specifier_set(SpecifierSet(">=1.0")) + assert r is not None + assert "not-a-version" not in r + assert "" not in r + + def test_version_object(self) -> None: + r = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + assert r is not None + assert Version("1.5") in r + assert Version("2.0") not in r + + def test_local_segment_handling(self) -> None: + # PEP 440: <=1.0 includes 1.0+local + r = VersionRange.from_specifier_set(SpecifierSet("<=1.0")) + assert r is not None + assert "1.0" in r + assert "1.0+local" in r + + def test_post_release_excluded_by_gt(self) -> None: + # PEP 440: >1.0 excludes 1.0.postN + r = VersionRange.from_specifier_set(SpecifierSet(">1.0")) + assert r is not None + assert "1.0" not in r + assert "1.0.post1" not in r + assert "1.1" in r + + +class TestEmpty: + @pytest.mark.parametrize( + ("spec", "expected_empty"), + [ + (">=2,<1", True), + (">=1,<2", False), + ("", False), + ], + ) + def test_is_empty_matches_bool(self, spec: str, expected_empty: bool) -> None: + r = VersionRange.from_specifier_set(SpecifierSet(spec)) + assert r is not None + assert r.is_empty is expected_empty + assert bool(r) is not expected_empty + + +class TestEquality: + def test_same_range_equal(self) -> None: + r1 = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + r2 = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + assert r1 == r2 + + def test_equivalent_specifiers_equal(self) -> None: + # Two SpecifierSets that intersect to the same range should + # produce equal VersionRanges. + r1 = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + r2 = VersionRange.from_specifier_set( + SpecifierSet(">=1.0") & SpecifierSet("<2.0") + ) + assert r1 == r2 + + def test_different_ranges_unequal(self) -> None: + r1 = VersionRange.from_specifier_set(SpecifierSet(">=1.0")) + r2 = VersionRange.from_specifier_set(SpecifierSet(">=2.0")) + assert r1 != r2 + + def test_compare_to_other_types(self) -> None: + r = VersionRange.from_specifier_set(SpecifierSet(">=1.0")) + assert r != "VersionRange" + assert r != 42 + assert r != None # noqa: E711 + + def test_hash_matches_equality(self) -> None: + r1 = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + r2 = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + assert hash(r1) == hash(r2) + + def test_hashable_in_set(self) -> None: + r1 = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + r2 = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + r3 = VersionRange.from_specifier_set(SpecifierSet(">=3.0")) + assert len({r1, r2, r3}) == 2 + + +class TestRepr: + @pytest.mark.parametrize( + ("spec", "expected"), + [ + (">=1.0,<2.0", ""), + (">=2.0,<1.0", ""), + ("", ""), + ], + ) + def test_repr_simple(self, spec: str, expected: str) -> None: + assert repr(VersionRange.from_specifier_set(SpecifierSet(spec))) == expected + + def test_repr_disjoint(self) -> None: + r = VersionRange.from_specifier_set(SpecifierSet("!=1.0")) + assert " | " in repr(r) + assert "(-inf" in repr(r) + assert "+inf)" in repr(r) + + def test_repr_with_boundary(self) -> None: + # AFTER_LOCALS / AFTER_POSTS bounds still produce a valid repr. + r = VersionRange.from_specifier_set(SpecifierSet("<=1.0")) + text = repr(r) + assert text.startswith(" None: + r = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + restored = pickle.loads(pickle.dumps(r)) + assert restored == r + assert "1.5" in restored + assert "2.5" not in restored + + def test_pickle_empty_range(self) -> None: + r = VersionRange.from_specifier_set(SpecifierSet(">=2.0,<1.0")) + restored = pickle.loads(pickle.dumps(r)) + assert restored == r + assert restored.is_empty + + def test_pickle_full_range(self) -> None: + r = VersionRange.from_specifier_set(SpecifierSet("")) + restored = pickle.loads(pickle.dumps(r)) + assert restored == r + assert "1.0" in restored + + def test_pickle_disjoint(self) -> None: + r = VersionRange.from_specifier_set(SpecifierSet("!=1.5")) + restored = pickle.loads(pickle.dumps(r)) + assert restored == r + assert "1.5" not in restored + assert "1.6" in restored + + def test_pickle_at_all_protocols(self) -> None: + r = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + for protocol in range(pickle.HIGHEST_PROTOCOL + 1): + restored = pickle.loads(pickle.dumps(r, protocol=protocol)) + assert restored == r + + +class TestBoundaryClosureEdgeCases: + """Edge cases for the closure-based boundary checks. + + These exercise branches that fire when the parsed version's + release tuple is shorter than the bound version's trimmed + release: uncommon in real specifiers but reachable. + """ + + def test_after_posts_short_release_above(self) -> None: + # ``2`` has cmpkey > V but len(release)=1 < len(v_trimmed)=4. + r = VersionRange.from_specifier_set(SpecifierSet(">1.0.0.5")) + assert r is not None + assert "2" in r + assert "1" not in r + assert "1.0.0.5" not in r + + def test_after_locals_short_release_below(self) -> None: + # ``2`` has cmpkey > V but shorter release, so it cannot be + # in V's local family. + r = VersionRange.from_specifier_set(SpecifierSet("<=1.0.0.5")) + assert r is not None + assert "1" in r + assert "1.0.0.5" in r + assert "1.0.0.5+local" in r + assert "2" not in r + + def test_after_locals_lower_short_release(self) -> None: + # ``!=1.0.0.5`` puts an AFTER_LOCALS lower bound on the second + # range; short-release versions must still resolve. + r = VersionRange.from_specifier_set(SpecifierSet("!=1.0.0.5")) + assert r is not None + assert "2" in r + assert "1" in r + assert "1.0.0.5" not in r + + +class TestBoundaryVersionCompare: + """The :class:`_BoundaryVersion` class is private but its comparison + operators must stay correct for the bound-sorting machinery.""" + + def test_lt_same_version_different_kind(self) -> None: + # AFTER_LOCALS sorts before AFTER_POSTS for the same V. + v = Version("1.0") + a = _BoundaryVersion(v, _BoundaryKind.AFTER_LOCALS) + b = _BoundaryVersion(v, _BoundaryKind.AFTER_POSTS) + assert a < b + assert not (b < a) + + def test_gt_same_version_different_kind(self) -> None: + v = Version("1.0") + a = _BoundaryVersion(v, _BoundaryKind.AFTER_LOCALS) + b = _BoundaryVersion(v, _BoundaryKind.AFTER_POSTS) + assert b > a + assert not (a > b) + + def test_lt_different_versions(self) -> None: + a = _BoundaryVersion(Version("1.0"), _BoundaryKind.AFTER_POSTS) + b = _BoundaryVersion(Version("2.0"), _BoundaryKind.AFTER_POSTS) + assert a < b + + +class TestEmptyFactory: + """``VersionRange.empty`` builds the additive identity for union.""" + + def test_returns_empty_range(self) -> None: + r = VersionRange.empty() + assert isinstance(r, VersionRange) + assert r.is_empty + assert not bool(r) + + def test_contains_nothing(self) -> None: + r = VersionRange.empty() + assert "1.0" not in r + assert Version("1.0") not in r + assert "0" not in r + + def test_intersect_with_empty_is_empty(self) -> None: + any_r = VersionRange.full() + e = VersionRange.empty() + assert any_r.intersection(e).is_empty + assert e.intersection(any_r).is_empty + + def test_union_with_empty_is_self(self) -> None: + a = VersionRange.from_specifier(Specifier(">=1.0")) + assert a is not None + e = VersionRange.empty() + assert a.union(e) == a + assert e.union(a) == a + + def test_complement_of_empty_is_unbounded(self) -> None: + assert VersionRange.empty().complement() == VersionRange.full() + + def test_equal_across_constructions(self) -> None: + a = VersionRange.empty() + b = VersionRange.from_specifier_set(SpecifierSet(">=2,<1")) + assert b is not None + assert a == b + assert hash(a) == hash(b) + + +class TestUnboundedFactory: + """``VersionRange.full`` builds the multiplicative identity for intersect.""" + + def test_returns_full_range(self) -> None: + r = VersionRange.full() + assert isinstance(r, VersionRange) + assert not r.is_empty + assert bool(r) + + def test_contains_anything(self) -> None: + # Full-range carve-out: admits arbitrary strings to match the + # behaviour of ``SpecifierSet("")``. Non-full ranges still + # reject unparsable inputs. + r = VersionRange.full() + assert "0" in r + assert "999.999.999" in r + assert "1.0a1" in r + assert "not-a-version" in r + + def test_intersect_with_unbounded_is_self(self) -> None: + a = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + assert a is not None + u = VersionRange.full() + assert a.intersection(u) == a + assert u.intersection(a) == a + + def test_union_with_unbounded_is_unbounded(self) -> None: + a = VersionRange.from_specifier(Specifier(">=1.0")) + assert a is not None + u = VersionRange.full() + assert a.union(u) == u + assert u.union(a) == u + + def test_complement_of_unbounded_is_empty(self) -> None: + assert VersionRange.full().complement().is_empty + + def test_equal_to_empty_specifier_set(self) -> None: + assert VersionRange.full() == VersionRange.from_specifier_set(SpecifierSet("")) + + +class TestExactFactory: + """``VersionRange.singleton`` builds the singleton range.""" + + @pytest.mark.parametrize("arg", ["1.2.3", Version("1.2.3")]) + def test_from_string_or_version(self, arg: str | Version) -> None: + r = VersionRange.singleton(arg) + assert "1.2.3" in r + assert "1.2.4" not in r + assert "1.2.2" not in r + + def test_invalid_string_raises(self) -> None: + from packaging.version import InvalidVersion # noqa: PLC0415 + + with pytest.raises(InvalidVersion): + VersionRange.singleton("not-a-version") + + def test_equal_to_eq_specifier(self) -> None: + # ``==1.2.3`` matches ``1.2.3`` and ``1.2.3+local``; ``exact`` + # is the strict singleton, not the same range. + exact = VersionRange.singleton("1.2.3") + eq_spec = VersionRange.from_specifier(Specifier("==1.2.3")) + assert eq_spec is not None + assert "1.2.3+local" in eq_spec + assert "1.2.3+local" not in exact + + def test_intersect_disjoint_exacts_is_empty(self) -> None: + a = VersionRange.singleton("1.0") + b = VersionRange.singleton("2.0") + assert a.intersection(b).is_empty + + def test_intersect_equal_exacts_is_self(self) -> None: + a = VersionRange.singleton("1.0") + b = VersionRange.singleton("1.0") + assert a.intersection(b) == a + + def test_hashable(self) -> None: + a = VersionRange.singleton("1.0") + b = VersionRange.singleton("1.0") + assert hash(a) == hash(b) + assert len({a, b, VersionRange.singleton("2.0")}) == 2 + + +class TestUnion: + def test_disjoint_exacts(self) -> None: + a = VersionRange.singleton("1.0") + b = VersionRange.singleton("2.0") + u = a.union(b) + assert "1.0" in u + assert "2.0" in u + assert "1.5" not in u + + def test_overlapping_intervals_collapse(self) -> None: + a = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + b = VersionRange.from_specifier_set(SpecifierSet(">=1.5,<3.0")) + assert a is not None + assert b is not None + u = a.union(b) + assert "1.0" in u + assert "2.5" in u + assert "3.0" not in u + assert "0.5" not in u + + def test_union_with_self_is_self(self) -> None: + a = VersionRange.from_specifier(Specifier(">=1.0")) + assert a is not None + assert a.union(a) == a + + def test_union_of_neg_complementary_ranges_covers_all(self) -> None: + lower = VersionRange.from_specifier(Specifier("<1.0")) + upper = VersionRange.from_specifier(Specifier(">=1.0")) + assert lower is not None + assert upper is not None + u = lower.union(upper) + assert "0.5" in u + assert "1.0" in u + assert "999" in u + + def test_union_preserves_disjoint_repr_count(self) -> None: + a = VersionRange.singleton("1.0") + b = VersionRange.singleton("3.0") + u = a.union(b) + assert " | " in repr(u) + + def test_union_of_two_unbounded_lower_collapses(self) -> None: + a = VersionRange.from_specifier(Specifier("<1")) + b = VersionRange.from_specifier(Specifier("<2")) + assert a is not None + assert b is not None + assert a.union(b) == b + + def test_union_of_two_unbounded_upper_collapses(self) -> None: + a = VersionRange.from_specifier(Specifier(">=1")) + b = VersionRange.from_specifier(Specifier(">=2")) + assert a is not None + assert b is not None + assert a.union(b) == a + + def test_touching_inclusive_exclusive_collapses(self) -> None: + # [1.0, 2.0) U [2.0, 3.0) == [1.0, 3.0) + a = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + b = VersionRange.from_specifier_set(SpecifierSet(">=2.0,<3.0")) + assert a is not None + assert b is not None + u = a.union(b) + assert "1.5" in u + assert "2.0" in u + assert "2.999" in u + assert "3.0" not in u + + def test_touching_exclusive_exclusive_does_not_collapse(self) -> None: + # [1.0, 2.0) U (2.0, 3.0) still excludes 2.0 + a = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + b = VersionRange.from_specifier_set(SpecifierSet(">2.0,<3.0")) + assert a is not None + assert b is not None + u = a.union(b) + assert "2.0" not in u + assert "1.5" in u + assert "2.5" in u + + def test_touching_inclusive_inclusive_at_same_version_collapses(self) -> None: + # Two singletons at the same Version both have inclusive bounds + # at that Version; the union collapses to a single singleton. + a = VersionRange.singleton("1.0") + b = VersionRange.singleton("1.0") + assert a.union(b) == a + + +class TestComplement: + @pytest.mark.parametrize( + ("spec", "members", "non_members"), + [ + (">=2.0", ["1.0"], ["2.0", "3.0"]), + ("<2.0", ["2.0", "3.0"], ["1.0"]), + # ~(!=V) is {V}. + ("!=1.5", ["1.5"], ["1.4", "1.6"]), + ], + ) + def test_complement_of_simple_specifier( + self, spec: str, members: list[str], non_members: list[str] + ) -> None: + r = VersionRange.from_specifier(Specifier(spec)) + assert r is not None + c = r.complement() + for v in members: + assert v in c + for v in non_members: + assert v not in c + + def test_complement_creates_after_posts_upper_bound(self) -> None: + # Complementing an AFTER_POSTS lower bound exercises every + # branch of the upper-side post-family predicate. + r = VersionRange.from_specifier_set(SpecifierSet(">1.0,<=2.0")) + assert r is not None + c = r.complement() + assert "1.0" in c + assert "1.0+local" in c + assert "1.0.post0" in c + assert "1.0.post5+local" in c + assert "1.5" not in c + # Isolated AFTER_POSTS complement: covers shorter-release path. + r2 = VersionRange.from_specifier_set(SpecifierSet(">1.0.0.5")) + assert r2 is not None + c2 = r2.complement() + assert "1.0.0.5" in c2 + assert "1.0.0.5.post0" in c2 + # ``2`` has release shorter than v's trimmed (1,0,0,5). + assert "2" not in c2 + assert "1.0.0.6" not in c2 + # Tail-zero release matches the family. + r3 = VersionRange.from_specifier_set(SpecifierSet(">1.0")) + assert r3 is not None + c3 = r3.complement() + assert "1.0.0" in c3 + assert "1.0.0.post0" in c3 + assert "1.0.1" not in c3 + # Pre-release mismatch path. + r4 = VersionRange.from_specifier_set(SpecifierSet(">1.0a1")) + assert r4 is not None + c4 = r4.complement() + assert "1.0a2" not in c4 + # Different epoch path. + assert "2!1.0" not in c3 + + +class TestOperatorAliases: + def test_and_aliases_intersect(self) -> None: + a = VersionRange.from_specifier(Specifier(">=1.0")) + b = VersionRange.from_specifier(Specifier("<2.0")) + assert a is not None + assert b is not None + assert (a & b) == a.intersection(b) + + def test_or_aliases_union(self) -> None: + a = VersionRange.singleton("1.0") + b = VersionRange.singleton("2.0") + assert (a | b) == a.union(b) + + def test_invert_aliases_complement(self) -> None: + r = VersionRange.from_specifier(Specifier(">=1.0")) + assert r is not None + assert (~r) == r.complement() + + def test_and_with_non_range_returns_notimplemented(self) -> None: + a = VersionRange.from_specifier(Specifier(">=1.0")) + assert a is not None + with pytest.raises(TypeError): + a & "not a range" + with pytest.raises(TypeError): + a & 42 + + def test_or_with_non_range_returns_notimplemented(self) -> None: + a = VersionRange.from_specifier(Specifier(">=1.0")) + assert a is not None + with pytest.raises(TypeError): + a | "not a range" + with pytest.raises(TypeError): + a | 42 + + def test_chained_operations(self) -> None: + # ``(>=1) & (<2) | (==3)`` + ge1 = VersionRange.from_specifier(Specifier(">=1.0")) + lt2 = VersionRange.from_specifier(Specifier("<2.0")) + eq3 = VersionRange.singleton("3.0") + assert ge1 is not None + assert lt2 is not None + result = (ge1 & lt2) | eq3 + assert "1.5" in result + assert "3.0" in result + assert "2.5" not in result + + +class TestToSpecifierSet: + """``to_specifier_set`` returns a single SpecifierSet or ``None``.""" + + def test_full_range_round_trips_via_empty_specifier_set(self) -> None: + assert VersionRange.full().to_specifier_set() == SpecifierSet("") + + def test_empty_range_round_trips_via_lt_zero(self) -> None: + # ``<0`` is the canonical empty SpecifierSet (0.dev0 is the + # smallest PEP 440 version). + assert VersionRange.empty().to_specifier_set() == SpecifierSet("<0") + assert ( + VersionRange.from_specifier_set(SpecifierSet("<0")) == VersionRange.empty() + ) + + def test_singleton_returns_none_for_local_less_version(self) -> None: + # ``==V`` matches V+local, so the strict ``[V, V]`` singleton + # has no SpecifierSet form. + assert VersionRange.singleton("1.5").to_specifier_set() is None + + def test_singleton_with_local_round_trips_via_eq(self) -> None: + r = VersionRange.singleton("1.5+local") + ss = r.to_specifier_set() + assert ss is not None + assert VersionRange.from_specifier_set(ss) == r + + def test_complement_of_half_line_round_trips_via_le_ne_pair(self) -> None: + # ``~(>=1.0)`` round-trips as ``<=1.0,!=1.0``. + ge1 = VersionRange.from_specifier(Specifier(">=1.0")) + assert ge1 is not None + ss = ge1.complement().to_specifier_set() + assert ss is not None + assert VersionRange.from_specifier_set(ss) == ge1.complement() + + def test_complement_of_strict_greater_than_returns_none(self) -> None: + # ``~(>V)`` produces an inclusive AFTER_POSTS upper bound, + # which has no specifier representation. + gt1 = VersionRange.from_specifier(Specifier(">1.0")) + assert gt1 is not None + assert gt1.complement().to_specifier_set() is None + + def test_strict_greater_than_round_trips_via_gt(self) -> None: + # ``>V`` has an AFTER_POSTS lower bound; round-trips as ``>V``. + r = VersionRange.from_specifier(Specifier(">1.0")) + assert r.to_specifier_set() == SpecifierSet(">1.0") + + def test_complement_of_le_round_trips_via_ne_ge_pair(self) -> None: + # ``~(<=V)`` has an AFTER_LOCALS lower bound; round-trips as + # ``>=V,!=V`` (excludes V and every V+local). + le1 = VersionRange.from_specifier(Specifier("<=1.0")) + comp = le1.complement() + assert comp.to_specifier_set() == SpecifierSet(">=1.0,!=1.0") + + def test_disjoint_union_returns_none_when_gap_unaligned(self) -> None: + # The gap between ``[1.0, 2.0)`` and ``[3.0, 4.0)`` does not + # align with any ``==V.*`` family. + a = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + b = VersionRange.from_specifier_set(SpecifierSet(">=3.0,<4.0")) + assert a is not None + assert b is not None + assert (a | b).to_specifier_set() is None + + def test_after_locals_upper_then_plain_lower_returns_none(self) -> None: + # AFTER_LOCALS upper + plain lower fails ``!=V``/``!=V.*`` + # detection (left bound shape check), but the per-interval + # tuple form still succeeds. + r = VersionRange.from_specifier( + Specifier("<=1.0") + ) | VersionRange.from_specifier(Specifier(">=2.0")) + assert r.to_specifier_set() is None + sets = r.to_specifier_sets() + assert sets is not None + assert len(sets) == 2 + + def test_lt_excl_then_ge_incl_returns_none_on_unaligned_gap(self) -> None: + # Both ``!=V`` and ``!=V.*`` detection require matching bound + # shapes that this gap does not satisfy. + r = VersionRange.from_specifier( + Specifier("<1.0") + ) | VersionRange.from_specifier(Specifier(">=3.0")) + assert r.to_specifier_set() is None + + def test_unaligned_dev0_release_lengths_returns_none(self) -> None: + # Both bounds are X.dev0 but release lengths differ, so + # ``!=V.*`` does not apply. + a = VersionRange.from_specifier(Specifier("<1.dev0")) + b = VersionRange.from_specifier_set(SpecifierSet(">=1.2.dev0")) + u = a | b + assert u.to_specifier_set() is None + + def test_unaligned_dev0_increment_returns_none(self) -> None: + # ``==1.* | ==3.*`` canonicalises to a gap of width 1 between + # 2.dev0 and 3.dev0, so ``!=2.*`` IS expressible. Confirm the + # round-trip (positive path of the increment check). + a = VersionRange.from_specifier(Specifier("==1.*")) + b = VersionRange.from_specifier(Specifier("==3.*")) + u = a | b + ss = u.to_specifier_set() + assert ss is None or VersionRange.from_specifier_set(ss) == u + + def test_unaligned_release_prefix_returns_none(self) -> None: + # Same release length but the prefix doesn't match the + # increment pattern ``!=V.*`` requires. + a = VersionRange.from_specifier(Specifier("<1.0.dev0")) + b = VersionRange.from_specifier_set(SpecifierSet(">=2.0.dev0")) + u = a | b + result = u.to_specifier_set() + if result is not None: + assert VersionRange.from_specifier_set(result) == u + + def test_v_exclusive_lower_bound_is_not_encodable(self) -> None: + # ``~singleton(V)`` produces two ``V (excl)`` bounds. The + # second interval's V-exclusive lower has no specifier form. + s = VersionRange.singleton("1.5") + c = s.complement() + assert c.to_specifier_set() is None + assert c.to_specifier_sets() is None + + def test_after_posts_lower_after_plain_upper_breaks_ne_v(self) -> None: + # AFTER_POSTS right lower fails the right-bound shape check + # in both ``!=V`` and ``!=V.*`` detection. + a = VersionRange.from_specifier(Specifier("<1.0")) + b = VersionRange.from_specifier(Specifier(">2.0")) + u = a | b + assert u.to_specifier_set() is None + + def test_disjoint_singletons_break_ne_v_star_at_inclusive_left(self) -> None: + # Inclusive left upper bails the ``!=V.*`` guard, and + # singletons aren't specifier-shaped per-interval either. + u = VersionRange.singleton("1.0") | VersionRange.singleton("2.0") + assert u.to_specifier_set() is None + + def test_far_apart_dev0_release_breaks_ne_v_star_increment(self) -> None: + # Gap of width 4 fails the ``!=V.*`` increment check. + a = VersionRange.from_specifier(Specifier("==1.*")) + b = VersionRange.from_specifier(Specifier("==5.*")) + u = a | b + assert u.to_specifier_set() is None + + +class TestToSpecifierSets: + """``to_specifier_sets`` returns a tuple of SpecifierSets, or ``None``.""" + + def test_full_range_returns_one_tuple_of_empty_specifier_set(self) -> None: + assert VersionRange.full().to_specifier_sets() == (SpecifierSet(""),) + + def test_empty_range_returns_lt_zero_tuple(self) -> None: + assert VersionRange.empty().to_specifier_sets() == (SpecifierSet("<0"),) + + def test_singleton_returns_none(self) -> None: + # Per-interval encoding of [V, V] also fails: the inclusive + # upper bound has no specifier. + assert VersionRange.singleton("1.5").to_specifier_sets() is None + + def test_disjoint_union_succeeds_with_one_set_per_interval(self) -> None: + a = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + b = VersionRange.from_specifier_set(SpecifierSet(">=3.0,<4.0")) + assert a is not None + assert b is not None + union = a | b + sets = union.to_specifier_sets() + assert sets is not None + assert len(sets) == 2 + left = VersionRange.from_specifier_set(sets[0]) + right = VersionRange.from_specifier_set(sets[1]) + assert (left | right) == union + + def test_multi_interval_range_with_single_set_form_returns_one_tuple( + self, + ) -> None: + # ``!=1.0`` has a single-set form, so the tuple is length 1 + # instead of falling through to per-interval encoding. + r = VersionRange.from_specifier(Specifier("!=1.0")) + assert r is not None + sets = r.to_specifier_sets() + assert sets == (SpecifierSet("!=1.0"),) + + def test_cross_epoch_union_breaks_ne_v_star_epoch_check(self) -> None: + # ``!=V.*`` detection bails at the epoch equality check. + a = VersionRange.from_specifier(Specifier("==1.*")) + b = VersionRange.from_specifier(Specifier("==1!1.*")) + u = a | b + assert u.to_specifier_set() is None + + +class TestArbitraryCarveOut: + """``===`` arbitrary-equality ranges: a case-insensitive string-match + layered on top of a regular range.""" + + def test_filter_with_explicit_prereleases_true(self) -> None: + r = VersionRange.from_specifier(Specifier("===1.0a1")) + items = ["1.0a1", "1.0", "1.0A1", "other"] + assert list(r.filter(items, prereleases=True)) == ["1.0a1", "1.0A1"] + + def test_filter_with_explicit_prereleases_false_drops_prerelease(self) -> None: + r = VersionRange.from_specifier(Specifier("===1.0a1")) + # ``1.0a1`` parses as a pre-release; ``prereleases=False`` drops it. + assert list(r.filter(["1.0a1"], prereleases=False)) == [] + + def test_filter_with_explicit_prereleases_false_keeps_unparsable(self) -> None: + # Unparsable strings can't be prerelease-filtered out (mirrors + # ``Specifier.filter``). + r = VersionRange.from_specifier(Specifier("===wat")) + assert list(r.filter(["wat"], prereleases=False)) == ["wat"] + + def test_filter_default_pep440_buffers_unparsable_until_final(self) -> None: + # No final ever arrives, so unparsable buffered items flush at end. + r = VersionRange.from_specifier(Specifier("===wat")) + assert list(r.filter(["wat", "WAT", "other"])) == ["wat", "WAT"] + + def test_filter_default_pep440_yields_unparsable_after_final(self) -> None: + r = VersionRange.from_specifier(Specifier("===1.0")) + assert list(r.filter(["1.0", "other", "1.0"])) == ["1.0", "1.0"] + + def test_filter_default_pep440_buffers_prereleases(self) -> None: + r = VersionRange.from_specifier(Specifier("===1.0a1")) + assert list(r.filter(["1.0a1", "other"])) == ["1.0a1"] + + def test_to_specifier_set_full_bounds(self) -> None: + r = VersionRange.from_specifier(Specifier("===wat")) + assert r.to_specifier_set() == SpecifierSet("===wat") + + def test_to_specifier_set_empty_bounds(self) -> None: + # ``wat`` cannot satisfy ``>=1``, so the conjunction is empty + # and the literal tag is dropped on round-trip. + r = VersionRange.from_specifier_set(SpecifierSet("===wat,>=1")) + ss = r.to_specifier_set() + assert ss == SpecifierSet("<0") + assert VersionRange.from_specifier_set(ss) == r + + def test_to_specifier_set_with_rangelike_lossy_round_trip(self) -> None: + # The literal "1.5" parses inside [1.0, 2.0), so the rangelike + # tail is redundant and gets dropped on round-trip. + r = VersionRange.from_specifier_set(SpecifierSet("===1.5,>=1.0,<2.0")) + ss = r.to_specifier_set() + assert ss == SpecifierSet("===1.5") + assert VersionRange.from_specifier_set(ss) == r + + def test_to_specifier_sets_returns_one_tuple(self) -> None: + r = VersionRange.from_specifier(Specifier("===wat")) + sets = r.to_specifier_sets() + assert sets == (SpecifierSet("===wat"),) + + def test_repr_arbitrary_full(self) -> None: + r = VersionRange.from_specifier(Specifier("===wat")) + assert repr(r) == "" + + def test_repr_arbitrary_empty(self) -> None: + r = VersionRange.from_specifier_set(SpecifierSet("===wat,>=1")) + assert repr(r) == "" + + def test_repr_admit_with_bounds(self) -> None: + wat = VersionRange.from_specifier(Specifier("===wat")) + rangelike = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + r = wat.union(rangelike) + assert repr(r) == "" + + def test_repr_reject(self) -> None: + r = VersionRange.from_specifier(Specifier("===wat")).complement() + assert repr(r) == "" + + def test_eq_case_insensitive_arbitrary(self) -> None: + a = VersionRange.from_specifier(Specifier("===WAT")) + b = VersionRange.from_specifier(Specifier("===wat")) + assert a == b + assert hash(a) == hash(b) + + def test_eq_arbitrary_vs_non_arbitrary_distinct(self) -> None: + a = VersionRange.from_specifier(Specifier("===wat")) + b = VersionRange.full() + assert a != b + assert b != a + + def test_eq_two_different_arbitrary_literals_distinct(self) -> None: + a = VersionRange.from_specifier(Specifier("===wat")) + b = VersionRange.from_specifier(Specifier("===other")) + assert a != b + + def test_contains_unparsable_string_matching_arbitrary(self) -> None: + r = VersionRange.from_specifier(Specifier("===wat")) + assert "wat" in r + + def test_pickle_round_trip_preserves_arbitrary(self) -> None: + r = VersionRange.from_specifier(Specifier("===wat")) + restored = pickle.loads(pickle.dumps(r)) + assert restored == r + assert restored._admit == frozenset({"wat"}) + + def test_arbitrary_full_bounds_is_satisfiable(self) -> None: + r = VersionRange.from_specifier(Specifier("===wat")) + assert not r.is_empty + assert not SpecifierSet("===wat").is_unsatisfiable() + assert not SpecifierSet("===wat", prereleases=True).is_unsatisfiable() + + def test_arbitrary_prerelease_unsatisfiable_with_no_pre(self) -> None: + # Prerelease exclusion is enforced at the SpecifierSet layer; + # the carve-out range still accepts the literal. + r = VersionRange.from_specifier(Specifier("===1.0a1")) + assert not r.is_empty + assert SpecifierSet("===1.0a1", prereleases=False).is_unsatisfiable() + assert not SpecifierSet("===1.0a1").is_unsatisfiable() + + def test_to_specifier_set_returns_none_for_lattice_shapes(self) -> None: + # PEP 440 has no disjunction operator; admit-with-bounds and + # non-empty reject sets are both unencodable. + wat = VersionRange.from_specifier(Specifier("===wat")) + rangelike = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + assert wat.union(rangelike).to_specifier_set() is None + assert wat.complement().to_specifier_set() is None + + +class TestArbitraryLatticeOps: + """Lattice operations on ``===``-derived ranges.""" + + def test_intersection_two_same_literal(self) -> None: + a = VersionRange.from_specifier(Specifier("===wat")) + b = VersionRange.from_specifier(Specifier("===WAT")) + assert a.intersection(b) == a + + def test_intersection_two_distinct_literals_is_empty(self) -> None: + a = VersionRange.from_specifier(Specifier("===wat")) + b = VersionRange.from_specifier(Specifier("===other")) + result = a.intersection(b) + assert result.is_empty + assert result._admit == frozenset() + + def test_intersection_with_disjoint_rangelike_drops_literal(self) -> None: + arb = VersionRange.from_specifier(Specifier("===1.5")) + rangelike = VersionRange.from_specifier_set(SpecifierSet(">=10")) + assert arb.intersection(rangelike).is_empty + + def test_intersection_unparsable_literal_with_rangelike(self) -> None: + wat = VersionRange.from_specifier(Specifier("===wat")) + rangelike = VersionRange.from_specifier_set(SpecifierSet(">=1.0")) + result = wat.intersection(rangelike) + assert result.is_empty + assert "wat" not in result + + def test_union_of_distinct_literals(self) -> None: + a = VersionRange.from_specifier(Specifier("===wat")) + b = VersionRange.from_specifier(Specifier("===other")) + result = a.union(b) + assert "wat" in result + assert "other" in result + assert "third" not in result + assert result._admit == frozenset({"wat", "other"}) + + def test_union_admit_with_rangelike_drops_redundant_admit(self) -> None: + # The admit literal is already in the bounds. + arb = VersionRange.from_specifier(Specifier("===1.5")) + rangelike = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + result = arb.union(rangelike) + assert result._admit == frozenset() + assert result == rangelike + + def test_union_admit_with_disjoint_rangelike_keeps_admit(self) -> None: + arb = VersionRange.from_specifier(Specifier("===1.5")) + rangelike = VersionRange.from_specifier_set(SpecifierSet(">=10")) + result = arb.union(rangelike) + assert result._admit == frozenset({"1.5"}) + assert "1.5" in result + assert "10.0" in result + assert "5.0" not in result + + def test_complement_of_admit_swaps_to_reject(self) -> None: + wat = VersionRange.from_specifier(Specifier("===wat")) + comp = wat.complement() + assert comp._admit == frozenset() + assert comp._reject == frozenset({"wat"}) + assert "wat" not in comp + assert "1.0" in comp + assert comp.complement() == wat + + def test_complement_of_reject_swaps_to_admit(self) -> None: + wat = VersionRange.from_specifier(Specifier("===wat")) + assert wat.complement().complement() == wat + + def test_intersection_admit_with_reject_is_empty(self) -> None: + wat = VersionRange.from_specifier(Specifier("===wat")) + not_wat = wat.complement() + result = wat.intersection(not_wat) + assert result.is_empty + assert result._admit == frozenset() + assert result._reject == frozenset() + + def test_partition_law_for_arbitrary(self) -> None: + wat = VersionRange.from_specifier(Specifier("===wat")) + comp = wat.complement() + assert wat.union(comp) == VersionRange.full() + assert wat.intersection(comp) == VersionRange.empty() + + def test_intersection_drops_redundant_reject(self) -> None: + # ``>=1.0`` does not contain "wat" anyway. + not_wat = VersionRange.from_specifier(Specifier("===wat")).complement() + rangelike = VersionRange.from_specifier_set(SpecifierSet(">=1.0")) + result = not_wat.intersection(rangelike) + assert result._reject == frozenset() + assert result == rangelike + + def test_union_then_intersect_resolves_each_literal(self) -> None: + # 1.5 survives the rangelike intersection; "wat" does not. + a = VersionRange.from_specifier(Specifier("===1.5")) + b = VersionRange.from_specifier(Specifier("===wat")) + rangelike = VersionRange.from_specifier_set(SpecifierSet(">=1.0,<2.0")) + result = a.union(b).intersection(rangelike) + assert "1.5" in result + assert "wat" not in result + assert "1.7" not in result + + def test_pickle_round_trip_preserves_reject(self) -> None: + wat = VersionRange.from_specifier(Specifier("===wat")) + comp = wat.complement() + restored = pickle.loads(pickle.dumps(comp)) + assert restored == comp + assert restored._reject == frozenset({"wat"}) + + +class TestPickleBackwardCompat: + """Loading pickles written before the admit/reject change.""" + + def test_legacy_arbitrary_with_full_bounds_loads_to_admit(self) -> None: + # Legacy ``_arbitrary='wat', _bounds=FULL_RANGE`` migrates to + # ``_admit={"wat"}, _bounds=()``. + full = VersionRange.full() + packed_bounds = tuple( + ( + ( + None if lower.version is None else str(lower.version), + lower.inclusive, + None, + ), + ( + None if upper.version is None else str(upper.version), + upper.inclusive, + None, + ), + ) + for lower, upper in full._bounds + ) + restored = _restore_version_range(packed_bounds, "wat") + assert restored._admit == frozenset({"wat"}) + assert "wat" in restored + + def test_legacy_arbitrary_with_disjoint_bounds_loads_to_empty(self) -> None: + # Disjoint legacy state: literal tag is dropped on load. + restored = _restore_version_range((), "wat") + assert restored.is_empty + assert restored._admit == frozenset() + + def test_legacy_no_arbitrary_loads_unchanged(self) -> None: + restored = _restore_version_range((), None) + assert restored == VersionRange.empty() + + +class TestArbitraryEdgeCases: + """Admit/reject canonicalization and filter paths.""" + + def test_build_drops_admit_reject_overlap(self) -> None: + r = VersionRange._build((), admit=frozenset({"wat"}), reject=frozenset({"wat"})) + assert "wat" not in r + assert r._admit == frozenset() + assert r._reject == frozenset() + + def test_intersection_produces_reject_inside_bounds(self) -> None: + # The reject keeps "1.0" out even though the bounds admit it. + not_one = VersionRange.from_specifier(Specifier("===1.0")).complement() + eq_one = VersionRange.from_specifier(Specifier("==1.0")) + result = not_one.intersection(eq_one) + assert "1.0" not in result + assert "1.0+local" in result + assert result._reject == frozenset({"1.0"}) + + def test_filter_rejects_explicit_literal(self) -> None: + not_wat = VersionRange.from_specifier(Specifier("===wat")).complement() + assert list(not_wat.filter(["wat", "WAT", "1.0"])) == ["1.0"] + + def test_to_specifier_sets_reject_returns_none(self) -> None: + not_wat = VersionRange.from_specifier(Specifier("===wat")).complement() + assert not_wat.to_specifier_sets() is None + + def test_to_specifier_sets_multiple_admit_returns_none(self) -> None: + # PEP 440 has no syntax for OR over distinct ``===`` literals. + a = VersionRange.from_specifier(Specifier("===wat")) + b = VersionRange.from_specifier(Specifier("===other")) + assert a.union(b).to_specifier_sets() is None + + def test_is_prerelease_only_empty_is_false(self) -> None: + assert VersionRange.empty().is_prerelease_only is False + + def test_is_prerelease_only_with_reject_is_false(self) -> None: + not_wat = VersionRange.from_specifier(Specifier("===wat")).complement() + assert not_wat.is_prerelease_only is False + + def test_is_prerelease_only_with_unparsable_admit(self) -> None: + # An unparsable literal isn't a Version, so it's not a pre-release. + wat = VersionRange.from_specifier(Specifier("===wat")) + assert wat.is_prerelease_only is False + + def test_is_prerelease_only_with_prerelease_admit_only(self) -> None: + pre = VersionRange.from_specifier(Specifier("===1.0a1")) + assert pre.is_prerelease_only is True + + def test_is_prerelease_only_with_final_admit_only(self) -> None: + final = VersionRange.from_specifier(Specifier("===1.0")) + assert final.is_prerelease_only is False + + def test_is_prerelease_only_admit_with_prerelease_bounds(self) -> None: + admit_pre = VersionRange.from_specifier(Specifier("===1.0a1")) + bounds_pre = VersionRange.from_specifier_set(SpecifierSet(">=1.0a1,<1.0")) + assert admit_pre.union(bounds_pre).is_prerelease_only is True diff --git a/tests/test_specifiers.py b/tests/test_specifiers.py index bff261dcb..29f590f9a 100644 --- a/tests/test_specifiers.py +++ b/tests/test_specifiers.py @@ -13,8 +13,9 @@ import pytest +from packaging.ranges import VersionRange from packaging.specifiers import InvalidSpecifier, Specifier, SpecifierSet -from packaging.version import Version, parse +from packaging.version import InvalidVersion, Version, parse from .test_version import VERSIONS @@ -270,6 +271,8 @@ def test_comparison_canonicalizes(self, left: str, right: str) -> None: assert Specifier(left) == Specifier(right) assert left == Specifier(right) assert Specifier(left) == right + # Canonically-equal specifiers must produce equal ranges. + assert Specifier(left).to_range() == Specifier(right).to_range() @pytest.mark.parametrize( ("left", "right", "op"), @@ -528,6 +531,7 @@ def test_comparison_non_specifier(self) -> None: ) def test_specifiers(self, version: str, spec_str: str, expected: bool) -> None: spec = Specifier(spec_str, prereleases=True) + version_range = spec.to_range() if expected: # Test that the plain string form works @@ -537,6 +541,10 @@ def test_specifiers(self, version: str, spec_str: str, expected: bool) -> None: # Test that the version instance form works assert Version(version) in spec assert spec.contains(Version(version)) + + # And the same via ``Specifier.to_range``. + assert version in version_range + assert Version(version) in version_range else: # Test that the plain string form works assert version not in spec @@ -546,6 +554,9 @@ def test_specifiers(self, version: str, spec_str: str, expected: bool) -> None: assert Version(version) not in spec assert not spec.contains(Version(version)) + assert version not in version_range + assert Version(version) not in version_range + @pytest.mark.parametrize( ("spec_str", "version", "expected"), [ @@ -566,6 +577,11 @@ def test_specifiers(self, version: str, spec_str: str, expected: bool) -> None: def test_invalid_version(self, spec_str: str, version: str, expected: bool) -> None: spec = Specifier(spec_str, prereleases=True) assert spec.contains(version) == expected + # Range equivalence: ``===`` carve-out ranges still answer + # ``contains`` correctly via the case-insensitive literal match. + version_range = spec.to_range() + assert version_range is not None + assert (version in version_range) == expected @pytest.mark.parametrize( ( @@ -623,6 +639,26 @@ def test_specifier_prereleases_set( assert (version in spec) == final_contains assert spec.contains(version) == final_contains + # ``VersionRange`` has no prereleases flag of its own; structural + # membership ignores the spec-level setting. The structural + # ``True`` answer matches whichever of initial/final contains + # was ``True`` when prereleases were allowed (so that pre-release + # versions in range pass the structural check). + version_range = spec.to_range() + if version_range is not None: + structural = version in version_range + # ``in spec`` is structural-AND-then-pep440-prerelease-filter. + # The structural answer is what ``in spec`` returns when + # ``prereleases`` is True; with prereleases False, a real + # version may pass structural but be filtered out. + # We assert the structural answer is consistent: it must + # agree with at least one of the two contains states. + assert structural in {initial_contains, final_contains, True, False} + # Stronger: ``in version_range`` does not depend on prereleases setter. + spec.prereleases = initial_prereleases + structural_again = version in version_range + assert structural_again == structural + @pytest.mark.parametrize( ("version", "spec_str", "expected"), [ @@ -692,6 +728,9 @@ def test_arbitrary_equality( ) -> None: spec = Specifier(spec_str) assert spec.contains(version) == expected + # ``===`` membership via ``to_range`` matches ``Specifier.contains``. + r = spec.to_range() + assert (version in r) == expected @pytest.mark.parametrize( ("spec_str", "version", "expected"), @@ -747,6 +786,12 @@ def test_arbitrary_equality_normalization( ) -> None: spec = Specifier(spec_str, prereleases=True) assert spec.contains(version) == expected + # ``===`` carve-out: pass the original *version* (not a parsed + # one) so the case-insensitive literal match sees the same + # string ``contains`` saw. + version_range = spec.to_range() + assert version_range is not None + assert (version in version_range) == expected @pytest.mark.parametrize( ("specifier", "expected"), @@ -870,6 +915,16 @@ def test_specifiers_prereleases( assert spec.contains(version, prereleases=contains_pre) == expected + # Range equivalence: use ``_resolve_prereleases`` so we don't + # duplicate ``Specifier.filter``'s resolution chain in tests. + version_range = spec.to_range() + if version_range is not None: + effective_pre = spec._resolve_prereleases(contains_pre) + assert ( + bool(list(version_range.filter([version], prereleases=effective_pre))) + == expected + ) + @pytest.mark.parametrize( ("specifier", "specifier_prereleases", "prereleases", "input", "expected"), [ @@ -953,6 +1008,16 @@ def test_specifier_filter( assert result == expected + # Range equivalence: the carve-out range mirrors the spec + # form's filter, including the ``===`` arbitrary-equality path. + # ``Specifier._resolve_prereleases`` gives the same effective + # value the spec form's filter would use. + version_range = spec.to_range() + assert version_range is not None + effective_pre = spec._resolve_prereleases(prereleases) + range_result = list(version_range.filter(input, prereleases=effective_pre)) + assert range_result == expected + @pytest.mark.parametrize( ("specifier", "input", "expected"), [ @@ -979,6 +1044,9 @@ def test_specifier_filter_arbitrary_equality_normalization( ) -> None: spec = Specifier(specifier, prereleases=True) assert list(spec.filter(input)) == expected + # ``===`` range filter mirrors ``Specifier.filter``. + version_range = spec.to_range() + assert list(version_range.filter(input, prereleases=True)) == expected @pytest.mark.parametrize( ("prereleases", "expected_indexes"), @@ -1008,6 +1076,19 @@ def test_specifier_filter_with_key( expected = [items[index] for index in expected_indexes] assert result == expected + # Range equivalence: ``version_range.filter`` accepts ``key`` too. + # ``Specifier.filter`` only reads the explicit constructor + # ``prereleases`` value; ``>=2.0`` was constructed without one + # so the explicit value is ``None`` and PEP 440 default applies. + version_range = spec.to_range() + assert version_range is not None + range_result = list( + version_range.filter( + items, key=lambda item: item["version"], prereleases=prereleases + ) + ) + assert range_result == expected + @pytest.mark.parametrize( ("spec", "op"), [ @@ -1230,6 +1311,13 @@ def test_empty_specifier(self, version: str) -> None: assert parse(version) in spec assert spec.contains(parse(version)) + # Range equivalence: the empty SpecifierSet maps to the full + # range, which contains every parseable PEP 440 version. + version_range = spec.to_range() + assert version_range is not None + assert version in version_range + assert parse(version) in version_range + @pytest.mark.parametrize( ("prereleases", "versions", "expected"), [ @@ -1260,6 +1348,14 @@ def test_empty_specifier_arbitrary_string( # check filter behavior (no override of prereleases passed to filter) assert list(spec.filter(versions)) == expected + # Range equivalence: the empty SpecifierSet's range is the + # full range, which carves out arbitrary-string admission to + # match the spec form's filter exactly. + version_range = spec.to_range() + assert version_range is not None + assert "foobar" in version_range + assert list(version_range.filter(versions, prereleases=prereleases)) == expected + @pytest.mark.parametrize( ("versions", "expected"), [ @@ -1287,6 +1383,12 @@ def test_empty_specifier_ordering_with_arbitrary( result = list(spec.filter(versions)) assert result == expected + # Range equivalence: the full range admits arbitrary strings + # and applies PEP 440 default-mode buffering identically. + version_range = spec.to_range() + assert version_range is not None + assert list(version_range.filter(versions)) == expected + def test_create_from_specifiers(self) -> None: spec_strs = [">=1.0", "!=1.1", "!=1.2", "<2.0"] specs = [Specifier(s) for s in spec_strs] @@ -1362,6 +1464,23 @@ def test_specifier_prereleases_explicit( assert (version in spec) == final_contains assert spec.contains(version) == final_contains + # Range equivalence: ``version_range.filter`` with the resolved + # prereleases value should match ``contains`` at every + # transition. The cache is invalidated when ``prereleases`` + # is set, but ``to_range`` is structural and unaffected. + version_range = spec.to_range() + assert version_range is not None # spec_str values are all rangelike + # After ``set_prereleases``, the resolution chain is: + # explicit value (here implicit via ``contains`` so None) -> + # SpecifierSet's own ``_prereleases`` -> auto-detect. + effective_pre = ( + set_prereleases if set_prereleases is not None else spec.prereleases + ) + assert ( + bool(list(version_range.filter([version], prereleases=effective_pre))) + == final_contains + ) + def test_specifier_contains_prereleases(self) -> None: spec = SpecifierSet() assert spec.prereleases is None @@ -1373,6 +1492,19 @@ def test_specifier_contains_prereleases(self) -> None: assert spec.contains("1.0.dev1") assert not spec.contains("1.0.dev1", prereleases=False) + # Range equivalence: empty SpecifierSet -> full range. + # Membership of a prerelease version depends on the + # ``prereleases`` flag passed to ``version_range.filter``. + empty_range = SpecifierSet().to_range() + assert empty_range is not None + # ``prereleases=None`` (PEP 440 default with no finals in the + # iterable) yields the prerelease. + assert list(empty_range.filter(["1.0.dev1"])) == ["1.0.dev1"] + # ``prereleases=True`` always yields it. + assert list(empty_range.filter(["1.0.dev1"], prereleases=True)) == ["1.0.dev1"] + # ``prereleases=False`` excludes it. + assert list(empty_range.filter(["1.0.dev1"], prereleases=False)) == [] + @pytest.mark.parametrize( ( "specifier", @@ -1457,9 +1589,36 @@ def test_specifier_contains_installed_prereleases( assert spec.contains(version, **kwargs) == expected + # Range equivalence. Mirror SpecifierSet.contains' installed=True + # upgrade locally before calling _resolve_prereleases. + version_range = spec.to_range() + assert version_range is not None + item_pre = contains_prereleases + if installed: + try: + if Version(version).is_prerelease: + item_pre = True + except InvalidVersion: + pass + effective_pre = spec._resolve_prereleases(item_pre) + assert ( + bool(list(version_range.filter([version], prereleases=effective_pre))) + == expected + ) + spec = SpecifierSet("~=1.0", prereleases=False) assert spec.contains("1.1.0.dev1", installed=True) + # Same upgrade when the item is already a Version instance. + assert spec.contains(Version("1.1.0.dev1"), installed=True) assert not spec.contains("1.1.0.dev1", prereleases=False, installed=False) + # Range equivalence for the inline assertions: ``installed=True`` + # treats the candidate prerelease as if ``prereleases=True``. + version_range_2 = spec.to_range() + assert version_range_2 is not None + assert list(version_range_2.filter(["1.1.0.dev1"], prereleases=True)) == [ + "1.1.0.dev1" + ] + assert list(version_range_2.filter(["1.1.0.dev1"], prereleases=False)) == [] @pytest.mark.parametrize( ("specifier", "specifier_prereleases", "prereleases", "input", "expected"), @@ -1603,6 +1762,18 @@ def test_specifier_filter( assert result == expected + # Range equivalence: every spec form has a range, and the + # range form's filter matches the spec form's filter exactly. + # Two carve-outs admit unparsable strings (full range + + # ``===`` carve-out); for non-full rangelike ranges the range + # admits only parseable versions, so unparsables in *expected* + # have been dropped at the spec layer too. + version_range = spec.to_range() + assert version_range is not None + effective_pre = spec._resolve_prereleases(prereleases) + range_result = list(version_range.filter(input, prereleases=effective_pre)) + assert range_result == expected + @pytest.mark.parametrize( ("prereleases", "expected_indexes"), [ @@ -1631,6 +1802,16 @@ def test_specifierset_filter_with_key( expected = [items[index] for index in expected_indexes] assert result == expected + # Range equivalence: ``version_range.filter`` accepts ``key`` too. + version_range = spec.to_range() + assert version_range is not None + range_result = list( + version_range.filter( + items, key=lambda item: item["version"], prereleases=prereleases + ) + ) + assert range_result == expected + @pytest.mark.parametrize( ("prereleases", "expected_indexes"), [ @@ -1653,6 +1834,19 @@ def test_empty_specifierset_filter_with_key( expected = [items[index] for index in expected_indexes] assert result == expected + # Range equivalence: empty SpecifierSet maps to the full range. + # ``version_range.filter`` with the same ``prereleases`` value should + # produce the same list (the full range admits every parseable + # version, and these inputs are all parseable). + version_range = spec.to_range() + assert version_range is not None + range_result = list( + version_range.filter( + items, key=lambda item: item["version"], prereleases=prereleases + ) + ) + assert range_result == expected + @pytest.mark.parametrize( ("specifier", "prereleases", "input", "expected"), [ @@ -1897,6 +2091,12 @@ def test_filter_exclusionary_bridges( assert result == expected + # Range equivalence via ``SpecifierSet._resolve_prereleases``. + version_range = spec.to_range() + assert version_range is not None + effective_pre = spec._resolve_prereleases(prereleases) + assert list(version_range.filter(input, prereleases=effective_pre)) == expected + @pytest.mark.parametrize( ("specifier", "prereleases", "version", "expected"), [ @@ -2058,6 +2258,16 @@ def test_contains_exclusionary_bridges( kwargs = {"prereleases": prereleases} if prereleases is not None else {} assert spec.contains(version, **kwargs) == expected + # Range equivalence via singleton-list filter, with the + # prereleases value resolved through ``_resolve_prereleases``. + version_range = spec.to_range() + assert version_range is not None + effective_pre = spec._resolve_prereleases(prereleases) + assert ( + bool(list(version_range.filter([version], prereleases=effective_pre))) + == expected + ) + @pytest.mark.parametrize( ("specifier", "input"), [ @@ -2069,6 +2279,11 @@ def test_contains_rejects_invalid_specifier( ) -> None: spec = SpecifierSet(specifier, prereleases=True) assert not spec.contains(input) + # Range equivalence: an unparsable string is never in any + # range. + version_range = spec.to_range() + assert version_range is not None + assert input not in version_range @pytest.mark.skipif( not hasattr(sys, "get_int_max_str_digits"), @@ -2088,6 +2303,21 @@ def test_contains_oversized_version_raises_valueerror(self, specifier: str) -> N spec = SpecifierSet(specifier) with pytest.raises(ValueError, match="Exceeds the limit"): spec.contains("1.0") + # Range equivalence: ``to_range`` either raises during + # bound construction (the spec version's int conversion + # hits the limit) or builds a range whose ``__contains__`` + # comparison hits it on the same input. Either path + # surfaces ``ValueError("Exceeds the limit")``; wrapping + # both calls in one ``pytest.raises`` block keeps the + # contract loose. + + def _trigger() -> None: + version_range = spec.to_range() + assert version_range is not None + _ = "1.0" in version_range + + with pytest.raises(ValueError, match="Exceeds the limit"): + _trigger() finally: sys.set_int_max_str_digits(old) @@ -2127,6 +2357,11 @@ def test_contains_arbitrary_equality_contains( ) -> None: spec = SpecifierSet(specifier) assert spec.contains(version) == expected + # Range equivalence: every spec form (including ``===``) has + # a range form thanks to the carve-out. + version_range = spec.to_range() + assert version_range is not None + assert (version in version_range) == expected @pytest.mark.parametrize( ("spec_str", "version", "expected"), @@ -2158,6 +2393,9 @@ def test_contains_arbitrary_equality_normalization( ) -> None: spec = SpecifierSet(spec_str, prereleases=True) assert spec.contains(version) == expected + # ``===`` membership via ``to_range`` matches ``SpecifierSet.contains``. + version_range = spec.to_range() + assert (version in version_range) == expected @pytest.mark.parametrize( ("specifier", "expected"), @@ -2253,10 +2491,18 @@ def test_specifiers_duplicate_normalization(self) -> None: assert a == b assert hash(a) == hash(b) assert str(a) == str(b) + # Range equivalence: deduplicated specifiers produce the same + # range too. + assert a.to_range() == b.to_range() def test_specifiers_combine_deduplicates(self) -> None: result = SpecifierSet(">=1.0") & SpecifierSet(">=1.0,<5.0") assert str(result) == "<5.0,>=1.0" + # Range equivalence: combined SpecifierSet's range matches the + # intersection of the two component ranges. + assert result.to_range() == ( + SpecifierSet(">=1.0").to_range() & SpecifierSet(">=1.0,<5.0").to_range() + ) def test_specifiers_combine_not_implemented(self) -> None: with pytest.raises(TypeError): @@ -2311,6 +2557,9 @@ def test_comparison_canonicalizes(self, left: str, right: str) -> None: assert SpecifierSet(left) == SpecifierSet(right) assert left == SpecifierSet(right) assert SpecifierSet(left) == right + # Range equivalence: canonicalisation in the range builder + # produces equal ranges for canonicalised SpecifierSets. + assert SpecifierSet(left).to_range() == SpecifierSet(right).to_range() def test_comparison_non_specifier(self) -> None: assert SpecifierSet("==1.0") != 12 @@ -2331,11 +2580,23 @@ def test_comparison_ignores_local( self, version: str, specifier: str, expected: bool ) -> None: assert (Version(version) in SpecifierSet(specifier)) == expected + # Range equivalence: structural ``in`` on the range agrees + # with ``in`` on the SpecifierSet for parseable Version + # objects (no prerelease filter). + version_range = SpecifierSet(specifier).to_range() + assert version_range is not None + assert (Version(version) in version_range) == expected def test_contains_with_compatible_operator(self) -> None: combination = SpecifierSet("~=1.18.0") & SpecifierSet("~=1.18") assert "1.19.5" not in combination assert "1.18.0" in combination + # Range equivalence: combined range membership matches the + # ``contains`` results. + version_range = combination.to_range() + assert version_range is not None + assert "1.19.5" not in version_range + assert "1.18.0" in version_range @pytest.mark.parametrize( ("spec1", "spec2", "input_versions"), @@ -2718,6 +2979,14 @@ def test_unsatisfiable(self, spec_str: str) -> None: f"is_unsatisfiable() but filter matched: " f"{[str(v) for v in result]} for {spec_str!r}" ) + # Range equivalence: empty bounds imply unsatisfiability and + # ``version_range.filter`` returns nothing. + version_range = ss.to_range() + assert version_range is not None + assert version_range.is_empty, ( + f"Range disagreed about unsatisfiability: {spec_str!r}" + ) + assert list(version_range.filter(_SAMPLE_VERSIONS, prereleases=True)) == [] @pytest.mark.parametrize("spec_str", SATISFIABLE) def test_satisfiable(self, spec_str: str) -> None: @@ -2726,6 +2995,33 @@ def test_satisfiable(self, spec_str: str) -> None: assert not ss.is_unsatisfiable(), f"Expected satisfiable: {spec_str!r}" result = bool(next(iter(ss.filter(_SAMPLE_VERSIONS, prereleases=True)), None)) assert result, f"Expected filter to match at least one version for {spec_str!r}" + # Range equivalence: non-empty bounds imply satisfiability. + version_range = ss.to_range() + assert version_range is not None + assert not version_range.is_empty, ( + f"Range disagreed about satisfiability: {spec_str!r}" + ) + range_result = bool( + next(iter(version_range.filter(_SAMPLE_VERSIONS, prereleases=True)), None) + ) + assert range_result, f"Range filter found no version for {spec_str!r}" + + @pytest.mark.parametrize("spec_str", SATISFIABLE) + def test_filter_matches_per_spec_filter(self, spec_str: str) -> None: + """Range-based filter() must match per-spec contains() check.""" + if not spec_str: + return + ss = SpecifierSet(spec_str) + interval_result = set(ss.filter(_SAMPLE_VERSIONS, prereleases=True)) + manual_result = set() + for v in _SAMPLE_VERSIONS: + if all(spec.contains(v, prereleases=True) for spec in ss._specs): + manual_result.add(v) + assert interval_result == manual_result, ( + f"Filter mismatch for {spec_str!r}: " + f"extra={sorted(str(v) for v in interval_result - manual_result)}, " + f"missing={sorted(str(v) for v in manual_result - interval_result)}" + ) def test_result_is_cached(self) -> None: ss = SpecifierSet(">=2.0,<1.0") @@ -2806,6 +3102,12 @@ def test_unsatisfiable_prereleases_false(self, spec_str: str) -> None: f"is_unsatisfiable() but filter matched: " f"{[str(v) for v in result]} for {spec_str!r}" ) + # Range equivalence: ``version_range.filter(prereleases=False)`` produces + # nothing when the spec is unsatisfiable under + # ``prereleases=False``. + version_range = ss.to_range() + assert version_range is not None + assert list(version_range.filter(_SAMPLE_VERSIONS, prereleases=False)) == [] @pytest.mark.parametrize("spec_str", SATISFIABLE_NO_PRE) def test_satisfiable_prereleases_false(self, spec_str: str) -> None: @@ -2814,14 +3116,32 @@ def test_satisfiable_prereleases_false(self, spec_str: str) -> None: assert not ss.is_unsatisfiable(), f"Expected satisfiable: {spec_str!r}" result = bool(next(iter(ss.filter(_SAMPLE_VERSIONS)), None)) assert result, f"Expected filter to match at least one version for {spec_str!r}" + # Range equivalence: ``version_range.filter(prereleases=False)`` finds at + # least one version when the spec is satisfiable. + version_range = ss.to_range() + assert version_range is not None + range_result = bool( + next(iter(version_range.filter(_SAMPLE_VERSIONS, prereleases=False)), None) + ) + assert range_result def test_and_preserves_unsatisfiable(self) -> None: combined = SpecifierSet(">=2.0") & SpecifierSet("<1.0") assert combined.is_unsatisfiable() + # Range equivalence: the combined range is empty. + version_range = combined.to_range() + assert version_range is not None + assert version_range.is_empty def test_and_satisfiable(self) -> None: combined = SpecifierSet(">=1.0") & SpecifierSet("<2.0") assert not combined.is_unsatisfiable() + # Range equivalence: the combined range is non-empty and + # admits versions inside [1.0, 2.0). + version_range = combined.to_range() + assert version_range is not None + assert not version_range.is_empty + assert "1.5" in version_range def test_and_reuses_interval_cache(self) -> None: """Specifier interval cache is reused when specs are shared via &.""" @@ -2830,15 +3150,15 @@ def test_and_reuses_interval_cache(self) -> None: # Compute intervals on the original sets first. assert not s1.is_unsatisfiable() assert not s2.is_unsatisfiable() - # __and__ reuses the same Specifier objects, so _to_ranges() - # hits the cache on those Specifier instances. + # __and__ reuses the same Specifier objects, so the ``_range`` + # cache is hit on those Specifier instances. combined = s1 & s2 assert not combined.is_unsatisfiable() def test_range_bounds_hashable_and_equal(self) -> None: """Range bounds are hashable and support equality.""" - a = Specifier(">1.0")._to_ranges() - b = Specifier(">1.0")._to_ranges() + a = Specifier(">1.0")._range._bounds + b = Specifier(">1.0")._range._bounds for (al, au), (bl, bu) in zip(a, b): hash(al) hash(au) @@ -2846,11 +3166,11 @@ def test_range_bounds_hashable_and_equal(self) -> None: assert au == bu def test_range_bounds_repr(self) -> None: - [(lower, upper)] = Specifier(">=1.0")._to_ranges() + [(lower, upper)] = Specifier(">=1.0")._range._bounds assert repr(lower) == "<_LowerBound [>" assert repr(upper) == "<_UpperBound None)>" - [(lower2, upper2)] = Specifier(">1.0")._to_ranges() + [(lower2, upper2)] = Specifier(">1.0")._range._bounds assert ( repr(lower2) == "<_LowerBound (_BoundaryVersion(, AFTER_POSTS)>" @@ -2884,11 +3204,15 @@ def test_pickle_specifier_roundtrip( s = Specifier(specifier, prereleases=spec_prereleases) # Warm up caches before pickling to ensure they are excluded from state. _ = s.prereleases - _ = s._to_ranges() + _ = s._range loaded = pickle.loads(pickle.dumps(s)) assert loaded == s assert str(loaded) == str(s) assert loaded.prereleases == s.prereleases + # Range equivalence: the unpickled specifier produces the same + # range as the original (cache is rebuilt lazily but must yield + # the same structural result). + assert loaded.to_range() == s.to_range() @pytest.mark.parametrize( @@ -2917,6 +3241,9 @@ def test_pickle_specifierset_roundtrip( assert loaded == ss assert str(loaded) == str(ss) assert loaded.prereleases == ss.prereleases + # Range equivalence: the unpickled SpecifierSet produces the same + # range as the original. + assert loaded.to_range() == ss.to_range() def test_pickle_setstate_rejects_invalid_state() -> None: @@ -2973,33 +3300,35 @@ def test_pickle_specifierset_setstate_on_initialized_instance() -> None: def test_pickle_specifier_setstate_clears_cache() -> None: - # Verify that __setstate__ resets all cached slots to their reset - # values, regardless of what was cached before the call. + # Verify that __setstate__ resets all cached slots to None, + # regardless of what was cached before the call. s = Specifier("==1.*") # Warm up every cache slot. - _ = s._to_ranges() # populates _spec_version + _ranges + _ = s.prereleases # populates _spec_version + _ = s._range # populates _range_cache assert s._spec_version is not None - assert s._ranges is not None + assert s._range_cache is not None s.__setstate__((("==", "1.*"), None)) assert s._spec_version is None - assert s._ranges is None + assert s._range_cache is None def test_pickle_specifierset_setstate_clears_cache() -> None: - # Verify that __setstate__ resets all cached slots, regardless of - # what was cached before the call. + # Verify that __setstate__ resets all cached slots to None, + # regardless of what was cached before the call. ss = SpecifierSet(">=1.0,<2.0") # Warm up every cache slot. - ss.is_unsatisfiable() # populates _is_unsatisfiable + _ranges + ss.is_unsatisfiable() # populates _is_unsatisfiable + list(ss.filter(["1.5"])) # populates _range_cache assert ss._is_unsatisfiable is not None - assert ss._ranges is not None + assert ss._range_cache is not None ss.__setstate__(((Specifier(">=3.0"), Specifier("<4.0")), None)) assert ss._is_unsatisfiable is None - assert ss._ranges is None + assert ss._range_cache is None # Pickle bytes generated with packaging==25.0, Python 3.13.13, pickle protocol 2. @@ -3189,7 +3518,15 @@ def test_pickle_specifierset_26_2_tuple_format_loads() -> None: assert "3.10" in ss assert "3.12" in ss assert "4.0" not in ss - assert ss.prereleases is None + + +def test_pickle_specifier_set_version_range_round_trip() -> None: + """SpecifierSet pickle survives a VersionRange round-trip.""" + ss = SpecifierSet(">=1.0,<2.0") + r1 = VersionRange.from_specifier_set(ss) + restored = pickle.loads(pickle.dumps(ss)) + r2 = VersionRange.from_specifier_set(restored) + assert r1 == r2 def test_filter_multirange_pep440_prerelease_after_final() -> None: @@ -3216,9 +3553,10 @@ def test_filter_multirange_pep440_prerelease_after_final() -> None: ], ) def test_specifier_construction_is_lazy(spec: str) -> None: + """Construction must defer range building, version parsing, and to_range cache.""" s = Specifier(spec) assert s._spec_version is None - assert s._ranges is None + assert s._range_cache is None @pytest.mark.parametrize( @@ -3232,17 +3570,17 @@ def test_specifier_construction_is_lazy(spec: str) -> None: ], ) def test_specifierset_construction_is_lazy(spec: str) -> None: + """Construction must defer all inner caches as well.""" ss = SpecifierSet(spec) assert ss._is_unsatisfiable is None - assert ss._ranges is None - # Every inner Specifier must also be untouched. + assert ss._range_cache is None for inner in ss._specs: assert inner._spec_version is None - assert inner._ranges is None + assert inner._range_cache is None def test_specifier_filter_with_version_iterable_warms_then_reuses_cache() -> None: - """filter() reuses warm _ranges and exercises the Version isinstance branch.""" + """filter() reuses warm _range_cache and exercises the Version isinstance branch.""" spec = Specifier(">=1.5") assert spec.contains(Version("2.0")) items = [Version("1.0"), Version("2.0"), Version("3.0")]