diff --git a/src/packaging/specifiers.py b/src/packaging/specifiers.py index 3b15703e..5d26b0d1 100644 --- a/src/packaging/specifiers.py +++ b/src/packaging/specifiers.py @@ -268,17 +268,30 @@ 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: + 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,16 +299,22 @@ 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, version_str = 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 version_str.endswith(".*"): return False + # "===" 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 + # 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 @@ -356,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 @@ -451,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 @@ -468,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 @@ -506,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 @@ -537,7 +557,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 +647,12 @@ 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 + 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 +967,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 @@ -1019,22 +1041,28 @@ 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: # 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) + # 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: - continue - if parsed_version.is_prerelease: + 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 b44b9765..661f6730 100644 --- a/tests/test_specifiers.py +++ b/tests/test_specifiers.py @@ -517,15 +517,25 @@ 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.*", "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), + ("!=1.0", "not a valid version", False), + ("!=1.*", "not a valid version", False), + # 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: str, expected: bool) -> None: spec = Specifier(spec_str, prereleases=True) - assert not spec.contains(version) + assert spec.contains(version) == expected @pytest.mark.parametrize( ( @@ -591,6 +601,12 @@ def test_specifier_prereleases_set( # Test identity comparison by itself ("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), @@ -634,19 +650,18 @@ 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_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 +745,41 @@ 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 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, ["1.0", "invalid", "2.0.0"], ["1.0"]), + # Test that !== ignores prereleases parameter for non-PEP 440 versions + ("!=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"]), + ("===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( @@ -968,6 +1018,37 @@ def test_empty_specifier(self, version: str) -> None: assert parse(version) in spec assert spec.contains(parse(version)) + @pytest.mark.parametrize( + ("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: bool | None, versions: list[str], expected: list[str] + ) -> None: + """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: dict[str, bool | None] = {} + 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] @@ -1189,13 +1270,69 @@ 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 - ("", None, None, ["invalid version"], []), - ("", None, False, ["invalid version"], []), - ("", False, None, ["invalid version"], []), - ("", None, None, ["1.0", "invalid version"], ["1.0"]), - ("", None, False, ["1.0", "invalid version"], ["1.0"]), - ("", False, None, ["1.0", "invalid version"], ["1.0"]), + # Test that invalid versions are accepted by empty SpecifierSet + ("", None, None, ["invalid version"], ["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", "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"], []), + # 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"], []), + # 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"], []), + ("!=1.0", None, None, ["1.0", "invalid", "2.0"], ["2.0"]), + ( + "!=2.0.*", + None, + None, + ["invalid", "foobar", "2.0"], + [], + ), + ("!=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"], + ["3.0"], + ), + ( + ">=1.0,!=2.0", + None, + None, + ["invalid", "1.0", "2.0", "3.0"], + ["1.0", "3.0"], + ), + # 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( @@ -1591,6 +1728,43 @@ 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", 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 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", False), + ("foobar", ">=1.0,!=2.0", False), + ("1.5", ">=1.0,!=2.0", True), + ], + ) + def test_contains_arbitrary_equality_contains( + self, version: str, specifier: str, expected: bool + ) -> None: + spec = SpecifierSet(specifier) + assert spec.contains(version) == expected + @pytest.mark.parametrize( ("specifier", "expected"), [