├── tests ├── __init__.py ├── conftest.py ├── test_parse.py ├── strategies.py ├── test_version.py └── test_packaging.py ├── src └── parver │ ├── py.typed │ ├── __init__.py │ ├── _typing.py │ ├── _segments.py │ ├── _helpers.py │ ├── _parse.py │ └── _version.py ├── docs ├── changelog.rst ├── license.rst ├── installation.rst ├── api.rst ├── index.rst ├── conf.py ├── usage.rst └── requirements.txt ├── MANIFEST.in ├── .github ├── dependabot.yml └── workflows │ ├── pre-commit.yml │ ├── python-publish.yml │ └── main.yml ├── .readthedocs.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.rst ├── noxfile.py ├── pyproject.toml └── CHANGELOG.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/parver/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | License 3 | ======= 4 | 5 | .. include:: ../LICENSE 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /src/parver/__init__.py: -------------------------------------------------------------------------------- 1 | from ._parse import ParseError 2 | from ._version import Version 3 | 4 | __all__ = ("ParseError", "Version") 5 | 6 | from ._helpers import fixup_module_metadata 7 | 8 | fixup_module_metadata(__name__, globals()) 9 | del fixup_module_metadata 10 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.13" 7 | 8 | python: 9 | install: 10 | - method: pip 11 | path: . 12 | - requirements: docs/requirements.txt 13 | 14 | sphinx: 15 | configuration: docs/conf.py 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | push: 8 | branches: ["main"] 9 | pull_request: 10 | branches: ["main"] 11 | 12 | jobs: 13 | pre-commit: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 17 | - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 18 | - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 19 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | environment: publish 15 | permissions: 16 | id-token: write 17 | attestations: write 18 | steps: 19 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 20 | - uses: "astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867" # v7.1.6 21 | with: 22 | enable-cache: true 23 | - name: Set up Python 24 | uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 25 | with: 26 | python-version: "3.x" 27 | - name: Build 28 | run: "uv build" 29 | - name: Publish 30 | uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 31 | -------------------------------------------------------------------------------- /.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: v6.0.0 4 | hooks: 5 | - id: check-toml 6 | - id: check-yaml 7 | - id: check-added-large-files 8 | - id: check-merge-conflict 9 | - id: end-of-file-fixer 10 | - id: trailing-whitespace 11 | args: [--markdown-linebreak-ext=md] 12 | - repo: https://github.com/rbubley/mirrors-prettier 13 | rev: v3.7.4 14 | hooks: 15 | - id: prettier 16 | - repo: https://github.com/astral-sh/ruff-pre-commit 17 | rev: v0.14.9 # also update pyproject.toml 18 | hooks: 19 | - id: ruff 20 | args: [--fix] 21 | - id: ruff-format 22 | - repo: https://github.com/astral-sh/uv-pre-commit 23 | rev: 0.9.17 24 | hooks: 25 | - id: uv-lock 26 | - id: uv-export 27 | args: 28 | - --frozen 29 | - --no-dev 30 | - --group=docs 31 | - --no-emit-project 32 | - --no-annotate 33 | - --output-file=docs/requirements.txt 34 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/parver/_segments.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, 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 | sep1: Optional[Separator] = attr.ib() 49 | sep2: Union[Separator, UnsetType, None] = attr.ib() 50 | 51 | 52 | @attr.s(slots=True) 53 | class Local: 54 | value: str = attr.ib() 55 | -------------------------------------------------------------------------------- /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=main 10 | :target: https://github.com/RazerM/parver/actions?workflow=CI 11 | :alt: CI Status 12 | 13 | .. image:: https://codecov.io/gh/RazerM/parver/branch/main/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/main/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/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 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | permissions: 5 | contents: read 6 | 7 | on: 8 | push: 9 | branches: ["main"] 10 | pull_request: 11 | branches: ["main"] 12 | workflow_dispatch: 13 | 14 | env: 15 | FORCE_COLOR: "1" 16 | UV_PYTHON_DOWNLOADS: "false" 17 | 18 | jobs: 19 | tests: 20 | name: "Python ${{ matrix.python-version }}" 21 | runs-on: "ubuntu-latest" 22 | 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] 27 | 28 | steps: 29 | - uses: "actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8" # v5.0.0 30 | with: 31 | persist-credentials: false 32 | - uses: "actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548" # v6.1.0 33 | with: 34 | python-version: "${{ matrix.python-version }}" 35 | allow-prereleases: true 36 | - uses: "astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867" # v7.1.6 37 | with: 38 | enable-cache: true 39 | - name: "Install dependencies" 40 | run: | 41 | python -VV 42 | python -m site 43 | uv --version 44 | uv sync --no-install-project --no-dev --group nox --group coverage 45 | - name: "Run nox targets for ${{ matrix.python-version }}" 46 | run: "uv run nox --python ${{ matrix.python-version }}" 47 | - name: "Convert coverage" 48 | run: | 49 | uv run coverage combine 50 | uv run coverage xml 51 | - name: "Upload coverage to Codecov" 52 | uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 53 | with: 54 | fail_ci_if_error: true 55 | token: ${{ secrets.CODECOV_TOKEN }} 56 | -------------------------------------------------------------------------------- /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, version_string_from_pep440_regex, 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_from_pep440_regex, whitespace) 16 | @settings(suppress_health_check=[HealthCheck.too_slow]) 17 | def test_parse_pep440_regex_hypothesis(prefix, version, suffix): 18 | Version.parse(prefix + version + suffix) 19 | 20 | 21 | @given(whitespace, version_string(strict=True), whitespace) 22 | @settings(suppress_health_check=[HealthCheck.too_slow]) 23 | def test_parse_strict_hypothesis(prefix, version, suffix): 24 | Version.parse(prefix + version + suffix, strict=True) 25 | 26 | 27 | @given(version_string(strict=False)) 28 | @settings(suppress_health_check=[HealthCheck.too_slow]) 29 | def test_parse_strict_error(version): 30 | v = Version.parse(version) 31 | 32 | # Exclude already normalized versions 33 | assume(str(v.normalize()) != version) 34 | 35 | # 0!1 normalizes to '1' 36 | assume(v.epoch != 0 or v.epoch_implicit) 37 | 38 | with pytest.raises(ParseError): 39 | Version.parse(version, strict=True) 40 | 41 | 42 | @given(version_string_from_pep440_regex) 43 | @settings(suppress_health_check=[HealthCheck.too_slow]) 44 | def test_parse_pep440_regex_strict_error(version): 45 | v = Version.parse(version) 46 | 47 | # Exclude already normalized versions 48 | assume(str(v.normalize()) != version) 49 | 50 | # 0!1 normalizes to '1' 51 | assume(v.epoch != 0 or v.epoch_implicit) 52 | 53 | with pytest.raises(ParseError): 54 | Version.parse(version, strict=True) 55 | 56 | 57 | @given(version_string()) 58 | @settings(suppress_health_check=[HealthCheck.too_slow]) 59 | def test_roundtrip(version): 60 | assert str(Version.parse(version)) == version 61 | 62 | 63 | @given(version_string_from_pep440_regex) 64 | @settings(suppress_health_check=[HealthCheck.too_slow]) 65 | def test_pep440_regex_roundtrip(version): 66 | assert str(Version.parse(version)) == version.lower() 67 | 68 | 69 | @pytest.mark.parametrize( 70 | "version", 71 | [ 72 | "1+ABC", 73 | "1+2-3", 74 | "1+2_3", 75 | "1+02_3", 76 | ], 77 | ) 78 | def test_parse_local_strict(version): 79 | with pytest.raises(ParseError): 80 | Version.parse(version, strict=True) 81 | Version.parse(version) 82 | -------------------------------------------------------------------------------- /src/parver/_helpers.py: -------------------------------------------------------------------------------- 1 | from collections import deque 2 | from collections.abc import Iterable 3 | from typing import Any, TypeVar, Union, cast, overload 4 | 5 | from ._typing import ImplicitZero 6 | 7 | T = TypeVar("T") 8 | R = TypeVar("R") 9 | 10 | 11 | class UnsetType: 12 | def __repr__(self) -> str: 13 | return "UNSET" 14 | 15 | 16 | UNSET = UnsetType() 17 | 18 | 19 | class InfinityType: 20 | def __repr__(self) -> str: 21 | return "Infinity" 22 | 23 | def __hash__(self) -> int: 24 | return hash(repr(self)) 25 | 26 | def __lt__(self, other: Any) -> bool: 27 | return False 28 | 29 | def __le__(self, other: Any) -> bool: 30 | return False 31 | 32 | def __eq__(self, other: Any) -> bool: 33 | return isinstance(other, self.__class__) 34 | 35 | def __ne__(self, other: Any) -> bool: 36 | return not isinstance(other, self.__class__) 37 | 38 | def __gt__(self, other: Any) -> bool: 39 | return True 40 | 41 | def __ge__(self, other: Any) -> bool: 42 | return True 43 | 44 | def __neg__(self) -> "NegativeInfinityType": 45 | return NegativeInfinity 46 | 47 | 48 | Infinity = InfinityType() 49 | 50 | 51 | class NegativeInfinityType: 52 | def __repr__(self) -> str: 53 | return "-Infinity" 54 | 55 | def __hash__(self) -> int: 56 | return hash(repr(self)) 57 | 58 | def __lt__(self, other: Any) -> bool: 59 | return True 60 | 61 | def __le__(self, other: Any) -> bool: 62 | return True 63 | 64 | def __eq__(self, other: Any) -> bool: 65 | return isinstance(other, self.__class__) 66 | 67 | def __ne__(self, other: Any) -> bool: 68 | return not isinstance(other, self.__class__) 69 | 70 | def __gt__(self, other: Any) -> bool: 71 | return False 72 | 73 | def __ge__(self, other: Any) -> bool: 74 | return False 75 | 76 | def __neg__(self) -> InfinityType: 77 | return Infinity 78 | 79 | 80 | NegativeInfinity = NegativeInfinityType() 81 | 82 | 83 | def fixup_module_metadata(module_name: str, namespace: dict[str, Any]) -> None: 84 | def fix_one(obj: Any) -> None: 85 | mod = getattr(obj, "__module__", None) 86 | if mod is not None and mod.startswith("parver."): 87 | obj.__module__ = module_name 88 | if isinstance(obj, type): 89 | for attr_value in obj.__dict__.values(): 90 | fix_one(attr_value) 91 | 92 | for objname in namespace["__all__"]: 93 | obj = namespace[objname] 94 | fix_one(obj) 95 | 96 | 97 | @overload 98 | def last(iterable: Iterable[T]) -> T: 99 | pass 100 | 101 | 102 | @overload 103 | def last(iterable: Iterable[T], *, default: T) -> T: 104 | pass 105 | 106 | 107 | def last(iterable: Iterable[T], *, default: Union[UnsetType, T] = UNSET) -> T: 108 | try: 109 | return deque(iterable, maxlen=1).pop() 110 | except IndexError: 111 | if default is UNSET: 112 | raise 113 | return cast(T, default) 114 | 115 | 116 | IMPLICIT_ZERO: ImplicitZero = "" 117 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterator 2 | from contextlib import contextmanager 3 | 4 | import nox 5 | 6 | nox.options.reuse_existing_virtualenvs = True 7 | nox.options.default_venv_backend = "uv" 8 | 9 | python_versions = ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] 10 | 11 | 12 | @nox.session(python="3.13") 13 | def docs(session: nox.Session) -> None: 14 | session.run_install( 15 | "uv", 16 | "sync", 17 | "--no-dev", 18 | "--group=docstest", 19 | f"--python={session.virtualenv.location}", 20 | env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location}, 21 | ) 22 | 23 | temp_dir = session.create_tmp() 24 | session.run( 25 | "sphinx-build", 26 | "-W", 27 | "-b", 28 | "html", 29 | "-d", 30 | f"{temp_dir}/doctrees", 31 | "docs", 32 | "docs/_build/html", 33 | ) 34 | session.run("doc8", "docs/") 35 | 36 | 37 | @nox.session(python="3.13") 38 | def typing(session: nox.Session) -> None: 39 | session.run_install( 40 | "uv", 41 | "sync", 42 | "--group=typing", 43 | f"--python={session.virtualenv.location}", 44 | env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location}, 45 | ) 46 | session.run("mypy", "src/parver") 47 | 48 | 49 | @nox.session(python=python_versions) 50 | def tests(session: nox.Session) -> None: 51 | session.run_install( 52 | "uv", 53 | "sync", 54 | f"--python={session.virtualenv.location}", 55 | env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location}, 56 | ) 57 | 58 | session.run( 59 | "coverage", 60 | "run", 61 | "-m", 62 | "pytest", 63 | *session.posargs, 64 | ) 65 | 66 | 67 | @nox.session(name="test-min-deps", python=python_versions) 68 | def test_min_deps(session: nox.Session) -> None: 69 | with restore_file("uv.lock"): 70 | session.run_install( 71 | "uv", 72 | "sync", 73 | "--resolution=lowest-direct", 74 | f"--python={session.virtualenv.location}", 75 | env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location}, 76 | ) 77 | 78 | session.run("pytest", *session.posargs) 79 | 80 | 81 | @nox.session(name="test-latest", python=python_versions) 82 | def test_latest(session: nox.Session) -> None: 83 | session.run_install( 84 | "uv", 85 | "sync", 86 | "--no-install-project", 87 | f"--python={session.virtualenv.location}", 88 | env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location}, 89 | ) 90 | session.run_install( 91 | "uv", 92 | "pip", 93 | "install", 94 | "--upgrade", 95 | ".", 96 | f"--python={session.virtualenv.location}", 97 | env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location}, 98 | ) 99 | 100 | session.run("pytest", *session.posargs) 101 | 102 | 103 | @contextmanager 104 | def restore_file(path: str) -> Iterator[None]: 105 | with open(path, "rb") as f: 106 | original = f.read() 107 | try: 108 | yield 109 | finally: 110 | with open(path, "wb") as f: 111 | f.write(original) 112 | -------------------------------------------------------------------------------- /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.9" 11 | license = "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 | "Programming Language :: Python :: 3 :: Only", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Programming Language :: Python :: 3.13", 25 | "Programming Language :: Python :: 3.14", 26 | "Programming Language :: Python :: Implementation :: CPython", 27 | "Programming Language :: Python :: Implementation :: PyPy", 28 | ] 29 | dependencies = [ 30 | "arpeggio>=2.0.2", 31 | "attrs>=25.3.0", 32 | "typing-extensions>=4.14.0; python_version<'3.10'", 33 | ] 34 | 35 | [dependency-groups] 36 | dev = [ 37 | { include-group = "coverage" }, 38 | { include-group = "nox" }, 39 | "hypothesis>=6.135.24", 40 | "pretend>=1.0.9", 41 | "pytest>=8.4.1", 42 | "ruff==0.14.9", 43 | ] 44 | docs = [ 45 | "furo>=2024.8.6", 46 | "sphinx>=7.4.7", 47 | ] 48 | docstest = [ 49 | { include-group = "docs" }, 50 | "doc8>=1.1.2", 51 | ] 52 | coverage = [ 53 | "coverage[toml]>=7.9.2", 54 | ] 55 | nox = [ 56 | "nox>=2025.5.1", 57 | ] 58 | typing = [ 59 | "mypy>=1.16.1", 60 | ] 61 | 62 | [project.urls] 63 | Documentation = "https://parver.readthedocs.io" 64 | "Source Code" = "https://github.com/RazerM/parver" 65 | 66 | [tool.setuptools] 67 | package-dir = { "" = "src" } 68 | 69 | [tool.setuptools.packages.find] 70 | where = ["src"] 71 | 72 | [tool.coverage.run] 73 | branch = true 74 | relative_files = true 75 | source = ["parver", "tests/"] 76 | parallel = true 77 | 78 | [tool.coverage.paths] 79 | source = ["src/parver", ".nox/**/site-packages/parver"] 80 | 81 | [tool.coverage.report] 82 | precision = 1 83 | exclude_lines = [ 84 | "pragma: no cover", 85 | '^\s*pass$', 86 | '^\s*raise NotImplementedError\b', 87 | '^\s*return NotImplemented\b', 88 | '^\s*raise$', 89 | '^\s*\.\.\.$', 90 | '^\s*if (typing\.)?TYPE_CHECKING:', 91 | '^\s*(typing\.)?assert_never\(' 92 | ] 93 | 94 | [tool.mypy] 95 | warn_unused_configs = true 96 | show_error_codes = true 97 | disallow_any_generics = true 98 | disallow_subclassing_any = true 99 | disallow_untyped_calls = true 100 | disallow_untyped_defs = true 101 | disallow_incomplete_defs = true 102 | check_untyped_defs = true 103 | disallow_untyped_decorators = true 104 | warn_unused_ignores = true 105 | warn_return_any = true 106 | no_implicit_reexport = true 107 | 108 | [[tool.mypy.overrides]] 109 | module = [ 110 | "arpeggio.*", 111 | ] 112 | ignore_missing_imports = true 113 | 114 | [tool.pytest.ini_options] 115 | testpaths = ["tests"] 116 | addopts = "-r s" 117 | 118 | [tool.ruff.lint] 119 | select = [ 120 | "E4", "E7", "E9", "F", # ruff defaults 121 | "I", # isort 122 | "UP", # pyupgrade 123 | "RUF", # ruff 124 | ] 125 | 126 | [tool.doc8] 127 | ignore-path = ["docs/_build", "docs/requirements.txt"] 128 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/strategies.py: -------------------------------------------------------------------------------- 1 | import re 2 | import string 3 | 4 | from hypothesis.strategies import ( 5 | composite, 6 | from_regex, 7 | integers, 8 | just, 9 | lists, 10 | one_of, 11 | sampled_from, 12 | text, 13 | ) 14 | 15 | from parver import Version 16 | 17 | num_int = integers(min_value=0) 18 | num_str = num_int.map(str) 19 | 20 | 21 | def epoch(): 22 | epoch = num_str.map(lambda s: s + "!") 23 | return one_of(just(""), epoch) 24 | 25 | 26 | @composite 27 | def release(draw): 28 | return draw( 29 | num_str.map(lambda s: [s, *draw(lists(num_str.map(lambda s: "." + s)))]).map( 30 | lambda parts: "".join(parts) 31 | ) 32 | ) 33 | 34 | 35 | def separator(strict=False, optional=False): 36 | sep = ["."] 37 | 38 | if optional: 39 | sep.append("") 40 | 41 | if not strict: 42 | sep.extend(["-", "_"]) 43 | 44 | return sampled_from(sep) 45 | 46 | 47 | @composite 48 | def pre(draw, strict=False): 49 | words = ["a", "b", "rc"] 50 | if not strict: 51 | words.extend(["c", "alpha", "beta", "pre", "preview"]) 52 | 53 | blank = just("") 54 | 55 | sep1 = separator(strict=strict, optional=True) 56 | if strict: 57 | sep1 = blank 58 | 59 | word = sampled_from(words) 60 | 61 | if strict: 62 | sep2 = blank 63 | else: 64 | sep2 = separator(strict=strict, optional=True) 65 | 66 | num_part = sep2.map(lambda s: s + draw(num_str)) 67 | if not strict: 68 | num_part = one_of(blank, num_part) 69 | 70 | nonempty = sep1.map(lambda s: s + draw(word) + draw(num_part)) 71 | 72 | return draw(one_of(blank, nonempty)) 73 | 74 | 75 | @composite 76 | def post(draw, strict=False): 77 | words = ["post"] 78 | if not strict: 79 | words.extend(["r", "rev"]) 80 | 81 | sep1 = separator(strict=strict, optional=not strict) 82 | word = sampled_from(words) 83 | 84 | blank = just("") 85 | 86 | sep2 = separator(strict=strict, optional=True) 87 | if strict: 88 | sep2 = blank 89 | 90 | num_part = sep2.map(lambda s: s + draw(num_str)) 91 | if not strict: 92 | num_part = one_of(blank, num_part) 93 | 94 | post = sep1.map(lambda s: s + draw(word) + draw(num_part)) 95 | 96 | if strict: 97 | return draw(post) 98 | 99 | post_implicit = num_str.map(lambda s: "-" + s) 100 | 101 | return draw(one_of(blank, post_implicit, post)) 102 | 103 | 104 | @composite 105 | def dev(draw, strict=False): 106 | sep = separator(strict=strict, optional=not strict) 107 | 108 | blank = just("") 109 | 110 | sep2 = separator(strict=strict, optional=True) 111 | if strict: 112 | sep2 = blank 113 | 114 | num_part = sep2.map(lambda s: s + draw(num_str)) 115 | if not strict: 116 | num_part = one_of(blank, num_part) 117 | 118 | return draw(one_of(blank, sep.map(lambda s: s + "dev" + draw(num_part)))) 119 | 120 | 121 | @composite 122 | def local_segment(draw): 123 | alpha = ( 124 | draw(one_of(just(""), integers(0, 9).map(str))) 125 | + draw(text(string.ascii_lowercase, min_size=1, max_size=1)) 126 | + draw(text(string.ascii_lowercase + string.digits)) 127 | ) 128 | return draw(one_of(num_str, just(alpha))) 129 | 130 | 131 | @composite 132 | def local(draw, strict=False): 133 | if strict: 134 | sep = just(".") 135 | else: 136 | sep = sampled_from("-_.") 137 | 138 | part = local_segment() 139 | sep_part = sep.map(lambda s: s + draw(local_segment())) 140 | sep_parts = lists(sep_part).map(lambda parts: "".join(parts)) 141 | 142 | return draw(one_of(just(""), part.map(lambda s: "+" + s + draw(sep_parts)))) 143 | 144 | 145 | whitespace = sampled_from(["", "\t", "\n", "\r", "\f", "\v"]) 146 | 147 | 148 | def vchar(strict=False): 149 | if strict: 150 | return just("") 151 | return sampled_from(["", "v"]) 152 | 153 | 154 | @composite 155 | def version_string(draw, strict=False): 156 | return ( 157 | draw(vchar(strict=strict)) 158 | + draw(epoch()) 159 | + draw(release()) 160 | + draw(pre(strict=strict)) 161 | + draw(post(strict=strict)) 162 | + draw(dev(strict=strict)) 163 | + draw(local(strict=strict)) 164 | ) 165 | 166 | 167 | @composite 168 | def version_strategy(draw, strict=False): 169 | return Version.parse(draw(version_string(strict=strict))) 170 | 171 | 172 | # The unmodified regex from PEP 440 173 | version_pattern = r""" 174 | v? 175 | (?: 176 | (?:(?P[0-9]+)!)? # epoch 177 | (?P[0-9]+(?:\.[0-9]+)*) # release segment 178 | (?P
                                          # pre-release
179 |             [-_\.]?
180 |             (?P(a|b|c|rc|alpha|beta|pre|preview))
181 |             [-_\.]?
182 |             (?P[0-9]+)?
183 |         )?
184 |         (?P                                         # post release
185 |             (?:-(?P[0-9]+))
186 |             |
187 |             (?:
188 |                 [-_\.]?
189 |                 (?Ppost|rev|r)
190 |                 [-_\.]?
191 |                 (?P[0-9]+)?
192 |             )
193 |         )?
194 |         (?P                                          # dev release
195 |             [-_\.]?
196 |             (?Pdev)
197 |             [-_\.]?
198 |             (?P[0-9]+)?
199 |         )?
200 |     )
201 |     (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
202 | """
203 | 
204 | # Now update it so that hypothesis doesn't generate cases we don't care about.
205 | 
206 | # Prevent leading zeros in integers
207 | version_pattern = version_pattern.replace("[0-9]+", "(?:0|[1-9][0-9]*)")
208 | # Prevent leading zeros in integer segments of local version identifiers
209 | version_pattern = version_pattern.replace(
210 |     "[a-z0-9]+", "(?:[a-z0-9]*[a-z][a-z0-9]*|0|[1-9][0-9]*)"
211 | )
212 | 
213 | version_regex = re.compile(
214 |     rf"^{version_pattern}\Z",
215 |     re.VERBOSE | re.IGNORECASE,
216 | )
217 | 
218 | version_string_from_pep440_regex = from_regex(version_regex)
219 | 


--------------------------------------------------------------------------------
/src/parver/_parse.py:
--------------------------------------------------------------------------------
  1 | from threading import Lock
  2 | from typing import Optional, 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 opt_sep_num
 23 |     pre_tag = "a" / "b" / "rc"
 24 |     post = sep post_tag opt_sep_num
 25 |     opt_sep_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 opt_sep_num?
 42 |     pre_tag = "c" / "rc" / "alpha" / "a" / "beta" / "b" / "preview" / "pre"
 43 |     post = sep? post_tag opt_sep_num?
 44 |     post_implicit = "-" int
 45 |     post_tag = "post" / "rev" / "r"
 46 |     dev = sep? dev_tag opt_sep_num?
 47 |     dev_tag = "dev"
 48 |     opt_sep_num = sep? int / sep !(post_tag / dev_tag)
 49 |     local = "+" local_part (sep local_part)*
 50 |     local_part = alpha / int
 51 |     sep = dot / "-" / "_"
 52 |     dot = "."
 53 |     int = r'[0-9]+'
 54 |     alpha = r'[0-9]*[a-z][a-z0-9]*'
 55 | """
 56 | 
 57 | _strict_parser = _permissive_parser = None
 58 | _parser_create_lock = Lock()
 59 | 
 60 | 
 61 | @attr.s(slots=True)
 62 | class Sep:
 63 |     value: Optional[Separator] = attr.ib()
 64 | 
 65 | 
 66 | @attr.s(slots=True)
 67 | class Tag:
 68 |     value: Union[PreTag, PostTag] = attr.ib()
 69 | 
 70 | 
 71 | class VersionVisitor(PTNodeVisitor):  # type: ignore[misc]
 72 |     def visit_version(
 73 |         self, node: Node, children: SemanticActionResults
 74 |     ) -> list[segment.Segment]:
 75 |         return list(children)
 76 | 
 77 |     def visit_v(self, node: Node, children: SemanticActionResults) -> segment.V:
 78 |         return segment.V()
 79 | 
 80 |     def visit_epoch(self, node: Node, children: SemanticActionResults) -> segment.Epoch:
 81 |         return segment.Epoch(children[0])
 82 | 
 83 |     def visit_release(
 84 |         self, node: Node, children: SemanticActionResults
 85 |     ) -> segment.Release:
 86 |         return segment.Release(tuple(children))
 87 | 
 88 |     def visit_pre(self, node: Node, children: SemanticActionResults) -> segment.Pre:
 89 |         sep1: Union[Separator, None, UnsetType] = UNSET
 90 |         tag: Union[PreTag, UnsetType] = UNSET
 91 |         sep2: Union[Separator, None, UnsetType] = UNSET
 92 |         num: Union[ImplicitZero, int, UnsetType] = UNSET
 93 | 
 94 |         for token in children:
 95 |             if sep1 is UNSET:
 96 |                 if isinstance(token, Sep):
 97 |                     sep1 = token.value
 98 |                 elif isinstance(token, Tag):
 99 |                     sep1 = None
100 |                     tag = cast(PreTag, token.value)
101 |             elif tag is UNSET:
102 |                 tag = token.value
103 |             else:
104 |                 assert isinstance(token, tuple)
105 |                 assert len(token) == 2
106 |                 sep2 = token[0].value
107 |                 num = token[1]
108 | 
109 |         if sep2 is UNSET:
110 |             sep2 = None
111 |             num = IMPLICIT_ZERO
112 | 
113 |         assert not isinstance(sep1, UnsetType)
114 |         assert not isinstance(tag, UnsetType)
115 |         assert not isinstance(sep2, UnsetType)
116 |         assert not isinstance(num, UnsetType)
117 | 
118 |         return segment.Pre(sep1=sep1, tag=tag, sep2=sep2, value=num)
119 | 
120 |     def visit_opt_sep_num(
121 |         self, node: Node, children: SemanticActionResults
122 |     ) -> tuple[Sep, Union[ImplicitZero, int]]:
123 |         # when "opt_sep_num = int", visit_int isn't called for some reason
124 |         # I don't understand. Let's call int() manually
125 |         if isinstance(node, Terminal):
126 |             return Sep(None), int(node.value)
127 | 
128 |         if len(children) == 1:
129 |             if isinstance(children[0], Sep):
130 |                 return children[0], IMPLICIT_ZERO
131 |             else:
132 |                 return Sep(None), children[0]
133 |         else:
134 |             return cast("tuple[Sep, int]", tuple(children[:2]))
135 | 
136 |     def visit_pre_tag(self, node: Node, children: SemanticActionResults) -> Tag:
137 |         return Tag(node.value)
138 | 
139 |     def visit_post(self, node: Node, children: SemanticActionResults) -> segment.Post:
140 |         sep1: Union[Separator, None, UnsetType] = UNSET
141 |         tag: Union[PostTag, None, UnsetType] = UNSET
142 |         sep2: Union[Separator, None, UnsetType] = UNSET
143 |         num: Union[ImplicitZero, int, UnsetType] = UNSET
144 | 
145 |         for token in children:
146 |             if sep1 is UNSET:
147 |                 if isinstance(token, Sep):
148 |                     sep1 = token.value
149 |                 elif isinstance(token, Tag):
150 |                     sep1 = None
151 |                     tag = cast(PostTag, token.value)
152 |             elif tag is UNSET:
153 |                 tag = token.value
154 |             else:
155 |                 assert isinstance(token, tuple)
156 |                 assert len(token) == 2
157 |                 sep2 = token[0].value
158 |                 num = token[1]
159 | 
160 |         if sep2 is UNSET:
161 |             sep2 = None
162 |             num = IMPLICIT_ZERO
163 | 
164 |         assert not isinstance(sep1, UnsetType)
165 |         assert not isinstance(tag, UnsetType)
166 |         assert not isinstance(sep2, UnsetType)
167 |         assert not isinstance(num, UnsetType)
168 | 
169 |         return segment.Post(sep1=sep1, tag=tag, sep2=sep2, value=num)
170 | 
171 |     def visit_post_tag(self, node: Node, children: SemanticActionResults) -> Tag:
172 |         return Tag(node.value)
173 | 
174 |     def visit_post_implicit(
175 |         self, node: Node, children: SemanticActionResults
176 |     ) -> segment.Post:
177 |         return segment.Post(sep1=UNSET, tag=None, sep2=UNSET, value=children[0])
178 | 
179 |     def visit_dev(self, node: Node, children: SemanticActionResults) -> segment.Dev:
180 |         num: Union[ImplicitZero, int] = IMPLICIT_ZERO
181 |         sep1: Union[Separator, None, UnsetType] = UNSET
182 |         sep2: Union[Separator, None, UnsetType] = UNSET
183 | 
184 |         for token in children:
185 |             if isinstance(token, Sep):
186 |                 assert sep1 is UNSET
187 |                 sep1 = token.value
188 |             elif isinstance(token, int):
189 |                 # we should only get an int if there's no sep2 - if there is,
190 |                 # we should get a tuple
191 |                 assert sep2 is UNSET
192 |                 sep2 = None
193 |                 num = token
194 |             elif isinstance(token, tuple):
195 |                 assert len(token) == 2
196 |                 sep2 = token[0].value
197 |                 num = token[1]
198 |             else:
199 |                 raise AssertionError(f"unknown dev child token type: {token!r}")
200 | 
201 |         # if there is a dev segment at all, the first sep is always known
202 |         if isinstance(sep1, UnsetType):
203 |             sep1 = None
204 | 
205 |         return segment.Dev(value=num, sep1=sep1, sep2=sep2)
206 | 
207 |     def visit_local(self, node: Node, children: SemanticActionResults) -> segment.Local:
208 |         return segment.Local("".join(str(getattr(c, "value", c)) for c in children))
209 | 
210 |     def visit_int(self, node: Node, children: SemanticActionResults) -> int:
211 |         return int(node.value)
212 | 
213 |     def visit_sep(self, node: Node, children: SemanticActionResults) -> Sep:
214 |         return Sep(node.value)
215 | 
216 | 
217 | class ParseError(ValueError):
218 |     """Raised when parsing an invalid version number."""
219 | 
220 | 
221 | def _get_parser(strict: bool) -> ParserPEG:
222 |     """Ensure the module-level peg parser is created and return it."""
223 |     global _strict_parser, _permissive_parser
224 | 
225 |     # Each branch below only acquires the lock if the global is unset.
226 | 
227 |     if strict:
228 |         if _strict_parser is None:
229 |             with _parser_create_lock:
230 |                 if _strict_parser is None:
231 |                     _strict_parser = ParserPEG(
232 |                         canonical, root_rule_name="version", skipws=False
233 |                     )
234 | 
235 |         return _strict_parser
236 |     else:
237 |         if _permissive_parser is None:
238 |             with _parser_create_lock:
239 |                 if _permissive_parser is None:
240 |                     _permissive_parser = ParserPEG(
241 |                         permissive,
242 |                         root_rule_name="version",
243 |                         skipws=False,
244 |                         ignore_case=True,
245 |                     )
246 | 
247 |         return _permissive_parser
248 | 
249 | 
250 | def parse(version: str, strict: bool = False) -> list[segment.Segment]:
251 |     parser = _get_parser(strict)
252 | 
253 |     try:
254 |         tree = parser.parse(version.strip())
255 |     except NoMatch as exc:
256 |         raise ParseError(str(exc)) from None
257 | 
258 |     return cast("list[segment.Segment]", visit_parse_tree(tree, VersionVisitor()))
259 | 


--------------------------------------------------------------------------------
/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_sep1="."),
114 |         dict(dev=None, dev_sep2="."),
115 |         dict(dev=None, post_sep1="."),
116 |         dict(dev=None, post_sep2="."),
117 |     ],
118 | )
119 | def test_invalid(kwargs):
120 |     """Test bad keyword combinations."""
121 |     with pytest.raises(ValueError):
122 |         Version(release=1, **kwargs)
123 | 
124 | 
125 | @pytest.mark.parametrize(
126 |     "kwargs",
127 |     [
128 |         dict(release="1"),
129 |         dict(v=3),
130 |         dict(post=True),
131 |         dict(epoch="1"),
132 |         dict(pre_tag="b", pre="2"),
133 |         dict(post="3"),
134 |         dict(dev="3"),
135 |         dict(local=[1, "abc"]),
136 |         dict(local=1),
137 |     ],
138 | )
139 | def test_validation_type(kwargs):
140 |     if "release" not in kwargs:
141 |         kwargs["release"] = 1
142 | 
143 |     with pytest.raises(TypeError):
144 |         # print so we can see output when test fails
145 |         print(Version(**kwargs))
146 | 
147 | 
148 | @pytest.mark.parametrize(
149 |     "release, exc, match",
150 |     [
151 |         ([], ValueError, "'release' cannot be empty"),
152 |         (-1, ValueError, r"'release' must be non-negative \(got -1\)"),
153 |         ([4, -1], ValueError, r"'release' must be non-negative \(got -1\)"),
154 |         ([4, "a"], TypeError, r"'release' must be.*int"),
155 |         ([4, True], TypeError, r"'release' must not be a bool"),
156 |     ],
157 | )
158 | def test_release_validation(release, exc, match):
159 |     with pytest.raises(exc, match=match):
160 |         Version(release=release)
161 | 
162 | 
163 | @pytest.mark.parametrize(
164 |     "kwargs",
165 |     [
166 |         dict(pre_tag="alph"),
167 |         dict(pre_tag="a", pre_sep1="x"),
168 |         dict(pre_tag="a", pre_sep2="x"),
169 |         dict(post=1, post_sep1="x"),
170 |         dict(post=1, post_sep2="x"),
171 |         dict(dev=4, dev_sep1="y"),
172 |         dict(dev=4, dev_sep2="y"),
173 |         dict(post_tag=None, post=1, post_sep1="."),
174 |         dict(post_tag=None, post=1, post_sep2="."),
175 |         dict(epoch=-1),
176 |         dict(pre_tag="a", pre=-1),
177 |         dict(post=-1),
178 |         dict(dev=-1),
179 |     ],
180 | )
181 | def test_validation_value(kwargs):
182 |     kwargs.setdefault("release", 1)
183 | 
184 |     with pytest.raises(ValueError):
185 |         # print so we can see output when test fails
186 |         print(Version(**kwargs))
187 | 
188 | 
189 | @pytest.mark.parametrize(
190 |     "kwargs, values, version",
191 |     [
192 |         (
193 |             dict(release=1),
194 |             dict(
195 |                 release=(1,),
196 |                 v=False,
197 |                 epoch=0,
198 |                 epoch_implicit=True,
199 |                 pre_tag=None,
200 |                 pre=None,
201 |                 pre_implicit=False,
202 |                 pre_sep1=None,
203 |                 pre_sep2=None,
204 |                 post=None,
205 |                 post_tag=None,
206 |                 post_implicit=False,
207 |                 post_sep1=None,
208 |                 post_sep2=None,
209 |                 dev=None,
210 |                 dev_implicit=False,
211 |                 dev_sep1=None,
212 |                 dev_sep2=None,
213 |                 local=None,
214 |             ),
215 |             "1",
216 |         ),
217 |         (
218 |             dict(epoch=0),
219 |             dict(epoch=0, epoch_implicit=False),
220 |             "0!1",
221 |         ),
222 |         (
223 |             dict(pre="", pre_tag="b"),
224 |             dict(
225 |                 pre=0,
226 |                 pre_tag="b",
227 |                 pre_implicit=True,
228 |                 pre_sep1=None,
229 |                 pre_sep2=None,
230 |             ),
231 |             "1b",
232 |         ),
233 |         (
234 |             dict(pre=0, pre_tag="a"),
235 |             dict(
236 |                 pre=0,
237 |                 pre_tag="a",
238 |                 pre_implicit=False,
239 |                 pre_sep1=None,
240 |                 pre_sep2=None,
241 |             ),
242 |             "1a0",
243 |         ),
244 |         (
245 |             dict(pre=2, pre_tag="pre", pre_sep1="-", pre_sep2="."),
246 |             dict(
247 |                 pre=2,
248 |                 pre_tag="pre",
249 |                 pre_implicit=False,
250 |                 pre_sep1="-",
251 |                 pre_sep2=".",
252 |             ),
253 |             "1-pre.2",
254 |         ),
255 |         (
256 |             dict(post=""),
257 |             dict(
258 |                 post=0,
259 |                 post_tag="post",
260 |                 post_implicit=True,
261 |                 post_sep1=".",
262 |                 post_sep2=None,
263 |             ),
264 |             "1.post",
265 |         ),
266 |         (
267 |             dict(post="", post_sep2="."),
268 |             dict(
269 |                 post=0,
270 |                 post_tag="post",
271 |                 post_implicit=True,
272 |                 post_sep1=".",
273 |                 post_sep2=".",
274 |             ),
275 |             "1.post.",
276 |         ),
277 |         (
278 |             dict(post=0),
279 |             dict(
280 |                 post=0,
281 |                 post_tag="post",
282 |                 post_implicit=False,
283 |                 post_sep1=".",
284 |                 post_sep2=None,
285 |             ),
286 |             "1.post0",
287 |         ),
288 |         (
289 |             dict(post=0, post_tag=None),
290 |             dict(
291 |                 post=0,
292 |                 post_tag=None,
293 |                 post_implicit=False,
294 |                 post_sep1="-",
295 |                 post_sep2=None,
296 |             ),
297 |             "1-0",
298 |         ),
299 |         (
300 |             dict(post=3, post_tag="rev", post_sep1="-", post_sep2="_"),
301 |             dict(
302 |                 post=3,
303 |                 post_tag="rev",
304 |                 post_implicit=False,
305 |                 post_sep1="-",
306 |                 post_sep2="_",
307 |             ),
308 |             "1-rev_3",
309 |         ),
310 |         (
311 |             dict(dev=""),
312 |             dict(dev=0, dev_implicit=True, dev_sep1="."),
313 |             "1.dev",
314 |         ),
315 |         (
316 |             dict(dev=2),
317 |             dict(dev=2, dev_implicit=False, dev_sep1="."),
318 |             "1.dev2",
319 |         ),
320 |         (
321 |             dict(dev=0, dev_sep1="-"),
322 |             dict(dev=0, dev_implicit=False, dev_sep1="-"),
323 |             "1-dev0",
324 |         ),
325 |         (
326 |             dict(dev=0, dev_sep2="-"),
327 |             dict(dev=0, dev_implicit=False, dev_sep1=".", dev_sep2="-"),
328 |             "1.dev-0",
329 |         ),
330 |         (
331 |             dict(local="a.b"),
332 |             dict(local="a.b"),
333 |             "1+a.b",
334 |         ),
335 |     ],
336 | )
337 | def test_attributes(kwargs, values, version):
338 |     # save us repeating ourselves in test data above
339 |     kwargs.setdefault("release", 1)
340 | 
341 |     v = Version(**kwargs)
342 |     assert str(v) == version
343 |     for key, value in values.items():
344 |         assert getattr(v, key) == value, key
345 | 
346 | 
347 | @given(version_strategy())
348 | @settings(suppress_health_check=[HealthCheck.too_slow])
349 | def test_replace_roundtrip(version):
350 |     """All the logic inside replace() is in converting the attributes to the
351 |     form expected by __init__, so this function tests most of that.
352 |     """
353 |     assert version.replace() == version
354 | 
355 | 
356 | @pytest.mark.parametrize(
357 |     "before, kwargs, after",
358 |     [
359 |         (
360 |             "v0!1.2.alpha-3_rev.4_dev5+l.6",
361 |             dict(
362 |                 release=(2, 1),
363 |                 epoch="",
364 |                 v=False,
365 |                 pre_tag="a",
366 |                 pre_sep1=None,
367 |                 pre_sep2=None,
368 |                 post_tag="post",
369 |                 post_sep1=".",
370 |                 post_sep2=None,
371 |                 dev_sep1=".",
372 |                 dev_sep2=".",
373 |                 local=None,
374 |             ),
375 |             "2.1a3.post4.dev.5",
376 |         ),
377 |         (
378 |             "2.1a3.post4.dev5",
379 |             dict(
380 |                 release=(1, 2),
381 |                 epoch=0,
382 |                 v=True,
383 |                 pre_tag="alpha",
384 |                 pre_sep1=".",
385 |                 pre_sep2="-",
386 |                 post_tag="rev",
387 |                 post_sep1="_",
388 |                 post_sep2=".",
389 |                 dev_sep1="_",
390 |                 dev_sep2="-",
391 |                 local="l.6",
392 |             ),
393 |             "v0!1.2.alpha-3_rev.4_dev-5+l.6",
394 |         ),
395 |         (
396 |             "2.post4",
397 |             dict(post_tag=None),
398 |             "2-4",
399 |         ),
400 |         (
401 |             "1.2.alpha-3_rev.4_dev5",
402 |             dict(pre=None, post=None, dev=None),
403 |             "1.2",
404 |         ),
405 |         (
406 |             "1.2.alpha-3_rev.4_dev5",
407 |             dict(pre=None, post=None, dev=None),
408 |             "1.2",
409 |         ),
410 |         (
411 |             "1.2",
412 |             dict(pre=None, post=None, dev=None),
413 |             "1.2",
414 |         ),
415 |         # Verify that the dot between post and dev is parsed as dev_sep1 and
416 |         # not post_sep2
417 |         (
418 |             "1.post.dev",
419 |             dict(post=None),
420 |             "1.dev",
421 |         ),
422 |         # Verify that the dot between pre and dev is parsed as dev_sep1 and
423 |         # not pre_sep2
424 |         (
425 |             "1.pre.dev",
426 |             dict(pre=None),
427 |             "1.dev",
428 |         ),
429 |         # Verify that the dot between pre and post is parsed as post_sep1 and
430 |         # not pre_sep2
431 |         (
432 |             "1.pre.post",
433 |             dict(pre=None),
434 |             "1.post",
435 |         ),
436 |     ],
437 | )
438 | def test_replace(before, kwargs, after):
439 |     """Make sure the keys we expect are passed through."""
440 |     assert str(Version.parse(before).replace(**kwargs)) == after
441 | 
442 | 
443 | @pytest.mark.parametrize(
444 |     "before, index, after",
445 |     [
446 |         ("1", 0, "2"),
447 |         ("1", 1, "1.1"),
448 |         ("1", 2, "1.0.1"),
449 |         ("1.1", 0, "2.0"),
450 |         ("1.1", 1, "1.2"),
451 |         ("1.1", 2, "1.1.1"),
452 |         ("1.1", 3, "1.1.0.1"),
453 |         ("4.3.2.1", 2, "4.3.3.0"),
454 |     ],
455 | )
456 | def test_bump_release(before, index, after):
457 |     assert str(Version.parse(before).bump_release(index=index)) == after
458 | 
459 | 
460 | @pytest.mark.parametrize(
461 |     "before, index, value, after",
462 |     [
463 |         ("2", 0, 1, "1"),
464 |         ("2", 0, 2, "2"),
465 |         ("2", 0, 3, "3"),
466 |         ("2", 1, 3, "2.3"),
467 |         ("2", 2, 3, "2.0.3"),
468 |         ("2.4", 0, 4, "4.0"),
469 |         ("2.4", 1, 6, "2.6"),
470 |         ("2.4", 2, 6, "2.4.6"),
471 |         ("2.4", 3, 6, "2.4.0.6"),
472 |         ("4.3.2.1", 1, 5, "4.5.0.0"),
473 |         # e.g. CalVer
474 |         ("2017.4", 0, 2018, "2018.0"),
475 |         ("17.5.1", 0, 18, "18.0.0"),
476 |         ("18.0.0", 1, 2, "18.2.0"),
477 |     ],
478 | )
479 | def test_bump_release_to(before, index, value, after):
480 |     v = Version.parse(before).bump_release_to(index=index, value=value)
481 |     assert str(v) == after
482 | 
483 | 
484 | @pytest.mark.parametrize(
485 |     "before, index, value, after",
486 |     [
487 |         ("2", 0, 1, "1"),
488 |         ("2", 0, 2, "2"),
489 |         ("2", 0, 3, "3"),
490 |         ("2", 1, 3, "2.3"),
491 |         ("2", 2, 3, "2.0.3"),
492 |         ("2.4", 0, 4, "4.4"),
493 |         ("2.4", 1, 6, "2.6"),
494 |         ("2.4", 2, 6, "2.4.6"),
495 |         ("2.4", 3, 6, "2.4.0.6"),
496 |         ("2.0.4", 1, 3, "2.3.4"),
497 |         ("4.3.2.1", 1, 5, "4.5.2.1"),
498 |     ],
499 | )
500 | def test_set_release(before, index, value, after):
501 |     v = Version.parse(before).set_release(index=index, value=value)
502 |     assert str(v) == after
503 | 
504 | 
505 | @pytest.mark.parametrize(
506 |     "index, exc",
507 |     [
508 |         ("1", TypeError),
509 |         (1.1, TypeError),
510 |         (-1, ValueError),
511 |     ],
512 | )
513 | def test_bump_release_error(index, exc):
514 |     with pytest.raises(exc):
515 |         print(Version(release=1).bump_release(index=index))
516 | 
517 | 
518 | @pytest.mark.parametrize(
519 |     "by",
520 |     [
521 |         "1",
522 |         1.1,
523 |         None,
524 |     ],
525 | )
526 | def test_bump_by_error(by):
527 |     v = Version(release=1)
528 | 
529 |     with pytest.raises(TypeError):
530 |         v.bump_epoch(by=by)
531 | 
532 |     with pytest.raises(TypeError):
533 |         v.bump_dev(by=by)
534 | 
535 |     with pytest.raises(TypeError):
536 |         v.bump_pre("a", by=by)
537 | 
538 |     with pytest.raises(TypeError):
539 |         v.bump_post(by=by)
540 | 
541 | 
542 | def test_bump_by_value_error():
543 |     v = Version(release=1)
544 | 
545 |     with pytest.raises(ValueError, match="negative"):
546 |         v.bump_epoch(by=-1)
547 | 
548 |     with pytest.raises(ValueError, match="negative"):
549 |         v.bump_dev(by=-1)
550 | 
551 |     with pytest.raises(ValueError, match="negative"):
552 |         v.bump_pre(by=-1)
553 | 
554 |     with pytest.raises(ValueError, match="negative"):
555 |         v.bump_post(by=-1)
556 | 
557 | 
558 | @pytest.mark.parametrize(
559 |     "before, tag, kwargs, after",
560 |     [
561 |         ("1", "a", dict(), "1a0"),
562 |         ("1", "a", dict(by=2), "1a1"),
563 |         ("1a0", None, dict(), "1a1"),
564 |         ("1a", None, dict(), "1a1"),
565 |         ("1a", "a", dict(), "1a1"),
566 |         ("1.b-0", None, dict(), "1.b-1"),
567 |         ("1a1", None, dict(by=-1), "1a0"),
568 |     ],
569 | )
570 | def test_bump_pre(before, tag, kwargs, after):
571 |     assert str(Version.parse(before).bump_pre(tag, **kwargs)) == after
572 | 
573 | 
574 | @pytest.mark.parametrize(
575 |     "version, tag",
576 |     [
577 |         ("1.2", None),
578 |         ("1.2a", "b"),
579 |     ],
580 | )
581 | def test_bump_pre_error(version, tag):
582 |     with pytest.raises(ValueError):
583 |         print(Version.parse(version).bump_pre(tag))
584 | 
585 | 
586 | @pytest.mark.parametrize(
587 |     "before, kwargs, after",
588 |     [
589 |         ("1", dict(), "1.post0"),
590 |         ("1", dict(by=2), "1.post1"),
591 |         ("1.post0", dict(), "1.post1"),
592 |         ("1rev", dict(), "1rev1"),
593 |         ("1-0", dict(), "1-1"),
594 |         ("1-0", dict(tag="post"), "1.post1"),
595 |         ("1-post_0", dict(tag=None), "1-1"),
596 |         ("1.post1", dict(by=-1), "1.post0"),
597 |     ],
598 | )
599 | def test_bump_post(before, kwargs, after):
600 |     assert str(Version.parse(before).bump_post(**kwargs)) == after
601 | 
602 | 
603 | @pytest.mark.parametrize(
604 |     "before, kwargs, after",
605 |     [
606 |         ("1", dict(), "1.dev0"),
607 |         ("1", dict(by=2), "1.dev1"),
608 |         ("1.dev0", dict(), "1.dev1"),
609 |         ("1-dev1", dict(), "1-dev2"),
610 |         ("1-dev1", dict(by=-1), "1-dev0"),
611 |     ],
612 | )
613 | def test_bump_dev(before, kwargs, after):
614 |     assert str(Version.parse(before).bump_dev(**kwargs)) == after
615 | 
616 | 
617 | @pytest.mark.parametrize(
618 |     "before, kwargs, after",
619 |     [
620 |         ("2", dict(), "1!2"),
621 |         ("2", dict(by=2), "2!2"),
622 |         ("0!3", dict(), "1!3"),
623 |         ("1!4", dict(), "2!4"),
624 |         ("1!4", dict(by=-1), "0!4"),
625 |         ("1!4", dict(by=2), "3!4"),
626 |     ],
627 | )
628 | def test_bump_epoch(before, kwargs, after):
629 |     assert str(Version.parse(before).bump_epoch(**kwargs)) == after
630 | 
631 | 
632 | @pytest.mark.parametrize(
633 |     "arg, expected",
634 |     [
635 |         (1, (1,)),
636 |         ([1], (1,)),
637 |         ((1, 2), (1, 2)),
638 |         ([1, 2], (1, 2)),
639 |         # range is a Sequence
640 |         (range(1, 3), (1, 2)),
641 |         # An iterable that is not also a sequence
642 |         ((x for x in range(1, 3)), (1, 2)),
643 |     ],
644 | )
645 | def test_release_tuple(arg, expected):
646 |     v = Version(release=arg)
647 |     assert isinstance(v.release, tuple)
648 |     assert v.release == expected
649 | 
650 | 
651 | @pytest.mark.parametrize(
652 |     "version",
653 |     [
654 |         "1a",
655 |         "1alpha",
656 |         "1a1",
657 |     ],
658 | )
659 | def test_is_alpha(version):
660 |     v = Version.parse(version)
661 |     assert v.is_alpha
662 |     assert not v.is_beta
663 |     assert not v.is_release_candidate
664 | 
665 | 
666 | @pytest.mark.parametrize(
667 |     "version",
668 |     [
669 |         "1b",
670 |         "1beta",
671 |         "1b1",
672 |     ],
673 | )
674 | def test_is_beta(version):
675 |     v = Version.parse(version)
676 |     assert not v.is_alpha
677 |     assert v.is_beta
678 |     assert not v.is_release_candidate
679 | 
680 | 
681 | @pytest.mark.parametrize(
682 |     "version",
683 |     [
684 |         "1rc",
685 |         "1c",
686 |         "1pre",
687 |         "1preview",
688 |         "1rc1",
689 |     ],
690 | )
691 | def test_is_release_candidate(version):
692 |     v = Version.parse(version)
693 |     assert not v.is_alpha
694 |     assert not v.is_beta
695 |     assert v.is_release_candidate
696 | 
697 | 
698 | def test_ambiguous():
699 |     with pytest.raises(ValueError, match=r"post_tag.*pre"):
700 |         Version(release=1, pre="", pre_tag="rc", post=2, post_tag=None)
701 | 
702 |     v = Version(release=1, pre="", pre_tag="rc", pre_sep2=".", post=2, post_tag=None)
703 |     assert str(v) == "1rc.-2"
704 |     assert str(v.normalize()) == "1rc0.post2"
705 | 
706 | 
707 | @pytest.mark.parametrize(
708 |     "before, after, kwargs",
709 |     [
710 |         ("1.0", "1", dict()),
711 |         ("1.0.0", "1", dict()),
712 |         ("1.0.0", "1.0", dict(min_length=2)),
713 |         ("1", "1.0", dict(min_length=2)),
714 |         ("0.0", "0", dict()),
715 |         ("1.0.2", "1.0.2", dict()),
716 |         ("1.0.2", "1.0.2", dict(min_length=1)),
717 |         ("1.0.2.0", "1.0.2", dict()),
718 |         ("1.2.0", "1.2", dict()),
719 |     ],
720 | )
721 | def test_truncate(before, after, kwargs):
722 |     v = Version.parse(before).truncate(**kwargs)
723 |     assert str(v) == after
724 | 
725 | 
726 | def test_truncate_error():
727 |     with pytest.raises(TypeError, match="min_length"):
728 |         Version.parse("1").truncate(min_length="banana")
729 | 
730 |     with pytest.raises(ValueError, match="min_length"):
731 |         Version.parse("1").truncate(min_length=0)
732 | 
733 | 
734 | def test_public_module():
735 |     assert Version.__module__ == "parver"
736 | 


