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
38 changes: 34 additions & 4 deletions src/packaging/specifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -727,13 +727,21 @@ def filter(
# 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 operator_callable(parsed_version, self.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
Expand Down Expand Up @@ -898,7 +906,13 @@ class SpecifierSet(BaseSpecifier):
specifiers (``>=3.0,!=3.1``), or no specifier at all.
"""

__slots__ = ("_canonicalized", "_prereleases", "_resolved_ops", "_specs")
__slots__ = (
"_canonicalized",
"_has_arbitrary",
"_prereleases",
"_resolved_ops",
"_specs",
)

def __init__(
self,
Expand Down Expand Up @@ -928,8 +942,13 @@ def __init__(
split_specifiers = [s.strip() for s in specifiers.split(",") if s.strip()]

self._specs: tuple[Specifier, ...] = tuple(map(Specifier, split_specifiers))
# Fast substring check; avoids iterating parsed specs.
self._has_arbitrary = "===" in specifiers
else:
self._specs = tuple(specifiers)
# Substring check works for both Specifier objects and plain
# strings (setuptools passes lists of strings).
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
Expand Down Expand Up @@ -1025,6 +1044,7 @@ def __and__(self, other: SpecifierSet | str) -> SpecifierSet:
specifier = 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
Expand Down Expand Up @@ -1137,7 +1157,12 @@ def contains(
if version is not None and installed and version.is_prerelease:
prereleases = True

check_item = item if version is None else version
# When item is a string and === is involved, keep it as-is
# so the comparison isn't done against the normalized form.
if version is None or (self._has_arbitrary and not isinstance(item, Version)):
check_item = item
else:
check_item = version
return bool(list(self.filter([check_item], prereleases=prereleases)))

@typing.overload
Expand Down Expand Up @@ -1289,6 +1314,11 @@ def _filter_versions(
yield item
elif exclude_prereleases and parsed.is_prerelease:
pass
elif all(op_fn(parsed, ver) for op_fn, ver, _ in ops):
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
113 changes: 113 additions & 0 deletions tests/test_specifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,61 @@ def test_arbitrary_equality(
spec = Specifier(spec_str)
assert spec.contains(version) == expected

@pytest.mark.parametrize(
("spec_str", "version", "expected"),
[
# Zero padding: unnormalized spec vs string/Version
# Strings preserve their original form, so "1.01" != "1.1"
("===1.1", "1.01", False),
("===1.01", "1.1", False),
("===1.01", "1.01", True),
("===1.1", "1.1", True),
# Version objects are normalized, so Version("1.01") -> "1.1"
("===1.1", Version("1.01"), True),
("===1.1", Version("1.1"), True),
("===1.01", Version("1.01"), False),
("===1.01", Version("1.1"), False),
# Prerelease separator normalization (issue #766)
# "1.a1" is valid PEP 440, normalizes to "1a1"
("===1.a1", "1.a1", True),
("===1a1", "1.a1", False),
("===1.a1", "1a1", False),
("===1a1", "1a1", True),
("===1.a1", Version("1.a1"), False),
("===1a1", Version("1.a1"), True),
# Epoch normalization: "0!1.0" normalizes to "1.0"
("===0!1.0", "0!1.0", True),
("===0!1.0", "1.0", False),
("===1.0", "0!1.0", False),
("===0!1.0", Version("1.0"), False),
("===1.0", Version("0!1.0"), True),
# Leading zeros in release segments
("===01.0", "01.0", True),
("===01.0", "1.0", False),
("===1.0", "01.0", False),
("===01.0", Version("1.0"), False),
("===1.0", Version("01.0"), True),
# Post-release normalization: "post" vs "-" separator
("===1.0.post1", "1.0.post1", True),
("===1.0-1", "1.0-1", True),
("===1.0-1", "1.0.post1", False),
("===1.0.post1", "1.0-1", False),
("===1.0-1", Version("1.0.post1"), False),
("===1.0.post1", Version("1.0-1"), True),
# Dev normalization
("===1.0.dev01", "1.0.dev01", True),
("===1.0.dev01", "1.0.dev1", False),
("===1.0.dev1", "1.0.dev01", False),
("===1.0.dev01", Version("1.0.dev1"), False),
("===1.0.dev1", Version("1.0.dev01"), True),
],
)
def test_arbitrary_equality_normalization(
self, spec_str: str, version: str | Version, expected: bool
) -> None:
spec = Specifier(spec_str, prereleases=True)
assert spec.contains(version) == expected

@pytest.mark.parametrize(
("specifier", "expected"),
[
Expand Down Expand Up @@ -830,6 +885,33 @@ def test_specifier_filter(

assert result == expected

@pytest.mark.parametrize(
("specifier", "input", "expected"),
[
# Strings preserve original form
("===1.01", ["1.01", "1.1", "1.0.1"], ["1.01"]),
("===1.1", ["1.01", "1.1"], ["1.1"]),
# Version objects use normalized form
(
"===1.1",
[Version("1.01"), Version("1.1")],
[Version("1.01"), Version("1.1")],
),
("===1.01", [Version("1.01"), Version("1.1")], []),
# Mixed strings and Version objects
("===1.1", ["1.01", "1.1", Version("1.01")], ["1.1", Version("1.01")]),
("===1.01", ["1.01", "1.1", Version("1.01")], ["1.01"]),
# Prerelease separator
("===1.a1", ["1.a1", "1a1"], ["1.a1"]),
("===1a1", ["1.a1", "1a1", Version("1.a1")], ["1a1", Version("1.a1")]),
],
)
def test_specifier_filter_arbitrary_equality_normalization(
self, specifier: str, input: list[str | Version], expected: list[str | Version]
) -> None:
spec = Specifier(specifier, prereleases=True)
assert list(spec.filter(input)) == expected

@pytest.mark.parametrize(
("prereleases", "expected_indexes"),
[
Expand Down Expand Up @@ -1917,6 +1999,37 @@ def test_contains_arbitrary_equality_contains(
spec = SpecifierSet(specifier)
assert spec.contains(version) == expected

@pytest.mark.parametrize(
("spec_str", "version", "expected"),
[
# Zero padding: string preserves original, Version normalizes
("===1.1", "1.01", False),
("===1.01", "1.1", False),
("===1.01", "1.01", True),
("===1.1", "1.1", True),
("===1.1", Version("1.01"), True),
("===1.01", Version("1.01"), False),
# Prerelease separator normalization
("===1.a1", "1.a1", True),
("===1a1", "1.a1", False),
("===1.a1", "1a1", False),
("===1a1", "1a1", True),
("===1.a1", Version("1.a1"), False),
("===1a1", Version("1.a1"), True),
# Combined with other operators
(">=1.0,===1.01", "1.01", True),
(">=1.0,===1.1", "1.1", True),
(">=1.0,===1.1", "1.01", False),
(">=1.0,===1.1", Version("1.01"), True),
(">=1.0,===1.01", Version("1.01"), False),
],
)
def test_contains_arbitrary_equality_normalization(
self, spec_str: str, version: str | Version, expected: bool
) -> None:
spec = SpecifierSet(spec_str, prereleases=True)
assert spec.contains(version) == expected

@pytest.mark.parametrize(
("specifier", "expected"),
[
Expand Down
Loading