diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2b8306fa8..53bca63ba 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -20,9 +20,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v3 - - uses: actions/setup-python@v3 + - uses: actions/setup-python@v4 name: Install Python with: python-version: "3.9" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c69c1f5e2..4c09e0213 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -22,9 +22,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v3 - - uses: actions/setup-python@v3 + - uses: actions/setup-python@v4 name: Install Python with: python-version: "3.9" @@ -43,13 +43,13 @@ jobs: needs: lint steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v3 - name: Build run: pipx run build - name: Archive files - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@v3 with: name: dist path: dist diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4ce84596b..6b6a5cc87 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,21 +23,16 @@ jobs: matrix: os: [Ubuntu, Windows, macOS] python_version: - ["3.7", "3.8", "3.9", "3.10", "pypy-3.7", "pypy-3.8", "pypy-3.9"] + ["3.7", "3.8", "3.9", "3.10", "pypy3.7", "pypy3.8", "pypy3.9"] steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v3 - - uses: actions/setup-python@v3 + - uses: actions/setup-python@v4 name: Install Python ${{ matrix.python_version }} with: python-version: ${{ matrix.python_version }} cache: "pip" - name: Run nox - run: | - # Need to fix-up PyPy. This can be removed once https://github.com/actions/setup-python/issues/346 lands. - INTERPRETER=${{ matrix.python_version }} - INTERPRETER=${INTERPRETER/-/} # remove the first '-' in "pypy-X.Y" -> "pypyX.Y" to match executable name - pipx run nox --error-on-missing-interpreters -s tests-${INTERPRETER} - shell: bash + run: pipx run nox --error-on-missing-interpreters -s tests-${{ matrix.python_version }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ce2e4a43a..f0b033f37 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,7 +31,7 @@ repos: hooks: - id: isort - - repo: https://gitlab.com/PyCQA/flake8 + - repo: https://github.com/PyCQA/flake8 rev: "3.9.2" hooks: - id: flake8 diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 214326e2f..7885906b5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,7 +4,10 @@ Changelog *unreleased* ~~~~~~~~~~~~ -No unreleased changes. +* ``Marker.evaluate`` will now assume evaluation environment with empty ``extra``. + Evaluating markers like ``"extra == 'xyz'"`` without passing any extra in the + ``environment`` will no longer raise an exception. +* Remove dependency on ``pyparsing``, by replacing it with a hand-written parser. This package now has no runtime dependencies (:issue:`468`) 21.3 - 2021-11-17 ~~~~~~~~~~~~~~~~~ diff --git a/docs/_static/.empty b/docs/_static/.empty deleted file mode 100644 index e69de29bb..000000000 diff --git a/docs/conf.py b/docs/conf.py index edd8dd5cc..c2fffd40e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -3,14 +3,16 @@ # for complete details. import os -import sys -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -sys.path.insert(0, os.path.abspath(".")) +# -- Project information loading ---------------------------------------------- + +ABOUT = {} +_BASE_DIR = os.path.join(os.path.dirname(__file__), os.pardir) +with open(os.path.join(_BASE_DIR, "packaging", "__about__.py")) as f: + exec(f.read(), ABOUT) # -- General configuration ---------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. @@ -19,93 +21,48 @@ "sphinx.ext.doctest", "sphinx.ext.extlinks", "sphinx.ext.intersphinx", - "sphinx.ext.viewcode", ] -# Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] - -# The suffix of source filenames. -source_suffix = ".rst" - -# The master toctree document. -master_doc = "index" - # General information about the project. project = "Packaging" +version = ABOUT["__version__"] +release = ABOUT["__version__"] +copyright = ABOUT["__copyright__"] -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# - -base_dir = os.path.join(os.path.dirname(__file__), os.pardir) -about = {} -with open(os.path.join(base_dir, "packaging", "__about__.py")) as f: - exec(f.read(), about) - -version = release = about["__version__"] -copyright = about["__copyright__"] - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -exclude_patterns = ["_build"] - -extlinks = { - "issue": ("https://github.com/pypa/packaging/issues/%s", "#"), - "pull": ("https://github.com/pypa/packaging/pull/%s", "PR #"), -} # -- Options for HTML output -------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = "furo" -html_title = "packaging" +html_title = project -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["_static"] +# -- Options for autodoc ---------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html#configuration -# Output file base name for HTML help builder. -htmlhelp_basename = "packagingdoc" +autodoc_member_order = "bysource" +autodoc_preserve_defaults = True +# Automatically extract typehints when specified and place them in +# descriptions of the relevant function/method. +autodoc_typehints = "description" -# -- Options for LaTeX output ------------------------------------------------- +# Don't show class signature with the class' name. +autodoc_class_signature = "separated" -latex_elements = {} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass [howto/manual]) -latex_documents = [ - ("index", "packaging.tex", "Packaging Documentation", "Donald Stufft", "manual") -] +# -- Options for extlinks ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/extensions/extlinks.html#configuration -# -- Options for manual page output ------------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [("index", "packaging", "Packaging Documentation", ["Donald Stufft"], 1)] - -# -- Options for Texinfo output ----------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ( - "index", - "packaging", - "Packaging Documentation", - "Donald Stufft", - "packaging", - "Core utilities for Python packages", - "Miscellaneous", - ) -] +extlinks = { + "issue": ("https://github.com/pypa/packaging/issues/%s", "#%s"), + "pull": ("https://github.com/pypa/packaging/pull/%s", "PR #%s"), +} -# Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {"https://docs.python.org/": None} +# -- Options for intersphinx ---------------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#configuration -epub_theme = "epub" +intersphinx_mapping = { + "python": ("https://docs.python.org/3/", None), + "pypug": ("https://packaging.python.org/", None), +} diff --git a/docs/index.rst b/docs/index.rst index aafdae83c..8d72cbf05 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -26,6 +26,7 @@ You can install packaging with ``pip``: markers requirements tags + metadata utils .. toctree:: diff --git a/docs/markers.rst b/docs/markers.rst index 478b76e4e..2ca012eca 100644 --- a/docs/markers.rst +++ b/docs/markers.rst @@ -34,17 +34,6 @@ Usage >>> # Markers can be also used with extras, to pull in dependencies if >>> # a certain extra is being installed >>> extra = Marker('extra == "bar"') - >>> # Evaluating an extra marker with no environment is an error - >>> try: - ... extra.evaluate() - ... except UndefinedEnvironmentName: - ... pass - >>> extra_environment = {'extra': ''} - >>> extra.evaluate(environment=extra_environment) - False - >>> extra_environment['extra'] = 'bar' - >>> extra.evaluate(environment=extra_environment) - True >>> # You can do simple comparisons between marker objects: >>> Marker("python_version > '3.6'") == Marker("python_version > '3.6'") True diff --git a/docs/metadata.rst b/docs/metadata.rst new file mode 100644 index 000000000..972b065c1 --- /dev/null +++ b/docs/metadata.rst @@ -0,0 +1,14 @@ +Metadata +========== + +A data representation for `core metadata`_. + +.. _`core metadata`: https://packaging.python.org/en/latest/specifications/core-metadata/ + + +Reference +--------- + +.. automodule:: packaging.metadata + :members: + :undoc-members: diff --git a/docs/specifiers.rst b/docs/specifiers.rst index 83299a8a7..934d9f6f8 100644 --- a/docs/specifiers.rst +++ b/docs/specifiers.rst @@ -1,11 +1,13 @@ Specifiers ========== -.. currentmodule:: packaging.specifiers +A core requirement of dealing with dependencies is the ability to +specify what versions of a dependency are acceptable for you. -A core requirement of dealing with dependencies 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. +See `Version Specifiers Specification`_ for more details on the exact +format implemented in this module, for use in Python Packaging tooling. + +.. _Version Specifiers Specification: https://packaging.python.org/en/latest/specifications/version-specifiers/ Usage ----- @@ -28,6 +30,10 @@ Usage >>> combined_spec &= "!=1.1" >>> combined_spec =1.0,~=1.0')> + >>> # We can iterate over the SpecifierSet to recover the + >>> # individual specifiers + >>> sorted(combined_spec, key=str) + [, =1.0')>, ] >>> # Create a few versions to check for contains. >>> v1 = Version("1.0a5") >>> v2 = Version("1.0") @@ -48,175 +54,6 @@ Usage Reference --------- -.. class:: SpecifierSet(specifiers="", prereleases=None) - - This class abstracts handling specifying the dependencies of a project. It - can be passed a single specifier (``>=3.0``), a comma-separated list of - specifiers (``>=3.0,!=3.1``), or no specifier at all. Each individual - specifier will be attempted to be parsed as a PEP 440 specifier - (:class:`Specifier`) or as a legacy, setuptools style specifier - (deprecated :class:`LegacySpecifier`). You may combine - :class:`SpecifierSet` instances using the ``&`` operator - (``SpecifierSet(">2") & SpecifierSet("<4")``). - - Both the membership tests and the combination support using raw strings - in place of already instantiated objects. - - :param str specifiers: The string representation of a specifier or a - comma-separated list of specifiers which will - be parsed and normalized before use. - :param bool prereleases: This tells the SpecifierSet 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 given ``specifiers`` are not parseable - than this exception will be raised. - - .. attribute:: prereleases - - A boolean value indicating whether this :class:`SpecifierSet` - represents a specifier that includes a 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) - - This is the more Pythonic version of :meth:`contains()`, but does - not allow you to override the ``prereleases`` argument. If you - need that, use :meth:`contains()`. - - See :meth:`contains()`. - - .. method:: contains(version, prereleases=None) - - Determines if ``version``, which can be either a version string, a - :class:`Version`, or a deprecated :class:`LegacyVersion` object, is - contained within this set of specifiers. - - 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:: __len__() - - Returns the number of specifiers in this specifier set. - - .. method:: __iter__() - - Returns an iterator over all the underlying :class:`Specifier` (or - deprecated :class:`LegacySpecifier`) instances in this specifier set. - - .. method:: filter(iterable, prereleases=None) - - Takes an iterable that can contain version strings, :class:`~.Version`, - and deprecated :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. - - -.. class:: Specifier(specifier, prereleases=None) - - This class abstracts the handling of a single `PEP 440`_ compatible - specifier. It is generally not required to instantiate this manually, - preferring instead to work with :class:`SpecifierSet`. - - :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:: operator - - The string value of the operator part of this specifier. - - .. attribute:: version - - The string version of the version part of this specifier. - - .. attribute:: prereleases - - See :attr:`SpecifierSet.prereleases`. - - .. method:: __contains__(version) - - See :meth:`SpecifierSet.__contains__()`. - - .. method:: contains(version, prereleases=None) - - See :meth:`SpecifierSet.contains()`. - - .. method:: filter(iterable, prereleases=None) - - See :meth:`SpecifierSet.filter()`. - - -.. class:: LegacySpecifier(specifier, prereleases=None) - - .. deprecated:: 20.5 - - Use :class:`Specifier` instead. - - This class abstracts the handling of a single legacy, setuptools style - specifier. It is generally not required to instantiate this manually, - preferring instead to work with :class:`SpecifierSet`. - - :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`` is not parseable then this - will be raised. - - .. attribute:: operator - - The string value of the operator part of this specifier. - - .. attribute:: version - - The string version of the version part of this specifier. - - .. attribute:: prereleases - - See :attr:`SpecifierSet.prereleases`. - - .. method:: __contains__(version) - - See :meth:`SpecifierSet.__contains__()`. - - .. method:: contains(version, prereleases=None) - - See :meth:`SpecifierSet.contains()`. - - .. method:: filter(iterable, prereleases=None) - - See :meth:`SpecifierSet.filter()`. - - -.. 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/ +.. automodule:: packaging.specifiers + :members: + :special-members: diff --git a/docs/utils.rst b/docs/utils.rst index 80d936ad0..b100b1ff8 100644 --- a/docs/utils.rst +++ b/docs/utils.rst @@ -10,10 +10,18 @@ A set of small, helper utilities for dealing with Python packages. Reference --------- +.. class:: NormalizedName + + A :class:`typing.NewType` of :class:`str`, representing a normalized name. + .. function:: canonicalize_name(name) - This function takes a valid Python package name, and returns the normalized - form of it. + This function takes a valid Python package or extra name, and returns the + normalized form of it. + + The return type is typed as :class:`NormalizedName`. This allows type + checkers to help require that a string has passed through this function + before use. :param str name: The name to normalize. @@ -46,10 +54,11 @@ Reference This function takes the filename of a wheel file, and parses it, returning a tuple of name, version, build number, and tags. - The name part of the tuple is normalized. The version portion is an - instance of :class:`~packaging.version.Version`. The build number - is ``()`` if there is no build number in the wheel filename, - otherwise a two-item tuple of an integer for the leading digits and + The name part of the tuple is normalized and typed as + :class:`NormalizedName`. The version portion is an instance of + :class:`~packaging.version.Version`. The build number is ``()`` if + there is no build number in the wheel filename, otherwise a + two-item tuple of an integer for the leading digits and a string for the rest of the build number. The tags portion is an instance of :class:`~packaging.tags.Tag`. diff --git a/docs/version.rst b/docs/version.rst index a43cf7868..2adf336e5 100644 --- a/docs/version.rst +++ b/docs/version.rst @@ -1,11 +1,13 @@ Version Handling ================ -.. currentmodule:: packaging.version - A core requirement of dealing with packages is the ability to work with -versions. `PEP 440`_ defines the standard version scheme for Python packages -which has been implemented by this module. +versions. + +See `Version Specifiers Specification`_ for more details on the exact +format implemented in this module, for use in Python Packaging tooling. + +.. _Version Specifiers Specification: https://packaging.python.org/en/latest/specifications/version-specifiers/ Usage ----- @@ -47,246 +49,6 @@ Usage Reference --------- -.. function:: parse(version) - - This function takes a version string and will parse it as a - :class:`Version` if the version is a valid PEP 440 version, otherwise it - will parse it as a deprecated :class:`LegacyVersion`. - - -.. class:: Version(version) - - This class abstracts handling of a project's versions. It implements the - scheme defined in `PEP 440`_. A :class:`Version` instance is comparison - aware and can be compared and sorted using the standard Python interfaces. - - :param str version: The string representation of a version which will be - parsed and normalized before use. - :raises InvalidVersion: If the ``version`` does not conform to PEP 440 in - any way then this exception will be raised. - - .. attribute:: public - - A string representing the public version portion of this ``Version()``. - - .. attribute:: base_version - - A string representing the base version of this :class:`Version` - instance. The base version is the public version of the project without - any pre or post release markers. - - .. attribute:: epoch - - An integer giving the version epoch of this :class:`Version` instance - - .. attribute:: release - - A tuple of integers giving the components of the release segment of - this :class:`Version` instance; that is, the ``1.2.3`` part of the - version number, including trailing zeroes but not including the epoch - or any prerelease/development/postrelease suffixes - - .. attribute:: major - - An integer representing the first item of :attr:`release` or ``0`` if unavailable. - - .. attribute:: minor - - An integer representing the second item of :attr:`release` or ``0`` if unavailable. - - .. attribute:: micro - - An integer representing the third item of :attr:`release` or ``0`` if unavailable. - - .. attribute:: local - - A string representing the local version portion of this ``Version()`` - if it has one, or ``None`` otherwise. - - .. attribute:: pre - - If this :class:`Version` instance represents a prerelease, this - attribute will be a pair of the prerelease phase (the string ``"a"``, - ``"b"``, or ``"rc"``) and the prerelease number (an integer). If this - instance is not a prerelease, the attribute will be `None`. - - .. attribute:: is_prerelease - - A boolean value indicating whether this :class:`Version` instance - represents a prerelease and/or development release. - - .. attribute:: dev - - If this :class:`Version` instance represents a development release, - this attribute will be the development release number (an integer); - otherwise, it will be `None`. - - .. attribute:: is_devrelease - - A boolean value indicating whether this :class:`Version` instance - represents a development release. - - .. attribute:: post - - If this :class:`Version` instance represents a postrelease, this - attribute will be the postrelease number (an integer); otherwise, it - will be `None`. - - .. attribute:: is_postrelease - - A boolean value indicating whether this :class:`Version` instance - represents a post-release. - - -.. class:: LegacyVersion(version) - - .. deprecated:: 20.5 - - Use :class:`Version` instead. - - This class abstracts handling of a project's versions if they are not - compatible with the scheme defined in `PEP 440`_. It implements a similar - interface to that of :class:`Version`. - - This class implements the previous de facto sorting algorithm used by - setuptools, however it will always sort as less than a :class:`Version` - instance. - - :param str version: The string representation of a version which will be - used as is. - - .. note:: - - :class:`LegacyVersion` instances are always ordered lower than :class:`Version` instances. - - >>> from packaging.version import Version, LegacyVersion - >>> v1 = Version("1.0") - >>> v2 = LegacyVersion("1.0") - >>> v1 > v2 - True - >>> v3 = LegacyVersion("1.3") - >>> v1 > v3 - True - - Also note that some strings are still valid PEP 440 strings (:class:`Version`), even if they look very similar to - other versions that are not (:class:`LegacyVersion`). Examples include versions with `Pre-release spelling`_ and - `Post-release spelling`_. - - >>> from packaging.version import parse - >>> v1 = parse('0.9.8a') - >>> v2 = parse('0.9.8beta') - >>> v3 = parse('0.9.8r') - >>> v4 = parse('0.9.8rev') - >>> v5 = parse('0.9.8t') - >>> v1 - - >>> v1.is_prerelease - True - >>> v2 - - >>> v2.is_prerelease - True - >>> v3 - - >>> v3.is_postrelease - True - >>> v4 - - >>> v4.is_postrelease - True - >>> v5 - - >>> v5.is_prerelease - False - >>> v5.is_postrelease - False - - .. attribute:: public - - A string representing the public version portion of this - :class:`LegacyVersion`. This will always be the entire version string. - - .. attribute:: base_version - - A string representing the base version portion of this - :class:`LegacyVersion` instance. This will always be the entire version - string. - - .. attribute:: epoch - - This will always be ``-1`` since without `PEP 440`_ we do not have the - concept of version epochs. The value reflects the fact that - :class:`LegacyVersion` instances always compare less than - :class:`Version` instances. - - .. attribute:: release - - This will always be ``None`` since without `PEP 440`_ we do not have - the concept of a release segment or its components. It exists - primarily to allow a :class:`LegacyVersion` to be used as a stand in - for a :class:`Version`. - - .. attribute:: local - - This will always be ``None`` since without `PEP 440`_ we do not have - the concept of a local version. It exists primarily to allow a - :class:`LegacyVersion` to be used as a stand in for a :class:`Version`. - - .. attribute:: pre - - This will always be ``None`` since without `PEP 440`_ we do not have - the concept of a prerelease. It exists primarily to allow a - :class:`LegacyVersion` to be used as a stand in for a :class:`Version`. - - .. attribute:: is_prerelease - - A boolean value indicating whether this :class:`LegacyVersion` - represents a prerelease and/or development release. Since without - `PEP 440`_ there is no concept of pre or dev releases this will - always be `False` and exists for compatibility with :class:`Version`. - - .. attribute:: dev - - This will always be ``None`` since without `PEP 440`_ we do not have - the concept of a development release. It exists primarily to allow a - :class:`LegacyVersion` to be used as a stand in for a :class:`Version`. - - .. attribute:: is_devrelease - - A boolean value indicating whether this :class:`LegacyVersion` - represents a development release. Since without `PEP 440`_ there is - no concept of dev releases this will always be `False` and exists for - compatibility with :class:`Version`. - - .. attribute:: post - - This will always be ``None`` since without `PEP 440`_ we do not have - the concept of a postrelease. It exists primarily to allow a - :class:`LegacyVersion` to be used as a stand in for a :class:`Version`. - - .. attribute:: is_postrelease - - A boolean value indicating whether this :class:`LegacyVersion` - represents a post-release. Since without `PEP 440`_ there is no concept - of post-releases this will always be ``False`` and exists for - compatibility with :class:`Version`. - - -.. exception:: InvalidVersion - - Raised when attempting to create a :class:`Version` with a version string - that does not conform to `PEP 440`_. - - -.. data:: VERSION_PATTERN - - A string containing the regular expression used to match a valid version. - The pattern is not anchored at either end, and is intended for embedding - in larger expressions (for example, matching a version number as part of - a file name). The regular expression should be compiled with the - ``re.VERBOSE`` and ``re.IGNORECASE`` flags set. - - -.. _PEP 440: https://www.python.org/dev/peps/pep-0440/ -.. _Pre-release spelling : https://www.python.org/dev/peps/pep-0440/#pre-release-spelling -.. _Post-release spelling : https://www.python.org/dev/peps/pep-0440/#post-release-spelling +.. automodule:: packaging.version + :members: + :special-members: diff --git a/packaging/_musllinux.py b/packaging/_musllinux.py index 8ac3059ba..d5d3e044b 100644 --- a/packaging/_musllinux.py +++ b/packaging/_musllinux.py @@ -39,9 +39,11 @@ def _parse_ld_musl_from_elf(f: IO[bytes]) -> Optional[str]: # p_fmt: Format for section header. # p_idx: Indexes to find p_type, p_offset, and p_filesz. e_fmt, p_fmt, p_idx = { - 1: ("IIIIHHH", "IIIIIIII", (0, 1, 4)), # 32-bit. - 2: ("QQQIHHH", "IIQQQQQQ", (0, 2, 5)), # 64-bit. - }[ident[4]] + (1, 1): ("IIIIHHH", ">IIIIIIII", (0, 1, 4)), # 32-bit MSB. + (2, 1): ("QQQIHHH", ">IIQQQQQQ", (0, 2, 5)), # 64-bit MSB. + }[(ident[4], ident[5])] except KeyError: return None else: diff --git a/packaging/_parser.py b/packaging/_parser.py new file mode 100644 index 000000000..ec02e72c6 --- /dev/null +++ b/packaging/_parser.py @@ -0,0 +1,228 @@ +# The docstring for each parse function contains the grammar for the rule. +# The grammar uses a simple EBNF-inspired syntax: +# +# - Uppercase names are tokens +# - Lowercase names are rules (parsed with a parse_* function) +# - Parentheses are used for grouping +# - A | means either-or +# - A * means 0 or more +# - A + means 1 or more +# - A ? means 0 or 1 + +from ast import literal_eval +from typing import Any, List, NamedTuple, Tuple, Union + +from ._tokenizer import Tokenizer + + +class Node: + def __init__(self, value: str) -> None: + self.value = value + + def __str__(self) -> str: + return self.value + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}('{self}')>" + + def serialize(self) -> str: + raise NotImplementedError + + +class Variable(Node): + def serialize(self) -> str: + return str(self) + + +class Value(Node): + def serialize(self) -> str: + return f'"{self}"' + + +class Op(Node): + def serialize(self) -> str: + return str(self) + + +MarkerVar = Union[Variable, Value] +MarkerItem = Tuple[MarkerVar, Op, MarkerVar] +# MarkerAtom = Union[MarkerItem, List["MarkerAtom"]] +# MarkerList = List[Union["MarkerList", MarkerAtom, str]] +# mypy does not suport recursive type definition +# https://github.com/python/mypy/issues/731 +MarkerAtom = Any +MarkerList = List[Any] + + +class Requirement(NamedTuple): + name: str + url: str + extras: List[str] + specifier: str + marker: str + + +def parse_named_requirement(requirement: str) -> Requirement: + """ + named_requirement: + IDENTIFIER extras (URL_SPEC | specifier) (SEMICOLON marker_expr)? END + """ + tokens = Tokenizer(requirement) + tokens.expect("IDENTIFIER", error_message="Expression must begin with package name") + name = tokens.read("IDENTIFIER").text + extras = parse_extras(tokens) + specifier = "" + url = "" + if tokens.match("URL_SPEC"): + url = tokens.read().text[1:].strip() + elif not tokens.match("END"): + specifier = parse_specifier(tokens) + if tokens.try_read("SEMICOLON"): + marker = "" + while not tokens.match("END"): + # we don't validate markers here, it's done later as part of + # packaging/requirements.py + marker += tokens.read().text + else: + marker = "" + tokens.expect( + "END", + error_message="Expected semicolon (followed by markers) or end of string", + ) + return Requirement(name, url, extras, specifier, marker) + + +def parse_extras(tokens: Tokenizer) -> List[str]: + """ + extras: LBRACKET (IDENTIFIER (COMMA IDENTIFIER)*)? RBRACKET + """ + extras = [] + if tokens.try_read("LBRACKET"): + while tokens.match("IDENTIFIER"): + extras.append(tokens.read("IDENTIFIER").text) + if not tokens.match("RBRACKET"): + tokens.read("COMMA", error_message="Missing comma after extra") + if not tokens.match("COMMA") and tokens.match("RBRACKET"): + break + tokens.read("RBRACKET", error_message="Closing square bracket is missing") + return extras + + +def parse_specifier(tokens: Tokenizer) -> str: + """ + specifier: + LPAREN version_many? RPAREN | version_many + """ + lparen = False + if tokens.try_read("LPAREN"): + lparen = True + parsed_specifiers = parse_version_many(tokens) + if lparen and not tokens.try_read("RPAREN"): + tokens.raise_syntax_error(message="Closing right parenthesis is missing") + return parsed_specifiers + + +def parse_version_many(tokens: Tokenizer) -> str: + """ + version_many: OP VERSION (COMMA OP VERSION)* + """ + parsed_specifiers = "" + while tokens.match("OP"): + parsed_specifiers += tokens.read("OP").text + if tokens.match("VERSION"): + parsed_specifiers += tokens.read("VERSION").text + else: + tokens.raise_syntax_error(message="Missing version") + if not tokens.match("COMMA"): + break + tokens.expect("COMMA", error_message="Missing comma after version") + parsed_specifiers += tokens.read("COMMA").text + return parsed_specifiers + + +def parse_marker_expr(tokens: Tokenizer) -> MarkerList: + """ + marker_expr: MARKER_ATOM (BOOLOP + MARKER_ATOM)+ + """ + expression = [parse_marker_atom(tokens)] + while tokens.match("BOOLOP"): + tok = tokens.read("BOOLOP") + expr_right = parse_marker_atom(tokens) + expression.extend((tok.text, expr_right)) + return expression + + +def parse_marker_atom(tokens: Tokenizer) -> MarkerAtom: + """ + marker_atom: LPAREN marker_expr RPAREN | marker_item + """ + if tokens.try_read("LPAREN"): + marker = parse_marker_expr(tokens) + tokens.read("RPAREN", error_message="Closing right parenthesis is missing") + return marker + else: + return parse_marker_item(tokens) + + +def parse_marker_item(tokens: Tokenizer) -> MarkerItem: + """ + marker_item: marker_var marker_op marker_var + """ + marker_var_left = parse_marker_var(tokens) + marker_op = parse_marker_op(tokens) + marker_var_right = parse_marker_var(tokens) + return (marker_var_left, marker_op, marker_var_right) + + +def parse_marker_var(tokens: Tokenizer) -> MarkerVar: + """ + marker_var: env_var | python_str + """ + if tokens.match("VARIABLE"): + return parse_env_var(tokens) + else: + return parse_python_str(tokens) + + +def parse_env_var(tokens: Tokenizer) -> Variable: + """ + env_var: VARIABLE + """ + env_var = tokens.read("VARIABLE").text.replace(".", "_") + if ( + env_var == "platform_python_implementation" + or env_var == "python_implementation" + ): + return Variable("platform_python_implementation") + else: + return Variable(env_var) + + +def parse_python_str(tokens: Tokenizer) -> Value: + """ + python_str: QUOTED_STRING + """ + token = tokens.read( + "QUOTED_STRING", + error_message="String with single or double quote at the beginning is expected", + ).text + python_str = literal_eval(token) + return Value(str(python_str)) + + +def parse_marker_op(tokens: Tokenizer) -> Op: + """ + marker_op: IN | NOT IN | OP + """ + if tokens.try_read("IN"): + return Op("in") + elif tokens.try_read("NOT"): + tokens.read("IN", error_message="NOT token must be follewed by IN token") + return Op("not in") + elif tokens.match("OP"): + return Op(tokens.read().text) + else: + return tokens.raise_syntax_error( + message='Couldn\'t parse marker operator. Expecting one of \ + "<=, <, !=, ==, >=, >, ~=, ===, not, not in"' + ) diff --git a/packaging/_tokenizer.py b/packaging/_tokenizer.py new file mode 100644 index 000000000..ecae9e345 --- /dev/null +++ b/packaging/_tokenizer.py @@ -0,0 +1,164 @@ +import re +from typing import Dict, Generator, NoReturn, Optional + +from .specifiers import Specifier + + +class Token: + def __init__(self, name: str, text: str, position: int) -> None: + self.name = name + self.text = text + self.position = position + + def matches(self, name: str = "") -> bool: + if name and self.name != name: + return False + return True + + +class ParseExceptionError(Exception): + """ + Parsing failed. + """ + + def __init__(self, message: str, position: int) -> None: + super().__init__(message) + self.position = position + + +DEFAULT_RULES = { + "LPAREN": r"\s*\(", + "RPAREN": r"\s*\)", + "LBRACKET": r"\s*\[", + "RBRACKET": r"\s*\]", + "SEMICOLON": r"\s*;", + "COMMA": r"\s*,", + "QUOTED_STRING": re.compile( + r""" + \s* + ( + ('[^']*') + | + ("[^"]*") + ) + """, + re.VERBOSE, + ), + "OP": r"\s*(===|==|~=|!=|<=|>=|<|>)", + "BOOLOP": r"\s*(or|and)", + "IN": r"\s*in", + "NOT": r"\s*not", + "VARIABLE": re.compile( + r""" + \s* + ( + python_version + |python_full_version + |os[._]name + |sys[._]platform + |platform_(release|system) + |platform[._](version|machine|python_implementation) + |python_implementation + |implementation_(name|version) + |extra + ) + """, + re.VERBOSE, + ), + "VERSION": re.compile(Specifier._version_regex_str, re.VERBOSE | re.IGNORECASE), + "URL_SPEC": r"\s*@ *[^ ]+", + "IDENTIFIER": r"\s*[a-zA-Z0-9._-]+", +} + + +class Tokenizer: + """Stream of tokens for a LL(1) parser. + + Provides methods to examine the next token to be read, and to read it + (advance to the next token). + """ + + next_token: Optional[Token] + + def __init__(self, source: str, rules: Dict[str, object] = DEFAULT_RULES) -> None: + self.source = source + self.rules = {name: re.compile(pattern) for name, pattern in rules.items()} + self.next_token = None + self.generator = self._tokenize() + self.position = 0 + + def peek(self) -> Token: + """ + Return the next token to be read. + """ + if not self.next_token: + self.next_token = next(self.generator) + return self.next_token + + def match(self, *name: str) -> bool: + """ + Return True if the next token matches the given arguments. + """ + token = self.peek() + return token.matches(*name) + + def expect(self, *name: str, error_message: str) -> Token: + """ + Raise SyntaxError if the next token doesn't match given arguments. + """ + token = self.peek() + if not token.matches(*name): + raise self.raise_syntax_error(message=error_message) + return token + + def read(self, *name: str, error_message: str = "") -> Token: + """Return the next token and advance to the next token. + + Raise SyntaxError if the token doesn't match. + """ + result = self.expect(*name, error_message=error_message) + self.next_token = None + return result + + def try_read(self, *name: str) -> Optional[Token]: + """read() if the next token matches the given arguments. + + Do nothing if it does not match. + """ + if self.match(*name): + return self.read() + return None + + def raise_syntax_error(self, *, message: str) -> NoReturn: + """ + Raise SyntaxError at the given position in the marker. + """ + at = f"at position {self.position}:" + marker = " " * self.position + "^" + raise ParseExceptionError( + f"{message}\n{at}\n {self.source}\n {marker}", + self.position, + ) + + def _make_token(self, name: str, text: str) -> Token: + """ + Make a token with the current position. + """ + return Token(name, text, self.position) + + def _tokenize(self) -> Generator[Token, Token, None]: + """ + The main generator of tokens. + """ + while self.position < len(self.source): + for name, expression in self.rules.items(): + match = expression.match(self.source, self.position) + if match: + token_text = match[0] + + yield self._make_token(name, token_text.strip()) + self.position += len(token_text) + break + else: + raise self.raise_syntax_error(message="Unrecognized token") + yield self._make_token("END", "") diff --git a/packaging/markers.py b/packaging/markers.py index 03d8cdefc..ddb0ac17d 100644 --- a/packaging/markers.py +++ b/packaging/markers.py @@ -8,18 +8,8 @@ import sys from typing import Any, Callable, Dict, List, Optional, Tuple, Union -from pyparsing import ( # noqa: N817 - Forward, - Group, - Literal as L, - ParseException, - ParseResults, - QuotedString, - ZeroOrMore, - stringEnd, - stringStart, -) - +from ._parser import MarkerAtom, MarkerList, Op, Value, Variable, parse_marker_expr +from ._tokenizer import ParseExceptionError, Tokenizer from .specifiers import InvalidSpecifier, Specifier from .utils import canonicalize_name @@ -53,101 +43,24 @@ class UndefinedEnvironmentName(ValueError): """ -class Node: - def __init__(self, value: Any) -> None: - self.value = value - - def __str__(self) -> str: - return str(self.value) - - def __repr__(self) -> str: - return f"<{self.__class__.__name__}('{self}')>" - - def serialize(self) -> str: - raise NotImplementedError - - -class Variable(Node): - def serialize(self) -> str: - return str(self) - - -class Value(Node): - def serialize(self) -> str: - return f'"{self}"' - - -class Op(Node): - def serialize(self) -> str: - return str(self) - - -VARIABLE = ( - L("implementation_version") - | L("platform_python_implementation") - | L("implementation_name") - | L("python_full_version") - | L("platform_release") - | L("platform_version") - | L("platform_machine") - | L("platform_system") - | L("python_version") - | L("sys_platform") - | L("os_name") - | L("os.name") # PEP-345 - | L("sys.platform") # PEP-345 - | L("platform.version") # PEP-345 - | L("platform.machine") # PEP-345 - | L("platform.python_implementation") # PEP-345 - | L("python_implementation") # undocumented setuptools legacy - | L("extra") # PEP-508 -) -ALIASES = { - "os.name": "os_name", - "sys.platform": "sys_platform", - "platform.version": "platform_version", - "platform.machine": "platform_machine", - "platform.python_implementation": "platform_python_implementation", - "python_implementation": "platform_python_implementation", -} -VARIABLE.setParseAction(lambda s, l, t: Variable(ALIASES.get(t[0], t[0]))) - -VERSION_CMP = ( - L("===") | L("==") | L(">=") | L("<=") | L("!=") | L("~=") | L(">") | L("<") -) - -MARKER_OP = VERSION_CMP | L("not in") | L("in") -MARKER_OP.setParseAction(lambda s, l, t: Op(t[0])) - -MARKER_VALUE = QuotedString("'") | QuotedString('"') -MARKER_VALUE.setParseAction(lambda s, l, t: Value(t[0])) - -BOOLOP = L("and") | L("or") - -MARKER_VAR = VARIABLE | MARKER_VALUE - -MARKER_ITEM = Group(MARKER_VAR + MARKER_OP + MARKER_VAR) -MARKER_ITEM.setParseAction(lambda s, l, t: tuple(t[0])) - -LPAREN = L("(").suppress() -RPAREN = L(")").suppress() - -MARKER_EXPR = Forward() -MARKER_ATOM = MARKER_ITEM | Group(LPAREN + MARKER_EXPR + RPAREN) -MARKER_EXPR << MARKER_ATOM + ZeroOrMore(BOOLOP + MARKER_EXPR) - -MARKER = stringStart + MARKER_EXPR + stringEnd - - -def _coerce_parse_result(results: Union[ParseResults, List[Any]]) -> List[Any]: - if isinstance(results, ParseResults): - return [_coerce_parse_result(i) for i in results] - else: - return results +def _normalize_extra_values(results: Any) -> Any: + """ + Normalize extra values. + """ + if isinstance(results[0], tuple): + lhs, op, rhs = results[0] + if isinstance(lhs, Variable) and lhs.value == "extra": + normalized_extra = canonicalize_name(rhs.value) + rhs = Value(normalized_extra) + elif isinstance(rhs, Variable) and rhs.value == "extra": + normalized_extra = canonicalize_name(lhs.value) + lhs = Value(normalized_extra) + results[0] = lhs, op, rhs + return results def _format_marker( - marker: Union[List[str], Tuple[Node, ...], str], first: Optional[bool] = True + marker: Union[List[str], MarkerAtom, str], first: Optional[bool] = True ) -> str: assert isinstance(marker, (list, tuple, str)) @@ -202,24 +115,6 @@ def _eval_op(lhs: str, op: Op, rhs: str) -> bool: return oper(lhs, rhs) -class Undefined: - pass - - -_undefined = Undefined() - - -def _get_env(environment: Dict[str, str], name: str) -> str: - value: Union[str, Undefined] = environment.get(name, _undefined) - - if isinstance(value, Undefined): - raise UndefinedEnvironmentName( - f"{name!r} does not exist in evaluation environment." - ) - - return value - - def _normalize(*values: str, key: str) -> Tuple[str, ...]: # PEP 685 – Comparison of extra names for optional distribution dependencies # https://peps.python.org/pep-0685/ @@ -228,11 +123,11 @@ def _normalize(*values: str, key: str) -> Tuple[str, ...]: if key == "extra": return tuple(canonicalize_name(v) for v in values) - # other environment markes don't have such standards + # other environment markers don't have such standards return values -def _evaluate_markers(markers: List[Any], environment: Dict[str, str]) -> bool: +def _evaluate_markers(markers: MarkerList, environment: Dict[str, str]) -> bool: groups: List[List[bool]] = [[]] for marker in markers: @@ -245,12 +140,12 @@ def _evaluate_markers(markers: List[Any], environment: Dict[str, str]) -> bool: if isinstance(lhs, Variable): environment_key = lhs.value - lhs_value = _get_env(environment, environment_key) + lhs_value = environment[environment_key] rhs_value = rhs.value else: lhs_value = lhs.value environment_key = rhs.value - rhs_value = _get_env(environment, environment_key) + rhs_value = environment[environment_key] lhs_value, rhs_value = _normalize(lhs_value, rhs_value, key=environment_key) groups[-1].append(_eval_op(lhs_value, op, rhs_value)) @@ -291,7 +186,9 @@ def default_environment() -> Dict[str, str]: class Marker: def __init__(self, marker: str) -> None: try: - self._markers = _coerce_parse_result(MARKER.parseString(marker)) + self._markers = _normalize_extra_values( + parse_marker_expr(Tokenizer(marker)) + ) # The attribute `_markers` can be described in terms of a recursive type: # MarkerList = List[Union[Tuple[Node, ...], str, MarkerList]] # @@ -308,10 +205,10 @@ def __init__(self, marker: str) -> None: # (, , ) # ] # ] - except ParseException as e: + except ParseExceptionError as e: raise InvalidMarker( f"Invalid marker: {marker!r}, parse error at " - f"{marker[e.loc : e.loc + 8]!r}" + f"{marker[e.position : e.position + 8]!r}" ) def __str__(self) -> str: @@ -339,6 +236,7 @@ def evaluate(self, environment: Optional[Dict[str, str]] = None) -> bool: The environment is determined from the current Python process. """ current_environment = default_environment() + current_environment["extra"] = "" if environment is not None: current_environment.update(environment) diff --git a/packaging/metadata.py b/packaging/metadata.py new file mode 100644 index 000000000..4bc9c595c --- /dev/null +++ b/packaging/metadata.py @@ -0,0 +1,211 @@ +import enum +from typing import Iterable, List, Optional, Tuple + +from .requirements import Requirement +from .specifiers import SpecifierSet +from .utils import NormalizedName, canonicalize_name +from .version import Version + +# Type aliases. +_NameAndEmail = Tuple[Optional[str], str] +_LabelAndURL = Tuple[str, str] + + +@enum.unique +class DynamicField(enum.Enum): + """ + An :class:`enum.Enum` representing fields which can be listed in the ``Dynamic`` + field of `core metadata`_. + + Every valid field is a name on this enum, upper-cased with any ``-`` replaced with + ``_``. Each value is the field name lower-cased (``-`` are kept). For example, the + ``Home-page`` field has a name of ``HOME_PAGE`` and a value of ``home-page``. + """ + + # `Name`, `Version`, and `Metadata-Version` are invalid in `Dynamic`. + # 1.0 + PLATFORM = "platform" + SUMMARY = "summary" + DESCRIPTION = "description" + KEYWORDS = "keywords" + HOME_PAGE = "home-page" + AUTHOR = "author" + AUTHOR_EMAIL = "author-email" + LICENSE = "license" + # 1.1 + SUPPORTED_PLATFORM = "supported-platform" + DOWNLOAD_URL = "download-url" + CLASSIFIER = "classifier" + # 1.2 + MAINTAINER = "maintainer" + MAINTAINER_EMAIL = "maintainer-email" + REQUIRES_DIST = "requires-dist" + REQUIRES_PYTHON = "requires-python" + REQUIRES_EXTERNAL = "requires-external" + PROJECT_URL = "project-url" + PROVIDES_DIST = "provides-dist" + OBSOLETES_DIST = "obsoletes-dist" + # 2.1 + DESCRIPTION_CONTENT_TYPE = "description-content-type" + PROVIDES_EXTRA = "provides-extra" + + +class Metadata: + """A class representing the `Core Metadata`_ for a project. + + Every potential metadata field except for ``Metadata-Version`` is represented by a + parameter to the class' constructor. The required metadata can be passed in + positionally or via keyword, while all optional metadata can only be passed in via + keyword. + + Every parameter has a matching attribute on instances, except for *name* (see + :attr:`display_name` and :attr:`canonical_name`). Any parameter that accepts an + :class:`~collections.abc.Iterable` is represented as a :class:`list` on the + corresponding attribute. + """ + + # A property named `display_name` exposes the value. + _display_name: str + # A property named `canonical_name` exposes the value. + _canonical_name: NormalizedName + version: Version + platforms: List[str] + summary: str + description: str + keywords: List[str] + home_page: str + author: str + author_emails: List[_NameAndEmail] + license: str + supported_platforms: List[str] + download_url: str + classifiers: List[str] + maintainer: str + maintainer_emails: List[_NameAndEmail] + requires_dists: List[Requirement] + requires_python: SpecifierSet + requires_externals: List[str] + project_urls: List[_LabelAndURL] + provides_dists: List[str] + obsoletes_dists: List[str] + description_content_type: str + provides_extras: List[NormalizedName] + dynamic_fields: List[DynamicField] + + def __init__( + self, + name: str, + version: Version, + *, + # 1.0 + platforms: Optional[Iterable[str]] = None, + summary: Optional[str] = None, + description: Optional[str] = None, + keywords: Optional[Iterable[str]] = None, + home_page: Optional[str] = None, + author: Optional[str] = None, + author_emails: Optional[Iterable[_NameAndEmail]] = None, + license: Optional[str] = None, + # 1.1 + supported_platforms: Optional[Iterable[str]] = None, + download_url: Optional[str] = None, + classifiers: Optional[Iterable[str]] = None, + # 1.2 + maintainer: Optional[str] = None, + maintainer_emails: Optional[Iterable[_NameAndEmail]] = None, + requires_dists: Optional[Iterable[Requirement]] = None, + requires_python: Optional[SpecifierSet] = None, + requires_externals: Optional[Iterable[str]] = None, + project_urls: Optional[Iterable[_LabelAndURL]] = None, + provides_dists: Optional[Iterable[str]] = None, + obsoletes_dists: Optional[Iterable[str]] = None, + # 2.1 + description_content_type: Optional[str] = None, + provides_extras: Optional[Iterable[NormalizedName]] = None, + # 2.2 + dynamic_fields: Optional[Iterable[DynamicField]] = None, + ) -> None: + """Initialize a Metadata object. + + The parameters all correspond to fields in `Core Metadata`_. + + :param name: ``Name`` + :param version: ``Version`` + :param platforms: ``Platform`` + :param summary: ``Summary`` + :param description: ``Description`` + :param keywords: ``Keywords`` + :param home_page: ``Home-Page`` + :param author: ``Author`` + :param author_emails: + ``Author-Email`` (two-item tuple represents the name and email of the + author) + :param license: ``License`` + :param supported_platforms: ``Supported-Platform`` + :param download_url: ``Download-URL`` + :param classifiers: ``Classifier`` + :param maintainer: ``Maintainer`` + :param maintainer_emails: + ``Maintainer-Email`` (two-item tuple represent the name and email of the + maintainer) + :param requires_dists: ``Requires-Dist`` + :param SpecifierSet requires_python: ``Requires-Python`` + :param requires_externals: ``Requires-External`` + :param project_urls: ``Project-URL`` + :param provides_dists: ``Provides-Dist`` + :param obsoletes_dists: ``Obsoletes-Dist`` + :param description_content_type: ``Description-Content-Type`` + :param provides_extras: ``Provides-Extra`` + :param dynamic_fields: ``Dynamic`` + """ + self.display_name = name + self.version = version + self.platforms = list(platforms or []) + self.summary = summary or "" + self.description = description or "" + self.keywords = list(keywords or []) + self.home_page = home_page or "" + self.author = author or "" + self.author_emails = list(author_emails or []) + self.license = license or "" + self.supported_platforms = list(supported_platforms or []) + self.download_url = download_url or "" + self.classifiers = list(classifiers or []) + self.maintainer = maintainer or "" + self.maintainer_emails = list(maintainer_emails or []) + self.requires_dists = list(requires_dists or []) + self.requires_python = requires_python or SpecifierSet() + self.requires_externals = list(requires_externals or []) + self.project_urls = list(project_urls or []) + self.provides_dists = list(provides_dists or []) + self.obsoletes_dists = list(obsoletes_dists or []) + self.description_content_type = description_content_type or "" + self.provides_extras = list(provides_extras or []) + self.dynamic_fields = list(dynamic_fields or []) + + @property + def display_name(self) -> str: + """ + The project name to be displayed to users (i.e. not normalized). Initially + set based on the `name` parameter. + + Setting this attribute will also update :attr:`canonical_name`. + """ + return self._display_name + + @display_name.setter + def display_name(self, value: str) -> None: + self._display_name = value + self._canonical_name = canonicalize_name(value) + + # Use functools.cached_property once Python 3.7 support is dropped. + # Value is set by self.display_name.setter to keep in sync with self.display_name. + @property + def canonical_name(self) -> NormalizedName: + """ + The normalized project name as per :func:`packaging.utils.canonicalize_name`. + + The attribute is read-only and automatically calculated based on the value of + :attr:`display_name`. + """ + return self._canonical_name diff --git a/packaging/requirements.py b/packaging/requirements.py index bc0b17ca3..971fb8fb5 100644 --- a/packaging/requirements.py +++ b/packaging/requirements.py @@ -2,26 +2,18 @@ # 2.0, and the BSD License. See the LICENSE file in the root of this repository # for complete details. -import re -import string import urllib.parse -from typing import Any, List, Optional as TOptional, Set - -from pyparsing import ( # noqa - Combine, - Literal as L, - Optional, - ParseException, - Regex, - Word, - ZeroOrMore, - originalTextFor, - stringEnd, - stringStart, -) +from collections import namedtuple +from typing import Any, List, Optional, Set + +from ._parser import parse_named_requirement +from ._tokenizer import ParseExceptionError +from .markers import InvalidMarker, Marker +from .specifiers import SpecifierSet -from .markers import MARKER_EXPR as _MARKER_EXPR, Marker -from .specifiers import LegacySpecifier, Specifier, SpecifierSet +_RequirementTuple = namedtuple( + "_RequirementTuple", ["name", "url", "extras", "specifier", "marker"] +) class InvalidRequirement(ValueError): @@ -30,60 +22,6 @@ class InvalidRequirement(ValueError): """ -ALPHANUM = Word(string.ascii_letters + string.digits) - -LBRACKET = L("[").suppress() -RBRACKET = L("]").suppress() -LPAREN = L("(").suppress() -RPAREN = L(")").suppress() -COMMA = L(",").suppress() -SEMICOLON = L(";").suppress() -AT = L("@").suppress() - -PUNCTUATION = Word("-_.") -IDENTIFIER_END = ALPHANUM | (ZeroOrMore(PUNCTUATION) + ALPHANUM) -IDENTIFIER = Combine(ALPHANUM + ZeroOrMore(IDENTIFIER_END)) - -NAME = IDENTIFIER("name") -EXTRA = IDENTIFIER - -URI = Regex(r"[^ ]+")("url") -URL = AT + URI - -EXTRAS_LIST = EXTRA + ZeroOrMore(COMMA + EXTRA) -EXTRAS = (LBRACKET + Optional(EXTRAS_LIST) + RBRACKET)("extras") - -VERSION_PEP440 = Regex(Specifier._regex_str, re.VERBOSE | re.IGNORECASE) -VERSION_LEGACY = Regex(LegacySpecifier._regex_str, re.VERBOSE | re.IGNORECASE) - -VERSION_ONE = VERSION_PEP440 ^ VERSION_LEGACY -VERSION_MANY = Combine( - VERSION_ONE + ZeroOrMore(COMMA + VERSION_ONE), joinString=",", adjacent=False -)("_raw_spec") -_VERSION_SPEC = Optional((LPAREN + VERSION_MANY + RPAREN) | VERSION_MANY) -_VERSION_SPEC.setParseAction(lambda s, l, t: t._raw_spec or "") - -VERSION_SPEC = originalTextFor(_VERSION_SPEC)("specifier") -VERSION_SPEC.setParseAction(lambda s, l, t: t[1]) - -MARKER_EXPR = originalTextFor(_MARKER_EXPR())("marker") -MARKER_EXPR.setParseAction( - lambda s, l, t: Marker(s[t._original_start : t._original_end]) -) -MARKER_SEPARATOR = SEMICOLON -MARKER = MARKER_SEPARATOR + MARKER_EXPR - -VERSION_AND_MARKER = VERSION_SPEC + Optional(MARKER) -URL_AND_MARKER = URL + Optional(MARKER) - -NAMED_REQUIREMENT = NAME + Optional(EXTRAS) + (URL_AND_MARKER | VERSION_AND_MARKER) - -REQUIREMENT = stringStart + NAMED_REQUIREMENT + stringEnd -# pyparsing isn't thread safe during initialization, so we do it eagerly, see -# issue #104 -REQUIREMENT.parseString("x[]") - - class Requirement: """Parse a requirement. @@ -99,11 +37,9 @@ class Requirement: def __init__(self, requirement_string: str) -> None: try: - req = REQUIREMENT.parseString(requirement_string) - except ParseException as e: - raise InvalidRequirement( - f'Parse error at "{ requirement_string[e.loc : e.loc + 8]!r}": {e.msg}' - ) + req = _RequirementTuple(*parse_named_requirement(requirement_string)) + except ParseExceptionError as e: + raise InvalidRequirement(str(e)) self.name: str = req.name if req.url: @@ -115,12 +51,15 @@ def __init__(self, requirement_string: str) -> None: not parsed_url.scheme and not parsed_url.netloc ): raise InvalidRequirement(f"Invalid URL: {req.url}") - self.url: TOptional[str] = req.url + self.url: Optional[str] = req.url else: self.url = None - self.extras: Set[str] = set(req.extras.asList() if req.extras else []) + self.extras: Set[str] = set(req.extras if req.extras else []) self.specifier: SpecifierSet = SpecifierSet(req.specifier) - self.marker: TOptional[Marker] = req.marker if req.marker else None + try: + self.marker: Optional[Marker] = Marker(req.marker) if req.marker else None + except InvalidMarker as e: + raise InvalidRequirement(str(e)) def __str__(self) -> str: parts: List[str] = [self.name] diff --git a/packaging/specifiers.py b/packaging/specifiers.py index a2d51b04c..9220739a7 100644 --- a/packaging/specifiers.py +++ b/packaging/specifiers.py @@ -1,38 +1,40 @@ # This file is dual licensed under the terms of the Apache License, Version # 2.0, and the BSD License. See the LICENSE file in the root of this repository # for complete details. +""" +.. testsetup:: + + from packaging.specifiers import Specifier, SpecifierSet, InvalidSpecifier + from packaging.version import Version +""" import abc -import functools import itertools import re -import warnings -from typing import ( - Callable, - Dict, - Iterable, - Iterator, - List, - Optional, - Pattern, - Set, - Tuple, - TypeVar, - Union, -) +from typing import Callable, Iterable, Iterator, List, Optional, Set, Tuple, Union from .utils import canonicalize_version -from .version import LegacyVersion, Version, parse +from .version import Version + +UnparsedVersion = Union[Version, str] +CallableOperator = Callable[[Version, str], bool] -ParsedVersion = Union[Version, LegacyVersion] -UnparsedVersion = Union[Version, LegacyVersion, str] -VersionTypeVar = TypeVar("VersionTypeVar", bound=UnparsedVersion) -CallableOperator = Callable[[ParsedVersion, str], bool] + +def _coerce_version(version: UnparsedVersion) -> Version: + if not isinstance(version, Version): + version = Version(version) + return version class InvalidSpecifier(ValueError): """ - An invalid specifier was found, users should refer to PEP 440. + Raised when attempting to create a :class:`Specifier` with a specifier + string that is invalid. + + >>> Specifier("lolwat") + Traceback (most recent call last): + ... + packaging.specifiers.InvalidSpecifier: Invalid specifier: 'lolwat' """ @@ -40,36 +42,39 @@ class BaseSpecifier(metaclass=abc.ABCMeta): @abc.abstractmethod def __str__(self) -> str: """ - Returns the str representation of this Specifier like object. This + Returns the str representation of this Specifier-like object. This should be representative of the Specifier itself. """ @abc.abstractmethod def __hash__(self) -> int: """ - Returns a hash value for this Specifier like object. + Returns a hash value for this Specifier-like object. """ @abc.abstractmethod def __eq__(self, other: object) -> bool: """ - Returns a boolean representing whether or not the two Specifier like + Returns a boolean representing whether or not the two Specifier-like objects are equal. + + :param other: The other object to check against. """ @property @abc.abstractmethod def prereleases(self) -> Optional[bool]: - """ - Returns whether or not pre-releases as a whole are allowed by this - specifier. + """Whether or not pre-releases as a whole are allowed. + + 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 use default semantics. """ @prereleases.setter def prereleases(self, value: bool) -> None: - """ - Sets whether or not pre-releases as a whole are allowed by this - specifier. + """Setter for :attr:`prereleases`. + + :param value: The value to set. """ @abc.abstractmethod @@ -80,231 +85,28 @@ def contains(self, item: str, prereleases: Optional[bool] = None) -> bool: @abc.abstractmethod def filter( - self, iterable: Iterable[VersionTypeVar], prereleases: Optional[bool] = None - ) -> Iterable[VersionTypeVar]: + self, iterable: Iterable[UnparsedVersion], prereleases: Optional[bool] = None + ) -> Iterable[UnparsedVersion]: """ Takes an iterable of items and filters them so that only items which are contained within this specifier are allowed in it. """ -class _IndividualSpecifier(BaseSpecifier): - - _operators: Dict[str, str] = {} - _regex: Pattern[str] - - def __init__(self, spec: str = "", prereleases: Optional[bool] = None) -> None: - match = self._regex.search(spec) - if not match: - raise InvalidSpecifier(f"Invalid specifier: '{spec}'") - - self._spec: Tuple[str, str] = ( - match.group("operator").strip(), - match.group("version").strip(), - ) - - # Store whether or not this Specifier should accept prereleases - self._prereleases = prereleases - - def __repr__(self) -> str: - pre = ( - f", prereleases={self.prereleases!r}" - if self._prereleases is not None - else "" - ) - - return f"<{self.__class__.__name__}({str(self)!r}{pre})>" - - def __str__(self) -> str: - return "{}{}".format(*self._spec) - - @property - def _canonical_spec(self) -> Tuple[str, str]: - canonical_version = canonicalize_version( - self._spec[1], - strip_trailing_zero=(self._spec[0] != "~="), - ) - return self._spec[0], canonical_version - - def __hash__(self) -> int: - return hash(self._canonical_spec) - - def __eq__(self, other: object) -> bool: - if isinstance(other, str): - try: - other = self.__class__(str(other)) - except InvalidSpecifier: - return NotImplemented - elif not isinstance(other, self.__class__): - return NotImplemented - - return self._canonical_spec == other._canonical_spec - - def _get_operator(self, op: str) -> CallableOperator: - operator_callable: CallableOperator = getattr( - self, f"_compare_{self._operators[op]}" - ) - return operator_callable - - def _coerce_version(self, version: UnparsedVersion) -> ParsedVersion: - if not isinstance(version, (LegacyVersion, Version)): - version = parse(version) - return version - - @property - def operator(self) -> str: - return self._spec[0] - - @property - def version(self) -> str: - return self._spec[1] - - @property - def prereleases(self) -> Optional[bool]: - return self._prereleases - - @prereleases.setter - def prereleases(self, value: bool) -> None: - self._prereleases = value - - def __contains__(self, item: str) -> bool: - return self.contains(item) - - def contains( - self, item: UnparsedVersion, prereleases: Optional[bool] = None - ) -> bool: - - # 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") - normalized_item = self._coerce_version(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 normalized_item.is_prerelease and not prereleases: - return False +class Specifier(BaseSpecifier): + """This class abstracts handling of version specifiers. - # Actually do the comparison to determine if this item is contained - # within this Specifier or not. - operator_callable: CallableOperator = self._get_operator(self.operator) - return operator_callable(normalized_item, self.version) - - def filter( - self, iterable: Iterable[VersionTypeVar], prereleases: Optional[bool] = None - ) -> Iterable[VersionTypeVar]: + .. tip:: - 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: - parsed_version = self._coerce_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 in case 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 beginning. - 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 - - -class LegacySpecifier(_IndividualSpecifier): - - _regex_str = r""" - (?P(==|!=|<=|>=|<|>)) - \s* - (?P - [^,;\s)]* # Since this is a "legacy" specifier, and the version - # string can be just about anything, we match everything - # except for whitespace, a semi-colon for marker support, - # a closing paren since versions can be enclosed in - # them, and a comma since it's a version separator. - ) - """ - - _regex = re.compile(r"^\s*" + _regex_str + r"\s*$", re.VERBOSE | re.IGNORECASE) - - _operators = { - "==": "equal", - "!=": "not_equal", - "<=": "less_than_equal", - ">=": "greater_than_equal", - "<": "less_than", - ">": "greater_than", - } - - def __init__(self, spec: str = "", prereleases: Optional[bool] = None) -> None: - super().__init__(spec, prereleases) - - warnings.warn( - "Creating a LegacyVersion has been deprecated and will be " - "removed in the next major release", - DeprecationWarning, - ) - - def _coerce_version(self, version: UnparsedVersion) -> LegacyVersion: - if not isinstance(version, LegacyVersion): - version = LegacyVersion(str(version)) - return version - - def _compare_equal(self, prospective: LegacyVersion, spec: str) -> bool: - return prospective == self._coerce_version(spec) - - def _compare_not_equal(self, prospective: LegacyVersion, spec: str) -> bool: - return prospective != self._coerce_version(spec) - - def _compare_less_than_equal(self, prospective: LegacyVersion, spec: str) -> bool: - return prospective <= self._coerce_version(spec) - - def _compare_greater_than_equal( - self, prospective: LegacyVersion, spec: str - ) -> bool: - return prospective >= self._coerce_version(spec) - - def _compare_less_than(self, prospective: LegacyVersion, spec: str) -> bool: - return prospective < self._coerce_version(spec) - - def _compare_greater_than(self, prospective: LegacyVersion, spec: str) -> bool: - return prospective > self._coerce_version(spec) - - -def _require_version_compare( - fn: Callable[["Specifier", ParsedVersion, str], bool] -) -> Callable[["Specifier", ParsedVersion, str], bool]: - @functools.wraps(fn) - def wrapped(self: "Specifier", prospective: ParsedVersion, spec: str) -> bool: - if not isinstance(prospective, Version): - return False - return fn(self, prospective, spec) - - return wrapped - - -class Specifier(_IndividualSpecifier): + It is generally not required to instantiate this manually. You should instead + prefer to work with :class:`SpecifierSet` instead, which can parse + comma-separated version specifiers (which is what package metadata contains). + """ - _regex_str = r""" + _operator_regex_str = r""" (?P(~=|==|!=|<=|>=|<|>|===)) + """ + _version_regex_str = r""" (?P (?: # The identity operators allow for an escape hatch that will @@ -341,10 +143,10 @@ class Specifier(_IndividualSpecifier): # You cannot use a wild card and a dev or local version # together so group them with a | and make them optional. (?: + \.\* # Wild card syntax of .* + | (?:[-_\.]?dev[-_\.]?[0-9]*)? # dev release (?:\+[a-z0-9]+(?:[-_\.][a-z0-9]+)*)? # local - | - \.\* # Wild card syntax of .* )? ) | @@ -396,7 +198,10 @@ class Specifier(_IndividualSpecifier): ) """ - _regex = re.compile(r"^\s*" + _regex_str + r"\s*$", re.VERBOSE | re.IGNORECASE) + _regex = re.compile( + r"^\s*" + _operator_regex_str + _version_regex_str + r"\s*$", + re.VERBOSE | re.IGNORECASE, + ) _operators = { "~=": "compatible", @@ -409,8 +214,152 @@ class Specifier(_IndividualSpecifier): "===": "arbitrary", } - @_require_version_compare - def _compare_compatible(self, prospective: ParsedVersion, spec: str) -> bool: + def __init__(self, spec: str = "", prereleases: Optional[bool] = None) -> None: + """Initialize a Specifier instance. + + :param spec: + The string representation of a specifier which will be parsed and + normalized before use. + :param 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 given specifier is invalid (i.e. bad syntax). + """ + match = self._regex.search(spec) + if not match: + raise InvalidSpecifier(f"Invalid specifier: '{spec}'") + + self._spec: Tuple[str, str] = ( + match.group("operator").strip(), + match.group("version").strip(), + ) + + # Store whether or not this Specifier should accept prereleases + self._prereleases = prereleases + + @property + def prereleases(self) -> bool: + # 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. + operator, version = self._spec + if operator in ["==", ">=", "<=", "~=", "==="]: + # The == specifier can include a trailing .*, if it does we + # want to remove before parsing. + if operator == "==" and version.endswith(".*"): + version = version[:-2] + + # Parse the version, and if it is a pre-release than this + # specifier allows pre-releases. + if Version(version).is_prerelease: + return True + + return False + + @prereleases.setter + def prereleases(self, value: bool) -> None: + self._prereleases = value + + @property + def operator(self) -> str: + """The operator of this specifier. + + >>> Specifier("==1.2.3").operator + '==' + """ + return self._spec[0] + + @property + def version(self) -> str: + """The version of this specifier. + + >>> Specifier("==1.2.3").version + '1.2.3' + """ + return self._spec[1] + + def __repr__(self) -> str: + """A representation of the Specifier that shows all internal state. + + >>> Specifier('>=1.0.0') + =1.0.0')> + >>> Specifier('>=1.0.0', prereleases=False) + =1.0.0', prereleases=False)> + >>> Specifier('>=1.0.0', prereleases=True) + =1.0.0', prereleases=True)> + """ + pre = ( + f", prereleases={self.prereleases!r}" + if self._prereleases is not None + else "" + ) + + return f"<{self.__class__.__name__}({str(self)!r}{pre})>" + + def __str__(self) -> str: + """A string representation of the Specifier that can be round-tripped. + + >>> str(Specifier('>=1.0.0')) + '>=1.0.0' + >>> str(Specifier('>=1.0.0', prereleases=False)) + '>=1.0.0' + """ + return "{}{}".format(*self._spec) + + @property + def _canonical_spec(self) -> Tuple[str, str]: + canonical_version = canonicalize_version( + self._spec[1], + strip_trailing_zero=(self._spec[0] != "~="), + ) + return self._spec[0], canonical_version + + def __hash__(self) -> int: + return hash(self._canonical_spec) + + def __eq__(self, other: object) -> bool: + """Whether or not the two Specifier-like objects are equal. + + :param other: The other object to check against. + + The value of :attr:`prereleases` is ignored. + + >>> Specifier("==1.2.3") == Specifier("== 1.2.3.0") + True + >>> (Specifier("==1.2.3", prereleases=False) == + ... Specifier("==1.2.3", prereleases=True)) + True + >>> Specifier("==1.2.3") == "==1.2.3" + True + >>> Specifier("==1.2.3") == Specifier("==1.2.4") + False + >>> Specifier("==1.2.3") == Specifier("~=1.2.3") + False + """ + if isinstance(other, str): + try: + other = self.__class__(str(other)) + except InvalidSpecifier: + return NotImplemented + elif not isinstance(other, self.__class__): + return NotImplemented + + return self._canonical_spec == other._canonical_spec + + def _get_operator(self, op: str) -> CallableOperator: + operator_callable: CallableOperator = getattr( + self, f"_compare_{self._operators[op]}" + ) + return operator_callable + + def _compare_compatible(self, prospective: Version, spec: str) -> bool: # Compatible releases have an equivalent combination of >= and ==. That # is that ~=2.2 is equivalent to >=2.2,==2.*. This allows us to @@ -431,8 +380,7 @@ def _compare_compatible(self, prospective: ParsedVersion, spec: str) -> bool: prospective, prefix ) - @_require_version_compare - def _compare_equal(self, prospective: ParsedVersion, spec: str) -> bool: + def _compare_equal(self, prospective: Version, spec: str) -> bool: # We need special logic to handle prefix matching if spec.endswith(".*"): @@ -471,30 +419,24 @@ def _compare_equal(self, prospective: ParsedVersion, spec: str) -> bool: return prospective == spec_version - @_require_version_compare - def _compare_not_equal(self, prospective: ParsedVersion, spec: str) -> bool: + def _compare_not_equal(self, prospective: Version, spec: str) -> bool: return not self._compare_equal(prospective, spec) - @_require_version_compare - def _compare_less_than_equal(self, prospective: ParsedVersion, spec: str) -> bool: + def _compare_less_than_equal(self, prospective: Version, spec: str) -> bool: # NB: Local version identifiers are NOT permitted in the version # specifier, so local version labels can be universally removed from # the prospective version. return Version(prospective.public) <= Version(spec) - @_require_version_compare - def _compare_greater_than_equal( - self, prospective: ParsedVersion, spec: str - ) -> bool: + def _compare_greater_than_equal(self, prospective: Version, spec: str) -> bool: # NB: Local version identifiers are NOT permitted in the version # specifier, so local version labels can be universally removed from # the prospective version. return Version(prospective.public) >= Version(spec) - @_require_version_compare - def _compare_less_than(self, prospective: ParsedVersion, spec_str: str) -> bool: + def _compare_less_than(self, prospective: Version, spec_str: str) -> bool: # Convert our spec to a Version instance, since we'll want to work with # it as a version. @@ -519,8 +461,7 @@ def _compare_less_than(self, prospective: ParsedVersion, spec_str: str) -> bool: # version in the spec. return True - @_require_version_compare - def _compare_greater_than(self, prospective: ParsedVersion, spec_str: str) -> bool: + def _compare_greater_than(self, prospective: Version, spec_str: str) -> bool: # Convert our spec to a Version instance, since we'll want to work with # it as a version. @@ -554,34 +495,133 @@ def _compare_greater_than(self, prospective: ParsedVersion, spec_str: str) -> bo def _compare_arbitrary(self, prospective: Version, spec: str) -> bool: return str(prospective).lower() == str(spec).lower() - @property - def prereleases(self) -> bool: + def __contains__(self, item: Union[str, Version]) -> bool: + """Return whether or not the item is contained in this specifier. - # 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 + :param item: The item to check for. - # Look at all of our specifiers and determine if they are inclusive - # operators, and if they are if they are including an explicit - # prerelease. - operator, version = self._spec - if operator in ["==", ">=", "<=", "~=", "==="]: - # The == specifier can include a trailing .*, if it does we - # want to remove before parsing. - if operator == "==" and version.endswith(".*"): - version = version[:-2] + This is used for the ``in`` operator and behaves the same as + :meth:`contains` with no ``prereleases`` argument passed. - # Parse the version, and if it is a pre-release than this - # specifier allows pre-releases. - if parse(version).is_prerelease: - return True + >>> "1.2.3" in Specifier(">=1.2.3") + True + >>> Version("1.2.3") in Specifier(">=1.2.3") + True + >>> "1.0.0" in Specifier(">=1.2.3") + False + >>> "1.3.0a1" in Specifier(">=1.2.3") + False + >>> "1.3.0a1" in Specifier(">=1.2.3", prereleases=True) + True + """ + return self.contains(item) - return False + def contains( + self, item: UnparsedVersion, prereleases: Optional[bool] = None + ) -> bool: + """Return whether or not the item is contained in this specifier. + + :param item: + The item to check for, which can be a version string or a + :class:`Version` instance. + :param prereleases: + Whether or not to match prereleases with this Specifier. If set to + ``None`` (the default), it uses :attr:`prereleases` to determine + whether or not prereleases are allowed. + + >>> Specifier(">=1.2.3").contains("1.2.3") + True + >>> Specifier(">=1.2.3").contains(Version("1.2.3")) + True + >>> Specifier(">=1.2.3").contains("1.0.0") + False + >>> Specifier(">=1.2.3").contains("1.3.0a1") + False + >>> Specifier(">=1.2.3", prereleases=True).contains("1.3.0a1") + True + >>> Specifier(">=1.2.3").contains("1.3.0a1", prereleases=True) + True + """ - @prereleases.setter - def prereleases(self, value: bool) -> None: - self._prereleases = value + # Determine if prereleases are to be allowed or not. + if prereleases is None: + prereleases = self.prereleases + + # Normalize item to a Version, this allows us to have a shortcut for + # "2.0" in Specifier(">=2") + normalized_item = _coerce_version(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 normalized_item.is_prerelease and not prereleases: + return False + + # Actually do the comparison to determine if this item is contained + # within this Specifier or not. + operator_callable: CallableOperator = self._get_operator(self.operator) + return operator_callable(normalized_item, self.version) + + def filter( + self, iterable: Iterable[UnparsedVersion], prereleases: Optional[bool] = None + ) -> Iterable[UnparsedVersion]: + """Filter items in the given iterable, that match the specifier. + + :param iterable: + An iterable that can contain version strings and :class:`Version` instances. + The items in the iterable will be filtered according to the specifier. + :param prereleases: + Whether or not to allow prereleases in the returned iterable. If set to + ``None`` (the default), it will be intelligently decide whether to allow + prereleases or not (based on the :attr:`prereleases` attribute, and + whether the only versions matching are prereleases). + + This method is smarter than just ``filter(Specifier().contains, [...])`` + because it implements the rule from :pep:`440` that a prerelease item + SHOULD be accepted if no other versions match the given specifier. + + >>> list(Specifier(">=1.2.3").filter(["1.2", "1.3", "1.5a1"])) + ['1.3'] + >>> list(Specifier(">=1.2.3").filter(["1.2", "1.2.3", "1.3", Version("1.4")])) + ['1.2.3', '1.3', ] + >>> list(Specifier(">=1.2.3").filter(["1.2", "1.5a1"])) + ['1.5a1'] + >>> list(Specifier(">=1.2.3").filter(["1.3", "1.5a1"], prereleases=True)) + ['1.3', '1.5a1'] + >>> list(Specifier(">=1.2.3", prereleases=True).filter(["1.3", "1.5a1"])) + ['1.3', '1.5a1'] + """ + + 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: + parsed_version = _coerce_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 beginning. + 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]+)$") @@ -623,22 +663,39 @@ def _pad_version(left: List[str], right: List[str]) -> Tuple[List[str], List[str class SpecifierSet(BaseSpecifier): + """This class abstracts handling of a set of version specifiers. + + It can be passed a single specifier (``>=3.0``), a comma-separated list of + specifiers (``>=3.0,!=3.1``), or no specifier at all. + """ + def __init__( self, specifiers: str = "", prereleases: Optional[bool] = None ) -> None: + """Initialize a SpecifierSet instance. + + :param specifiers: + The string representation of a specifier or a comma-separated list of + specifiers which will be parsed and normalized before use. + :param prereleases: + This tells the SpecifierSet 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 given ``specifiers`` are not parseable than this exception will be + raised. + """ - # Split on , to break each individual specifier into it's own item, and + # Split on `,` to break each individual specifier into it's own item, and # strip each item to remove leading/trailing whitespace. split_specifiers = [s.strip() for s in specifiers.split(",") if s.strip()] # Parsed each individual specifier, attempting first to make it a - # Specifier and falling back to a LegacySpecifier. - parsed: Set[_IndividualSpecifier] = set() + # Specifier. + parsed: Set[Specifier] = set() for specifier in split_specifiers: - try: - parsed.add(Specifier(specifier)) - except InvalidSpecifier: - parsed.add(LegacySpecifier(specifier)) + parsed.add(Specifier(specifier)) # Turn our parsed specifiers into a frozen set and save them for later. self._specs = frozenset(parsed) @@ -647,7 +704,40 @@ def __init__( # we accept prereleases or not. self._prereleases = prereleases + @property + def prereleases(self) -> Optional[bool]: + # If we have been given an explicit prerelease modifier, then we'll + # pass that through here. + if self._prereleases is not None: + return self._prereleases + + # If we don't have any specifiers, and we don't have a forced value, + # then we'll just return None since we don't know if this should have + # pre-releases or not. + if not self._specs: + return None + + # Otherwise we'll see if any of the given specifiers accept + # prereleases, if any of them do we'll return True, otherwise False. + return any(s.prereleases for s in self._specs) + + @prereleases.setter + def prereleases(self, value: bool) -> None: + self._prereleases = value + def __repr__(self) -> str: + """A representation of the specifier set that shows all internal state. + + Note that the ordering of the individual specifiers within the set may not + match the input string. + + >>> SpecifierSet('>=1.0.0,!=2.0.0') + =1.0.0')> + >>> SpecifierSet('>=1.0.0,!=2.0.0', prereleases=False) + =1.0.0', prereleases=False)> + >>> SpecifierSet('>=1.0.0,!=2.0.0', prereleases=True) + =1.0.0', prereleases=True)> + """ pre = ( f", prereleases={self.prereleases!r}" if self._prereleases is not None @@ -657,12 +747,31 @@ def __repr__(self) -> str: return f"" def __str__(self) -> str: + """A string representation of the specifier set that can be round-tripped. + + Note that the ordering of the individual specifiers within the set may not + match the input string. + + >>> str(SpecifierSet(">=1.0.0,!=1.0.1")) + '!=1.0.1,>=1.0.0' + >>> str(SpecifierSet(">=1.0.0,!=1.0.1", prereleases=False)) + '!=1.0.1,>=1.0.0' + """ return ",".join(sorted(str(s) for s in self._specs)) def __hash__(self) -> int: return hash(self._specs) def __and__(self, other: Union["SpecifierSet", str]) -> "SpecifierSet": + """Return a SpecifierSet which is a combination of the two sets. + + :param other: The other object to combine with. + + >>> SpecifierSet(">=1.0.0,!=1.0.1") & '<=2.0.0,!=2.0.1' + =1.0.0')> + >>> SpecifierSet(">=1.0.0,!=1.0.1") & SpecifierSet('<=2.0.0,!=2.0.1') + =1.0.0')> + """ if isinstance(other, str): other = SpecifierSet(other) elif not isinstance(other, SpecifierSet): @@ -686,7 +795,25 @@ def __and__(self, other: Union["SpecifierSet", str]) -> "SpecifierSet": return specifier def __eq__(self, other: object) -> bool: - if isinstance(other, (str, _IndividualSpecifier)): + """Whether or not the two SpecifierSet-like objects are equal. + + :param other: The other object to check against. + + The value of :attr:`prereleases` is ignored. + + >>> SpecifierSet(">=1.0.0,!=1.0.1") == SpecifierSet(">=1.0.0,!=1.0.1") + True + >>> (SpecifierSet(">=1.0.0,!=1.0.1", prereleases=False) == + ... SpecifierSet(">=1.0.0,!=1.0.1", prereleases=True)) + True + >>> SpecifierSet(">=1.0.0,!=1.0.1") == ">=1.0.0,!=1.0.1" + True + >>> SpecifierSet(">=1.0.0,!=1.0.1") == SpecifierSet(">=1.0.0") + False + >>> SpecifierSet(">=1.0.0,!=1.0.1") == SpecifierSet(">=1.0.0,!=1.0.2") + False + """ + if isinstance(other, (str, Specifier)): other = SpecifierSet(str(other)) elif not isinstance(other, SpecifierSet): return NotImplemented @@ -694,34 +821,38 @@ def __eq__(self, other: object) -> bool: return self._specs == other._specs def __len__(self) -> int: + """Returns the number of specifiers in this specifier set.""" return len(self._specs) - def __iter__(self) -> Iterator[_IndividualSpecifier]: - return iter(self._specs) - - @property - def prereleases(self) -> Optional[bool]: - - # If we have been given an explicit prerelease modifier, then we'll - # pass that through here. - if self._prereleases is not None: - return self._prereleases - - # If we don't have any specifiers, and we don't have a forced value, - # then we'll just return None since we don't know if this should have - # pre-releases or not. - if not self._specs: - return None - - # Otherwise we'll see if any of the given specifiers accept - # prereleases, if any of them do we'll return True, otherwise False. - return any(s.prereleases for s in self._specs) + def __iter__(self) -> Iterator[Specifier]: + """ + Returns an iterator over all the underlying :class:`Specifier` instances + in this specifier set. - @prereleases.setter - def prereleases(self, value: bool) -> None: - self._prereleases = value + >>> sorted(SpecifierSet(">=1.0.0,!=1.0.1"), key=str) + [, =1.0.0')>] + """ + return iter(self._specs) def __contains__(self, item: UnparsedVersion) -> bool: + """Return whether or not the item is contained in this specifier. + + :param item: The item to check for. + + This is used for the ``in`` operator and behaves the same as + :meth:`contains` with no ``prereleases`` argument passed. + + >>> "1.2.3" in SpecifierSet(">=1.0.0,!=1.0.1") + True + >>> Version("1.2.3") in SpecifierSet(">=1.0.0,!=1.0.1") + True + >>> "1.0.1" in SpecifierSet(">=1.0.0,!=1.0.1") + False + >>> "1.3.0a1" in SpecifierSet(">=1.0.0,!=1.0.1") + False + >>> "1.3.0a1" in SpecifierSet(">=1.0.0,!=1.0.1", prereleases=True) + True + """ return self.contains(item) def contains( @@ -730,10 +861,32 @@ def contains( prereleases: Optional[bool] = None, installed: Optional[bool] = None, ) -> bool: - - # Ensure that our item is a Version or LegacyVersion instance. - if not isinstance(item, (LegacyVersion, Version)): - item = parse(item) + """Return whether or not the item is contained in this SpecifierSet. + + :param item: + The item to check for, which can be a version string or a + :class:`Version` instance. + :param prereleases: + Whether or not to match prereleases with this SpecifierSet. If set to + ``None`` (the default), it uses :attr:`prereleases` to determine + whether or not prereleases are allowed. + + >>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.2.3") + True + >>> SpecifierSet(">=1.0.0,!=1.0.1").contains(Version("1.2.3")) + True + >>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.0.1") + False + >>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.3.0a1") + False + >>> SpecifierSet(">=1.0.0,!=1.0.1", prereleases=True).contains("1.3.0a1") + True + >>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.3.0a1", prereleases=True) + True + """ + # Ensure that our item is a Version instance. + if not isinstance(item, Version): + item = Version(item) # Determine if we're forcing a prerelease or not, if we're not forcing # one for this particular filter call, then we'll use whatever the @@ -751,7 +904,7 @@ def contains( return False if installed and item.is_prerelease: - item = parse(item.base_version) + item = Version(item.base_version) # We simply dispatch to the underlying specs here to make sure that the # given version is contained within all of them. @@ -760,9 +913,46 @@ def contains( return all(s.contains(item, prereleases=prereleases) for s in self._specs) def filter( - self, iterable: Iterable[VersionTypeVar], prereleases: Optional[bool] = None - ) -> Iterable[VersionTypeVar]: - + self, iterable: Iterable[UnparsedVersion], prereleases: Optional[bool] = None + ) -> Iterable[UnparsedVersion]: + """Filter items in the given iterable, that match the specifiers in this set. + + :param iterable: + An iterable that can contain version strings and :class:`Version` instances. + The items in the iterable will be filtered according to the specifier. + :param prereleases: + Whether or not to allow prereleases in the returned iterable. If set to + ``None`` (the default), it will be intelligently decide whether to allow + prereleases or not (based on the :attr:`prereleases` attribute, and + whether the only versions matching are prereleases). + + This method is smarter than just ``filter(SpecifierSet(...).contains, [...])`` + because it implements the rule from :pep:`440` that a prerelease item + SHOULD be accepted if no other versions match the given specifier. + + >>> list(SpecifierSet(">=1.2.3").filter(["1.2", "1.3", "1.5a1"])) + ['1.3'] + >>> list(SpecifierSet(">=1.2.3").filter(["1.2", "1.3", Version("1.4")])) + ['1.3', ] + >>> list(SpecifierSet(">=1.2.3").filter(["1.2", "1.5a1"])) + [] + >>> list(SpecifierSet(">=1.2.3").filter(["1.3", "1.5a1"], prereleases=True)) + ['1.3', '1.5a1'] + >>> list(SpecifierSet(">=1.2.3", prereleases=True).filter(["1.3", "1.5a1"])) + ['1.3', '1.5a1'] + + An "empty" SpecifierSet will filter items based on the presence of prerelease + versions in the set. + + >>> list(SpecifierSet("").filter(["1.3", "1.5a1"])) + ['1.3'] + >>> list(SpecifierSet("").filter(["1.5a1"])) + ['1.5a1'] + >>> list(SpecifierSet("", prereleases=True).filter(["1.3", "1.5a1"])) + ['1.3', '1.5a1'] + >>> list(SpecifierSet("").filter(["1.3", "1.5a1"], prereleases=True)) + ['1.3', '1.5a1'] + """ # Determine if we're forcing a prerelease or not, if we're not forcing # one for this particular filter call, then we'll use whatever the # SpecifierSet thinks for whether or not we should support prereleases. @@ -778,24 +968,13 @@ def filter( return iterable # If we do not have any specifiers, then we need to have a rough filter # which will filter out any pre-releases, unless there are no final - # releases, and which will filter out LegacyVersion in general. + # releases. else: - filtered: List[VersionTypeVar] = [] - found_prereleases: List[VersionTypeVar] = [] - - item: UnparsedVersion - parsed_version: Union[Version, LegacyVersion] + filtered: List[UnparsedVersion] = [] + found_prereleases: List[UnparsedVersion] = [] for item in iterable: - # Ensure that we some kind of Version class for this item. - if not isinstance(item, (LegacyVersion, Version)): - parsed_version = parse(item) - else: - parsed_version = item - - # Filter out any item which is parsed as a LegacyVersion - if isinstance(parsed_version, LegacyVersion): - continue + parsed_version = _coerce_version(item) # Store any item which is a pre-release for later unless we've # already found a final version or we are accepting prereleases diff --git a/packaging/tags.py b/packaging/tags.py index 744cf2cb3..5b6c5ffd0 100644 --- a/packaging/tags.py +++ b/packaging/tags.py @@ -4,6 +4,7 @@ import logging import platform +import subprocess import sys import sysconfig from importlib.machinery import EXTENSION_SUFFIXES @@ -356,6 +357,22 @@ def mac_platforms( version_str, _, cpu_arch = platform.mac_ver() if version is None: version = cast("MacVersion", tuple(map(int, version_str.split(".")[:2]))) + if version == (10, 16): + # When built against an older macOS SDK, Python will report macOS 10.16 + # instead of the real version. + version_str = subprocess.run( + [ + sys.executable, + "-sS", + "-c", + "import platform; print(platform.mac_ver()[0])", + ], + check=True, + env={"SYSTEM_VERSION_COMPAT": "0"}, + stdout=subprocess.PIPE, + universal_newlines=True, + ).stdout + version = cast("MacVersion", tuple(map(int, version_str.split(".")[:2]))) else: version = version if arch is None: diff --git a/packaging/version.py b/packaging/version.py index de9a09a4e..e5c738cfd 100644 --- a/packaging/version.py +++ b/packaging/version.py @@ -1,16 +1,20 @@ # This file is dual licensed under the terms of the Apache License, Version # 2.0, and the BSD License. See the LICENSE file in the root of this repository # for complete details. +""" +.. testsetup:: + + from packaging.version import parse, Version +""" import collections import itertools import re -import warnings -from typing import Callable, Iterator, List, Optional, SupportsInt, Tuple, Union +from typing import Callable, Optional, SupportsInt, Tuple, Union from ._structures import Infinity, InfinityType, NegativeInfinity, NegativeInfinityType -__all__ = ["parse", "Version", "LegacyVersion", "InvalidVersion", "VERSION_PATTERN"] +__all__ = ["VERSION_PATTERN", "parse", "Version", "InvalidVersion"] InfiniteTypes = Union[InfinityType, NegativeInfinityType] PrePostDevType = Union[InfiniteTypes, Tuple[str, int]] @@ -29,36 +33,37 @@ CmpKey = Tuple[ int, Tuple[int, ...], PrePostDevType, PrePostDevType, PrePostDevType, LocalType ] -LegacyCmpKey = Tuple[int, Tuple[str, ...]] -VersionComparisonMethod = Callable[ - [Union[CmpKey, LegacyCmpKey], Union[CmpKey, LegacyCmpKey]], bool -] +VersionComparisonMethod = Callable[[CmpKey, CmpKey], bool] _Version = collections.namedtuple( "_Version", ["epoch", "release", "dev", "pre", "post", "local"] ) -def parse(version: str) -> Union["LegacyVersion", "Version"]: - """ - Parse the given version string and return either a :class:`Version` object - or a :class:`LegacyVersion` object depending on if the given version is - a valid PEP 440 version or a legacy version. +def parse(version: str) -> "Version": + """Parse the given version string. + + >>> parse('1.0.dev1') + + + :param version: The version string to parse. + :raises InvalidVersion: When the version string is not a valid version. """ - try: - return Version(version) - except InvalidVersion: - return LegacyVersion(version) + return Version(version) class InvalidVersion(ValueError): - """ - An invalid version was found, users should refer to PEP 440. + """Raised when a version string is not a valid version. + + >>> Version("invalid") + Traceback (most recent call last): + ... + packaging.version.InvalidVersion: Invalid version: 'invalid' """ class _BaseVersion: - _key: Union[CmpKey, LegacyCmpKey] + _key: CmpKey def __hash__(self) -> int: return hash(self._key) @@ -103,126 +108,9 @@ def __ne__(self, other: object) -> bool: return self._key != other._key -class LegacyVersion(_BaseVersion): - def __init__(self, version: str) -> None: - self._version = str(version) - self._key = _legacy_cmpkey(self._version) - - warnings.warn( - "Creating a LegacyVersion has been deprecated and will be " - "removed in the next major release", - DeprecationWarning, - ) - - def __str__(self) -> str: - return self._version - - def __repr__(self) -> str: - return f"" - - @property - def public(self) -> str: - return self._version - - @property - def base_version(self) -> str: - return self._version - - @property - def epoch(self) -> int: - return -1 - - @property - def release(self) -> None: - return None - - @property - def pre(self) -> None: - return None - - @property - def post(self) -> None: - return None - - @property - def dev(self) -> None: - return None - - @property - def local(self) -> None: - return None - - @property - def is_prerelease(self) -> bool: - return False - - @property - def is_postrelease(self) -> bool: - return False - - @property - def is_devrelease(self) -> bool: - return False - - -_legacy_version_component_re = re.compile(r"(\d+ | [a-z]+ | \.| -)", re.VERBOSE) - -_legacy_version_replacement_map = { - "pre": "c", - "preview": "c", - "-": "final-", - "rc": "c", - "dev": "@", -} - - -def _parse_version_parts(s: str) -> Iterator[str]: - for part in _legacy_version_component_re.split(s): - part = _legacy_version_replacement_map.get(part, part) - - if not part or part == ".": - continue - - if part[:1] in "0123456789": - # pad for numeric comparison - yield part.zfill(8) - else: - yield "*" + part - - # ensure that alpha/beta/candidate are before final - yield "*final" - - -def _legacy_cmpkey(version: str) -> LegacyCmpKey: - - # We hardcode an epoch of -1 here. A PEP 440 version can only have a epoch - # greater than or equal to 0. This will effectively put the LegacyVersion, - # which uses the defacto standard originally implemented by setuptools, - # as before all PEP 440 versions. - epoch = -1 - - # This scheme is taken from pkg_resources.parse_version setuptools prior to - # it's adoption of the packaging library. - parts: List[str] = [] - for part in _parse_version_parts(version.lower()): - if part.startswith("*"): - # remove "-" before a prerelease tag - if part < "*final": - while parts and parts[-1] == "*final-": - parts.pop() - - # remove trailing zeros from each series of numeric parts - while parts and parts[-1] == "00000000": - parts.pop() - - parts.append(part) - - return epoch, tuple(parts) - - # Deliberately not anchored to the start and end of the string, to make it # easier for 3rd party code to reuse -VERSION_PATTERN = r""" +_VERSION_PATTERN = r""" v? (?: (?:(?P[0-9]+)!)? # epoch @@ -253,12 +141,55 @@ def _legacy_cmpkey(version: str) -> LegacyCmpKey: (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))? # local version """ +VERSION_PATTERN = _VERSION_PATTERN +""" +A string containing the regular expression used to match a valid version. + +The pattern is not anchored at either end, and is intended for embedding in larger +expressions (for example, matching a version number as part of a file name). The +regular expression should be compiled with the ``re.VERBOSE`` and ``re.IGNORECASE`` +flags set. + +:meta hide-value: +""" + class Version(_BaseVersion): + """This class abstracts handling of a project's versions. + + A :class:`Version` instance is comparison aware and can be compared and + sorted using the standard Python interfaces. + + >>> v1 = Version("1.0a5") + >>> v2 = Version("1.0") + >>> v1 + + >>> v2 + + >>> v1 < v2 + True + >>> v1 == v2 + False + >>> v1 > v2 + False + >>> v1 >= v2 + False + >>> v1 <= v2 + True + """ _regex = re.compile(r"^\s*" + VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE) def __init__(self, version: str) -> None: + """Initialize a Version object. + + :param version: + The string representation of a version which will be parsed and normalized + before use. + :raises InvalidVersion: + If the ``version`` does not conform to PEP 440 in any way then this + exception will be raised. + """ # Validate the version and parse it into pieces match = self._regex.search(version) @@ -288,9 +219,19 @@ def __init__(self, version: str) -> None: ) def __repr__(self) -> str: + """A representation of the Version that shows all internal state. + + >>> Version('1.0.0') + + """ return f"" def __str__(self) -> str: + """A string representation of the version that can be rounded-tripped. + + >>> str(Version("1.0a5")) + '1.0a5' + """ parts = [] # Epoch @@ -320,29 +261,80 @@ def __str__(self) -> str: @property def epoch(self) -> int: + """The epoch of the version. + + >>> Version("2.0.0").epoch + 0 + >>> Version("1!2.0.0").epoch + 1 + """ _epoch: int = self._version.epoch return _epoch @property def release(self) -> Tuple[int, ...]: + """The components of the "release" segment of the version. + + >>> Version("1.2.3").release + (1, 2, 3) + >>> Version("2.0.0").release + (2, 0, 0) + >>> Version("1!2.0.0.post0").release + (2, 0, 0) + + Includes trailing zeroes but not the epoch or any pre-release / development / + post-release suffixes. + """ _release: Tuple[int, ...] = self._version.release return _release @property def pre(self) -> Optional[Tuple[str, int]]: + """The pre-release segment of the version. + + >>> print(Version("1.2.3").pre) + None + >>> Version("1.2.3a1").pre + ('a', 1) + >>> Version("1.2.3b1").pre + ('b', 1) + >>> Version("1.2.3rc1").pre + ('rc', 1) + """ _pre: Optional[Tuple[str, int]] = self._version.pre return _pre @property def post(self) -> Optional[int]: + """The post-release number of the version. + + >>> print(Version("1.2.3").post) + None + >>> Version("1.2.3.post1").post + 1 + """ return self._version.post[1] if self._version.post else None @property def dev(self) -> Optional[int]: + """The development number of the version. + + >>> print(Version("1.2.3").dev) + None + >>> Version("1.2.3.dev1").dev + 1 + """ return self._version.dev[1] if self._version.dev else None @property def local(self) -> Optional[str]: + """The local version segment of the version. + + >>> print(Version("1.2.3").local) + None + >>> Version("1.2.3+abc").local + 'abc' + """ if self._version.local: return ".".join(str(x) for x in self._version.local) else: @@ -350,10 +342,31 @@ def local(self) -> Optional[str]: @property def public(self) -> str: + """The public portion of the version. + + >>> Version("1.2.3").public + '1.2.3' + >>> Version("1.2.3+abc").public + '1.2.3' + >>> Version("1.2.3+abc.dev1").public + '1.2.3' + """ return str(self).split("+", 1)[0] @property def base_version(self) -> str: + """The "base version" of the version. + + >>> Version("1.2.3").base_version + '1.2.3' + >>> Version("1.2.3+abc").base_version + '1.2.3' + >>> Version("1!1.2.3+abc.dev1").base_version + '1!1.2.3' + + The "base version" is the public version of the project without any pre or post + release markers. + """ parts = [] # Epoch @@ -367,26 +380,72 @@ def base_version(self) -> str: @property def is_prerelease(self) -> bool: + """Whether this version is a pre-release. + + >>> Version("1.2.3").is_prerelease + False + >>> Version("1.2.3a1").is_prerelease + True + >>> Version("1.2.3b1").is_prerelease + True + >>> Version("1.2.3rc1").is_prerelease + True + >>> Version("1.2.3dev1").is_prerelease + True + """ return self.dev is not None or self.pre is not None @property def is_postrelease(self) -> bool: + """Whether this version is a post-release. + + >>> Version("1.2.3").is_postrelease + False + >>> Version("1.2.3.post1").is_postrelease + True + """ return self.post is not None @property def is_devrelease(self) -> bool: + """Whether this version is a development release. + + >>> Version("1.2.3").is_devrelease + False + >>> Version("1.2.3.dev1").is_devrelease + True + """ return self.dev is not None @property def major(self) -> int: + """The first item of :attr:`release` or ``0`` if unavailable. + + >>> Version("1.2.3").major + 1 + """ return self.release[0] if len(self.release) >= 1 else 0 @property def minor(self) -> int: + """The second item of :attr:`release` or ``0`` if unavailable. + + >>> Version("1.2.3").minor + 2 + >>> Version("1").minor + 0 + """ return self.release[1] if len(self.release) >= 2 else 0 @property def micro(self) -> int: + """The third item of :attr:`release` or ``0`` if unavailable. + + >>> Version("1.2.3").micro + 3 + >>> Version("1").micro + 0 + """ return self.release[2] if len(self.release) >= 3 else 0 diff --git a/setup.py b/setup.py index 82ef248e4..d2cb3eead 100644 --- a/setup.py +++ b/setup.py @@ -48,7 +48,6 @@ author=about["__author__"], author_email=about["__email__"], python_requires=">=3.7", - install_requires=["pyparsing>=2.0.2,!=3.0.5"], # 2.0.2 + needed to avoid issue #91 classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", diff --git a/tests/test_markers.py b/tests/test_markers.py index b1ccf63c6..41bda762a 100644 --- a/tests/test_markers.py +++ b/tests/test_markers.py @@ -10,12 +10,11 @@ import pytest +from packaging._parser import Node from packaging.markers import ( InvalidMarker, Marker, - Node, UndefinedComparison, - UndefinedEnvironmentName, default_environment, format_full_version, ) @@ -62,11 +61,11 @@ class TestNode: def test_accepts_value(self, value): assert Node(value).value == value - @pytest.mark.parametrize("value", ["one", "two", None, 3, 5, []]) + @pytest.mark.parametrize("value", ["one", "two"]) def test_str(self, value): assert str(Node(value)) == str(value) - @pytest.mark.parametrize("value", ["one", "two", None, 3, 5, []]) + @pytest.mark.parametrize("value", ["one", "two"]) def test_repr(self, value): assert repr(Node(value)) == f"" @@ -166,6 +165,7 @@ def test_parses_valid(self, marker_string): "python_version", "(python_version)", "python_version >= 1.0 and (python_version)", + '(python_version == "2.7" and os_name == "linux"', ], ) def test_parses_invalid(self, marker_string): @@ -253,11 +253,8 @@ def test_compare_markers_to_other_objects(self): # Markers should not be comparable to other kinds of objects. assert Marker("os_name == 'nt'") != "os_name == 'nt'" - def test_extra_with_no_extra_in_environment(self): - # We can't evaluate an extra if no extra is passed into the environment - m = Marker("extra == 'security'") - with pytest.raises(UndefinedEnvironmentName): - m.evaluate() + def test_environment_assumes_empty_extra(self): + assert Marker('extra == "im_valid"').evaluate() is False @pytest.mark.parametrize( ("marker_string", "environment", "expected"), @@ -362,3 +359,12 @@ def test_evaluate_setuptools_legacy_markers(self): marker_string = "python_implementation=='Jython'" args = [{"platform_python_implementation": "CPython"}] assert Marker(marker_string).evaluate(*args) is False + + def test_extra_str_normalization(self): + raw_name = "S_P__A_M" + normalized_name = "s-p-a-m" + lhs = f"{raw_name!r} == extra" + rhs = f"extra == {raw_name!r}" + + assert str(Marker(lhs)) == f'"{normalized_name}" == extra' + assert str(Marker(rhs)) == f'extra == "{normalized_name}"' diff --git a/tests/test_metadata.py b/tests/test_metadata.py new file mode 100644 index 000000000..fda418ce5 --- /dev/null +++ b/tests/test_metadata.py @@ -0,0 +1,43 @@ +import pytest + +from packaging import metadata, utils, version + + +class TestInit: + def test_defaults(self): + specified_attributes = {"display_name", "canonical_name", "version"} + metadata_ = metadata.Metadata("packaging", version.Version("2023.0.0")) + for attr in dir(metadata_): + if attr in specified_attributes or attr.startswith("_"): + continue + assert not getattr(metadata_, attr) + + +class TestNameNormalization: + + version = version.Version("1.0.0") + display_name = "A--B" + canonical_name = utils.canonicalize_name(display_name) + + def test_via_init(self): + metadata_ = metadata.Metadata(self.display_name, self.version) + + assert metadata_.display_name == self.display_name + assert metadata_.canonical_name == self.canonical_name + + def test_via_display_name_setter(self): + metadata_ = metadata.Metadata("a", self.version) + + assert metadata_.display_name == "a" + assert metadata_.canonical_name == "a" + + metadata_.display_name = self.display_name + + assert metadata_.display_name == self.display_name + assert metadata_.canonical_name == self.canonical_name + + def test_no_canonical_name_setter(self): + metadata_ = metadata.Metadata("a", self.version) + + with pytest.raises(AttributeError): + metadata_.canonical_name = "b" # type: ignore diff --git a/tests/test_musllinux.py b/tests/test_musllinux.py index d2c87ca15..2623bdbc1 100644 --- a/tests/test_musllinux.py +++ b/tests/test_musllinux.py @@ -101,14 +101,15 @@ def test_parse_ld_musl_from_elf_no_interpreter_section(): with BIN_MUSL_X86_64.open("rb") as f: data = f.read() - # Change all sections to *not* PT_INTERP. - unpacked = struct.unpack("16BHHIQQQIHHH", data[:58]) + # Change all sections to *not* PT_INTERP. We are explicitly using LSB rules + # because the binaries are in LSB. + unpacked = struct.unpack("<16BHHIQQQIHHH", data[:58]) *_, e_phoff, _, _, _, e_phentsize, e_phnum = unpacked for i in range(e_phnum + 1): sb = e_phoff + e_phentsize * i se = sb + 56 - section = struct.unpack("IIQQQQQQ", data[sb:se]) - data = data[:sb] + struct.pack("IIQQQQQQ", 0, *section[1:]) + data[se:] + section = struct.unpack("=1.x.y;python_version=='2.6'") - self._assert_requirement( - req, "name", specifier=">=1.x.y", marker='python_version == "2.6"' - ) + with pytest.raises(InvalidRequirement) as e: + Requirement("name>=1.x.y;python_version=='2.6'") + assert "Expected semicolon (followed by markers) or end of string" in str(e) + + def test_missing_name(self): + with pytest.raises(InvalidRequirement) as e: + Requirement("@ http://example.com") + assert "Expression must begin with package name" in str(e) + + def test_name_with_missing_version(self): + with pytest.raises(InvalidRequirement) as e: + Requirement("name>=") + assert "Missing version" in str(e) def test_version_with_parens_and_whitespace(self): req = Requirement("name (==4)") self._assert_requirement(req, "name", specifier="==4") + def test_version_with_missing_closing_paren(self): + with pytest.raises(InvalidRequirement) as e: + Requirement("name(==4") + assert "Closing right parenthesis is missing" in str(e) + def test_name_with_multiple_versions(self): req = Requirement("name>=3,<2") self._assert_requirement(req, "name", specifier="<2,>=3") @@ -81,6 +96,22 @@ def test_name_with_multiple_versions_and_whitespace(self): req = Requirement("name >=2, <3") self._assert_requirement(req, "name", specifier="<3,>=2") + def test_name_with_multiple_versions_in_parenthesis(self): + req = Requirement("name (>=2,<3)") + self._assert_requirement(req, "name", specifier="<3,>=2") + + def test_name_with_no_extras_no_versions_in_parenthesis(self): + req = Requirement("name []()") + self._assert_requirement(req, "name", specifier="", extras=[]) + + def test_name_with_extra_and_multiple_versions_in_parenthesis(self): + req = Requirement("name [foo, bar](>=2,<3)") + self._assert_requirement(req, "name", specifier="<3,>=2", extras=["foo", "bar"]) + + def test_name_with_no_versions_in_parenthesis(self): + req = Requirement("name ()") + self._assert_requirement(req, "name", specifier="") + def test_extras(self): req = Requirement("foobar [quux,bar]") self._assert_requirement(req, "foobar", extras=["bar", "quux"]) @@ -89,16 +120,27 @@ def test_empty_extras(self): req = Requirement("foo[]") self._assert_requirement(req, "foo") + def test_unclosed_extras(self): + with pytest.raises(InvalidRequirement) as e: + Requirement("foo[") + assert "Closing square bracket is missing" in str(e) + + def test_extras_without_comma(self): + with pytest.raises(InvalidRequirement) as e: + Requirement("foobar[quux bar]") + assert "Missing comma after extra" in str(e) + def test_url(self): - url_section = "@ http://example.com" - parsed = URL.parseString(url_section) - assert parsed.url == "http://example.com" + url_section = "test @ http://example.com" + req = Requirement(url_section) + self._assert_requirement(req, "test", "http://example.com", extras=[]) def test_url_and_marker(self): - instring = "@ http://example.com ; os_name=='a'" - parsed = URL_AND_MARKER.parseString(instring) - assert parsed.url == "http://example.com" - assert str(parsed.marker) == 'os_name == "a"' + instring = "test @ http://example.com ; os_name=='a'" + req = Requirement(instring) + self._assert_requirement( + req, "test", "http://example.com", extras=[], marker='os_name == "a"' + ) def test_invalid_url(self): with pytest.raises(InvalidRequirement) as e: @@ -149,6 +191,11 @@ def test_invalid_marker(self): with pytest.raises(InvalidRequirement): Requirement("name; foobar=='x'") + def test_marker_with_missing_semicolon(self): + with pytest.raises(InvalidRequirement) as e: + Requirement('name[bar]>=3 python_version == "2.7"') + assert "Expected semicolon (followed by markers) or end of string" in str(e) + def test_types(self): req = Requirement("foobar[quux]<2,>=3; os_name=='a'") assert isinstance(req.name, str) @@ -192,9 +239,7 @@ def test_sys_platform_linux_in(self): def test_parseexception_error_msg(self): with pytest.raises(InvalidRequirement) as e: Requirement("toto 42") - assert "Expected stringEnd" in str(e.value) or ( - "Expected string_end" in str(e.value) # pyparsing>=3.0.0 - ) + assert "Expected semicolon (followed by markers) or end of string" in str(e) EQUAL_DEPENDENCIES = [ ("packaging>20.1", "packaging>20.1"), diff --git a/tests/test_specifiers.py b/tests/test_specifiers.py index 5949ebf61..92d04eb0e 100644 --- a/tests/test_specifiers.py +++ b/tests/test_specifiers.py @@ -4,19 +4,13 @@ import itertools import operator -import warnings import pytest -from packaging.specifiers import ( - InvalidSpecifier, - LegacySpecifier, - Specifier, - SpecifierSet, -) -from packaging.version import LegacyVersion, Version, parse +from packaging.specifiers import InvalidSpecifier, Specifier, SpecifierSet +from packaging.version import Version, parse -from .test_version import LEGACY_VERSIONS, VERSIONS +from .test_version import VERSIONS LEGACY_SPECIFIERS = [ "==2.1.0.3", @@ -37,7 +31,6 @@ ">=7.9a1", "<1.0.dev1", ">2.0.post1", - "===lolwat", ] @@ -489,12 +482,10 @@ def test_specifiers(self, version, spec, expected): @pytest.mark.parametrize( ("version", "spec", "expected"), [ + ("1.0.0", "===1.0", False), + ("1.0.dev0", "===1.0", False), # 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), ("1.0.dev0", "===1.0.dev0", True), ], ) @@ -567,10 +558,6 @@ def test_specifier_filter(self, specifier, prereleases, input, expected): assert list(spec.filter(input, **kwargs)) == expected - @pytest.mark.xfail - def test_specifier_explicit_legacy(self): - assert Specifier("==1.0").contains(LegacyVersion("1.0")) - @pytest.mark.parametrize( ("spec", "op"), [ @@ -583,6 +570,7 @@ def test_specifier_explicit_legacy(self): (">=7.9a1", ">="), ("<1.0.dev1", "<"), (">2.0.post1", ">"), + # === is an escape hatch in PEP 440 ("===lolwat", "==="), ], ) @@ -601,6 +589,7 @@ def test_specifier_operator_property(self, spec, op): (">=7.9a1", "7.9a1"), ("<1.0.dev1", "1.0.dev1"), (">2.0.post1", "2.0.post1"), + # === is an escape hatch in PEP 440 ("===lolwat", "lolwat"), ], ) @@ -637,141 +626,8 @@ def test_specifier_hash_for_compatible_operator(self): assert hash(Specifier("~=1.18.0")) != hash(Specifier("~=1.18")) -class TestLegacySpecifier: - def test_legacy_specifier_is_deprecated(self): - with warnings.catch_warnings(record=True) as w: - LegacySpecifier(">=some-legacy-version") - assert len(w) == 1 - assert issubclass(w[0].category, DeprecationWarning) - - @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"), - # 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"), - # 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"), - ] - ] - + [ - (v, s, False) - for v, s in [ - # Test the equality operation - ("2.1", "==2"), - ("2.1", "==2.0"), - ("2.1", "==2.0.0"), - # Test the in-equality operation - ("2.0", "!=2"), - ("2.0", "!=2.0"), - ("2.0", "!=2.0.0"), - # 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"), - # Test the less than operation - ("3", "<2"), - ] - ], - ) - def test_specifiers(self, version, spec, expected): - spec = LegacySpecifier(spec, prereleases=True) - - if expected: - # Test that the plain string form works - assert version in spec - assert spec.contains(version) - - # Test that the version instance form works - assert LegacyVersion(version) in spec - assert spec.contains(LegacyVersion(version)) - else: - # Test that the plain string form works - assert version not in spec - assert not spec.contains(version) - - # Test that the version instance form works - assert LegacyVersion(version) not in spec - assert not spec.contains(LegacyVersion(version)) - - def test_specifier_explicit_prereleases(self): - spec = LegacySpecifier(">=1.0") - assert not spec.prereleases - spec.prereleases = True - assert spec.prereleases - - spec = LegacySpecifier(">=1.0", prereleases=False) - assert not spec.prereleases - spec.prereleases = True - assert spec.prereleases - - spec = LegacySpecifier(">=1.0", prereleases=True) - assert spec.prereleases - spec.prereleases = False - assert not spec.prereleases - - spec = LegacySpecifier(">=1.0", prereleases=True) - assert spec.prereleases - spec.prereleases = None - assert not spec.prereleases - - class TestSpecifierSet: - @pytest.mark.parametrize("version", VERSIONS + LEGACY_VERSIONS) + @pytest.mark.parametrize("version", VERSIONS) def test_empty_specifier(self, version): spec = SpecifierSet(prereleases=True) @@ -836,7 +692,6 @@ def test_specifier_contains_installed_prereleases(self): (">=1.0.dev1", None, None, ["1.0", "2.0a1"], ["1.0", "2.0a1"]), ("", None, None, ["1.0a1"], ["1.0a1"]), ("", None, None, ["1.0", Version("2.0")], ["1.0", Version("2.0")]), - ("", None, None, ["2.0dog", "1.0"], ["1.0"]), # Test overriding with the prereleases parameter on filter ("", None, False, ["1.0a1"], []), (">=1.0.dev1", None, False, ["1.0", "2.0a1"], ["1.0"]), @@ -862,10 +717,6 @@ def test_specifier_filter( assert list(spec.filter(input, **kwargs)) == expected - def test_legacy_specifiers_combined(self): - spec = SpecifierSet("<3,>1-1-1") - assert "2.0" in spec - @pytest.mark.parametrize( ("specifier", "expected"), [ diff --git a/tests/test_tags.py b/tests/test_tags.py index 446dee4ef..06cd3b4a2 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -4,6 +4,7 @@ import collections.abc +import subprocess try: import ctypes @@ -230,12 +231,48 @@ def test_version_detection(self, monkeypatch): version = platform.mac_ver()[0].split(".") major = version[0] minor = version[1] if major == "10" else "0" - expected = f"macosx_{major}_{minor}" + + platforms = list(tags.mac_platforms(arch="x86_64")) + if (major, minor) == ("10", "16"): + print(platforms, "macosx_11+") + # For 10.16, the real version is at least 11.0. + prefix, major, minor, _ = platforms[0].split("_", maxsplit=3) + assert prefix == "macosx" + assert int(major) >= 11 + assert minor == "0" + else: + expected = f"macosx_{major}_{minor}_" + print(platforms, expected) + assert platforms[0].startswith(expected) + + def test_version_detection_10_15(self, monkeypatch): + monkeypatch.setattr( + platform, "mac_ver", lambda: ("10.15", ("", "", ""), "x86_64") + ) + expected = "macosx_10_15_" platforms = list(tags.mac_platforms(arch="x86_64")) print(platforms, expected) assert platforms[0].startswith(expected) + def test_version_detection_compatibility(self, monkeypatch): + if platform.system() != "Darwin": + monkeypatch.setattr( + subprocess, + "run", + lambda *args, **kwargs: subprocess.CompletedProcess( + [], 0, stdout="10.15" + ), + ) + monkeypatch.setattr( + platform, "mac_ver", lambda: ("10.16", ("", "", ""), "x86_64") + ) + unexpected = "macosx_10_16_" + + platforms = list(tags.mac_platforms(arch="x86_64")) + print(platforms, unexpected) + assert not platforms[0].startswith(unexpected) + @pytest.mark.parametrize("arch", ["x86_64", "i386"]) def test_arch_detection(self, arch, monkeypatch): if platform.system() != "Darwin" or platform.mac_ver()[2] != arch: diff --git a/tests/test_utils.py b/tests/test_utils.py index 84a8b38b0..a6c6711d1 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -49,7 +49,9 @@ def test_canonicalize_name(name, expected): ("1.0a0", "1a0"), ("1.0rc0", "1rc0"), ("100!0.0", "100!0"), - ("1.0.1-test7", "1.0.1-test7"), # LegacyVersion is unchanged + # improper version strings are unchanged + ("lolwat", "lolwat"), + ("1.0.1-test7", "1.0.1-test7"), ], ) def test_canonicalize_version(version, expected): diff --git a/tests/test_version.py b/tests/test_version.py index 5f2251e11..8004c0ccf 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -4,19 +4,20 @@ import itertools import operator -import warnings import pretend import pytest -from packaging.version import InvalidVersion, LegacyVersion, Version, parse +from packaging.version import InvalidVersion, Version, parse -@pytest.mark.parametrize( - ("version", "klass"), [("1.0", Version), ("1-1-1", LegacyVersion)] -) -def test_parse(version, klass): - assert isinstance(parse(version), klass) +def test_parse(): + assert isinstance(parse("1.0"), Version) + + +def test_parse_raises(): + with pytest.raises(InvalidVersion): + parse("lolwat") # This list must be in the correct sorting order @@ -759,10 +760,6 @@ def test_compare_other(self, op, expected): assert getattr(operator, op)(Version("1"), other) is expected - def test_compare_legacyversion_version(self): - result = sorted([Version("0"), LegacyVersion("1")]) - assert result == [LegacyVersion("1"), Version("0")] - def test_major_version(self): assert Version("2.1.0").major == 2 @@ -774,131 +771,3 @@ def test_micro_version(self): assert Version("2.1.3").micro == 3 assert Version("2.1").micro == 0 assert Version("2").micro == 0 - - -LEGACY_VERSIONS = ["foobar", "a cat is fine too", "lolwut", "1-0", "2.0-a1"] - - -class TestLegacyVersion: - def test_legacy_version_is_deprecated(self): - with warnings.catch_warnings(record=True) as w: - LegacyVersion("some-legacy-version") - assert len(w) == 1 - assert issubclass(w[0].category, DeprecationWarning) - - @pytest.mark.parametrize("version", VERSIONS + LEGACY_VERSIONS) - def test_valid_legacy_versions(self, version): - LegacyVersion(version) - - @pytest.mark.parametrize("version", VERSIONS + LEGACY_VERSIONS) - def test_legacy_version_str_repr(self, version): - assert str(LegacyVersion(version)) == version - assert repr(LegacyVersion(version)) == "".format( - repr(version) - ) - - @pytest.mark.parametrize("version", VERSIONS + LEGACY_VERSIONS) - def test_legacy_version_hash(self, version): - assert hash(LegacyVersion(version)) == hash(LegacyVersion(version)) - - @pytest.mark.parametrize("version", VERSIONS + LEGACY_VERSIONS) - def test_legacy_version_public(self, version): - assert LegacyVersion(version).public == version - - @pytest.mark.parametrize("version", VERSIONS + LEGACY_VERSIONS) - def test_legacy_version_base_version(self, version): - assert LegacyVersion(version).base_version == version - - @pytest.mark.parametrize("version", VERSIONS + LEGACY_VERSIONS) - def test_legacy_version_epoch(self, version): - assert LegacyVersion(version).epoch == -1 - - @pytest.mark.parametrize("version", VERSIONS + LEGACY_VERSIONS) - def test_legacy_version_release(self, version): - assert LegacyVersion(version).release is None - - @pytest.mark.parametrize("version", VERSIONS + LEGACY_VERSIONS) - def test_legacy_version_local(self, version): - assert LegacyVersion(version).local is None - - @pytest.mark.parametrize("version", VERSIONS + LEGACY_VERSIONS) - def test_legacy_version_pre(self, version): - assert LegacyVersion(version).pre is None - - @pytest.mark.parametrize("version", VERSIONS + LEGACY_VERSIONS) - def test_legacy_version_is_prerelease(self, version): - assert not LegacyVersion(version).is_prerelease - - @pytest.mark.parametrize("version", VERSIONS + LEGACY_VERSIONS) - def test_legacy_version_dev(self, version): - assert LegacyVersion(version).dev is None - - @pytest.mark.parametrize("version", VERSIONS + LEGACY_VERSIONS) - def test_legacy_version_is_devrelease(self, version): - assert not LegacyVersion(version).is_devrelease - - @pytest.mark.parametrize("version", VERSIONS + LEGACY_VERSIONS) - def test_legacy_version_post(self, version): - assert LegacyVersion(version).post is None - - @pytest.mark.parametrize("version", VERSIONS + LEGACY_VERSIONS) - def test_legacy_version_is_postrelease(self, version): - assert not LegacyVersion(version).is_postrelease - - @pytest.mark.parametrize( - ("left", "right", "op"), - # Below we'll generate every possible combination of - # VERSIONS + LEGACY_VERSIONS that should be True for the given operator - itertools.chain( - * - # Verify that the equal (==) operator works correctly - [[(x, x, operator.eq) for x in VERSIONS + LEGACY_VERSIONS]] - + - # Verify that the not equal (!=) operator works correctly - [ - [ - (x, y, operator.ne) - for j, y in enumerate(VERSIONS + LEGACY_VERSIONS) - if i != j - ] - for i, x in enumerate(VERSIONS + LEGACY_VERSIONS) - ] - ), - ) - def test_comparison_true(self, left, right, op): - assert op(LegacyVersion(left), LegacyVersion(right)) - - @pytest.mark.parametrize( - ("left", "right", "op"), - # Below we'll generate every possible combination of - # VERSIONS + LEGACY_VERSIONS that should be False for the given - # operator - itertools.chain( - * - # Verify that the equal (==) operator works correctly - [ - [ - (x, y, operator.eq) - for j, y in enumerate(VERSIONS + LEGACY_VERSIONS) - if i != j - ] - for i, x in enumerate(VERSIONS + LEGACY_VERSIONS) - ] - + - # Verify that the not equal (!=) operator works correctly - [[(x, x, operator.ne) for x in VERSIONS + LEGACY_VERSIONS]] - ), - ) - def test_comparison_false(self, left, right, op): - assert not op(LegacyVersion(left), LegacyVersion(right)) - - @pytest.mark.parametrize("op", ["lt", "le", "eq", "ge", "gt", "ne"]) - def test_dunder_op_returns_notimplemented(self, op): - method = getattr(LegacyVersion, f"__{op}__") - assert method(LegacyVersion("1"), 1) is NotImplemented - - @pytest.mark.parametrize(("op", "expected"), [("eq", False), ("ne", True)]) - def test_compare_other(self, op, expected): - other = pretend.stub(**{f"__{op}__": lambda other: NotImplemented}) - - assert getattr(operator, op)(LegacyVersion("1"), other) is expected