--------------------------------------------------------------------------------
/docs/requirements.txt:
--------------------------------------------------------------------------------
  1 | # This file was autogenerated by uv via the following command:
  2 | #    uv export --frozen --no-dev --group=docs --no-emit-project --no-annotate --output-file=docs/requirements.txt
  3 | accessible-pygments==0.0.5 \
  4 |     --hash=sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872 \
  5 |     --hash=sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7
  6 | alabaster==0.7.16 ; python_full_version < '3.10' \
  7 |     --hash=sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65 \
  8 |     --hash=sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92
  9 | alabaster==1.0.0 ; python_full_version >= '3.10' \
 10 |     --hash=sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e \
 11 |     --hash=sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b
 12 | arpeggio==2.0.3 \
 13 |     --hash=sha256:9374d9c531b62018b787635f37fd81c9a6ee69ef2d28c5db3cd18791b1f7db2f \
 14 |     --hash=sha256:9e85ad35cfc6c938676817c7ae9a1000a7c72a34c71db0c687136c460d12b85e
 15 | attrs==25.4.0 \
 16 |     --hash=sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11 \
 17 |     --hash=sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373
 18 | babel==2.17.0 \
 19 |     --hash=sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d \
 20 |     --hash=sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2
 21 | beautifulsoup4==4.14.3 \
 22 |     --hash=sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb \
 23 |     --hash=sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86
 24 | certifi==2025.11.12 \
 25 |     --hash=sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b \
 26 |     --hash=sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316
 27 | charset-normalizer==3.4.4 \
 28 |     --hash=sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad \
 29 |     --hash=sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93 \
 30 |     --hash=sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394 \
 31 |     --hash=sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89 \
 32 |     --hash=sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc \
 33 |     --hash=sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86 \
 34 |     --hash=sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d \
 35 |     --hash=sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f \
 36 |     --hash=sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8 \
 37 |     --hash=sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0 \
 38 |     --hash=sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505 \
 39 |     --hash=sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161 \
 40 |     --hash=sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152 \
 41 |     --hash=sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318 \
 42 |     --hash=sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72 \
 43 |     --hash=sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4 \
 44 |     --hash=sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e \
 45 |     --hash=sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3 \
 46 |     --hash=sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c \
 47 |     --hash=sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1 \
 48 |     --hash=sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8 \
 49 |     --hash=sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1 \
 50 |     --hash=sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2 \
 51 |     --hash=sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44 \
 52 |     --hash=sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26 \
 53 |     --hash=sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88 \
 54 |     --hash=sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016 \
 55 |     --hash=sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede \
 56 |     --hash=sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf \
 57 |     --hash=sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc \
 58 |     --hash=sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0 \
 59 |     --hash=sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84 \
 60 |     --hash=sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db \
 61 |     --hash=sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1 \
 62 |     --hash=sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed \
 63 |     --hash=sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8 \
 64 |     --hash=sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133 \
 65 |     --hash=sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e \
 66 |     --hash=sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef \
 67 |     --hash=sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14 \
 68 |     --hash=sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0 \
 69 |     --hash=sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d \
 70 |     --hash=sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828 \
 71 |     --hash=sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f \
 72 |     --hash=sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf \
 73 |     --hash=sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6 \
 74 |     --hash=sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328 \
 75 |     --hash=sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090 \
 76 |     --hash=sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381 \
 77 |     --hash=sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c \
 78 |     --hash=sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb \
 79 |     --hash=sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc \
 80 |     --hash=sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a \
 81 |     --hash=sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec \
 82 |     --hash=sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc \
 83 |     --hash=sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac \
 84 |     --hash=sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e \
 85 |     --hash=sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313 \
 86 |     --hash=sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569 \
 87 |     --hash=sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3 \
 88 |     --hash=sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d \
 89 |     --hash=sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525 \
 90 |     --hash=sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894 \
 91 |     --hash=sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9 \
 92 |     --hash=sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a \
 93 |     --hash=sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9 \
 94 |     --hash=sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14 \
 95 |     --hash=sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25 \
 96 |     --hash=sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50 \
 97 |     --hash=sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1 \
 98 |     --hash=sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3 \
 99 |     --hash=sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e \
