From f63fa0223661a3822ef57a2ece2f591b6edfd8d2 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Sun, 12 Apr 2026 11:53:35 -0400 Subject: [PATCH 1/7] Reimplement specifiers using version ranges --- src/packaging/specifiers.py | 624 ++++++++++-------------------------- tests/test_specifiers.py | 50 ++- tests/test_version.py | 8 + 3 files changed, 210 insertions(+), 472 deletions(-) diff --git a/src/packaging/specifiers.py b/src/packaging/specifiers.py index ad8d53aab..cb8095fe1 100644 --- a/src/packaging/specifiers.py +++ b/src/packaging/specifiers.py @@ -13,7 +13,6 @@ import abc import enum import functools -import itertools import re import sys import typing @@ -62,7 +61,6 @@ def _validate_pre(pre: object, /) -> TypeGuard[bool | None]: T = TypeVar("T") UnparsedVersion = Union[Version, str] UnparsedVersionVar = TypeVar("UnparsedVersionVar", bound=UnparsedVersion) -CallableOperator = Callable[[Version, str], bool] # The smallest possible PEP 440 version. No valid version is less than this. _MIN_VERSION: Final[Version] = Version("0.dev0") @@ -297,28 +295,6 @@ def _coerce_version(version: UnparsedVersion) -> Version | None: return version -def _public_version(version: Version) -> Version: - if version.local is None: - return version - return version.__replace__(local=None) - - -def _post_base(version: Version) -> Version: - """The version that *version* is a post-release of. - - 1.0.post1 -> 1.0, 1.0a1.post0 -> 1.0a1, 1.0.post0.dev1 -> 1.0. - """ - return version.__replace__(post=None, dev=None, local=None) - - -def _earliest_prerelease(version: Version) -> Version: - """Earliest pre-release of *version*. - - 1.2 -> 1.2.dev0, 1.2.post1 -> 1.2.post1.dev0. - """ - return version.__replace__(dev=0, local=None) - - def _nearest_non_prerelease( v: _VersionOrBoundary, ) -> Version | None: @@ -340,6 +316,86 @@ def _nearest_non_prerelease( return v.__replace__(pre=None, dev=None, local=None) +def _filter_by_ranges( + ranges: Sequence[_VersionRange], + iterable: Iterable[Any], + key: Callable[[Any], UnparsedVersion] | None, + prereleases: bool, +) -> Iterator[Any]: + """Filter versions against precomputed version ranges. + + Local version segments are preserved on candidates; the range bounds + use :class:`_BoundaryVersion` to handle local-version semantics. + + Used by both :class:`Specifier` and :class:`SpecifierSet`. + Prerelease buffering (PEP 440 default) is NOT handled here; + callers wrap the result with :func:`_pep440_filter_prereleases` + when needed. + """ + exclude_prereleases = prereleases is False + + 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 + # Check if version falls within any range. Ranges are sorted and + # non-overlapping, so at most one can match. + for lower, upper in ranges: + if lower.version is not None and ( + parsed < lower.version + or (parsed == lower.version and not lower.inclusive) + ): + break + if ( + upper.version is None + or parsed < upper.version + or (parsed == upper.version and upper.inclusive) + ): + yield item + break + + +def _pep440_filter_prereleases( + iterable: Iterable[Any], key: Callable[[Any], UnparsedVersion] | None +) -> Iterator[Any]: + """Filter per PEP 440: exclude prereleases unless no finals exist.""" + 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 + + class InvalidSpecifier(ValueError): """ Raised when attempting to create a :class:`Specifier` with a specifier @@ -459,7 +515,6 @@ class Specifier(BaseSpecifier): "_ranges", "_spec", "_spec_version", - "_wildcard_split", ) _specifier_regex_str = r""" @@ -558,17 +613,6 @@ class Specifier(BaseSpecifier): r"\s*" + _specifier_regex_str + r"\s*", re.VERBOSE | re.IGNORECASE ) - _operators: Final = { - "~=": "compatible", - "==": "equal", - "!=": "not_equal", - "<=": "less_than_equal", - ">=": "greater_than_equal", - "<": "less_than", - ">": "greater_than", - "===": "arbitrary", - } - def __init__(self, spec: str = "", prereleases: bool | None = None) -> None: """Initialize a Specifier instance. @@ -601,10 +645,7 @@ def __init__(self, spec: str = "", prereleases: bool | None = None) -> None: # Specifier version cache self._spec_version: tuple[str, Version] | None = None - # Populated on first wildcard (==X.*) comparison - self._wildcard_split: tuple[list[str], int] | None = None - - # Version range cache (populated by _to_ranges) + # Version range cache. self._ranges: Sequence[_VersionRange] | None = None def _get_spec_version(self, version: str) -> Version | None: @@ -892,159 +933,6 @@ def __eq__(self, other: object) -> bool: return self._canonical_spec == other._canonical_spec - def _get_operator(self, op: str) -> CallableOperator: - operator_callable: CallableOperator = getattr( - self, f"_compare_{self._operators[op]}" - ) - return operator_callable - - def _compare_compatible(self, prospective: Version, spec: str) -> bool: - # Compatible releases have an equivalent combination of >= and ==. That - # is that ~=2.2 is equivalent to >=2.2,==2.*. This allows us to - # implement this in terms of the other specifiers instead of - # implementing it ourselves. The only thing we need to do is construct - # the other specifiers. - - # We want everything but the last item in the version, but we want to - # ignore suffix segments. - prefix = _version_join( - list(itertools.takewhile(_is_not_suffix, _version_split(spec)))[:-1] - ) - - # Add the prefix notation to the end of our string - prefix += ".*" - - return (self._compare_greater_than_equal(prospective, spec)) and ( - self._compare_equal(prospective, prefix) - ) - - def _get_wildcard_split(self, spec: str) -> tuple[list[str], int]: - """Cached split of a wildcard spec into components and numeric length. - - >>> Specifier("==1.*")._get_wildcard_split("1.*") - (['0', '1'], 2) - >>> Specifier("==3.10.*")._get_wildcard_split("3.10.*") - (['0', '3', '10'], 3) - """ - wildcard_split = self._wildcard_split - if wildcard_split is None: - normalized = canonicalize_version(spec[:-2], strip_trailing_zero=False) - split_spec = _version_split(normalized) - wildcard_split = (split_spec, _numeric_prefix_len(split_spec)) - self._wildcard_split = wildcard_split - return wildcard_split - - def _compare_equal(self, prospective: Version, spec: str) -> bool: - # We need special logic to handle prefix matching - if spec.endswith(".*"): - split_spec, spec_numeric_len = self._get_wildcard_split(spec) - - # In the case of prefix matching we want to ignore local segment. - normalized_prospective = canonicalize_version( - _public_version(prospective), strip_trailing_zero=False - ) - # Split the prospective version out by bangs and dots, and pretend - # that there is an implicit dot in between a release segment and - # a pre-release segment. - split_prospective = _version_split(normalized_prospective) - - # 0-pad the prospective version before shortening it to get the correct - # shortened version. - padded_prospective = _left_pad(split_prospective, spec_numeric_len) - - # Shorten the prospective version to be the same length as the spec - # so that we can determine if the specifier is a prefix of the - # prospective version or not. - shortened_prospective = padded_prospective[: len(split_spec)] - - return shortened_prospective == split_spec - else: - # Convert our spec string into a Version - spec_version = self._require_spec_version(spec) - - # If the specifier does not have a local segment, then we want to - # act as if the prospective version also does not have a local - # segment. - if not spec_version.local: - prospective = _public_version(prospective) - - return prospective == spec_version - - def _compare_not_equal(self, prospective: Version, spec: str) -> bool: - return not self._compare_equal(prospective, spec) - - def _compare_less_than_equal(self, prospective: Version, spec: str) -> bool: - # NB: Local version identifiers are NOT permitted in the version - # specifier, so local version labels can be universally removed from - # the prospective version. - return _public_version(prospective) <= self._require_spec_version(spec) - - def _compare_greater_than_equal(self, prospective: Version, spec: str) -> bool: - # NB: Local version identifiers are NOT permitted in the version - # specifier, so local version labels can be universally removed from - # the prospective version. - return _public_version(prospective) >= self._require_spec_version(spec) - - def _compare_less_than(self, prospective: Version, spec_str: str) -> bool: - # Convert our spec to a Version instance, since we'll want to work with - # it as a version. - spec = self._require_spec_version(spec_str) - - # Check to see if the prospective version is less than the spec - # version. If it's not we can short circuit and just return False now - # instead of doing extra unneeded work. - if not prospective < spec: - return False - - # The spec says: "= _earliest_prerelease(spec) - ): - return False - - # If we've gotten to here, it means that prospective version is both - # less than the spec version *and* it's not a pre-release of the same - # version in the spec. - return True - - def _compare_greater_than(self, prospective: Version, spec_str: str) -> bool: - # Convert our spec to a Version instance, since we'll want to work with - # it as a version. - spec = self._require_spec_version(spec_str) - - # Check to see if the prospective version is greater than the spec - # version. If it's not we can short circuit and just return False now - # instead of doing extra unneeded work. - if not prospective > spec: - return False - - # The spec says: ">V MUST NOT allow a post-release of the specified - # version unless the specified version is itself a post-release." - if ( - not spec.is_postrelease - and prospective.is_postrelease - and _post_base(prospective) == spec - ): - return False - - # Per the spec: ">V MUST NOT match a local version of the specified - # version". A "local version of V" is any version whose public part - # equals V. So >1.0a1 must not match 1.0a1+local, but must still - # match 1.0a2+local. - if prospective.local is not None and _public_version(prospective) == spec: - return False - - # If we've gotten to here, it means that prospective version is both - # greater than the spec version *and* it's not a pre-release of the - # same version in the spec. - return True - - def _compare_arbitrary(self, prospective: Version | str, spec: str) -> bool: - return str(prospective).lower() == str(spec).lower() - def __contains__(self, item: str | Version) -> bool: """Return whether or not the item is contained in this specifier. @@ -1144,190 +1032,47 @@ def filter( ... key=lambda x: x["ver"])) [{'ver': '1.3'}] """ - prereleases_versions = [] - found_non_prereleases = False - - # Determine if to include prereleases by default - include_prereleases = ( - prereleases if prereleases is not None else self.prereleases - ) - - # Get the matching operator - operator_callable = self._get_operator(self.operator) - - # Filter versions - for version in iterable: - parsed_version = _coerce_version(version if key is None else key(version)) - match = False - if parsed_version is None: - # === operator can match arbitrary (non-version) strings - if self.operator == "===" and self._compare_arbitrary( - version, self.version - ): - yield version - elif self.operator == "===": - match = self._compare_arbitrary( - version if key is None else key(version), self.version - ) - else: - match = operator_callable(parsed_version, self.version) - - if match and parsed_version is not None: - # If it's not a prerelease or prereleases are allowed, yield it directly - if not parsed_version.is_prerelease or include_prereleases: - found_non_prereleases = True - yield version - # Otherwise collect prereleases for potential later use - elif prereleases is None and self._prereleases is not False: - prereleases_versions.append(version) - - # If no non-prereleases were found and prereleases weren't - # explicitly forbidden, yield the collected prereleases - if ( - not found_non_prereleases - and prereleases is None - and self._prereleases is not False - ): - yield from prereleases_versions - - -_prefix_regex = re.compile(r"([0-9]+)((?:a|b|c|rc)[0-9]+)") - - -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: + if self.operator == "===": + # === uses arbitrary string matching, not version comparison. + # Still respect prereleases when the string parses to a version. + if prereleases is None and self._prereleases is not None: + prereleases = self._prereleases + exclude_pre = prereleases is False + spec_str = self.version + for item in iterable: + raw = item if key is None else key(item) + if str(raw).lower() != spec_str.lower(): + continue + if exclude_pre: + parsed = _coerce_version(raw) + if parsed is not None and parsed.is_prerelease: + continue 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 - - -def _version_split(version: str) -> list[str]: - """Split version into components. - - The split components are intended for version comparison. The logic does - not attempt to retain the original version string, so joining the - components back with :func:`_version_join` may not produce the original - version string. - """ - result: list[str] = [] - - epoch, _, rest = version.rpartition("!") - result.append(epoch or "0") + return + + # Determine concrete prerelease behavior, or leave as None + # for PEP 440 default (include prereleases only if no finals exist). + if prereleases is None: + if self._prereleases is not None: + prereleases = self._prereleases + elif self.prereleases: + prereleases = True + + # When prereleases is still None, pass True to include all versions + # and let _pep440_filter_prereleases handle the buffering. + resolve_pre = True if prereleases is None else prereleases + + filtered = _filter_by_ranges( + self._to_ranges(), + iterable, + key, + prereleases=resolve_pre, + ) - for item in rest.split("."): - match = _prefix_regex.fullmatch(item) - if match: - result.extend(match.groups()) + if prereleases is not None: + yield from filtered else: - result.append(item) - return result - - -def _version_join(components: list[str]) -> str: - """Join split version components into a version string. - - This function assumes the input came from :func:`_version_split`, where the - first component must be the epoch (either empty or numeric), and all other - components numeric. - """ - epoch, *rest = components - return f"{epoch}!{'.'.join(rest)}" - - -def _is_not_suffix(segment: str) -> bool: - return not any( - segment.startswith(prefix) for prefix in ("dev", "a", "b", "rc", "post") - ) - - -def _numeric_prefix_len(split: list[str]) -> int: - """Count leading numeric components in a :func:`_version_split` result. - - >>> _numeric_prefix_len(["0", "1", "2", "a1"]) - 3 - """ - count = 0 - for segment in split: - if not segment.isdigit(): - break - count += 1 - return count - - -def _left_pad(split: list[str], target_numeric_len: int) -> list[str]: - """Pad a :func:`_version_split` result with ``"0"`` segments to reach - ``target_numeric_len`` numeric components. Suffix segments are preserved. - - >>> _left_pad(["0", "1", "a1"], 4) - ['0', '1', '0', '0', 'a1'] - """ - numeric_len = _numeric_prefix_len(split) - pad_needed = target_numeric_len - numeric_len - if pad_needed <= 0: - return split - return [*split[:numeric_len], *(["0"] * pad_needed), *split[numeric_len:]] - - -def _operator_cost(op_entry: tuple[CallableOperator, str, str]) -> int: - """Sort key for Cost Based Ordering of specifier operators in _filter_versions. - - Operators run sequentially on a shrinking candidate set, so operators that - reject the most versions should run first to minimize work for later ones. - - Tier 0: Exact equality (==, ===), likely to narrow candidates to one version - Tier 1: Range checks (>=, <=, >, <), cheap and usually reject a large portion - Tier 2: Wildcard equality (==.*) and compatible release (~=), more expensive - Tier 3: Exact !=, cheap but rarely rejects - Tier 4: Wildcard !=.*, expensive and rarely rejects - """ - _, ver, op = op_entry - if op == "==": - return 0 if not ver.endswith(".*") else 2 - if op in (">=", "<=", ">", "<"): - return 1 - if op == "~=": - return 2 - if op == "!=": - return 3 if not ver.endswith(".*") else 4 - if op == "===": - return 0 - - raise ValueError(f"Unknown operator: {op!r}") # pragma: no cover + yield from _pep440_filter_prereleases(filtered, key) class SpecifierSet(BaseSpecifier): @@ -1354,7 +1099,7 @@ class SpecifierSet(BaseSpecifier): "_has_arbitrary", "_is_unsatisfiable", "_prereleases", - "_resolved_ops", + "_ranges", "_specs", ) @@ -1395,21 +1140,20 @@ def __init__( self._has_arbitrary = any("===" in str(s) for s in self._specs) self._canonicalized = len(self._specs) <= 1 - self._resolved_ops: list[tuple[CallableOperator, str, str]] | None = None + 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._prereleases = prereleases - self._is_unsatisfiable: bool | None = None - def _canonical_specs(self) -> tuple[Specifier, ...]: """Deduplicate, sort, and cache specs for order-sensitive operations.""" if not self._canonicalized: self._specs = tuple(dict.fromkeys(sorted(self._specs, key=str))) self._canonicalized = True - self._resolved_ops = None self._is_unsatisfiable = None + self._ranges = None return self._specs @property @@ -1436,6 +1180,7 @@ def prereleases(self) -> bool | None: def prereleases(self, value: bool | None) -> None: self._prereleases = value self._is_unsatisfiable = None + self._ranges = None def __getstate__(self) -> tuple[tuple[Specifier, ...], bool | None]: # Return state as a 2-item tuple for compactness: @@ -1556,7 +1301,6 @@ def __and__(self, other: SpecifierSet | str) -> SpecifierSet: specifier._specs = self._specs + other._specs specifier._canonicalized = len(specifier._specs) <= 1 specifier._has_arbitrary = self._has_arbitrary or other._has_arbitrary - specifier._resolved_ops = None # Combine prerelease settings: use common or non-None value if self._prereleases is None or self._prereleases == other._prereleases: @@ -1610,26 +1354,34 @@ def __iter__(self) -> Iterator[Specifier]: """ return iter(self._specs) - def _get_ranges(self) -> Sequence[_VersionRange]: + def _get_ranges(self) -> Sequence[_VersionRange] | None: """Intersect all specifiers into a single list of version ranges. - Returns an empty list when unsatisfiable. ``===`` specs are - modeled as full range; string matching is checked separately - by :meth:`_check_arbitrary_unsatisfiable`. + Returns ``None`` if any spec uses ``===`` (arbitrary string + matching that cannot be modeled as version ranges). + Returns an empty list when unsatisfiable. """ + if self._ranges is not None: + return self._ranges + specs = self._specs + # Intersect each spec's ranges, bailing out on === (string + # matching, not version comparison) or empty intersection. result: Sequence[_VersionRange] | None = None for s in specs: + if s.operator == "===": + return None if result is None: result = s._to_ranges() else: result = _intersect_ranges(result, s._to_ranges()) if not result: - break + break # empty intersection, already unsatisfiable if result is None: # pragma: no cover raise RuntimeError("_get_ranges called with no specs") + self._ranges = result return result def is_unsatisfiable(self) -> bool: @@ -1654,7 +1406,18 @@ def is_unsatisfiable(self) -> bool: self._is_unsatisfiable = False return False - result = not self._get_ranges() + ranges = self._get_ranges() + if ranges is not None: + result = not ranges + else: + # _get_ranges returned None (=== specs present). + # Ranges are still valid for emptiness checking (=== is + # modeled as full range); only filtering cannot use them. + computed = functools.reduce( + _intersect_ranges, + (s._to_ranges() for s in self._specs), + ) + result = not computed if not result: result = self._check_arbitrary_unsatisfiable() @@ -1668,7 +1431,13 @@ def is_unsatisfiable(self) -> bool: def _check_prerelease_only_ranges(self) -> bool: """With prereleases=False, check if every range contains only pre-release versions (which would be excluded from matching).""" - for lower, upper in self._get_ranges(): + ranges = self._get_ranges() + if ranges is None: + ranges = functools.reduce( + _intersect_ranges, + (s._to_ranges() for s in self._specs), + ) + for lower, upper in ranges: nearest = _nearest_non_prerelease(lower.version) if nearest is None: return False @@ -1852,24 +1621,33 @@ def filter( if prereleases is None and self.prereleases is not None: prereleases = self.prereleases - # Filter versions that match all specifiers using Cost Based Ordering. + # Filter versions that match all specifiers. if self._specs: - # When prereleases is None, we need to let all versions through - # the individual filters, then decide about prereleases at the end - # based on whether any non-prereleases matched ALL specs. + resolve_pre = True if prereleases is None else prereleases - # Fast path: single specifier, delegate directly. - if len(self._specs) == 1: - filtered = self._specs[0].filter( + filtered: Iterator[Any] + ranges = self._get_ranges() + if ranges is not None: + filtered = _filter_by_ranges( + ranges, iterable, - prereleases=True if prereleases is None else prereleases, - key=key, + key, + prereleases=resolve_pre, ) else: - filtered = self._filter_versions( - iterable, - key, - prereleases=True if prereleases is None else prereleases, + # _get_ranges returns None when specs include === + # (arbitrary string matching, not version comparison). + specs = self._specs + filtered = ( + item + for item in iterable + if all( + s.contains( + item if key is None else key(item), + prereleases=resolve_pre, + ) + for s in specs + ) ) if prereleases is not None: @@ -1894,49 +1672,3 @@ def filter( # PEP 440: exclude prereleases unless no final releases matched return _pep440_filter_prereleases(iterable, key) - - def _filter_versions( - self, - iterable: Iterable[Any], - key: Callable[[Any], UnparsedVersion] | None, - prereleases: bool | None = None, - ) -> Iterator[Any]: - """Filter versions against all specifiers in a single pass. - - Uses Cost Based Ordering: specifiers are sorted by _operator_cost so - that cheap range operators reject versions early, avoiding expensive - wildcard or compatible operators on versions that would have been - rejected anyway. - """ - # Pre-resolve operators and sort (cached after first call). - if self._resolved_ops is None: - self._resolved_ops = sorted( - ( - (spec._get_operator(spec.operator), spec.version, spec.operator) - for spec in self._specs - ), - key=_operator_cost, - ) - ops = self._resolved_ops - exclude_prereleases = prereleases is False - - for item in iterable: - parsed = _coerce_version(item if key is None else key(item)) - - if parsed is None: - # Only === can match non-parseable versions. - if all( - op == "===" and str(item).lower() == ver.lower() - for _, ver, op in ops - ): - yield item - elif exclude_prereleases and parsed.is_prerelease: - pass - elif all( - str(item if key is None else key(item)).lower() == ver.lower() - if op == "===" - else op_fn(parsed, ver) - for op_fn, ver, op in ops - ): - # Short-circuits on the first failing operator. - yield item diff --git a/tests/test_specifiers.py b/tests/test_specifiers.py index 3eabedb89..d138611fb 100644 --- a/tests/test_specifiers.py +++ b/tests/test_specifiers.py @@ -1125,32 +1125,6 @@ def test_spec_version_cache_consistency( _ = spec == Specifier(specifier) assert spec._spec_version is initial_cache - @pytest.mark.parametrize( - ("specifier", "test_versions"), - [ - ( - "==1.0.*", - ["0.9", "1.0", "1.0.1", "1.0a1", "1.0.dev1", "1.0.post1", "1.0+local"], - ), - ( - "!=1.0.*", - ["0.9", "1.0", "1.0.1", "1.0a1", "1.0.dev1", "1.0.post1", "1.0+local"], - ), - ], - ) - def test_spec_version_cache_with_wildcards( - self, specifier: str, test_versions: list[str] - ) -> None: - """Wildcard specifiers use prefix matching, cache stays None.""" - spec = Specifier(specifier, prereleases=True) - - for v in test_versions: - _ = v in spec - _ = spec.prereleases - _ = hash(spec) - - assert spec._spec_version is None - @pytest.mark.parametrize( "specifier", [ @@ -2638,6 +2612,9 @@ class TestIsUnsatisfiable: # Local versions (spec with local + spec without local that strips local) "==1.0+local1,>=1.0", "!=1.0+local1,>=1.0", + "==1.0,>=0.5", + "==1.0,!=0.5", + "==1.0,<=2.0", "==1.0+local1,!=1.0+local2", "==1.0+local1,==1.0", "==1.0+local1,<=1.0", @@ -2670,6 +2647,8 @@ class TestIsUnsatisfiable: # Final version sits below its own post-releases ">=1.0,<1.0.post0", ">=1.0,<1.0.post1", + # Lower bound below base.dev0: other-base versions survive + ">=0,<1.0.post0,!=1.0", # None: 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}" + @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") assert ss.is_unsatisfiable() @@ -2773,6 +2769,8 @@ def test_cache_reset_on_prereleases_change(self) -> None: "<2.0", # Exact local pin: nearest == upper and upper inclusive "==1.0+local", + # === forces range fallback in prerelease check + "===1.0", # === with unparsable string (prereleases filter does not apply) "===foobar", # Compatible release from pre-release includes final release diff --git a/tests/test_version.py b/tests/test_version.py index 7f7515e51..664e172d6 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -919,6 +919,14 @@ def test_base_version_ne_with_base_version(self) -> None: v2 = SimpleVersion("2.0") assert v1 != v2 + def test_gt_with_cached_other(self) -> None: + """__gt__ fast path when other already has a cached key.""" + other = Version("1.0") + # Pre-populate other's key cache via a comparison. + _ = other < Version("2.0") + # Now a fresh version calls __gt__ with a pre-cached other. + assert Version("2.0") > other + def test_version_compare_with_base_version_subclass(self) -> None: """Test Version comparison with another _BaseVersion subclass""" v1 = Version("1.0") From 92a7a460fe163403bb82fc10c7a1b96e21529c61 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Sat, 2 May 2026 11:24:29 -0400 Subject: [PATCH 2/7] Move range logic into it's own module --- src/packaging/_ranges.py | 665 ++++++++++++++++++++++++++++++++++++ src/packaging/specifiers.py | 662 +++++++---------------------------- tests/test_specifiers.py | 162 +++++++-- tests/test_version.py | 4 +- 4 files changed, 930 insertions(+), 563 deletions(-) create mode 100644 src/packaging/_ranges.py diff --git a/src/packaging/_ranges.py b/src/packaging/_ranges.py new file mode 100644 index 000000000..7d08a1f3e --- /dev/null +++ b/src/packaging/_ranges.py @@ -0,0 +1,665 @@ +# 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 == "<": + # TypeGuard[bool | None]: UnparsedVersion = Union[Version, str] UnparsedVersionVar = TypeVar("UnparsedVersionVar", bound=UnparsedVersion) -# The smallest possible PEP 440 version. No valid version is less than this. -_MIN_VERSION: Final[Version] = Version("0.dev0") - - -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] - - -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. - - Some specifier semantics imply boundaries between real versions: - ``<=1.0`` includes ``1.0+local`` and ``>1.0`` excludes - ``1.0.post0``. No real :class:`Version` falls on those boundaries, - so this class creates values that sort between the real versions - on either side. - - Two kinds exist, shown relative to a base version V:: - - V < V+local < AFTER_LOCALS(V) < V.post0 < AFTER_POSTS(V) - - ``AFTER_LOCALS`` sits after V and every V+local, but before - V.post0. Upper bound of ``<=V``, ``==V``, ``!=V``. - - ``AFTER_POSTS`` sits after every V.postN, but before the next - release segment. Lower bound of ``>V`` (final or pre-release V) - to exclude post-releases per PEP 440. - """ - - __slots__ = ("_kind", "_trimmed_release", "version") - - def __init__(self, version: Version, kind: _BoundaryKind) -> None: - self.version = version - self._kind = kind - self._trimmed_release = _trim_release(version.release) - - def _is_family(self, other: Version) -> bool: - """Is ``other`` a version that this boundary sorts above?""" - v = self.version - if not ( - other.epoch == v.epoch - and _trim_release(other.release) == self._trimmed_release - and other.pre == v.pre - ): - return False - if self._kind == _BoundaryKind.AFTER_LOCALS: - # Local family: exact same public version (any local label). - return other.post == v.post and other.dev == v.dev - # Post family: same base + any post-release (or identical). - return other.dev == v.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 - return not self._is_family(other) and self.version < 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})" - - -@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__ = ("inclusive", "version") - - def __init__(self, version: _VersionOrBoundary, inclusive: bool) -> None: - self.version = version - self.inclusive = inclusive - - 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). - 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__ = ("inclusive", "version") - - def __init__(self, version: _VersionOrBoundary, inclusive: bool) -> None: - self.version = version - self.inclusive = inclusive - - 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 typing.TYPE_CHECKING: - from collections.abc import Iterable, Iterator, Sequence - - _VersionOrBoundary = Union[Version, _BoundaryVersion, None] - - #: A single contiguous version range, represented as a - #: (lower bound, upper bound) 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 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 _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: UnparsedVersion) -> Version | None: if not isinstance(version, Version): @@ -295,107 +84,6 @@ def _coerce_version(version: UnparsedVersion) -> Version | None: return version -def _nearest_non_prerelease( - v: _VersionOrBoundary, -) -> Version | None: - """Smallest non-pre-release version at or above *v*, or None.""" - if v is None: - return None - if isinstance(v, _BoundaryVersion): - inner = v.version - if inner.is_prerelease: - # AFTER_LOCALS(1.0a1) -> nearest non-pre is 1.0 - return inner.__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 - k = (inner.post + 1) if inner.post is not None else 0 - return inner.__replace__(post=k, local=None) - if not v.is_prerelease: - return v - # Strip pre/dev to get the final or post-release form. - return v.__replace__(pre=None, dev=None, local=None) - - -def _filter_by_ranges( - ranges: Sequence[_VersionRange], - iterable: Iterable[Any], - key: Callable[[Any], UnparsedVersion] | None, - prereleases: bool, -) -> Iterator[Any]: - """Filter versions against precomputed version ranges. - - Local version segments are preserved on candidates; the range bounds - use :class:`_BoundaryVersion` to handle local-version semantics. - - Used by both :class:`Specifier` and :class:`SpecifierSet`. - Prerelease buffering (PEP 440 default) is NOT handled here; - callers wrap the result with :func:`_pep440_filter_prereleases` - when needed. - """ - exclude_prereleases = prereleases is False - - 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 - # Check if version falls within any range. Ranges are sorted and - # non-overlapping, so at most one can match. - for lower, upper in ranges: - if lower.version is not None and ( - parsed < lower.version - or (parsed == lower.version and not lower.inclusive) - ): - break - if ( - upper.version is None - or parsed < upper.version - or (parsed == upper.version and upper.inclusive) - ): - yield item - break - - -def _pep440_filter_prereleases( - iterable: Iterable[Any], key: Callable[[Any], UnparsedVersion] | None -) -> Iterator[Any]: - """Filter per PEP 440: exclude prereleases unless no finals exist.""" - 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 - - class InvalidSpecifier(ValueError): """ Raised when attempting to create a :class:`Specifier` with a specifier @@ -613,6 +301,18 @@ class Specifier(BaseSpecifier): r"\s*" + _specifier_regex_str + r"\s*", re.VERBOSE | re.IGNORECASE ) + # Legacy unused attribute, kept for backward compatibility + _operators: Final = { + "~=": "compatible", + "==": "equal", + "!=": "not_equal", + "<=": "less_than_equal", + ">=": "greater_than_equal", + "<": "less_than", + ">": "greater_than", + "===": "arbitrary", + } + def __init__(self, spec: str = "", prereleases: bool | None = None) -> None: """Initialize a Specifier instance. @@ -645,8 +345,8 @@ def __init__(self, spec: str = "", prereleases: bool | None = None) -> None: # Specifier version cache self._spec_version: tuple[str, Version] | None = None - # Version range cache. - self._ranges: Sequence[_VersionRange] | None = None + # Version range cache (populated by _to_ranges) + self._ranges: Sequence[VersionRange] | None = None def _get_spec_version(self, version: str) -> Version | None: """One element cache, as only one spec Version is needed per Specifier.""" @@ -670,7 +370,7 @@ def _require_spec_version(self, version: str) -> Version: assert spec_version is not None return spec_version - def _to_ranges(self) -> Sequence[_VersionRange]: + def _to_ranges(self) -> Sequence[VersionRange]: """Convert this specifier to sorted, non-overlapping version ranges. Each standard operator maps to one or two ranges. ``===`` is @@ -683,92 +383,18 @@ def _to_ranges(self) -> Sequence[_VersionRange]: ver_str = self.version if op == "===": - self._ranges = _FULL_RANGE - return _FULL_RANGE - - if ver_str.endswith(".*"): - result = self._wildcard_ranges(op, ver_str) + result: Sequence[VersionRange] = FULL_RANGE + elif ver_str.endswith(".*"): + base = self._require_spec_version(ver_str[:-2]) + result = wildcard_ranges(op, base) else: - result = self._standard_ranges(op, ver_str) + v = self._require_spec_version(ver_str) + has_local = "+" in ver_str + result = standard_ranges(op, v, has_local) self._ranges = result return result - def _wildcard_ranges(self, op: str, ver_str: str) -> list[_VersionRange]: - # ==1.2.* -> [1.2.dev0, 1.3.dev0); !=1.2.* -> complement. - base = self._require_spec_version(ver_str[:-2]) - 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(self, op: str, ver_str: str) -> list[_VersionRange]: - v = self._require_spec_version(ver_str) - - if op == ">=": - return [(_LowerBound(v, True), _POS_INF)] - - if op == "<=": - return [ - ( - _NEG_INF, - _UpperBound(_BoundaryVersion(v, _BoundaryKind.AFTER_LOCALS), True), - ) - ] - - if op == ">": - if v.dev is not None: - # >V.devN: dev versions have no post-releases, so the - # next real version is V.dev(N+1). - lower_ver = v.__replace__(dev=v.dev + 1, local=None) - return [(_LowerBound(lower_ver, True), _POS_INF)] - if v.post is not None: - # >V.postN: next real version is V.post(N+1).dev0. - lower_ver = v.__replace__(post=v.post + 1, dev=0, local=None) - return [(_LowerBound(lower_ver, True), _POS_INF)] - # >V (final or pre-release): skip V+local and all V.postN. - return [ - ( - _LowerBound(_BoundaryVersion(v, _BoundaryKind.AFTER_POSTS), False), - _POS_INF, - ) - ] - - if op == "<": - # bool | None: # If there is an explicit prereleases set for this, then we'll just @@ -810,7 +436,6 @@ 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._wildcard_split = None self._ranges = None if isinstance(state, tuple): @@ -1033,46 +658,46 @@ def filter( [{'ver': '1.3'}] """ if self.operator == "===": - # === uses arbitrary string matching, not version comparison. - # Still respect prereleases when the string parses to a version. - if prereleases is None and self._prereleases is not None: - prereleases = self._prereleases - exclude_pre = prereleases is False - spec_str = self.version - for item in iterable: - raw = item if key is None else key(item) - if str(raw).lower() != spec_str.lower(): - continue - if exclude_pre: - parsed = _coerce_version(raw) - if parsed is not None and parsed.is_prerelease: - continue - yield item - return + 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) - # Determine concrete prerelease behavior, or leave as None - # for PEP 440 default (include prereleases only if no finals exist). if prereleases is None: if self._prereleases is not None: prereleases = self._prereleases elif self.prereleases: prereleases = True - # When prereleases is still None, pass True to include all versions - # and let _pep440_filter_prereleases handle the buffering. - resolve_pre = True if prereleases is None else prereleases + ranges = self._ranges + if ranges is None: + ranges = self._to_ranges() + return filter_by_ranges(ranges, iterable, key, prereleases) - filtered = _filter_by_ranges( - self._to_ranges(), - iterable, - key, - prereleases=resolve_pre, - ) - if prereleases is not None: - yield from filtered - else: - yield from _pep440_filter_prereleases(filtered, key) +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 + ) class SpecifierSet(BaseSpecifier): @@ -1141,7 +766,7 @@ def __init__( self._canonicalized = len(self._specs) <= 1 self._is_unsatisfiable: bool | None = None - self._ranges: Sequence[_VersionRange] | 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. @@ -1180,7 +805,6 @@ def prereleases(self) -> bool | None: def prereleases(self, value: bool | None) -> None: self._prereleases = value self._is_unsatisfiable = None - self._ranges = None def __getstate__(self) -> tuple[tuple[Specifier, ...], bool | None]: # Return state as a 2-item tuple for compactness: @@ -1190,7 +814,7 @@ 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._resolved_ops = None + self._ranges = None self._is_unsatisfiable = None if isinstance(state, tuple): @@ -1354,30 +978,24 @@ def __iter__(self) -> Iterator[Specifier]: """ return iter(self._specs) - def _get_ranges(self) -> Sequence[_VersionRange] | None: - """Intersect all specifiers into a single list of version ranges. + def _get_ranges(self) -> Sequence[VersionRange]: + """Intersect all specifiers into a single sequence of version ranges. - Returns ``None`` if any spec uses ``===`` (arbitrary string - matching that cannot be modeled as version ranges). - Returns an empty list when unsatisfiable. + Empty when unsatisfiable. Callers must ensure ``self._specs`` + is non-empty. """ if self._ranges is not None: return self._ranges - specs = self._specs - - # Intersect each spec's ranges, bailing out on === (string - # matching, not version comparison) or empty intersection. - result: Sequence[_VersionRange] | None = None - for s in specs: - if s.operator == "===": - return None + result: Sequence[VersionRange] | None = None + for s in self._specs: + sub = s._to_ranges() if result is None: - result = s._to_ranges() + result = sub else: - result = _intersect_ranges(result, s._to_ranges()) + result = intersect_ranges(result, sub) if not result: - break # empty intersection, already unsatisfiable + break if result is None: # pragma: no cover raise RuntimeError("_get_ranges called with no specs") @@ -1406,47 +1024,17 @@ def is_unsatisfiable(self) -> bool: self._is_unsatisfiable = False return False - ranges = self._get_ranges() - if ranges is not None: - result = not ranges - else: - # _get_ranges returned None (=== specs present). - # Ranges are still valid for emptiness checking (=== is - # modeled as full range); only filtering cannot use them. - computed = functools.reduce( - _intersect_ranges, - (s._to_ranges() for s in self._specs), - ) - result = not computed + result = not self._get_ranges() if not result: result = self._check_arbitrary_unsatisfiable() if not result and self.prereleases is False: - result = self._check_prerelease_only_ranges() + result = ranges_are_prerelease_only(self._get_ranges()) self._is_unsatisfiable = result return result - def _check_prerelease_only_ranges(self) -> bool: - """With prereleases=False, check if every range contains only - pre-release versions (which would be excluded from matching).""" - ranges = self._get_ranges() - if ranges is None: - ranges = functools.reduce( - _intersect_ranges, - (s._to_ranges() for s in self._specs), - ) - for lower, upper in ranges: - nearest = _nearest_non_prerelease(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 _check_arbitrary_unsatisfiable(self) -> bool: """Check === (arbitrary equality) specs for unsatisfiability. @@ -1621,54 +1209,66 @@ def filter( if prereleases is None and self.prereleases is not None: prereleases = self.prereleases - # Filter versions that match all specifiers. if self._specs: - resolve_pre = True if prereleases is None else prereleases - - filtered: Iterator[Any] - ranges = self._get_ranges() - if ranges is not None: - filtered = _filter_by_ranges( - ranges, - iterable, - key, - prereleases=resolve_pre, - ) - else: - # _get_ranges returns None when specs include === - # (arbitrary string matching, not version comparison). + if self._has_arbitrary: + # Slow path for === specs = self._specs - filtered = ( + matches = ( item for item in iterable if all( - s.contains( - item if key is None else key(item), - prereleases=resolve_pre, - ) + s.contains(item if key is None else key(item), prereleases=True) for s in specs ) ) + return _apply_prereleases_filter(matches, key, prereleases) - if prereleases is not None: - return filtered + ranges = self._ranges + if ranges is None: + ranges = self._get_ranges() + return filter_by_ranges(ranges, iterable, key, prereleases) - return _pep440_filter_prereleases(filtered, key) + # Empty SpecifierSet. + return _apply_prereleases_filter(iterable, key, prereleases) - # Handle Empty SpecifierSet. - if prereleases is True: - return iter(iterable) - if prereleases is False: - return ( - item - for item in iterable - if ( - (version := _coerce_version(item if key is None else key(item))) - is None - or not version.is_prerelease - ) - ) +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 - # PEP 440: exclude prereleases unless no final releases matched - return _pep440_filter_prereleases(iterable, key) + 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 diff --git a/tests/test_specifiers.py b/tests/test_specifiers.py index d138611fb..82d95ae7f 100644 --- a/tests/test_specifiers.py +++ b/tests/test_specifiers.py @@ -1125,6 +1125,35 @@ def test_spec_version_cache_consistency( _ = spec == Specifier(specifier) assert spec._spec_version is initial_cache + @pytest.mark.parametrize( + ("specifier", "test_versions"), + [ + ( + "==1.0.*", + ["0.9", "1.0", "1.0.1", "1.0a1", "1.0.dev1", "1.0.post1", "1.0+local"], + ), + ( + "!=1.0.*", + ["0.9", "1.0", "1.0.1", "1.0a1", "1.0.dev1", "1.0.post1", "1.0+local"], + ), + ], + ) + def test_spec_version_cache_with_wildcards( + self, specifier: str, test_versions: list[str] + ) -> None: + """Wildcard specifiers cache the parsed base version once.""" + spec = Specifier(specifier, prereleases=True) + + for v in test_versions: + _ = v in spec + _ = spec.prereleases + _ = hash(spec) + + # ``==1.0.*`` parses ``1.0`` for the range bounds; that + # parsed base lands in the cache. + assert spec._spec_version is not None + assert spec._spec_version[0] == "1.0" + @pytest.mark.parametrize( "specifier", [ @@ -2698,23 +2727,6 @@ def test_satisfiable(self, spec_str: str) -> None: 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}" - @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") assert ss.is_unsatisfiable() @@ -2961,38 +2973,33 @@ def test_pickle_specifierset_setstate_on_initialized_instance() -> None: def test_pickle_specifier_setstate_clears_cache() -> None: - # Verify that __setstate__ resets all three cached slots to None, - # regardless of what was cached before the call. + # Verify that __setstate__ resets all cached slots to their reset + # values, regardless of what was cached before the call. s = Specifier("==1.*") # Warm up every cache slot. - _ = s.prereleases # populates _spec_version - _ = s._get_wildcard_split("1.*") # populates _wildcard_split - _ = s._to_ranges() # populates _ranges + _ = s._to_ranges() # populates _spec_version + _ranges assert s._spec_version is not None - assert s._wildcard_split is not None assert s._ranges is not None s.__setstate__((("==", "1.*"), None)) assert s._spec_version is None - assert s._wildcard_split is None assert s._ranges is None def test_pickle_specifierset_setstate_clears_cache() -> None: - # Verify that __setstate__ resets all cached slots to None, - # regardless of what was cached before the call. + # Verify that __setstate__ resets all cached slots, 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 - list(ss.filter(["1.5"])) # populates _resolved_ops + ss.is_unsatisfiable() # populates _is_unsatisfiable + _ranges assert ss._is_unsatisfiable is not None - assert ss._resolved_ops is not None + assert ss._ranges is not None ss.__setstate__(((Specifier(">=3.0"), Specifier("<4.0")), None)) assert ss._is_unsatisfiable is None - assert ss._resolved_ops is None + assert ss._ranges is None # Pickle bytes generated with packaging==25.0, Python 3.13.13, pickle protocol 2. @@ -3183,3 +3190,98 @@ def test_pickle_specifierset_26_2_tuple_format_loads() -> None: assert "3.12" in ss assert "4.0" not in ss assert ss.prereleases is None + + +def test_filter_multirange_pep440_prerelease_after_final() -> None: + """Multi-range PEP 440 path drops a prerelease that arrives after a final.""" + # `!=1.5` has two ranges: (-inf, 1.5) and (AFTER_LOCALS(1.5), +inf). + ss = SpecifierSet("!=1.5") + assert list(ss.filter(["1.4", "1.6a1"])) == ["1.4"] + assert list(ss.filter(["1.6a1", "1.4"])) == ["1.4"] + + +@pytest.mark.parametrize( + "spec", + [ + ">0.5", + ">=1.0", + "<=2.0", + "<3.0", + "==1.5", + "!=1.5", + "==1.*", + "!=1.0+local", + "~=1.2.3", + "===wat", + ], +) +def test_specifier_construction_is_lazy(spec: str) -> None: + s = Specifier(spec) + assert s._spec_version is None + assert s._ranges is None + + +@pytest.mark.parametrize( + "spec", + [ + "", + ">=1.0", + ">=1.0,<2.0", + ">=3.8,!=3.9.*,!=3.10.0,!=3.10.1,~=3.10.2,<3.14,!=3.11.0", + "===wat", + ], +) +def test_specifierset_construction_is_lazy(spec: str) -> None: + ss = SpecifierSet(spec) + assert ss._is_unsatisfiable is None + assert ss._ranges is None + # Every inner Specifier must also be untouched. + for inner in ss._specs: + assert inner._spec_version is None + assert inner._ranges is None + + +def test_specifier_filter_with_version_iterable_warms_then_reuses_cache() -> None: + """filter() reuses warm _ranges 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")] + assert list(spec.filter(items)) == [Version("2.0"), Version("3.0")] + + +@pytest.mark.parametrize( + ("spec_str", "version_str", "expected"), + [ + # >V (AFTER_POSTS): trimmed release longer than parsed.release + (">1.2.3", "2", True), + # !=V (AFTER_LOCALS upper-side): trimmed release longer than parsed + ("!=1.2.3", "2", True), + # <=V (AFTER_LOCALS upper): parsed > V cmpkey-wise but shorter release + ("<=1.2.3", "2", False), + ], +) +def test_boundary_closure_short_release( + spec_str: str, version_str: str, expected: bool +) -> None: + """Closures handle parsed versions whose release is shorter than the boundary's.""" + assert Specifier(spec_str).contains(Version(version_str)) is expected + + +def test_filter_arbitrary_with_prereleases_false_skips_pre() -> None: + """``===1.0a1`` with ``prereleases=False`` drops the pre-release.""" + spec = Specifier("===1.0a1") + assert list(spec.filter(["1.0a1", "0.9"], prereleases=False)) == [] + + +def test_filter_arbitrary_pep440_unparsable_buffer_flush() -> None: + """``===wat`` flushes the unparsable buffer when no final ever lands.""" + # "wat" never parses, so no final can be reached for the literal + # match. Two matching items both buffer and then flush at the end. + spec = Specifier("===wat") + assert list(spec.filter(["wat", "wat"])) == ["wat", "wat"] + + +def test_filter_arbitrary_pep440_pre_only() -> None: + """``===1.0a1`` PEP 440 default: prerelease literal flushes without a final.""" + pre_spec = Specifier("===1.0a1") + assert list(pre_spec.filter(["1.0a1"])) == ["1.0a1"] diff --git a/tests/test_version.py b/tests/test_version.py index 664e172d6..9729298be 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -922,9 +922,9 @@ def test_base_version_ne_with_base_version(self) -> None: def test_gt_with_cached_other(self) -> None: """__gt__ fast path when other already has a cached key.""" other = Version("1.0") - # Pre-populate other's key cache via a comparison. + # Warm other's key cache. _ = other < Version("2.0") - # Now a fresh version calls __gt__ with a pre-cached other. + # Fresh version on the left, cached one on the right. assert Version("2.0") > other def test_version_compare_with_base_version_subclass(self) -> None: From e524f48f29b5e3b0d07c30d0fce3b250a6e30bf3 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Sat, 9 May 2026 11:49:43 -0400 Subject: [PATCH 3/7] Fix _make_cold in benchmarks --- benchmarks/specifiers.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/benchmarks/specifiers.py b/benchmarks/specifiers.py index 7e0cdccc4..23b6bb8cf 100644 --- a/benchmarks/specifiers.py +++ b/benchmarks/specifiers.py @@ -60,10 +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, "_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 @add_attributes(pretty_name="SpecifierSet constructor") def time_constructor(self) -> None: From cea3be17b2d3df5aeda16e773b84bf263d4a7909 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Sat, 9 May 2026 14:43:31 -0400 Subject: [PATCH 4/7] Fast-path `.contains` --- src/packaging/_ranges.py | 12 ++--- src/packaging/specifiers.py | 92 ++++++++++++++++++++++++++++++++++++- tests/test_specifiers.py | 8 +++- 3 files changed, 104 insertions(+), 8 deletions(-) diff --git a/src/packaging/_ranges.py b/src/packaging/_ranges.py index 7d08a1f3e..5ab4c41e6 100644 --- a/src/packaging/_ranges.py +++ b/src/packaging/_ranges.py @@ -66,7 +66,7 @@ class _BoundaryVersion: def __init__(self, version: Version, kind: _BoundaryKind) -> None: self.version = version self._kind = kind - self._cached_trimmed_release = _trim_release(version.release) + self._cached_trimmed_release = trim_release(version.release) self._cached_epoch = version.epoch self._cached_pre = version.pre self._cached_post = version.post @@ -78,7 +78,7 @@ def _is_family(self, other: Version) -> bool: 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. + # components are zero. Avoids trim_release's tuple allocation. other_release = other.release trimmed_release = self._cached_trimmed_release trimmed_length = len(trimmed_release) @@ -258,7 +258,7 @@ def __repr__(self) -> str: FULL_RANGE: Final[tuple[VersionRange]] = ((_NEG_INF, _POS_INF),) -def _trim_release(release: tuple[int, ...]) -> tuple[int, ...]: +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: @@ -297,7 +297,7 @@ def _make_above_after_posts(version: Version) -> Callable[[Version], bool]: version_epoch = version.epoch version_pre = version.pre version_dev = version.dev - version_release_trimmed = _trim_release(version.release) + version_release_trimmed = trim_release(version.release) trimmed_length = len(version_release_trimmed) def above(parsed: Version) -> bool: @@ -340,7 +340,7 @@ def _make_above_after_locals(version: Version) -> Callable[[Version], bool]: version_pre = version.pre version_post = version.post version_dev = version.dev - version_release_trimmed = _trim_release(version.release) + version_release_trimmed = trim_release(version.release) trimmed_length = len(version_release_trimmed) def above(parsed: Version) -> bool: @@ -379,7 +379,7 @@ def _make_below_after_locals(version: Version) -> Callable[[Version], bool]: version_pre = version.pre version_post = version.post version_dev = version.dev - version_release_trimmed = _trim_release(version.release) + version_release_trimmed = trim_release(version.release) trimmed_length = len(version_release_trimmed) def below(parsed: Version) -> bool: diff --git a/src/packaging/specifiers.py b/src/packaging/specifiers.py index 85b2c3990..141773b85 100644 --- a/src/packaging/specifiers.py +++ b/src/packaging/specifiers.py @@ -29,6 +29,7 @@ intersect_ranges, ranges_are_prerelease_only, standard_ranges, + trim_release, wildcard_ranges, ) from .utils import canonicalize_version @@ -84,6 +85,46 @@ def _coerce_version(version: UnparsedVersion) -> Version | 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. +_DIRECT_COMPARE_OPS: dict[str, Callable[[Version, Version], bool]] = { + ">=": Version.__ge__, + "<=": Version.__le__, + "==": Version.__eq__, + "!=": Version.__ne__, +} + + +def _fast_match(specifier: Specifier, parsed: Version) -> bool | None: + """Match ``parsed`` against ``specifier`` without building a range. + + Handles ``>=``, ``<=``, ``==``, ``!=``, ``<``, ``>`` when the spec is + not a wildcard and ``parsed`` has no local. Returns ``None`` when the + range path must be used. Pre-release policy is left to the caller. + """ + op_str, ver_str = specifier._spec + if ver_str.endswith(".*") or parsed.local is not None: + return None + + direct_compare = _DIRECT_COMPARE_OPS.get(op_str) + if direct_compare is not None: + return direct_compare(parsed, specifier._require_spec_version(ver_str)) + + if op_str in ("<", ">"): + spec_v = specifier._require_spec_version(ver_str) + # ``V`` carve out V's family (pre/dev/post); that only + # matters when parsed shares V's epoch and trimmed release. + # Otherwise a direct cmpkey comparison is correct. + if parsed.epoch != spec_v.epoch or trim_release(parsed.release) != trim_release( + spec_v.release + ): + return parsed < spec_v if op_str == "<" else parsed > spec_v + return None + + return None + + class InvalidSpecifier(ValueError): """ Raised when attempting to create a :class:`Specifier` with a specifier @@ -603,8 +644,30 @@ def contains(self, item: UnparsedVersion, prereleases: bool | None = None) -> bo >>> Specifier(">=1.2.3").contains("1.3.0a1") True """ + # ``===`` compares the raw string, so a Version parse here would + # be wasted. + if self._spec[0] == "===": + return bool(list(self.filter([item], prereleases=prereleases))) - return bool(list(self.filter([item], prereleases=prereleases))) + parsed = _coerce_version(item) + if parsed is None: + # Standard operators never match an unparsable input. + return False + + match = _fast_match(self, parsed) + if match is not None: + if prereleases is None: + if self._prereleases is not None: + prereleases = self._prereleases + elif self.prereleases: + prereleases = True + if prereleases is False and parsed.is_prerelease: + return False + return match + + # Pass the already-parsed Version so filter_by_ranges doesn't + # re-coerce it. + return bool(list(self.filter([parsed], prereleases=prereleases))) @typing.overload def filter( @@ -1138,6 +1201,33 @@ def contains( check_item = item else: check_item = version + + # Fast path: skip the intersected-range build while every spec + # answers directly. Once ``_ranges`` 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 + and version is not None + and not self._has_arbitrary + and version.local is None + and self._specs + ): + if version.is_prerelease and ( + prereleases is False + or (prereleases is None and self._prereleases is False) + ): + return False + for spec in self._specs: + match = _fast_match(spec, version) + if match is None: + break + if not match: + return False + else: + return True + return bool(list(self.filter([check_item], prereleases=prereleases))) @typing.overload diff --git a/tests/test_specifiers.py b/tests/test_specifiers.py index 82d95ae7f..b13fe9835 100644 --- a/tests/test_specifiers.py +++ b/tests/test_specifiers.py @@ -3264,7 +3264,13 @@ def test_boundary_closure_short_release( spec_str: str, version_str: str, expected: bool ) -> None: """Closures handle parsed versions whose release is shorter than the boundary's.""" - assert Specifier(spec_str).contains(Version(version_str)) is expected + spec = Specifier(spec_str) + version = Version(version_str) + assert spec.contains(version) is expected + # Also drive Specifier.filter directly: contains has a fast path that + # bypasses the range closures for short releases, but filter does not. + filtered = list(spec.filter([version], prereleases=True)) + assert (filtered == [version]) is expected def test_filter_arbitrary_with_prereleases_false_skips_pre() -> None: From 7c830b5a5b6e7ad8c9ef71a60667c04e6fba01c3 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Sun, 10 May 2026 10:03:52 -0400 Subject: [PATCH 5/7] fix spacing --- src/packaging/specifiers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/packaging/specifiers.py b/src/packaging/specifiers.py index 141773b85..ad7494bf7 100644 --- a/src/packaging/specifiers.py +++ b/src/packaging/specifiers.py @@ -1044,7 +1044,7 @@ def __iter__(self) -> Iterator[Specifier]: def _get_ranges(self) -> Sequence[VersionRange]: """Intersect all specifiers into a single sequence of version ranges. - Empty when unsatisfiable. Callers must ensure ``self._specs`` + Empty when unsatisfiable. Callers must ensure ``self._specs`` is non-empty. """ if self._ranges is not None: From 3abc7454621f9db48690eda9f02e31e85e7137d8 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Sun, 10 May 2026 10:44:04 -0400 Subject: [PATCH 6/7] Fix `===` and pre-release=False --- src/packaging/specifiers.py | 12 ++++++------ tests/test_specifiers.py | 7 +++++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/packaging/specifiers.py b/src/packaging/specifiers.py index ad7494bf7..bfd45ee58 100644 --- a/src/packaging/specifiers.py +++ b/src/packaging/specifiers.py @@ -720,6 +720,12 @@ def filter( ... key=lambda x: x["ver"])) [{'ver': '1.3'}] """ + 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 = ( @@ -729,12 +735,6 @@ def filter( ) return _apply_prereleases_filter(matches, key, prereleases) - if prereleases is None: - if self._prereleases is not None: - prereleases = self._prereleases - elif self.prereleases: - prereleases = True - ranges = self._ranges if ranges is None: ranges = self._to_ranges() diff --git a/tests/test_specifiers.py b/tests/test_specifiers.py index b13fe9835..2838ebede 100644 --- a/tests/test_specifiers.py +++ b/tests/test_specifiers.py @@ -3279,6 +3279,13 @@ def test_filter_arbitrary_with_prereleases_false_skips_pre() -> None: assert list(spec.filter(["1.0a1", "0.9"], prereleases=False)) == [] +def test_filter_arbitrary_constructor_prereleases_false_skips_pre() -> None: + """``Specifier('===1.0a1', prereleases=False)`` honors the constructor flag.""" + spec = Specifier("===1.0a1", prereleases=False) + assert list(spec.filter(["1.0a1"])) == [] + assert spec.contains("1.0a1") is False + + def test_filter_arbitrary_pep440_unparsable_buffer_flush() -> None: """``===wat`` flushes the unparsable buffer when no final ever lands.""" # "wat" never parses, so no final can be reached for the literal From 72bb0ba789ad7fcb86a96a8a1950af01b7ff0a7d Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Sun, 10 May 2026 11:20:17 -0400 Subject: [PATCH 7/7] Add test for === and key on unparsable versions --- tests/test_specifiers.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_specifiers.py b/tests/test_specifiers.py index 2838ebede..bff261dcb 100644 --- a/tests/test_specifiers.py +++ b/tests/test_specifiers.py @@ -3286,6 +3286,16 @@ def test_filter_arbitrary_constructor_prereleases_false_skips_pre() -> None: assert spec.contains("1.0a1") is False +def test_filter_arbitrary_unparsable_uses_key() -> None: + """``===`` filter with ``key=`` matches against the keyed value.""" + items = [{"v": "wat"}, {"v": "WAT"}, {"v": "else"}] + spec = Specifier("===wat") + assert list(spec.filter(items, key=lambda x: x["v"])) == [ + {"v": "wat"}, + {"v": "WAT"}, + ] + + def test_filter_arbitrary_pep440_unparsable_buffer_flush() -> None: """``===wat`` flushes the unparsable buffer when no final ever lands.""" # "wat" never parses, so no final can be reached for the literal