Skip to content

Commit a70ead8

Browse files
authored
perf: use _get_spec_version in more places in Specifier (#1005)
1 parent 5bf7018 commit a70ead8

File tree

2 files changed

+149
-8
lines changed

2 files changed

+149
-8
lines changed

src/packaging/specifiers.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -288,14 +288,14 @@ def prereleases(self) -> bool:
288288
# the version in the specifier is a prerelease.
289289
operator, version = self._spec
290290
if operator != "!=":
291-
# The == specifier can include a trailing .*, if it does we
292-
# want to remove before parsing.
291+
# The == specifier with trailing .* cannot include prereleases
292+
# e.g. "==1.0a1.*" is not valid.
293293
if operator == "==" and version.endswith(".*"):
294-
version = version[:-2]
294+
return False
295295

296-
# Parse the version, and if it is a pre-release than this
297-
# specifier allows pre-releases.
298-
if Version(version).is_prerelease:
296+
# For all other operators, use the check if spec Version
297+
# object implies pre-releases.
298+
if self._get_spec_version(version).is_prerelease:
299299
return True
300300

301301
return False
@@ -353,11 +353,11 @@ def __str__(self) -> str:
353353
@property
354354
def _canonical_spec(self) -> tuple[str, str]:
355355
operator, version = self._spec
356-
if operator == "===":
356+
if operator == "===" or version.endswith(".*"):
357357
return operator, version
358358

359359
canonical_version = canonicalize_version(
360-
version,
360+
self._get_spec_version(version),
361361
strip_trailing_zero=(operator != "~="),
362362
)
363363

tests/test_specifiers.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -817,6 +817,147 @@ def test_specifier_hash_for_compatible_operator(self) -> None:
817817
assert hash(Specifier("~=1.18.0")) != hash(Specifier("~=1.18"))
818818

819819

820+
class TestSpecifierInternal:
821+
"""Tests for internal Specifier._spec_version cache behavior.
822+
823+
Specifier._spec_version is a one-element cache that stores the parsed Version
824+
corresponding to Specifier.version after the first time it is needed for
825+
comparison, these tests validate that the cache is set and never changed.
826+
"""
827+
828+
@pytest.mark.parametrize(
829+
("specifier", "test_versions"),
830+
[
831+
(">=1.0", ["0.9", "1.0", "1.1", "2.0"]),
832+
("<=1.0", ["0.9", "1.0", "1.1", "2.0"]),
833+
(">1.0", ["0.9", "1.0", "1.1", "2.0"]),
834+
("<1.0", ["0.9", "1.0", "1.1", "2.0"]),
835+
("==1.0", ["0.9", "1.0", "1.1", "2.0"]),
836+
("!=1.0", ["0.9", "1.0", "1.1", "2.0"]),
837+
("~=1.0", ["0.9", "1.0", "1.1", "2.0"]),
838+
(">=1.0a1", ["0.9", "1.0a1", "1.0", "1.1"]),
839+
(">=1.0.post1", ["0.9", "1.0", "1.0.post1", "1.1"]),
840+
(">=1.0.dev1", ["0.9", "1.0.dev1", "1.0", "1.1"]),
841+
("==1.0+local", ["1.0", "1.0+local", "1.0+other", "1.1"]),
842+
(">=1!1.0", ["0!2.0", "1!0.9", "1!1.0", "1!1.1"]),
843+
],
844+
)
845+
def test_spec_version_cache_consistency(
846+
self, specifier: str, test_versions: list[str]
847+
) -> None:
848+
"""Cache is set on first contains and remains unchanged."""
849+
spec = Specifier(specifier, prereleases=True)
850+
assert spec._spec_version is None
851+
852+
_ = test_versions[0] in spec
853+
assert spec._spec_version == (spec.version, Version(spec.version))
854+
initial_cache = spec._spec_version
855+
856+
for v in test_versions[1:]:
857+
_ = v in spec
858+
assert spec._spec_version is initial_cache
859+
860+
_ = hash(spec)
861+
assert spec._spec_version is initial_cache
862+
863+
_ = spec.prereleases
864+
assert spec._spec_version is initial_cache
865+
866+
_ = spec == Specifier(specifier)
867+
assert spec._spec_version is initial_cache
868+
869+
@pytest.mark.parametrize(
870+
("specifier", "test_versions"),
871+
[
872+
(
873+
"==1.0.*",
874+
["0.9", "1.0", "1.0.1", "1.0a1", "1.0.dev1", "1.0.post1", "1.0+local"],
875+
),
876+
(
877+
"!=1.0.*",
878+
["0.9", "1.0", "1.0.1", "1.0a1", "1.0.dev1", "1.0.post1", "1.0+local"],
879+
),
880+
],
881+
)
882+
def test_spec_version_cache_with_wildcards(
883+
self, specifier: str, test_versions: list[str]
884+
) -> None:
885+
"""Wildcard specifiers use prefix matching, cache stays None."""
886+
spec = Specifier(specifier, prereleases=True)
887+
888+
for v in test_versions:
889+
_ = v in spec
890+
_ = spec.prereleases
891+
_ = hash(spec)
892+
893+
assert spec._spec_version is None
894+
895+
@pytest.mark.parametrize(
896+
"specifier",
897+
[
898+
"===1.0",
899+
"===1.0.0+local",
900+
"===1.0.dev1",
901+
],
902+
)
903+
def test_spec_version_cache_with_arbitrary_equality(self, specifier: str) -> None:
904+
spec = Specifier(specifier)
905+
906+
_ = "1.0" in spec
907+
_ = spec.prereleases
908+
_ = hash(spec)
909+
910+
assert spec._spec_version == (spec.version, Version(spec.version))
911+
912+
@pytest.mark.parametrize(
913+
("specifier", "versions"),
914+
[
915+
(
916+
"~=1.4.2",
917+
[
918+
# Matching versions
919+
"1.4.2",
920+
"1.4.3.dev1",
921+
"1.4.3a1",
922+
"1.4.3",
923+
"1.4.3.post1",
924+
"1.4.3+local",
925+
# Not matching versions
926+
"1.4.1",
927+
"1.4.1.post1",
928+
"1.5.0.dev0",
929+
"1.5.0a1",
930+
"1.5.0",
931+
"2.0",
932+
"2.0+local",
933+
],
934+
),
935+
],
936+
)
937+
def test_spec_version_cache_compatible_operator(
938+
self,
939+
specifier: str,
940+
versions: list[str],
941+
) -> None:
942+
"""~= caches the original spec version, not the prefix used for ==."""
943+
spec = Specifier(specifier, prereleases=True)
944+
assert spec._spec_version is None
945+
946+
assert versions[0] in spec
947+
assert spec._spec_version == (spec.version, Version(spec.version))
948+
initial_cache = spec._spec_version
949+
950+
for v in versions[1:]:
951+
_ = v in spec
952+
assert spec._spec_version is initial_cache
953+
954+
_ = hash(spec)
955+
assert spec._spec_version is initial_cache
956+
957+
_ = spec.prereleases
958+
assert spec._spec_version is initial_cache
959+
960+
820961
class TestSpecifierSet:
821962
@pytest.mark.parametrize("version", VERSIONS)
822963
def test_empty_specifier(self, version: str) -> None:

0 commit comments

Comments
 (0)