100 |     --hash=sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815 \
101 |     --hash=sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c \
102 |     --hash=sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6 \
103 |     --hash=sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6 \
104 |     --hash=sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e \
105 |     --hash=sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69 \
106 |     --hash=sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15 \
107 |     --hash=sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191 \
108 |     --hash=sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0 \
109 |     --hash=sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897 \
110 |     --hash=sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd \
111 |     --hash=sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2 \
112 |     --hash=sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794 \
113 |     --hash=sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d \
114 |     --hash=sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224 \
115 |     --hash=sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838 \
116 |     --hash=sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a \
117 |     --hash=sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d \
118 |     --hash=sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f \
119 |     --hash=sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8 \
120 |     --hash=sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490 \
121 |     --hash=sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966 \
122 |     --hash=sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9 \
123 |     --hash=sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3 \
124 |     --hash=sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e \
125 |     --hash=sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608
126 | colorama==0.4.6 ; sys_platform == 'win32' \
127 |     --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
128 |     --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
129 | docutils==0.21.2 \
130 |     --hash=sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f \
131 |     --hash=sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2
132 | furo==2025.9.25 \
133 |     --hash=sha256:2937f68e823b8e37b410c972c371bc2b1d88026709534927158e0cb3fac95afe \
134 |     --hash=sha256:3eac05582768fdbbc2bdfa1cdbcdd5d33cfc8b4bd2051729ff4e026a1d7e0a98
135 | idna==3.11 \
136 |     --hash=sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea \
137 |     --hash=sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902
138 | imagesize==1.4.1 \
139 |     --hash=sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b \
140 |     --hash=sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a
141 | importlib-metadata==8.7.0 ; python_full_version < '3.10' \
142 |     --hash=sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000 \
143 |     --hash=sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd
144 | jinja2==3.1.6 \
145 |     --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
146 |     --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
147 | markupsafe==3.0.3 \
148 |     --hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \
149 |     --hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \
150 |     --hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \
151 |     --hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \
152 |     --hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \
153 |     --hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \
154 |     --hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \
155 |     --hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \
156 |     --hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \
157 |     --hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \
158 |     --hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \
159 |     --hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \
160 |     --hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \
161 |     --hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \
162 |     --hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \
163 |     --hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \
164 |     --hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \
165 |     --hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \
166 |     --hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \
167 |     --hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \
168 |     --hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \
169 |     --hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \
170 |     --hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \
171 |     --hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \
172 |     --hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \
173 |     --hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \
174 |     --hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \
175 |     --hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \
176 |     --hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \
177 |     --hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \
178 |     --hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \
179 |     --hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \
180 |     --hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \
181 |     --hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \
182 |     --hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \
183 |     --hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \
184 |     --hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \
185 |     --hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \
186 |     --hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \
187 |     --hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \
188 |     --hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \
189 |     --hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \
190 |     --hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \
191 |     --hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \
192 |     --hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \
193 |     --hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \
194 |     --hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \
195 |     --hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \
196 |     --hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \
197 |     --hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \
198 |     --hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \
199 |     --hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \
200 |     --hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \
201 |     --hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \
202 |     --hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \
203 |     --hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \
204 |     --hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \
205 |     --hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \
206 |     --hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \
207 |     --hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \
208 |     --hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \
209 |     --hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \
210 |     --hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \
211 |     --hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \
212 |     --hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \
213 |     --hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \
214 |     --hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \
215 |     --hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \
216 |     --hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \
217 |     --hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \
218 |     --hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \
219 |     --hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \
220 |     --hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \
221 |     --hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \
222 |     --hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \
223 |     --hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \
224 |     --hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \
225 |     --hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \
226 |     --hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \
227 |     --hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \
228 |     --hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \
229 |     --hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \
230 |     --hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \
231 |     --hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \
232 |     --hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \
233 |     --hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \
234 |     --hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \
235 |     --hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \
236 |     --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
237 | packaging==25.0 \
238 |     --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \
239 |     --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
240 | pygments==2.19.2 \
241 |     --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \
242 |     --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b
243 | requests==2.32.5 \
244 |     --hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \
245 |     --hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf
246 | roman-numerals-py==3.1.0 ; python_full_version >= '3.11' \
247 |     --hash=sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c \
248 |     --hash=sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d
249 | snowballstemmer==3.0.1 \
250 |     --hash=sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064 \
251 |     --hash=sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895
252 | soupsieve==2.8 \
253 |     --hash=sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c \
254 |     --hash=sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f
255 | sphinx==7.4.7 ; python_full_version < '3.10' \
256 |     --hash=sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe \
257 |     --hash=sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239
258 | sphinx==8.1.3 ; python_full_version == '3.10.*' \
259 |     --hash=sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2 \
260 |     --hash=sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927
261 | sphinx==8.2.3 ; python_full_version >= '3.11' \
262 |     --hash=sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348 \
263 |     --hash=sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3
264 | sphinx-basic-ng==1.0.0b2 \
265 |     --hash=sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9 \
266 |     --hash=sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b
267 | sphinxcontrib-applehelp==2.0.0 \
268 |     --hash=sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1 \
269 |     --hash=sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5
270 | sphinxcontrib-devhelp==2.0.0 \
271 |     --hash=sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad \
272 |     --hash=sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2
273 | sphinxcontrib-htmlhelp==2.1.0 \
274 |     --hash=sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8 \
275 |     --hash=sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9
276 | sphinxcontrib-jsmath==1.0.1 \
277 |     --hash=sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178 \
278 |     --hash=sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8
279 | sphinxcontrib-qthelp==2.0.0 \
280 |     --hash=sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab \
281 |     --hash=sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb
282 | sphinxcontrib-serializinghtml==2.0.0 \
283 |     --hash=sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331 \
284 |     --hash=sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d
285 | tomli==2.3.0 ; python_full_version < '3.11' \
286 |     --hash=sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456 \
287 |     --hash=sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845 \
288 |     --hash=sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999 \
289 |     --hash=sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0 \
290 |     --hash=sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878 \
291 |     --hash=sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf \
292 |     --hash=sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3 \
293 |     --hash=sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be \
294 |     --hash=sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52 \
295 |     --hash=sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b \
296 |     --hash=sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67 \
297 |     --hash=sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549 \
298 |     --hash=sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba \
299 |     --hash=sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22 \
300 |     --hash=sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c \
301 |     --hash=sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f \
302 |     --hash=sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6 \
303 |     --hash=sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba \
304 |     --hash=sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45 \
305 |     --hash=sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f \
306 |     --hash=sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77 \
307 |     --hash=sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606 \
308 |     --hash=sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441 \
309 |     --hash=sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0 \
310 |     --hash=sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f \
311 |     --hash=sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530 \
312 |     --hash=sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05 \
313 |     --hash=sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8 \
314 |     --hash=sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005 \
315 |     --hash=sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879 \
316 |     --hash=sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae \
317 |     --hash=sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc \
318 |     --hash=sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b \
319 |     --hash=sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b \
320 |     --hash=sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e \
321 |     --hash=sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf \
322 |     --hash=sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac \
323 |     --hash=sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8 \
324 |     --hash=sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b \
325 |     --hash=sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf \
326 |     --hash=sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463 \
327 |     --hash=sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876
328 | typing-extensions==4.15.0 \
329 |     --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
330 |     --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
331 | urllib3==2.6.2 \
332 |     --hash=sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797 \
333 |     --hash=sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd
334 | zipp==3.23.0 ; python_full_version < '3.10' \
335 |     --hash=sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e \
336 |     --hash=sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166
337 | 


