Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion benchmarks/specifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from pathlib import Path

from packaging.specifiers import SpecifierSet
from packaging.specifiers import Specifier, SpecifierSet
from packaging.version import Version

from . import add_attributes
Expand Down Expand Up @@ -55,6 +55,20 @@ def setup(self) -> None:
for sp in self._warm_compatible._specs:
sp.contains(self.complex_versions[0])

specifier_strs = [str(sp) for s in self._cold_specs for sp in s._specs]
self._warm_specifiers = [Specifier(s) for s in specifier_strs]
others = [Specifier(s) for s in specifier_strs]
for sp in (*self._warm_specifiers, *others):
hash(sp)
self._warm_specifier_pairs = list(
zip(self._warm_specifiers, others, strict=True)
)

self._warm_spec_groups = [s._specs for s in self._warm_specs]
for group in self._warm_spec_groups:
for sp in group:
hash(sp)

def _make_cold(self, spec: SpecifierSet) -> None:
if hasattr(spec, "_canonicalized"):
spec._canonicalized = False
Expand All @@ -70,6 +84,8 @@ def _make_cold(self, spec: SpecifierSet) -> None:
sp._wildcard_split = None
if hasattr(sp, "_ranges"):
sp._ranges = None
if hasattr(sp, "_canonical_spec_cache"):
sp._canonical_spec_cache = None

@add_attributes(pretty_name="SpecifierSet constructor")
def time_constructor(self) -> None:
Expand Down Expand Up @@ -116,3 +132,20 @@ def time_filter_complex_warm(self) -> None:
@add_attributes(pretty_name="SpecifierSet filter (compatible, warm)")
def time_filter_compatible_warm(self) -> None:
list(self._warm_compatible.filter(self.complex_versions))

@add_attributes(pretty_name="Specifier hash (warm)")
def time_hash_specifier_warm(self) -> None:
for sp in self._warm_specifiers:
hash(sp)

@add_attributes(pretty_name="Specifier equality (warm)")
def time_eq_specifier_warm(self) -> None:
for a, b in self._warm_specifier_pairs:
_ = a == b

# A SpecifierSet caches its deduplication, so construction has to be inside the
# timed region to reach the sort and dict.fromkeys at all.
@add_attributes(pretty_name="SpecifierSet construct + dedup (warm)")
def time_construct_and_dedup_warm(self) -> None:
for group in self._warm_spec_groups:
SpecifierSet(group)._canonical_specs()
28 changes: 20 additions & 8 deletions src/packaging/specifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ class Specifier(BaseSpecifier):
"""

__slots__ = (
"_canonical_spec_cache",
"_prereleases",
"_ranges",
"_spec",
Expand Down Expand Up @@ -380,6 +381,9 @@ def __init__(self, spec: str = "", prereleases: bool | None = None) -> None:
# Version range cache (populated by _to_ranges)
self._ranges: Sequence[Interval] | None = None

# Canonical (operator, version) cache (populated by _canonical_spec)
self._canonical_spec_cache: tuple[str, str] | None = None

def _get_spec_version(self, version: str) -> Version | None:
"""One element cache, as only one spec Version is needed per Specifier."""
if self._spec_version is not None and self._spec_version[0] == version:
Expand Down Expand Up @@ -465,6 +469,7 @@ def __setstate__(self, state: object) -> None:
# Always discard cached values - they will be recomputed on demand.
self._spec_version = None
self._ranges = None
self._canonical_spec_cache = None

if isinstance(state, tuple):
if len(state) == 2:
Expand Down Expand Up @@ -542,17 +547,24 @@ def __str__(self) -> str:

@property
def _canonical_spec(self) -> tuple[str, str]:
cached = self._canonical_spec_cache
if cached is not None:
return cached

operator, version = self._spec
if operator == "===" or version.endswith(".*"):
return operator, version

spec_version = self._require_spec_version(version)

canonical_version = canonicalize_version(
spec_version, strip_trailing_zero=(operator != "~=")
)
result = self._spec
else:
canonical = canonicalize_version(
self._require_spec_version(version),
strip_trailing_zero=(operator != "~="),
)
# Most versions are already canonical, so reuse the existing tuple rather
# than retaining a second copy of a string equal to the one we hold.
result = self._spec if canonical == version else (operator, canonical)

return operator, canonical_version
self._canonical_spec_cache = result
return result

def __hash__(self) -> int:
return hash(self._canonical_spec)
Expand Down
77 changes: 73 additions & 4 deletions tests/test_specifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1104,11 +1104,12 @@ def test_specifier_hash_for_compatible_operator(self) -> None:


class TestSpecifierInternal:
"""Tests for internal Specifier._spec_version cache behavior.
"""Tests for Specifier's internal caches.

Specifier._spec_version is a one-element cache that stores the parsed Version
corresponding to Specifier.version after the first time it is needed for
comparison, these tests validate that the cache is set and never changed.
_spec_version holds the parsed Version for Specifier.version, populated the first
time a comparison needs it. _canonical_spec_cache holds the canonical
(operator, version) pair, populated on first read. Both are set once and never
changed afterwards, and neither survives __setstate__.
"""

