From 9270fb4ce5f4e050205573871ca13331e2d9599a Mon Sep 17 00:00:00 2001 From: Donald Stufft Date: Sun, 5 Oct 2014 20:02:52 -0400 Subject: [PATCH] Move specifier support into packaging.specifiers --- CHANGELOG.rst | 3 + docs/index.rst | 1 + docs/specifiers.rst | 115 +++++++ docs/version.rst | 92 +---- packaging/specifiers.py | 428 ++++++++++++++++++++++++ packaging/version.py | 412 +---------------------- tests/test_specifiers.py | 703 +++++++++++++++++++++++++++++++++++++++ tests/test_version.py | 683 +------------------------------------ 8 files changed, 1253 insertions(+), 1184 deletions(-) create mode 100644 docs/specifiers.rst create mode 100644 packaging/specifiers.py create mode 100644 tests/test_specifiers.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3ba55c7c..341d2f62 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,9 @@ Changelog .. note:: This version is not yet released and is under active development. +* **BACKWARDS INCOMPATIBLE** Move the specifier support out of + ``packaging.version`` into ``packaging.specifiers``. + 14.2 - 2014-09-10 ~~~~~~~~~~~~~~~~~ diff --git a/docs/index.rst b/docs/index.rst index a63afb6c..c0f557a2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -21,6 +21,7 @@ API :maxdepth: 1 version + specifiers Project diff --git a/docs/specifiers.rst b/docs/specifiers.rst new file mode 100644 index 00000000..c102af2c --- /dev/null +++ b/docs/specifiers.rst @@ -0,0 +1,115 @@ +Specifiers +========== + +.. currentmodule:: packaging.specifiers + +A core requirement of dealing with dependency is the ability to specify what +versions of a dependency are acceptable for you. `PEP 440`_ defines the +standard specifier scheme which has been implemented by this module. + +Usage +----- + +.. doctest:: + + >>> from packaging.specifiers import Specifier + >>> from packaging.version import Version + >>> spec1 = Specifier("~=1.0") + >>> spec1 + + >>> spec2 = Specifier(">=1.0") + >>> spec2 + =1.0')> + >>> # We can combine specifiers + >>> combined_spec = spec1 & spec2 + >>> combined_spec + =1.0,~=1.0')> + >>> # We can also implicitly combine a string specifier + >>> combined_spec &= "!=1.1" + >>> combined_spec + =1.0,~=1.0')> + >>> # Create a few versions to check for contains. + >>> v1 = Version("1.0a5") + >>> v2 = Version("1.0") + >>> # We can check a version object to see if it falls within a specifier + >>> combined_spec.contains(v1) + False + >>> combined_spec.contains(v2) + True + >>> # We can even do the same with a string based version + >>> combined_spec.contains("1.4") + True + >>> # Finally we can filter a list of versions to get only those which are + >>> # contained within our specifier. + >>> list(combined_spec.filter([v1, v2, "1.4"])) + [, '1.4'] + + +Reference +--------- + +.. class:: Specifier(specifier, prereleases=None) + + This class abstracts handling of specifying the dependencies of a project. + It implements the scheme defined in `PEP 440`_. You may combine Specifier + instances using the ``&`` operator (``Specifier(">2") & Specifier(">3")``). + + Both the membership test and the combination supports using raw strings + in place of already instantiated objects. + + :param str specifier: The string representation of a specifier which will + be parsed and normalized before use. + :param bool prereleases: This tells the specifier if it should accept + prerelease versions if applicable or not. The + default of ``None`` will autodetect it from the + given specifiers. + :raises InvalidSpecifier: If the ``specifier`` does not conform to PEP 440 + in any way then this exception will be raised. + + .. attribute:: prereleases + + A boolean value indicating whether this :class:`Specifier` represents + a specifier that includesa pre-release versions. This can be set to + either ``True`` or ``False`` to explicitly enable or disable + prereleases or it can be set to ``None`` (the default) to enable + autodetection. + + .. method:: contains(version, prereleases=None) + + Determines if ``version``, which can be either a version string, a + :class:`Version`, or a :class:`LegacyVersion` object, is contained + within this specifier. + + This will either match or not match prereleases based on the + ``prereleases`` parameter. When ``prereleases`` is set to ``None`` + (the default) it will use the ``Specifier().prereleases`` attribute to + determine if to allow them. Otherwise it will use the boolean value of + the passed in value to determine if to allow them or not. + + .. method:: filter(iterable, prereleases=None) + + Takes an iterable that can contain version strings, :class:`Version`, + and :class:`LegacyVersion` instances and will then filter it, returning + an iterable that contains only items which match the rules of this + specifier object. + + This method is smarter than just + ``filter(Specifier().contains, [...])`` because it implements the rule + from PEP 440 where a prerelease item SHOULD be accepted if no other + versions match the given specifier. + + The ``prereleases`` parameter functions similarly to that of the same + parameter in ``contains``. If the value is ``None`` (the default) then + it will intelligently decide if to allow prereleases based on the + specifier, the ``Specifier().prereleases`` value, and the PEP 440 + rules. Otherwise it will act as a boolean which will enable or disable + all prerelease versions from being included. + + +.. exception:: InvalidSpecifier + + Raised when attempting to create a :class:`Specifier` with a specifier + string that does not conform to `PEP 440`_. + + +.. _`PEP 440`: https://www.python.org/dev/peps/pep-0440/ diff --git a/docs/version.rst b/docs/version.rst index a4bf7262..deaa14c7 100644 --- a/docs/version.rst +++ b/docs/version.rst @@ -12,7 +12,7 @@ Usage .. doctest:: - >>> from packaging.version import Version, Specifier, parse + >>> from packaging.version import Version, parse >>> v1 = parse("1.0a5") >>> v2 = Version("1.0") >>> v1 @@ -29,32 +29,6 @@ Usage Traceback (most recent call last): ... InvalidVersion: Invalid version: 'french toast' - >>> spec1 = Specifier("~=1.0") - >>> spec1 - - >>> spec2 = Specifier(">=1.0") - >>> spec2 - =1.0')> - >>> # We can combine specifiers - >>> combined_spec = spec1 & spec2 - >>> combined_spec - =1.0,~=1.0')> - >>> # We can also implicitly combine a string specifier - >>> combined_spec &= "!=1.1" - >>> combined_spec - =1.0,~=1.0')> - >>> # We can check a version object to see if it falls within a specifier - >>> combined_spec.contains(v1) - False - >>> combined_spec.contains(v2) - True - >>> # We can even do the same with a string based version - >>> combined_spec.contains("1.4") - True - >>> # Finally we can filter a list of versions to get only those which are - >>> # contained within our specifier. - >>> list(combined_spec.filter([v1, v2, "1.4"])) - [, '1.4'] Reference @@ -125,74 +99,10 @@ Reference `False` and exists for compatibility with :class:`Version`. -.. class:: Specifier(specifier, prereleases=None) - - This class abstracts handling of specifying the dependencies of a project. - It implements the scheme defined in `PEP 440`_. You may combine Specifier - instances using the ``&`` operator (``Specifier(">2") & Specifier(">3")``). - - Both the membership test and the combination supports using raw strings - in place of already instantiated objects. - - :param str specifier: The string representation of a specifier which will - be parsed and normalized before use. - :param bool prereleases: This tells the specifier if it should accept - prerelease versions if applicable or not. The - default of ``None`` will autodetect it from the - given specifiers. - :raises InvalidSpecifier: If the ``specifier`` does not conform to PEP 440 - in any way then this exception will be raised. - - .. attribute:: prereleases - - A boolean value indicating whether this :class:`Specifier` represents - a specifier that includesa pre-release versions. This can be set to - either ``True`` or ``False`` to explicitly enable or disable - prereleases or it can be set to ``None`` (the default) to enable - autodetection. - - .. method:: contains(version, prereleases=None) - - Determines if ``version``, which can be either a version string, a - :class:`Version`, or a :class:`LegacyVersion` object, is contained - within this specifier. - - This will either match or not match prereleases based on the - ``prereleases`` parameter. When ``prereleases`` is set to ``None`` - (the default) it will use the ``Specifier().prereleases`` attribute to - determine if to allow them. Otherwise it will use the boolean value of - the passed in value to determine if to allow them or not. - - .. method:: filter(iterable, prereleases=None) - - Takes an iterable that can contain version strings, :class:`Version`, - and :class:`LegacyVersion` instances and will then filter it, returning - an iterable that contains only items which match the rules of this - specifier object. - - This method is smarter than just - ``filter(Specifier().contains, [...])`` because it implements the rule - from PEP 440 where a prerelease item SHOULD be accepted if no other - versions match the given specifier. - - The ``prereleases`` parameter functions similarly to that of the same - parameter in ``contains``. If the value is ``None`` (the default) then - it will intelligently decide if to allow prereleases based on the - specifier, the ``Specifier().prereleases`` value, and the PEP 440 - rules. Otherwise it will act as a boolean which will enable or disable - all prerelease versions from being included. - - .. exception:: InvalidVersion Raised when attempting to create a :class:`Version` with a version string that does not conform to `PEP 440`_. -.. exception:: InvalidSpecifier - - Raised when attempting to create a :class:`Specifier` with a specifier - string that does not conform to `PEP 440`_. - - .. _`PEP 440`: https://www.python.org/dev/peps/pep-0440/ diff --git a/packaging/specifiers.py b/packaging/specifiers.py new file mode 100644 index 00000000..ef342654 --- /dev/null +++ b/packaging/specifiers.py @@ -0,0 +1,428 @@ +# Copyright 2014 Donald Stufft +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import absolute_import, division, print_function + +import itertools +import re + +from ._compat import string_types +from .version import Version, LegacyVersion, parse + + +class InvalidSpecifier(ValueError): + """ + An invalid specifier was found, users should refer to PEP 440. + """ + + +class Specifier(object): + + _regex = re.compile( + r""" + ^ + \s* + (?P(~=|==|!=|<=|>=|<|>|===)) + (?P + (?: + # The identity operators allow for an escape hatch that will + # do an exact string match of the version you wish to install. + # This will not be parsed by PEP 440 and we cannot determine + # any semantic meaning from it. This operator is discouraged + # but included entirely as an escape hatch. + (?<====) # Only match for the identity operator + \s* + [^\s]* # We just match everything, except for whitespace + # since we are only testing for strict identity. + ) + | + (?: + # The (non)equality operators allow for wild card and local + # versions to be specified so we have to define these two + # operators separately to enable that. + (?<===|!=) # Only match for equals and not equals + + \s* + v? + (?:[0-9]+!)? # epoch + [0-9]+(?:\.[0-9]+)* # release + (?: # pre release + [-_\.]? + (a|b|c|rc|alpha|beta|pre|preview) + [-_\.]? + [0-9]* + )? + (?: # post release + (?:-[0-9]+)|(?:[-_\.]?(post|rev|r)[-_\.]?[0-9]*) + )? + + # You cannot use a wild card and a dev or local version + # together so group them with a | and make them optional. + (?: + (?:[-_\.]?dev[-_\.]?[0-9]*)? # dev release + (?:\+[a-z0-9]+(?:[-_\.][a-z0-9]+)*)? # local + | + \.\* # Wild card syntax of .* + )? + ) + | + (?: + # The compatible operator requires at least two digits in the + # release segment. + (?<=~=) # Only match for the compatible operator + + \s* + v? + (?:[0-9]+!)? # epoch + [0-9]+(?:\.[0-9]+)+ # release (We have a + instead of a *) + (?: # pre release + [-_\.]? + (a|b|c|rc|alpha|beta|pre|preview) + [-_\.]? + [0-9]* + )? + (?: # post release + (?:-[0-9]+)|(?:[-_\.]?(post|rev|r)[-_\.]?[0-9]*) + )? + (?:[-_\.]?dev[-_\.]?[0-9]*)? # dev release + ) + | + (?: + # All other operators only allow a sub set of what the + # (non)equality operators do. Specifically they do not allow + # local versions to be specified nor do they allow the prefix + # matching wild cards. + (?=": "greater_than_equal", + "<": "less_than", + ">": "greater_than", + "===": "arbitrary", + } + + def __init__(self, specs="", prereleases=None): + # Split on comma to get each individual specification + _specs = set() + for spec in (s for s in specs.split(",") if s): + match = self._regex.search(spec) + if not match: + raise InvalidSpecifier("Invalid specifier: '{0}'".format(spec)) + + _specs.add( + ( + match.group("operator").strip(), + match.group("version").strip(), + ) + ) + + # Set a frozen set for our specifications + self._specs = frozenset(_specs) + + # Store whether or not this Specifier should accept prereleases + self._prereleases = prereleases + + def __repr__(self): + return "".format(repr(str(self))) + + def __str__(self): + return ",".join(["".join(s) for s in sorted(self._specs)]) + + def __hash__(self): + return hash(self._specs) + + def __and__(self, other): + if isinstance(other, string_types): + other = Specifier(other) + elif not isinstance(other, Specifier): + return NotImplemented + + return self.__class__(",".join([str(self), str(other)])) + + def __eq__(self, other): + if isinstance(other, string_types): + other = Specifier(other) + elif not isinstance(other, Specifier): + return NotImplemented + + return self._specs == other._specs + + def __ne__(self, other): + if isinstance(other, string_types): + other = Specifier(other) + elif not isinstance(other, Specifier): + return NotImplemented + + return self._specs != other._specs + + def _get_operator(self, op): + return getattr(self, "_compare_{0}".format(self._operators[op])) + + def _compare_compatible(self, prospective, spec): + # Compatible releases have an equivalent combination of >= and ==. That + # is that ~=2.2 is equivalent to >=2.2,==2.*. This allows us to + # implement this in terms of the other specifiers instead of + # implementing it ourselves. The only thing we need to do is construct + # the other specifiers. + + # We want everything but the last item in the version, but we want to + # ignore post and dev releases and we want to treat the pre-release as + # it's own separate segment. + prefix = ".".join( + list( + itertools.takewhile( + lambda x: (not x.startswith("post") + and not x.startswith("dev")), + _version_split(spec), + ) + )[:-1] + ) + + # Add the prefix notation to the end of our string + prefix += ".*" + + return (self._get_operator(">=")(prospective, spec) + and self._get_operator("==")(prospective, prefix)) + + def _compare_equal(self, prospective, spec): + # We need special logic to handle prefix matching + if spec.endswith(".*"): + # Split the spec out by dots, and pretend that there is an implicit + # dot in between a release segment and a pre-release segment. + spec = _version_split(spec[:-2]) # Remove the trailing .* + + # Split the prospective version out by dots, and pretend that there + # is an implicit dot in between a release segment and a pre-release + # segment. + prospective = _version_split(str(prospective)) + + # Shorten the prospective version to be the same length as the spec + # so that we can determine if the specifier is a prefix of the + # prospective version or not. + prospective = prospective[:len(spec)] + + # Pad out our two sides with zeros so that they both equal the same + # length. + spec, prospective = _pad_version(spec, prospective) + else: + # Convert our spec string into a Version + 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 + # segment. + if not spec.local: + prospective = Version(prospective.public) + + return prospective == spec + + def _compare_not_equal(self, prospective, spec): + return not self._compare_equal(prospective, spec) + + def _compare_less_than_equal(self, prospective, spec): + return prospective <= Version(spec) + + def _compare_greater_than_equal(self, prospective, spec): + return prospective >= Version(spec) + + def _compare_less_than(self, prospective, spec): + # Less than are defined as exclusive operators, this implies that + # pre-releases do not match for the same series as the spec. This is + # implemented by making V imply !=V.*. + return (prospective > Version(spec) + and self._get_operator("!=")(prospective, spec + ".*")) + + def _compare_arbitrary(self, prospective, spec): + return str(prospective).lower() == str(spec).lower() + + @property + def prereleases(self): + # If there is an explicit prereleases set for this, then we'll just + # blindly use that. + if self._prereleases is not None: + return self._prereleases + + # Look at all of our specifiers and determine if they are inclusive + # operators, and if they are if they are including an explicit + # prerelease. + for spec, version in self._specs: + if spec in ["==", ">=", "<=", "~="]: + # The == specifier can include a trailing .*, if it does we + # want to remove before parsing. + if spec == "==" and version.endswith(".*"): + version = version[:-2] + + # Parse the version, and if it is a pre-release than this + # specifier allows pre-releases. + if parse(version).is_prerelease: + return True + + return False + + @prereleases.setter + def prereleases(self, value): + self._prereleases = value + + def contains(self, item, prereleases=None): + # Determine if prereleases are to be allowed or not. + if prereleases is None: + prereleases = self.prereleases + + # Normalize item to a Version or LegacyVersion, this allows us to have + # a shortcut for ``"2.0" in Specifier(">=2") + if isinstance(item, (Version, LegacyVersion)): + version_item = item + else: + try: + version_item = Version(item) + except ValueError: + version_item = LegacyVersion(item) + + # Determine if we should be supporting prereleases in this specifier + # or not, if we do not support prereleases than we can short circuit + # logic if this version is a prereleases. + if version_item.is_prerelease and not prereleases: + return False + + # Detect if we have any specifiers, if we do not then anything matches + # and we can short circuit all this logic. + if not self._specs: + return True + + # If we're operating on a LegacyVersion, then we can only support + # arbitrary comparison so do a quick check to see if the spec contains + # any non arbitrary specifiers + if isinstance(version_item, LegacyVersion): + if any(op != "===" for op, _ in self._specs): + return False + + # Ensure that the passed in version matches all of our version + # specifiers + return all( + self._get_operator(op)( + version_item if op != "===" else item, + spec, + ) + for op, spec, in self._specs + ) + + def filter(self, iterable, prereleases=None): + iterable = list(iterable) + yielded = False + found_prereleases = [] + + kw = {"prereleases": prereleases if prereleases is not None else True} + + # Attempt to iterate over all the values in the iterable and if any of + # them match, yield them. + for version in iterable: + if not isinstance(version, (Version, LegacyVersion)): + parsed_version = parse(version) + else: + parsed_version = version + + if self.contains(parsed_version, **kw): + # If our version is a prerelease, and we were not set to allow + # prereleases, then we'll store it for later incase nothing + # else matches this specifier. + if (parsed_version.is_prerelease + and not (prereleases or self.prereleases)): + found_prereleases.append(version) + # Either this is not a prerelease, or we should have been + # accepting prereleases from the begining. + else: + yielded = True + yield version + + # Now that we've iterated over everything, determine if we've yielded + # any values, and if we have not and we have any prereleases stored up + # then we will go ahead and yield the prereleases. + if not yielded and found_prereleases: + for version in found_prereleases: + yield version + + +_prefix_regex = re.compile(r"^([0-9]+)((?:a|b|c|rc)[0-9]+)$") + + +def _version_split(version): + result = [] + for item in version.split("."): + match = _prefix_regex.search(item) + if match: + result.extend(match.groups()) + else: + result.append(item) + return result + + +def _pad_version(left, right): + left_split, right_split = [], [] + + # Get the release segment of our versions + left_split.append(list(itertools.takewhile(lambda x: x.isdigit(), left))) + right_split.append(list(itertools.takewhile(lambda x: x.isdigit(), right))) + + # Get the rest of our versions + left_split.append(left[len(left_split):]) + right_split.append(left[len(right_split):]) + + # Insert our padding + left_split.insert( + 1, + ["0"] * max(0, len(right_split[0]) - len(left_split[0])), + ) + right_split.insert( + 1, + ["0"] * max(0, len(left_split[0]) - len(right_split[0])), + ) + + return ( + list(itertools.chain(*left_split)), + list(itertools.chain(*right_split)), + ) diff --git a/packaging/version.py b/packaging/version.py index 0affe899..e76e9607 100644 --- a/packaging/version.py +++ b/packaging/version.py @@ -17,13 +17,11 @@ import itertools import re -from ._compat import string_types from ._structures import Infinity __all__ = [ - "parse", "Version", "LegacyVersion", "InvalidVersion", "Specifier", - "InvalidSpecifier", + "parse", "Version", "LegacyVersion", "InvalidVersion", ] @@ -376,411 +374,3 @@ def _cmpkey(epoch, release, pre, post, dev, local): ) return epoch, release, pre, post, dev, local - - -class InvalidSpecifier(ValueError): - """ - An invalid specifier was found, users should refer to PEP 440. - """ - - -class Specifier(object): - - _regex = re.compile( - r""" - ^ - \s* - (?P(~=|==|!=|<=|>=|<|>|===)) - (?P - (?: - # The identity operators allow for an escape hatch that will - # do an exact string match of the version you wish to install. - # This will not be parsed by PEP 440 and we cannot determine - # any semantic meaning from it. This operator is discouraged - # but included entirely as an escape hatch. - (?<====) # Only match for the identity operator - \s* - [^\s]* # We just match everything, except for whitespace - # since we are only testing for strict identity. - ) - | - (?: - # The (non)equality operators allow for wild card and local - # versions to be specified so we have to define these two - # operators separately to enable that. - (?<===|!=) # Only match for equals and not equals - - \s* - v? - (?:[0-9]+!)? # epoch - [0-9]+(?:\.[0-9]+)* # release - (?: # pre release - [-_\.]? - (a|b|c|rc|alpha|beta|pre|preview) - [-_\.]? - [0-9]* - )? - (?: # post release - (?:-[0-9]+)|(?:[-_\.]?(post|rev|r)[-_\.]?[0-9]*) - )? - - # You cannot use a wild card and a dev or local version - # together so group them with a | and make them optional. - (?: - (?:[-_\.]?dev[-_\.]?[0-9]*)? # dev release - (?:\+[a-z0-9]+(?:[-_\.][a-z0-9]+)*)? # local - | - \.\* # Wild card syntax of .* - )? - ) - | - (?: - # The compatible operator requires at least two digits in the - # release segment. - (?<=~=) # Only match for the compatible operator - - \s* - v? - (?:[0-9]+!)? # epoch - [0-9]+(?:\.[0-9]+)+ # release (We have a + instead of a *) - (?: # pre release - [-_\.]? - (a|b|c|rc|alpha|beta|pre|preview) - [-_\.]? - [0-9]* - )? - (?: # post release - (?:-[0-9]+)|(?:[-_\.]?(post|rev|r)[-_\.]?[0-9]*) - )? - (?:[-_\.]?dev[-_\.]?[0-9]*)? # dev release - ) - | - (?: - # All other operators only allow a sub set of what the - # (non)equality operators do. Specifically they do not allow - # local versions to be specified nor do they allow the prefix - # matching wild cards. - (?=": "greater_than_equal", - "<": "less_than", - ">": "greater_than", - "===": "arbitrary", - } - - def __init__(self, specs="", prereleases=None): - # Split on comma to get each individual specification - _specs = set() - for spec in (s for s in specs.split(",") if s): - match = self._regex.search(spec) - if not match: - raise InvalidSpecifier("Invalid specifier: '{0}'".format(spec)) - - _specs.add( - ( - match.group("operator").strip(), - match.group("version").strip(), - ) - ) - - # Set a frozen set for our specifications - self._specs = frozenset(_specs) - - # Store whether or not this Specifier should accept prereleases - self._prereleases = prereleases - - def __repr__(self): - return "".format(repr(str(self))) - - def __str__(self): - return ",".join(["".join(s) for s in sorted(self._specs)]) - - def __hash__(self): - return hash(self._specs) - - def __and__(self, other): - if isinstance(other, string_types): - other = Specifier(other) - elif not isinstance(other, Specifier): - return NotImplemented - - return self.__class__(",".join([str(self), str(other)])) - - def __eq__(self, other): - if isinstance(other, string_types): - other = Specifier(other) - elif not isinstance(other, Specifier): - return NotImplemented - - return self._specs == other._specs - - def __ne__(self, other): - if isinstance(other, string_types): - other = Specifier(other) - elif not isinstance(other, Specifier): - return NotImplemented - - return self._specs != other._specs - - def _get_operator(self, op): - return getattr(self, "_compare_{0}".format(self._operators[op])) - - def _compare_compatible(self, prospective, spec): - # Compatible releases have an equivalent combination of >= and ==. That - # is that ~=2.2 is equivalent to >=2.2,==2.*. This allows us to - # implement this in terms of the other specifiers instead of - # implementing it ourselves. The only thing we need to do is construct - # the other specifiers. - - # We want everything but the last item in the version, but we want to - # ignore post and dev releases and we want to treat the pre-release as - # it's own separate segment. - prefix = ".".join( - list( - itertools.takewhile( - lambda x: (not x.startswith("post") - and not x.startswith("dev")), - _version_split(spec), - ) - )[:-1] - ) - - # Add the prefix notation to the end of our string - prefix += ".*" - - return (self._get_operator(">=")(prospective, spec) - and self._get_operator("==")(prospective, prefix)) - - def _compare_equal(self, prospective, spec): - # We need special logic to handle prefix matching - if spec.endswith(".*"): - # Split the spec out by dots, and pretend that there is an implicit - # dot in between a release segment and a pre-release segment. - spec = _version_split(spec[:-2]) # Remove the trailing .* - - # Split the prospective version out by dots, and pretend that there - # is an implicit dot in between a release segment and a pre-release - # segment. - prospective = _version_split(str(prospective)) - - # Shorten the prospective version to be the same length as the spec - # so that we can determine if the specifier is a prefix of the - # prospective version or not. - prospective = prospective[:len(spec)] - - # Pad out our two sides with zeros so that they both equal the same - # length. - spec, prospective = _pad_version(spec, prospective) - else: - # Convert our spec string into a Version - 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 - # segment. - if not spec.local: - prospective = Version(prospective.public) - - return prospective == spec - - def _compare_not_equal(self, prospective, spec): - return not self._compare_equal(prospective, spec) - - def _compare_less_than_equal(self, prospective, spec): - return prospective <= Version(spec) - - def _compare_greater_than_equal(self, prospective, spec): - return prospective >= Version(spec) - - def _compare_less_than(self, prospective, spec): - # Less than are defined as exclusive operators, this implies that - # pre-releases do not match for the same series as the spec. This is - # implemented by making V imply !=V.*. - return (prospective > Version(spec) - and self._get_operator("!=")(prospective, spec + ".*")) - - def _compare_arbitrary(self, prospective, spec): - return str(prospective).lower() == str(spec).lower() - - @property - def prereleases(self): - # If there is an explicit prereleases set for this, then we'll just - # blindly use that. - if self._prereleases is not None: - return self._prereleases - - # Look at all of our specifiers and determine if they are inclusive - # operators, and if they are if they are including an explicit - # prerelease. - for spec, version in self._specs: - if spec in ["==", ">=", "<=", "~="]: - # The == specifier can include a trailing .*, if it does we - # want to remove before parsing. - if spec == "==" and version.endswith(".*"): - version = version[:-2] - - # Parse the version, and if it is a pre-release than this - # specifier allows pre-releases. - if parse(version).is_prerelease: - return True - - return False - - @prereleases.setter - def prereleases(self, value): - self._prereleases = value - - def contains(self, item, prereleases=None): - # Determine if prereleases are to be allowed or not. - if prereleases is None: - prereleases = self.prereleases - - # Normalize item to a Version or LegacyVersion, this allows us to have - # a shortcut for ``"2.0" in Specifier(">=2") - if isinstance(item, (Version, LegacyVersion)): - version_item = item - else: - try: - version_item = Version(item) - except ValueError: - version_item = LegacyVersion(item) - - # Determine if we should be supporting prereleases in this specifier - # or not, if we do not support prereleases than we can short circuit - # logic if this version is a prereleases. - if version_item.is_prerelease and not prereleases: - return False - - # Detect if we have any specifiers, if we do not then anything matches - # and we can short circuit all this logic. - if not self._specs: - return True - - # If we're operating on a LegacyVersion, then we can only support - # arbitrary comparison so do a quick check to see if the spec contains - # any non arbitrary specifiers - if isinstance(version_item, LegacyVersion): - if any(op != "===" for op, _ in self._specs): - return False - - # Ensure that the passed in version matches all of our version - # specifiers - return all( - self._get_operator(op)( - version_item if op != "===" else item, - spec, - ) - for op, spec, in self._specs - ) - - def filter(self, iterable, prereleases=None): - iterable = list(iterable) - yielded = False - found_prereleases = [] - - kw = {"prereleases": prereleases if prereleases is not None else True} - - # Attempt to iterate over all the values in the iterable and if any of - # them match, yield them. - for version in iterable: - if not isinstance(version, (Version, LegacyVersion)): - parsed_version = parse(version) - else: - parsed_version = version - - if self.contains(parsed_version, **kw): - # If our version is a prerelease, and we were not set to allow - # prereleases, then we'll store it for later incase nothing - # else matches this specifier. - if (parsed_version.is_prerelease - and not (prereleases or self.prereleases)): - found_prereleases.append(version) - # Either this is not a prerelease, or we should have been - # accepting prereleases from the begining. - else: - yielded = True - yield version - - # Now that we've iterated over everything, determine if we've yielded - # any values, and if we have not and we have any prereleases stored up - # then we will go ahead and yield the prereleases. - if not yielded and found_prereleases: - for version in found_prereleases: - yield version - - -_prefix_regex = re.compile(r"^([0-9]+)((?:a|b|c|rc)[0-9]+)$") - - -def _version_split(version): - result = [] - for item in version.split("."): - match = _prefix_regex.search(item) - if match: - result.extend(match.groups()) - else: - result.append(item) - return result - - -def _pad_version(left, right): - left_split, right_split = [], [] - - # Get the release segment of our versions - left_split.append(list(itertools.takewhile(lambda x: x.isdigit(), left))) - right_split.append(list(itertools.takewhile(lambda x: x.isdigit(), right))) - - # Get the rest of our versions - left_split.append(left[len(left_split):]) - right_split.append(left[len(right_split):]) - - # Insert our padding - left_split.insert( - 1, - ["0"] * max(0, len(right_split[0]) - len(left_split[0])), - ) - right_split.insert( - 1, - ["0"] * max(0, len(left_split[0]) - len(right_split[0])), - ) - - return ( - list(itertools.chain(*left_split)), - list(itertools.chain(*right_split)), - ) diff --git a/tests/test_specifiers.py b/tests/test_specifiers.py new file mode 100644 index 00000000..09be74ed --- /dev/null +++ b/tests/test_specifiers.py @@ -0,0 +1,703 @@ +# Copyright 2014 Donald Stufft +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import absolute_import, division, print_function + +import itertools +import operator +import re + +import pytest + +from packaging.specifiers import InvalidSpecifier, Specifier +from packaging.version import Version + +from .test_version import VERSIONS, LEGACY_VERSIONS + + +# These should all be without spaces, we'll generate some with spaces using +# these as templates. +SPECIFIERS = [ + "~=2.0", "==2.1.*", "==2.1.0.3", "!=2.2.*", "!=2.2.0.5", "<=5", ">=7.9a1", + "<1.0.dev1", ">2.0.post1", "===lolwat", +] + + +class TestSpecifier: + + @pytest.mark.parametrize( + "specifier", + # Generate all possible combinations of the SPECIFIERS to test to make + # sure they all work. + [ + ",".join(combination) + for combination in itertools.chain(*( + itertools.combinations(SPECIFIERS, n) + for n in range(1, len(SPECIFIERS) + 1) + )) + ] + + + # Do the same thing, except include spaces in the specifiers + [ + ",".join([ + " ".join(re.split(r"(===|~=|==|!=|<=|>=|<|>)", item)[1:]) + for item in combination + ]) + for combination in itertools.chain(*( + itertools.combinations(SPECIFIERS, n) + for n in range(1, len(SPECIFIERS) + 1) + )) + ] + + + # Finally do the same thing once more, except join some with spaces and + # some without. + [ + ",".join([ + ("" if j % 2 else " ").join( + re.split(r"(===|~=|==|!=|<=|>=|<|>)", item)[1:] + ) + for j, item in enumerate(combination) + ]) + for combination in itertools.chain(*( + itertools.combinations(SPECIFIERS, n) + for n in range(1, len(SPECIFIERS) + 1) + )) + ] + ) + def test_specifiers_valid(self, specifier): + Specifier(specifier) + + @pytest.mark.parametrize( + "specifier", + [ + # Operator-less specifier + "2.0", + + # Invalid operator + "=>2.0", + + # Version-less specifier + "==", + + # Local segment on operators which don't support them + "~=1.0+5", + ">=1.0+deadbeef", + "<=1.0+abc123", + ">1.0+watwat", + "<1.0+1.0", + + # Prefix matching on operators which don't support them + "~=1.0.*", + ">=1.0.*", + "<=1.0.*", + ">1.0.*", + "<1.0.*", + + # Combination of local and prefix matching on operators which do + # support one or the other + "==1.0.*+5", + "!=1.0.*+deadbeef", + + # Prefix matching cannot be used inside of a local version + "==1.0+5.*", + "!=1.0+deadbeef.*", + + # Prefix matching must appear at the end + "==1.0.*.5", + + # Compatible operator requires 2 digits in the release operator + "~=1", + + # Cannot use a prefix matching after a .devN version + "==1.0.dev1.*", + "!=1.0.dev1.*", + ], + ) + def test_specifiers_invalid(self, specifier): + with pytest.raises(InvalidSpecifier): + Specifier(specifier) + + @pytest.mark.parametrize( + "version", + [ + # Various development release incarnations + "1.0dev", + "1.0.dev", + "1.0dev1", + "1.0dev", + "1.0-dev", + "1.0-dev1", + "1.0DEV", + "1.0.DEV", + "1.0DEV1", + "1.0DEV", + "1.0.DEV1", + "1.0-DEV", + "1.0-DEV1", + + # Various alpha incarnations + "1.0a", + "1.0.a", + "1.0.a1", + "1.0-a", + "1.0-a1", + "1.0alpha", + "1.0.alpha", + "1.0.alpha1", + "1.0-alpha", + "1.0-alpha1", + "1.0A", + "1.0.A", + "1.0.A1", + "1.0-A", + "1.0-A1", + "1.0ALPHA", + "1.0.ALPHA", + "1.0.ALPHA1", + "1.0-ALPHA", + "1.0-ALPHA1", + + # Various beta incarnations + "1.0b", + "1.0.b", + "1.0.b1", + "1.0-b", + "1.0-b1", + "1.0beta", + "1.0.beta", + "1.0.beta1", + "1.0-beta", + "1.0-beta1", + "1.0B", + "1.0.B", + "1.0.B1", + "1.0-B", + "1.0-B1", + "1.0BETA", + "1.0.BETA", + "1.0.BETA1", + "1.0-BETA", + "1.0-BETA1", + + # Various release candidate incarnations + "1.0c", + "1.0.c", + "1.0.c1", + "1.0-c", + "1.0-c1", + "1.0rc", + "1.0.rc", + "1.0.rc1", + "1.0-rc", + "1.0-rc1", + "1.0C", + "1.0.C", + "1.0.C1", + "1.0-C", + "1.0-C1", + "1.0RC", + "1.0.RC", + "1.0.RC1", + "1.0-RC", + "1.0-RC1", + + # Various post release incarnations + "1.0post", + "1.0.post", + "1.0post1", + "1.0post", + "1.0-post", + "1.0-post1", + "1.0POST", + "1.0.POST", + "1.0POST1", + "1.0POST", + "1.0.POST1", + "1.0-POST", + "1.0-POST1", + "1.0-5", + + # Local version case insensitivity + "1.0+AbC" + + # Integer Normalization + "1.01", + "1.0a05", + "1.0b07", + "1.0c056", + "1.0rc09", + "1.0.post000", + "1.1.dev09000", + "00!1.2", + "0100!0.0", + + # Various other normalizations + "v1.0", + " \r \f \v v1.0\t\n", + ], + ) + def test_specifiers_normalized(self, version): + if "+" not in version: + ops = ["~=", "==", "!=", "<=", ">=", "<", ">"] + else: + ops = ["==", "!="] + + for op in ops: + Specifier(op + version) + + @pytest.mark.parametrize( + ("specifier", "expected"), + [ + # Single item specifiers should just be reflexive + ("!=2.0", "!=2.0"), + ("<2.0", "<2.0"), + ("<=2.0", "<=2.0"), + ("==2.0", "==2.0"), + (">2.0", ">2.0"), + (">=2.0", ">=2.0"), + ("~=2.0", "~=2.0"), + + # Multiple item specifiers should be sorted lexicographically + ("<2,!=1.5", "!=1.5,<2"), + ( + "~=1.3.5,>5.3,==1.3.*,<=700,>=0,!=99.99,<1000", + "!=99.99,<1000,<=700,==1.3.*,>5.3,>=0,~=1.3.5", + ), + + # Spaces should be removed + ("== 2.0", "==2.0"), + (">=2.0, !=2.1.0", "!=2.1.0,>=2.0"), + ("< 2, >= 5,~= 2.2,==5.4", "<2,==5.4,>=5,~=2.2"), + ], + ) + def test_specifiers_str_and_repr(self, specifier, expected): + spec = Specifier(specifier) + + assert str(spec) == expected + assert repr(spec) == "".format(repr(expected)) + + @pytest.mark.parametrize("specifier", SPECIFIERS) + def test_specifiers_hash(self, specifier): + assert hash(Specifier(specifier)) == hash(Specifier(specifier)) + + @pytest.mark.parametrize( + "specifiers", + [ + ["!=2", "==2.*"], + [">=5.7", "<7000"], + ["==2.5.0+3", ">1"], + ], + ) + def test_combining_specifiers(self, specifiers): + # Test combining Specifier objects + spec = Specifier(specifiers[0]) + for s in specifiers[1:]: + spec &= Specifier(s) + assert spec == Specifier(",".join(specifiers)) + + # Test combining a string with a Specifier object + spec = Specifier(specifiers[0]) + for s in specifiers[1:]: + spec &= s + assert spec == Specifier(",".join(specifiers)) + + def test_combining_non_specifiers(self): + with pytest.raises(TypeError): + Specifier("==2.0") & 12 + + @pytest.mark.parametrize( + ("left", "right", "op"), + itertools.chain( + * + # Verify that the equal (==) operator works correctly + [ + [(x, x, operator.eq) for x in SPECIFIERS] + ] + + + # Verify that the not equal (!=) operator works correctly + [ + [ + (x, y, operator.ne) + for j, y in enumerate(SPECIFIERS) + if i != j + ] + for i, x in enumerate(SPECIFIERS) + ] + ) + ) + def test_comparison_true(self, left, right, op): + assert op(Specifier(left), Specifier(right)) + assert op(left, Specifier(right)) + assert op(Specifier(left), right) + + @pytest.mark.parametrize( + ("left", "right", "op"), + itertools.chain( + * + # Verify that the equal (==) operator works correctly + [ + [(x, x, operator.ne) for x in SPECIFIERS] + ] + + + # Verify that the not equal (!=) operator works correctly + [ + [ + (x, y, operator.eq) + for j, y in enumerate(SPECIFIERS) + if i != j + ] + for i, x in enumerate(SPECIFIERS) + ] + ) + ) + def test_comparison_false(self, left, right, op): + assert not op(Specifier(left), Specifier(right)) + assert not op(left, Specifier(right)) + assert not op(Specifier(left), right) + + def test_comparison_non_specifier(self): + assert Specifier("==1.0") != 12 + assert not Specifier("==1.0") == 12 + + @pytest.mark.parametrize( + ("version", "spec", "expected"), + [ + (v, s, True) + for v, s in [ + # Test the equality operation + ("2.0", "==2"), + ("2.0", "==2.0"), + ("2.0", "==2.0.0"), + ("2.0+deadbeef", "==2"), + ("2.0+deadbeef", "==2.0"), + ("2.0+deadbeef", "==2.0.0"), + ("2.0+deadbeef", "==2+deadbeef"), + ("2.0+deadbeef", "==2.0+deadbeef"), + ("2.0+deadbeef", "==2.0.0+deadbeef"), + ("2.0+deadbeef.0", "==2.0.0+deadbeef.00"), + + # Test the equality operation with a prefix + ("2.dev1", "==2.*"), + ("2a1", "==2.*"), + ("2a1.post1", "==2.*"), + ("2b1", "==2.*"), + ("2b1.dev1", "==2.*"), + ("2c1", "==2.*"), + ("2c1.post1.dev1", "==2.*"), + ("2rc1", "==2.*"), + ("2", "==2.*"), + ("2.0", "==2.*"), + ("2.0.0", "==2.*"), + ("2.0.post1", "==2.0.post1.*"), + ("2.0.post1.dev1", "==2.0.post1.*"), + + # Test the in-equality operation + ("2.1", "!=2"), + ("2.1", "!=2.0"), + ("2.0.1", "!=2"), + ("2.0.1", "!=2.0"), + ("2.0.1", "!=2.0.0"), + ("2.0", "!=2.0+deadbeef"), + + # Test the in-equality operation with a prefix + ("2.0", "!=3.*"), + ("2.1", "!=2.0.*"), + + # Test the greater than equal operation + ("2.0", ">=2"), + ("2.0", ">=2.0"), + ("2.0", ">=2.0.0"), + ("2.0.post1", ">=2"), + ("2.0.post1.dev1", ">=2"), + ("3", ">=2"), + + # Test the less than equal operation + ("2.0", "<=2"), + ("2.0", "<=2.0"), + ("2.0", "<=2.0.0"), + ("2.0.dev1", "<=2"), + ("2.0a1", "<=2"), + ("2.0a1.dev1", "<=2"), + ("2.0b1", "<=2"), + ("2.0b1.post1", "<=2"), + ("2.0c1", "<=2"), + ("2.0c1.post1.dev1", "<=2"), + ("2.0rc1", "<=2"), + ("1", "<=2"), + + # Test the greater than operation + ("3", ">2"), + ("2.1", ">2.0"), + + # Test the less than operation + ("1", "<2"), + ("2.0", "<2.1"), + + # Test the compatibility operation + ("1", "~=1.0"), + ("1.0.1", "~=1.0"), + ("1.1", "~=1.0"), + ("1.9999999", "~=1.0"), + + # Test that epochs are handled sanely + ("2!1.0", "~=2!1.0"), + ("2!1.0", "==2!1.*"), + ("2!1.0", "==2!1.0"), + ("2!1.0", "!=1.0"), + ("1.0", "!=2!1.0"), + ("1.0", "<=2!0.1"), + ("2!1.0", ">=2.0"), + ("1.0", "<2!0.1"), + ("2!1.0", ">2.0"), + ] + ] + + + [ + (v, s, False) + for v, s in [ + # Test the equality operation + ("2.1", "==2"), + ("2.1", "==2.0"), + ("2.1", "==2.0.0"), + ("2.0", "==2.0+deadbeef"), + + # Test the equality operation with a prefix + ("2.0", "==3.*"), + ("2.1", "==2.0.*"), + + # Test the in-equality operation + ("2.0", "!=2"), + ("2.0", "!=2.0"), + ("2.0", "!=2.0.0"), + ("2.0+deadbeef", "!=2"), + ("2.0+deadbeef", "!=2.0"), + ("2.0+deadbeef", "!=2.0.0"), + ("2.0+deadbeef", "!=2+deadbeef"), + ("2.0+deadbeef", "!=2.0+deadbeef"), + ("2.0+deadbeef", "!=2.0.0+deadbeef"), + ("2.0+deadbeef.0", "!=2.0.0+deadbeef.00"), + + # Test the in-equality operation with a prefix + ("2.dev1", "!=2.*"), + ("2a1", "!=2.*"), + ("2a1.post1", "!=2.*"), + ("2b1", "!=2.*"), + ("2b1.dev1", "!=2.*"), + ("2c1", "!=2.*"), + ("2c1.post1.dev1", "!=2.*"), + ("2rc1", "!=2.*"), + ("2", "!=2.*"), + ("2.0", "!=2.*"), + ("2.0.0", "!=2.*"), + ("2.0.post1", "!=2.0.post1.*"), + ("2.0.post1.dev1", "!=2.0.post1.*"), + + # Test the greater than equal operation + ("2.0.dev1", ">=2"), + ("2.0a1", ">=2"), + ("2.0a1.dev1", ">=2"), + ("2.0b1", ">=2"), + ("2.0b1.post1", ">=2"), + ("2.0c1", ">=2"), + ("2.0c1.post1.dev1", ">=2"), + ("2.0rc1", ">=2"), + ("1", ">=2"), + + # Test the less than equal operation + ("2.0.post1", "<=2"), + ("2.0.post1.dev1", "<=2"), + ("3", "<=2"), + + # Test the greater than operation + ("1", ">2"), + ("2.0.dev1", ">2"), + ("2.0a1", ">2"), + ("2.0a1.post1", ">2"), + ("2.0b1", ">2"), + ("2.0b1.dev1", ">2"), + ("2.0c1", ">2"), + ("2.0c1.post1.dev1", ">2"), + ("2.0rc1", ">2"), + ("2.0", ">2"), + ("2.0.post1", ">2"), + ("2.0.post1.dev1", ">2"), + ("2.0.1", ">2"), + + # Test the less than operation + ("2.0.dev1", "<2"), + ("2.0a1", "<2"), + ("2.0a1.post1", "<2"), + ("2.0b1", "<2"), + ("2.0b2.dev1", "<2"), + ("2.0c1", "<2"), + ("2.0c1.post1.dev1", "<2"), + ("2.0rc1", "<2"), + ("2.0", "<2"), + ("2.post1", "<2"), + ("2.post1.dev1", "<2"), + ("3", "<2"), + + # Test the compatibility operation + ("2.0", "~=1.0"), + ("1.1.0", "~=1.0.0"), + ("1.1.post1", "~=1.0.0"), + + # Test that epochs are handled sanely + ("1.0", "~=2!1.0"), + ("2!1.0", "~=1.0"), + ("2!1.0", "==1.0"), + ("1.0", "==2!1.0"), + ("2!1.0", "==1.*"), + ("1.0", "==2!1.*"), + ("2!1.0", "!=2!1.0"), + ] + ], + ) + def test_specifiers(self, version, spec, expected): + spec = Specifier(spec, prereleases=True) + + if expected: + # Test that the plain string form works + assert spec.contains(version) + + # Test that the version instance form works + assert spec.contains(Version(version)) + else: + # Test that the plain string form works + assert not spec.contains(version) + + # Test that the version instance form works + assert not spec.contains(Version(version)) + + @pytest.mark.parametrize( + ("version", "spec", "expected"), + [ + # Test identity comparison by itself + ("lolwat", "===lolwat", True), + ("Lolwat", "===lolwat", True), + ("1.0", "===1.0", True), + ("nope", "===lolwat", False), + ("1.0.0", "===1.0", False), + + # Test multiple specs combined with an identity comparison + ("nope", "===nope,!=1.0", False), + ("1.0.0", "===1.0.0,==1.*", True), + ("1.0.0", "===1.0,==1.*", False), + ], + ) + def test_specifiers_identity(self, version, spec, expected): + spec = Specifier(spec) + + if expected: + # Identity comparisons only support the plain string form + assert spec.contains(version) + else: + # Identity comparisons only support the plain string form + assert not spec.contains(version) + + @pytest.mark.parametrize( + "version", + VERSIONS + LEGACY_VERSIONS, + ) + def test_empty_specifier(self, version): + spec = Specifier(prereleases=True) + + assert spec.contains(version) + + @pytest.mark.parametrize( + ("specifier", "expected"), + [ + ("==1.0", False), + ("", False), + (">=1.0", False), + ("<=1.0", False), + ("~=1.0", False), + ("<1.0", False), + (">1.0", False), + ("<1.0.dev1", False), + (">1.0.dev1", False), + ("==1.0.*", False), + ("==1.0.dev1", True), + (">=1.0.dev1", True), + ("<=1.0.dev1", True), + ("~=1.0.dev1", True), + ], + ) + def test_specifier_prereleases_detection(self, specifier, expected): + assert Specifier(specifier).prereleases == expected + + def test_specifier_prereleases_explicit(self): + spec = Specifier() + assert not spec.prereleases + assert not spec.contains("1.0.dev1") + spec.prereleases = True + assert spec.prereleases + assert spec.contains("1.0.dev1") + + spec = Specifier(prereleases=True) + assert spec.prereleases + assert spec.contains("1.0.dev1") + spec.prereleases = False + assert not spec.prereleases + assert not spec.contains("1.0.dev1") + + spec = Specifier(prereleases=True) + assert spec.prereleases + assert spec.contains("1.0.dev1") + spec.prereleases = None + assert not spec.prereleases + assert not spec.contains("1.0.dev1") + + @pytest.mark.parametrize( + ("specifier", "version", "expected"), + [ + (">=1.0", "2.0.dev1", False), + (">=2.0.dev1", "2.0a1", True), + ("==2.0.*", "2.0a1.dev1", False), + ("==2.0a1.*", "2.0a1.dev1", True), + ("<=2.0", "1.0.dev1", False), + ("<=2.0.dev1", "1.0a1", True), + ], + ) + def test_specifiers_prereleases(self, specifier, version, expected): + spec = Specifier(specifier) + + if expected: + assert spec.contains(version) + spec.prereleases = False + assert not spec.contains(version) + else: + assert not spec.contains(version) + spec.prereleases = True + assert spec.contains(version) + + @pytest.mark.parametrize( + ("specifier", "prereleases", "input", "expected"), + [ + ("", None, ["1.0", "2.0a1"], ["1.0"]), + (">=1.0.dev1", None, ["1.0", "2.0a1"], ["1.0", "2.0a1"]), + ("", None, ["1.0a1"], ["1.0a1"]), + ("", False, ["1.0a1"], []), + (">=1.0.dev1", False, ["1.0", "2.0a1"], ["1.0"]), + ("", True, ["1.0", "2.0a1"], ["1.0", "2.0a1"]), + ("", None, ["1.0", Version("2.0")], ["1.0", Version("2.0")]), + ], + ) + def test_specifier_filter(self, specifier, prereleases, input, expected): + spec = Specifier(specifier) + + kwargs = ( + {"prereleases": prereleases} if prereleases is not None else {} + ) + + assert list(spec.filter(input, **kwargs)) == expected diff --git a/tests/test_version.py b/tests/test_version.py index ec85668f..46967247 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -15,14 +15,11 @@ import itertools import operator -import re import pretend import pytest -from packaging.version import ( - Version, LegacyVersion, InvalidVersion, Specifier, InvalidSpecifier, parse, -) +from packaging.version import Version, LegacyVersion, InvalidVersion, parse @pytest.mark.parametrize( @@ -559,681 +556,3 @@ def test_compare_other(self, op, expected): ) assert getattr(operator, op)(LegacyVersion("1"), other) is expected - - -# These should all be without spaces, we'll generate some with spaces using -# these as templates. -SPECIFIERS = [ - "~=2.0", "==2.1.*", "==2.1.0.3", "!=2.2.*", "!=2.2.0.5", "<=5", ">=7.9a1", - "<1.0.dev1", ">2.0.post1", "===lolwat", -] - - -class TestSpecifier: - - @pytest.mark.parametrize( - "specifier", - # Generate all possible combinations of the SPECIFIERS to test to make - # sure they all work. - [ - ",".join(combination) - for combination in itertools.chain(*( - itertools.combinations(SPECIFIERS, n) - for n in range(1, len(SPECIFIERS) + 1) - )) - ] - + - # Do the same thing, except include spaces in the specifiers - [ - ",".join([ - " ".join(re.split(r"(===|~=|==|!=|<=|>=|<|>)", item)[1:]) - for item in combination - ]) - for combination in itertools.chain(*( - itertools.combinations(SPECIFIERS, n) - for n in range(1, len(SPECIFIERS) + 1) - )) - ] - + - # Finally do the same thing once more, except join some with spaces and - # some without. - [ - ",".join([ - ("" if j % 2 else " ").join( - re.split(r"(===|~=|==|!=|<=|>=|<|>)", item)[1:] - ) - for j, item in enumerate(combination) - ]) - for combination in itertools.chain(*( - itertools.combinations(SPECIFIERS, n) - for n in range(1, len(SPECIFIERS) + 1) - )) - ] - ) - def test_specifiers_valid(self, specifier): - Specifier(specifier) - - @pytest.mark.parametrize( - "specifier", - [ - # Operator-less specifier - "2.0", - - # Invalid operator - "=>2.0", - - # Version-less specifier - "==", - - # Local segment on operators which don't support them - "~=1.0+5", - ">=1.0+deadbeef", - "<=1.0+abc123", - ">1.0+watwat", - "<1.0+1.0", - - # Prefix matching on operators which don't support them - "~=1.0.*", - ">=1.0.*", - "<=1.0.*", - ">1.0.*", - "<1.0.*", - - # Combination of local and prefix matching on operators which do - # support one or the other - "==1.0.*+5", - "!=1.0.*+deadbeef", - - # Prefix matching cannot be used inside of a local version - "==1.0+5.*", - "!=1.0+deadbeef.*", - - # Prefix matching must appear at the end - "==1.0.*.5", - - # Compatible operator requires 2 digits in the release operator - "~=1", - - # Cannot use a prefix matching after a .devN version - "==1.0.dev1.*", - "!=1.0.dev1.*", - ], - ) - def test_specifiers_invalid(self, specifier): - with pytest.raises(InvalidSpecifier): - Specifier(specifier) - - @pytest.mark.parametrize( - "version", - [ - # Various development release incarnations - "1.0dev", - "1.0.dev", - "1.0dev1", - "1.0dev", - "1.0-dev", - "1.0-dev1", - "1.0DEV", - "1.0.DEV", - "1.0DEV1", - "1.0DEV", - "1.0.DEV1", - "1.0-DEV", - "1.0-DEV1", - - # Various alpha incarnations - "1.0a", - "1.0.a", - "1.0.a1", - "1.0-a", - "1.0-a1", - "1.0alpha", - "1.0.alpha", - "1.0.alpha1", - "1.0-alpha", - "1.0-alpha1", - "1.0A", - "1.0.A", - "1.0.A1", - "1.0-A", - "1.0-A1", - "1.0ALPHA", - "1.0.ALPHA", - "1.0.ALPHA1", - "1.0-ALPHA", - "1.0-ALPHA1", - - # Various beta incarnations - "1.0b", - "1.0.b", - "1.0.b1", - "1.0-b", - "1.0-b1", - "1.0beta", - "1.0.beta", - "1.0.beta1", - "1.0-beta", - "1.0-beta1", - "1.0B", - "1.0.B", - "1.0.B1", - "1.0-B", - "1.0-B1", - "1.0BETA", - "1.0.BETA", - "1.0.BETA1", - "1.0-BETA", - "1.0-BETA1", - - # Various release candidate incarnations - "1.0c", - "1.0.c", - "1.0.c1", - "1.0-c", - "1.0-c1", - "1.0rc", - "1.0.rc", - "1.0.rc1", - "1.0-rc", - "1.0-rc1", - "1.0C", - "1.0.C", - "1.0.C1", - "1.0-C", - "1.0-C1", - "1.0RC", - "1.0.RC", - "1.0.RC1", - "1.0-RC", - "1.0-RC1", - - # Various post release incarnations - "1.0post", - "1.0.post", - "1.0post1", - "1.0post", - "1.0-post", - "1.0-post1", - "1.0POST", - "1.0.POST", - "1.0POST1", - "1.0POST", - "1.0.POST1", - "1.0-POST", - "1.0-POST1", - "1.0-5", - - # Local version case insensitivity - "1.0+AbC" - - # Integer Normalization - "1.01", - "1.0a05", - "1.0b07", - "1.0c056", - "1.0rc09", - "1.0.post000", - "1.1.dev09000", - "00!1.2", - "0100!0.0", - - # Various other normalizations - "v1.0", - " \r \f \v v1.0\t\n", - ], - ) - def test_specifiers_normalized(self, version): - if "+" not in version: - ops = ["~=", "==", "!=", "<=", ">=", "<", ">"] - else: - ops = ["==", "!="] - - for op in ops: - Specifier(op + version) - - @pytest.mark.parametrize( - ("specifier", "expected"), - [ - # Single item specifiers should just be reflexive - ("!=2.0", "!=2.0"), - ("<2.0", "<2.0"), - ("<=2.0", "<=2.0"), - ("==2.0", "==2.0"), - (">2.0", ">2.0"), - (">=2.0", ">=2.0"), - ("~=2.0", "~=2.0"), - - # Multiple item specifiers should be sorted lexicographically - ("<2,!=1.5", "!=1.5,<2"), - ( - "~=1.3.5,>5.3,==1.3.*,<=700,>=0,!=99.99,<1000", - "!=99.99,<1000,<=700,==1.3.*,>5.3,>=0,~=1.3.5", - ), - - # Spaces should be removed - ("== 2.0", "==2.0"), - (">=2.0, !=2.1.0", "!=2.1.0,>=2.0"), - ("< 2, >= 5,~= 2.2,==5.4", "<2,==5.4,>=5,~=2.2"), - ], - ) - def test_specifiers_str_and_repr(self, specifier, expected): - spec = Specifier(specifier) - - assert str(spec) == expected - assert repr(spec) == "".format(repr(expected)) - - @pytest.mark.parametrize("specifier", SPECIFIERS) - def test_specifiers_hash(self, specifier): - assert hash(Specifier(specifier)) == hash(Specifier(specifier)) - - @pytest.mark.parametrize( - "specifiers", - [ - ["!=2", "==2.*"], - [">=5.7", "<7000"], - ["==2.5.0+3", ">1"], - ], - ) - def test_combining_specifiers(self, specifiers): - # Test combining Specifier objects - spec = Specifier(specifiers[0]) - for s in specifiers[1:]: - spec &= Specifier(s) - assert spec == Specifier(",".join(specifiers)) - - # Test combining a string with a Specifier object - spec = Specifier(specifiers[0]) - for s in specifiers[1:]: - spec &= s - assert spec == Specifier(",".join(specifiers)) - - def test_combining_non_specifiers(self): - with pytest.raises(TypeError): - Specifier("==2.0") & 12 - - @pytest.mark.parametrize( - ("left", "right", "op"), - itertools.chain( - * - # Verify that the equal (==) operator works correctly - [ - [(x, x, operator.eq) for x in SPECIFIERS] - ] - + - # Verify that the not equal (!=) operator works correctly - [ - [ - (x, y, operator.ne) - for j, y in enumerate(SPECIFIERS) - if i != j - ] - for i, x in enumerate(SPECIFIERS) - ] - ) - ) - def test_comparison_true(self, left, right, op): - assert op(Specifier(left), Specifier(right)) - assert op(left, Specifier(right)) - assert op(Specifier(left), right) - - @pytest.mark.parametrize( - ("left", "right", "op"), - itertools.chain( - * - # Verify that the equal (==) operator works correctly - [ - [(x, x, operator.ne) for x in SPECIFIERS] - ] - + - # Verify that the not equal (!=) operator works correctly - [ - [ - (x, y, operator.eq) - for j, y in enumerate(SPECIFIERS) - if i != j - ] - for i, x in enumerate(SPECIFIERS) - ] - ) - ) - def test_comparison_false(self, left, right, op): - assert not op(Specifier(left), Specifier(right)) - assert not op(left, Specifier(right)) - assert not op(Specifier(left), right) - - def test_comparison_non_specifier(self): - assert Specifier("==1.0") != 12 - assert not Specifier("==1.0") == 12 - - @pytest.mark.parametrize( - ("version", "spec", "expected"), - [ - (v, s, True) - for v, s in [ - # Test the equality operation - ("2.0", "==2"), - ("2.0", "==2.0"), - ("2.0", "==2.0.0"), - ("2.0+deadbeef", "==2"), - ("2.0+deadbeef", "==2.0"), - ("2.0+deadbeef", "==2.0.0"), - ("2.0+deadbeef", "==2+deadbeef"), - ("2.0+deadbeef", "==2.0+deadbeef"), - ("2.0+deadbeef", "==2.0.0+deadbeef"), - ("2.0+deadbeef.0", "==2.0.0+deadbeef.00"), - - # Test the equality operation with a prefix - ("2.dev1", "==2.*"), - ("2a1", "==2.*"), - ("2a1.post1", "==2.*"), - ("2b1", "==2.*"), - ("2b1.dev1", "==2.*"), - ("2c1", "==2.*"), - ("2c1.post1.dev1", "==2.*"), - ("2rc1", "==2.*"), - ("2", "==2.*"), - ("2.0", "==2.*"), - ("2.0.0", "==2.*"), - ("2.0.post1", "==2.0.post1.*"), - ("2.0.post1.dev1", "==2.0.post1.*"), - - # Test the in-equality operation - ("2.1", "!=2"), - ("2.1", "!=2.0"), - ("2.0.1", "!=2"), - ("2.0.1", "!=2.0"), - ("2.0.1", "!=2.0.0"), - ("2.0", "!=2.0+deadbeef"), - - # Test the in-equality operation with a prefix - ("2.0", "!=3.*"), - ("2.1", "!=2.0.*"), - - # Test the greater than equal operation - ("2.0", ">=2"), - ("2.0", ">=2.0"), - ("2.0", ">=2.0.0"), - ("2.0.post1", ">=2"), - ("2.0.post1.dev1", ">=2"), - ("3", ">=2"), - - # Test the less than equal operation - ("2.0", "<=2"), - ("2.0", "<=2.0"), - ("2.0", "<=2.0.0"), - ("2.0.dev1", "<=2"), - ("2.0a1", "<=2"), - ("2.0a1.dev1", "<=2"), - ("2.0b1", "<=2"), - ("2.0b1.post1", "<=2"), - ("2.0c1", "<=2"), - ("2.0c1.post1.dev1", "<=2"), - ("2.0rc1", "<=2"), - ("1", "<=2"), - - # Test the greater than operation - ("3", ">2"), - ("2.1", ">2.0"), - - # Test the less than operation - ("1", "<2"), - ("2.0", "<2.1"), - - # Test the compatibility operation - ("1", "~=1.0"), - ("1.0.1", "~=1.0"), - ("1.1", "~=1.0"), - ("1.9999999", "~=1.0"), - - # Test that epochs are handled sanely - ("2!1.0", "~=2!1.0"), - ("2!1.0", "==2!1.*"), - ("2!1.0", "==2!1.0"), - ("2!1.0", "!=1.0"), - ("1.0", "!=2!1.0"), - ("1.0", "<=2!0.1"), - ("2!1.0", ">=2.0"), - ("1.0", "<2!0.1"), - ("2!1.0", ">2.0"), - ] - ] - + - [ - (v, s, False) - for v, s in [ - # Test the equality operation - ("2.1", "==2"), - ("2.1", "==2.0"), - ("2.1", "==2.0.0"), - ("2.0", "==2.0+deadbeef"), - - # Test the equality operation with a prefix - ("2.0", "==3.*"), - ("2.1", "==2.0.*"), - - # Test the in-equality operation - ("2.0", "!=2"), - ("2.0", "!=2.0"), - ("2.0", "!=2.0.0"), - ("2.0+deadbeef", "!=2"), - ("2.0+deadbeef", "!=2.0"), - ("2.0+deadbeef", "!=2.0.0"), - ("2.0+deadbeef", "!=2+deadbeef"), - ("2.0+deadbeef", "!=2.0+deadbeef"), - ("2.0+deadbeef", "!=2.0.0+deadbeef"), - ("2.0+deadbeef.0", "!=2.0.0+deadbeef.00"), - - # Test the in-equality operation with a prefix - ("2.dev1", "!=2.*"), - ("2a1", "!=2.*"), - ("2a1.post1", "!=2.*"), - ("2b1", "!=2.*"), - ("2b1.dev1", "!=2.*"), - ("2c1", "!=2.*"), - ("2c1.post1.dev1", "!=2.*"), - ("2rc1", "!=2.*"), - ("2", "!=2.*"), - ("2.0", "!=2.*"), - ("2.0.0", "!=2.*"), - ("2.0.post1", "!=2.0.post1.*"), - ("2.0.post1.dev1", "!=2.0.post1.*"), - - # Test the greater than equal operation - ("2.0.dev1", ">=2"), - ("2.0a1", ">=2"), - ("2.0a1.dev1", ">=2"), - ("2.0b1", ">=2"), - ("2.0b1.post1", ">=2"), - ("2.0c1", ">=2"), - ("2.0c1.post1.dev1", ">=2"), - ("2.0rc1", ">=2"), - ("1", ">=2"), - - # Test the less than equal operation - ("2.0.post1", "<=2"), - ("2.0.post1.dev1", "<=2"), - ("3", "<=2"), - - # Test the greater than operation - ("1", ">2"), - ("2.0.dev1", ">2"), - ("2.0a1", ">2"), - ("2.0a1.post1", ">2"), - ("2.0b1", ">2"), - ("2.0b1.dev1", ">2"), - ("2.0c1", ">2"), - ("2.0c1.post1.dev1", ">2"), - ("2.0rc1", ">2"), - ("2.0", ">2"), - ("2.0.post1", ">2"), - ("2.0.post1.dev1", ">2"), - ("2.0.1", ">2"), - - # Test the less than operation - ("2.0.dev1", "<2"), - ("2.0a1", "<2"), - ("2.0a1.post1", "<2"), - ("2.0b1", "<2"), - ("2.0b2.dev1", "<2"), - ("2.0c1", "<2"), - ("2.0c1.post1.dev1", "<2"), - ("2.0rc1", "<2"), - ("2.0", "<2"), - ("2.post1", "<2"), - ("2.post1.dev1", "<2"), - ("3", "<2"), - - # Test the compatibility operation - ("2.0", "~=1.0"), - ("1.1.0", "~=1.0.0"), - ("1.1.post1", "~=1.0.0"), - - # Test that epochs are handled sanely - ("1.0", "~=2!1.0"), - ("2!1.0", "~=1.0"), - ("2!1.0", "==1.0"), - ("1.0", "==2!1.0"), - ("2!1.0", "==1.*"), - ("1.0", "==2!1.*"), - ("2!1.0", "!=2!1.0"), - ] - ], - ) - def test_specifiers(self, version, spec, expected): - spec = Specifier(spec, prereleases=True) - - if expected: - # Test that the plain string form works - assert spec.contains(version) - - # Test that the version instance form works - assert spec.contains(Version(version)) - else: - # Test that the plain string form works - assert not spec.contains(version) - - # Test that the version instance form works - assert not spec.contains(Version(version)) - - @pytest.mark.parametrize( - ("version", "spec", "expected"), - [ - # Test identity comparison by itself - ("lolwat", "===lolwat", True), - ("Lolwat", "===lolwat", True), - ("1.0", "===1.0", True), - ("nope", "===lolwat", False), - ("1.0.0", "===1.0", False), - - # Test multiple specs combined with an identity comparison - ("nope", "===nope,!=1.0", False), - ("1.0.0", "===1.0.0,==1.*", True), - ("1.0.0", "===1.0,==1.*", False), - ], - ) - def test_specifiers_identity(self, version, spec, expected): - spec = Specifier(spec) - - if expected: - # Identity comparisons only support the plain string form - assert spec.contains(version) - else: - # Identity comparisons only support the plain string form - assert not spec.contains(version) - - @pytest.mark.parametrize( - "version", - VERSIONS + LEGACY_VERSIONS, - ) - def test_empty_specifier(self, version): - spec = Specifier(prereleases=True) - - assert spec.contains(version) - - @pytest.mark.parametrize( - ("specifier", "expected"), - [ - ("==1.0", False), - ("", False), - (">=1.0", False), - ("<=1.0", False), - ("~=1.0", False), - ("<1.0", False), - (">1.0", False), - ("<1.0.dev1", False), - (">1.0.dev1", False), - ("==1.0.*", False), - ("==1.0.dev1", True), - (">=1.0.dev1", True), - ("<=1.0.dev1", True), - ("~=1.0.dev1", True), - ], - ) - def test_specifier_prereleases_detection(self, specifier, expected): - assert Specifier(specifier).prereleases == expected - - def test_specifier_prereleases_explicit(self): - spec = Specifier() - assert not spec.prereleases - assert not spec.contains("1.0.dev1") - spec.prereleases = True - assert spec.prereleases - assert spec.contains("1.0.dev1") - - spec = Specifier(prereleases=True) - assert spec.prereleases - assert spec.contains("1.0.dev1") - spec.prereleases = False - assert not spec.prereleases - assert not spec.contains("1.0.dev1") - - spec = Specifier(prereleases=True) - assert spec.prereleases - assert spec.contains("1.0.dev1") - spec.prereleases = None - assert not spec.prereleases - assert not spec.contains("1.0.dev1") - - @pytest.mark.parametrize( - ("specifier", "version", "expected"), - [ - (">=1.0", "2.0.dev1", False), - (">=2.0.dev1", "2.0a1", True), - ("==2.0.*", "2.0a1.dev1", False), - ("==2.0a1.*", "2.0a1.dev1", True), - ("<=2.0", "1.0.dev1", False), - ("<=2.0.dev1", "1.0a1", True), - ], - ) - def test_specifiers_prereleases(self, specifier, version, expected): - spec = Specifier(specifier) - - if expected: - assert spec.contains(version) - spec.prereleases = False - assert not spec.contains(version) - else: - assert not spec.contains(version) - spec.prereleases = True - assert spec.contains(version) - - @pytest.mark.parametrize( - ("specifier", "prereleases", "input", "expected"), - [ - ("", None, ["1.0", "2.0a1"], ["1.0"]), - (">=1.0.dev1", None, ["1.0", "2.0a1"], ["1.0", "2.0a1"]), - ("", None, ["1.0a1"], ["1.0a1"]), - ("", False, ["1.0a1"], []), - (">=1.0.dev1", False, ["1.0", "2.0a1"], ["1.0"]), - ("", True, ["1.0", "2.0a1"], ["1.0", "2.0a1"]), - ("", None, ["1.0", Version("2.0")], ["1.0", Version("2.0")]), - ], - ) - def test_specifier_filter(self, specifier, prereleases, input, expected): - spec = Specifier(specifier) - - kwargs = ( - {"prereleases": prereleases} if prereleases is not None else {} - ) - - assert list(spec.filter(input, **kwargs)) == expected