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