├── .flake8 ├── .github ├── dependabot.yml └── workflows │ ├── main.yml │ ├── pre-commit.yml │ └── python-publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── api.rst ├── changelog.rst ├── conf.py ├── index.rst ├── installation.rst ├── license.rst └── usage.rst ├── pyproject.toml ├── src └── parver │ ├── __init__.py │ ├── _helpers.py │ ├── _parse.py │ ├── _segments.py │ ├── _typing.py │ ├── _version.py │ └── py.typed ├── tests ├── __init__.py ├── conftest.py ├── strategies.py ├── test_packaging.py ├── test_parse.py └── test_version.py └── tox.ini /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | extend-ignore=E203 3 | # Should aim for 80, but don't warn until 90. 4 | max-line-length = 90 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 1024 8 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | push: 6 | branches: ["master"] 7 | pull_request: 8 | branches: ["master"] 9 | workflow_dispatch: 10 | 11 | jobs: 12 | tests: 13 | name: "Python ${{ matrix.python-version }}" 14 | runs-on: "ubuntu-latest" 15 | 16 | strategy: 17 | matrix: 18 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 19 | 20 | steps: 21 | - uses: "actions/checkout@v4" 22 | - uses: "actions/setup-python@v5" 23 | with: 24 | python-version: "${{ matrix.python-version }}" 25 | - name: "Install dependencies" 26 | run: | 27 | set -xe 28 | python -VV 29 | python -m site 30 | python -m pip install --upgrade pip setuptools wheel 31 | python -m pip install --upgrade coverage[toml] virtualenv tox tox-gh-actions 32 | 33 | - name: "Run tox targets for ${{ matrix.python-version }}" 34 | run: "python -m tox" 35 | - name: "Convert coverage" 36 | run: "python -m coverage xml" 37 | - name: "Upload coverage to Codecov" 38 | uses: "codecov/codecov-action@v5" 39 | with: 40 | fail_ci_if_error: true 41 | token: ${{ secrets.CODECOV_TOKEN }} 42 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | pull_request: 7 | branches: ["master"] 8 | 9 | jobs: 10 | pre-commit: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-python@v5 15 | - uses: pre-commit/action@v3.0.0 16 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: "3.x" 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install setuptools wheel twine build 22 | - name: Build and publish 23 | env: 24 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 25 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 26 | run: | 27 | python -m build 28 | twine upload dist/* 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | .pytest_cache 46 | .hypothesis 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: trailing-whitespace 6 | args: [--markdown-linebreak-ext=md] 7 | - id: end-of-file-fixer 8 | - id: check-yaml 9 | - repo: https://github.com/PyCQA/flake8 10 | rev: 6.1.0 11 | hooks: 12 | - id: flake8 13 | additional_dependencies: [pep8-naming] 14 | - repo: https://github.com/timothycrosley/isort 15 | rev: 5.12.0 16 | hooks: 17 | - id: isort 18 | - repo: https://github.com/asottile/pyupgrade 19 | rev: v3.14.0 20 | hooks: 21 | - id: pyupgrade 22 | args: [--py38-plus] 23 | - repo: https://github.com/psf/black-pre-commit-mirror 24 | rev: 23.9.1 25 | hooks: 26 | - id: black 27 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-20.04 5 | tools: 6 | python: "3.11" 7 | 8 | python: 9 | install: 10 | - method: pip 11 | path: . 12 | extra_requirements: 13 | - docs 14 | 15 | sphinx: 16 | configuration: docs/conf.py 17 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 0.5 (2023-10-03) 5 | ---------------- 6 | 7 | Added 8 | ~~~~~ 9 | 10 | - Support for Python 3.12 11 | 12 | Removed 13 | ~~~~~~~ 14 | 15 | - Support for Python 3.7 16 | 17 | 18 | 0.4 (2022-11-11) 19 | ---------------- 20 | 21 | Added 22 | ~~~~~ 23 | 24 | - Type hints. 25 | 26 | Removed 27 | ~~~~~~~ 28 | 29 | - Support for Python 2.7, 3.5, and 3.6. 30 | - ``__version__``, ``__author__``, and ``__email__`` attributes from `parver` module. Use :mod:`importlib.metadata` instead. 31 | 32 | 33 | 0.3.1 (2020-09-28) 34 | ------------------ 35 | 36 | Added 37 | ~~~~~ 38 | 39 | - Grammar is parsed when first used to improve import time. 40 | 41 | Fixed 42 | ~~~~~ 43 | 44 | - attrs deprecation warning. The minimum attrs version is now 19.2 45 | - Errors raised for keyword-only argument errors on Python 3 did not 46 | have the right error message. 47 | 48 | 49 | 0.3 (2020-02-20) 50 | ---------------- 51 | 52 | Added 53 | ~~~~~ 54 | 55 | - ``Version.truncate`` method to remove trailing zeros from the release 56 | segment. 57 | - ``Version`` now validates each item in the release sequence. 58 | - ``Version.bump_epoch`` method. 59 | - Add ``by`` keyword argument to ``bump_pre``, ``bump_post``, and 60 | ``bump_dev`` methods, which e.g. ``.bump_dev(by=-1)``. 61 | 62 | Changed 63 | ~~~~~~~ 64 | 65 | - **BREAKING CHANGE**. The ``Version`` constructor now uses an empty 66 | string to represent an implicit zero instead of ``None``. 67 | 68 | .. code:: python 69 | 70 | >>> Version(release=1, post='') 71 | 72 | 73 | Removed 74 | ~~~~~~~ 75 | 76 | - **BREAKING CHANGE**. ``Version.clear`` is no longer necessary. Use 77 | ``Version.replace(pre=None, post=None, dev=None)`` instead. 78 | 79 | 80 | Fixed 81 | ~~~~~ 82 | 83 | - ``Version`` incorrectly allowed an empty release sequence. 84 | - ``Version`` rejects ``bool`` for numeric components. 85 | - ``Version`` rejects negative integers for numeric components. 86 | - The strict parser no longer accepts local versions with ``-`` or 87 | ``_`` separators, or uppercase letters. 88 | - The strict parser no longer accepts numbers with leading zeros. 89 | - The local version was only being converted to lowercase when parsing 90 | with ``strict=False``. It is now always converted. 91 | - The local version separators were not being normalized to use ``.``. 92 | 93 | 94 | 0.2.1 (2018-12-31) 95 | ------------------ 96 | 97 | Fixed 98 | ~~~~~ 99 | 100 | - On Python 2, ``Version`` was incorrectly rejecting ``long`` integer 101 | values. 102 | 103 | 104 | 0.2 (2018-11-21) 105 | ---------------- 106 | 107 | Added 108 | ~~~~~ 109 | 110 | - ``Version.bump_release_to`` method for control over the value to bump 111 | to, e.g. for `CalVer`_. 112 | - ``Version.set_release`` method for finer control over release values 113 | without resetting subsequent indices to zero. 114 | 115 | .. _CalVer: https://calver.org 116 | 117 | 118 | Changed 119 | ~~~~~~~ 120 | 121 | - **BREAKING CHANGE**. The argument to ``Version.bump_release`` is now 122 | a keyword only argument, e.g. ``Version.bump_release(index=0)``. 123 | - The ``release`` parameter to ``Version`` now accepts any iterable. 124 | 125 | 126 | Fixed 127 | ~~~~~ 128 | 129 | - Deprecation warnings about invalid escape sequences in ``_parse.py``. 130 | 131 | 132 | 0.1.1 (2018-06-19) 133 | ------------------ 134 | 135 | Fixed 136 | ~~~~~ 137 | 138 | - ``Version`` accepted ``pre=None`` and ``post_tag=None``, which 139 | produces an ambiguous version number. This is because an implicit 140 | pre-release number combined with an implicit post-release looks like 141 | a pre-release with a custom separator: 142 | 143 | .. code:: python 144 | 145 | >>> Version(release=1, pre_tag='a', pre=None, post_tag=None, post=2) 146 | 147 | >>> Version(release=1, pre_tag='a', pre_sep2='-', pre=2) 148 | 149 | 150 | The first form now raises a ``ValueError``. 151 | 152 | - Don’t allow ``post=None`` when ``post_tag=None``. Implicit post 153 | releases cannot have implicit post release numbers. 154 | 155 | 156 | 0.1 (2018-05-20) 157 | ---------------- 158 | 159 | First release. 160 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Frazer McLean 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.rst 2 | include LICENSE 3 | include tox.ini 4 | recursive-include tests *.py 5 | recursive-include docs * 6 | prune docs/_build 7 | include src/parver/py.typed 8 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://img.shields.io/pypi/v/parver.svg 2 | :target: https://pypi.org/project/parver/ 3 | :alt: PyPI 4 | 5 | .. image:: https://img.shields.io/badge/docs-read%20now-blue.svg 6 | :target: https://parver.readthedocs.io/en/latest/?badge=latest 7 | :alt: Documentation Status 8 | 9 | .. image:: https://github.com/RazerM/parver/workflows/CI/badge.svg?branch=master 10 | :target: https://github.com/RazerM/parver/actions?workflow=CI 11 | :alt: CI Status 12 | 13 | .. image:: https://codecov.io/gh/RazerM/parver/branch/master/graph/badge.svg 14 | :target: https://codecov.io/gh/RazerM/parver 15 | :alt: Test coverage 16 | 17 | .. image:: https://img.shields.io/github/license/RazerM/parver.svg 18 | :target: https://raw.githubusercontent.com/RazerM/parver/master/LICENSE.txt 19 | :alt: MIT License 20 | 21 | parver 22 | ====== 23 | 24 | parver allows parsing and manipulation of `PEP 440`_ version numbers. 25 | 26 | Example 27 | ======= 28 | 29 | .. code:: python 30 | 31 | >>> Version.parse('1.3').bump_dev() 32 | 33 | >>> v = Version.parse('v1.2.alpha-3') 34 | >>> v.is_alpha 35 | True 36 | >>> v.pre 37 | 3 38 | >>> v 39 | 40 | >>> v.normalize() 41 | 42 | 43 | .. _`PEP 440`: https://www.python.org/dev/peps/pep-0440/ 44 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | API Reference 3 | ============= 4 | 5 | .. module:: parver 6 | 7 | .. testsetup:: 8 | 9 | from parver import Version 10 | 11 | .. autoclass:: Version 12 | :members: 13 | 14 | .. autoclass:: ParseError 15 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | from importlib.metadata import distribution 7 | 8 | # -- Project information ----------------------------------------------------- 9 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 10 | 11 | project = "parver" 12 | copyright = "2018, Frazer McLean" 13 | author = "Frazer McLean" 14 | release = distribution("parver").version 15 | 16 | 17 | # -- General configuration --------------------------------------------------- 18 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 19 | 20 | extensions = [ 21 | "sphinx.ext.autodoc", 22 | "sphinx.ext.doctest", 23 | "sphinx.ext.intersphinx", 24 | "sphinx.ext.viewcode", 25 | ] 26 | 27 | templates_path = ["_templates"] 28 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 29 | 30 | 31 | # -- Options for HTML output ------------------------------------------------- 32 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 33 | 34 | html_theme = "furo" 35 | html_title = f"parver documentation v{release}" 36 | html_static_path = [] 37 | 38 | 39 | # -- Extension configuration ------------------------------------------------- 40 | 41 | intersphinx_mapping = { 42 | "python": ("https://docs.python.org/3", None), 43 | } 44 | 45 | autodoc_member_order = "bysource" 46 | autodoc_typehints = "description" 47 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. parver documentation master file 2 | 3 | parver documentation 4 | ======================== 5 | 6 | ``parver`` is a Python package for parsing and manipulating `PEP 440`_ version 7 | numbers. 8 | 9 | Head over to :doc:`installation` or :doc:`usage` to get started. 10 | 11 | .. _`PEP 440`: https://www.python.org/dev/peps/pep-0440/ 12 | 13 | .. toctree:: 14 | :hidden: 15 | 16 | installation 17 | usage 18 | api 19 | changelog 20 | license 21 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ************ 2 | Installation 3 | ************ 4 | 5 | parver is available from PyPI_: 6 | 7 | .. code:: bash 8 | 9 | pip install parver 10 | 11 | .. _PyPI: https://pypi.org/project/parver 12 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | License 3 | ======= 4 | 5 | .. include:: ../LICENSE 6 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ***** 2 | Usage 3 | ***** 4 | 5 | .. py:currentmodule:: parver 6 | 7 | ``parver`` provides the :class:`Version` class. It is immutable, so methods 8 | which you might expect to mutate instead return a new instance with the 9 | requested modifications: 10 | 11 | .. testsetup:: 12 | 13 | from parver import Version 14 | 15 | .. doctest:: 16 | 17 | >>> v = Version.parse('1.3') 18 | >>> v 19 | 20 | >>> v.bump_release(index=0) 21 | 22 | >>> v.bump_release(index=1) 23 | 24 | >>> assert v == Version(release=(1, 3)) 25 | 26 | Note here that we used an index to tell ``parver`` which number to bump. You 27 | may typically refer to indices 0, 1, and 2 as major, minor, and patch releases, 28 | but this depends on which versioning convention your project uses. 29 | 30 | Development, pre-release, and post releases are also supported: 31 | 32 | .. doctest:: 33 | 34 | >>> Version.parse('1.3').bump_dev() 35 | 36 | >>> Version.parse('1.3').bump_pre('b') 37 | 38 | >>> Version.parse('1.3').bump_post() 39 | 40 | 41 | Parsing 42 | ======= 43 | 44 | ``parver`` can parse any `PEP 440`_-compatible version string. Here is one in 45 | canonical form: 46 | 47 | .. doctest:: 48 | 49 | >>> v = Version.parse('1!2.3a4.post5.dev6+local', strict=True) 50 | >>> v 51 | 52 | >>> assert v.epoch == 1 53 | >>> assert v.release == (2, 3) 54 | >>> assert v.pre_tag == 'a' 55 | >>> assert v.pre == 4 56 | >>> assert v.post == 5 57 | >>> assert v.dev == 6 58 | >>> assert v.local == 'local' 59 | 60 | With ``strict=True``, :meth:`~Version.parse` will raise :exc:`ParseError` if 61 | the version is not in canonical form. 62 | 63 | Any version in canonical form will have the same normalized string output: 64 | 65 | >>> assert str(v.normalize()) == str(v) 66 | 67 | For version numbers that aren't in canonical form, ``parver`` has no problem 68 | parsing them. In this example, there are a couple of non-standard elements: 69 | 70 | * Non-standard separators in the pre-release segment. 71 | * `alpha` rather than `a` for the pre-release identifier. 72 | * An implicit post release number. 73 | 74 | .. doctest:: 75 | 76 | >>> v = Version.parse('1.2.alpha-3.post') 77 | >>> v 78 | 79 | >>> assert v.pre == 3 80 | >>> assert v.pre_tag == 'alpha' 81 | >>> assert v.is_alpha 82 | >>> assert v.post == 0 83 | >>> assert v.post_implicit 84 | >>> v.normalize() 85 | 86 | >>> assert v == v.normalize() 87 | >>> assert str(v) != str(v.normalize()) 88 | 89 | Note that normalization **does not** affect equality (or ordering). 90 | 91 | Also note that ``parver`` can round-trip [#]_ your version strings; 92 | non-standard parameters are kept as-is, even when you mutate: 93 | 94 | .. doctest:: 95 | 96 | >>> v = Version.parse('v1.2.alpha-3.post') 97 | >>> v.replace(post=None).bump_pre() 98 | 99 | 100 | .. [#] One exception is that ``parver`` always converts the version string to 101 | lowercase. 102 | 103 | .. _`PEP 440`: https://www.python.org/dev/peps/pep-0440/ 104 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=64"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "parver" 7 | version = "0.5" 8 | description = "Parse and manipulate version numbers." 9 | readme = "README.rst" 10 | requires-python = ">=3.8" 11 | license = { text = "MIT" } 12 | authors = [ 13 | { name = "Frazer McLean", email = "frazer@frazermclean.co.uk" }, 14 | ] 15 | keywords = ["pep440", "version", "parse"] 16 | classifiers = [ 17 | "Development Status :: 5 - Production/Stable", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: MIT License", 20 | "Programming Language :: Python :: 3 :: Only", 21 | "Programming Language :: Python :: 3.8", 22 | "Programming Language :: Python :: 3.9", 23 | "Programming Language :: Python :: 3.10", 24 | "Programming Language :: Python :: 3.11", 25 | "Programming Language :: Python :: 3.12", 26 | "Programming Language :: Python :: Implementation :: CPython", 27 | "Programming Language :: Python :: Implementation :: PyPy", 28 | ] 29 | dependencies = [ 30 | "arpeggio>=1.7", 31 | "attrs >= 19.2", 32 | "typing-extensions; python_version<'3.10'", 33 | ] 34 | 35 | [project.optional-dependencies] 36 | test = ["pytest", "hypothesis", "pretend"] 37 | docs = ["sphinx", "furo"] 38 | docstest = ["doc8"] 39 | pep8test = ["flake8", "pep8-naming"] 40 | 41 | [project.urls] 42 | Documentation = "https://parver.readthedocs.io" 43 | "Source Code" = "https://github.com/RazerM/parver" 44 | 45 | [tool.setuptools] 46 | package-dir = { "" = "src" } 47 | 48 | [tool.setuptools.packages.find] 49 | where = ["src"] 50 | 51 | [tool.coverage.run] 52 | branch = true 53 | source = ["parver", "tests/"] 54 | 55 | [tool.coverage.paths] 56 | source = ["src/parver", ".tox/*/lib/python*/site-packages/parver"] 57 | 58 | [tool.coverage.report] 59 | precision = 1 60 | exclude_lines = ["pragma: no cover", "pass"] 61 | 62 | [tool.isort] 63 | profile = "black" 64 | 65 | [tool.mypy] 66 | warn_unused_configs = true 67 | show_error_codes = true 68 | disallow_any_generics = true 69 | disallow_subclassing_any = true 70 | disallow_untyped_calls = true 71 | disallow_untyped_defs = true 72 | disallow_incomplete_defs = true 73 | check_untyped_defs = true 74 | disallow_untyped_decorators = true 75 | warn_unused_ignores = true 76 | warn_return_any = true 77 | no_implicit_reexport = true 78 | 79 | [[tool.mypy.overrides]] 80 | module = [ 81 | "arpeggio.*", 82 | ] 83 | ignore_missing_imports = true 84 | -------------------------------------------------------------------------------- /src/parver/__init__.py: -------------------------------------------------------------------------------- 1 | from ._parse import ParseError 2 | from ._version import Version 3 | 4 | __all__ = ("Version", "ParseError") 5 | 6 | from ._helpers import fixup_module_metadata 7 | 8 | fixup_module_metadata(__name__, globals()) 9 | del fixup_module_metadata 10 | -------------------------------------------------------------------------------- /src/parver/_helpers.py: -------------------------------------------------------------------------------- 1 | from collections import deque 2 | from typing import Any, Dict, Iterable, TypeVar, Union, cast, overload 3 | 4 | from ._typing import ImplicitZero 5 | 6 | T = TypeVar("T") 7 | R = TypeVar("R") 8 | 9 | 10 | class UnsetType: 11 | def __repr__(self) -> str: 12 | return "UNSET" 13 | 14 | 15 | UNSET = UnsetType() 16 | 17 | 18 | class InfinityType: 19 | def __repr__(self) -> str: 20 | return "Infinity" 21 | 22 | def __hash__(self) -> int: 23 | return hash(repr(self)) 24 | 25 | def __lt__(self, other: Any) -> bool: 26 | return False 27 | 28 | def __le__(self, other: Any) -> bool: 29 | return False 30 | 31 | def __eq__(self, other: Any) -> bool: 32 | return isinstance(other, self.__class__) 33 | 34 | def __ne__(self, other: Any) -> bool: 35 | return not isinstance(other, self.__class__) 36 | 37 | def __gt__(self, other: Any) -> bool: 38 | return True 39 | 40 | def __ge__(self, other: Any) -> bool: 41 | return True 42 | 43 | def __neg__(self) -> "NegativeInfinityType": 44 | return NegativeInfinity 45 | 46 | 47 | Infinity = InfinityType() 48 | 49 | 50 | class NegativeInfinityType: 51 | def __repr__(self) -> str: 52 | return "-Infinity" 53 | 54 | def __hash__(self) -> int: 55 | return hash(repr(self)) 56 | 57 | def __lt__(self, other: Any) -> bool: 58 | return True 59 | 60 | def __le__(self, other: Any) -> bool: 61 | return True 62 | 63 | def __eq__(self, other: Any) -> bool: 64 | return isinstance(other, self.__class__) 65 | 66 | def __ne__(self, other: Any) -> bool: 67 | return not isinstance(other, self.__class__) 68 | 69 | def __gt__(self, other: Any) -> bool: 70 | return False 71 | 72 | def __ge__(self, other: Any) -> bool: 73 | return False 74 | 75 | def __neg__(self) -> InfinityType: 76 | return Infinity 77 | 78 | 79 | NegativeInfinity = NegativeInfinityType() 80 | 81 | 82 | def fixup_module_metadata(module_name: str, namespace: Dict[str, Any]) -> None: 83 | def fix_one(obj: Any) -> None: 84 | mod = getattr(obj, "__module__", None) 85 | if mod is not None and mod.startswith("parver."): 86 | obj.__module__ = module_name 87 | if isinstance(obj, type): 88 | for attr_value in obj.__dict__.values(): 89 | fix_one(attr_value) 90 | 91 | for objname in namespace["__all__"]: 92 | obj = namespace[objname] 93 | fix_one(obj) 94 | 95 | 96 | @overload 97 | def last(iterable: Iterable[T]) -> T: 98 | pass 99 | 100 | 101 | @overload 102 | def last(iterable: Iterable[T], *, default: T) -> T: 103 | pass 104 | 105 | 106 | def last(iterable: Iterable[T], *, default: Union[UnsetType, T] = UNSET) -> T: 107 | try: 108 | return deque(iterable, maxlen=1).pop() 109 | except IndexError: 110 | if default is UNSET: 111 | raise 112 | return cast(T, default) 113 | 114 | 115 | IMPLICIT_ZERO: ImplicitZero = "" 116 | -------------------------------------------------------------------------------- /src/parver/_parse.py: -------------------------------------------------------------------------------- 1 | from threading import Lock 2 | from typing import List, Optional, Tuple, Union, cast 3 | 4 | import attr 5 | from arpeggio import ( 6 | NoMatch, 7 | PTNodeVisitor, 8 | SemanticActionResults, 9 | Terminal, 10 | visit_parse_tree, 11 | ) 12 | from arpeggio.cleanpeg import ParserPEG 13 | 14 | from . import _segments as segment 15 | from ._helpers import IMPLICIT_ZERO, UNSET, UnsetType 16 | from ._typing import ImplicitZero, Node, PostTag, PreTag, Separator 17 | 18 | canonical = r""" 19 | version = epoch? release pre? post? dev? local? EOF 20 | epoch = int "!" 21 | release = int (dot int)* 22 | pre = pre_tag pre_post_num 23 | pre_tag = "a" / "b" / "rc" 24 | post = sep post_tag pre_post_num 25 | pre_post_num = int 26 | post_tag = "post" 27 | dev = sep "dev" int 28 | local = "+" local_part (sep local_part)* 29 | local_part = alpha / int 30 | sep = dot 31 | dot = "." 32 | int = r'0|[1-9][0-9]*' 33 | alpha = r'[0-9]*[a-z][a-z0-9]*' 34 | """ 35 | 36 | permissive = r""" 37 | version = v? epoch? release pre? (post / post_implicit)? dev? local? EOF 38 | v = "v" 39 | epoch = int "!" 40 | release = int (dot int)* 41 | pre = sep? pre_tag pre_post_num? 42 | pre_tag = "c" / "rc" / "alpha" / "a" / "beta" / "b" / "preview" / "pre" 43 | post = sep? post_tag pre_post_num? 44 | post_implicit = "-" int 45 | post_tag = "post" / "rev" / "r" 46 | pre_post_num = sep? int 47 | dev = sep? "dev" int? 48 | local = "+" local_part (sep local_part)* 49 | local_part = alpha / int 50 | sep = dot / "-" / "_" 51 | dot = "." 52 | int = r'[0-9]+' 53 | alpha = r'[0-9]*[a-z][a-z0-9]*' 54 | """ 55 | 56 | _strict_parser = _permissive_parser = None 57 | _parser_create_lock = Lock() 58 | 59 | 60 | @attr.s(slots=True) 61 | class Sep: 62 | value: Optional[Separator] = attr.ib() 63 | 64 | 65 | @attr.s(slots=True) 66 | class Tag: 67 | value: Union[PreTag, PostTag] = attr.ib() 68 | 69 | 70 | class VersionVisitor(PTNodeVisitor): # type: ignore[misc] 71 | def visit_version( 72 | self, node: Node, children: SemanticActionResults 73 | ) -> List[segment.Segment]: 74 | return list(children) 75 | 76 | def visit_v(self, node: Node, children: SemanticActionResults) -> segment.V: 77 | return segment.V() 78 | 79 | def visit_epoch(self, node: Node, children: SemanticActionResults) -> segment.Epoch: 80 | return segment.Epoch(children[0]) 81 | 82 | def visit_release( 83 | self, node: Node, children: SemanticActionResults 84 | ) -> segment.Release: 85 | return segment.Release(tuple(children)) 86 | 87 | def visit_pre(self, node: Node, children: SemanticActionResults) -> segment.Pre: 88 | sep1: Union[Separator, None, UnsetType] = UNSET 89 | tag: Union[PreTag, UnsetType] = UNSET 90 | sep2: Union[Separator, None, UnsetType] = UNSET 91 | num: Union[ImplicitZero, int, UnsetType] = UNSET 92 | 93 | for token in children: 94 | if sep1 is UNSET: 95 | if isinstance(token, Sep): 96 | sep1 = token.value 97 | elif isinstance(token, Tag): 98 | sep1 = None 99 | tag = cast(PreTag, token.value) 100 | elif tag is UNSET: 101 | tag = token.value 102 | else: 103 | assert isinstance(token, tuple) 104 | assert len(token) == 2 105 | sep2 = token[0].value 106 | num = token[1] 107 | 108 | if sep2 is UNSET: 109 | sep2 = None 110 | num = IMPLICIT_ZERO 111 | 112 | assert not isinstance(sep1, UnsetType) 113 | assert not isinstance(tag, UnsetType) 114 | assert not isinstance(sep2, UnsetType) 115 | assert not isinstance(num, UnsetType) 116 | 117 | return segment.Pre(sep1=sep1, tag=tag, sep2=sep2, value=num) 118 | 119 | def visit_pre_post_num( 120 | self, node: Node, children: SemanticActionResults 121 | ) -> Tuple[Sep, int]: 122 | # when "pre_post_num = int", visit_int isn't called for some reason 123 | # I don't understand. Let's call int() manually 124 | if isinstance(node, Terminal): 125 | return Sep(None), int(node.value) 126 | 127 | if len(children) == 1: 128 | return Sep(None), children[0] 129 | else: 130 | return cast("Tuple[Sep, int]", tuple(children[:2])) 131 | 132 | def visit_pre_tag(self, node: Node, children: SemanticActionResults) -> Tag: 133 | return Tag(node.value) 134 | 135 | def visit_post(self, node: Node, children: SemanticActionResults) -> segment.Post: 136 | sep1: Union[Separator, None, UnsetType] = UNSET 137 | tag: Union[PostTag, None, UnsetType] = UNSET 138 | sep2: Union[Separator, None, UnsetType] = UNSET 139 | num: Union[ImplicitZero, int, UnsetType] = UNSET 140 | 141 | for token in children: 142 | if sep1 is UNSET: 143 | if isinstance(token, Sep): 144 | sep1 = token.value 145 | elif isinstance(token, Tag): 146 | sep1 = None 147 | tag = cast(PostTag, token.value) 148 | elif tag is UNSET: 149 | tag = token.value 150 | else: 151 | assert isinstance(token, tuple) 152 | assert len(token) == 2 153 | sep2 = token[0].value 154 | num = token[1] 155 | 156 | if sep2 is UNSET: 157 | sep2 = None 158 | num = IMPLICIT_ZERO 159 | 160 | assert not isinstance(sep1, UnsetType) 161 | assert not isinstance(tag, UnsetType) 162 | assert not isinstance(sep2, UnsetType) 163 | assert not isinstance(num, UnsetType) 164 | 165 | return segment.Post(sep1=sep1, tag=tag, sep2=sep2, value=num) 166 | 167 | def visit_post_tag(self, node: Node, children: SemanticActionResults) -> Tag: 168 | return Tag(node.value) 169 | 170 | def visit_post_implicit( 171 | self, node: Node, children: SemanticActionResults 172 | ) -> segment.Post: 173 | return segment.Post(sep1=UNSET, tag=None, sep2=UNSET, value=children[0]) 174 | 175 | def visit_dev(self, node: Node, children: SemanticActionResults) -> segment.Dev: 176 | num: Union[ImplicitZero, int] = IMPLICIT_ZERO 177 | sep: Union[Separator, None, UnsetType] = UNSET 178 | 179 | for token in children: 180 | if sep is UNSET: 181 | if isinstance(token, Sep): 182 | sep = token.value 183 | else: 184 | num = token 185 | else: 186 | num = token 187 | 188 | if isinstance(sep, UnsetType): 189 | sep = None 190 | 191 | return segment.Dev(value=num, sep=sep) 192 | 193 | def visit_local(self, node: Node, children: SemanticActionResults) -> segment.Local: 194 | return segment.Local("".join(str(getattr(c, "value", c)) for c in children)) 195 | 196 | def visit_int(self, node: Node, children: SemanticActionResults) -> int: 197 | return int(node.value) 198 | 199 | def visit_sep(self, node: Node, children: SemanticActionResults) -> Sep: 200 | return Sep(node.value) 201 | 202 | 203 | class ParseError(ValueError): 204 | """Raised when parsing an invalid version number.""" 205 | 206 | 207 | def _get_parser(strict: bool) -> ParserPEG: 208 | """Ensure the module-level peg parser is created and return it.""" 209 | global _strict_parser, _permissive_parser 210 | 211 | # Each branch below only acquires the lock if the global is unset. 212 | 213 | if strict: 214 | if _strict_parser is None: 215 | with _parser_create_lock: 216 | if _strict_parser is None: 217 | _strict_parser = ParserPEG( 218 | canonical, root_rule_name="version", skipws=False 219 | ) 220 | 221 | return _strict_parser 222 | else: 223 | if _permissive_parser is None: 224 | with _parser_create_lock: 225 | if _permissive_parser is None: 226 | _permissive_parser = ParserPEG( 227 | permissive, 228 | root_rule_name="version", 229 | skipws=False, 230 | ignore_case=True, 231 | ) 232 | 233 | return _permissive_parser 234 | 235 | 236 | def parse(version: str, strict: bool = False) -> List[segment.Segment]: 237 | parser = _get_parser(strict) 238 | 239 | try: 240 | tree = parser.parse(version.strip()) 241 | except NoMatch as exc: 242 | raise ParseError(str(exc)) from None 243 | 244 | return cast("List[segment.Segment]", visit_parse_tree(tree, VersionVisitor())) 245 | -------------------------------------------------------------------------------- /src/parver/_segments.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Tuple, Union 2 | 3 | import attr 4 | 5 | from ._helpers import UnsetType 6 | from ._typing import ImplicitZero, PostTag, PreTag, Separator 7 | 8 | 9 | @attr.s(slots=True) 10 | class Segment: 11 | pass 12 | 13 | 14 | @attr.s(slots=True) 15 | class V(Segment): 16 | pass 17 | 18 | 19 | @attr.s(slots=True) 20 | class Epoch: 21 | value: int = attr.ib() 22 | 23 | 24 | @attr.s(slots=True) 25 | class Release: 26 | value: Tuple[int, ...] = attr.ib() 27 | 28 | 29 | @attr.s(slots=True) 30 | class Pre: 31 | value: Union[ImplicitZero, int] = attr.ib() 32 | sep1: Optional[Separator] = attr.ib() 33 | tag: PreTag = attr.ib() 34 | sep2: Optional[Separator] = attr.ib() 35 | 36 | 37 | @attr.s(slots=True) 38 | class Post: 39 | value: Union[ImplicitZero, int] = attr.ib() 40 | sep1: Union[Separator, UnsetType, None] = attr.ib() 41 | tag: Optional[PostTag] = attr.ib() 42 | sep2: Union[Separator, UnsetType, None] = attr.ib() 43 | 44 | 45 | @attr.s(slots=True) 46 | class Dev: 47 | value: Union[ImplicitZero, int] = attr.ib() 48 | sep: Optional[Separator] = attr.ib() 49 | 50 | 51 | @attr.s(slots=True) 52 | class Local: 53 | value: str = attr.ib() 54 | -------------------------------------------------------------------------------- /src/parver/_typing.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import Literal, Union 3 | 4 | from arpeggio import NonTerminal, Terminal 5 | 6 | if sys.version_info >= (3, 10): 7 | from typing import TypeAlias 8 | else: 9 | from typing_extensions import TypeAlias 10 | 11 | PreTag: TypeAlias = Literal["c", "rc", "alpha", "a", "beta", "b", "preview", "pre"] 12 | NormalizedPreTag: TypeAlias = Literal["a", "b", "rc"] 13 | Separator: TypeAlias = Literal[".", "-", "_"] 14 | PostTag: TypeAlias = Literal["post", "rev", "r"] 15 | 16 | ImplicitZero: TypeAlias = Literal[""] 17 | 18 | Node: TypeAlias = Union[Terminal, NonTerminal] 19 | -------------------------------------------------------------------------------- /src/parver/_version.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import operator 3 | import re 4 | from functools import partial 5 | from typing import ( 6 | Any, 7 | Callable, 8 | Dict, 9 | Iterable, 10 | Optional, 11 | Sequence, 12 | Set, 13 | Tuple, 14 | Union, 15 | cast, 16 | overload, 17 | ) 18 | 19 | import attr 20 | from attr import Attribute, converters 21 | from attr.validators import and_, deep_iterable, in_, instance_of, optional 22 | 23 | from . import _segments as segment 24 | from ._helpers import IMPLICIT_ZERO, UNSET, Infinity, UnsetType, last 25 | from ._parse import parse 26 | from ._typing import ImplicitZero, NormalizedPreTag, PostTag, PreTag, Separator 27 | 28 | POST_TAGS: Set[PostTag] = {"post", "rev", "r"} 29 | SEPS: Set[Separator] = {".", "-", "_"} 30 | PRE_TAGS: Set[PreTag] = {"c", "rc", "alpha", "a", "beta", "b", "preview", "pre"} 31 | 32 | _ValidatorType = Callable[[Any, "Attribute[Any]", Any], None] 33 | 34 | 35 | def unset_or(validator: _ValidatorType) -> _ValidatorType: 36 | def validate(inst: Any, attr: "Attribute[Any]", value: Any) -> None: 37 | if value is UNSET: 38 | return 39 | 40 | validator(inst, attr, value) 41 | 42 | return validate 43 | 44 | 45 | def implicit_or( 46 | validator: Union[_ValidatorType, Sequence[_ValidatorType]] 47 | ) -> _ValidatorType: 48 | if isinstance(validator, Sequence): 49 | validator = and_(*validator) 50 | 51 | def validate(inst: Any, attr: "Attribute[Any]", value: Any) -> None: 52 | if value == IMPLICIT_ZERO: 53 | return 54 | 55 | validator(inst, attr, value) 56 | 57 | return validate 58 | 59 | 60 | def not_bool(inst: Any, attr: "Attribute[Any]", value: Any) -> None: 61 | if isinstance(value, bool): 62 | raise TypeError( 63 | "'{name}' must not be a bool (got {value!r})".format( 64 | name=attr.name, value=value 65 | ) 66 | ) 67 | 68 | 69 | def is_non_negative(inst: Any, attr: "Attribute[Any]", value: Any) -> None: 70 | if value < 0: 71 | raise ValueError( 72 | "'{name}' must be non-negative (got {value!r})".format( 73 | name=attr.name, value=value 74 | ) 75 | ) 76 | 77 | 78 | def non_empty(inst: Any, attr: "Attribute[Any]", value: Any) -> None: 79 | if not value: 80 | raise ValueError(f"'{attr.name}' cannot be empty") 81 | 82 | 83 | def check_by(by: int, current: Optional[int]) -> None: 84 | if not isinstance(by, int): 85 | raise TypeError("by must be an integer") 86 | 87 | if current is None and by < 0: 88 | raise ValueError("Cannot bump by negative amount when current value is unset.") 89 | 90 | 91 | validate_post_tag: _ValidatorType = unset_or(optional(in_(POST_TAGS))) 92 | validate_pre_tag: _ValidatorType = optional(in_(PRE_TAGS)) 93 | validate_sep: _ValidatorType = optional(in_(SEPS)) 94 | validate_sep_or_unset: _ValidatorType = unset_or(optional(in_(SEPS))) 95 | is_bool: _ValidatorType = instance_of(bool) 96 | is_int: _ValidatorType = instance_of(int) 97 | is_str: _ValidatorType = instance_of(str) 98 | is_tuple: _ValidatorType = instance_of(tuple) 99 | 100 | # "All numeric components MUST be non-negative integers." 101 | num_comp = [not_bool, is_int, is_non_negative] 102 | 103 | release_validator = deep_iterable(and_(*num_comp), and_(is_tuple, non_empty)) 104 | 105 | 106 | def convert_release(release: Union[int, Iterable[int]]) -> Tuple[int, ...]: 107 | if isinstance(release, Iterable) and not isinstance(release, str): 108 | return tuple(release) 109 | elif isinstance(release, int): 110 | return (release,) 111 | 112 | # The input value does not conform to the function type, let it pass through 113 | # to the validator 114 | return release 115 | 116 | 117 | def convert_local(local: Optional[str]) -> Optional[str]: 118 | if isinstance(local, str): 119 | return local.lower() 120 | return local 121 | 122 | 123 | def convert_implicit(value: Union[ImplicitZero, int]) -> int: 124 | """This function is a lie, since mypy's attrs plugin takes the argument type 125 | as that of the constructed __init__. The lie is required because we aren't 126 | dealing with ImplicitZero until __attrs_post_init__. 127 | """ 128 | return value # type: ignore[return-value] 129 | 130 | 131 | @attr.s(frozen=True, repr=False, eq=False) 132 | class Version: 133 | """ 134 | 135 | :param release: Numbers for the release segment. 136 | 137 | :param v: Optional preceding v character. 138 | 139 | :param epoch: `Version epoch`_. Implicitly zero but hidden by default. 140 | 141 | :param pre_tag: `Pre-release`_ identifier, typically `a`, `b`, or `rc`. 142 | Required to signify a pre-release. 143 | 144 | :param pre: `Pre-release`_ number. May be ``''`` to signify an 145 | `implicit pre-release number`_. 146 | 147 | :param post: `Post-release`_ number. May be ``''`` to signify an 148 | `implicit post release number`_. 149 | 150 | :param dev: `Developmental release`_ number. May be ``''`` to signify an 151 | `implicit development release number`_. 152 | 153 | :param local: `Local version`_ segment. 154 | 155 | :param pre_sep1: Specify an alternate separator before the pre-release 156 | segment. The normal form is `None`. 157 | 158 | :param pre_sep2: Specify an alternate separator between the identifier and 159 | number. The normal form is ``'.'``. 160 | 161 | :param post_sep1: Specify an alternate separator before the post release 162 | segment. The normal form is ``'.'``. 163 | 164 | :param post_sep2: Specify an alternate separator between the identifier and 165 | number. The normal form is ``'.'``. 166 | 167 | :param dev_sep: Specify an alternate separator before the development 168 | release segment. The normal form is ``'.'``. 169 | 170 | :param post_tag: Specify alternate post release identifier `rev` or `r`. 171 | May be `None` to signify an `implicit post release`_. 172 | 173 | .. note:: The attributes below are not equal to the parameters passed to 174 | the initialiser! 175 | 176 | The main difference is that implicit numbers become `0` and set the 177 | corresponding `_implicit` attribute: 178 | 179 | .. doctest:: 180 | 181 | >>> v = Version(release=1, post='') 182 | >>> str(v) 183 | '1.post' 184 | >>> v.post 185 | 0 186 | >>> v.post_implicit 187 | True 188 | 189 | .. attribute:: release 190 | 191 | A tuple of integers giving the components of the release segment of 192 | this :class:`Version` instance; that is, the ``1.2.3`` part of the 193 | version number, including trailing zeros but not including the epoch 194 | or any prerelease/development/postrelease suffixes 195 | 196 | .. attribute:: v 197 | 198 | Whether this :class:`Version` instance includes a preceding v character. 199 | 200 | .. attribute:: epoch 201 | 202 | An integer giving the version epoch of this :class:`Version` instance. 203 | :attr:`epoch_implicit` may be `True` if this number is zero. 204 | 205 | .. attribute:: pre_tag 206 | 207 | If this :class:`Version` instance represents a pre-release, this 208 | attribute will be the pre-release identifier. One of `a`, `b`, `rc`, 209 | `c`, `alpha`, `beta`, `preview`, or `pre`. 210 | 211 | **Note:** you should not use this attribute to check or compare 212 | pre-release identifiers. Use :meth:`is_alpha`, :meth:`is_beta`, and 213 | :meth:`is_release_candidate` instead. 214 | 215 | .. attribute:: pre 216 | 217 | If this :class:`Version` instance represents a pre-release, this 218 | attribute will be the pre-release number. If this instance is not a 219 | pre-release, the attribute will be `None`. :attr:`pre_implicit` may be 220 | `True` if this number is zero. 221 | 222 | .. attribute:: post 223 | 224 | If this :class:`Version` instance represents a postrelease, this 225 | attribute will be the postrelease number (an integer); otherwise, it 226 | will be `None`. :attr:`post_implicit` may be `True` if this number 227 | is zero. 228 | 229 | .. attribute:: dev 230 | 231 | If this :class:`Version` instance represents a development release, 232 | this attribute will be the development release number (an integer); 233 | otherwise, it will be `None`. :attr:`dev_implicit` may be `True` if this 234 | number is zero. 235 | 236 | .. attribute:: local 237 | 238 | A string representing the local version portion of this :class:`Version` 239 | instance if it has one, or ``None`` otherwise. 240 | 241 | .. attribute:: pre_sep1 242 | 243 | The separator before the pre-release identifier. 244 | 245 | .. attribute:: pre_sep2 246 | 247 | The seperator between the pre-release identifier and number. 248 | 249 | .. attribute:: post_sep1 250 | 251 | The separator before the post release identifier. 252 | 253 | .. attribute:: post_sep2 254 | 255 | The seperator between the post release identifier and number. 256 | 257 | .. attribute:: dev_sep 258 | 259 | The separator before the develepment release identifier. 260 | 261 | .. attribute:: post_tag 262 | 263 | If this :class:`Version` instance represents a post release, this 264 | attribute will be the post release identifier. One of `post`, `rev`, 265 | `r`, or `None` to represent an implicit post release. 266 | 267 | .. _`Version epoch`: https://www.python.org/dev/peps/pep-0440/#version-epochs 268 | .. _`Pre-release`: https://www.python.org/dev/peps/pep-0440/#pre-releases 269 | .. _`implicit pre-release number`: https://www.python.org/dev/peps/ 270 | pep-0440/#implicit-pre-release-number 271 | .. _`Post-release`: https://www.python.org/dev/peps/pep-0440/#post-releases 272 | .. _`implicit post release number`: https://www.python.org/dev/peps/ 273 | pep-0440/#implicit-post-release-number 274 | .. _`Developmental release`: https://www.python.org/dev/peps/pep-0440/ 275 | #developmental-releases 276 | .. _`implicit development release number`: https://www.python.org/dev/peps/ 277 | pep-0440/#implicit-development-release-number 278 | .. _`Local version`: https://www.python.org/dev/peps/pep-0440/ 279 | #local-version-identifiers 280 | .. _`implicit post release`: https://www.python.org/dev/peps/pep-0440/ 281 | #implicit-post-releases 282 | 283 | """ 284 | 285 | release: Tuple[int, ...] = attr.ib( 286 | converter=convert_release, validator=release_validator 287 | ) 288 | v: bool = attr.ib(default=False, validator=is_bool) 289 | epoch: int = attr.ib( 290 | default=cast(int, IMPLICIT_ZERO), 291 | converter=convert_implicit, 292 | validator=implicit_or(num_comp), 293 | ) 294 | pre_tag: Optional[PreTag] = attr.ib(default=None, validator=validate_pre_tag) 295 | pre: Optional[int] = attr.ib( 296 | default=None, 297 | converter=converters.optional(convert_implicit), 298 | validator=implicit_or(optional(num_comp)), 299 | ) 300 | post: Optional[int] = attr.ib( 301 | default=None, 302 | converter=converters.optional(convert_implicit), 303 | validator=implicit_or(optional(num_comp)), 304 | ) 305 | dev: Optional[int] = attr.ib( 306 | default=None, 307 | converter=converters.optional(convert_implicit), 308 | validator=implicit_or(optional(num_comp)), 309 | ) 310 | local: Optional[str] = attr.ib( 311 | default=None, converter=convert_local, validator=optional(is_str) 312 | ) 313 | 314 | pre_sep1: Optional[Separator] = attr.ib(default=None, validator=validate_sep) 315 | pre_sep2: Optional[Separator] = attr.ib(default=None, validator=validate_sep) 316 | post_sep1: Optional[Separator] = attr.ib( 317 | default=UNSET, validator=validate_sep_or_unset 318 | ) 319 | post_sep2: Optional[Separator] = attr.ib( 320 | default=UNSET, validator=validate_sep_or_unset 321 | ) 322 | dev_sep: Optional[Separator] = attr.ib( 323 | default=UNSET, validator=validate_sep_or_unset 324 | ) 325 | post_tag: Optional[PostTag] = attr.ib(default=UNSET, validator=validate_post_tag) 326 | 327 | epoch_implicit: bool = attr.ib(default=False, init=False) 328 | pre_implicit: bool = attr.ib(default=False, init=False) 329 | post_implicit: bool = attr.ib(default=False, init=False) 330 | dev_implicit: bool = attr.ib(default=False, init=False) 331 | _key = attr.ib(init=False) 332 | 333 | def __attrs_post_init__(self) -> None: 334 | set_ = partial(object.__setattr__, self) 335 | 336 | if self.epoch == IMPLICIT_ZERO: 337 | set_("epoch", 0) 338 | set_("epoch_implicit", True) 339 | 340 | self._validate_pre(set_) 341 | self._validate_post(set_) 342 | self._validate_dev(set_) 343 | 344 | set_( 345 | "_key", 346 | _cmpkey( 347 | self.epoch, 348 | self.release, 349 | _normalize_pre_tag(self.pre_tag), 350 | self.pre, 351 | self.post, 352 | self.dev, 353 | self.local, 354 | ), 355 | ) 356 | 357 | def _validate_pre(self, set_: Callable[[str, Any], None]) -> None: 358 | if self.pre_tag is None: 359 | if self.pre is not None: 360 | raise ValueError("Must set pre_tag if pre is given.") 361 | 362 | if self.pre_sep1 is not None or self.pre_sep2 is not None: 363 | raise ValueError("Cannot set pre_sep1 or pre_sep2 without pre_tag.") 364 | else: 365 | if self.pre == IMPLICIT_ZERO: 366 | set_("pre", 0) 367 | set_("pre_implicit", True) 368 | elif self.pre is None: 369 | raise ValueError("Must set pre if pre_tag is given.") 370 | 371 | def _validate_post(self, set_: Callable[[str, Any], None]) -> None: 372 | got_post_tag = self.post_tag is not UNSET 373 | got_post = self.post is not None 374 | got_post_sep1 = self.post_sep1 is not UNSET 375 | got_post_sep2 = self.post_sep2 is not UNSET 376 | 377 | # post_tag relies on post 378 | if got_post_tag and not got_post: 379 | raise ValueError("Must set post if post_tag is given.") 380 | 381 | if got_post: 382 | if not got_post_tag: 383 | # user gets the default for post_tag 384 | set_("post_tag", "post") 385 | if self.post == IMPLICIT_ZERO: 386 | set_("post_implicit", True) 387 | set_("post", 0) 388 | 389 | # Validate parameters for implicit post-release (post_tag=None). 390 | # An implicit post-release is e.g. '1-2' (== '1.post2') 391 | if self.post_tag is None: 392 | if self.post_implicit: 393 | raise ValueError( 394 | "Implicit post releases (post_tag=None) require a numerical " 395 | "value for 'post' argument." 396 | ) 397 | 398 | if got_post_sep1 or got_post_sep2: 399 | raise ValueError( 400 | "post_sep1 and post_sep2 cannot be set for implicit post " 401 | "releases (post_tag=None)" 402 | ) 403 | 404 | if self.pre_implicit: 405 | raise ValueError( 406 | "post_tag cannot be None with an implicit pre-release (pre='')." 407 | ) 408 | 409 | set_("post_sep1", "-") 410 | elif self.post_tag is UNSET: 411 | if got_post_sep1 or got_post_sep2: 412 | raise ValueError("Cannot set post_sep1 or post_sep2 without post_tag.") 413 | 414 | set_("post_tag", None) 415 | 416 | if not got_post_sep1 and self.post_sep1 is UNSET: 417 | set_("post_sep1", None if self.post is None else ".") 418 | 419 | if not got_post_sep2: 420 | set_("post_sep2", None) 421 | 422 | assert self.post_sep1 is not UNSET 423 | assert self.post_sep2 is not UNSET 424 | 425 | def _validate_dev(self, set_: Callable[[str, Any], None]) -> None: 426 | if self.dev == IMPLICIT_ZERO: 427 | set_("dev_implicit", True) 428 | set_("dev", 0) 429 | elif self.dev is None: 430 | if self.dev_sep is not UNSET: 431 | raise ValueError("Cannot set dev_sep without dev.") 432 | 433 | if self.dev_sep is UNSET: 434 | set_("dev_sep", None if self.dev is None else ".") 435 | 436 | @classmethod 437 | def parse(cls, version: str, strict: bool = False) -> "Version": 438 | """ 439 | :param version: Version number as defined in `PEP 440`_. 440 | :type version: str 441 | 442 | :param strict: Enable strict parsing of the canonical PEP 440 format. 443 | :type strict: bool 444 | 445 | .. _`PEP 440`: https://www.python.org/dev/peps/pep-0440/ 446 | 447 | :raises ParseError: If version is not valid for the given value of 448 | `strict`. 449 | 450 | .. doctest:: 451 | :options: -IGNORE_EXCEPTION_DETAIL 452 | 453 | >>> Version.parse('1.dev') 454 | 455 | >>> Version.parse('1.dev', strict=True) 456 | Traceback (most recent call last): 457 | ... 458 | parver.ParseError: Expected int at position (1, 6) => '1.dev*'. 459 | """ 460 | segments = parse(version, strict=strict) 461 | 462 | kwargs: Dict[str, Any] = dict() 463 | 464 | for s in segments: 465 | if isinstance(s, segment.Epoch): 466 | kwargs["epoch"] = s.value 467 | elif isinstance(s, segment.Release): 468 | kwargs["release"] = s.value 469 | elif isinstance(s, segment.Pre): 470 | kwargs["pre"] = s.value 471 | kwargs["pre_tag"] = s.tag 472 | kwargs["pre_sep1"] = s.sep1 473 | kwargs["pre_sep2"] = s.sep2 474 | elif isinstance(s, segment.Post): 475 | kwargs["post"] = s.value 476 | kwargs["post_tag"] = s.tag 477 | kwargs["post_sep1"] = s.sep1 478 | kwargs["post_sep2"] = s.sep2 479 | elif isinstance(s, segment.Dev): 480 | kwargs["dev"] = s.value 481 | kwargs["dev_sep"] = s.sep 482 | elif isinstance(s, segment.Local): 483 | kwargs["local"] = s.value 484 | elif isinstance(s, segment.V): 485 | kwargs["v"] = True 486 | else: 487 | raise TypeError(f"Unexpected segment: {segment}") 488 | 489 | return cls(**kwargs) 490 | 491 | def normalize(self) -> "Version": 492 | return Version( 493 | release=self.release, 494 | epoch=IMPLICIT_ZERO if self.epoch == 0 else self.epoch, 495 | pre_tag=_normalize_pre_tag(self.pre_tag), 496 | pre=self.pre, 497 | post=self.post, 498 | dev=self.dev, 499 | local=_normalize_local(self.local), 500 | ) 501 | 502 | def __str__(self) -> str: 503 | parts = [] 504 | 505 | if self.v: 506 | parts.append("v") 507 | 508 | if not self.epoch_implicit: 509 | parts.append(f"{self.epoch}!") 510 | 511 | parts.append(".".join(str(x) for x in self.release)) 512 | 513 | if self.pre_tag is not None: 514 | if self.pre_sep1: 515 | parts.append(self.pre_sep1) 516 | parts.append(self.pre_tag) 517 | if self.pre_sep2: 518 | parts.append(self.pre_sep2) 519 | if not self.pre_implicit: 520 | parts.append(str(self.pre)) 521 | 522 | if self.post_tag is None and self.post is not None: 523 | parts.append(f"-{self.post}") 524 | elif self.post_tag is not None: 525 | if self.post_sep1: 526 | parts.append(self.post_sep1) 527 | parts.append(self.post_tag) 528 | if self.post_sep2: 529 | parts.append(self.post_sep2) 530 | if not self.post_implicit: 531 | parts.append(str(self.post)) 532 | 533 | if self.dev is not None: 534 | if self.dev_sep is not None: 535 | parts.append(self.dev_sep) 536 | parts.append("dev") 537 | if not self.dev_implicit: 538 | parts.append(str(self.dev)) 539 | 540 | if self.local is not None: 541 | parts.append(f"+{self.local}") 542 | 543 | return "".join(parts) 544 | 545 | def __repr__(self) -> str: 546 | return f"<{self.__class__.__name__} {str(self)!r}>" 547 | 548 | def __hash__(self) -> int: 549 | return hash(self._key) 550 | 551 | def __lt__(self, other: Any) -> Any: 552 | return self._compare(other, operator.lt) 553 | 554 | def __le__(self, other: Any) -> Any: 555 | return self._compare(other, operator.le) 556 | 557 | def __eq__(self, other: Any) -> Any: 558 | return self._compare(other, operator.eq) 559 | 560 | def __ge__(self, other: Any) -> Any: 561 | return self._compare(other, operator.ge) 562 | 563 | def __gt__(self, other: Any) -> Any: 564 | return self._compare(other, operator.gt) 565 | 566 | def __ne__(self, other: Any) -> Any: 567 | return self._compare(other, operator.ne) 568 | 569 | def _compare(self, other: Any, method: Callable[[Any, Any], bool]) -> Any: 570 | if not isinstance(other, Version): 571 | return NotImplemented 572 | 573 | return method(self._key, other._key) 574 | 575 | @property 576 | def public(self) -> str: 577 | """A string representing the public version portion of this 578 | :class:`Version` instance. 579 | """ 580 | return str(self).split("+", 1)[0] 581 | 582 | def base_version(self) -> "Version": 583 | """Return a new :class:`Version` instance for the base version of the 584 | current instance. The base version is the public version of the project 585 | without any pre or post release markers. 586 | 587 | See also: :meth:`clear` and :meth:`replace`. 588 | """ 589 | return self.replace(pre=None, post=None, dev=None, local=None) 590 | 591 | @property 592 | def is_prerelease(self) -> bool: 593 | """A boolean value indicating whether this :class:`Version` instance 594 | represents a pre-release and/or development release. 595 | """ 596 | return self.dev is not None or self.pre is not None 597 | 598 | @property 599 | def is_alpha(self) -> bool: 600 | """A boolean value indicating whether this :class:`Version` instance 601 | represents an alpha pre-release. 602 | """ 603 | return _normalize_pre_tag(self.pre_tag) == "a" 604 | 605 | @property 606 | def is_beta(self) -> bool: 607 | """A boolean value indicating whether this :class:`Version` instance 608 | represents a beta pre-release. 609 | """ 610 | return _normalize_pre_tag(self.pre_tag) == "b" 611 | 612 | @property 613 | def is_release_candidate(self) -> bool: 614 | """A boolean value indicating whether this :class:`Version` instance 615 | represents a release candidate pre-release. 616 | """ 617 | return _normalize_pre_tag(self.pre_tag) == "rc" 618 | 619 | @property 620 | def is_postrelease(self) -> bool: 621 | """A boolean value indicating whether this :class:`Version` instance 622 | represents a post-release. 623 | """ 624 | return self.post is not None 625 | 626 | @property 627 | def is_devrelease(self) -> bool: 628 | """A boolean value indicating whether this :class:`Version` instance 629 | represents a development release. 630 | """ 631 | return self.dev is not None 632 | 633 | def _attrs_as_init(self) -> Dict[str, Any]: 634 | d = attr.asdict(self, filter=lambda attr, _: attr.init) 635 | 636 | if self.epoch_implicit: 637 | d["epoch"] = IMPLICIT_ZERO 638 | 639 | if self.pre_implicit: 640 | d["pre"] = IMPLICIT_ZERO 641 | 642 | if self.post_implicit: 643 | d["post"] = IMPLICIT_ZERO 644 | 645 | if self.dev_implicit: 646 | d["dev"] = IMPLICIT_ZERO 647 | 648 | if self.pre is None: 649 | del d["pre"] 650 | del d["pre_tag"] 651 | del d["pre_sep1"] 652 | del d["pre_sep2"] 653 | 654 | if self.post is None: 655 | del d["post"] 656 | del d["post_tag"] 657 | del d["post_sep1"] 658 | del d["post_sep2"] 659 | elif self.post_tag is None: 660 | del d["post_sep1"] 661 | del d["post_sep2"] 662 | 663 | if self.dev is None: 664 | del d["dev"] 665 | del d["dev_sep"] 666 | 667 | return d 668 | 669 | def replace( 670 | self, 671 | release: Union[int, Iterable[int], UnsetType] = UNSET, 672 | v: Union[bool, UnsetType] = UNSET, 673 | epoch: Union[ImplicitZero, int, UnsetType] = UNSET, 674 | pre_tag: Union[PreTag, None, UnsetType] = UNSET, 675 | pre: Union[ImplicitZero, int, None, UnsetType] = UNSET, 676 | post: Union[ImplicitZero, int, None, UnsetType] = UNSET, 677 | dev: Union[ImplicitZero, int, None, UnsetType] = UNSET, 678 | local: Union[str, None, UnsetType] = UNSET, 679 | pre_sep1: Union[Separator, None, UnsetType] = UNSET, 680 | pre_sep2: Union[Separator, None, UnsetType] = UNSET, 681 | post_sep1: Union[Separator, None, UnsetType] = UNSET, 682 | post_sep2: Union[Separator, None, UnsetType] = UNSET, 683 | dev_sep: Union[Separator, None, UnsetType] = UNSET, 684 | post_tag: Union[PostTag, None, UnsetType] = UNSET, 685 | ) -> "Version": 686 | """Return a new :class:`Version` instance with the same attributes, 687 | except for those given as keyword arguments. Arguments have the same 688 | meaning as they do when constructing a new :class:`Version` instance 689 | manually. 690 | """ 691 | kwargs = dict( 692 | release=release, 693 | v=v, 694 | epoch=epoch, 695 | pre_tag=pre_tag, 696 | pre=pre, 697 | post=post, 698 | dev=dev, 699 | local=local, 700 | pre_sep1=pre_sep1, 701 | pre_sep2=pre_sep2, 702 | post_sep1=post_sep1, 703 | post_sep2=post_sep2, 704 | dev_sep=dev_sep, 705 | post_tag=post_tag, 706 | ) 707 | kwargs = {k: v for k, v in kwargs.items() if v is not UNSET} 708 | d = self._attrs_as_init() 709 | 710 | if kwargs.get("post_tag", UNSET) is None: 711 | # ensure we don't carry over separators for new implicit post 712 | # release. By popping from d, there will still be an error if the 713 | # user tries to set them in kwargs 714 | d.pop("post_sep1", None) 715 | d.pop("post_sep2", None) 716 | 717 | if kwargs.get("post", UNSET) is None: 718 | kwargs["post_tag"] = UNSET 719 | d.pop("post_sep1", None) 720 | d.pop("post_sep2", None) 721 | 722 | if kwargs.get("pre", UNSET) is None: 723 | kwargs["pre_tag"] = None 724 | d.pop("pre_sep1", None) 725 | d.pop("pre_sep2", None) 726 | 727 | if kwargs.get("dev", UNSET) is None: 728 | d.pop("dev_sep", None) 729 | 730 | d.update(kwargs) 731 | return Version(**d) 732 | 733 | def _set_release( 734 | self, index: int, value: Optional[int] = None, bump: bool = True 735 | ) -> "Version": 736 | if not isinstance(index, int): 737 | raise TypeError("index must be an integer") 738 | 739 | if index < 0: 740 | raise ValueError("index cannot be negative") 741 | 742 | release = list(self.release) 743 | new_len = index + 1 744 | 745 | if len(release) < new_len: 746 | release.extend(itertools.repeat(0, new_len - len(release))) 747 | 748 | def new_parts(i: int, n: int) -> int: 749 | if i < index: 750 | return n 751 | if i == index: 752 | if value is None: 753 | return n + 1 754 | return value 755 | if bump: 756 | return 0 757 | return n 758 | 759 | new_release = itertools.starmap(new_parts, enumerate(release)) 760 | return self.replace(release=new_release) 761 | 762 | def bump_epoch(self, *, by: int = 1) -> "Version": 763 | """Return a new :class:`Version` instance with the epoch number 764 | bumped. 765 | 766 | :param by: How much to bump the number by. 767 | :type by: int 768 | 769 | :raises TypeError: `by` is not an integer. 770 | 771 | .. doctest:: 772 | 773 | >>> Version.parse('1.4').bump_epoch() 774 | 775 | >>> Version.parse('2!1.4').bump_epoch(by=-1) 776 | 777 | """ 778 | check_by(by, self.epoch) 779 | 780 | epoch = by - 1 if self.epoch is None else self.epoch + by 781 | return self.replace(epoch=epoch) 782 | 783 | def bump_release(self, *, index: int) -> "Version": 784 | """Return a new :class:`Version` instance with the release number 785 | bumped at the given `index`. 786 | 787 | :param index: Index of the release number tuple to bump. It is not 788 | limited to the current size of the tuple. Intermediate indices will 789 | be set to zero. 790 | :type index: int 791 | 792 | :raises TypeError: `index` is not an integer. 793 | :raises ValueError: `index` is negative. 794 | 795 | .. doctest:: 796 | 797 | >>> v = Version.parse('1.4') 798 | >>> v.bump_release(index=0) 799 | 800 | >>> v.bump_release(index=1) 801 | 802 | >>> v.bump_release(index=2) 803 | 804 | >>> v.bump_release(index=3) 805 | 806 | 807 | .. seealso:: 808 | 809 | For more control over the value that is bumped to, see 810 | :meth:`bump_release_to`. 811 | 812 | For fine-grained control, :meth:`set_release` may be used to set 813 | the value at a specific index without setting subsequenct indices 814 | to zero. 815 | """ 816 | return self._set_release(index=index) 817 | 818 | def bump_release_to(self, *, index: int, value: int) -> "Version": 819 | """Return a new :class:`Version` instance with the release number 820 | bumped at the given `index` to `value`. May be used for versioning 821 | schemes such as `CalVer`_. 822 | 823 | .. _`CalVer`: https://calver.org 824 | 825 | :param index: Index of the release number tuple to bump. It is not 826 | limited to the current size of the tuple. Intermediate indices will 827 | be set to zero. 828 | :type index: int 829 | :param value: Value to bump to. This may be any value, but subsequent 830 | indices will be set to zero like a normal version bump. 831 | :type value: int 832 | 833 | :raises TypeError: `index` is not an integer. 834 | :raises ValueError: `index` is negative. 835 | 836 | .. testsetup:: 837 | 838 | import datetime 839 | 840 | .. doctest:: 841 | 842 | >>> v = Version.parse('18.4') 843 | >>> v.bump_release_to(index=0, value=20) 844 | 845 | >>> v.bump_release_to(index=1, value=10) 846 | 847 | 848 | For a project using `CalVer`_ with format ``YYYY.MM.MICRO``, this 849 | method could be used to set the date parts: 850 | 851 | .. doctest:: 852 | 853 | >>> v = Version.parse('2018.4.1') 854 | >>> v = v.bump_release_to(index=0, value=2018) 855 | >>> v = v.bump_release_to(index=1, value=10) 856 | >>> v 857 | 858 | 859 | .. seealso:: 860 | 861 | For typical use cases, see :meth:`bump_release`. 862 | 863 | For fine-grained control, :meth:`set_release` may be used to set 864 | the value at a specific index without setting subsequenct indices 865 | to zero. 866 | """ 867 | return self._set_release(index=index, value=value) 868 | 869 | def set_release(self, *, index: int, value: int) -> "Version": 870 | """Return a new :class:`Version` instance with the release number 871 | at the given `index` set to `value`. 872 | 873 | :param index: Index of the release number tuple to set. It is not 874 | limited to the current size of the tuple. Intermediate indices will 875 | be set to zero. 876 | :type index: int 877 | :param value: Value to set. 878 | :type value: int 879 | 880 | :raises TypeError: `index` is not an integer. 881 | :raises ValueError: `index` is negative. 882 | 883 | .. doctest:: 884 | 885 | >>> v = Version.parse('1.2.3') 886 | >>> v.set_release(index=0, value=3) 887 | 888 | >>> v.set_release(index=1, value=4) 889 | 890 | 891 | .. seealso:: 892 | 893 | For typical use cases, see :meth:`bump_release`. 894 | """ 895 | return self._set_release(index=index, value=value, bump=False) 896 | 897 | def bump_pre(self, tag: Optional[PreTag] = None, *, by: int = 1) -> "Version": 898 | """Return a new :class:`Version` instance with the pre-release number 899 | bumped. 900 | 901 | :param tag: Pre-release tag. Required if not already set. 902 | :type tag: str 903 | :param by: How much to bump the number by. 904 | :type by: int 905 | 906 | :raises ValueError: Trying to call ``bump_pre(tag=None)`` on a 907 | :class:`Version` instance that is not already a pre-release. 908 | :raises ValueError: Calling the method with a `tag` not equal to the 909 | current :attr:`post_tag`. See :meth:`replace` instead. 910 | :raises TypeError: `by` is not an integer. 911 | 912 | .. doctest:: 913 | 914 | >>> Version.parse('1.4').bump_pre('a') 915 | 916 | >>> Version.parse('1.4b1').bump_pre() 917 | 918 | >>> Version.parse('1.4b1').bump_pre(by=-1) 919 | 920 | """ 921 | check_by(by, self.pre) 922 | 923 | pre = by - 1 if self.pre is None else self.pre + by 924 | 925 | if self.pre_tag is None: 926 | if tag is None: 927 | raise ValueError("Cannot bump without pre_tag. Use .bump_pre('')") 928 | else: 929 | # This is an error because different tags have different meanings 930 | if tag is not None and self.pre_tag != tag: 931 | raise ValueError( 932 | "Cannot bump with pre_tag mismatch ({0} != {1}). Use " 933 | ".replace(pre_tag={1!r})".format(self.pre_tag, tag) 934 | ) 935 | tag = self.pre_tag 936 | 937 | return self.replace(pre=pre, pre_tag=tag) 938 | 939 | @overload 940 | def bump_post(self, tag: Optional[PostTag], *, by: int = 1) -> "Version": 941 | pass 942 | 943 | @overload 944 | def bump_post(self, *, by: int = 1) -> "Version": 945 | pass 946 | 947 | def bump_post( 948 | self, tag: Union[PostTag, None, UnsetType] = UNSET, *, by: int = 1 949 | ) -> "Version": 950 | """Return a new :class:`Version` instance with the post release number 951 | bumped. 952 | 953 | :param tag: Post release tag. Will preserve the current tag by default, 954 | or use `post` if the instance is not already a post release. 955 | :type tag: str 956 | :param by: How much to bump the number by. 957 | :type by: int 958 | 959 | :raises TypeError: `by` is not an integer. 960 | 961 | .. doctest:: 962 | 963 | >>> Version.parse('1.4').bump_post() 964 | 965 | >>> Version.parse('1.4.post0').bump_post(tag=None) 966 | 967 | >>> Version.parse('1.4_post-1').bump_post(tag='rev') 968 | 969 | >>> Version.parse('1.4.post2').bump_post(by=-1) 970 | 971 | """ 972 | check_by(by, self.post) 973 | 974 | post = by - 1 if self.post is None else self.post + by 975 | if tag is UNSET and self.post is not None: 976 | tag = self.post_tag 977 | return self.replace(post=post, post_tag=tag) 978 | 979 | def bump_dev(self, *, by: int = 1) -> "Version": 980 | """Return a new :class:`Version` instance with the development release 981 | number bumped. 982 | 983 | :param by: How much to bump the number by. 984 | :type by: int 985 | 986 | :raises TypeError: `by` is not an integer. 987 | 988 | .. doctest:: 989 | 990 | >>> Version.parse('1.4').bump_dev() 991 | 992 | >>> Version.parse('1.4_dev1').bump_dev() 993 | 994 | >>> Version.parse('1.4.dev3').bump_dev(by=-1) 995 | 996 | """ 997 | check_by(by, self.dev) 998 | 999 | dev = by - 1 if self.dev is None else self.dev + by 1000 | return self.replace(dev=dev) 1001 | 1002 | def truncate(self, *, min_length: int = 1) -> "Version": 1003 | """Return a new :class:`Version` instance with trailing zeros removed 1004 | from the release segment. 1005 | 1006 | :param min_length: Minimum number of parts to keep. 1007 | :type min_length: int 1008 | 1009 | .. doctest:: 1010 | 1011 | >>> Version.parse('0.1.0').truncate() 1012 | 1013 | >>> Version.parse('1.0.0').truncate(min_length=2) 1014 | 1015 | >>> Version.parse('1').truncate(min_length=2) 1016 | 1017 | """ 1018 | if not isinstance(min_length, int): 1019 | raise TypeError("min_length must be an integer") 1020 | 1021 | if min_length < 1: 1022 | raise ValueError("min_length must be positive") 1023 | 1024 | release = list(self.release) 1025 | if len(release) < min_length: 1026 | release.extend(itertools.repeat(0, min_length - len(release))) 1027 | 1028 | last_nonzero = max( 1029 | last((i for i, n in enumerate(release) if n), default=0), 1030 | min_length - 1, 1031 | ) 1032 | return self.replace(release=release[: last_nonzero + 1]) 1033 | 1034 | 1035 | def _normalize_pre_tag(pre_tag: Optional[PreTag]) -> Optional[NormalizedPreTag]: 1036 | if pre_tag is None: 1037 | return None 1038 | 1039 | if pre_tag == "alpha": 1040 | pre_tag = "a" 1041 | elif pre_tag == "beta": 1042 | pre_tag = "b" 1043 | elif pre_tag in {"c", "pre", "preview"}: 1044 | pre_tag = "rc" 1045 | 1046 | return cast(NormalizedPreTag, pre_tag) 1047 | 1048 | 1049 | def _normalize_local(local: Optional[str]) -> Optional[str]: 1050 | if local is None: 1051 | return None 1052 | 1053 | return ".".join(map(str, _parse_local_version(local))) 1054 | 1055 | 1056 | def _cmpkey( 1057 | epoch: int, 1058 | release: Tuple[int, ...], 1059 | pre_tag: Optional[NormalizedPreTag], 1060 | pre_num: Optional[int], 1061 | post: Optional[int], 1062 | dev: Optional[int], 1063 | local: Optional[str], 1064 | ) -> Any: 1065 | # When we compare a release version, we want to compare it with all of the 1066 | # trailing zeros removed. So we'll use a reverse the list, drop all the now 1067 | # leading zeros until we come to something non zero, then take the rest 1068 | # re-reverse it back into the correct order and make it a tuple and use 1069 | # that for our sorting key. 1070 | release = tuple( 1071 | reversed( 1072 | list( 1073 | itertools.dropwhile( 1074 | lambda x: x == 0, 1075 | reversed(release), 1076 | ) 1077 | ) 1078 | ) 1079 | ) 1080 | 1081 | pre = pre_tag, pre_num 1082 | 1083 | # We need to "trick" the sorting algorithm to put 1.0.dev0 before 1.0a0. 1084 | # We'll do this by abusing the pre segment, but we _only_ want to do this 1085 | # if there is not a pre or a post segment. If we have one of those then 1086 | # the normal sorting rules will handle this case correctly. 1087 | if pre_num is None and post is None and dev is not None: 1088 | pre = -Infinity # type: ignore[assignment] 1089 | # Versions without a pre-release (except as noted above) should sort after 1090 | # those with one. 1091 | elif pre_num is None: 1092 | pre = Infinity # type: ignore[assignment] 1093 | 1094 | # Versions without a post segment should sort before those with one. 1095 | if post is None: 1096 | post = -Infinity # type: ignore[assignment] 1097 | 1098 | # Versions without a development segment should sort after those with one. 1099 | if dev is None: 1100 | dev = Infinity # type: ignore[assignment] 1101 | 1102 | if local is None: 1103 | # Versions without a local segment should sort before those with one. 1104 | local = -Infinity # type: ignore[assignment] 1105 | else: 1106 | # Versions with a local segment need that segment parsed to implement 1107 | # the sorting rules in PEP440. 1108 | # - Alpha numeric segments sort before numeric segments 1109 | # - Alpha numeric segments sort lexicographically 1110 | # - Numeric segments sort numerically 1111 | # - Shorter versions sort before longer versions when the prefixes 1112 | # match exactly 1113 | local = tuple( # type: ignore[assignment] 1114 | (i, "") if isinstance(i, int) else (-Infinity, i) 1115 | for i in _parse_local_version(local) 1116 | ) 1117 | 1118 | return epoch, release, pre, post, dev, local 1119 | 1120 | 1121 | _local_version_separators = re.compile(r"[._-]") 1122 | 1123 | 1124 | @overload 1125 | def _parse_local_version(local: str) -> Tuple[Union[str, int], ...]: 1126 | pass 1127 | 1128 | 1129 | @overload 1130 | def _parse_local_version(local: None) -> None: 1131 | pass 1132 | 1133 | 1134 | def _parse_local_version(local: Optional[str]) -> Optional[Tuple[Union[str, int], ...]]: 1135 | """ 1136 | Takes a string like abc.1.twelve and turns it into ("abc", 1, "twelve"). 1137 | """ 1138 | if local is not None: 1139 | return tuple( 1140 | part.lower() if not part.isdigit() else int(part) 1141 | for part in _local_version_separators.split(local) 1142 | ) 1143 | 1144 | return None 1145 | -------------------------------------------------------------------------------- /src/parver/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RazerM/parver/1c81df9944f8161c766ffb710f9abc78310775d2/src/parver/py.typed -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RazerM/parver/1c81df9944f8161c766ffb710f9abc78310775d2/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from hypothesis import Verbosity, settings 4 | 5 | settings.register_profile("ci", max_examples=1000) 6 | settings.register_profile("debug", verbosity=Verbosity.verbose) 7 | settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "default")) 8 | -------------------------------------------------------------------------------- /tests/strategies.py: -------------------------------------------------------------------------------- 1 | import string 2 | 3 | from hypothesis.strategies import ( 4 | composite, 5 | integers, 6 | just, 7 | lists, 8 | one_of, 9 | sampled_from, 10 | text, 11 | ) 12 | 13 | from parver import Version 14 | 15 | num_int = integers(min_value=0) 16 | num_str = num_int.map(str) 17 | 18 | 19 | def epoch(): 20 | epoch = num_str.map(lambda s: s + "!") 21 | return one_of(just(""), epoch) 22 | 23 | 24 | @composite 25 | def release(draw): 26 | return draw( 27 | num_str.map(lambda s: [s] + draw(lists(num_str.map(lambda s: "." + s)))).map( 28 | lambda parts: "".join(parts) 29 | ) 30 | ) 31 | 32 | 33 | def separator(strict=False, optional=False): 34 | sep = ["."] 35 | 36 | if optional: 37 | sep.append("") 38 | 39 | if not strict: 40 | sep.extend(["-", "_"]) 41 | 42 | return sampled_from(sep) 43 | 44 | 45 | @composite 46 | def pre(draw, strict=False): 47 | words = ["a", "b", "rc"] 48 | if not strict: 49 | words.extend(["c", "alpha", "beta", "pre", "preview"]) 50 | 51 | blank = just("") 52 | 53 | sep1 = separator(strict=strict, optional=True) 54 | if strict: 55 | sep1 = blank 56 | 57 | word = sampled_from(words) 58 | 59 | if strict: 60 | sep2 = blank 61 | else: 62 | sep2 = separator(strict=strict, optional=True) 63 | 64 | num_part = sep2.map(lambda s: s + draw(num_str)) 65 | if not strict: 66 | num_part = one_of(blank, num_part) 67 | 68 | nonempty = sep1.map(lambda s: s + draw(word) + draw(num_part)) 69 | 70 | return draw(one_of(blank, nonempty)) 71 | 72 | 73 | @composite 74 | def post(draw, strict=False): 75 | words = ["post"] 76 | if not strict: 77 | words.extend(["r", "rev"]) 78 | 79 | sep1 = separator(strict=strict, optional=not strict) 80 | word = sampled_from(words) 81 | 82 | blank = just("") 83 | 84 | sep2 = separator(strict=strict, optional=True) 85 | if strict: 86 | sep2 = blank 87 | 88 | num_part = sep2.map(lambda s: s + draw(num_str)) 89 | if not strict: 90 | num_part = one_of(blank, num_part) 91 | 92 | post = sep1.map(lambda s: s + draw(word) + draw(num_part)) 93 | 94 | if strict: 95 | return draw(post) 96 | 97 | post_implicit = num_str.map(lambda s: "-" + s) 98 | 99 | return draw(one_of(blank, post_implicit, post)) 100 | 101 | 102 | @composite 103 | def dev(draw, strict=False): 104 | sep = separator(strict=strict, optional=not strict) 105 | 106 | blank = just("") 107 | 108 | num_part = num_str 109 | if not strict: 110 | num_part = one_of(blank, num_part) 111 | 112 | return draw(one_of(blank, sep.map(lambda s: s + "dev" + draw(num_part)))) 113 | 114 | 115 | @composite 116 | def local_segment(draw): 117 | alpha = ( 118 | draw(one_of(just(""), integers(0, 9).map(str))) 119 | + draw(text(string.ascii_lowercase, min_size=1, max_size=1)) 120 | + draw(text(string.ascii_lowercase + string.digits)) 121 | ) 122 | return draw(one_of(num_str, just(alpha))) 123 | 124 | 125 | @composite 126 | def local(draw, strict=False): 127 | if strict: 128 | sep = just(".") 129 | else: 130 | sep = sampled_from("-_.") 131 | 132 | part = local_segment() 133 | sep_part = sep.map(lambda s: s + draw(local_segment())) 134 | sep_parts = lists(sep_part).map(lambda parts: "".join(parts)) 135 | 136 | return draw(one_of(just(""), part.map(lambda s: "+" + s + draw(sep_parts)))) 137 | 138 | 139 | whitespace = sampled_from(["", "\t", "\n", "\r", "\f", "\v"]) 140 | 141 | 142 | def vchar(strict=False): 143 | if strict: 144 | return just("") 145 | return sampled_from(["", "v"]) 146 | 147 | 148 | @composite 149 | def version_string(draw, strict=False): 150 | return ( 151 | draw(vchar(strict=strict)) 152 | + draw(epoch()) 153 | + draw(release()) 154 | + draw(pre(strict=strict)) 155 | + draw(post(strict=strict)) 156 | + draw(dev(strict=strict)) 157 | + draw(local(strict=strict)) 158 | ) 159 | 160 | 161 | @composite 162 | def version_strategy(draw, strict=False): 163 | return Version.parse(draw(version_string(strict=strict))) 164 | -------------------------------------------------------------------------------- /tests/test_packaging.py: -------------------------------------------------------------------------------- 1 | """The tests in this file have been adapted from the excellent test suite in 2 | packaging.version. See https://github.com/pypa/packaging 3 | 4 | Copyright (c) Donald Stufft and individual contributors. 5 | https://github.com/pypa/packaging/blob/master/LICENSE 6 | """ 7 | 8 | import itertools 9 | import operator 10 | 11 | import pretend 12 | import pytest 13 | 14 | from parver import ParseError, Version 15 | 16 | # This list must be in the correct sorting order 17 | VERSIONS = [ 18 | # Implicit epoch of 0 19 | "1.0.dev456", 20 | "1.0a1", 21 | "1.0a2.dev456", 22 | "1.0a12.dev456", 23 | "1.0a12", 24 | "1.0b1.dev456", 25 | "1.0b2", 26 | "1.0b2.post345.dev456", 27 | "1.0b2.post345", 28 | "1.0b2-346", 29 | "1.0c1.dev456", 30 | "1.0c1", 31 | "1.0rc2", 32 | "1.0c3", 33 | "1.0", 34 | "1.0.post456.dev34", 35 | "1.0.post456", 36 | "1.1.dev1", 37 | "1.2+123abc", 38 | "1.2+123abc456", 39 | "1.2+abc", 40 | "1.2+abc123", 41 | "1.2+abc123def", 42 | "1.2+1234.abc", 43 | "1.2+123456", 44 | "1.2.r32+123456", 45 | "1.2.rev33+123456", 46 | # Explicit epoch of 1 47 | "1!1.0.dev456", 48 | "1!1.0a1", 49 | "1!1.0a2.dev456", 50 | "1!1.0a12.dev456", 51 | "1!1.0a12", 52 | "1!1.0b1.dev456", 53 | "1!1.0b2", 54 | "1!1.0b2.post345.dev456", 55 | "1!1.0b2.post345", 56 | "1!1.0b2-346", 57 | "1!1.0c1.dev456", 58 | "1!1.0c1", 59 | "1!1.0rc2", 60 | "1!1.0c3", 61 | "1!1.0", 62 | "1!1.0.post456.dev34", 63 | "1!1.0.post456", 64 | "1!1.1.dev1", 65 | "1!1.2+123abc", 66 | "1!1.2+123abc456", 67 | "1!1.2+abc", 68 | "1!1.2+abc123", 69 | "1!1.2+abc123def", 70 | "1!1.2+1234.abc", 71 | "1!1.2+123456", 72 | "1!1.2.r32+123456", 73 | "1!1.2.rev33+123456", 74 | ] 75 | 76 | # Don't want an exception here if VERSIONS cannot be parsed, 77 | # that's what test_valid_versions is for. 78 | try: 79 | PARSED_VERSIONS = [Version.parse(v) for v in VERSIONS] 80 | except ParseError: # pragma: no cover 81 | PARSED_VERSIONS = [] 82 | 83 | 84 | class TestVersion: 85 | @pytest.mark.parametrize("version", VERSIONS) 86 | def test_valid_versions(self, version): 87 | assert str(Version.parse(version)) == version 88 | 89 | @pytest.mark.parametrize( 90 | "version", 91 | [ 92 | # Non sensical versions should be invalid 93 | "french toast", 94 | # Versions with invalid local versions 95 | "1.0+a+", 96 | "1.0++", 97 | "1.0+_foobar", 98 | "1.0+foo&asd", 99 | "1.0+1+1", 100 | ], 101 | ) 102 | def test_invalid_versions(self, version): 103 | with pytest.raises(ParseError): 104 | Version.parse(version) 105 | 106 | @pytest.mark.parametrize( 107 | ("version", "normalized"), 108 | [ 109 | # Various development release incarnations 110 | ("1.0dev", "1.0.dev0"), 111 | ("1.0.dev", "1.0.dev0"), 112 | ("1.0dev1", "1.0.dev1"), 113 | ("1.0dev", "1.0.dev0"), 114 | ("1.0-dev", "1.0.dev0"), 115 | ("1.0-dev1", "1.0.dev1"), 116 | ("1.0DEV", "1.0.dev0"), 117 | ("1.0.DEV", "1.0.dev0"), 118 | ("1.0DEV1", "1.0.dev1"), 119 | ("1.0DEV", "1.0.dev0"), 120 | ("1.0.DEV1", "1.0.dev1"), 121 | ("1.0-DEV", "1.0.dev0"), 122 | ("1.0-DEV1", "1.0.dev1"), 123 | # Various alpha incarnations 124 | ("1.0a", "1.0a0"), 125 | ("1.0.a", "1.0a0"), 126 | ("1.0.a1", "1.0a1"), 127 | ("1.0-a", "1.0a0"), 128 | ("1.0-a1", "1.0a1"), 129 | ("1.0alpha", "1.0a0"), 130 | ("1.0.alpha", "1.0a0"), 131 | ("1.0.alpha1", "1.0a1"), 132 | ("1.0-alpha", "1.0a0"), 133 | ("1.0-alpha1", "1.0a1"), 134 | ("1.0A", "1.0a0"), 135 | ("1.0.A", "1.0a0"), 136 | ("1.0.A1", "1.0a1"), 137 | ("1.0-A", "1.0a0"), 138 | ("1.0-A1", "1.0a1"), 139 | ("1.0ALPHA", "1.0a0"), 140 | ("1.0.ALPHA", "1.0a0"), 141 | ("1.0.ALPHA1", "1.0a1"), 142 | ("1.0-ALPHA", "1.0a0"), 143 | ("1.0-ALPHA1", "1.0a1"), 144 | # Various beta incarnations 145 | ("1.0b", "1.0b0"), 146 | ("1.0.b", "1.0b0"), 147 | ("1.0.b1", "1.0b1"), 148 | ("1.0-b", "1.0b0"), 149 | ("1.0-b1", "1.0b1"), 150 | ("1.0beta", "1.0b0"), 151 | ("1.0.beta", "1.0b0"), 152 | ("1.0.beta1", "1.0b1"), 153 | ("1.0-beta", "1.0b0"), 154 | ("1.0-beta1", "1.0b1"), 155 | ("1.0B", "1.0b0"), 156 | ("1.0.B", "1.0b0"), 157 | ("1.0.B1", "1.0b1"), 158 | ("1.0-B", "1.0b0"), 159 | ("1.0-B1", "1.0b1"), 160 | ("1.0BETA", "1.0b0"), 161 | ("1.0.BETA", "1.0b0"), 162 | ("1.0.BETA1", "1.0b1"), 163 | ("1.0-BETA", "1.0b0"), 164 | ("1.0-BETA1", "1.0b1"), 165 | # Various release candidate incarnations 166 | ("1.0c", "1.0rc0"), 167 | ("1.0.c", "1.0rc0"), 168 | ("1.0.c1", "1.0rc1"), 169 | ("1.0-c", "1.0rc0"), 170 | ("1.0-c1", "1.0rc1"), 171 | ("1.0rc", "1.0rc0"), 172 | ("1.0.rc", "1.0rc0"), 173 | ("1.0.rc1", "1.0rc1"), 174 | ("1.0-rc", "1.0rc0"), 175 | ("1.0-rc1", "1.0rc1"), 176 | ("1.0C", "1.0rc0"), 177 | ("1.0.C", "1.0rc0"), 178 | ("1.0.C1", "1.0rc1"), 179 | ("1.0-C", "1.0rc0"), 180 | ("1.0-C1", "1.0rc1"), 181 | ("1.0RC", "1.0rc0"), 182 | ("1.0.RC", "1.0rc0"), 183 | ("1.0.RC1", "1.0rc1"), 184 | ("1.0-RC", "1.0rc0"), 185 | ("1.0-RC1", "1.0rc1"), 186 | # Various post release incarnations 187 | ("1.0post", "1.0.post0"), 188 | ("1.0.post", "1.0.post0"), 189 | ("1.0post1", "1.0.post1"), 190 | ("1.0post", "1.0.post0"), 191 | ("1.0-post", "1.0.post0"), 192 | ("1.0-post1", "1.0.post1"), 193 | ("1.0POST", "1.0.post0"), 194 | ("1.0.POST", "1.0.post0"), 195 | ("1.0POST1", "1.0.post1"), 196 | ("1.0POST", "1.0.post0"), 197 | ("1.0r", "1.0.post0"), 198 | ("1.0rev", "1.0.post0"), 199 | ("1.0.POST1", "1.0.post1"), 200 | ("1.0.r1", "1.0.post1"), 201 | ("1.0.rev1", "1.0.post1"), 202 | ("1.0-POST", "1.0.post0"), 203 | ("1.0-POST1", "1.0.post1"), 204 | ("1.0-5", "1.0.post5"), 205 | ("1.0-r5", "1.0.post5"), 206 | ("1.0-rev5", "1.0.post5"), 207 | # Local version case insensitivity 208 | ("1.0+AbC", "1.0+abc"), 209 | # Integer Normalization 210 | ("1.01", "1.1"), 211 | ("1.0a05", "1.0a5"), 212 | ("1.0b07", "1.0b7"), 213 | ("1.0c056", "1.0rc56"), 214 | ("1.0rc09", "1.0rc9"), 215 | ("1.0.post000", "1.0.post0"), 216 | ("1.1.dev09000", "1.1.dev9000"), 217 | ("00!1.2", "1.2"), 218 | ("0100!0.0", "100!0.0"), 219 | # Various other normalizations 220 | ("v1.0", "1.0"), 221 | (" v1.0\t\n", "1.0"), 222 | ], 223 | ) 224 | def test_normalized_versions(self, version, normalized): 225 | assert str(Version.parse(version).normalize()) == normalized 226 | 227 | @pytest.mark.parametrize( 228 | ("version", "expected"), 229 | [ 230 | ("1.0.dev456", "1.0.dev456"), 231 | ("1.0a1", "1.0a1"), 232 | ("1.0a2.dev456", "1.0a2.dev456"), 233 | ("1.0a12.dev456", "1.0a12.dev456"), 234 | ("1.0a12", "1.0a12"), 235 | ("1.0b1.dev456", "1.0b1.dev456"), 236 | ("1.0b2", "1.0b2"), 237 | ("1.0b2.post345.dev456", "1.0b2.post345.dev456"), 238 | ("1.0b2.post345", "1.0b2.post345"), 239 | ("1.0rc1.dev456", "1.0rc1.dev456"), 240 | ("1.0rc1", "1.0rc1"), 241 | ("1.0", "1.0"), 242 | ("1.0.post456.dev34", "1.0.post456.dev34"), 243 | ("1.0.post456", "1.0.post456"), 244 | ("1.0.1", "1.0.1"), 245 | ("0!1.0.2", "1.0.2"), 246 | ("1.0.3+7", "1.0.3+7"), 247 | ("0!1.0.4+8.0", "1.0.4+8.0"), 248 | ("1.0.5+9.5", "1.0.5+9.5"), 249 | ("1.2+1234.abc", "1.2+1234.abc"), 250 | ("1.2+123456", "1.2+123456"), 251 | ("1.2+123abc", "1.2+123abc"), 252 | ("1.2+123abc456", "1.2+123abc456"), 253 | ("1.2+abc", "1.2+abc"), 254 | ("1.2+abc123", "1.2+abc123"), 255 | ("1.2+abc123def", "1.2+abc123def"), 256 | ("1.1.dev1", "1.1.dev1"), 257 | ("7!1.0.dev456", "7!1.0.dev456"), 258 | ("7!1.0a1", "7!1.0a1"), 259 | ("7!1.0a2.dev456", "7!1.0a2.dev456"), 260 | ("7!1.0a12.dev456", "7!1.0a12.dev456"), 261 | ("7!1.0a12", "7!1.0a12"), 262 | ("7!1.0b1.dev456", "7!1.0b1.dev456"), 263 | ("7!1.0b2", "7!1.0b2"), 264 | ("7!1.0b2.post345.dev456", "7!1.0b2.post345.dev456"), 265 | ("7!1.0b2.post345", "7!1.0b2.post345"), 266 | ("7!1.0rc1.dev456", "7!1.0rc1.dev456"), 267 | ("7!1.0rc1", "7!1.0rc1"), 268 | ("7!1.0", "7!1.0"), 269 | ("7!1.0.post456.dev34", "7!1.0.post456.dev34"), 270 | ("7!1.0.post456", "7!1.0.post456"), 271 | ("7!1.0.1", "7!1.0.1"), 272 | ("7!1.0.2", "7!1.0.2"), 273 | ("7!1.0.3+7", "7!1.0.3+7"), 274 | ("7!1.0.4+8.0", "7!1.0.4+8.0"), 275 | ("7!1.0.5+9.5", "7!1.0.5+9.5"), 276 | ("7!1.1.dev1", "7!1.1.dev1"), 277 | ("1+2_3-Four", "1+2.3.four"), 278 | ], 279 | ) 280 | def test_version_str_repr(self, version, expected): 281 | v = Version.parse(version).normalize() 282 | assert str(v) == expected 283 | assert repr(v) == f"" 284 | 285 | def test_version_rc_and_c_equals(self): 286 | assert Version.parse("1.0rc1") == Version.parse("1.0c1") 287 | 288 | @pytest.mark.parametrize("version", PARSED_VERSIONS) 289 | def test_version_hash(self, version): 290 | assert hash(version) == hash(version) 291 | 292 | @pytest.mark.parametrize( 293 | ("version", "public"), 294 | [ 295 | ("1.0", "1.0"), 296 | ("1.0.dev0", "1.0.dev0"), 297 | ("1.0.dev6", "1.0.dev6"), 298 | ("1.0a1", "1.0a1"), 299 | ("1.0a1.post5", "1.0a1.post5"), 300 | ("1.0a1.post5.dev6", "1.0a1.post5.dev6"), 301 | ("1.0rc4", "1.0rc4"), 302 | ("1.0.post5", "1.0.post5"), 303 | ("1!1.0", "1!1.0"), 304 | ("1!1.0.dev6", "1!1.0.dev6"), 305 | ("1!1.0a1", "1!1.0a1"), 306 | ("1!1.0a1.post5", "1!1.0a1.post5"), 307 | ("1!1.0a1.post5.dev6", "1!1.0a1.post5.dev6"), 308 | ("1!1.0rc4", "1!1.0rc4"), 309 | ("1!1.0.post5", "1!1.0.post5"), 310 | ("1.0+deadbeef", "1.0"), 311 | ("1.0.dev6+deadbeef", "1.0.dev6"), 312 | ("1.0a1+deadbeef", "1.0a1"), 313 | ("1.0a1.post5+deadbeef", "1.0a1.post5"), 314 | ("1.0a1.post5.dev6+deadbeef", "1.0a1.post5.dev6"), 315 | ("1.0rc4+deadbeef", "1.0rc4"), 316 | ("1.0.post5+deadbeef", "1.0.post5"), 317 | ("1!1.0+deadbeef", "1!1.0"), 318 | ("1!1.0.dev6+deadbeef", "1!1.0.dev6"), 319 | ("1!1.0a1+deadbeef", "1!1.0a1"), 320 | ("1!1.0a1.post5+deadbeef", "1!1.0a1.post5"), 321 | ("1!1.0a1.post5.dev6+deadbeef", "1!1.0a1.post5.dev6"), 322 | ("1!1.0rc4+deadbeef", "1!1.0rc4"), 323 | ("1!1.0.post5+deadbeef", "1!1.0.post5"), 324 | ], 325 | ) 326 | def test_version_public(self, version, public): 327 | assert Version.parse(version).public == public 328 | 329 | @pytest.mark.parametrize( 330 | ("version", "base_version"), 331 | [ 332 | ("1.0", "1.0"), 333 | ("1.0.dev0", "1.0"), 334 | ("1.0.dev6", "1.0"), 335 | ("1.0a1", "1.0"), 336 | ("1.0a1.post5", "1.0"), 337 | ("1.0a1.post5.dev6", "1.0"), 338 | ("1.0rc4", "1.0"), 339 | ("1.0.post5", "1.0"), 340 | ("1!1.0", "1!1.0"), 341 | ("1!1.0.dev6", "1!1.0"), 342 | ("1!1.0a1", "1!1.0"), 343 | ("1!1.0a1.post5", "1!1.0"), 344 | ("1!1.0a1.post5.dev6", "1!1.0"), 345 | ("1!1.0rc4", "1!1.0"), 346 | ("1!1.0.post5", "1!1.0"), 347 | ("1.0+deadbeef", "1.0"), 348 | ("1.0.dev6+deadbeef", "1.0"), 349 | ("1.0a1+deadbeef", "1.0"), 350 | ("1.0a1.post5+deadbeef", "1.0"), 351 | ("1.0a1.post5.dev6+deadbeef", "1.0"), 352 | ("1.0rc4+deadbeef", "1.0"), 353 | ("1.0.post5+deadbeef", "1.0"), 354 | ("1!1.0+deadbeef", "1!1.0"), 355 | ("1!1.0.dev6+deadbeef", "1!1.0"), 356 | ("1!1.0a1+deadbeef", "1!1.0"), 357 | ("1!1.0a1.post5+deadbeef", "1!1.0"), 358 | ("1!1.0a1.post5.dev6+deadbeef", "1!1.0"), 359 | ("1!1.0rc4+deadbeef", "1!1.0"), 360 | ("1!1.0.post5+deadbeef", "1!1.0"), 361 | ], 362 | ) 363 | def test_version_base_version(self, version, base_version): 364 | assert str(Version.parse(version).base_version()) == base_version 365 | 366 | @pytest.mark.parametrize( 367 | ("version", "epoch"), 368 | [ 369 | ("1.0", 0), 370 | ("1.0.dev0", 0), 371 | ("1.0.dev6", 0), 372 | ("1.0a1", 0), 373 | ("1.0a1.post5", 0), 374 | ("1.0a1.post5.dev6", 0), 375 | ("1.0rc4", 0), 376 | ("1.0.post5", 0), 377 | ("1!1.0", 1), 378 | ("1!1.0.dev6", 1), 379 | ("1!1.0a1", 1), 380 | ("1!1.0a1.post5", 1), 381 | ("1!1.0a1.post5.dev6", 1), 382 | ("1!1.0rc4", 1), 383 | ("1!1.0.post5", 1), 384 | ("1.0+deadbeef", 0), 385 | ("1.0.dev6+deadbeef", 0), 386 | ("1.0a1+deadbeef", 0), 387 | ("1.0a1.post5+deadbeef", 0), 388 | ("1.0a1.post5.dev6+deadbeef", 0), 389 | ("1.0rc4+deadbeef", 0), 390 | ("1.0.post5+deadbeef", 0), 391 | ("1!1.0+deadbeef", 1), 392 | ("1!1.0.dev6+deadbeef", 1), 393 | ("1!1.0a1+deadbeef", 1), 394 | ("1!1.0a1.post5+deadbeef", 1), 395 | ("1!1.0a1.post5.dev6+deadbeef", 1), 396 | ("1!1.0rc4+deadbeef", 1), 397 | ("1!1.0.post5+deadbeef", 1), 398 | ], 399 | ) 400 | def test_version_epoch(self, version, epoch): 401 | assert Version.parse(version).epoch == epoch 402 | 403 | @pytest.mark.parametrize( 404 | ("version", "release"), 405 | [ 406 | ("1.0", (1, 0)), 407 | ("1.0.dev0", (1, 0)), 408 | ("1.0.dev6", (1, 0)), 409 | ("1.0a1", (1, 0)), 410 | ("1.0a1.post5", (1, 0)), 411 | ("1.0a1.post5.dev6", (1, 0)), 412 | ("1.0rc4", (1, 0)), 413 | ("1.0.post5", (1, 0)), 414 | ("1!1.0", (1, 0)), 415 | ("1!1.0.dev6", (1, 0)), 416 | ("1!1.0a1", (1, 0)), 417 | ("1!1.0a1.post5", (1, 0)), 418 | ("1!1.0a1.post5.dev6", (1, 0)), 419 | ("1!1.0rc4", (1, 0)), 420 | ("1!1.0.post5", (1, 0)), 421 | ("1.0+deadbeef", (1, 0)), 422 | ("1.0.dev6+deadbeef", (1, 0)), 423 | ("1.0a1+deadbeef", (1, 0)), 424 | ("1.0a1.post5+deadbeef", (1, 0)), 425 | ("1.0a1.post5.dev6+deadbeef", (1, 0)), 426 | ("1.0rc4+deadbeef", (1, 0)), 427 | ("1.0.post5+deadbeef", (1, 0)), 428 | ("1!1.0+deadbeef", (1, 0)), 429 | ("1!1.0.dev6+deadbeef", (1, 0)), 430 | ("1!1.0a1+deadbeef", (1, 0)), 431 | ("1!1.0a1.post5+deadbeef", (1, 0)), 432 | ("1!1.0a1.post5.dev6+deadbeef", (1, 0)), 433 | ("1!1.0rc4+deadbeef", (1, 0)), 434 | ("1!1.0.post5+deadbeef", (1, 0)), 435 | ], 436 | ) 437 | def test_version_release(self, version, release): 438 | assert Version.parse(version).release == release 439 | 440 | @pytest.mark.parametrize( 441 | ("version", "local"), 442 | [ 443 | ("1.0", None), 444 | ("1.0.dev0", None), 445 | ("1.0.dev6", None), 446 | ("1.0a1", None), 447 | ("1.0a1.post5", None), 448 | ("1.0a1.post5.dev6", None), 449 | ("1.0rc4", None), 450 | ("1.0.post5", None), 451 | ("1!1.0", None), 452 | ("1!1.0.dev6", None), 453 | ("1!1.0a1", None), 454 | ("1!1.0a1.post5", None), 455 | ("1!1.0a1.post5.dev6", None), 456 | ("1!1.0rc4", None), 457 | ("1!1.0.post5", None), 458 | ("1.0+deadbeef", "deadbeef"), 459 | ("1.0.dev6+deadbeef", "deadbeef"), 460 | ("1.0a1+deadbeef", "deadbeef"), 461 | ("1.0a1.post5+deadbeef", "deadbeef"), 462 | ("1.0a1.post5.dev6+deadbeef", "deadbeef"), 463 | ("1.0rc4+deadbeef", "deadbeef"), 464 | ("1.0.post5+deadbeef", "deadbeef"), 465 | ("1!1.0+deadbeef", "deadbeef"), 466 | ("1!1.0.dev6+deadbeef", "deadbeef"), 467 | ("1!1.0a1+deadbeef", "deadbeef"), 468 | ("1!1.0a1.post5+deadbeef", "deadbeef"), 469 | ("1!1.0a1.post5.dev6+deadbeef", "deadbeef"), 470 | ("1!1.0rc4+deadbeef", "deadbeef"), 471 | ("1!1.0.post5+deadbeef", "deadbeef"), 472 | ], 473 | ) 474 | def test_version_local(self, version, local): 475 | assert Version.parse(version).local == local 476 | 477 | @pytest.mark.parametrize( 478 | ("version", "pre"), 479 | [ 480 | ("1.0", None), 481 | ("1.0.dev0", None), 482 | ("1.0.dev6", None), 483 | ("1.0a1", ("a", 1)), 484 | ("1.0a1.post5", ("a", 1)), 485 | ("1.0a1.post5.dev6", ("a", 1)), 486 | ("1.0rc4", ("rc", 4)), 487 | ("1.0.post5", None), 488 | ("1!1.0", None), 489 | ("1!1.0.dev6", None), 490 | ("1!1.0a1", ("a", 1)), 491 | ("1!1.0a1.post5", ("a", 1)), 492 | ("1!1.0a1.post5.dev6", ("a", 1)), 493 | ("1!1.0rc4", ("rc", 4)), 494 | ("1!1.0.post5", None), 495 | ("1.0+deadbeef", None), 496 | ("1.0.dev6+deadbeef", None), 497 | ("1.0a1+deadbeef", ("a", 1)), 498 | ("1.0a1.post5+deadbeef", ("a", 1)), 499 | ("1.0a1.post5.dev6+deadbeef", ("a", 1)), 500 | ("1.0rc4+deadbeef", ("rc", 4)), 501 | ("1.0.post5+deadbeef", None), 502 | ("1!1.0+deadbeef", None), 503 | ("1!1.0.dev6+deadbeef", None), 504 | ("1!1.0a1+deadbeef", ("a", 1)), 505 | ("1!1.0a1.post5+deadbeef", ("a", 1)), 506 | ("1!1.0a1.post5.dev6+deadbeef", ("a", 1)), 507 | ("1!1.0rc4+deadbeef", ("rc", 4)), 508 | ("1!1.0.post5+deadbeef", None), 509 | ], 510 | ) 511 | def test_version_pre(self, version, pre): 512 | v = Version.parse(version) 513 | if pre is None: 514 | assert v.pre is None and v.pre_tag is None 515 | return 516 | assert v.pre == pre[1] 517 | assert v.pre_tag == pre[0] 518 | 519 | @pytest.mark.parametrize( 520 | ("version", "expected"), 521 | [ 522 | ("1.0.dev0", True), 523 | ("1.0.dev1", True), 524 | ("1.0a1.dev1", True), 525 | ("1.0b1.dev1", True), 526 | ("1.0c1.dev1", True), 527 | ("1.0rc1.dev1", True), 528 | ("1.0a1", True), 529 | ("1.0b1", True), 530 | ("1.0c1", True), 531 | ("1.0rc1", True), 532 | ("1.0a1.post1.dev1", True), 533 | ("1.0b1.post1.dev1", True), 534 | ("1.0c1.post1.dev1", True), 535 | ("1.0rc1.post1.dev1", True), 536 | ("1.0a1.post1", True), 537 | ("1.0b1.post1", True), 538 | ("1.0c1.post1", True), 539 | ("1.0rc1.post1", True), 540 | ("1.0", False), 541 | ("1.0+dev", False), 542 | ("1.0.post1", False), 543 | ("1.0.post1+dev", False), 544 | ], 545 | ) 546 | def test_version_is_prerelease(self, version, expected): 547 | assert Version.parse(version).is_prerelease is expected 548 | 549 | @pytest.mark.parametrize( 550 | ("version", "dev"), 551 | [ 552 | ("1.0", None), 553 | ("1.0.dev0", 0), 554 | ("1.0.dev6", 6), 555 | ("1.0a1", None), 556 | ("1.0a1.post5", None), 557 | ("1.0a1.post5.dev6", 6), 558 | ("1.0rc4", None), 559 | ("1.0.post5", None), 560 | ("1!1.0", None), 561 | ("1!1.0.dev6", 6), 562 | ("1!1.0a1", None), 563 | ("1!1.0a1.post5", None), 564 | ("1!1.0a1.post5.dev6", 6), 565 | ("1!1.0rc4", None), 566 | ("1!1.0.post5", None), 567 | ("1.0+deadbeef", None), 568 | ("1.0.dev6+deadbeef", 6), 569 | ("1.0a1+deadbeef", None), 570 | ("1.0a1.post5+deadbeef", None), 571 | ("1.0a1.post5.dev6+deadbeef", 6), 572 | ("1.0rc4+deadbeef", None), 573 | ("1.0.post5+deadbeef", None), 574 | ("1!1.0+deadbeef", None), 575 | ("1!1.0.dev6+deadbeef", 6), 576 | ("1!1.0a1+deadbeef", None), 577 | ("1!1.0a1.post5+deadbeef", None), 578 | ("1!1.0a1.post5.dev6+deadbeef", 6), 579 | ("1!1.0rc4+deadbeef", None), 580 | ("1!1.0.post5+deadbeef", None), 581 | ], 582 | ) 583 | def test_version_dev(self, version, dev): 584 | assert Version.parse(version).dev == dev 585 | 586 | @pytest.mark.parametrize( 587 | ("version", "expected"), 588 | [ 589 | ("1.0", False), 590 | ("1.0.dev0", True), 591 | ("1.0.dev6", True), 592 | ("1.0a1", False), 593 | ("1.0a1.post5", False), 594 | ("1.0a1.post5.dev6", True), 595 | ("1.0rc4", False), 596 | ("1.0.post5", False), 597 | ("1!1.0", False), 598 | ("1!1.0.dev6", True), 599 | ("1!1.0a1", False), 600 | ("1!1.0a1.post5", False), 601 | ("1!1.0a1.post5.dev6", True), 602 | ("1!1.0rc4", False), 603 | ("1!1.0.post5", False), 604 | ("1.0+deadbeef", False), 605 | ("1.0.dev6+deadbeef", True), 606 | ("1.0a1+deadbeef", False), 607 | ("1.0a1.post5+deadbeef", False), 608 | ("1.0a1.post5.dev6+deadbeef", True), 609 | ("1.0rc4+deadbeef", False), 610 | ("1.0.post5+deadbeef", False), 611 | ("1!1.0+deadbeef", False), 612 | ("1!1.0.dev6+deadbeef", True), 613 | ("1!1.0a1+deadbeef", False), 614 | ("1!1.0a1.post5+deadbeef", False), 615 | ("1!1.0a1.post5.dev6+deadbeef", True), 616 | ("1!1.0rc4+deadbeef", False), 617 | ("1!1.0.post5+deadbeef", False), 618 | ], 619 | ) 620 | def test_version_is_devrelease(self, version, expected): 621 | assert Version.parse(version).is_devrelease is expected 622 | 623 | @pytest.mark.parametrize( 624 | ("version", "post"), 625 | [ 626 | ("1.0", None), 627 | ("1.0.dev0", None), 628 | ("1.0.dev6", None), 629 | ("1.0a1", None), 630 | ("1.0a1.post5", 5), 631 | ("1.0a1.post5.dev6", 5), 632 | ("1.0rc4", None), 633 | ("1.0.post5", 5), 634 | ("1!1.0", None), 635 | ("1!1.0.dev6", None), 636 | ("1!1.0a1", None), 637 | ("1!1.0a1.post5", 5), 638 | ("1!1.0a1.post5.dev6", 5), 639 | ("1!1.0rc4", None), 640 | ("1!1.0.post5", 5), 641 | ("1.0+deadbeef", None), 642 | ("1.0.dev6+deadbeef", None), 643 | ("1.0a1+deadbeef", None), 644 | ("1.0a1.post5+deadbeef", 5), 645 | ("1.0a1.post5.dev6+deadbeef", 5), 646 | ("1.0rc4+deadbeef", None), 647 | ("1.0.post5+deadbeef", 5), 648 | ("1!1.0+deadbeef", None), 649 | ("1!1.0.dev6+deadbeef", None), 650 | ("1!1.0a1+deadbeef", None), 651 | ("1!1.0a1.post5+deadbeef", 5), 652 | ("1!1.0a1.post5.dev6+deadbeef", 5), 653 | ("1!1.0rc4+deadbeef", None), 654 | ("1!1.0.post5+deadbeef", 5), 655 | ], 656 | ) 657 | def test_version_post(self, version, post): 658 | assert Version.parse(version).post == post 659 | 660 | @pytest.mark.parametrize( 661 | ("version", "expected"), 662 | [ 663 | ("1.0.dev1", False), 664 | ("1.0", False), 665 | ("1.0+foo", False), 666 | ("1.0.post1.dev1", True), 667 | ("1.0.post1", True), 668 | ], 669 | ) 670 | def test_version_is_postrelease(self, version, expected): 671 | assert Version.parse(version).is_postrelease is expected 672 | 673 | @pytest.mark.parametrize( 674 | ("left", "right", "op"), 675 | # Below we'll generate every possible combination of VERSIONS that 676 | # should be True for the given operator 677 | itertools.chain( 678 | * 679 | # Verify that the less than (<) operator works correctly 680 | [ 681 | [(x, y, operator.lt) for y in PARSED_VERSIONS[i + 1 :]] 682 | for i, x in enumerate(PARSED_VERSIONS) 683 | ] 684 | + 685 | # Verify that the less than equal (<=) operator works correctly 686 | [ 687 | [(x, y, operator.le) for y in PARSED_VERSIONS[i:]] 688 | for i, x in enumerate(PARSED_VERSIONS) 689 | ] 690 | + 691 | # Verify that the equal (==) operator works correctly 692 | [[(x, x, operator.eq) for x in PARSED_VERSIONS]] 693 | + 694 | # Verify that the not equal (!=) operator works correctly 695 | [ 696 | [(x, y, operator.ne) for j, y in enumerate(PARSED_VERSIONS) if i != j] 697 | for i, x in enumerate(PARSED_VERSIONS) 698 | ] 699 | + 700 | # Verify that the greater than equal (>=) operator works correctly 701 | [ 702 | [(x, y, operator.ge) for y in PARSED_VERSIONS[: i + 1]] 703 | for i, x in enumerate(PARSED_VERSIONS) 704 | ] 705 | + 706 | # Verify that the greater than (>) operator works correctly 707 | [ 708 | [(x, y, operator.gt) for y in PARSED_VERSIONS[:i]] 709 | for i, x in enumerate(PARSED_VERSIONS) 710 | ] 711 | ), 712 | ) 713 | def test_comparison_true(self, left, right, op): 714 | assert op(left, right) 715 | 716 | @pytest.mark.parametrize( 717 | ("left", "right", "op"), 718 | # Below we'll generate every possible combination of VERSIONS that 719 | # should be False for the given operator 720 | itertools.chain( 721 | * 722 | # Verify that the less than (<) operator works correctly 723 | [ 724 | [(x, y, operator.lt) for y in PARSED_VERSIONS[: i + 1]] 725 | for i, x in enumerate(PARSED_VERSIONS) 726 | ] 727 | + 728 | # Verify that the less than equal (<=) operator works correctly 729 | [ 730 | [(x, y, operator.le) for y in PARSED_VERSIONS[:i]] 731 | for i, x in enumerate(PARSED_VERSIONS) 732 | ] 733 | + 734 | # Verify that the equal (==) operator works correctly 735 | [ 736 | [(x, y, operator.eq) for j, y in enumerate(PARSED_VERSIONS) if i != j] 737 | for i, x in enumerate(PARSED_VERSIONS) 738 | ] 739 | + 740 | # Verify that the not equal (!=) operator works correctly 741 | [[(x, x, operator.ne) for x in PARSED_VERSIONS]] 742 | + 743 | # Verify that the greater than equal (>=) operator works correctly 744 | [ 745 | [(x, y, operator.ge) for y in PARSED_VERSIONS[i + 1 :]] 746 | for i, x in enumerate(PARSED_VERSIONS) 747 | ] 748 | + 749 | # Verify that the greater than (>) operator works correctly 750 | [ 751 | [(x, y, operator.gt) for y in PARSED_VERSIONS[i:]] 752 | for i, x in enumerate(PARSED_VERSIONS) 753 | ] 754 | ), 755 | ) 756 | def test_comparison_false(self, left, right, op): 757 | assert not op(left, right) 758 | 759 | @pytest.mark.parametrize(("op", "expected"), [("eq", False), ("ne", True)]) 760 | def test_compare_other(self, op, expected): 761 | other = pretend.stub(**{f"__{op}__": lambda other: NotImplemented}) 762 | 763 | assert getattr(operator, op)(Version.parse("1"), other) is expected 764 | -------------------------------------------------------------------------------- /tests/test_parse.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from hypothesis import HealthCheck, assume, given, settings 3 | 4 | from parver import ParseError, Version 5 | 6 | from .strategies import version_string, whitespace 7 | 8 | 9 | @given(whitespace, version_string(), whitespace) 10 | @settings(suppress_health_check=[HealthCheck.too_slow]) 11 | def test_parse_hypothesis(prefix, version, suffix): 12 | Version.parse(prefix + version + suffix) 13 | 14 | 15 | @given(whitespace, version_string(strict=True), whitespace) 16 | @settings(suppress_health_check=[HealthCheck.too_slow]) 17 | def test_parse_strict_hypothesis(prefix, version, suffix): 18 | Version.parse(prefix + version + suffix, strict=True) 19 | 20 | 21 | @given(version_string(strict=False)) 22 | @settings(suppress_health_check=[HealthCheck.too_slow]) 23 | def test_parse_strict_error(version): 24 | v = Version.parse(version) 25 | 26 | # Exclude already normalized versions 27 | assume(str(v.normalize()) != version) 28 | 29 | # 0!1 normalizes to '1' 30 | assume(v.epoch != 0 or v.epoch_implicit) 31 | 32 | with pytest.raises(ParseError): 33 | Version.parse(version, strict=True) 34 | 35 | 36 | @given(version_string()) 37 | @settings(suppress_health_check=[HealthCheck.too_slow]) 38 | def test_roundtrip(version): 39 | assert str(Version.parse(version)) == version 40 | 41 | 42 | @pytest.mark.parametrize( 43 | "version", 44 | [ 45 | "1+ABC", 46 | "1+2-3", 47 | "1+2_3", 48 | "1+02_3", 49 | ], 50 | ) 51 | def test_parse_local_strict(version): 52 | with pytest.raises(ParseError): 53 | Version.parse(version, strict=True) 54 | Version.parse(version) 55 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from hypothesis import HealthCheck, given, settings 3 | 4 | from parver import Version 5 | 6 | from .strategies import version_strategy 7 | 8 | 9 | def v(*args, **kwargs): 10 | return args, kwargs 11 | 12 | 13 | @pytest.mark.parametrize( 14 | "vargs, s", 15 | [ 16 | (v(1), "1"), 17 | (v(release=(1,)), "1"), 18 | (v(release=(1, 2)), "1.2"), 19 | # epoch 20 | (v(release=1, epoch=""), "1"), 21 | (v(release=1, epoch=0), "0!1"), 22 | (v(release=1, epoch=1), "1!1"), 23 | (v(release=1, pre_tag=None), "1"), 24 | # pre_tag with implicit pre 25 | (v(release=1, pre="", pre_tag="c"), "1c"), 26 | (v(release=1, pre="", pre_tag="rc"), "1rc"), 27 | (v(release=1, pre="", pre_tag="alpha"), "1alpha"), 28 | (v(release=1, pre="", pre_tag="a"), "1a"), 29 | (v(release=1, pre="", pre_tag="beta"), "1beta"), 30 | (v(release=1, pre="", pre_tag="b"), "1b"), 31 | (v(release=1, pre="", pre_tag="preview"), "1preview"), 32 | (v(release=1, pre="", pre_tag="pre"), "1pre"), 33 | # pre_tag with pre 34 | (v(release=1, pre=0, pre_tag="c"), "1c0"), 35 | (v(release=1, pre=1, pre_tag="rc"), "1rc1"), 36 | (v(release=1, pre=2, pre_tag="alpha"), "1alpha2"), 37 | (v(release=1, pre=3, pre_tag="a"), "1a3"), 38 | (v(release=1, pre="", pre_tag="beta"), "1beta"), 39 | (v(release=1, pre=0, pre_tag="b"), "1b0"), 40 | (v(release=1, pre=0, pre_tag="preview"), "1preview0"), 41 | (v(release=1, pre=0, pre_tag="pre"), "1pre0"), 42 | # pre_tag with pre_sep1 43 | (v(release=1, pre="", pre_sep1=None, pre_tag="b"), "1b"), 44 | (v(release=1, pre="", pre_sep1=".", pre_tag="b"), "1.b"), 45 | (v(release=1, pre="", pre_sep1="-", pre_tag="b"), "1-b"), 46 | (v(release=1, pre="", pre_sep1="_", pre_tag="b"), "1_b"), 47 | # pre_tag with pre_sep2 48 | (v(release=2, pre=1, pre_sep2=None, pre_tag="b"), "2b1"), 49 | (v(release=2, pre=1, pre_sep2=".", pre_tag="b"), "2b.1"), 50 | (v(release=2, pre=1, pre_sep2="-", pre_tag="b"), "2b-1"), 51 | (v(release=2, pre=1, pre_sep2="_", pre_tag="b"), "2b_1"), 52 | # pre_tag with pre_sep1 and pre_sep2 53 | (v(release=2, pre=1, pre_sep1=".", pre_sep2=None, pre_tag="b"), "2.b1"), 54 | (v(release=2, pre=1, pre_sep1=".", pre_sep2=".", pre_tag="b"), "2.b.1"), 55 | (v(release=2, pre=1, pre_sep1=".", pre_sep2="-", pre_tag="b"), "2.b-1"), 56 | (v(release=2, pre=1, pre_sep1=".", pre_sep2="_", pre_tag="b"), "2.b_1"), 57 | # post 58 | (v(release=1, post=""), "1.post"), 59 | (v(release=1, post=0), "1.post0"), 60 | (v(release=1, post=1), "1.post1"), 61 | # post_tag 62 | (v(release=1, post=0, post_tag=None), "1-0"), 63 | (v(release=1, post="", post_tag="post"), "1.post"), 64 | (v(release=1, post="", post_tag="r"), "1.r"), 65 | (v(release=1, post="", post_tag="rev"), "1.rev"), 66 | # post with post_sep1 67 | (v(release=1, post="", post_sep1=None), "1post"), 68 | (v(release=1, post="", post_sep1="."), "1.post"), 69 | (v(release=1, post="", post_sep1="-"), "1-post"), 70 | (v(release=1, post="", post_sep1="_"), "1_post"), 71 | # post with post_sep2 72 | (v(release=2, post=1, post_sep2=None), "2.post1"), 73 | (v(release=2, post=1, post_sep2="."), "2.post.1"), 74 | (v(release=2, post=1, post_sep2="-"), "2.post-1"), 75 | (v(release=2, post=1, post_sep2="_"), "2.post_1"), 76 | # post with post_sep1 and post_sep2 77 | (v(release=2, post=1, post_sep1=".", post_sep2=None), "2.post1"), 78 | (v(release=2, post=1, post_sep1=".", post_sep2="."), "2.post.1"), 79 | (v(release=2, post=1, post_sep1=".", post_sep2="-"), "2.post-1"), 80 | (v(release=2, post=1, post_sep1=".", post_sep2="_"), "2.post_1"), 81 | # dev 82 | (v(release=1, dev=""), "1.dev"), 83 | (v(release=1, dev=0), "1.dev0"), 84 | (v(release=1, dev=1), "1.dev1"), 85 | # local 86 | (v(release=1, local=None), "1"), 87 | (v(release=1, local="a"), "1+a"), 88 | (v(release=1, local="0"), "1+0"), 89 | (v(release=1, local="a0"), "1+a0"), 90 | (v(release=1, local="a.0"), "1+a.0"), 91 | (v(release=1, local="0-0"), "1+0-0"), 92 | (v(release=1, local="0_a"), "1+0_a"), 93 | ], 94 | ) 95 | def test_init(vargs, s): 96 | args, kwargs = vargs 97 | assert str(Version(*args, **kwargs)) == s 98 | 99 | 100 | @pytest.mark.parametrize( 101 | "kwargs", 102 | [ 103 | dict(pre=1), 104 | dict(pre_sep1="."), 105 | dict(pre_sep2="."), 106 | dict(pre_sep1=".", pre_sep2="."), 107 | dict(post_tag=None), 108 | dict(post_tag=None, post=""), 109 | dict(post_tag=None, post_sep1="."), 110 | dict(post_tag=None, post_sep2="."), 111 | dict(post_tag=None, post_sep1=".", post_sep2="."), 112 | dict(pre_tag="a"), 113 | dict(dev=None, dev_sep="."), 114 | dict(dev=None, post_sep1="."), 115 | dict(dev=None, post_sep2="."), 116 | ], 117 | ) 118 | def test_invalid(kwargs): 119 | """Test bad keyword combinations.""" 120 | with pytest.raises(ValueError): 121 | Version(release=1, **kwargs) 122 | 123 | 124 | @pytest.mark.parametrize( 125 | "kwargs", 126 | [ 127 | dict(release="1"), 128 | dict(v=3), 129 | dict(post=True), 130 | dict(epoch="1"), 131 | dict(pre_tag="b", pre="2"), 132 | dict(post="3"), 133 | dict(dev="3"), 134 | dict(local=[1, "abc"]), 135 | dict(local=1), 136 | ], 137 | ) 138 | def test_validation_type(kwargs): 139 | if "release" not in kwargs: 140 | kwargs["release"] = 1 141 | 142 | with pytest.raises(TypeError): 143 | # print so we can see output when test fails 144 | print(Version(**kwargs)) 145 | 146 | 147 | @pytest.mark.parametrize( 148 | "release, exc, match", 149 | [ 150 | ([], ValueError, "'release' cannot be empty"), 151 | (-1, ValueError, r"'release' must be non-negative \(got -1\)"), 152 | ([4, -1], ValueError, r"'release' must be non-negative \(got -1\)"), 153 | ([4, "a"], TypeError, r"'release' must be.*int"), 154 | ([4, True], TypeError, r"'release' must not be a bool"), 155 | ], 156 | ) 157 | def test_release_validation(release, exc, match): 158 | with pytest.raises(exc, match=match): 159 | Version(release=release) 160 | 161 | 162 | @pytest.mark.parametrize( 163 | "kwargs", 164 | [ 165 | dict(pre_tag="alph"), 166 | dict(pre_tag="a", pre_sep1="x"), 167 | dict(pre_tag="a", pre_sep2="x"), 168 | dict(post=1, post_sep1="x"), 169 | dict(post=1, post_sep2="x"), 170 | dict(dev=4, dev_sep="y"), 171 | dict(post_tag=None, post=1, post_sep1="."), 172 | dict(post_tag=None, post=1, post_sep2="."), 173 | dict(epoch=-1), 174 | dict(pre_tag="a", pre=-1), 175 | dict(post=-1), 176 | dict(dev=-1), 177 | ], 178 | ) 179 | def test_validation_value(kwargs): 180 | kwargs.setdefault("release", 1) 181 | 182 | with pytest.raises(ValueError): 183 | # print so we can see output when test fails 184 | print(Version(**kwargs)) 185 | 186 | 187 | @pytest.mark.parametrize( 188 | "kwargs, values, version", 189 | [ 190 | ( 191 | dict(release=1), 192 | dict( 193 | release=(1,), 194 | v=False, 195 | epoch=0, 196 | epoch_implicit=True, 197 | pre_tag=None, 198 | pre=None, 199 | pre_implicit=False, 200 | pre_sep1=None, 201 | pre_sep2=None, 202 | post=None, 203 | post_tag=None, 204 | post_implicit=False, 205 | post_sep1=None, 206 | post_sep2=None, 207 | dev=None, 208 | dev_implicit=False, 209 | dev_sep=None, 210 | local=None, 211 | ), 212 | "1", 213 | ), 214 | ( 215 | dict(epoch=0), 216 | dict(epoch=0, epoch_implicit=False), 217 | "0!1", 218 | ), 219 | ( 220 | dict(pre="", pre_tag="b"), 221 | dict( 222 | pre=0, 223 | pre_tag="b", 224 | pre_implicit=True, 225 | pre_sep1=None, 226 | pre_sep2=None, 227 | ), 228 | "1b", 229 | ), 230 | ( 231 | dict(pre=0, pre_tag="a"), 232 | dict( 233 | pre=0, 234 | pre_tag="a", 235 | pre_implicit=False, 236 | pre_sep1=None, 237 | pre_sep2=None, 238 | ), 239 | "1a0", 240 | ), 241 | ( 242 | dict(pre=2, pre_tag="pre", pre_sep1="-", pre_sep2="."), 243 | dict( 244 | pre=2, 245 | pre_tag="pre", 246 | pre_implicit=False, 247 | pre_sep1="-", 248 | pre_sep2=".", 249 | ), 250 | "1-pre.2", 251 | ), 252 | ( 253 | dict(post=""), 254 | dict( 255 | post=0, 256 | post_tag="post", 257 | post_implicit=True, 258 | post_sep1=".", 259 | post_sep2=None, 260 | ), 261 | "1.post", 262 | ), 263 | ( 264 | dict(post="", post_sep2="."), 265 | dict( 266 | post=0, 267 | post_tag="post", 268 | post_implicit=True, 269 | post_sep1=".", 270 | post_sep2=".", 271 | ), 272 | "1.post.", 273 | ), 274 | ( 275 | dict(post=0), 276 | dict( 277 | post=0, 278 | post_tag="post", 279 | post_implicit=False, 280 | post_sep1=".", 281 | post_sep2=None, 282 | ), 283 | "1.post0", 284 | ), 285 | ( 286 | dict(post=0, post_tag=None), 287 | dict( 288 | post=0, 289 | post_tag=None, 290 | post_implicit=False, 291 | post_sep1="-", 292 | post_sep2=None, 293 | ), 294 | "1-0", 295 | ), 296 | ( 297 | dict(post=3, post_tag="rev", post_sep1="-", post_sep2="_"), 298 | dict( 299 | post=3, 300 | post_tag="rev", 301 | post_implicit=False, 302 | post_sep1="-", 303 | post_sep2="_", 304 | ), 305 | "1-rev_3", 306 | ), 307 | ( 308 | dict(dev=""), 309 | dict(dev=0, dev_implicit=True, dev_sep="."), 310 | "1.dev", 311 | ), 312 | ( 313 | dict(dev=2), 314 | dict(dev=2, dev_implicit=False, dev_sep="."), 315 | "1.dev2", 316 | ), 317 | ( 318 | dict(dev=0, dev_sep="-"), 319 | dict(dev=0, dev_implicit=False, dev_sep="-"), 320 | "1-dev0", 321 | ), 322 | ( 323 | dict(local="a.b"), 324 | dict(local="a.b"), 325 | "1+a.b", 326 | ), 327 | ], 328 | ) 329 | def test_attributes(kwargs, values, version): 330 | # save us repeating ourselves in test data above 331 | kwargs.setdefault("release", 1) 332 | 333 | v = Version(**kwargs) 334 | assert str(v) == version 335 | for key, value in values.items(): 336 | assert getattr(v, key) == value, key 337 | 338 | 339 | @given(version_strategy()) 340 | @settings(suppress_health_check=[HealthCheck.too_slow]) 341 | def test_replace_roundtrip(version): 342 | """All the logic inside replace() is in converting the attributes to the 343 | form expected by __init__, so this function tests most of that. 344 | """ 345 | assert version.replace() == version 346 | 347 | 348 | @pytest.mark.parametrize( 349 | "before, kwargs, after", 350 | [ 351 | ( 352 | "v0!1.2.alpha-3_rev.4_dev5+l.6", 353 | dict( 354 | release=(2, 1), 355 | epoch="", 356 | v=False, 357 | pre_tag="a", 358 | pre_sep1=None, 359 | pre_sep2=None, 360 | post_tag="post", 361 | post_sep1=".", 362 | post_sep2=None, 363 | dev_sep=".", 364 | local=None, 365 | ), 366 | "2.1a3.post4.dev5", 367 | ), 368 | ( 369 | "2.1a3.post4.dev5", 370 | dict( 371 | release=(1, 2), 372 | epoch=0, 373 | v=True, 374 | pre_tag="alpha", 375 | pre_sep1=".", 376 | pre_sep2="-", 377 | post_tag="rev", 378 | post_sep1="_", 379 | post_sep2=".", 380 | dev_sep="_", 381 | local="l.6", 382 | ), 383 | "v0!1.2.alpha-3_rev.4_dev5+l.6", 384 | ), 385 | ( 386 | "2.post4", 387 | dict(post_tag=None), 388 | "2-4", 389 | ), 390 | ( 391 | "1.2.alpha-3_rev.4_dev5", 392 | dict(pre=None, post=None, dev=None), 393 | "1.2", 394 | ), 395 | ( 396 | "1.2.alpha-3_rev.4_dev5", 397 | dict(pre=None, post=None, dev=None), 398 | "1.2", 399 | ), 400 | ( 401 | "1.2", 402 | dict(pre=None, post=None, dev=None), 403 | "1.2", 404 | ), 405 | ], 406 | ) 407 | def test_replace(before, kwargs, after): 408 | """Make sure the keys we expect are passed through.""" 409 | assert str(Version.parse(before).replace(**kwargs)) == after 410 | 411 | 412 | @pytest.mark.parametrize( 413 | "before, index, after", 414 | [ 415 | ("1", 0, "2"), 416 | ("1", 1, "1.1"), 417 | ("1", 2, "1.0.1"), 418 | ("1.1", 0, "2.0"), 419 | ("1.1", 1, "1.2"), 420 | ("1.1", 2, "1.1.1"), 421 | ("1.1", 3, "1.1.0.1"), 422 | ("4.3.2.1", 2, "4.3.3.0"), 423 | ], 424 | ) 425 | def test_bump_release(before, index, after): 426 | assert str(Version.parse(before).bump_release(index=index)) == after 427 | 428 | 429 | @pytest.mark.parametrize( 430 | "before, index, value, after", 431 | [ 432 | ("2", 0, 1, "1"), 433 | ("2", 0, 2, "2"), 434 | ("2", 0, 3, "3"), 435 | ("2", 1, 3, "2.3"), 436 | ("2", 2, 3, "2.0.3"), 437 | ("2.4", 0, 4, "4.0"), 438 | ("2.4", 1, 6, "2.6"), 439 | ("2.4", 2, 6, "2.4.6"), 440 | ("2.4", 3, 6, "2.4.0.6"), 441 | ("4.3.2.1", 1, 5, "4.5.0.0"), 442 | # e.g. CalVer 443 | ("2017.4", 0, 2018, "2018.0"), 444 | ("17.5.1", 0, 18, "18.0.0"), 445 | ("18.0.0", 1, 2, "18.2.0"), 446 | ], 447 | ) 448 | def test_bump_release_to(before, index, value, after): 449 | v = Version.parse(before).bump_release_to(index=index, value=value) 450 | assert str(v) == after 451 | 452 | 453 | @pytest.mark.parametrize( 454 | "before, index, value, after", 455 | [ 456 | ("2", 0, 1, "1"), 457 | ("2", 0, 2, "2"), 458 | ("2", 0, 3, "3"), 459 | ("2", 1, 3, "2.3"), 460 | ("2", 2, 3, "2.0.3"), 461 | ("2.4", 0, 4, "4.4"), 462 | ("2.4", 1, 6, "2.6"), 463 | ("2.4", 2, 6, "2.4.6"), 464 | ("2.4", 3, 6, "2.4.0.6"), 465 | ("2.0.4", 1, 3, "2.3.4"), 466 | ("4.3.2.1", 1, 5, "4.5.2.1"), 467 | ], 468 | ) 469 | def test_set_release(before, index, value, after): 470 | v = Version.parse(before).set_release(index=index, value=value) 471 | assert str(v) == after 472 | 473 | 474 | @pytest.mark.parametrize( 475 | "index, exc", 476 | [ 477 | ("1", TypeError), 478 | (1.1, TypeError), 479 | (-1, ValueError), 480 | ], 481 | ) 482 | def test_bump_release_error(index, exc): 483 | with pytest.raises(exc): 484 | print(Version(release=1).bump_release(index=index)) 485 | 486 | 487 | @pytest.mark.parametrize( 488 | "by", 489 | [ 490 | "1", 491 | 1.1, 492 | None, 493 | ], 494 | ) 495 | def test_bump_by_error(by): 496 | v = Version(release=1) 497 | 498 | with pytest.raises(TypeError): 499 | v.bump_epoch(by=by) 500 | 501 | with pytest.raises(TypeError): 502 | v.bump_dev(by=by) 503 | 504 | with pytest.raises(TypeError): 505 | v.bump_pre("a", by=by) 506 | 507 | with pytest.raises(TypeError): 508 | v.bump_post(by=by) 509 | 510 | 511 | def test_bump_by_value_error(): 512 | v = Version(release=1) 513 | 514 | with pytest.raises(ValueError, match="negative"): 515 | v.bump_epoch(by=-1) 516 | 517 | with pytest.raises(ValueError, match="negative"): 518 | v.bump_dev(by=-1) 519 | 520 | with pytest.raises(ValueError, match="negative"): 521 | v.bump_pre(by=-1) 522 | 523 | with pytest.raises(ValueError, match="negative"): 524 | v.bump_post(by=-1) 525 | 526 | 527 | @pytest.mark.parametrize( 528 | "before, tag, kwargs, after", 529 | [ 530 | ("1", "a", dict(), "1a0"), 531 | ("1", "a", dict(by=2), "1a1"), 532 | ("1a0", None, dict(), "1a1"), 533 | ("1a", None, dict(), "1a1"), 534 | ("1a", "a", dict(), "1a1"), 535 | ("1.b-0", None, dict(), "1.b-1"), 536 | ("1a1", None, dict(by=-1), "1a0"), 537 | ], 538 | ) 539 | def test_bump_pre(before, tag, kwargs, after): 540 | assert str(Version.parse(before).bump_pre(tag, **kwargs)) == after 541 | 542 | 543 | @pytest.mark.parametrize( 544 | "version, tag", 545 | [ 546 | ("1.2", None), 547 | ("1.2a", "b"), 548 | ], 549 | ) 550 | def test_bump_pre_error(version, tag): 551 | with pytest.raises(ValueError): 552 | print(Version.parse(version).bump_pre(tag)) 553 | 554 | 555 | @pytest.mark.parametrize( 556 | "before, kwargs, after", 557 | [ 558 | ("1", dict(), "1.post0"), 559 | ("1", dict(by=2), "1.post1"), 560 | ("1.post0", dict(), "1.post1"), 561 | ("1rev", dict(), "1rev1"), 562 | ("1-0", dict(), "1-1"), 563 | ("1-0", dict(tag="post"), "1.post1"), 564 | ("1-post_0", dict(tag=None), "1-1"), 565 | ("1.post1", dict(by=-1), "1.post0"), 566 | ], 567 | ) 568 | def test_bump_post(before, kwargs, after): 569 | assert str(Version.parse(before).bump_post(**kwargs)) == after 570 | 571 | 572 | @pytest.mark.parametrize( 573 | "before, kwargs, after", 574 | [ 575 | ("1", dict(), "1.dev0"), 576 | ("1", dict(by=2), "1.dev1"), 577 | ("1.dev0", dict(), "1.dev1"), 578 | ("1-dev1", dict(), "1-dev2"), 579 | ("1-dev1", dict(by=-1), "1-dev0"), 580 | ], 581 | ) 582 | def test_bump_dev(before, kwargs, after): 583 | assert str(Version.parse(before).bump_dev(**kwargs)) == after 584 | 585 | 586 | @pytest.mark.parametrize( 587 | "before, kwargs, after", 588 | [ 589 | ("2", dict(), "1!2"), 590 | ("2", dict(by=2), "2!2"), 591 | ("0!3", dict(), "1!3"), 592 | ("1!4", dict(), "2!4"), 593 | ("1!4", dict(by=-1), "0!4"), 594 | ("1!4", dict(by=2), "3!4"), 595 | ], 596 | ) 597 | def test_bump_epoch(before, kwargs, after): 598 | assert str(Version.parse(before).bump_epoch(**kwargs)) == after 599 | 600 | 601 | @pytest.mark.parametrize( 602 | "arg, expected", 603 | [ 604 | (1, (1,)), 605 | ([1], (1,)), 606 | ((1, 2), (1, 2)), 607 | ([1, 2], (1, 2)), 608 | # range is a Sequence 609 | (range(1, 3), (1, 2)), 610 | # An iterable that is not also a sequence 611 | ((x for x in range(1, 3)), (1, 2)), 612 | ], 613 | ) 614 | def test_release_tuple(arg, expected): 615 | v = Version(release=arg) 616 | assert isinstance(v.release, tuple) 617 | assert v.release == expected 618 | 619 | 620 | @pytest.mark.parametrize( 621 | "version", 622 | [ 623 | "1a", 624 | "1alpha", 625 | "1a1", 626 | ], 627 | ) 628 | def test_is_alpha(version): 629 | v = Version.parse(version) 630 | assert v.is_alpha 631 | assert not v.is_beta 632 | assert not v.is_release_candidate 633 | 634 | 635 | @pytest.mark.parametrize( 636 | "version", 637 | [ 638 | "1b", 639 | "1beta", 640 | "1b1", 641 | ], 642 | ) 643 | def test_is_beta(version): 644 | v = Version.parse(version) 645 | assert not v.is_alpha 646 | assert v.is_beta 647 | assert not v.is_release_candidate 648 | 649 | 650 | @pytest.mark.parametrize( 651 | "version", 652 | [ 653 | "1rc", 654 | "1c", 655 | "1pre", 656 | "1preview", 657 | "1rc1", 658 | ], 659 | ) 660 | def test_is_release_candidate(version): 661 | v = Version.parse(version) 662 | assert not v.is_alpha 663 | assert not v.is_beta 664 | assert v.is_release_candidate 665 | 666 | 667 | def test_ambiguous(): 668 | with pytest.raises(ValueError, match="post_tag.*pre"): 669 | Version(release=1, pre="", pre_tag="rc", post=2, post_tag=None) 670 | 671 | 672 | @pytest.mark.parametrize( 673 | "before, after, kwargs", 674 | [ 675 | ("1.0", "1", dict()), 676 | ("1.0.0", "1", dict()), 677 | ("1.0.0", "1.0", dict(min_length=2)), 678 | ("1", "1.0", dict(min_length=2)), 679 | ("0.0", "0", dict()), 680 | ("1.0.2", "1.0.2", dict()), 681 | ("1.0.2", "1.0.2", dict(min_length=1)), 682 | ("1.0.2.0", "1.0.2", dict()), 683 | ("1.2.0", "1.2", dict()), 684 | ], 685 | ) 686 | def test_truncate(before, after, kwargs): 687 | v = Version.parse(before).truncate(**kwargs) 688 | assert str(v) == after 689 | 690 | 691 | def test_truncate_error(): 692 | with pytest.raises(TypeError, match="min_length"): 693 | Version.parse("1").truncate(min_length="banana") 694 | 695 | with pytest.raises(ValueError, match="min_length"): 696 | Version.parse("1").truncate(min_length=0) 697 | 698 | 699 | def test_public_module(): 700 | assert Version.__module__ == "parver" 701 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 3.3 3 | envlist = py37,py38,py39,py310,py311,py312,pep8,docs,typing 4 | isolated_build = True 5 | 6 | [testenv] 7 | extras = 8 | test 9 | deps = 10 | coverage[toml] 11 | commands = 12 | # Use parallel mode to fix paths. See tool.coverage.paths in pyproject.toml 13 | coverage run --parallel-mode -m pytest {posargs} 14 | coverage combine 15 | coverage report -m 16 | 17 | [testenv:pep8] 18 | basepython = python3.12 19 | extras = 20 | pep8test 21 | commands = 22 | flake8 . 23 | 24 | [testenv:docs] 25 | basepython = python3.12 26 | extras = 27 | docs 28 | docstest 29 | commands = 30 | sphinx-build -W -b html -d {envtmpdir}/doctrees docs docs/_build/html 31 | sphinx-build -W -b doctest -d {envtmpdir}/doctrees docs docs/_build/html 32 | doc8 docs/ 33 | 34 | [testenv:typing] 35 | basepython = python3.12 36 | deps = mypy>=0.931 37 | commands = 38 | mypy src/parver 39 | 40 | [doc8] 41 | ignore-path = docs/_build/ 42 | 43 | [pytest] 44 | addopts = -r s 45 | 46 | [gh-actions] 47 | python = 48 | 3.7: py37 49 | 3.8: py38 50 | 3.9: py39 51 | 3.10: py310 52 | 3.11: py311 53 | 3.12: py312, pep8, docs, typing 54 | --------------------------------------------------------------------------------