--------------------------------------------------------------------------------
/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 |             # Verify that the less than (<) operator works correctly
679 |             *[
680 |                 [(x, y, operator.lt) for y in PARSED_VERSIONS[i + 1 :]]
681 |                 for i, x in enumerate(PARSED_VERSIONS)
682 |             ]
683 |             +
684 |             # Verify that the less than equal (<=) operator works correctly
685 |             [
686 |                 [(x, y, operator.le) for y in PARSED_VERSIONS[i:]]
687 |                 for i, x in enumerate(PARSED_VERSIONS)
688 |             ]
689 |             +
690 |             # Verify that the equal (==) operator works correctly
691 |             [[(x, x, operator.eq) for x in PARSED_VERSIONS]]
692 |             +
693 |             # Verify that the not equal (!=) operator works correctly
694 |             [
695 |                 [(x, y, operator.ne) for j, y in enumerate(PARSED_VERSIONS) if i != j]
696 |                 for i, x in enumerate(PARSED_VERSIONS)
697 |             ]
698 |             +
699 |             # Verify that the greater than equal (>=) operator works correctly
700 |             [
701 |                 [(x, y, operator.ge) for y in PARSED_VERSIONS[: i + 1]]
702 |                 for i, x in enumerate(PARSED_VERSIONS)
703 |             ]
704 |             +
705 |             # Verify that the greater than (>) operator works correctly
706 |             [
707 |                 [(x, y, operator.gt) for y in PARSED_VERSIONS[:i]]
708 |                 for i, x in enumerate(PARSED_VERSIONS)
709 |             ]
710 |         ),
711 |     )
712 |     def test_comparison_true(self, left, right, op):
713 |         assert op(left, right)
714 | 
715 |     @pytest.mark.parametrize(
716 |         ("left", "right", "op"),
717 |         # Below we'll generate every possible combination of VERSIONS that
718 |         # should be False for the given operator
719 |         itertools.chain(
720 |             # Verify that the less than (<) operator works correctly
721 |             *[
722 |                 [(x, y, operator.lt) for y in PARSED_VERSIONS[: i + 1]]
723 |                 for i, x in enumerate(PARSED_VERSIONS)
724 |             ]
725 |             +
726 |             # Verify that the less than equal (<=) operator works correctly
727 |             [
728 |                 [(x, y, operator.le) for y in PARSED_VERSIONS[:i]]
729 |                 for i, x in enumerate(PARSED_VERSIONS)
730 |             ]
731 |             +
732 |             # Verify that the equal (==) operator works correctly
733 |             [
734 |                 [(x, y, operator.eq) for j, y in enumerate(PARSED_VERSIONS) if i != j]
735 |                 for i, x in enumerate(PARSED_VERSIONS)
736 |             ]
737 |             +
738 |             # Verify that the not equal (!=) operator works correctly
739 |             [[(x, x, operator.ne) for x in PARSED_VERSIONS]]
740 |             +
741 |             # Verify that the greater than equal (>=) operator works correctly
742 |             [
743 |                 [(x, y, operator.ge) for y in PARSED_VERSIONS[i + 1 :]]
744 |                 for i, x in enumerate(PARSED_VERSIONS)
745 |             ]
746 |             +
747 |             # Verify that the greater than (>) operator works correctly
748 |             [
749 |                 [(x, y, operator.gt) for y in PARSED_VERSIONS[i:]]
750 |                 for i, x in enumerate(PARSED_VERSIONS)
751 |             ]
752 |         ),
753 |     )
754 |     def test_comparison_false(self, left, right, op):
755 |         assert not op(left, right)
756 | 
757 |     @pytest.mark.parametrize(("op", "expected"), [("eq", False), ("ne", True)])
758 |     def test_compare_other(self, op, expected):
759 |         other = pretend.stub(**{f"__{op}__": lambda other: NotImplemented})
760 | 
761 |         assert getattr(operator, op)(Version.parse("1"), other) is expected
762 | 


