From 93a7824738eb144cee3beadbf7e43b034f365b00 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Fri, 31 Oct 2025 21:37:59 -0400 Subject: [PATCH 1/8] Support arbitrary equality on arbitrary strings --- src/packaging/specifiers.py | 37 +++++---- tests/test_specifiers.py | 154 ++++++++++++++++++++++++++++++++---- 2 files changed, 161 insertions(+), 30 deletions(-) diff --git a/src/packaging/specifiers.py b/src/packaging/specifiers.py index 3b15703e..bad81f97 100644 --- a/src/packaging/specifiers.py +++ b/src/packaging/specifiers.py @@ -278,7 +278,7 @@ def _get_spec_version(self, version: str) -> Version: return version_specifier @property - def prereleases(self) -> bool: + def prereleases(self) -> bool | None: # If there is an explicit prereleases set for this, then we'll just # blindly use that. if self._prereleases is not None: @@ -286,13 +286,19 @@ def prereleases(self) -> bool: # Only the "!=" operator does not imply prereleases when # the version in the specifier is a prerelease. - operator, version = self._spec + operator, item = self._spec if operator != "!=": # The == specifier with trailing .* cannot include prereleases # e.g. "==1.0a1.*" is not valid. - if operator == "==" and version.endswith(".*"): + if operator == "==" and item.endswith(".*"): return False + # "===" can have arbitrary string versions, so we cannot + # parse those, we take prereleases as False for those. + version = _coerce_version(item) + if version is None: + return None + # For all other operators, use the check if spec Version # object implies pre-releases. if self._get_spec_version(version).is_prerelease: @@ -537,7 +543,7 @@ def _compare_greater_than(self, prospective: Version, spec_str: str) -> bool: # same version in the spec. return True - def _compare_arbitrary(self, prospective: Version, spec: str) -> bool: + def _compare_arbitrary(self, prospective: Version | str, spec: str) -> bool: return str(prospective).lower() == str(spec).lower() def __contains__(self, item: str | Version) -> bool: @@ -627,9 +633,15 @@ def filter( for version in iterable: parsed_version = _coerce_version(version) if parsed_version is None: - continue - - if operator_callable(parsed_version, self.version): + # === operator can match arbitrary (non-version) strings + if self.operator == "===" and self._compare_arbitrary( + version, self.version + ): + yield version + # != operator: non-version strings pass through (they're "not equal") + elif self.operator == "!=": + yield version + elif operator_callable(parsed_version, self.version): # 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 @@ -944,13 +956,12 @@ def contains( True """ version = _coerce_version(item) - if version is None: - return False - if installed and version.is_prerelease: + if version is not None and installed and version.is_prerelease: prereleases = True - return bool(list(self.filter([version], prereleases=prereleases))) + check_item = item if version is None else version + return bool(list(self.filter([check_item], prereleases=prereleases))) def filter( self, iterable: Iterable[UnparsedVersionVar], prereleases: bool | None = None @@ -1030,9 +1041,7 @@ def filter( for item in iterable: parsed_version = _coerce_version(item) - if parsed_version is None: - continue - if parsed_version.is_prerelease: + if parsed_version is not None and parsed_version.is_prerelease: found_prereleases.append(item) else: filtered.append(item) diff --git a/tests/test_specifiers.py b/tests/test_specifiers.py index b44b9765..47f6641a 100644 --- a/tests/test_specifiers.py +++ b/tests/test_specifiers.py @@ -517,15 +517,26 @@ def test_specifiers(self, version: str, spec_str: str, expected: bool) -> None: assert not spec.contains(Version(version)) @pytest.mark.parametrize( - ("spec_str", "version"), + ("spec_str", "version", "expected"), [ - ("==1.0", "not a valid version"), - ("===invalid", "invalid"), + ("==1.0", "not a valid version", False), + (">=1.0", "not a valid version", False), + (">1.0", "not a valid version", False), + ("<=1.0", "not a valid version", False), + ("<1.0", "not a valid version", False), + ("~=1.0", "not a valid version", False), + # Test invalid versions with != (should pass as "not equal") + ("!=1.0", "not a valid version", True), + ("!=1.0", "not a valid version", True), + ("!=2.0.*", "not a valid version", True), + # Test with arbitrary equality (===) + ("===invalid", "invalid", True), + ("===foobar", "invalid", False), ], ) - def test_invalid_spec(self, spec_str: str, version: str) -> None: + def test_invalid_version(self, spec_str: str, version, expected: str) -> None: spec = Specifier(spec_str, prereleases=True) - assert not spec.contains(version) + assert spec.contains(version) == expected @pytest.mark.parametrize( ( @@ -588,9 +599,15 @@ def test_specifier_prereleases_set( [ ("1.0.0", "===1.0", False), ("1.0.dev0", "===1.0", False), - # Test identity comparison by itself + # Test exact arbitrary equality (===) ("1.0", "===1.0", True), ("1.0.dev0", "===1.0.dev0", True), + # Test that local versions don't match + ("1.0+downstream1", "===1.0", False), + ("1.0", "===1.0+downstream1", False), + # Test with arbitrary (non-version) strings + ("foobar", "===foobar", True), + ("foobar", "===baz", False), # Test case insensitivity for pre-release versions ("1.0a1", "===1.0a1", True), ("1.0A1", "===1.0A1", True), @@ -636,17 +653,11 @@ def test_specifier_prereleases_set( ("1.0A1.POST2.DEV3", "===1.0a1.post2.dev3", True), ], ) - def test_specifiers_identity( + def test_arbitrary_equality( self, version: str, spec_str: str, expected: bool ) -> None: spec = Specifier(spec_str) - - if expected: - # Identity comparisons only support the plain string form - assert version in spec - else: - # Identity comparisons only support the plain string form - assert version not in spec + assert spec.contains(version) == expected @pytest.mark.parametrize( ("specifier", "expected"), @@ -730,6 +741,33 @@ def test_specifiers_prereleases( # Test that invalid versions are discarded (">=1.0", None, None, ["not a valid version"], []), (">=1.0", None, None, ["1.0", "not a valid version"], ["1.0"]), + # Test arbitrary equality (===) + ("===foobar", None, None, ["foobar", "foo", "bar"], ["foobar"]), + ("===foobar", None, None, ["foo", "bar"], []), + # Test that === does not match with zero padding + ("===1.0", None, None, ["1.0", "1.0.0", "2.0"], ["1.0"]), + # Test that === does not match with local versions + ("===1.0", None, None, ["1.0", "1.0+downstream1"], ["1.0"]), + # Test === with mix of valid versions and arbitrary strings + ( + "===foobar", + None, + None, + ["foobar", "1.0", "2.0a1", "invalid"], + ["foobar"], + ), + ("===1.0", None, None, ["1.0", "foobar", "invalid", "1.0.0"], ["1.0"]), + # Test != with invalid versions (should pass through as "not equal") + ("!=1.0", None, None, ["invalid", "foobar"], ["invalid", "foobar"]), + ("!=1.0", None, None, ["1.0", "invalid", "2.0"], ["invalid", "2.0"]), + ( + "!=2.0.*", + None, + None, + ["invalid", "foobar", "2.0"], + ["invalid", "foobar"], + ), + ("!=2.0.*", None, None, ["1.0", "invalid", "2.0.0"], ["1.0", "invalid"]), ], ) def test_specifier_filter( @@ -1190,12 +1228,61 @@ def test_specifier_contains_installed_prereleases( (">=1.0,<=2.0dev", True, False, ["1.0", "1.5a1"], ["1.0"]), (">=1.0,<=2.0dev", False, True, ["1.0", "1.5a1"], ["1.0", "1.5a1"]), # Test that invalid versions are discarded - ("", None, None, ["invalid version"], []), + ("", None, None, ["invalid version"], ["invalid version"]), ("", None, False, ["invalid version"], []), ("", False, None, ["invalid version"], []), - ("", None, None, ["1.0", "invalid version"], ["1.0"]), + ("", None, None, ["1.0", "invalid version"], ["1.0", "invalid version"]), ("", None, False, ["1.0", "invalid version"], ["1.0"]), ("", False, None, ["1.0", "invalid version"], ["1.0"]), + # Test arbitrary equality (===) + ("===foobar", None, None, ["foobar", "foo", "bar"], ["foobar"]), + ("===foobar", None, None, ["foo", "bar"], []), + # Test that === does not match with zero padding + ("===1.0", None, None, ["1.0", "1.0.0", "2.0"], ["1.0"]), + # Test that === does not match with local versions + ("===1.0", None, None, ["1.0", "1.0+downstream1"], ["1.0"]), + # Test === combined with other operators (arbitrary string) + (">=1.0,===foobar", None, None, ["foobar", "1.0", "2.0"], []), + ("!= 2.0,===foobar", None, None, ["foobar", "2.0", "bar"], ["foobar"]), + # Test === combined with other operators (version string) + (">=1.0,===1.5", None, None, ["1.0", "1.5", "2.0"], ["1.5"]), + (">=2.0,===1.5", None, None, ["1.0", "1.5", "2.0"], []), + # Test === with mix of valid and invalid versions + ( + "===foobar", + None, + None, + ["foobar", "1.0", "invalid", "2.0a1"], + ["foobar"], + ), + ("===1.0", None, None, ["1.0", "foobar", "invalid", "1.0.0"], ["1.0"]), + (">=1.0,===1.5", None, None, ["1.5", "foobar", "invalid"], ["1.5"]), + # Test != with invalid versions (should pass through as "not equal") + ("!=1.0", None, None, ["invalid", "foobar"], ["invalid", "foobar"]), + ("!=1.0", None, None, ["1.0", "invalid", "2.0"], ["invalid", "2.0"]), + ( + "!=2.0.*", + None, + None, + ["invalid", "foobar", "2.0"], + ["invalid", "foobar"], + ), + ("!=2.0.*", None, None, ["1.0", "invalid", "2.0.0"], ["1.0", "invalid"]), + # Test != with invalid versions combined with other operators + ( + "!=1.0,!=2.0", + None, + None, + ["invalid", "1.0", "2.0", "3.0"], + ["invalid", "3.0"], + ), + ( + ">=1.0,!=2.0", + None, + None, + ["invalid", "1.0", "2.0", "3.0"], + ["1.0", "3.0"], + ), ], ) def test_specifier_filter( @@ -1591,6 +1678,41 @@ def test_contains_rejects_invalid_specifier( spec = SpecifierSet(specifier, prereleases=True) assert not spec.contains(input) + @pytest.mark.parametrize( + ("version", "specifier", "expected"), + [ + # Test arbitrary equality (===) with arbitrary strings + ("foobar", "===foobar", True), + ("foo", "===foobar", False), + ("bar", "===foobar", False), + # Test that === does not match with zero padding + ("1.0", "===1.0", True), + ("1.0.0", "===1.0", False), + # Test that === does not match with local versions + ("1.0", "===1.0+downstream1", False), + ("1.0+downstream1", "===1.0", False), + # Test === combined with other operators (arbitrary string) + ("foobar", "===foobar,!=1.0", True), + ("1.0", "===foobar,!=1.0", False), + ("foobar", ">=1.0,===foobar", False), + # Test === combined with other operators (version string) + ("1.5", ">=1.0,===1.5", True), + ("1.5", ">=2.0,===1.5", False), # Doesn't meet >=2.0 + ("2.5", ">=1.0,===2.5", True), + # Test != with invalid versions (should pass as "not equal") + ("invalid", "!=1.0", True), + ("foobar", "!=1.0", True), + ("invalid", "!=2.0.*", True), + # Test != with invalid versions combined with other operators + ("invalid", "!=1.0,!=2.0", True), + ("foobar", ">=1.0,!=2.0", False), + ("1.5", ">=1.0,!=2.0", True), + ], + ) + def test_contains_arbitrary_equality_contains(self, version, specifier, expected): + spec = SpecifierSet(specifier) + assert spec.contains(version) == expected + @pytest.mark.parametrize( ("specifier", "expected"), [ From 1e1da041e75a21d135f144af91f53870403d50cf Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Fri, 31 Oct 2025 22:43:20 -0400 Subject: [PATCH 2/8] Make empty specifier set consistent on pre-releases of non PEP-440 versions --- src/packaging/specifiers.py | 4 ++-- tests/test_specifiers.py | 48 +++++++++++++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/packaging/specifiers.py b/src/packaging/specifiers.py index bad81f97..c911e8f9 100644 --- a/src/packaging/specifiers.py +++ b/src/packaging/specifiers.py @@ -1030,8 +1030,8 @@ def filter( return ( item for item in iterable - if (version := _coerce_version(item)) is not None - and not version.is_prerelease + if (version := _coerce_version(item)) is None + or not version.is_prerelease ) # Finally if prereleases is None, apply PEP 440 logic: diff --git a/tests/test_specifiers.py b/tests/test_specifiers.py index 47f6641a..e7314f23 100644 --- a/tests/test_specifiers.py +++ b/tests/test_specifiers.py @@ -768,6 +768,20 @@ def test_specifiers_prereleases( ["invalid", "foobar"], ), ("!=2.0.*", None, None, ["1.0", "invalid", "2.0.0"], ["1.0", "invalid"]), + # Test that !== ignores prereleases parameter for non-PEP 440 versions + ("!=1.0", None, True, ["invalid", "foobar"], ["invalid", "foobar"]), + ("!=1.0", None, False, ["invalid", "foobar"], ["invalid", "foobar"]), + ("!=1.0", True, None, ["invalid", "foobar"], ["invalid", "foobar"]), + ("!=1.0", False, None, ["invalid", "foobar"], ["invalid", "foobar"]), + ("!=1.0", True, True, ["invalid", "foobar"], ["invalid", "foobar"]), + ("!=1.0", False, False, ["invalid", "foobar"], ["invalid", "foobar"]), + # Test that === ignores prereleases parameter for non-PEP 440 versions + ("===foobar", None, True, ["foobar", "foo"], ["foobar"]), + ("===foobar", None, False, ["foobar", "foo"], ["foobar"]), + ("===foobar", True, None, ["foobar", "foo"], ["foobar"]), + ("===foobar", False, None, ["foobar", "foo"], ["foobar"]), + ("===foobar", True, True, ["foobar", "foo"], ["foobar"]), + ("===foobar", False, False, ["foobar", "foo"], ["foobar"]), ], ) def test_specifier_filter( @@ -1006,6 +1020,16 @@ def test_empty_specifier(self, version: str) -> None: assert parse(version) in spec assert spec.contains(parse(version)) + @pytest.mark.parametrize( + "prereleases", + [None, False, True], + ) + def test_empty_specifier_arbitrary_string(self, prereleases): + """Test empty SpecifierSet accepts arbitrary strings.""" + + spec = SpecifierSet("", prereleases=prereleases) + assert spec.contains("foobar") + def test_create_from_specifiers(self) -> None: spec_strs = [">=1.0", "!=1.1", "!=1.2", "<2.0"] specs = [Specifier(s) for s in spec_strs] @@ -1227,13 +1251,13 @@ def test_specifier_contains_installed_prereleases( (">=1.0,<=2.0", False, True, ["1.0", "1.5a1"], ["1.0", "1.5a1"]), (">=1.0,<=2.0dev", True, False, ["1.0", "1.5a1"], ["1.0"]), (">=1.0,<=2.0dev", False, True, ["1.0", "1.5a1"], ["1.0", "1.5a1"]), - # Test that invalid versions are discarded + # Test that invalid versions are accepted by empty SpecifierSet ("", None, None, ["invalid version"], ["invalid version"]), - ("", None, False, ["invalid version"], []), - ("", False, None, ["invalid version"], []), + ("", None, False, ["invalid version"], ["invalid version"]), + ("", False, None, ["invalid version"], ["invalid version"]), ("", None, None, ["1.0", "invalid version"], ["1.0", "invalid version"]), - ("", None, False, ["1.0", "invalid version"], ["1.0"]), - ("", False, None, ["1.0", "invalid version"], ["1.0"]), + ("", None, False, ["1.0", "invalid version"], ["1.0", "invalid version"]), + ("", False, None, ["1.0", "invalid version"], ["1.0", "invalid version"]), # Test arbitrary equality (===) ("===foobar", None, None, ["foobar", "foo", "bar"], ["foobar"]), ("===foobar", None, None, ["foo", "bar"], []), @@ -1283,6 +1307,20 @@ def test_specifier_contains_installed_prereleases( ["invalid", "1.0", "2.0", "3.0"], ["1.0", "3.0"], ), + # Test that != ignores prereleases parameter for non-PEP 440 versions + ("!=1.0", None, True, ["invalid", "foobar"], ["invalid", "foobar"]), + ("!=1.0", None, False, ["invalid", "foobar"], ["invalid", "foobar"]), + ("!=1.0", True, None, ["invalid", "foobar"], ["invalid", "foobar"]), + ("!=1.0", False, None, ["invalid", "foobar"], ["invalid", "foobar"]), + ("!=1.0", True, True, ["invalid", "foobar"], ["invalid", "foobar"]), + ("!=1.0", False, False, ["invalid", "foobar"], ["invalid", "foobar"]), + # Test that === ignores prereleases parameter for non-PEP 440 versions + ("===foobar", None, True, ["foobar", "foo"], ["foobar"]), + ("===foobar", None, False, ["foobar", "foo"], ["foobar"]), + ("===foobar", True, None, ["foobar", "foo"], ["foobar"]), + ("===foobar", False, None, ["foobar", "foo"], ["foobar"]), + ("===foobar", True, True, ["foobar", "foo"], ["foobar"]), + ("===foobar", False, False, ["foobar", "foo"], ["foobar"]), ], ) def test_specifier_filter( From 5c3d8c58aa1d4fb22b0b4446a1601f477c6624c4 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Sun, 2 Nov 2025 13:14:46 -0500 Subject: [PATCH 3/8] Change `!=` to not allow invalid versions --- src/packaging/specifiers.py | 3 -- tests/test_specifiers.py | 60 ++++++++++++++++--------------------- 2 files changed, 26 insertions(+), 37 deletions(-) diff --git a/src/packaging/specifiers.py b/src/packaging/specifiers.py index c911e8f9..c003f97a 100644 --- a/src/packaging/specifiers.py +++ b/src/packaging/specifiers.py @@ -638,9 +638,6 @@ def filter( version, self.version ): yield version - # != operator: non-version strings pass through (they're "not equal") - elif self.operator == "!=": - yield version elif operator_callable(parsed_version, self.version): # If it's not a prerelease or prereleases are allowed, yield it directly if not parsed_version.is_prerelease or include_prereleases: diff --git a/tests/test_specifiers.py b/tests/test_specifiers.py index e7314f23..5cf33ddc 100644 --- a/tests/test_specifiers.py +++ b/tests/test_specifiers.py @@ -520,15 +520,14 @@ def test_specifiers(self, version: str, spec_str: str, expected: bool) -> None: ("spec_str", "version", "expected"), [ ("==1.0", "not a valid version", False), + ("==1.*", "not a valid version", False), (">=1.0", "not a valid version", False), (">1.0", "not a valid version", False), ("<=1.0", "not a valid version", False), ("<1.0", "not a valid version", False), ("~=1.0", "not a valid version", False), - # Test invalid versions with != (should pass as "not equal") - ("!=1.0", "not a valid version", True), - ("!=1.0", "not a valid version", True), - ("!=2.0.*", "not a valid version", True), + ("!=1.0", "not a valid version", False), + ("!=1.*", "not a valid version", False), # Test with arbitrary equality (===) ("===invalid", "invalid", True), ("===foobar", "invalid", False), @@ -757,24 +756,24 @@ def test_specifiers_prereleases( ["foobar"], ), ("===1.0", None, None, ["1.0", "foobar", "invalid", "1.0.0"], ["1.0"]), - # Test != with invalid versions (should pass through as "not equal") - ("!=1.0", None, None, ["invalid", "foobar"], ["invalid", "foobar"]), - ("!=1.0", None, None, ["1.0", "invalid", "2.0"], ["invalid", "2.0"]), + # Test != with invalid versions (should not pass as versions are not valid) + ("!=1.0", None, None, ["invalid", "foobar"], []), + ("!=1.0", None, None, ["1.0", "invalid", "2.0"], ["2.0"]), ( "!=2.0.*", None, None, ["invalid", "foobar", "2.0"], - ["invalid", "foobar"], + [] ), - ("!=2.0.*", None, None, ["1.0", "invalid", "2.0.0"], ["1.0", "invalid"]), + ("!=2.0.*", None, None, ["1.0", "invalid", "2.0.0"], ["1.0"]), # Test that !== ignores prereleases parameter for non-PEP 440 versions - ("!=1.0", None, True, ["invalid", "foobar"], ["invalid", "foobar"]), - ("!=1.0", None, False, ["invalid", "foobar"], ["invalid", "foobar"]), - ("!=1.0", True, None, ["invalid", "foobar"], ["invalid", "foobar"]), - ("!=1.0", False, None, ["invalid", "foobar"], ["invalid", "foobar"]), - ("!=1.0", True, True, ["invalid", "foobar"], ["invalid", "foobar"]), - ("!=1.0", False, False, ["invalid", "foobar"], ["invalid", "foobar"]), + ("!=1.0", None, True, ["invalid", "foobar"], []), + ("!=1.0", None, False, ["invalid", "foobar"], []), + ("!=1.0", True, None, ["invalid", "foobar"], []), + ("!=1.0", False, None, ["invalid", "foobar"], []), + ("!=1.0", True, True, ["invalid", "foobar"], []), + ("!=1.0", False, False, ["invalid", "foobar"], []), # Test that === ignores prereleases parameter for non-PEP 440 versions ("===foobar", None, True, ["foobar", "foo"], ["foobar"]), ("===foobar", None, False, ["foobar", "foo"], ["foobar"]), @@ -1267,7 +1266,7 @@ def test_specifier_contains_installed_prereleases( ("===1.0", None, None, ["1.0", "1.0+downstream1"], ["1.0"]), # Test === combined with other operators (arbitrary string) (">=1.0,===foobar", None, None, ["foobar", "1.0", "2.0"], []), - ("!= 2.0,===foobar", None, None, ["foobar", "2.0", "bar"], ["foobar"]), + ("!=2.0,===foobar", None, None, ["foobar", "2.0", "bar"], []), # Test === combined with other operators (version string) (">=1.0,===1.5", None, None, ["1.0", "1.5", "2.0"], ["1.5"]), (">=2.0,===1.5", None, None, ["1.0", "1.5", "2.0"], []), @@ -1282,23 +1281,23 @@ def test_specifier_contains_installed_prereleases( ("===1.0", None, None, ["1.0", "foobar", "invalid", "1.0.0"], ["1.0"]), (">=1.0,===1.5", None, None, ["1.5", "foobar", "invalid"], ["1.5"]), # Test != with invalid versions (should pass through as "not equal") - ("!=1.0", None, None, ["invalid", "foobar"], ["invalid", "foobar"]), - ("!=1.0", None, None, ["1.0", "invalid", "2.0"], ["invalid", "2.0"]), + ("!=1.0", None, None, ["invalid", "foobar"], []), + ("!=1.0", None, None, ["1.0", "invalid", "2.0"], ["2.0"]), ( "!=2.0.*", None, None, ["invalid", "foobar", "2.0"], - ["invalid", "foobar"], + [], ), - ("!=2.0.*", None, None, ["1.0", "invalid", "2.0.0"], ["1.0", "invalid"]), + ("!=2.0.*", None, None, ["1.0", "invalid", "2.0.0"], ["1.0"]), # Test != with invalid versions combined with other operators ( "!=1.0,!=2.0", None, None, ["invalid", "1.0", "2.0", "3.0"], - ["invalid", "3.0"], + ["3.0"], ), ( ">=1.0,!=2.0", @@ -1307,13 +1306,6 @@ def test_specifier_contains_installed_prereleases( ["invalid", "1.0", "2.0", "3.0"], ["1.0", "3.0"], ), - # Test that != ignores prereleases parameter for non-PEP 440 versions - ("!=1.0", None, True, ["invalid", "foobar"], ["invalid", "foobar"]), - ("!=1.0", None, False, ["invalid", "foobar"], ["invalid", "foobar"]), - ("!=1.0", True, None, ["invalid", "foobar"], ["invalid", "foobar"]), - ("!=1.0", False, None, ["invalid", "foobar"], ["invalid", "foobar"]), - ("!=1.0", True, True, ["invalid", "foobar"], ["invalid", "foobar"]), - ("!=1.0", False, False, ["invalid", "foobar"], ["invalid", "foobar"]), # Test that === ignores prereleases parameter for non-PEP 440 versions ("===foobar", None, True, ["foobar", "foo"], ["foobar"]), ("===foobar", None, False, ["foobar", "foo"], ["foobar"]), @@ -1730,19 +1722,19 @@ def test_contains_rejects_invalid_specifier( ("1.0", "===1.0+downstream1", False), ("1.0+downstream1", "===1.0", False), # Test === combined with other operators (arbitrary string) - ("foobar", "===foobar,!=1.0", True), + ("foobar", "===foobar,!=1.0", False), ("1.0", "===foobar,!=1.0", False), ("foobar", ">=1.0,===foobar", False), # Test === combined with other operators (version string) ("1.5", ">=1.0,===1.5", True), ("1.5", ">=2.0,===1.5", False), # Doesn't meet >=2.0 ("2.5", ">=1.0,===2.5", True), - # Test != with invalid versions (should pass as "not equal") - ("invalid", "!=1.0", True), - ("foobar", "!=1.0", True), - ("invalid", "!=2.0.*", True), + # Test != with invalid versions (should not pass as not valid versions) + ("invalid", "!=1.0", False), + ("foobar", "!=1.0", False), + ("invalid", "!=2.0.*", False), # Test != with invalid versions combined with other operators - ("invalid", "!=1.0,!=2.0", True), + ("invalid", "!=1.0,!=2.0", False), ("foobar", ">=1.0,!=2.0", False), ("1.5", ">=1.0,!=2.0", True), ], From 46faf8c076ee01ef20a39744c7540019a2c7a139 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Sun, 2 Nov 2025 13:38:45 -0500 Subject: [PATCH 4/8] Non-PEP440 versions shouldn't prevent prerelease versions in empty specifier --- src/packaging/specifiers.py | 16 ++++++++++++---- tests/test_specifiers.py | 35 ++++++++++++++++++++++++----------- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/packaging/specifiers.py b/src/packaging/specifiers.py index c003f97a..2c890bff 100644 --- a/src/packaging/specifiers.py +++ b/src/packaging/specifiers.py @@ -1033,14 +1033,22 @@ def filter( # Finally if prereleases is None, apply PEP 440 logic: # exclude prereleases unless there are no final releases that matched. - filtered: list[UnparsedVersionVar] = [] + filtered_items: list[UnparsedVersionVar] = [] found_prereleases: list[UnparsedVersionVar] = [] + found_final_release = False for item in iterable: parsed_version = _coerce_version(item) - if parsed_version is not None and parsed_version.is_prerelease: + # Arbitrary strings are always included as it is not + # possible to determine if they are prereleases, + # and they have already passed all specifiers. + if parsed_version is None: + filtered_items.append(item) + found_prereleases.append(item) + elif parsed_version.is_prerelease: found_prereleases.append(item) else: - filtered.append(item) + filtered_items.append(item) + found_final_release = True - return iter(filtered if filtered else found_prereleases) + return iter(filtered_items if found_final_release else found_prereleases) diff --git a/tests/test_specifiers.py b/tests/test_specifiers.py index 5cf33ddc..7a81efda 100644 --- a/tests/test_specifiers.py +++ b/tests/test_specifiers.py @@ -598,7 +598,7 @@ def test_specifier_prereleases_set( [ ("1.0.0", "===1.0", False), ("1.0.dev0", "===1.0", False), - # Test exact arbitrary equality (===) + # Test identity comparison by itself ("1.0", "===1.0", True), ("1.0.dev0", "===1.0.dev0", True), # Test that local versions don't match @@ -759,13 +759,7 @@ def test_specifiers_prereleases( # Test != with invalid versions (should not pass as versions are not valid) ("!=1.0", None, None, ["invalid", "foobar"], []), ("!=1.0", None, None, ["1.0", "invalid", "2.0"], ["2.0"]), - ( - "!=2.0.*", - None, - None, - ["invalid", "foobar", "2.0"], - [] - ), + ("!=2.0.*", None, None, ["invalid", "foobar", "2.0"], []), ("!=2.0.*", None, None, ["1.0", "invalid", "2.0.0"], ["1.0"]), # Test that !== ignores prereleases parameter for non-PEP 440 versions ("!=1.0", None, True, ["invalid", "foobar"], []), @@ -1020,15 +1014,34 @@ def test_empty_specifier(self, version: str) -> None: assert spec.contains(parse(version)) @pytest.mark.parametrize( - "prereleases", - [None, False, True], + ("prereleases", "versions", "expected"), + [ + # single arbitrary string + (None, ["foobar"], ["foobar"]), + (False, ["foobar"], ["foobar"]), + (True, ["foobar"], ["foobar"]), + # arbitrary string with a stable version present + (None, ["foobar", "1.0"], ["foobar", "1.0"]), + (False, ["foobar", "1.0"], ["foobar", "1.0"]), + (True, ["foobar", "1.0"], ["foobar", "1.0"]), + # arbitrary string with a prerelease only + (None, ["foobar", "1.0a1"], ["foobar", "1.0a1"]), + (False, ["foobar", "1.0a1"], ["foobar"]), + (True, ["foobar", "1.0a1"], ["foobar", "1.0a1"]), + ], ) - def test_empty_specifier_arbitrary_string(self, prereleases): + def test_empty_specifier_arbitrary_string(self, prereleases, versions, expected): """Test empty SpecifierSet accepts arbitrary strings.""" spec = SpecifierSet("", prereleases=prereleases) + + # basic behavior preserved assert spec.contains("foobar") + # check filter behavior (no override of prereleases passed to filter) + kwargs = {} + assert list(spec.filter(versions, **kwargs)) == expected + def test_create_from_specifiers(self) -> None: spec_strs = [">=1.0", "!=1.1", "!=1.2", "<2.0"] specs = [Specifier(s) for s in spec_strs] From 913ebc5bb3d8d953f83f1fd47f150dd7de6ed84c Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 25 Nov 2025 23:56:53 -0500 Subject: [PATCH 5/8] Add non-PEP 440 case insensitivity --- tests/test_specifiers.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_specifiers.py b/tests/test_specifiers.py index 7a81efda..282242b2 100644 --- a/tests/test_specifiers.py +++ b/tests/test_specifiers.py @@ -650,6 +650,11 @@ def test_specifier_prereleases_set( ("1.0A1.POST2.DEV3", "===1.0A1.POST2.DEV3", True), ("1.0a1.post2.dev3", "===1.0A1.POST2.DEV3", True), ("1.0A1.POST2.DEV3", "===1.0a1.post2.dev3", True), + # Test case insensitivity of non-PEP 440 versions + ("lolwat", "===LOLWAT", True), + ("lolwat", "===LoLWaT", True), + ("LOLWAT", "===lolwat", True), + ("LoLWaT", "===lOlwAt", True), ], ) def test_arbitrary_equality( From 621112e8a103ba1c9e512df8ef8a2686b9aa23e5 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Wed, 26 Nov 2025 00:02:05 -0500 Subject: [PATCH 6/8] Fix linting --- tests/test_specifiers.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_specifiers.py b/tests/test_specifiers.py index 282242b2..661f6730 100644 --- a/tests/test_specifiers.py +++ b/tests/test_specifiers.py @@ -533,7 +533,7 @@ def test_specifiers(self, version: str, spec_str: str, expected: bool) -> None: ("===foobar", "invalid", False), ], ) - def test_invalid_version(self, spec_str: str, version, expected: str) -> None: + def test_invalid_version(self, spec_str: str, version: str, expected: bool) -> None: spec = Specifier(spec_str, prereleases=True) assert spec.contains(version) == expected @@ -1035,7 +1035,9 @@ def test_empty_specifier(self, version: str) -> None: (True, ["foobar", "1.0a1"], ["foobar", "1.0a1"]), ], ) - def test_empty_specifier_arbitrary_string(self, prereleases, versions, expected): + def test_empty_specifier_arbitrary_string( + self, prereleases: bool | None, versions: list[str], expected: list[str] + ) -> None: """Test empty SpecifierSet accepts arbitrary strings.""" spec = SpecifierSet("", prereleases=prereleases) @@ -1044,7 +1046,7 @@ def test_empty_specifier_arbitrary_string(self, prereleases, versions, expected) assert spec.contains("foobar") # check filter behavior (no override of prereleases passed to filter) - kwargs = {} + kwargs: dict[str, bool | None] = {} assert list(spec.filter(versions, **kwargs)) == expected def test_create_from_specifiers(self) -> None: @@ -1757,7 +1759,9 @@ def test_contains_rejects_invalid_specifier( ("1.5", ">=1.0,!=2.0", True), ], ) - def test_contains_arbitrary_equality_contains(self, version, specifier, expected): + def test_contains_arbitrary_equality_contains( + self, version: str, specifier: str, expected: bool + ) -> None: spec = SpecifierSet(specifier) assert spec.contains(version) == expected From b1fbe73e99fa7caa7882351ca266ec3a669dd7db Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Sun, 30 Nov 2025 21:46:13 -0500 Subject: [PATCH 7/8] Add a `_require_spec_version` to pass type checking --- src/packaging/specifiers.py | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/packaging/specifiers.py b/src/packaging/specifiers.py index 2c890bff..c004330d 100644 --- a/src/packaging/specifiers.py +++ b/src/packaging/specifiers.py @@ -268,15 +268,28 @@ def __init__(self, spec: str = "", prereleases: bool | None = None) -> None: # Specifier version cache self._spec_version: tuple[str, Version] | None = None - def _get_spec_version(self, version: str) -> Version: + def _get_spec_version(self, version: str) -> Version | None: """One element cache, as only one spec Version is needed per Specifier.""" if self._spec_version is not None and self._spec_version[0] == version: return self._spec_version[1] - version_specifier = Version(version) + version_specifier = _coerce_version(version) + if version_specifier is None: + return None + self._spec_version = (version, version_specifier) return version_specifier + def _require_spec_version(self, version: str) -> Version: + """Get spec version, asserting it's valid (not for === operator). + + This method should only be called for operators where version + strings are guaranteed to be valid PEP 440 versions (not ===). + """ + spec_version = self._get_spec_version(version) + assert spec_version is not None + return spec_version + @property def prereleases(self) -> bool | None: # If there is an explicit prereleases set for this, then we'll just @@ -295,13 +308,13 @@ def prereleases(self) -> bool | None: # "===" can have arbitrary string versions, so we cannot # parse those, we take prereleases as False for those. - version = _coerce_version(item) + version = self._get_spec_version(item) if version is None: return None # For all other operators, use the check if spec Version # object implies pre-releases. - if self._get_spec_version(version).is_prerelease: + if version.is_prerelease: return True return False @@ -362,9 +375,10 @@ def _canonical_spec(self) -> tuple[str, str]: if operator == "===" or version.endswith(".*"): return operator, version + spec_version = self._require_spec_version(version) + canonical_version = canonicalize_version( - self._get_spec_version(version), - strip_trailing_zero=(operator != "~="), + spec_version, strip_trailing_zero=(operator != "~=") ) return operator, canonical_version @@ -457,7 +471,7 @@ def _compare_equal(self, prospective: Version, spec: str) -> bool: return shortened_prospective == split_spec else: # Convert our spec string into a Version - spec_version = self._get_spec_version(spec) + 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 @@ -474,18 +488,18 @@ 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._get_spec_version(spec) + 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._get_spec_version(spec) + 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._get_spec_version(spec_str) + 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 @@ -512,7 +526,7 @@ def _compare_less_than(self, prospective: Version, spec_str: str) -> bool: 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._get_spec_version(spec_str) + 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 From 16827d3eb205070a8ba3b08eac9d8c34c3ed740a Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Mon, 1 Dec 2025 19:13:47 -0500 Subject: [PATCH 8/8] Update item to version_str in prereleases property --- src/packaging/specifiers.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/packaging/specifiers.py b/src/packaging/specifiers.py index c004330d..5d26b0d1 100644 --- a/src/packaging/specifiers.py +++ b/src/packaging/specifiers.py @@ -299,16 +299,16 @@ def prereleases(self) -> bool | None: # Only the "!=" operator does not imply prereleases when # the version in the specifier is a prerelease. - operator, item = self._spec + operator, version_str = self._spec if operator != "!=": # The == specifier with trailing .* cannot include prereleases # e.g. "==1.0a1.*" is not valid. - if operator == "==" and item.endswith(".*"): + if operator == "==" and version_str.endswith(".*"): return False - # "===" can have arbitrary string versions, so we cannot - # parse those, we take prereleases as False for those. - version = self._get_spec_version(item) + # "===" can have arbitrary string versions, so we cannot parse + # those, we take prereleases as unknown (None) for those. + version = self._get_spec_version(version_str) if version is None: return None