From 98703d42fd6fd981d2e40b2c7b99921214277d2b Mon Sep 17 00:00:00 2001 From: Pradyun Gedam Date: Sat, 9 Jul 2022 11:40:28 +0100 Subject: [PATCH 1/5] Rework conf.py to match newer Sphinx style This is the style of organisation within the conf.py file that is used in newer Sphinx versions. --- docs/conf.py | 109 ++++++++++++++++----------------------------------- 1 file changed, 33 insertions(+), 76 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 70a93378..c2fffd40 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", "#%s"), - "pull": ("https://github.com/pypa/packaging/pull/%s", "PR #%s"), -} # -- 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 = {} +# -- Options for extlinks ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/extensions/extlinks.html#configuration -# 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 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), +} From cce2e7b9b6e899311a1f5b99d951f843b5452aff Mon Sep 17 00:00:00 2001 From: Pradyun Gedam Date: Sat, 9 Jul 2022 11:40:49 +0100 Subject: [PATCH 2/5] Remove an unused `_static` directory If we need it, we can add it in later. --- docs/_static/.empty | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 docs/_static/.empty diff --git a/docs/_static/.empty b/docs/_static/.empty deleted file mode 100644 index e69de29b..00000000 From 790759cc2ea899b02b2fc7c0b397d71306ad48e8 Mon Sep 17 00:00:00 2001 From: Pradyun Gedam Date: Sat, 9 Jul 2022 11:46:32 +0100 Subject: [PATCH 3/5] Rework the `metadata` module annotations and documentation The migration from modern annotations (thanks to a `__future__` import) to the plain typing-import based annotations is to deal with limitations of intersphinx with autodoc, which currently can't linkify annotations with future style imports. Similarly, to make the type annotations in the generated documentation correctly link to the various "internal" types, they need to be imported and referenced by their regular name instead of a changed-name import. In the spirit of consistency, all imports were changed to match this style. This allows for eliminating duplication of content in the implementation and documentation, reducing the general maintainance overhead of this module. --- docs/metadata.rst | 82 ++------------------ packaging/metadata.py | 171 ++++++++++++++++++++++++++---------------- 2 files changed, 111 insertions(+), 142 deletions(-) diff --git a/docs/metadata.rst b/docs/metadata.rst index 890750e6..972b065c 100644 --- a/docs/metadata.rst +++ b/docs/metadata.rst @@ -1,86 +1,14 @@ Metadata ========== -.. currentmodule:: packaging.metadata - A data representation for `core metadata`_. +.. _`core metadata`: https://packaging.python.org/en/latest/specifications/core-metadata/ + Reference --------- -.. class:: DynamicField - - 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``. - - -.. class:: Metadata(name, version, *, platforms=None, summary=None, description=None, keywords=None, home_page=None, author=None, author_emails=None, license=None, supported_platforms=None, download_url=None, classifiers=None, maintainer=None, maintainer_emails=None, requires_dists=None, requires_python=None, requires_externals=None, project_urls=None, provides_dists= None, obsoletes_dists= None, description_content_type=None, provides_extras=None, dynamic_fields=None) - - 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. - - :param str name: ``Name``. - :param packaging.version.Version version: ``Version`` (note - that this is different than ``Metadata-Version``). - :param Iterable[str] platforms: ``Platform``. - :param str summary: ``Summary``. - :param str description: ``Description``. - :param Iterable[str] keywords: ``Keywords``. - :param str home_page: ``Home-Page``. - :param str author: ``Author``. - :param Iterable[tuple[str | None, str]] author_emails: ``Author-Email`` - where the two-item tuple represents the name and email of the author, - respectively. - :param str license: ``License``. - :param Iterable[str] supported_platforms: ``Supported-Platform``. - :param str download_url: ``Download-URL``. - :param Iterable[str] classifiers: ``Classifier``. - :param str maintainer: ``Maintainer``. - :param Iterable[tuple[str | None, str]] maintainer_emails: ``Maintainer-Email``, - where the two-item tuple represents the name and email of the maintainer, - respectively. - :param Iterable[packaging.requirements.Requirement] requires_dists: ``Requires-Dist``. - :param packaging.specifiers.SpecifierSet requires_python: ``Requires-Python``. - :param Iterable[str] requires_externals: ``Requires-External``. - :param tuple[str, str] project_urls: ``Project-URL``. - :param Iterable[str] provides_dists: ``Provides-Dist``. - :param Iterable[str] obsoletes_dists: ``Obsoletes-Dist``. - :param str description_content_type: ``Description-Content-Type``. - :param Iterable[packaging.utils.NormalizedName] provides_extras: ``Provides-Extra``. - :param Iterable[DynamicField] dynamic_fields: ``Dynamic``. - - Attributes not directly corresponding to a parameter are: - - .. attribute:: display_name - - 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`. - - .. attribute:: canonical_name - - 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`. - - -.. _`core metadata`: https://packaging.python.org/en/latest/specifications/core-metadata/ -.. _`project metadata`: https://packaging.python.org/en/latest/specifications/declaring-project-metadata/ -.. _`source distribution`: https://packaging.python.org/en/latest/specifications/source-distribution-format/ -.. _`binary distrubtion`: https://packaging.python.org/en/latest/specifications/binary-distribution-format/ +.. automodule:: packaging.metadata + :members: + :undoc-members: diff --git a/packaging/metadata.py b/packaging/metadata.py index 81405feb..4bc9c595 100644 --- a/packaging/metadata.py +++ b/packaging/metadata.py @@ -1,15 +1,10 @@ -from __future__ import annotations - import enum -from collections.abc import Iterable -from typing import Optional, Tuple +from typing import Iterable, List, Optional, Tuple -from . import ( # Alt name avoids shadowing. - requirements, - specifiers, - utils, - version as packaging_version, -) +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] @@ -18,11 +13,13 @@ @enum.unique class DynamicField(enum.Enum): - """ - Field names for the `dynamic` field. + An :class:`enum.Enum` representing fields which can be listed in the ``Dynamic`` + field of `core metadata`_. - All values are lower-cased for easy comparison. + 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`. @@ -54,77 +51,112 @@ class DynamicField(enum.Enum): class Metadata: + """A class representing the `Core Metadata`_ for a project. - """ - A representation of core metadata. + 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: utils.NormalizedName - version: packaging_version.Version - platforms: list[str] + _canonical_name: NormalizedName + version: Version + platforms: List[str] summary: str description: str - keywords: list[str] + keywords: List[str] home_page: str author: str - author_emails: list[_NameAndEmail] + author_emails: List[_NameAndEmail] license: str - supported_platforms: list[str] + supported_platforms: List[str] download_url: str - classifiers: list[str] + classifiers: List[str] maintainer: str - maintainer_emails: list[_NameAndEmail] - requires_dists: list[requirements.Requirement] - requires_python: specifiers.SpecifierSet - requires_externals: list[str] - project_urls: list[_LabelAndURL] - provides_dists: list[str] - obsoletes_dists: list[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[utils.NormalizedName] - dynamic_fields: list[DynamicField] + provides_extras: List[NormalizedName] + dynamic_fields: List[DynamicField] def __init__( self, name: str, - version: packaging_version.Version, + version: Version, *, # 1.0 - platforms: Iterable[str] | None = None, - summary: str | None = None, - description: str | None = None, - keywords: Iterable[str] | None = None, - home_page: str | None = None, - author: str | None = None, - author_emails: Iterable[_NameAndEmail] | None = None, - license: str | None = None, + 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: Iterable[str] | None = None, - download_url: str | None = None, - classifiers: Iterable[str] | None = None, + supported_platforms: Optional[Iterable[str]] = None, + download_url: Optional[str] = None, + classifiers: Optional[Iterable[str]] = None, # 1.2 - maintainer: str | None = None, - maintainer_emails: Iterable[_NameAndEmail] | None = None, - requires_dists: Iterable[requirements.Requirement] | None = None, - requires_python: specifiers.SpecifierSet | None = None, - requires_externals: Iterable[str] | None = None, - project_urls: Iterable[_LabelAndURL] | None = None, - provides_dists: Iterable[str] | None = None, - obsoletes_dists: Iterable[str] | None = None, + 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: str | None = None, - provides_extras: Iterable[utils.NormalizedName] | None = None, + description_content_type: Optional[str] = None, + provides_extras: Optional[Iterable[NormalizedName]] = None, # 2.2 - dynamic_fields: Iterable[DynamicField] | None = None, + dynamic_fields: Optional[Iterable[DynamicField]] = None, ) -> None: - """ - Set all attributes on the instance. - - An argument of `None` will be converted to an appropriate, false-y value - (e.g. the empty string). + """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 @@ -142,7 +174,7 @@ def __init__( self.maintainer = maintainer or "" self.maintainer_emails = list(maintainer_emails or []) self.requires_dists = list(requires_dists or []) - self.requires_python = requires_python or specifiers.SpecifierSet() + 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 []) @@ -153,18 +185,27 @@ def __init__( @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: - """ - Set the value for self.display_name and self.canonical_name. - """ self._display_name = value - self._canonical_name = utils.canonicalize_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) -> utils.NormalizedName: + 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 From fe5a6f114be6691a1a93db4b59c9b2ef838f3b31 Mon Sep 17 00:00:00 2001 From: Pradyun Gedam Date: Sat, 9 Jul 2022 11:48:16 +0100 Subject: [PATCH 4/5] Rework the specifiers documentation Moving the content into inline docstrings makes it easier to ensure that they are updated/evolve with the implementation. This also expands the behaviors shown, by documenting the relevant details for each function. These examples are doctest'd in CI. --- docs/specifiers.rst | 137 +------------ packaging/specifiers.py | 437 +++++++++++++++++++++++++++++++++------- 2 files changed, 375 insertions(+), 199 deletions(-) diff --git a/docs/specifiers.rst b/docs/specifiers.rst index 253c5107..87df4497 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 ----- @@ -48,127 +50,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`). 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` 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` instances - in this specifier set. - - .. method:: filter(iterable, prereleases=None) - - Takes an iterable that can contain version strings, :class:`~.Version`, - instances and will then filter them, 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()`. - - -.. 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/packaging/specifiers.py b/packaging/specifiers.py index dab49eef..840e0878 100644 --- a/packaging/specifiers.py +++ b/packaging/specifiers.py @@ -1,6 +1,12 @@ # 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 itertools @@ -22,7 +28,13 @@ def _coerce_version(version: UnparsedVersion) -> 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' """ @@ -30,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 @@ -79,6 +94,14 @@ def filter( class Specifier(BaseSpecifier): + """This class abstracts handling of version specifiers. + + .. tip:: + + 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""" (?P(~=|==|!=|<=|>=|<|>|===)) @@ -187,6 +210,18 @@ class Specifier(BaseSpecifier): } 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}'") @@ -199,7 +234,62 @@ def __init__(self, spec: str = "", prereleases: Optional[bool] = None) -> None: # 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 @@ -209,6 +299,13 @@ def __repr__(self) -> str: 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 @@ -223,6 +320,24 @@ 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)) @@ -375,20 +490,53 @@ def _compare_greater_than(self, prospective: Version, spec_str: str) -> bool: def _compare_arbitrary(self, prospective: Version, spec: str) -> bool: return str(prospective).lower() == str(spec).lower() - @property - def operator(self) -> str: - return self._spec[0] + def __contains__(self, item: Union[str, Version]) -> bool: + """Return whether or not the item is contained in this specifier. - @property - def version(self) -> str: - return self._spec[1] + :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. - def __contains__(self, item: str) -> bool: + >>> "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) 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 + """ # Determine if prereleases are to be allowed or not. if prereleases is None: @@ -412,6 +560,32 @@ def contains( 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 = [] @@ -444,35 +618,6 @@ def filter( for version in found_prereleases: yield version - @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 - _prefix_regex = re.compile(r"^([0-9]+)((?:a|b|c|rc)[0-9]+)$") @@ -513,11 +658,31 @@ 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()] @@ -534,7 +699,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 @@ -544,12 +742,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): @@ -573,6 +790,24 @@ def __and__(self, other: Union["SpecifierSet", str]) -> "SpecifierSet": return specifier def __eq__(self, other: object) -> bool: + """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): @@ -581,34 +816,35 @@ 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[Specifier]: + """ + Returns an iterator over all the underlying :class:`Specifier` instances + in this specifier set. + """ 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) - - @prereleases.setter - def prereleases(self, value: bool) -> None: - self._prereleases = value - 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( @@ -617,7 +853,29 @@ def contains( prereleases: Optional[bool] = None, installed: Optional[bool] = None, ) -> bool: - + """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) @@ -649,7 +907,44 @@ def contains( def filter( 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. From 1707d41181624ded63c5f323aadcd80cf41b51ef Mon Sep 17 00:00:00 2001 From: Pradyun Gedam Date: Sat, 9 Jul 2022 11:48:36 +0100 Subject: [PATCH 5/5] Rework the version documentation Moving the content into inline docstrings makes it easier to ensure that they are updated/evolve with the implementation. This also expands the behaviors shown, by documenting the relevant details for each function. These examples are doctest'd in CI. --- docs/version.rst | 122 ++------------------------- packaging/version.py | 197 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 198 insertions(+), 121 deletions(-) diff --git a/docs/version.rst b/docs/version.rst index 73a2a01a..2adf336e 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,112 +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, raises :class:`InvalidVersion`. - - -.. 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. - - -.. 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/version.py b/packaging/version.py index 9a23b8e2..e5c738cf 100644 --- a/packaging/version.py +++ b/packaging/version.py @@ -1,6 +1,11 @@ # 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 @@ -9,7 +14,7 @@ from ._structures import Infinity, InfinityType, NegativeInfinity, NegativeInfinityType -__all__ = ["parse", "Version", "InvalidVersion", "VERSION_PATTERN"] +__all__ = ["VERSION_PATTERN", "parse", "Version", "InvalidVersion"] InfiniteTypes = Union[InfinityType, NegativeInfinityType] PrePostDevType = Union[InfiniteTypes, Tuple[str, int]] @@ -36,19 +41,24 @@ def parse(version: str) -> "Version": - """ - Parse the given version string. + """Parse the given version string. - Returns a :class:`Version` object, if the given version is a valid PEP 440 version. + >>> parse('1.0.dev1') + - Raises :class:`InvalidVersion` otherwise. + :param version: The version string to parse. + :raises InvalidVersion: When the version string is not a valid 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' """ @@ -100,7 +110,7 @@ def __ne__(self, other: object) -> bool: # 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 @@ -131,12 +141,55 @@ def __ne__(self, other: object) -> bool: (?:\+(?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) @@ -166,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 @@ -198,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: @@ -228,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 @@ -245,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