--------------------------------------------------------------------------------
/src/parver/_version.py:
--------------------------------------------------------------------------------
   1 | import itertools
   2 | import operator
   3 | import re
   4 | from collections.abc import Iterable, Sequence
   5 | from functools import partial
   6 | from typing import (
   7 |     Any,
   8 |     Callable,
   9 |     Optional,
  10 |     Union,
  11 |     cast,
  12 |     overload,
  13 | )
  14 | 
  15 | import attr
  16 | from attr import Attribute, converters
  17 | from attr.validators import and_, deep_iterable, in_, instance_of, optional
  18 | 
  19 | from . import _segments as segment
  20 | from ._helpers import IMPLICIT_ZERO, UNSET, Infinity, UnsetType, last
  21 | from ._parse import parse
  22 | from ._typing import ImplicitZero, NormalizedPreTag, PostTag, PreTag, Separator
  23 | 
  24 | POST_TAGS: set[PostTag] = {"post", "rev", "r"}
  25 | SEPS: set[Separator] = {".", "-", "_"}
  26 | PRE_TAGS: set[PreTag] = {"c", "rc", "alpha", "a", "beta", "b", "preview", "pre"}
  27 | 
  28 | _ValidatorType = Callable[[Any, "Attribute[Any]", Any], None]
  29 | 
  30 | 
  31 | def unset_or(validator: _ValidatorType) -> _ValidatorType:
  32 |     def validate(inst: Any, attr: "Attribute[Any]", value: Any) -> None:
  33 |         if value is UNSET:
  34 |             return
  35 | 
  36 |         validator(inst, attr, value)
  37 | 
  38 |     return validate
  39 | 
  40 | 
  41 | def implicit_or(
  42 |     validator: Union[_ValidatorType, Sequence[_ValidatorType]],
  43 | ) -> _ValidatorType:
  44 |     if isinstance(validator, Sequence):
  45 |         validator = and_(*validator)
  46 | 
  47 |     def validate(inst: Any, attr: "Attribute[Any]", value: Any) -> None:
  48 |         if value == IMPLICIT_ZERO:
  49 |             return
  50 | 
  51 |         validator(inst, attr, value)
  52 | 
  53 |     return validate
  54 | 
  55 | 
  56 | def not_bool(inst: Any, attr: "Attribute[Any]", value: Any) -> None:
  57 |     if isinstance(value, bool):
  58 |         raise TypeError(f"'{attr.name}' must not be a bool (got {value!r})")
  59 | 
  60 | 
  61 | def is_non_negative(inst: Any, attr: "Attribute[Any]", value: Any) -> None:
  62 |     if value < 0:
  63 |         raise ValueError(f"'{attr.name}' must be non-negative (got {value!r})")
  64 | 
  65 | 
  66 | def non_empty(inst: Any, attr: "Attribute[Any]", value: Any) -> None:
  67 |     if not value:
  68 |         raise ValueError(f"'{attr.name}' cannot be empty")
  69 | 
  70 | 
  71 | def check_by(by: int, current: Optional[int]) -> None:
  72 |     if not isinstance(by, int):
  73 |         raise TypeError("by must be an integer")
  74 | 
  75 |     if current is None and by < 0:
  76 |         raise ValueError("Cannot bump by negative amount when current value is unset.")
  77 | 
  78 | 
  79 | validate_post_tag: _ValidatorType = unset_or(optional(in_(POST_TAGS)))
  80 | validate_pre_tag: _ValidatorType = optional(in_(PRE_TAGS))
  81 | validate_sep: _ValidatorType = optional(in_(SEPS))
  82 | validate_sep_or_unset: _ValidatorType = unset_or(optional(in_(SEPS)))
  83 | is_bool: _ValidatorType = instance_of(bool)
  84 | is_int: _ValidatorType = instance_of(int)
  85 | is_str: _ValidatorType = instance_of(str)
  86 | is_tuple: _ValidatorType = instance_of(tuple)
  87 | 
  88 | # "All numeric components MUST be non-negative integers."
  89 | num_comp = [not_bool, is_int, is_non_negative]
  90 | 
  91 | release_validator = deep_iterable(and_(*num_comp), and_(is_tuple, non_empty))
  92 | 
  93 | 
  94 | def convert_release(release: Union[int, Iterable[int]]) -> tuple[int, ...]:
  95 |     if isinstance(release, Iterable) and not isinstance(release, str):
  96 |         return tuple(release)
  97 |     elif isinstance(release, int):
  98 |         return (release,)
  99 | 
 100 |     # The input value does not conform to the function type, let it pass through
 101 |     # to the validator
 102 |     return release
 103 | 
 104 | 
 105 | def convert_local(local: Optional[str]) -> Optional[str]:
 106 |     if isinstance(local, str):
 107 |         return local.lower()
 108 |     return local
 109 | 
 110 | 
 111 | def convert_implicit(value: Union[ImplicitZero, int]) -> int:
 112 |     """This function is a lie, since mypy's attrs plugin takes the argument type
 113 |     as that of the constructed __init__. The lie is required because we aren't
 114 |     dealing with ImplicitZero until __attrs_post_init__.
 115 |     """
 116 |     return value  # type: ignore[return-value]
 117 | 
 118 | 
 119 | @attr.s(frozen=True, repr=False, eq=False)
 120 | class Version:
 121 |     """
 122 | 
 123 |     :param release: Numbers for the release segment.
 124 | 
 125 |     :param v: Optional preceding v character.
 126 | 
 127 |     :param epoch: `Version epoch`_. Implicitly zero but hidden by default.
 128 | 
 129 |     :param pre_tag: `Pre-release`_ identifier, typically `a`, `b`, or `rc`.
 130 |         Required to signify a pre-release.
 131 | 
 132 |     :param pre: `Pre-release`_ number. May be ``''`` to signify an
 133 |         `implicit pre-release number`_.
 134 | 
 135 |     :param post: `Post-release`_ number. May be ``''`` to signify an
 136 |         `implicit post release number`_.
 137 | 
 138 |     :param dev: `Developmental release`_ number. May be ``''`` to signify an
 139 |         `implicit development release number`_.
 140 | 
 141 |     :param local: `Local version`_ segment.
 142 | 
 143 |     :param pre_sep1: Specify an alternate separator before the pre-release
 144 |         segment. The normal form is `None`.
 145 | 
 146 |     :param pre_sep2: Specify an alternate separator between the identifier and
 147 |         number. The normal form is `None`.
 148 | 
 149 |     :param post_sep1: Specify an alternate separator before the post release
 150 |         segment. The normal form is ``'.'``.
 151 | 
 152 |     :param post_sep2: Specify an alternate separator between the identifier and
 153 |         number. The normal form is `None`.
 154 | 
 155 |     :param dev_sep1: Specify an alternate separator before the development
 156 |         release segment. The normal form is ``'.'``.
 157 | 
 158 |     :param dev_sep2: Specify an alternate separator between the identifier and
 159 |         number. The normal form is `None`.
 160 | 
 161 |     :param post_tag: Specify alternate post release identifier `rev` or `r`.
 162 |         May be `None` to signify an `implicit post release`_.
 163 | 
 164 |     .. note:: The attributes below are not equal to the parameters passed to
 165 |         the initialiser!
 166 | 
 167 |         The main difference is that implicit numbers become `0` and set the
 168 |         corresponding `_implicit` attribute:
 169 | 
 170 |         .. doctest::
 171 | 
 172 |             >>> v = Version(release=1, post='')
 173 |             >>> str(v)
 174 |             '1.post'
 175 |             >>> v.post
 176 |             0
 177 |             >>> v.post_implicit
 178 |             True
 179 | 
 180 |     .. attribute:: release
 181 | 
 182 |         A tuple of integers giving the components of the release segment of
 183 |         this :class:`Version` instance; that is, the ``1.2.3`` part of the
 184 |         version number, including trailing zeros but not including the epoch
 185 |         or any prerelease/development/postrelease suffixes
 186 | 
 187 |     .. attribute:: v
 188 | 
 189 |         Whether this :class:`Version` instance includes a preceding v character.
 190 | 
 191 |     .. attribute:: epoch
 192 | 
 193 |         An integer giving the version epoch of this :class:`Version` instance.
 194 |         :attr:`epoch_implicit` may be `True` if this number is zero.
 195 | 
 196 |     .. attribute:: pre_tag
 197 | 
 198 |         If this :class:`Version` instance represents a pre-release, this
 199 |         attribute will be the pre-release identifier. One of `a`, `b`, `rc`,
 200 |         `c`, `alpha`, `beta`, `preview`, or `pre`.
 201 | 
 202 |         **Note:** you should not use this attribute to check or compare
 203 |         pre-release identifiers. Use :meth:`is_alpha`, :meth:`is_beta`, and
 204 |         :meth:`is_release_candidate` instead.
 205 | 
 206 |     .. attribute:: pre
 207 | 
 208 |         If this :class:`Version` instance represents a pre-release, this
 209 |         attribute will be the pre-release number. If this instance is not a
 210 |         pre-release, the attribute will be `None`. :attr:`pre_implicit` may be
 211 |         `True` if this number is zero.
 212 | 
 213 |     .. attribute:: post
 214 | 
 215 |         If this :class:`Version` instance represents a postrelease, this
 216 |         attribute will be the postrelease number (an integer); otherwise, it
 217 |         will be `None`. :attr:`post_implicit` may be `True` if this number
 218 |         is zero.
 219 | 
 220 |     .. attribute:: dev
 221 | 
 222 |         If this :class:`Version` instance represents a development release,
 223 |         this attribute will be the development release number (an integer);
 224 |         otherwise, it will be `None`. :attr:`dev_implicit` may be `True` if this
 225 |         number is zero.
 226 | 
 227 |     .. attribute:: local
 228 | 
 229 |         A string representing the local version portion of this :class:`Version`
 230 |         instance if it has one, or ``None`` otherwise.
 231 | 
 232 |     .. attribute:: pre_sep1
 233 | 
 234 |         The separator before the pre-release identifier.
 235 | 
 236 |     .. attribute:: pre_sep2
 237 | 
 238 |         The separator between the pre-release identifier and number.
 239 | 
 240 |     .. attribute:: post_sep1
 241 | 
 242 |         The separator before the post release identifier.
 243 | 
 244 |     .. attribute:: post_sep2
 245 | 
 246 |         The separator between the post release identifier and number.
 247 | 
 248 |     .. attribute:: dev_sep1
 249 | 
 250 |         The separator before the develepment release identifier.
 251 | 
 252 |     .. attribute:: dev_sep2
 253 | 
 254 |         The separator between the development release identifier and number.
 255 | 
 256 |     .. attribute:: post_tag
 257 | 
 258 |         If this :class:`Version` instance represents a post release, this
 259 |         attribute will be the post release identifier. One of `post`, `rev`,
 260 |         `r`, or `None` to represent an implicit post release.
 261 | 
 262 |     .. _`Version epoch`: https://www.python.org/dev/peps/pep-0440/#version-epochs
 263 |     .. _`Pre-release`: https://www.python.org/dev/peps/pep-0440/#pre-releases
 264 |     .. _`implicit pre-release number`: https://www.python.org/dev/peps/
 265 |         pep-0440/#implicit-pre-release-number
 266 |     .. _`Post-release`: https://www.python.org/dev/peps/pep-0440/#post-releases
 267 |     .. _`implicit post release number`: https://www.python.org/dev/peps/
 268 |         pep-0440/#implicit-post-release-number
 269 |     .. _`Developmental release`: https://www.python.org/dev/peps/pep-0440/
 270 |         #developmental-releases
 271 |     .. _`implicit development release number`: https://www.python.org/dev/peps/
 272 |         pep-0440/#implicit-development-release-number
 273 |     .. _`Local version`: https://www.python.org/dev/peps/pep-0440/
 274 |         #local-version-identifiers
 275 |     .. _`implicit post release`: https://www.python.org/dev/peps/pep-0440/
 276 |         #implicit-post-releases
 277 | 
 278 |     """
 279 | 
 280 |     release: tuple[int, ...] = attr.ib(
 281 |         converter=convert_release, validator=release_validator
 282 |     )
 283 |     v: bool = attr.ib(default=False, validator=is_bool)
 284 |     epoch: int = attr.ib(
 285 |         default=cast(int, IMPLICIT_ZERO),
 286 |         converter=convert_implicit,
 287 |         validator=implicit_or(num_comp),
 288 |     )
 289 |     pre_tag: Optional[PreTag] = attr.ib(default=None, validator=validate_pre_tag)
 290 |     pre: Optional[int] = attr.ib(
 291 |         default=None,
 292 |         converter=converters.optional(convert_implicit),
 293 |         validator=implicit_or(optional(num_comp)),
 294 |     )
 295 |     post: Optional[int] = attr.ib(
 296 |         default=None,
 297 |         converter=converters.optional(convert_implicit),
 298 |         validator=implicit_or(optional(num_comp)),
 299 |     )
 300 |     dev: Optional[int] = attr.ib(
 301 |         default=None,
 302 |         converter=converters.optional(convert_implicit),
 303 |         validator=implicit_or(optional(num_comp)),
 304 |     )
 305 |     local: Optional[str] = attr.ib(
 306 |         default=None, converter=convert_local, validator=optional(is_str)
 307 |     )
 308 | 
 309 |     pre_sep1: Optional[Separator] = attr.ib(default=None, validator=validate_sep)
 310 |     pre_sep2: Optional[Separator] = attr.ib(default=None, validator=validate_sep)
 311 |     post_sep1: Optional[Separator] = attr.ib(
 312 |         default=UNSET, validator=validate_sep_or_unset
 313 |     )
 314 |     post_sep2: Optional[Separator] = attr.ib(
 315 |         default=UNSET, validator=validate_sep_or_unset
 316 |     )
 317 |     dev_sep1: Optional[Separator] = attr.ib(
 318 |         default=UNSET, validator=validate_sep_or_unset
 319 |     )
 320 |     dev_sep2: Optional[Separator] = attr.ib(
 321 |         default=UNSET, validator=validate_sep_or_unset
 322 |     )
 323 |     post_tag: Optional[PostTag] = attr.ib(default=UNSET, validator=validate_post_tag)
 324 | 
 325 |     epoch_implicit: bool = attr.ib(default=False, init=False)
 326 |     pre_implicit: bool = attr.ib(default=False, init=False)
 327 |     post_implicit: bool = attr.ib(default=False, init=False)
 328 |     dev_implicit: bool = attr.ib(default=False, init=False)
 329 |     _key = attr.ib(init=False)
 330 | 
 331 |     def __attrs_post_init__(self) -> None:
 332 |         set_ = partial(object.__setattr__, self)
 333 | 
 334 |         if self.epoch == IMPLICIT_ZERO:
 335 |             set_("epoch", 0)
 336 |             set_("epoch_implicit", True)
 337 | 
 338 |         self._validate_pre(set_)
 339 |         self._validate_post(set_)
 340 |         self._validate_dev(set_)
 341 | 
 342 |         set_(
 343 |             "_key",
 344 |             _cmpkey(
 345 |                 self.epoch,
 346 |                 self.release,
 347 |                 _normalize_pre_tag(self.pre_tag),
 348 |                 self.pre,
 349 |                 self.post,
 350 |                 self.dev,
 351 |                 self.local,
 352 |             ),
 353 |         )
 354 | 
 355 |     def _validate_pre(self, set_: Callable[[str, Any], None]) -> None:
 356 |         if self.pre_tag is None:
 357 |             if self.pre is not None:
 358 |                 raise ValueError("Must set pre_tag if pre is given.")
 359 | 
 360 |             if self.pre_sep1 is not None or self.pre_sep2 is not None:
 361 |                 raise ValueError("Cannot set pre_sep1 or pre_sep2 without pre_tag.")
 362 |         else:
 363 |             if self.pre == IMPLICIT_ZERO:
 364 |                 set_("pre", 0)
 365 |                 set_("pre_implicit", True)
 366 |             elif self.pre is None:
 367 |                 raise ValueError("Must set pre if pre_tag is given.")
 368 | 
 369 |     def _validate_post(self, set_: Callable[[str, Any], None]) -> None:
 370 |         got_post_tag = self.post_tag is not UNSET
 371 |         got_post = self.post is not None
 372 |         got_post_sep1 = self.post_sep1 is not UNSET
 373 |         got_post_sep2 = self.post_sep2 is not UNSET
 374 | 
 375 |         # post_tag relies on post
 376 |         if got_post_tag and not got_post:
 377 |             raise ValueError("Must set post if post_tag is given.")
 378 | 
 379 |         if got_post:
 380 |             if not got_post_tag:
 381 |                 # user gets the default for post_tag
 382 |                 set_("post_tag", "post")
 383 |             if self.post == IMPLICIT_ZERO:
 384 |                 set_("post_implicit", True)
 385 |                 set_("post", 0)
 386 | 
 387 |         # Validate parameters for implicit post-release (post_tag=None).
 388 |         # An implicit post-release is e.g. '1-2' (== '1.post2')
 389 |         if self.post_tag is None:
 390 |             if self.post_implicit:
 391 |                 raise ValueError(
 392 |                     "Implicit post releases (post_tag=None) require a numerical "
 393 |                     "value for 'post' argument."
 394 |                 )
 395 | 
 396 |             if got_post_sep1 or got_post_sep2:
 397 |                 raise ValueError(
 398 |                     "post_sep1 and post_sep2 cannot be set for implicit post "
 399 |                     "releases (post_tag=None)"
 400 |                 )
 401 | 
 402 |             if self.pre_implicit and self.pre_sep2 is None:
 403 |                 raise ValueError(
 404 |                     "post_tag cannot be None with an implicit pre-release "
 405 |                     "(pre='') unless pre_sep2 is not None."
 406 |                 )
 407 | 
 408 |             set_("post_sep1", "-")
 409 |         elif self.post_tag is UNSET:
 410 |             if got_post_sep1 or got_post_sep2:
 411 |                 raise ValueError("Cannot set post_sep1 or post_sep2 without post_tag.")
 412 | 
 413 |             set_("post_tag", None)
 414 | 
 415 |         if not got_post_sep1 and self.post_sep1 is UNSET:
 416 |             set_("post_sep1", None if self.post is None else ".")
 417 | 
 418 |         if not got_post_sep2:
 419 |             set_("post_sep2", None)
 420 | 
 421 |         assert self.post_sep1 is not UNSET
 422 |         assert self.post_sep2 is not UNSET
 423 | 
 424 |     def _validate_dev(self, set_: Callable[[str, Any], None]) -> None:
 425 |         if self.dev == IMPLICIT_ZERO:
 426 |             set_("dev_implicit", True)
 427 |             set_("dev", 0)
 428 |         elif self.dev is None:
 429 |             if self.dev_sep1 is not UNSET:
 430 |                 raise ValueError("Cannot set dev_sep1 without dev.")
 431 |             if self.dev_sep2 is not UNSET:
 432 |                 raise ValueError("Cannot set dev_sep2 without dev.")
 433 | 
 434 |         if self.dev_sep1 is UNSET:
 435 |             set_("dev_sep1", None if self.dev is None else ".")
 436 | 
 437 |         if self.dev_sep2 is UNSET:
 438 |             set_("dev_sep2", None)
 439 | 
 440 |     @classmethod
 441 |     def parse(cls, version: str, strict: bool = False) -> "Version":
 442 |         """
 443 |         :param version: Version number as defined in `PEP 440`_.
 444 |         :type version: str
 445 | 
 446 |         :param strict: Enable strict parsing of the canonical PEP 440 format.
 447 |         :type strict: bool
 448 | 
 449 |         .. _`PEP 440`: https://www.python.org/dev/peps/pep-0440/
 450 | 
 451 |         :raises ParseError: If version is not valid for the given value of
 452 |             `strict`.
 453 | 
 454 |         .. doctest::
 455 |             :options: -IGNORE_EXCEPTION_DETAIL
 456 | 
 457 |             >>> Version.parse('1.dev')
 458 |             
 459 |             >>> Version.parse('1.dev', strict=True)
 460 |             Traceback (most recent call last):
 461 |               ...
 462 |             parver.ParseError: Expected int at position (1, 6) => '1.dev*'.
 463 |         """
 464 |         segments = parse(version, strict=strict)
 465 | 
 466 |         kwargs: dict[str, Any] = dict()
 467 | 
 468 |         for s in segments:
 469 |             if isinstance(s, segment.Epoch):
 470 |                 kwargs["epoch"] = s.value
 471 |             elif isinstance(s, segment.Release):
 472 |                 kwargs["release"] = s.value
 473 |             elif isinstance(s, segment.Pre):
 474 |                 kwargs["pre"] = s.value
 475 |                 kwargs["pre_tag"] = s.tag
 476 |                 kwargs["pre_sep1"] = s.sep1
 477 |                 kwargs["pre_sep2"] = s.sep2
 478 |             elif isinstance(s, segment.Post):
 479 |                 kwargs["post"] = s.value
 480 |                 kwargs["post_tag"] = s.tag
 481 |                 kwargs["post_sep1"] = s.sep1
 482 |                 kwargs["post_sep2"] = s.sep2
 483 |             elif isinstance(s, segment.Dev):
 484 |                 kwargs["dev"] = s.value
 485 |                 kwargs["dev_sep1"] = s.sep1
 486 |                 kwargs["dev_sep2"] = s.sep2
 487 |             elif isinstance(s, segment.Local):
 488 |                 kwargs["local"] = s.value
 489 |             elif isinstance(s, segment.V):
 490 |                 kwargs["v"] = True
 491 |             else:
 492 |                 raise TypeError(f"Unexpected segment: {segment}")
 493 | 
 494 |         return cls(**kwargs)
 495 | 
 496 |     def normalize(self) -> "Version":
 497 |         return Version(
 498 |             release=self.release,
 499 |             epoch=IMPLICIT_ZERO if self.epoch == 0 else self.epoch,
 500 |             pre_tag=_normalize_pre_tag(self.pre_tag),
 501 |             pre=self.pre,
 502 |             post=self.post,
 503 |             dev=self.dev,
 504 |             local=_normalize_local(self.local),
 505 |         )
 506 | 
 507 |     def __str__(self) -> str:
 508 |         parts = []
 509 | 
 510 |         if self.v:
 511 |             parts.append("v")
 512 | 
 513 |         if not self.epoch_implicit:
 514 |             parts.append(f"{self.epoch}!")
 515 | 
 516 |         parts.append(".".join(str(x) for x in self.release))
 517 | 
 518 |         if self.pre_tag is not None:
 519 |             if self.pre_sep1:
 520 |                 parts.append(self.pre_sep1)
 521 |             parts.append(self.pre_tag)
 522 |             if self.pre_sep2:
 523 |                 parts.append(self.pre_sep2)
 524 |             if not self.pre_implicit:
 525 |                 parts.append(str(self.pre))
 526 | 
 527 |         if self.post_tag is None and self.post is not None:
 528 |             parts.append(f"-{self.post}")
 529 |         elif self.post_tag is not None:
 530 |             if self.post_sep1:
 531 |                 parts.append(self.post_sep1)
 532 |             parts.append(self.post_tag)
 533 |             if self.post_sep2:
 534 |                 parts.append(self.post_sep2)
 535 |             if not self.post_implicit:
 536 |                 parts.append(str(self.post))
 537 | 
 538 |         if self.dev is not None:
 539 |             if self.dev_sep1 is not None:
 540 |                 parts.append(self.dev_sep1)
 541 |             parts.append("dev")
 542 |             if self.dev_sep2:
 543 |                 parts.append(self.dev_sep2)
 544 |             if not self.dev_implicit:
 545 |                 parts.append(str(self.dev))
 546 | 
 547 |         if self.local is not None:
 548 |             parts.append(f"+{self.local}")
 549 | 
 550 |         return "".join(parts)
 551 | 
 552 |     def __repr__(self) -> str:
 553 |         return f"<{self.__class__.__name__} {str(self)!r}>"
 554 | 
 555 |     def __hash__(self) -> int:
 556 |         return hash(self._key)
 557 | 
 558 |     def __lt__(self, other: Any) -> Any:
 559 |         return self._compare(other, operator.lt)
 560 | 
 561 |     def __le__(self, other: Any) -> Any:
 562 |         return self._compare(other, operator.le)
 563 | 
 564 |     def __eq__(self, other: Any) -> Any:
 565 |         return self._compare(other, operator.eq)
 566 | 
 567 |     def __ge__(self, other: Any) -> Any:
 568 |         return self._compare(other, operator.ge)
 569 | 
 570 |     def __gt__(self, other: Any) -> Any:
 571 |         return self._compare(other, operator.gt)
 572 | 
 573 |     def __ne__(self, other: Any) -> Any:
 574 |         return self._compare(other, operator.ne)
 575 | 
 576 |     def _compare(self, other: Any, method: Callable[[Any, Any], bool]) -> Any:
 577 |         if not isinstance(other, Version):
 578 |             return NotImplemented
 579 | 
 580 |         return method(self._key, other._key)
 581 | 
 582 |     @property
 583 |     def public(self) -> str:
 584 |         """A string representing the public version portion of this
 585 |         :class:`Version` instance.
 586 |         """
 587 |         return str(self).split("+", 1)[0]
 588 | 
 589 |     def base_version(self) -> "Version":
 590 |         """Return a new :class:`Version` instance for the base version of the
 591 |         current instance. The base version is the public version of the project
 592 |         without any pre or post release markers.
 593 | 
 594 |         See also: :meth:`clear` and :meth:`replace`.
 595 |         """
 596 |         return self.replace(pre=None, post=None, dev=None, local=None)
 597 | 
 598 |     @property
 599 |     def is_prerelease(self) -> bool:
 600 |         """A boolean value indicating whether this :class:`Version` instance
 601 |         represents a pre-release and/or development release.
 602 |         """
 603 |         return self.dev is not None or self.pre is not None
 604 | 
 605 |     @property
 606 |     def is_alpha(self) -> bool:
 607 |         """A boolean value indicating whether this :class:`Version` instance
 608 |         represents an alpha pre-release.
 609 |         """
 610 |         return _normalize_pre_tag(self.pre_tag) == "a"
 611 | 
 612 |     @property
 613 |     def is_beta(self) -> bool:
 614 |         """A boolean value indicating whether this :class:`Version` instance
 615 |         represents a beta pre-release.
 616 |         """
 617 |         return _normalize_pre_tag(self.pre_tag) == "b"
 618 | 
 619 |     @property
 620 |     def is_release_candidate(self) -> bool:
 621 |         """A boolean value indicating whether this :class:`Version` instance
 622 |         represents a release candidate pre-release.
 623 |         """
 624 |         return _normalize_pre_tag(self.pre_tag) == "rc"
 625 | 
 626 |     @property
 627 |     def is_postrelease(self) -> bool:
 628 |         """A boolean value indicating whether this :class:`Version` instance
 629 |         represents a post-release.
 630 |         """
 631 |         return self.post is not None
 632 | 
 633 |     @property
 634 |     def is_devrelease(self) -> bool:
 635 |         """A boolean value indicating whether this :class:`Version` instance
 636 |         represents a development release.
 637 |         """
 638 |         return self.dev is not None
 639 | 
 640 |     def _attrs_as_init(self) -> dict[str, Any]:
 641 |         d = attr.asdict(self, filter=lambda attr, _: attr.init)
 642 | 
 643 |         if self.epoch_implicit:
 644 |             d["epoch"] = IMPLICIT_ZERO
 645 | 
 646 |         if self.pre_implicit:
 647 |             d["pre"] = IMPLICIT_ZERO
 648 | 
 649 |         if self.post_implicit:
 650 |             d["post"] = IMPLICIT_ZERO
 651 | 
 652 |         if self.dev_implicit:
 653 |             d["dev"] = IMPLICIT_ZERO
 654 | 
 655 |         if self.pre is None:
 656 |             del d["pre"]
 657 |             del d["pre_tag"]
 658 |             del d["pre_sep1"]
 659 |             del d["pre_sep2"]
 660 | 
 661 |         if self.post is None:
 662 |             del d["post"]
 663 |             del d["post_tag"]
 664 |             del d["post_sep1"]
 665 |             del d["post_sep2"]
 666 |         elif self.post_tag is None:
 667 |             del d["post_sep1"]
 668 |             del d["post_sep2"]
 669 | 
 670 |         if self.dev is None:
 671 |             del d["dev"]
 672 |             del d["dev_sep1"]
 673 |             del d["dev_sep2"]
 674 | 
 675 |         return d
 676 | 
 677 |     def replace(
 678 |         self,
 679 |         release: Union[int, Iterable[int], UnsetType] = UNSET,
 680 |         v: Union[bool, UnsetType] = UNSET,
 681 |         epoch: Union[ImplicitZero, int, UnsetType] = UNSET,
 682 |         pre_tag: Union[PreTag, None, UnsetType] = UNSET,
 683 |         pre: Union[ImplicitZero, int, None, UnsetType] = UNSET,
 684 |         post: Union[ImplicitZero, int, None, UnsetType] = UNSET,
 685 |         dev: Union[ImplicitZero, int, None, UnsetType] = UNSET,
 686 |         local: Union[str, None, UnsetType] = UNSET,
 687 |         pre_sep1: Union[Separator, None, UnsetType] = UNSET,
 688 |         pre_sep2: Union[Separator, None, UnsetType] = UNSET,
 689 |         post_sep1: Union[Separator, None, UnsetType] = UNSET,
 690 |         post_sep2: Union[Separator, None, UnsetType] = UNSET,
 691 |         dev_sep1: Union[Separator, None, UnsetType] = UNSET,
 692 |         dev_sep2: Union[Separator, None, UnsetType] = UNSET,
 693 |         post_tag: Union[PostTag, None, UnsetType] = UNSET,
 694 |     ) -> "Version":
 695 |         """Return a new :class:`Version` instance with the same attributes,
 696 |         except for those given as keyword arguments. Arguments have the same
 697 |         meaning as they do when constructing a new :class:`Version` instance
 698 |         manually.
 699 |         """
 700 |         kwargs = dict(
 701 |             release=release,
 702 |             v=v,
 703 |             epoch=epoch,
 704 |             pre_tag=pre_tag,
 705 |             pre=pre,
 706 |             post=post,
 707 |             dev=dev,
 708 |             local=local,
 709 |             pre_sep1=pre_sep1,
 710 |             pre_sep2=pre_sep2,
 711 |             post_sep1=post_sep1,
 712 |             post_sep2=post_sep2,
 713 |             dev_sep1=dev_sep1,
 714 |             dev_sep2=dev_sep2,
 715 |             post_tag=post_tag,
 716 |         )
 717 |         kwargs = {k: v for k, v in kwargs.items() if v is not UNSET}
 718 |         d = self._attrs_as_init()
 719 | 
 720 |         if kwargs.get("post_tag", UNSET) is None:
 721 |             # ensure we don't carry over separators for new implicit post
 722 |             # release. By popping from d, there will still be an error if the
 723 |             # user tries to set them in kwargs
 724 |             d.pop("post_sep1", None)
 725 |             d.pop("post_sep2", None)
 726 | 
 727 |         if kwargs.get("post", UNSET) is None:
 728 |             kwargs["post_tag"] = UNSET
 729 |             d.pop("post_sep1", None)
 730 |             d.pop("post_sep2", None)
 731 | 
 732 |         if kwargs.get("pre", UNSET) is None:
 733 |             kwargs["pre_tag"] = None
 734 |             d.pop("pre_sep1", None)
 735 |             d.pop("pre_sep2", None)
 736 | 
 737 |         if kwargs.get("dev", UNSET) is None:
 738 |             d.pop("dev_sep1", None)
 739 |             d.pop("dev_sep2", None)
 740 | 
 741 |         d.update(kwargs)
 742 |         return Version(**d)
 743 | 
 744 |     def _set_release(
 745 |         self, index: int, value: Optional[int] = None, bump: bool = True
 746 |     ) -> "Version":
 747 |         if not isinstance(index, int):
 748 |             raise TypeError("index must be an integer")
 749 | 
 750 |         if index < 0:
 751 |             raise ValueError("index cannot be negative")
 752 | 
 753 |         release = list(self.release)
 754 |         new_len = index + 1
 755 | 
 756 |         if len(release) < new_len:
 757 |             release.extend(itertools.repeat(0, new_len - len(release)))
 758 | 
 759 |         def new_parts(i: int, n: int) -> int:
 760 |             if i < index:
 761 |                 return n
 762 |             if i == index:
 763 |                 if value is None:
 764 |                     return n + 1
 765 |                 return value
 766 |             if bump:
 767 |                 return 0
 768 |             return n
 769 | 
 770 |         new_release = itertools.starmap(new_parts, enumerate(release))
 771 |         return self.replace(release=new_release)
 772 | 
 773 |     def bump_epoch(self, *, by: int = 1) -> "Version":
 774 |         """Return a new :class:`Version` instance with the epoch number
 775 |         bumped.
 776 | 
 777 |         :param by: How much to bump the number by.
 778 |         :type by: int
 779 | 
 780 |         :raises TypeError: `by` is not an integer.
 781 | 
 782 |         .. doctest::
 783 | 
 784 |             >>> Version.parse('1.4').bump_epoch()
 785 |             
 786 |             >>> Version.parse('2!1.4').bump_epoch(by=-1)
 787 |             
 788 |         """
 789 |         check_by(by, self.epoch)
 790 | 
 791 |         epoch = by - 1 if self.epoch is None else self.epoch + by
 792 |         return self.replace(epoch=epoch)
 793 | 
 794 |     def bump_release(self, *, index: int) -> "Version":
 795 |         """Return a new :class:`Version` instance with the release number
 796 |         bumped at the given `index`.
 797 | 
 798 |         :param index: Index of the release number tuple to bump. It is not
 799 |             limited to the current size of the tuple. Intermediate indices will
 800 |             be set to zero.
 801 |         :type index: int
 802 | 
 803 |         :raises TypeError: `index` is not an integer.
 804 |         :raises ValueError: `index` is negative.
 805 | 
 806 |         .. doctest::
 807 | 
 808 |             >>> v = Version.parse('1.4')
 809 |             >>> v.bump_release(index=0)
 810 |             
 811 |             >>> v.bump_release(index=1)
 812 |             
 813 |             >>> v.bump_release(index=2)
 814 |             
 815 |             >>> v.bump_release(index=3)
 816 |             
 817 | 
 818 |         .. seealso::
 819 | 
 820 |             For more control over the value that is bumped to, see
 821 |             :meth:`bump_release_to`.
 822 | 
 823 |             For fine-grained control, :meth:`set_release` may be used to set
 824 |             the value at a specific index without setting subsequenct indices
 825 |             to zero.
 826 |         """
 827 |         return self._set_release(index=index)
 828 | 
 829 |     def bump_release_to(self, *, index: int, value: int) -> "Version":
 830 |         """Return a new :class:`Version` instance with the release number
 831 |         bumped at the given `index` to `value`. May be used for versioning
 832 |         schemes such as `CalVer`_.
 833 | 
 834 |         .. _`CalVer`: https://calver.org
 835 | 
 836 |         :param index: Index of the release number tuple to bump. It is not
 837 |             limited to the current size of the tuple. Intermediate indices will
 838 |             be set to zero.
 839 |         :type index: int
 840 |         :param value: Value to bump to. This may be any value, but subsequent
 841 |             indices will be set to zero like a normal version bump.
 842 |         :type value: int
 843 | 
 844 |         :raises TypeError: `index` is not an integer.
 845 |         :raises ValueError: `index` is negative.
 846 | 
 847 |         .. testsetup::
 848 | 
 849 |             import datetime
 850 | 
 851 |         .. doctest::
 852 | 
 853 |             >>> v = Version.parse('18.4')
 854 |             >>> v.bump_release_to(index=0, value=20)
 855 |             
 856 |             >>> v.bump_release_to(index=1, value=10)
 857 |             
 858 | 
 859 |         For a project using `CalVer`_ with format ``YYYY.MM.MICRO``, this
 860 |         method could be used to set the date parts:
 861 | 
 862 |         .. doctest::
 863 | 
 864 |             >>> v = Version.parse('2018.4.1')
 865 |             >>> v = v.bump_release_to(index=0, value=2018)
 866 |             >>> v = v.bump_release_to(index=1, value=10)
 867 |             >>> v
 868 |             
 869 | 
 870 |         .. seealso::
 871 | 
 872 |             For typical use cases, see :meth:`bump_release`.
 873 | 
 874 |             For fine-grained control, :meth:`set_release` may be used to set
 875 |             the value at a specific index without setting subsequenct indices
 876 |             to zero.
 877 |         """
 878 |         return self._set_release(index=index, value=value)
 879 | 
 880 |     def set_release(self, *, index: int, value: int) -> "Version":
 881 |         """Return a new :class:`Version` instance with the release number
 882 |         at the given `index` set to `value`.
 883 | 
 884 |         :param index: Index of the release number tuple to set. It is not
 885 |             limited to the current size of the tuple. Intermediate indices will
 886 |             be set to zero.
 887 |         :type index: int
 888 |         :param value: Value to set.
 889 |         :type value: int
 890 | 
 891 |         :raises TypeError: `index` is not an integer.
 892 |         :raises ValueError: `index` is negative.
 893 | 
 894 |         .. doctest::
 895 | 
 896 |             >>> v = Version.parse('1.2.3')
 897 |             >>> v.set_release(index=0, value=3)
 898 |             
 899 |             >>> v.set_release(index=1, value=4)
 900 |             
 901 | 
 902 |         .. seealso::
 903 | 
 904 |             For typical use cases, see :meth:`bump_release`.
 905 |         """
 906 |         return self._set_release(index=index, value=value, bump=False)
 907 | 
 908 |     def bump_pre(self, tag: Optional[PreTag] = None, *, by: int = 1) -> "Version":
 909 |         """Return a new :class:`Version` instance with the pre-release number
 910 |         bumped.
 911 | 
 912 |         :param tag: Pre-release tag. Required if not already set.
 913 |         :type tag: str
 914 |         :param by: How much to bump the number by.
 915 |         :type by: int
 916 | 
 917 |         :raises ValueError: Trying to call ``bump_pre(tag=None)`` on a
 918 |             :class:`Version` instance that is not already a pre-release.
 919 |         :raises ValueError: Calling the method with a `tag` not equal to the
 920 |             current :attr:`post_tag`. See :meth:`replace` instead.
 921 |         :raises TypeError: `by` is not an integer.
 922 | 
 923 |         .. doctest::
 924 | 
 925 |             >>> Version.parse('1.4').bump_pre('a')
 926 |             
 927 |             >>> Version.parse('1.4b1').bump_pre()
 928 |             
 929 |             >>> Version.parse('1.4b1').bump_pre(by=-1)
 930 |             
 931 |         """
 932 |         check_by(by, self.pre)
 933 | 
 934 |         pre = by - 1 if self.pre is None else self.pre + by
 935 | 
 936 |         if self.pre_tag is None:
 937 |             if tag is None:
 938 |                 raise ValueError("Cannot bump without pre_tag. Use .bump_pre('')")
 939 |         else:
 940 |             # This is an error because different tags have different meanings
 941 |             if tag is not None and self.pre_tag != tag:
 942 |                 raise ValueError(
 943 |                     f"Cannot bump with pre_tag mismatch ({self.pre_tag} != {tag}). "
 944 |                     f"Use .replace(pre_tag={tag!r})"
 945 |                 )
 946 |             tag = self.pre_tag
 947 | 
 948 |         return self.replace(pre=pre, pre_tag=tag)
 949 | 
 950 |     @overload
 951 |     def bump_post(self, tag: Optional[PostTag], *, by: int = 1) -> "Version":
 952 |         pass
 953 | 
 954 |     @overload
 955 |     def bump_post(self, *, by: int = 1) -> "Version":
 956 |         pass
 957 | 
 958 |     def bump_post(
 959 |         self, tag: Union[PostTag, None, UnsetType] = UNSET, *, by: int = 1
 960 |     ) -> "Version":
 961 |         """Return a new :class:`Version` instance with the post release number
 962 |         bumped.
 963 | 
 964 |         :param tag: Post release tag. Will preserve the current tag by default,
 965 |             or use `post` if the instance is not already a post release.
 966 |         :type tag: str
 967 |         :param by: How much to bump the number by.
 968 |         :type by: int
 969 | 
 970 |         :raises TypeError: `by` is not an integer.
 971 | 
 972 |         .. doctest::
 973 | 
 974 |             >>> Version.parse('1.4').bump_post()
 975 |             
 976 |             >>> Version.parse('1.4.post0').bump_post(tag=None)
 977 |             
 978 |             >>> Version.parse('1.4_post-1').bump_post(tag='rev')
 979 |             
 980 |             >>> Version.parse('1.4.post2').bump_post(by=-1)
 981 |             
 982 |         """
 983 |         check_by(by, self.post)
 984 | 
 985 |         post = by - 1 if self.post is None else self.post + by
 986 |         if tag is UNSET and self.post is not None:
 987 |             tag = self.post_tag
 988 |         return self.replace(post=post, post_tag=tag)
 989 | 
 990 |     def bump_dev(self, *, by: int = 1) -> "Version":
 991 |         """Return a new :class:`Version` instance with the development release
 992 |         number bumped.
 993 | 
 994 |         :param by: How much to bump the number by.
 995 |         :type by: int
 996 | 
 997 |         :raises TypeError: `by` is not an integer.
 998 | 
 999 |         .. doctest::
1000 | 
1001 |             >>> Version.parse('1.4').bump_dev()
1002 |             
1003 |             >>> Version.parse('1.4_dev1').bump_dev()
1004 |             
1005 |             >>> Version.parse('1.4.dev3').bump_dev(by=-1)
1006 |             
1007 |         """
1008 |         check_by(by, self.dev)
1009 | 
1010 |         dev = by - 1 if self.dev is None else self.dev + by
1011 |         return self.replace(dev=dev)
1012 | 
1013 |     def truncate(self, *, min_length: int = 1) -> "Version":
1014 |         """Return a new :class:`Version` instance with trailing zeros removed
1015 |         from the release segment.
1016 | 
1017 |         :param min_length: Minimum number of parts to keep.
1018 |         :type min_length: int
1019 | 
1020 |         .. doctest::
1021 | 
1022 |             >>> Version.parse('0.1.0').truncate()
1023 |             
1024 |             >>> Version.parse('1.0.0').truncate(min_length=2)
1025 |             
1026 |             >>> Version.parse('1').truncate(min_length=2)
1027 |             
1028 |         """
1029 |         if not isinstance(min_length, int):
1030 |             raise TypeError("min_length must be an integer")
1031 | 
1032 |         if min_length < 1:
1033 |             raise ValueError("min_length must be positive")
1034 | 
1035 |         release = list(self.release)
1036 |         if len(release) < min_length:
1037 |             release.extend(itertools.repeat(0, min_length - len(release)))
1038 | 
1039 |         last_nonzero = max(
1040 |             last((i for i, n in enumerate(release) if n), default=0),
1041 |             min_length - 1,
1042 |         )
1043 |         return self.replace(release=release[: last_nonzero + 1])
1044 | 
1045 | 
1046 | def _normalize_pre_tag(pre_tag: Optional[PreTag]) -> Optional[NormalizedPreTag]:
1047 |     if pre_tag is None:
1048 |         return None
1049 | 
1050 |     if pre_tag == "alpha":
1051 |         pre_tag = "a"
1052 |     elif pre_tag == "beta":
1053 |         pre_tag = "b"
1054 |     elif pre_tag in {"c", "pre", "preview"}:
1055 |         pre_tag = "rc"
1056 | 
1057 |     return cast(NormalizedPreTag, pre_tag)
1058 | 
1059 | 
1060 | def _normalize_local(local: Optional[str]) -> Optional[str]:
1061 |     if local is None:
1062 |         return None
1063 | 
1064 |     return ".".join(map(str, _parse_local_version(local)))
1065 | 
1066 | 
1067 | def _cmpkey(
1068 |     epoch: int,
1069 |     release: tuple[int, ...],
1070 |     pre_tag: Optional[NormalizedPreTag],
1071 |     pre_num: Optional[int],
1072 |     post: Optional[int],
1073 |     dev: Optional[int],
1074 |     local: Optional[str],
1075 | ) -> Any:
1076 |     # When we compare a release version, we want to compare it with all of the
1077 |     # trailing zeros removed. So we'll use a reverse the list, drop all the now
1078 |     # leading zeros until we come to something non zero, then take the rest
1079 |     # re-reverse it back into the correct order and make it a tuple and use
1080 |     # that for our sorting key.
1081 |     release = tuple(
1082 |         reversed(
1083 |             list(
1084 |                 itertools.dropwhile(
1085 |                     lambda x: x == 0,
1086 |                     reversed(release),
1087 |                 )
1088 |             )
1089 |         )
1090 |     )
1091 | 
1092 |     pre = pre_tag, pre_num
1093 | 
1094 |     # We need to "trick" the sorting algorithm to put 1.0.dev0 before 1.0a0.
1095 |     # We'll do this by abusing the pre segment, but we _only_ want to do this
1096 |     # if there is not a pre or a post segment. If we have one of those then
1097 |     # the normal sorting rules will handle this case correctly.
1098 |     if pre_num is None and post is None and dev is not None:
1099 |         pre = -Infinity  # type: ignore[assignment]
1100 |     # Versions without a pre-release (except as noted above) should sort after
1101 |     # those with one.
1102 |     elif pre_num is None:
1103 |         pre = Infinity  # type: ignore[assignment]
1104 | 
1105 |     # Versions without a post segment should sort before those with one.
1106 |     if post is None:
1107 |         post = -Infinity  # type: ignore[assignment]
1108 | 
1109 |     # Versions without a development segment should sort after those with one.
1110 |     if dev is None:
1111 |         dev = Infinity  # type: ignore[assignment]
1112 | 
1113 |     if local is None:
1114 |         # Versions without a local segment should sort before those with one.
1115 |         local = -Infinity  # type: ignore[assignment]
1116 |     else:
1117 |         # Versions with a local segment need that segment parsed to implement
1118 |         # the sorting rules in PEP440.
1119 |         # - Alpha numeric segments sort before numeric segments
1120 |         # - Alpha numeric segments sort lexicographically
1121 |         # - Numeric segments sort numerically
1122 |         # - Shorter versions sort before longer versions when the prefixes
1123 |         #   match exactly
1124 |         local = tuple(  # type: ignore[assignment]
1125 |             (i, "") if isinstance(i, int) else (-Infinity, i)
1126 |             for i in _parse_local_version(local)
1127 |         )
1128 | 
1129 |     return epoch, release, pre, post, dev, local
1130 | 
1131 | 
1132 | _local_version_separators = re.compile(r"[._-]")
1133 | 
1134 | 
1135 | @overload
1136 | def _parse_local_version(local: str) -> tuple[Union[str, int], ...]:
1137 |     pass
1138 | 
1139 | 
1140 | @overload
1141 | def _parse_local_version(local: None) -> None:
1142 |     pass
1143 | 
1144 | 
1145 | def _parse_local_version(local: Optional[str]) -> Optional[tuple[Union[str, int], ...]]:
1146 |     """
1147 |     Takes a string like abc.1.twelve and turns it into ("abc", 1, "twelve").
1148 |     """
1149 |     if local is not None:
1150 |         return tuple(
1151 |             part.lower() if not part.isdigit() else int(part)
1152 |             for part in _local_version_separators.split(local)
1153 |         )
1154 | 
1155 |     return None
1156 | 


--------------------------------------------------------------------------------