jpayne@68: # This file is dual licensed under the terms of the Apache License, Version jpayne@68: # 2.0, and the BSD License. See the LICENSE file in the root of this repository jpayne@68: # for complete details. jpayne@68: """ jpayne@68: .. testsetup:: jpayne@68: jpayne@68: from packaging.specifiers import Specifier, SpecifierSet, InvalidSpecifier jpayne@68: from packaging.version import Version jpayne@68: """ jpayne@68: jpayne@68: from __future__ import annotations jpayne@68: jpayne@68: import abc jpayne@68: import itertools jpayne@68: import re jpayne@68: from typing import Callable, Iterable, Iterator, TypeVar, Union jpayne@68: jpayne@68: from .utils import canonicalize_version jpayne@68: from .version import Version jpayne@68: jpayne@68: UnparsedVersion = Union[Version, str] jpayne@68: UnparsedVersionVar = TypeVar("UnparsedVersionVar", bound=UnparsedVersion) jpayne@68: CallableOperator = Callable[[Version, str], bool] jpayne@68: jpayne@68: jpayne@68: def _coerce_version(version: UnparsedVersion) -> Version: jpayne@68: if not isinstance(version, Version): jpayne@68: version = Version(version) jpayne@68: return version jpayne@68: jpayne@68: jpayne@68: class InvalidSpecifier(ValueError): jpayne@68: """ jpayne@68: Raised when attempting to create a :class:`Specifier` with a specifier jpayne@68: string that is invalid. jpayne@68: jpayne@68: >>> Specifier("lolwat") jpayne@68: Traceback (most recent call last): jpayne@68: ... jpayne@68: packaging.specifiers.InvalidSpecifier: Invalid specifier: 'lolwat' jpayne@68: """ jpayne@68: jpayne@68: jpayne@68: class BaseSpecifier(metaclass=abc.ABCMeta): jpayne@68: @abc.abstractmethod jpayne@68: def __str__(self) -> str: jpayne@68: """ jpayne@68: Returns the str representation of this Specifier-like object. This jpayne@68: should be representative of the Specifier itself. jpayne@68: """ jpayne@68: jpayne@68: @abc.abstractmethod jpayne@68: def __hash__(self) -> int: jpayne@68: """ jpayne@68: Returns a hash value for this Specifier-like object. jpayne@68: """ jpayne@68: jpayne@68: @abc.abstractmethod jpayne@68: def __eq__(self, other: object) -> bool: jpayne@68: """ jpayne@68: Returns a boolean representing whether or not the two Specifier-like jpayne@68: objects are equal. jpayne@68: jpayne@68: :param other: The other object to check against. jpayne@68: """ jpayne@68: jpayne@68: @property jpayne@68: @abc.abstractmethod jpayne@68: def prereleases(self) -> bool | None: jpayne@68: """Whether or not pre-releases as a whole are allowed. jpayne@68: jpayne@68: This can be set to either ``True`` or ``False`` to explicitly enable or disable jpayne@68: prereleases or it can be set to ``None`` (the default) to use default semantics. jpayne@68: """ jpayne@68: jpayne@68: @prereleases.setter jpayne@68: def prereleases(self, value: bool) -> None: jpayne@68: """Setter for :attr:`prereleases`. jpayne@68: jpayne@68: :param value: The value to set. jpayne@68: """ jpayne@68: jpayne@68: @abc.abstractmethod jpayne@68: def contains(self, item: str, prereleases: bool | None = None) -> bool: jpayne@68: """ jpayne@68: Determines if the given item is contained within this specifier. jpayne@68: """ jpayne@68: jpayne@68: @abc.abstractmethod jpayne@68: def filter( jpayne@68: self, iterable: Iterable[UnparsedVersionVar], prereleases: bool | None = None jpayne@68: ) -> Iterator[UnparsedVersionVar]: jpayne@68: """ jpayne@68: Takes an iterable of items and filters them so that only items which jpayne@68: are contained within this specifier are allowed in it. jpayne@68: """ jpayne@68: jpayne@68: jpayne@68: class Specifier(BaseSpecifier): jpayne@68: """This class abstracts handling of version specifiers. jpayne@68: jpayne@68: .. tip:: jpayne@68: jpayne@68: It is generally not required to instantiate this manually. You should instead jpayne@68: prefer to work with :class:`SpecifierSet` instead, which can parse jpayne@68: comma-separated version specifiers (which is what package metadata contains). jpayne@68: """ jpayne@68: jpayne@68: _operator_regex_str = r""" jpayne@68: (?P(~=|==|!=|<=|>=|<|>|===)) jpayne@68: """ jpayne@68: _version_regex_str = r""" jpayne@68: (?P jpayne@68: (?: jpayne@68: # The identity operators allow for an escape hatch that will jpayne@68: # do an exact string match of the version you wish to install. jpayne@68: # This will not be parsed by PEP 440 and we cannot determine jpayne@68: # any semantic meaning from it. This operator is discouraged jpayne@68: # but included entirely as an escape hatch. jpayne@68: (?<====) # Only match for the identity operator jpayne@68: \s* jpayne@68: [^\s;)]* # The arbitrary version can be just about anything, jpayne@68: # we match everything except for whitespace, a jpayne@68: # semi-colon for marker support, and a closing paren jpayne@68: # since versions can be enclosed in them. jpayne@68: ) jpayne@68: | jpayne@68: (?: jpayne@68: # The (non)equality operators allow for wild card and local jpayne@68: # versions to be specified so we have to define these two jpayne@68: # operators separately to enable that. jpayne@68: (?<===|!=) # Only match for equals and not equals jpayne@68: jpayne@68: \s* jpayne@68: v? jpayne@68: (?:[0-9]+!)? # epoch jpayne@68: [0-9]+(?:\.[0-9]+)* # release jpayne@68: jpayne@68: # You cannot use a wild card and a pre-release, post-release, a dev or jpayne@68: # local version together so group them with a | and make them optional. jpayne@68: (?: jpayne@68: \.\* # Wild card syntax of .* jpayne@68: | jpayne@68: (?: # pre release jpayne@68: [-_\.]? jpayne@68: (alpha|beta|preview|pre|a|b|c|rc) jpayne@68: [-_\.]? jpayne@68: [0-9]* jpayne@68: )? jpayne@68: (?: # post release jpayne@68: (?:-[0-9]+)|(?:[-_\.]?(post|rev|r)[-_\.]?[0-9]*) jpayne@68: )? jpayne@68: (?:[-_\.]?dev[-_\.]?[0-9]*)? # dev release jpayne@68: (?:\+[a-z0-9]+(?:[-_\.][a-z0-9]+)*)? # local jpayne@68: )? jpayne@68: ) jpayne@68: | jpayne@68: (?: jpayne@68: # The compatible operator requires at least two digits in the jpayne@68: # release segment. jpayne@68: (?<=~=) # Only match for the compatible operator jpayne@68: jpayne@68: \s* jpayne@68: v? jpayne@68: (?:[0-9]+!)? # epoch jpayne@68: [0-9]+(?:\.[0-9]+)+ # release (We have a + instead of a *) jpayne@68: (?: # pre release jpayne@68: [-_\.]? jpayne@68: (alpha|beta|preview|pre|a|b|c|rc) jpayne@68: [-_\.]? jpayne@68: [0-9]* jpayne@68: )? jpayne@68: (?: # post release jpayne@68: (?:-[0-9]+)|(?:[-_\.]?(post|rev|r)[-_\.]?[0-9]*) jpayne@68: )? jpayne@68: (?:[-_\.]?dev[-_\.]?[0-9]*)? # dev release jpayne@68: ) jpayne@68: | jpayne@68: (?: jpayne@68: # All other operators only allow a sub set of what the jpayne@68: # (non)equality operators do. Specifically they do not allow jpayne@68: # local versions to be specified nor do they allow the prefix jpayne@68: # matching wild cards. jpayne@68: (?=": "greater_than_equal", jpayne@68: "<": "less_than", jpayne@68: ">": "greater_than", jpayne@68: "===": "arbitrary", jpayne@68: } jpayne@68: jpayne@68: def __init__(self, spec: str = "", prereleases: bool | None = None) -> None: jpayne@68: """Initialize a Specifier instance. jpayne@68: jpayne@68: :param spec: jpayne@68: The string representation of a specifier which will be parsed and jpayne@68: normalized before use. jpayne@68: :param prereleases: jpayne@68: This tells the specifier if it should accept prerelease versions if jpayne@68: applicable or not. The default of ``None`` will autodetect it from the jpayne@68: given specifiers. jpayne@68: :raises InvalidSpecifier: jpayne@68: If the given specifier is invalid (i.e. bad syntax). jpayne@68: """ jpayne@68: match = self._regex.search(spec) jpayne@68: if not match: jpayne@68: raise InvalidSpecifier(f"Invalid specifier: {spec!r}") jpayne@68: jpayne@68: self._spec: tuple[str, str] = ( jpayne@68: match.group("operator").strip(), jpayne@68: match.group("version").strip(), jpayne@68: ) jpayne@68: jpayne@68: # Store whether or not this Specifier should accept prereleases jpayne@68: self._prereleases = prereleases jpayne@68: jpayne@68: # https://github.com/python/mypy/pull/13475#pullrequestreview-1079784515 jpayne@68: @property # type: ignore[override] jpayne@68: def prereleases(self) -> bool: jpayne@68: # If there is an explicit prereleases set for this, then we'll just jpayne@68: # blindly use that. jpayne@68: if self._prereleases is not None: jpayne@68: return self._prereleases jpayne@68: jpayne@68: # Look at all of our specifiers and determine if they are inclusive jpayne@68: # operators, and if they are if they are including an explicit jpayne@68: # prerelease. jpayne@68: operator, version = self._spec jpayne@68: if operator in ["==", ">=", "<=", "~=", "===", ">", "<"]: jpayne@68: # The == specifier can include a trailing .*, if it does we jpayne@68: # want to remove before parsing. jpayne@68: if operator == "==" and version.endswith(".*"): jpayne@68: version = version[:-2] jpayne@68: jpayne@68: # Parse the version, and if it is a pre-release than this jpayne@68: # specifier allows pre-releases. jpayne@68: if Version(version).is_prerelease: jpayne@68: return True jpayne@68: jpayne@68: return False jpayne@68: jpayne@68: @prereleases.setter jpayne@68: def prereleases(self, value: bool) -> None: jpayne@68: self._prereleases = value jpayne@68: jpayne@68: @property jpayne@68: def operator(self) -> str: jpayne@68: """The operator of this specifier. jpayne@68: jpayne@68: >>> Specifier("==1.2.3").operator jpayne@68: '==' jpayne@68: """ jpayne@68: return self._spec[0] jpayne@68: jpayne@68: @property jpayne@68: def version(self) -> str: jpayne@68: """The version of this specifier. jpayne@68: jpayne@68: >>> Specifier("==1.2.3").version jpayne@68: '1.2.3' jpayne@68: """ jpayne@68: return self._spec[1] jpayne@68: jpayne@68: def __repr__(self) -> str: jpayne@68: """A representation of the Specifier that shows all internal state. jpayne@68: jpayne@68: >>> Specifier('>=1.0.0') jpayne@68: =1.0.0')> jpayne@68: >>> Specifier('>=1.0.0', prereleases=False) jpayne@68: =1.0.0', prereleases=False)> jpayne@68: >>> Specifier('>=1.0.0', prereleases=True) jpayne@68: =1.0.0', prereleases=True)> jpayne@68: """ jpayne@68: pre = ( jpayne@68: f", prereleases={self.prereleases!r}" jpayne@68: if self._prereleases is not None jpayne@68: else "" jpayne@68: ) jpayne@68: jpayne@68: return f"<{self.__class__.__name__}({str(self)!r}{pre})>" jpayne@68: jpayne@68: def __str__(self) -> str: jpayne@68: """A string representation of the Specifier that can be round-tripped. jpayne@68: jpayne@68: >>> str(Specifier('>=1.0.0')) jpayne@68: '>=1.0.0' jpayne@68: >>> str(Specifier('>=1.0.0', prereleases=False)) jpayne@68: '>=1.0.0' jpayne@68: """ jpayne@68: return "{}{}".format(*self._spec) jpayne@68: jpayne@68: @property jpayne@68: def _canonical_spec(self) -> tuple[str, str]: jpayne@68: canonical_version = canonicalize_version( jpayne@68: self._spec[1], jpayne@68: strip_trailing_zero=(self._spec[0] != "~="), jpayne@68: ) jpayne@68: return self._spec[0], canonical_version jpayne@68: jpayne@68: def __hash__(self) -> int: jpayne@68: return hash(self._canonical_spec) jpayne@68: jpayne@68: def __eq__(self, other: object) -> bool: jpayne@68: """Whether or not the two Specifier-like objects are equal. jpayne@68: jpayne@68: :param other: The other object to check against. jpayne@68: jpayne@68: The value of :attr:`prereleases` is ignored. jpayne@68: jpayne@68: >>> Specifier("==1.2.3") == Specifier("== 1.2.3.0") jpayne@68: True jpayne@68: >>> (Specifier("==1.2.3", prereleases=False) == jpayne@68: ... Specifier("==1.2.3", prereleases=True)) jpayne@68: True jpayne@68: >>> Specifier("==1.2.3") == "==1.2.3" jpayne@68: True jpayne@68: >>> Specifier("==1.2.3") == Specifier("==1.2.4") jpayne@68: False jpayne@68: >>> Specifier("==1.2.3") == Specifier("~=1.2.3") jpayne@68: False jpayne@68: """ jpayne@68: if isinstance(other, str): jpayne@68: try: jpayne@68: other = self.__class__(str(other)) jpayne@68: except InvalidSpecifier: jpayne@68: return NotImplemented jpayne@68: elif not isinstance(other, self.__class__): jpayne@68: return NotImplemented jpayne@68: jpayne@68: return self._canonical_spec == other._canonical_spec jpayne@68: jpayne@68: def _get_operator(self, op: str) -> CallableOperator: jpayne@68: operator_callable: CallableOperator = getattr( jpayne@68: self, f"_compare_{self._operators[op]}" jpayne@68: ) jpayne@68: return operator_callable jpayne@68: jpayne@68: def _compare_compatible(self, prospective: Version, spec: str) -> bool: jpayne@68: # Compatible releases have an equivalent combination of >= and ==. That jpayne@68: # is that ~=2.2 is equivalent to >=2.2,==2.*. This allows us to jpayne@68: # implement this in terms of the other specifiers instead of jpayne@68: # implementing it ourselves. The only thing we need to do is construct jpayne@68: # the other specifiers. jpayne@68: jpayne@68: # We want everything but the last item in the version, but we want to jpayne@68: # ignore suffix segments. jpayne@68: prefix = _version_join( jpayne@68: list(itertools.takewhile(_is_not_suffix, _version_split(spec)))[:-1] jpayne@68: ) jpayne@68: jpayne@68: # Add the prefix notation to the end of our string jpayne@68: prefix += ".*" jpayne@68: jpayne@68: return self._get_operator(">=")(prospective, spec) and self._get_operator("==")( jpayne@68: prospective, prefix jpayne@68: ) jpayne@68: jpayne@68: def _compare_equal(self, prospective: Version, spec: str) -> bool: jpayne@68: # We need special logic to handle prefix matching jpayne@68: if spec.endswith(".*"): jpayne@68: # In the case of prefix matching we want to ignore local segment. jpayne@68: normalized_prospective = canonicalize_version( jpayne@68: prospective.public, strip_trailing_zero=False jpayne@68: ) jpayne@68: # Get the normalized version string ignoring the trailing .* jpayne@68: normalized_spec = canonicalize_version(spec[:-2], strip_trailing_zero=False) jpayne@68: # Split the spec out by bangs and dots, and pretend that there is jpayne@68: # an implicit dot in between a release segment and a pre-release segment. jpayne@68: split_spec = _version_split(normalized_spec) jpayne@68: jpayne@68: # Split the prospective version out by bangs and dots, and pretend jpayne@68: # that there is an implicit dot in between a release segment and jpayne@68: # a pre-release segment. jpayne@68: split_prospective = _version_split(normalized_prospective) jpayne@68: jpayne@68: # 0-pad the prospective version before shortening it to get the correct jpayne@68: # shortened version. jpayne@68: padded_prospective, _ = _pad_version(split_prospective, split_spec) jpayne@68: jpayne@68: # Shorten the prospective version to be the same length as the spec jpayne@68: # so that we can determine if the specifier is a prefix of the jpayne@68: # prospective version or not. jpayne@68: shortened_prospective = padded_prospective[: len(split_spec)] jpayne@68: jpayne@68: return shortened_prospective == split_spec jpayne@68: else: jpayne@68: # Convert our spec string into a Version jpayne@68: spec_version = Version(spec) jpayne@68: jpayne@68: # If the specifier does not have a local segment, then we want to jpayne@68: # act as if the prospective version also does not have a local jpayne@68: # segment. jpayne@68: if not spec_version.local: jpayne@68: prospective = Version(prospective.public) jpayne@68: jpayne@68: return prospective == spec_version jpayne@68: jpayne@68: def _compare_not_equal(self, prospective: Version, spec: str) -> bool: jpayne@68: return not self._compare_equal(prospective, spec) jpayne@68: jpayne@68: def _compare_less_than_equal(self, prospective: Version, spec: str) -> bool: jpayne@68: # NB: Local version identifiers are NOT permitted in the version jpayne@68: # specifier, so local version labels can be universally removed from jpayne@68: # the prospective version. jpayne@68: return Version(prospective.public) <= Version(spec) jpayne@68: jpayne@68: def _compare_greater_than_equal(self, prospective: Version, spec: str) -> bool: jpayne@68: # NB: Local version identifiers are NOT permitted in the version jpayne@68: # specifier, so local version labels can be universally removed from jpayne@68: # the prospective version. jpayne@68: return Version(prospective.public) >= Version(spec) jpayne@68: jpayne@68: def _compare_less_than(self, prospective: Version, spec_str: str) -> bool: jpayne@68: # Convert our spec to a Version instance, since we'll want to work with jpayne@68: # it as a version. jpayne@68: spec = Version(spec_str) jpayne@68: jpayne@68: # Check to see if the prospective version is less than the spec jpayne@68: # version. If it's not we can short circuit and just return False now jpayne@68: # instead of doing extra unneeded work. jpayne@68: if not prospective < spec: jpayne@68: return False jpayne@68: jpayne@68: # This special case is here so that, unless the specifier itself jpayne@68: # includes is a pre-release version, that we do not accept pre-release jpayne@68: # versions for the version mentioned in the specifier (e.g. <3.1 should jpayne@68: # not match 3.1.dev0, but should match 3.0.dev0). jpayne@68: if not spec.is_prerelease and prospective.is_prerelease: jpayne@68: if Version(prospective.base_version) == Version(spec.base_version): jpayne@68: return False jpayne@68: jpayne@68: # If we've gotten to here, it means that prospective version is both jpayne@68: # less than the spec version *and* it's not a pre-release of the same jpayne@68: # version in the spec. jpayne@68: return True jpayne@68: jpayne@68: def _compare_greater_than(self, prospective: Version, spec_str: str) -> bool: jpayne@68: # Convert our spec to a Version instance, since we'll want to work with jpayne@68: # it as a version. jpayne@68: spec = Version(spec_str) jpayne@68: jpayne@68: # Check to see if the prospective version is greater than the spec jpayne@68: # version. If it's not we can short circuit and just return False now jpayne@68: # instead of doing extra unneeded work. jpayne@68: if not prospective > spec: jpayne@68: return False jpayne@68: jpayne@68: # This special case is here so that, unless the specifier itself jpayne@68: # includes is a post-release version, that we do not accept jpayne@68: # post-release versions for the version mentioned in the specifier jpayne@68: # (e.g. >3.1 should not match 3.0.post0, but should match 3.2.post0). jpayne@68: if not spec.is_postrelease and prospective.is_postrelease: jpayne@68: if Version(prospective.base_version) == Version(spec.base_version): jpayne@68: return False jpayne@68: jpayne@68: # Ensure that we do not allow a local version of the version mentioned jpayne@68: # in the specifier, which is technically greater than, to match. jpayne@68: if prospective.local is not None: jpayne@68: if Version(prospective.base_version) == Version(spec.base_version): jpayne@68: return False jpayne@68: jpayne@68: # If we've gotten to here, it means that prospective version is both jpayne@68: # greater than the spec version *and* it's not a pre-release of the jpayne@68: # same version in the spec. jpayne@68: return True jpayne@68: jpayne@68: def _compare_arbitrary(self, prospective: Version, spec: str) -> bool: jpayne@68: return str(prospective).lower() == str(spec).lower() jpayne@68: jpayne@68: def __contains__(self, item: str | Version) -> bool: jpayne@68: """Return whether or not the item is contained in this specifier. jpayne@68: jpayne@68: :param item: The item to check for. jpayne@68: jpayne@68: This is used for the ``in`` operator and behaves the same as jpayne@68: :meth:`contains` with no ``prereleases`` argument passed. jpayne@68: jpayne@68: >>> "1.2.3" in Specifier(">=1.2.3") jpayne@68: True jpayne@68: >>> Version("1.2.3") in Specifier(">=1.2.3") jpayne@68: True jpayne@68: >>> "1.0.0" in Specifier(">=1.2.3") jpayne@68: False jpayne@68: >>> "1.3.0a1" in Specifier(">=1.2.3") jpayne@68: False jpayne@68: >>> "1.3.0a1" in Specifier(">=1.2.3", prereleases=True) jpayne@68: True jpayne@68: """ jpayne@68: return self.contains(item) jpayne@68: jpayne@68: def contains(self, item: UnparsedVersion, prereleases: bool | None = None) -> bool: jpayne@68: """Return whether or not the item is contained in this specifier. jpayne@68: jpayne@68: :param item: jpayne@68: The item to check for, which can be a version string or a jpayne@68: :class:`Version` instance. jpayne@68: :param prereleases: jpayne@68: Whether or not to match prereleases with this Specifier. If set to jpayne@68: ``None`` (the default), it uses :attr:`prereleases` to determine jpayne@68: whether or not prereleases are allowed. jpayne@68: jpayne@68: >>> Specifier(">=1.2.3").contains("1.2.3") jpayne@68: True jpayne@68: >>> Specifier(">=1.2.3").contains(Version("1.2.3")) jpayne@68: True jpayne@68: >>> Specifier(">=1.2.3").contains("1.0.0") jpayne@68: False jpayne@68: >>> Specifier(">=1.2.3").contains("1.3.0a1") jpayne@68: False jpayne@68: >>> Specifier(">=1.2.3", prereleases=True).contains("1.3.0a1") jpayne@68: True jpayne@68: >>> Specifier(">=1.2.3").contains("1.3.0a1", prereleases=True) jpayne@68: True jpayne@68: """ jpayne@68: jpayne@68: # Determine if prereleases are to be allowed or not. jpayne@68: if prereleases is None: jpayne@68: prereleases = self.prereleases jpayne@68: jpayne@68: # Normalize item to a Version, this allows us to have a shortcut for jpayne@68: # "2.0" in Specifier(">=2") jpayne@68: normalized_item = _coerce_version(item) jpayne@68: jpayne@68: # Determine if we should be supporting prereleases in this specifier jpayne@68: # or not, if we do not support prereleases than we can short circuit jpayne@68: # logic if this version is a prereleases. jpayne@68: if normalized_item.is_prerelease and not prereleases: jpayne@68: return False jpayne@68: jpayne@68: # Actually do the comparison to determine if this item is contained jpayne@68: # within this Specifier or not. jpayne@68: operator_callable: CallableOperator = self._get_operator(self.operator) jpayne@68: return operator_callable(normalized_item, self.version) jpayne@68: jpayne@68: def filter( jpayne@68: self, iterable: Iterable[UnparsedVersionVar], prereleases: bool | None = None jpayne@68: ) -> Iterator[UnparsedVersionVar]: jpayne@68: """Filter items in the given iterable, that match the specifier. jpayne@68: jpayne@68: :param iterable: jpayne@68: An iterable that can contain version strings and :class:`Version` instances. jpayne@68: The items in the iterable will be filtered according to the specifier. jpayne@68: :param prereleases: jpayne@68: Whether or not to allow prereleases in the returned iterator. If set to jpayne@68: ``None`` (the default), it will be intelligently decide whether to allow jpayne@68: prereleases or not (based on the :attr:`prereleases` attribute, and jpayne@68: whether the only versions matching are prereleases). jpayne@68: jpayne@68: This method is smarter than just ``filter(Specifier().contains, [...])`` jpayne@68: because it implements the rule from :pep:`440` that a prerelease item jpayne@68: SHOULD be accepted if no other versions match the given specifier. jpayne@68: jpayne@68: >>> list(Specifier(">=1.2.3").filter(["1.2", "1.3", "1.5a1"])) jpayne@68: ['1.3'] jpayne@68: >>> list(Specifier(">=1.2.3").filter(["1.2", "1.2.3", "1.3", Version("1.4")])) jpayne@68: ['1.2.3', '1.3', ] jpayne@68: >>> list(Specifier(">=1.2.3").filter(["1.2", "1.5a1"])) jpayne@68: ['1.5a1'] jpayne@68: >>> list(Specifier(">=1.2.3").filter(["1.3", "1.5a1"], prereleases=True)) jpayne@68: ['1.3', '1.5a1'] jpayne@68: >>> list(Specifier(">=1.2.3", prereleases=True).filter(["1.3", "1.5a1"])) jpayne@68: ['1.3', '1.5a1'] jpayne@68: """ jpayne@68: jpayne@68: yielded = False jpayne@68: found_prereleases = [] jpayne@68: jpayne@68: kw = {"prereleases": prereleases if prereleases is not None else True} jpayne@68: jpayne@68: # Attempt to iterate over all the values in the iterable and if any of jpayne@68: # them match, yield them. jpayne@68: for version in iterable: jpayne@68: parsed_version = _coerce_version(version) jpayne@68: jpayne@68: if self.contains(parsed_version, **kw): jpayne@68: # If our version is a prerelease, and we were not set to allow jpayne@68: # prereleases, then we'll store it for later in case nothing jpayne@68: # else matches this specifier. jpayne@68: if parsed_version.is_prerelease and not ( jpayne@68: prereleases or self.prereleases jpayne@68: ): jpayne@68: found_prereleases.append(version) jpayne@68: # Either this is not a prerelease, or we should have been jpayne@68: # accepting prereleases from the beginning. jpayne@68: else: jpayne@68: yielded = True jpayne@68: yield version jpayne@68: jpayne@68: # Now that we've iterated over everything, determine if we've yielded jpayne@68: # any values, and if we have not and we have any prereleases stored up jpayne@68: # then we will go ahead and yield the prereleases. jpayne@68: if not yielded and found_prereleases: jpayne@68: for version in found_prereleases: jpayne@68: yield version jpayne@68: jpayne@68: jpayne@68: _prefix_regex = re.compile(r"^([0-9]+)((?:a|b|c|rc)[0-9]+)$") jpayne@68: jpayne@68: jpayne@68: def _version_split(version: str) -> list[str]: jpayne@68: """Split version into components. jpayne@68: jpayne@68: The split components are intended for version comparison. The logic does jpayne@68: not attempt to retain the original version string, so joining the jpayne@68: components back with :func:`_version_join` may not produce the original jpayne@68: version string. jpayne@68: """ jpayne@68: result: list[str] = [] jpayne@68: jpayne@68: epoch, _, rest = version.rpartition("!") jpayne@68: result.append(epoch or "0") jpayne@68: jpayne@68: for item in rest.split("."): jpayne@68: match = _prefix_regex.search(item) jpayne@68: if match: jpayne@68: result.extend(match.groups()) jpayne@68: else: jpayne@68: result.append(item) jpayne@68: return result jpayne@68: jpayne@68: jpayne@68: def _version_join(components: list[str]) -> str: jpayne@68: """Join split version components into a version string. jpayne@68: jpayne@68: This function assumes the input came from :func:`_version_split`, where the jpayne@68: first component must be the epoch (either empty or numeric), and all other jpayne@68: components numeric. jpayne@68: """ jpayne@68: epoch, *rest = components jpayne@68: return f"{epoch}!{'.'.join(rest)}" jpayne@68: jpayne@68: jpayne@68: def _is_not_suffix(segment: str) -> bool: jpayne@68: return not any( jpayne@68: segment.startswith(prefix) for prefix in ("dev", "a", "b", "rc", "post") jpayne@68: ) jpayne@68: jpayne@68: jpayne@68: def _pad_version(left: list[str], right: list[str]) -> tuple[list[str], list[str]]: jpayne@68: left_split, right_split = [], [] jpayne@68: jpayne@68: # Get the release segment of our versions jpayne@68: left_split.append(list(itertools.takewhile(lambda x: x.isdigit(), left))) jpayne@68: right_split.append(list(itertools.takewhile(lambda x: x.isdigit(), right))) jpayne@68: jpayne@68: # Get the rest of our versions jpayne@68: left_split.append(left[len(left_split[0]) :]) jpayne@68: right_split.append(right[len(right_split[0]) :]) jpayne@68: jpayne@68: # Insert our padding jpayne@68: left_split.insert(1, ["0"] * max(0, len(right_split[0]) - len(left_split[0]))) jpayne@68: right_split.insert(1, ["0"] * max(0, len(left_split[0]) - len(right_split[0]))) jpayne@68: jpayne@68: return ( jpayne@68: list(itertools.chain.from_iterable(left_split)), jpayne@68: list(itertools.chain.from_iterable(right_split)), jpayne@68: ) jpayne@68: jpayne@68: jpayne@68: class SpecifierSet(BaseSpecifier): jpayne@68: """This class abstracts handling of a set of version specifiers. jpayne@68: jpayne@68: It can be passed a single specifier (``>=3.0``), a comma-separated list of jpayne@68: specifiers (``>=3.0,!=3.1``), or no specifier at all. jpayne@68: """ jpayne@68: jpayne@68: def __init__( jpayne@68: self, jpayne@68: specifiers: str | Iterable[Specifier] = "", jpayne@68: prereleases: bool | None = None, jpayne@68: ) -> None: jpayne@68: """Initialize a SpecifierSet instance. jpayne@68: jpayne@68: :param specifiers: jpayne@68: The string representation of a specifier or a comma-separated list of jpayne@68: specifiers which will be parsed and normalized before use. jpayne@68: May also be an iterable of ``Specifier`` instances, which will be used jpayne@68: as is. jpayne@68: :param prereleases: jpayne@68: This tells the SpecifierSet if it should accept prerelease versions if jpayne@68: applicable or not. The default of ``None`` will autodetect it from the jpayne@68: given specifiers. jpayne@68: jpayne@68: :raises InvalidSpecifier: jpayne@68: If the given ``specifiers`` are not parseable than this exception will be jpayne@68: raised. jpayne@68: """ jpayne@68: jpayne@68: if isinstance(specifiers, str): jpayne@68: # Split on `,` to break each individual specifier into its own item, and jpayne@68: # strip each item to remove leading/trailing whitespace. jpayne@68: split_specifiers = [s.strip() for s in specifiers.split(",") if s.strip()] jpayne@68: jpayne@68: # Make each individual specifier a Specifier and save in a frozen set jpayne@68: # for later. jpayne@68: self._specs = frozenset(map(Specifier, split_specifiers)) jpayne@68: else: jpayne@68: # Save the supplied specifiers in a frozen set. jpayne@68: self._specs = frozenset(specifiers) jpayne@68: jpayne@68: # Store our prereleases value so we can use it later to determine if jpayne@68: # we accept prereleases or not. jpayne@68: self._prereleases = prereleases jpayne@68: jpayne@68: @property jpayne@68: def prereleases(self) -> bool | None: jpayne@68: # If we have been given an explicit prerelease modifier, then we'll jpayne@68: # pass that through here. jpayne@68: if self._prereleases is not None: jpayne@68: return self._prereleases jpayne@68: jpayne@68: # If we don't have any specifiers, and we don't have a forced value, jpayne@68: # then we'll just return None since we don't know if this should have jpayne@68: # pre-releases or not. jpayne@68: if not self._specs: jpayne@68: return None jpayne@68: jpayne@68: # Otherwise we'll see if any of the given specifiers accept jpayne@68: # prereleases, if any of them do we'll return True, otherwise False. jpayne@68: return any(s.prereleases for s in self._specs) jpayne@68: jpayne@68: @prereleases.setter jpayne@68: def prereleases(self, value: bool) -> None: jpayne@68: self._prereleases = value jpayne@68: jpayne@68: def __repr__(self) -> str: jpayne@68: """A representation of the specifier set that shows all internal state. jpayne@68: jpayne@68: Note that the ordering of the individual specifiers within the set may not jpayne@68: match the input string. jpayne@68: jpayne@68: >>> SpecifierSet('>=1.0.0,!=2.0.0') jpayne@68: =1.0.0')> jpayne@68: >>> SpecifierSet('>=1.0.0,!=2.0.0', prereleases=False) jpayne@68: =1.0.0', prereleases=False)> jpayne@68: >>> SpecifierSet('>=1.0.0,!=2.0.0', prereleases=True) jpayne@68: =1.0.0', prereleases=True)> jpayne@68: """ jpayne@68: pre = ( jpayne@68: f", prereleases={self.prereleases!r}" jpayne@68: if self._prereleases is not None jpayne@68: else "" jpayne@68: ) jpayne@68: jpayne@68: return f"" jpayne@68: jpayne@68: def __str__(self) -> str: jpayne@68: """A string representation of the specifier set that can be round-tripped. jpayne@68: jpayne@68: Note that the ordering of the individual specifiers within the set may not jpayne@68: match the input string. jpayne@68: jpayne@68: >>> str(SpecifierSet(">=1.0.0,!=1.0.1")) jpayne@68: '!=1.0.1,>=1.0.0' jpayne@68: >>> str(SpecifierSet(">=1.0.0,!=1.0.1", prereleases=False)) jpayne@68: '!=1.0.1,>=1.0.0' jpayne@68: """ jpayne@68: return ",".join(sorted(str(s) for s in self._specs)) jpayne@68: jpayne@68: def __hash__(self) -> int: jpayne@68: return hash(self._specs) jpayne@68: jpayne@68: def __and__(self, other: SpecifierSet | str) -> SpecifierSet: jpayne@68: """Return a SpecifierSet which is a combination of the two sets. jpayne@68: jpayne@68: :param other: The other object to combine with. jpayne@68: jpayne@68: >>> SpecifierSet(">=1.0.0,!=1.0.1") & '<=2.0.0,!=2.0.1' jpayne@68: =1.0.0')> jpayne@68: >>> SpecifierSet(">=1.0.0,!=1.0.1") & SpecifierSet('<=2.0.0,!=2.0.1') jpayne@68: =1.0.0')> jpayne@68: """ jpayne@68: if isinstance(other, str): jpayne@68: other = SpecifierSet(other) jpayne@68: elif not isinstance(other, SpecifierSet): jpayne@68: return NotImplemented jpayne@68: jpayne@68: specifier = SpecifierSet() jpayne@68: specifier._specs = frozenset(self._specs | other._specs) jpayne@68: jpayne@68: if self._prereleases is None and other._prereleases is not None: jpayne@68: specifier._prereleases = other._prereleases jpayne@68: elif self._prereleases is not None and other._prereleases is None: jpayne@68: specifier._prereleases = self._prereleases jpayne@68: elif self._prereleases == other._prereleases: jpayne@68: specifier._prereleases = self._prereleases jpayne@68: else: jpayne@68: raise ValueError( jpayne@68: "Cannot combine SpecifierSets with True and False prerelease " jpayne@68: "overrides." jpayne@68: ) jpayne@68: jpayne@68: return specifier jpayne@68: jpayne@68: def __eq__(self, other: object) -> bool: jpayne@68: """Whether or not the two SpecifierSet-like objects are equal. jpayne@68: jpayne@68: :param other: The other object to check against. jpayne@68: jpayne@68: The value of :attr:`prereleases` is ignored. jpayne@68: jpayne@68: >>> SpecifierSet(">=1.0.0,!=1.0.1") == SpecifierSet(">=1.0.0,!=1.0.1") jpayne@68: True jpayne@68: >>> (SpecifierSet(">=1.0.0,!=1.0.1", prereleases=False) == jpayne@68: ... SpecifierSet(">=1.0.0,!=1.0.1", prereleases=True)) jpayne@68: True jpayne@68: >>> SpecifierSet(">=1.0.0,!=1.0.1") == ">=1.0.0,!=1.0.1" jpayne@68: True jpayne@68: >>> SpecifierSet(">=1.0.0,!=1.0.1") == SpecifierSet(">=1.0.0") jpayne@68: False jpayne@68: >>> SpecifierSet(">=1.0.0,!=1.0.1") == SpecifierSet(">=1.0.0,!=1.0.2") jpayne@68: False jpayne@68: """ jpayne@68: if isinstance(other, (str, Specifier)): jpayne@68: other = SpecifierSet(str(other)) jpayne@68: elif not isinstance(other, SpecifierSet): jpayne@68: return NotImplemented jpayne@68: jpayne@68: return self._specs == other._specs jpayne@68: jpayne@68: def __len__(self) -> int: jpayne@68: """Returns the number of specifiers in this specifier set.""" jpayne@68: return len(self._specs) jpayne@68: jpayne@68: def __iter__(self) -> Iterator[Specifier]: jpayne@68: """ jpayne@68: Returns an iterator over all the underlying :class:`Specifier` instances jpayne@68: in this specifier set. jpayne@68: jpayne@68: >>> sorted(SpecifierSet(">=1.0.0,!=1.0.1"), key=str) jpayne@68: [, =1.0.0')>] jpayne@68: """ jpayne@68: return iter(self._specs) jpayne@68: jpayne@68: def __contains__(self, item: UnparsedVersion) -> bool: jpayne@68: """Return whether or not the item is contained in this specifier. jpayne@68: jpayne@68: :param item: The item to check for. jpayne@68: jpayne@68: This is used for the ``in`` operator and behaves the same as jpayne@68: :meth:`contains` with no ``prereleases`` argument passed. jpayne@68: jpayne@68: >>> "1.2.3" in SpecifierSet(">=1.0.0,!=1.0.1") jpayne@68: True jpayne@68: >>> Version("1.2.3") in SpecifierSet(">=1.0.0,!=1.0.1") jpayne@68: True jpayne@68: >>> "1.0.1" in SpecifierSet(">=1.0.0,!=1.0.1") jpayne@68: False jpayne@68: >>> "1.3.0a1" in SpecifierSet(">=1.0.0,!=1.0.1") jpayne@68: False jpayne@68: >>> "1.3.0a1" in SpecifierSet(">=1.0.0,!=1.0.1", prereleases=True) jpayne@68: True jpayne@68: """ jpayne@68: return self.contains(item) jpayne@68: jpayne@68: def contains( jpayne@68: self, jpayne@68: item: UnparsedVersion, jpayne@68: prereleases: bool | None = None, jpayne@68: installed: bool | None = None, jpayne@68: ) -> bool: jpayne@68: """Return whether or not the item is contained in this SpecifierSet. jpayne@68: jpayne@68: :param item: jpayne@68: The item to check for, which can be a version string or a jpayne@68: :class:`Version` instance. jpayne@68: :param prereleases: jpayne@68: Whether or not to match prereleases with this SpecifierSet. If set to jpayne@68: ``None`` (the default), it uses :attr:`prereleases` to determine jpayne@68: whether or not prereleases are allowed. jpayne@68: jpayne@68: >>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.2.3") jpayne@68: True jpayne@68: >>> SpecifierSet(">=1.0.0,!=1.0.1").contains(Version("1.2.3")) jpayne@68: True jpayne@68: >>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.0.1") jpayne@68: False jpayne@68: >>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.3.0a1") jpayne@68: False jpayne@68: >>> SpecifierSet(">=1.0.0,!=1.0.1", prereleases=True).contains("1.3.0a1") jpayne@68: True jpayne@68: >>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.3.0a1", prereleases=True) jpayne@68: True jpayne@68: """ jpayne@68: # Ensure that our item is a Version instance. jpayne@68: if not isinstance(item, Version): jpayne@68: item = Version(item) jpayne@68: jpayne@68: # Determine if we're forcing a prerelease or not, if we're not forcing jpayne@68: # one for this particular filter call, then we'll use whatever the jpayne@68: # SpecifierSet thinks for whether or not we should support prereleases. jpayne@68: if prereleases is None: jpayne@68: prereleases = self.prereleases jpayne@68: jpayne@68: # We can determine if we're going to allow pre-releases by looking to jpayne@68: # see if any of the underlying items supports them. If none of them do jpayne@68: # and this item is a pre-release then we do not allow it and we can jpayne@68: # short circuit that here. jpayne@68: # Note: This means that 1.0.dev1 would not be contained in something jpayne@68: # like >=1.0.devabc however it would be in >=1.0.debabc,>0.0.dev0 jpayne@68: if not prereleases and item.is_prerelease: jpayne@68: return False jpayne@68: jpayne@68: if installed and item.is_prerelease: jpayne@68: item = Version(item.base_version) jpayne@68: jpayne@68: # We simply dispatch to the underlying specs here to make sure that the jpayne@68: # given version is contained within all of them. jpayne@68: # Note: This use of all() here means that an empty set of specifiers jpayne@68: # will always return True, this is an explicit design decision. jpayne@68: return all(s.contains(item, prereleases=prereleases) for s in self._specs) jpayne@68: jpayne@68: def filter( jpayne@68: self, iterable: Iterable[UnparsedVersionVar], prereleases: bool | None = None jpayne@68: ) -> Iterator[UnparsedVersionVar]: jpayne@68: """Filter items in the given iterable, that match the specifiers in this set. jpayne@68: jpayne@68: :param iterable: jpayne@68: An iterable that can contain version strings and :class:`Version` instances. jpayne@68: The items in the iterable will be filtered according to the specifier. jpayne@68: :param prereleases: jpayne@68: Whether or not to allow prereleases in the returned iterator. If set to jpayne@68: ``None`` (the default), it will be intelligently decide whether to allow jpayne@68: prereleases or not (based on the :attr:`prereleases` attribute, and jpayne@68: whether the only versions matching are prereleases). jpayne@68: jpayne@68: This method is smarter than just ``filter(SpecifierSet(...).contains, [...])`` jpayne@68: because it implements the rule from :pep:`440` that a prerelease item jpayne@68: SHOULD be accepted if no other versions match the given specifier. jpayne@68: jpayne@68: >>> list(SpecifierSet(">=1.2.3").filter(["1.2", "1.3", "1.5a1"])) jpayne@68: ['1.3'] jpayne@68: >>> list(SpecifierSet(">=1.2.3").filter(["1.2", "1.3", Version("1.4")])) jpayne@68: ['1.3', ] jpayne@68: >>> list(SpecifierSet(">=1.2.3").filter(["1.2", "1.5a1"])) jpayne@68: [] jpayne@68: >>> list(SpecifierSet(">=1.2.3").filter(["1.3", "1.5a1"], prereleases=True)) jpayne@68: ['1.3', '1.5a1'] jpayne@68: >>> list(SpecifierSet(">=1.2.3", prereleases=True).filter(["1.3", "1.5a1"])) jpayne@68: ['1.3', '1.5a1'] jpayne@68: jpayne@68: An "empty" SpecifierSet will filter items based on the presence of prerelease jpayne@68: versions in the set. jpayne@68: jpayne@68: >>> list(SpecifierSet("").filter(["1.3", "1.5a1"])) jpayne@68: ['1.3'] jpayne@68: >>> list(SpecifierSet("").filter(["1.5a1"])) jpayne@68: ['1.5a1'] jpayne@68: >>> list(SpecifierSet("", prereleases=True).filter(["1.3", "1.5a1"])) jpayne@68: ['1.3', '1.5a1'] jpayne@68: >>> list(SpecifierSet("").filter(["1.3", "1.5a1"], prereleases=True)) jpayne@68: ['1.3', '1.5a1'] jpayne@68: """ jpayne@68: # Determine if we're forcing a prerelease or not, if we're not forcing jpayne@68: # one for this particular filter call, then we'll use whatever the jpayne@68: # SpecifierSet thinks for whether or not we should support prereleases. jpayne@68: if prereleases is None: jpayne@68: prereleases = self.prereleases jpayne@68: jpayne@68: # If we have any specifiers, then we want to wrap our iterable in the jpayne@68: # filter method for each one, this will act as a logical AND amongst jpayne@68: # each specifier. jpayne@68: if self._specs: jpayne@68: for spec in self._specs: jpayne@68: iterable = spec.filter(iterable, prereleases=bool(prereleases)) jpayne@68: return iter(iterable) jpayne@68: # If we do not have any specifiers, then we need to have a rough filter jpayne@68: # which will filter out any pre-releases, unless there are no final jpayne@68: # releases. jpayne@68: else: jpayne@68: filtered: list[UnparsedVersionVar] = [] jpayne@68: found_prereleases: list[UnparsedVersionVar] = [] jpayne@68: jpayne@68: for item in iterable: jpayne@68: parsed_version = _coerce_version(item) jpayne@68: jpayne@68: # Store any item which is a pre-release for later unless we've jpayne@68: # already found a final version or we are accepting prereleases jpayne@68: if parsed_version.is_prerelease and not prereleases: jpayne@68: if not filtered: jpayne@68: found_prereleases.append(item) jpayne@68: else: jpayne@68: filtered.append(item) jpayne@68: jpayne@68: # If we've found no items except for pre-releases, then we'll go jpayne@68: # ahead and use the pre-releases jpayne@68: if not filtered and found_prereleases and prereleases is None: jpayne@68: return iter(found_prereleases) jpayne@68: jpayne@68: return iter(filtered)