@pytest.mark.parametrize(
Expand Down Expand Up @@ -1246,6 +1247,71 @@ def test_spec_version_cache_compatible_operator(
_ = spec.prereleases
assert spec._spec_version is initial_cache

@pytest.mark.parametrize(
("specifier", "expected"),
[
(">=1.0.0", (">=", "1")),
("<1.0.0", ("<", "1")),
("==1.2.3.0", ("==", "1.2.3")),
("!=1.0", ("!=", "1")),
("~=1.18.0", ("~=", "1.18.0")),
("~=1.18", ("~=", "1.18")),
("==1.0.*", ("==", "1.0.*")),
("===1.0.0", ("===", "1.0.0")),
("===not-a-version", ("===", "not-a-version")),
],
)
def test_canonical_spec_cache_consistency(
self, specifier: str, expected: tuple[str, str]
) -> None:
"""Cache is set on first read and remains unchanged."""
spec = Specifier(specifier)
assert spec._canonical_spec_cache is None

first = spec._canonical_spec
assert first == expected
assert spec._canonical_spec_cache is first

assert spec._canonical_spec is first
_ = hash(spec)
assert spec == Specifier(specifier)
assert spec._canonical_spec_cache is first

@pytest.mark.parametrize(
"specifier",
[">=1.0.0", "==1.2.3.0", "~=1.18.0", "==1.0.*", "===1.0.0"],
)
def test_canonical_spec_cache_matches_fresh_value(self, specifier: str) -> None:
"""A warmed Specifier is indistinguishable from a freshly built one."""
warm = Specifier(specifier)
_ = warm._canonical_spec

assert warm._canonical_spec == Specifier(specifier)._canonical_spec
assert hash(warm) == hash(Specifier(specifier))
assert warm == Specifier(specifier)

def test_canonical_spec_cache_not_pickled(self) -> None:
"""__getstate__ omits the cache, so it is rebuilt on the far side."""
spec = Specifier(">=1.0.0")
assert spec._canonical_spec == (">=", "1")

loaded = pickle.loads(pickle.dumps(spec))
assert loaded._canonical_spec_cache is None
assert loaded._canonical_spec == (">=", "1")
assert hash(loaded) == hash(spec)

def test_canonical_spec_cache_dropped_by_setstate(self) -> None:
"""__setstate__ replaces _spec, so a cache built from the old one must go."""
spec = Specifier(">=1.0.0")
assert spec._canonical_spec == (">=", "1")

spec.__setstate__(((">=", "9.9.0"), None))

assert spec._canonical_spec_cache is None
assert spec._canonical_spec == (">=", "9.9")
assert hash(spec) == hash(Specifier(">=9.9"))
assert spec == Specifier(">=9.9")


class TestSpecifierSet:
@pytest.mark.parametrize("version", VERSIONS)
Expand Down Expand Up @@ -3238,13 +3304,16 @@ def test_pickle_specifier_setstate_clears_cache() -> None:
s = Specifier("==1.*")
# Warm up every cache slot.
_ = s._to_ranges() # populates _spec_version + _ranges
_ = s._canonical_spec
assert s._spec_version is not None
assert s._ranges is not None
assert s._canonical_spec_cache is not None

s.__setstate__((("==", "1.*"), None))

assert s._spec_version is None
assert s._ranges is None
assert s._canonical_spec_cache is None


def test_pickle_specifierset_setstate_clears_cache() -> None:
Expand Down
Loading