Source code for parver._version

from __future__ import annotations

import itertools
import operator
import re
import sys
from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypeVar, cast, overload

from ._helpers import IMPLICIT_ZERO, UNSET, Infinity, UnsetType, last
from ._release_int import ReleaseInt
from ._typing import ImplicitZero, NormalizedPreTag, Separator

if TYPE_CHECKING:
    if sys.version_info >= (3, 11):
        from typing import Unpack
    else:
        from typing_extensions import Unpack

PRE_TAG: tuple[str, ...] = ("alpha", "beta", "preview", "pre", "rc", "a", "b", "c")
PRE_TAG_STRICT: tuple[str, ...] = ("a", "b", "rc")
POST_TAG: tuple[str, ...] = ("post", "rev", "r")
POST_TAG_STRICT: tuple[str, ...] = ("post",)
DEV_TAG: tuple[str, ...] = ("dev",)
SEPARATOR: tuple[Separator, ...] = (".", "-", "_")
SEPARATOR_STRICT: tuple[Separator, ...] = (".",)

T = TypeVar("T")

_local_version_separators = re.compile(r"[._-]")


def check_by(by: int, current: int | None) -> None:
    """Validate the 'by' parameter for bump methods."""
    if not isinstance(by, int):
        msg = "by must be an integer"
        raise TypeError(msg)

    if current is None and by < 0:
        msg = "Cannot bump by negative amount when current value is unset."
        raise ValueError(msg)


def _normalize_pre_tag(pre_tag: str | None) -> NormalizedPreTag | None:
    """Normalize a pre-release tag to its canonical form."""
    if pre_tag is None:
        return None

    pre_tag_lower = pre_tag.lower()
    if pre_tag_lower == "alpha":
        return "a"
    elif pre_tag_lower == "beta":
        return "b"
    elif pre_tag_lower in {"c", "pre", "preview"}:
        return "rc"
    elif pre_tag_lower in {"a", "b", "rc"}:
        return cast("NormalizedPreTag", pre_tag_lower)

    # Unknown tag - shouldn't happen with valid versions
    return cast("NormalizedPreTag", pre_tag_lower)


@overload
def _parse_local_version_normalized(local: str) -> tuple[str | int, ...]: ...


@overload
def _parse_local_version_normalized(local: None) -> None: ...


def _parse_local_version_normalized(local: str | None) -> tuple[str | int, ...] | None:
    """
    Takes a string like abc.1.twelve and turns it into ("abc", 1, "twelve").
    """
    if local is not None:
        return tuple(
            part.lower() if not part.isdigit() else int(part)
            for part in _local_version_separators.split(local)
        )

    return None


def _normalize_local(local: str | None) -> str | None:
    """Normalize a local version string."""
    if local is None:
        return None

    return ".".join(map(str, _parse_local_version_normalized(local)))


def _cmpkey(
    epoch: int,
    release: tuple[int, ...],
    pre_tag: NormalizedPreTag | None,
    pre_num: int | None,
    post: int | None,
    dev: int | None,
    local: str | None,
) -> Any:
    """Create a comparison key for version ordering."""
    # When we compare a release version, we want to compare it with all of the
    # trailing zeros removed. So we'll use a reverse the list, drop all the now
    # leading zeros until we come to something non zero, then take the rest
    # re-reverse it back into the correct order and make it a tuple and use
    # that for our sorting key.
    release = tuple(
        reversed(
            list(
                itertools.dropwhile(
                    lambda x: x == 0,
                    reversed(release),
                )
            )
        )
    )

    pre: Any = pre_tag, pre_num

    # We need to "trick" the sorting algorithm to put 1.0.dev0 before 1.0a0.
    # We'll do this by abusing the pre segment, but we _only_ want to do this
    # if there is not a pre or a post segment. If we have one of those then
    # the normal sorting rules will handle this case correctly.
    if pre_num is None and post is None and dev is not None:
        pre = -Infinity
    # Versions without a pre-release (except as noted above) should sort after
    # those with one.
    elif pre_num is None:
        pre = Infinity

    # Versions without a post segment should sort before those with one.
    post_key: Any = post
    if post is None:
        post_key = -Infinity

    # Versions without a development segment should sort after those with one.
    dev_key: Any = dev
    if dev is None:
        dev_key = Infinity

    local_key: Any
    if local is None:
        # Versions without a local segment should sort before those with one.
        local_key = -Infinity
    else:
        # Versions with a local segment need that segment parsed to implement
        # the sorting rules in PEP440.
        # - Alpha numeric segments sort before numeric segments
        # - Alpha numeric segments sort lexicographically
        # - Numeric segments sort numerically
        # - Shorter versions sort before longer versions when the prefixes
        #   match exactly
        local_key = tuple(
            (i, "") if isinstance(i, int) else (-Infinity, i)
            for i in _parse_local_version_normalized(local)
        )

    return epoch, release, pre, post_key, dev_key, local_key


[docs] class ParseError(ValueError): """Raised when parsing an invalid version number."""
def _format_expected(expected: Iterable[str]) -> str: expected = _dedupe_ordered(expected) if len(expected) == 1: return expected[0] if len(expected) == 2: return f"{expected[0]} or {expected[1]}" return f"{', '.join(expected[:-1])}, or {expected[-1]}" def _dedupe_ordered(items: Iterable[str]) -> tuple[str, ...]: """Return unique strings while preserving their first-seen order.""" return tuple(dict.fromkeys(items)) def _format_found(version: str, index: int) -> str: if index >= len(version): return "end of input" return repr(version[index])
[docs] class NoLeadingNumberError(ParseError): """Raised when a version or epoch segment does not start with a number. .. doctest:: >>> Version.parse("1!") Traceback (most recent call last): ... parver.NoLeadingNumberError: Expected a release number at position 2, found end of input """ def __init__(self, *, version: str | None = None, index: int = 0): super().__init__() self.version = version self.index = index def __str__(self) -> str: if self.version is not None: found = _format_found(self.version, self.index) return f"Expected a release number at position {self.index}, found {found}" return "Expected a release number" def __reduce__(self) -> tuple[Any, ...]: cls = type(self) return cls.__new__, (cls, *self.args), self.__dict__
[docs] class UnexpectedInputError(ParseError): """Raised when the parser finds input that is not valid at its position. .. doctest:: >>> Version.parse("1.") Traceback (most recent call last): ... parver.UnexpectedInputError: Expected a release number, a pre-release tag, a post-release tag, or 'dev' at position 2, found end of input """ full_version: str index: int expected: tuple[str, ...] version: str remaining: str def __init__(self, *, version: str, index: int, expected: Iterable[str]): super().__init__() self.full_version = version self.index = index self.expected = _dedupe_ordered(expected) self.version = version[:index] self.remaining = version[index:] def __str__(self) -> str: expected = _format_expected(self.expected) found = _format_found(self.full_version, self.index) return f"Expected {expected} at position {self.index}, found {found}" def __reduce__(self) -> tuple[Any, ...]: cls = type(self) return cls.__new__, (cls, *self.args), self.__dict__
[docs] class LocalEmptyError(ParseError): """Raised when a local version marker is not followed by a segment. .. doctest:: >>> Version.parse("1+") Traceback (most recent call last): ... parver.LocalEmptyError: Expected a local version segment after '+' """ def __init__(self, *, precursor: str): super().__init__() self.precursor = precursor def __str__(self) -> str: return f"Expected a local version segment after {self.precursor!r}" def __reduce__(self) -> tuple[Any, ...]: cls = type(self) return cls.__new__, (cls, *self.args), self.__dict__
[docs] class StrictParseError(ParseError): """Base class for strict mode parse errors."""
[docs] class StrictPreTagError(UnexpectedInputError, StrictParseError): """Raised when strict mode rejects a non-canonical pre-release tag. .. doctest:: >>> Version.parse("1.2alpha1", strict=True) Traceback (most recent call last): ... parver.StrictPreTagError: Pre-release tag 'alpha' is not allowed in strict mode; use 'a' """ tag: str normalized_tag: str def __init__(self, *, version: str, index: int, tag: str): super().__init__( version=version, index=index, expected=("a strict pre-release tag",), ) self.tag = tag normalized_tag = _normalize_pre_tag(tag) assert normalized_tag is not None self.normalized_tag = normalized_tag def __str__(self) -> str: return ( f"Pre-release tag {self.tag!r} is not allowed in strict mode; " f"use {self.normalized_tag!r}" )
[docs] class StrictSegmentError(UnexpectedInputError, StrictParseError): """Raised when strict mode rejects otherwise valid non-strict syntax. .. doctest:: >>> Version.parse("1.2-1", strict=True) Traceback (most recent call last): ... parver.StrictSegmentError: Implicit post-release shorthand '-1' is not allowed in strict mode; use '.post1' """ message: str def __init__(self, *, version: str, index: int, message: str): super().__init__( version=version, index=index, expected=("strict version syntax",), ) self.message = message def __str__(self) -> str: return self.message
[docs] class VPrefixNotAllowedError(StrictParseError): """Raised when 'v' prefix is used in strict mode. .. doctest:: >>> Version.parse("v1", strict=True) Traceback (most recent call last): ... parver.VPrefixNotAllowedError: The 'v' prefix is not allowed in strict mode """ def __str__(self) -> str: return "The 'v' prefix is not allowed in strict mode"
[docs] class LeadingZerosError(StrictParseError): """Raised when a number has leading zeros in strict mode. .. doctest:: >>> Version.parse("1.02", strict=True) Traceback (most recent call last): ... parver.LeadingZerosError: Release number '02' has leading zeros in strict mode; use '2' """ def __init__(self, number: str, context: str | None = None): super().__init__() self.number = number self.context = context def __str__(self) -> str: subject = f"{self.context.capitalize()} number" if self.context else "Number" return ( f"{subject} {self.number!r} has leading zeros in strict mode; " f"use {str(int(self.number))!r}" )
[docs] class ImplicitNumberError(StrictParseError): """Raised when an implicit number is used where strict mode requires one. .. doctest:: >>> Version.parse("1.2a", strict=True) Traceback (most recent call last): ... parver.ImplicitNumberError: Pre-release number is required in strict mode; use '0' (found end of input) """ def __init__( self, context: str | None = None, *, version: str | None = None, index: int = 0, ): super().__init__() self.context = context self.version = version self.index = index def __str__(self) -> str: ctx = self.context or "segment" if self.version is not None: found = _format_found(self.version, self.index) return ( f"{ctx.capitalize()} number is required in strict mode; " f"use '0' (found {found})" ) return f"{ctx.capitalize()} number is required in strict mode" def __reduce__(self) -> tuple[Any, ...]: cls = type(self) return cls.__new__, (cls, *self.args), self.__dict__
[docs] class InvalidLocalError(StrictParseError): """Raised when a local version segment is invalid in strict mode. .. doctest:: >>> Version.parse("1+ABC", strict=True) Traceback (most recent call last): ... parver.InvalidLocalError: Local version segment 'ABC' is not allowed in strict mode: must be lowercase alphanumeric; use 'abc' """ def __init__( self, local: str, reason: str | None = None, *, replacement: str | None = None, ): super().__init__() self.local = local self.reason = reason self.replacement = replacement def __str__(self) -> str: if self.replacement is not None: return ( f"Local version segment {self.local!r} is not allowed in " f"strict mode: {self.reason}; use {self.replacement!r}" ) reason_str = f": {self.reason}" if self.reason else "" return ( f"Local version segment {self.local!r} is not allowed in " f"strict mode{reason_str}" ) def __reduce__(self) -> tuple[Any, ...]: cls = type(self) return cls.__new__, (cls, *self.args), self.__dict__
KeyPath: TypeAlias = "str | tuple[str, Unpack[tuple[str | int, ...]]]" NonEmptyTuple: TypeAlias = "tuple[T, Unpack[tuple[T, ...]]]" def _nicepath(path: KeyPath) -> str: if isinstance(path, str): return path assert isinstance(path[0], str) parts = [path[0]] for part in path[1:]: if isinstance(part, str): parts.append(f".{part}") else: parts.append(f"[{part}]") return "".join(parts) def _validate_numeric_component( path: KeyPath, value: Any, *, allow_implicit: bool = False, ) -> None: if allow_implicit and value == IMPLICIT_ZERO: return # For release validation, use 'release' in error messages name = ( "'release'" if (isinstance(path, tuple) and path[0] == "release") else _nicepath(path) ) if isinstance(value, bool): msg = f"{name} must not be a bool (got {value!r})" raise TypeError(msg) if not isinstance(value, int): msg = f"{name} must be an integer (got {value!r})" raise TypeError(msg) if value < 0: msg = f"{name} must be non-negative (got {value!r})" raise ValueError(msg) def _validate_separator(name: str, value: Any) -> None: if value not in SEPARATOR: msg = f"{name} must be one of {SEPARATOR!r} (got {value!r})" raise ValueError(msg) def _validate_tag(name: str, value: str, allowed: tuple[str, ...]) -> None: if not isinstance(value, str): msg = f"{name} must be a string (got {value!r})" raise TypeError(msg) if value.lower() not in allowed: msg = f"{name} must be one of {allowed!r} (got {value!r})" raise ValueError(msg) def _validate_pre_tag(value: str) -> None: _validate_tag("pre_tag", value, PRE_TAG) def _validate_post_tag(value: str) -> None: _validate_tag("post_tag", value, POST_TAG) def _validate_dev_tag(value: str) -> None: _validate_tag("dev_tag", value, DEV_TAG) def _release_target_part( current: int, *, value: int | None = None, width: int | None = None, ) -> int: if value is None: updated = current + 1 if width is not None: return ReleaseInt(updated, width=width) return updated if width is not None: return ReleaseInt(value, width=width) if isinstance(current, ReleaseInt): return ReleaseInt(value, width=current.minimum_width) return value
[docs] class Version: """ A PEP 440 version number. :param v: Optional preceding v character. :param epoch: `Version epoch`_. Implicitly zero but hidden by default. :param release: Numbers for the release segment. :param pre_sep1: Specify an alternate separator before the pre-release segment. The normal form is `None`. :param pre_tag: `Pre-release`_ identifier, typically `a`, `b`, or `rc`. Required to signify a pre-release. :param pre_sep2: Specify an alternate separator between the identifier and number. The normal form is `None`. :param pre: `Pre-release`_ number. May be ``''`` to signify an `implicit pre-release number`_. :param post_sep1: Specify an alternate separator before the post release segment. The normal form is ``'.'``. :param post_tag: Specify alternate post release identifier `rev` or `r`. May be `None` to signify an `implicit post release`_. :param post_sep2: Specify an alternate separator between the identifier and number. The normal form is `None`. :param post: `Post-release`_ number. May be ``''`` to signify an `implicit post release number`_. :param dev_sep1: Specify an alternate separator before the development release segment. The normal form is ``'.'``. :param dev_tag: Specify the development release identifier. The normal form is `dev`. :param dev_sep2: Specify an alternate separator between the identifier and number. The normal form is `None`. :param dev: `Developmental release`_ number. May be ``''`` to signify an `implicit development release number`_. :param local: `Local version`_ segment. .. note:: The attributes below are not equal to the parameters passed to the initialiser! The main difference is that implicit numbers become `0` and set the corresponding `_implicit` attribute: .. doctest:: >>> v = Version(release=1, post='') >>> str(v) '1.post' >>> v.post 0 >>> v.post_implicit True .. _`Version epoch`: https://www.python.org/dev/peps/pep-0440/#version-epochs .. _`Pre-release`: https://www.python.org/dev/peps/pep-0440/#pre-releases .. _`implicit pre-release number`: https://www.python.org/dev/peps/ pep-0440/#implicit-pre-release-number .. _`Post-release`: https://www.python.org/dev/peps/pep-0440/#post-releases .. _`implicit post release number`: https://www.python.org/dev/peps/ pep-0440/#implicit-post-release-number .. _`Developmental release`: https://www.python.org/dev/peps/pep-0440/ #developmental-releases .. _`implicit development release number`: https://www.python.org/dev/peps/ pep-0440/#implicit-development-release-number .. _`Local version`: https://www.python.org/dev/peps/pep-0440/ #local-version-identifiers .. _`implicit post release`: https://www.python.org/dev/peps/pep-0440/ #implicit-post-releases """ __slots__ = ( "_frozen", "_key", "dev", "dev_implicit", "dev_sep1", "dev_sep2", "dev_tag", "epoch", "epoch_implicit", "local", "post", "post_implicit", "post_sep1", "post_sep2", "post_tag", "pre", "pre_implicit", "pre_sep1", "pre_sep2", "pre_tag", "release", "v", ) _frozen: bool _key: Any v: Literal["v", "V"] | None """The leading ``v`` or ``V`` prefix, or ``None`` if it has no prefix.""" epoch: int """The version epoch. :attr:`epoch_implicit` may be `True` if this number is zero. """ epoch_implicit: bool """Whether the epoch is omitted from the version string.""" release: NonEmptyTuple[int] """A tuple of integers giving the components of the release segment; that is, the ``1.2.3`` part of the version number, including trailing zeros but not including the epoch or any prerelease/development/postrelease suffixes. """ pre_sep1: Separator | None """The separator before the pre-release identifier.""" pre_tag: str | None """If this version represents a pre-release, this attribute will be the pre-release identifier. One of `a`, `b`, `rc`, `c`, `alpha`, `beta`, `preview`, or `pre` (but may not be lowercase!) .. warning:: You should not use this attribute to check or compare pre-release identifiers. Use :attr:`is_alpha`, :attr:`is_beta`, and :attr:`is_release_candidate` instead. """ pre_sep2: Separator | None """The separator between the pre-release identifier and number.""" pre: int | None """If this version represents a pre-release, this attribute will be the pre-release number. If this instance is not a pre-release, the attribute will be `None`. :attr:`pre_implicit` may be `True` if this number is zero. """ pre_implicit: bool """Whether the pre-release number is omitted from the version string.""" post_sep1: Separator | None """The separator before the post release identifier.""" post_tag: str | None """If this version represents a post release, this attribute will be the post release identifier. One of `post`, `rev`, `r`, or `None` to represent an implicit post release (but may not be lowercase!). """ post_sep2: Separator | None """The separator between the post release identifier and number.""" post: int | None """If this version represents a postrelease, this attribute will be the postrelease number (an integer); otherwise, it will be `None`. :attr:`post_implicit` may be `True` if this number is zero. """ post_implicit: bool """Whether the post-release number is omitted from the version string.""" dev_sep1: Separator | None """The separator before the development release identifier.""" dev_tag: str | None """The development release identifier. The normal form is `dev`.""" dev_sep2: Separator | None """The separator between the development release identifier and number.""" dev: int | None """If this version represents a development release, this attribute will be the development release number (an integer); otherwise, it will be `None`. :attr:`dev_implicit` may be `True` if this number is zero. """ dev_implicit: bool """Whether the development release number is omitted from the version string.""" local: str | None """A string representing the local version portion of this version if it has one, or ``None`` otherwise. """ def __setattr__(self, name: str, value: object) -> None: if getattr(self, "_frozen", False): msg = f"{type(self).__name__} is immutable" raise AttributeError(msg) super().__setattr__(name, value) def __delattr__(self, name: str) -> None: msg = f"{type(self).__name__} is immutable" raise AttributeError(msg) def __setstate__(self, state: tuple[None, dict[str, Any]]) -> None: _, slots = state for name, value in slots.items(): object.__setattr__(self, name, value) def __init__( self, *, v: Literal["v", "V"] | bool | None = None, epoch: ImplicitZero | int = IMPLICIT_ZERO, release: int | Iterable[int], pre_sep1: Separator | None = None, pre_tag: str | None = None, pre_sep2: Separator | None = None, pre: ImplicitZero | int | None = None, post_sep1: Separator | None | UnsetType = UNSET, post_tag: str | None | UnsetType = UNSET, post_sep2: Separator | None | UnsetType = UNSET, post: ImplicitZero | int | None = None, dev_sep1: Separator | None | UnsetType = UNSET, dev_tag: str | None = None, dev_sep2: Separator | None | UnsetType = UNSET, dev: ImplicitZero | int | None = None, local: str | None = None, ) -> None: self._frozen = False match v: case True: self.v = "v" case False: self.v = None case "v" | "V" | None: self.v = v case _: msg = "v must be 'v', 'V', bool, or None" raise TypeError(msg) _validate_numeric_component("epoch", epoch, allow_implicit=True) if epoch == IMPLICIT_ZERO: self.epoch = 0 self.epoch_implicit = True else: self.epoch = epoch self.epoch_implicit = False if isinstance(release, Iterable) and not isinstance(release, str): release = tuple(release) elif isinstance(release, int): release = (release,) if not isinstance(release, tuple): msg = f"release must be an int or iterable of ints (got {release!r})" raise TypeError(msg) for i, number in enumerate(release): _validate_numeric_component(("release", i), number) if not release: msg = "'release' cannot be empty" raise ValueError(msg) self.release = release if pre is not None: _validate_numeric_component("pre", pre, allow_implicit=True) if pre_sep1 is not None: _validate_separator("pre_sep1", pre_sep1) if pre_sep2 is not None: _validate_separator("pre_sep2", pre_sep2) if pre_tag is None: if pre is not None: msg = "Must set pre_tag if pre is given." raise ValueError(msg) if pre_sep1 is not None or pre_sep2 is not None: msg = "Cannot set pre_sep1 or pre_sep2 without pre_tag." raise ValueError(msg) else: _validate_pre_tag(pre_tag) if pre is None: msg = "Must set pre if pre_tag is given." raise ValueError(msg) self.pre_tag = pre_tag if pre == IMPLICIT_ZERO: self.pre = 0 self.pre_implicit = True else: self.pre = pre self.pre_implicit = False self.pre_sep1 = pre_sep1 self.pre_sep2 = pre_sep2 if post is not None: _validate_numeric_component("post", post, allow_implicit=True) if post_sep1 is not None and post_sep1 is not UNSET: _validate_separator("post_sep1", post_sep1) if post_sep2 is not None and post_sep2 is not UNSET: _validate_separator("post_sep2", post_sep2) got_post_tag = post_tag is not UNSET got_post = post is not None got_post_sep1 = post_sep1 is not UNSET got_post_sep2 = post_sep2 is not UNSET # post_tag relies on post if got_post_tag and not got_post: msg = "Must set post if post_tag is given." raise ValueError(msg) if got_post: if not got_post_tag: # user gets the default for post_tag post_tag = "post" if post == IMPLICIT_ZERO: self.post_implicit = True self.post = 0 else: self.post_implicit = False self.post = post else: self.post_implicit = False self.post = None # Validate parameters for implicit post-release (post_tag=None). # An implicit post-release is e.g. '1-2' (== '1.post2') if post_tag is None: if self.post_implicit: msg = ( "Implicit post releases (post_tag=None) require a numerical " "value for 'post' argument." ) raise ValueError(msg) if got_post_sep1 or got_post_sep2: msg = ( "post_sep1 and post_sep2 cannot be set for implicit post " "releases (post_tag=None)" ) raise ValueError(msg) if self.pre_implicit and self.pre_sep2 is None: msg = ( "post_tag cannot be None with an implicit pre-release " "(pre='') unless pre_sep2 is not None." ) raise ValueError(msg) self.post_sep1 = "-" elif post_tag is UNSET: if got_post_sep1 or got_post_sep2: msg = "Cannot set post_sep1 or post_sep2 without post_tag." raise ValueError(msg) post_tag = None self.post_sep1 = None if self.post is None else "." else: assert not isinstance(post_tag, UnsetType) _validate_post_tag(post_tag) if not got_post_sep1: self.post_sep1 = None if self.post is None else "." else: assert not isinstance(post_sep1, UnsetType) self.post_sep1 = post_sep1 if not got_post_sep2: self.post_sep2 = None else: assert not isinstance(post_sep2, UnsetType) self.post_sep2 = post_sep2 assert not isinstance(post_tag, UnsetType) self.post_tag = post_tag if dev is not None: _validate_numeric_component("dev", dev, allow_implicit=True) if dev_sep1 is not None and dev_sep1 is not UNSET: _validate_separator("dev_sep1", dev_sep1) if dev_sep2 is not None and dev_sep2 is not UNSET: _validate_separator("dev_sep2", dev_sep2) got_dev_tag = dev_tag is not None got_dev_sep1 = dev_sep1 is not UNSET got_dev_sep2 = dev_sep2 is not UNSET if got_dev_tag and dev is None: msg = "Must set dev if dev_tag is given." raise ValueError(msg) if dev == IMPLICIT_ZERO: self.dev_implicit = True self.dev = 0 elif dev is not None: self.dev_implicit = False self.dev = dev else: self.dev_implicit = False self.dev = None if got_dev_sep1: msg = "Cannot set dev_sep1 without dev." raise ValueError(msg) if got_dev_sep2: msg = "Cannot set dev_sep2 without dev." raise ValueError(msg) if self.dev is not None and not got_dev_tag: dev_tag = "dev" if not got_dev_sep1: self.dev_sep1 = None if self.dev is None else "." else: assert not isinstance(dev_sep1, UnsetType) self.dev_sep1 = dev_sep1 if not got_dev_sep2: self.dev_sep2 = None else: assert not isinstance(dev_sep2, UnsetType) self.dev_sep2 = dev_sep2 if dev_tag is not None: _validate_dev_tag(dev_tag) self.dev_tag = dev_tag if local is not None and not isinstance(local, str): msg = f"local must be a string (got {local!r})" raise TypeError(msg) self.local = local # Compute comparison key self._key = _cmpkey( self.epoch, self.release, _normalize_pre_tag(self.pre_tag), self.pre, self.post, self.dev, self.local, ) self._frozen = True
[docs] @classmethod def parse(cls, version: str, *, strict: bool = False) -> Version: """ Parse a version string. :param version: Version number as defined in PEP 440. :param strict: Enable strict parsing of the canonical PEP 440 format. :raises ParseError: If version is not valid for the given value of `strict`. .. rubric:: Example >>> Version.parse("1.2a3") <Version '1.2a3'> """ return Parser(version, strict=strict).parse()
def __str__(self) -> str: parts: list[str] = [] if self.v: parts.append(self.v) if not self.epoch_implicit: parts.append(f"{self.epoch}!") parts.append(".".join(str(x) for x in self.release)) if self.pre_tag is not None: if self.pre_sep1: parts.append(self.pre_sep1) parts.append(self.pre_tag) if self.pre_sep2: parts.append(self.pre_sep2) if not self.pre_implicit: parts.append(str(self.pre)) if self.post_tag is None and self.post is not None: parts.append(f"-{self.post}") elif self.post_tag is not None: if self.post_sep1: parts.append(self.post_sep1) parts.append(self.post_tag) if self.post_sep2: parts.append(self.post_sep2) if not self.post_implicit: parts.append(str(self.post)) if self.dev is not None: if self.dev_sep1 is not None: parts.append(self.dev_sep1) parts.append(self.dev_tag if self.dev_tag is not None else "dev") if self.dev_sep2: parts.append(self.dev_sep2) if not self.dev_implicit: parts.append(str(self.dev)) if self.local is not None: parts.append(f"+{self.local}") return "".join(parts) def __repr__(self) -> str: return f"<{self.__class__.__name__} {str(self)!r}>" def __hash__(self) -> int: return hash(self._key) def __lt__(self, other: Any) -> Any: return self._compare(other, operator.lt) def __le__(self, other: Any) -> Any: return self._compare(other, operator.le) def __eq__(self, other: Any) -> Any: return self._compare(other, operator.eq) def __ge__(self, other: Any) -> Any: return self._compare(other, operator.ge) def __gt__(self, other: Any) -> Any: return self._compare(other, operator.gt) def __ne__(self, other: Any) -> Any: return self._compare(other, operator.ne) def _compare(self, other: Any, method: Callable[[Any, Any], bool]) -> Any: if not isinstance(other, Version): return NotImplemented return method(self._key, other._key) @property def public(self) -> str: """A string representing the public version portion of this Version instance (without the local segment). """ return str(self).split("+", 1)[0] @property def is_prerelease(self) -> bool: """A boolean value indicating whether this Version instance represents a pre-release and/or development release. """ return self.dev is not None or self.pre is not None @property def is_alpha(self) -> bool: """A boolean value indicating whether this Version instance represents an alpha pre-release. """ return _normalize_pre_tag(self.pre_tag) == "a" @property def is_beta(self) -> bool: """A boolean value indicating whether this Version instance represents a beta pre-release. """ return _normalize_pre_tag(self.pre_tag) == "b" @property def is_release_candidate(self) -> bool: """A boolean value indicating whether this Version instance represents a release candidate pre-release. """ return _normalize_pre_tag(self.pre_tag) == "rc" @property def is_postrelease(self) -> bool: """A boolean value indicating whether this Version instance represents a post-release. """ return self.post is not None @property def is_devrelease(self) -> bool: """A boolean value indicating whether this Version instance represents a development release. """ return self.dev is not None def _attrs_as_init(self) -> dict[str, Any]: """Convert current attributes to a dict suitable for __init__.""" d: dict[str, Any] = dict( release=self.release, v=self.v, epoch=IMPLICIT_ZERO if self.epoch_implicit else self.epoch, local=self.local, ) if self.pre is not None: d["pre"] = IMPLICIT_ZERO if self.pre_implicit else self.pre d["pre_tag"] = self.pre_tag d["pre_sep1"] = self.pre_sep1 d["pre_sep2"] = self.pre_sep2 if self.post is not None: d["post"] = IMPLICIT_ZERO if self.post_implicit else self.post d["post_tag"] = self.post_tag if self.post_tag is not None: d["post_sep1"] = self.post_sep1 d["post_sep2"] = self.post_sep2 if self.dev is not None: d["dev"] = IMPLICIT_ZERO if self.dev_implicit else self.dev d["dev_tag"] = self.dev_tag d["dev_sep1"] = self.dev_sep1 d["dev_sep2"] = self.dev_sep2 return d
[docs] def replace( self, release: int | Iterable[int] | UnsetType = UNSET, v: Literal["v", "V"] | None | UnsetType = UNSET, epoch: ImplicitZero | int | UnsetType = UNSET, pre_tag: str | None | UnsetType = UNSET, pre: ImplicitZero | int | None | UnsetType = UNSET, post: ImplicitZero | int | None | UnsetType = UNSET, dev: ImplicitZero | int | None | UnsetType = UNSET, local: str | None | UnsetType = UNSET, pre_sep1: Separator | None | UnsetType = UNSET, pre_sep2: Separator | None | UnsetType = UNSET, post_sep1: Separator | None | UnsetType = UNSET, post_sep2: Separator | None | UnsetType = UNSET, dev_sep1: Separator | None | UnsetType = UNSET, dev_sep2: Separator | None | UnsetType = UNSET, post_tag: str | None | UnsetType = UNSET, dev_tag: str | None | UnsetType = UNSET, ) -> Version: """Return a new Version instance with the same attributes, except for those given as keyword arguments. Arguments have the same meaning as they do when constructing a new Version instance manually. .. rubric:: Example >>> Version.parse("1.2").replace(post=1) <Version '1.2.post1'> >>> Version.parse("1.2").replace(pre_tag="a", pre=0) <Version '1.2a0'> >>> Version.parse("1-1").replace(post_tag="post") <Version '1.post1'> """ kwargs: dict[str, Any] = dict( release=release, v=v, epoch=epoch, pre_tag=pre_tag, pre=pre, post=post, dev=dev, local=local, pre_sep1=pre_sep1, pre_sep2=pre_sep2, post_sep1=post_sep1, post_sep2=post_sep2, dev_sep1=dev_sep1, dev_sep2=dev_sep2, post_tag=post_tag, dev_tag=dev_tag, ) kwargs = {k: v for k, v in kwargs.items() if v is not UNSET} d = self._attrs_as_init() if kwargs.get("post_tag", UNSET) is None: # ensure we don't carry over separators for new implicit post # release. By popping from d, there will still be an error if the # user tries to set them in kwargs d.pop("post_sep1", None) d.pop("post_sep2", None) if kwargs.get("post", UNSET) is None: kwargs["post_tag"] = UNSET d.pop("post_sep1", None) d.pop("post_sep2", None) if kwargs.get("pre", UNSET) is None: kwargs["pre_tag"] = None d.pop("pre_sep1", None) d.pop("pre_sep2", None) if kwargs.get("dev", UNSET) is None: d.pop("dev_sep1", None) d.pop("dev_sep2", None) d.pop("dev_tag", None) d.update(kwargs) return Version(**d)
def _set_release( self, index: int, value: int | None = None, bump: bool = True, width: int | None = None, ) -> Version: """Helper method for release-related bump operations.""" if not isinstance(index, int): msg = "index must be an integer" raise TypeError(msg) if index < 0: msg = "index cannot be negative" raise ValueError(msg) release = list(self.release) new_len = index + 1 if len(release) < new_len: release.extend(itertools.repeat(0, new_len - len(release))) def new_parts(i: int, n: int) -> int: if i < index: return n if i == index: return _release_target_part(n, value=value, width=width) if bump: if isinstance(n, ReleaseInt): return n.zero_like() return 0 return n new_release = list(itertools.starmap(new_parts, enumerate(release))) return self.replace(release=new_release)
[docs] def bump_epoch(self, *, by: int = 1, width: int | None = None) -> Version: """Return a new Version instance with the epoch number bumped. :param by: How much to bump the number by. :param width: Minimum width for the resulting epoch number. :raises TypeError: `by` is not an integer. .. rubric:: Example >>> Version.parse("1.2").bump_epoch() <Version '1!1.2'> >>> Version.parse("04!1").bump_epoch() <Version '05!1'> >>> Version.parse("1").bump_epoch(width=2) <Version '01!1'> """ check_by(by, self.epoch) epoch = by - 1 if self.epoch is None else self.epoch + by if width is not None: epoch = ReleaseInt(epoch, width=width) return self.replace(epoch=epoch)
[docs] def bump_release(self, *, index: int, width: int | None = None) -> Version: """Return a new Version instance with the release number bumped at the given `index`. :param index: Index of the release number tuple to bump. It is not limited to the current size of the tuple. Intermediate indices will be set to zero. :param width: Minimum width for the resulting release number at `index`. :raises TypeError: `index` is not an integer. :raises ValueError: `index` is negative. .. rubric:: Example >>> Version.parse("1.2").bump_release(index=1) <Version '1.3'> >>> Version.parse("1.02").bump_release(index=1) <Version '1.03'> >>> Version.parse("1.2").bump_release(index=1, width=2) <Version '1.03'> """ return self._set_release(index=index, width=width)
[docs] def bump_release_to( self, *, index: int, value: int, width: int | None = None ) -> Version: """Return a new Version instance with the release number bumped at the given `index` to `value`. May be used for CalVer. :param index: Index of the release number tuple to bump. :param value: Value to bump to. Subsequent indices will be set to zero. :param width: Minimum width for the resulting release number at `index`. :raises TypeError: `index` is not an integer. :raises ValueError: `index` is negative. .. rubric:: Example >>> Version.parse("2026.05").bump_release_to(index=1, value=6) <Version '2026.06'> >>> Version.parse("2026.05").bump_release_to(index=0, value=2027) <Version '2027.00'> >>> Version.parse("2026.12").bump_release_to(index=0, value=2027) <Version '2027.0'> >>> Version.parse("2027.0").bump_release_to(index=1, value=1, width=2) <Version '2027.01'> """ return self._set_release(index=index, value=value, width=width)
[docs] def set_release( self, *, index: int, value: int, width: int | None = None ) -> Version: """Return a new Version instance with the release number at the given `index` set to `value`. :param index: Index of the release number tuple to set. :param value: Value to set. :param width: Minimum width for the resulting release number at `index`. :raises TypeError: `index` is not an integer. :raises ValueError: `index` is negative. .. warning:: This method lets you produce a version number older than the input version. You may be looking for :meth:`bump_release_to`. .. rubric:: Example >>> v = Version.parse("2024.01") >>> v.set_release(index=0, value=2026).set_release(index=1, value=5) <Version '2026.05'> >>> Version.parse("1").set_release(index=1, value=6, width=2) <Version '1.06'> """ return self._set_release(index=index, value=value, bump=False, width=width)
[docs] def bump_pre( self, tag: str | None = None, *, by: int = 1, width: int | None = None ) -> Version: """Return a new Version instance with the pre-release number bumped. :param tag: Pre-release tag. Required if not already set. :param by: How much to bump the number by. :param width: Minimum width for the resulting pre-release number. :raises ValueError: Trying to call bump_pre(tag=None) on a Version that is not already a pre-release. :raises ValueError: Calling with a tag not equal to the current pre_tag. :raises TypeError: `by` is not an integer. .. rubric:: Example >>> Version.parse("1.2").bump_pre("a") <Version '1.2a0'> >>> Version.parse("1a04").bump_pre() <Version '1a05'> >>> Version.parse("1").bump_pre("b", width=2) <Version '1b00'> """ check_by(by, self.pre) pre = by - 1 if self.pre is None else self.pre + by if width is not None: pre = ReleaseInt(pre, width=width) if self.pre_tag is None: if tag is None: msg = "Cannot bump without pre_tag. Use .bump_pre('<tag>')" raise ValueError(msg) else: # This is an error because different tags have different meanings if tag is not None and self.pre_tag.lower() != tag.lower(): msg = ( f"Cannot bump with pre_tag mismatch ({self.pre_tag} != {tag}). " f"Use .replace(pre_tag={tag!r})" ) raise ValueError(msg) tag = self.pre_tag return self.replace(pre=pre, pre_tag=tag)
@overload def bump_post( self, tag: str | None, *, by: int = 1, width: int | None = None ) -> Version: ... @overload def bump_post(self, *, by: int = 1, width: int | None = None) -> Version: ...
[docs] def bump_post( self, tag: str | None | UnsetType = UNSET, *, by: int = 1, width: int | None = None, ) -> Version: """Return a new Version instance with the post release number bumped. :param tag: Post release tag. Will preserve the current tag by default, or use 'post' if the instance is not already a post release. :param by: How much to bump the number by. :param width: Minimum width for the resulting post-release number. :raises TypeError: `by` is not an integer. .. rubric:: Example >>> Version.parse("1.2").bump_post() <Version '1.2.post0'> >>> Version.parse("1.2").bump_post("rev") <Version '1.2.rev0'> >>> Version.parse("1-04").bump_post() <Version '1-05'> >>> Version.parse("1").bump_post(None, by=2, width=2) <Version '1-01'> """ check_by(by, self.post) post = by - 1 if self.post is None else self.post + by if width is not None: post = ReleaseInt(post, width=width) if tag is UNSET and self.post is not None: tag = self.post_tag return self.replace(post=post, post_tag=tag)
@overload def bump_dev( self, tag: str, *, by: int = 1, width: int | None = None ) -> Version: ... @overload def bump_dev(self, *, by: int = 1, width: int | None = None) -> Version: ...
[docs] def bump_dev( self, tag: str | UnsetType = UNSET, *, by: int = 1, width: int | None = None, ) -> Version: """Return a new Version instance with the development release number bumped. :param tag: Development release tag. Will preserve the current tag by default, or use 'dev' if the instance is not already a development release. :param by: How much to bump the number by. :param width: Minimum width for the resulting development release number. :raises TypeError: `by` is not an integer. .. rubric:: Example >>> Version.parse("1.2").bump_dev() <Version '1.2.dev0'> >>> Version.parse("1.2").bump_dev("DEV") <Version '1.2.DEV0'> >>> Version.parse("1.dev04").bump_dev() <Version '1.dev05'> >>> Version.parse("1").bump_dev(width=2) <Version '1.dev00'> """ check_by(by, self.dev) dev = by - 1 if self.dev is None else self.dev + by if width is not None: dev = ReleaseInt(dev, width=width) if tag is UNSET and self.dev is not None: assert self.dev_tag is not None tag = self.dev_tag return self.replace(dev=dev, dev_tag=tag)
[docs] def normalize(self) -> Version: """Return a new Version instance with normalized values. Normalizes pre-release tags, local version, and removes the v prefix. .. rubric:: Example >>> Version.parse("v1.02-BETA_3+LOCAL").normalize() <Version '1.2b3+local'> """ return Version( release=[int(x) for x in self.release], epoch=IMPLICIT_ZERO if self.epoch == 0 else int(self.epoch), pre_tag=_normalize_pre_tag(self.pre_tag), pre=None if self.pre is None else int(self.pre), post=None if self.post is None else int(self.post), dev=None if self.dev is None else int(self.dev), local=_normalize_local(self.local), )
[docs] def base_version(self) -> Version: """Return a new Version instance for the base version. The base version is the public version without any pre or post release markers. .. rubric:: Example >>> Version.parse("1.2a3.post4.dev5+local").base_version() <Version '1.2'> """ return self.replace(pre=None, post=None, dev=None, local=None)
[docs] def truncate(self, *, min_length: int = 1) -> Version: """Return a new Version instance with trailing zeros removed from the release segment. :param min_length: Minimum number of parts to keep. :raises TypeError: `min_length` is not an integer. :raises ValueError: `min_length` is not positive. .. rubric:: Example >>> Version.parse("1.0.0").truncate() <Version '1'> >>> Version.parse("1.0.1").bump_release(index=0).truncate(min_length=2) <Version '2.0'> """ if not isinstance(min_length, int): msg = "min_length must be an integer" raise TypeError(msg) if min_length < 1: msg = "min_length must be positive" raise ValueError(msg) release = list(self.release) if len(release) < min_length: release.extend(itertools.repeat(0, min_length - len(release))) last_nonzero = max( last((i for i, n in enumerate(release) if n), default=0), min_length - 1, ) return self.replace(release=release[: last_nonzero + 1])
def is_ascii_digit(c: str) -> bool: return "0" <= c <= "9" def is_ascii_alphanumeric(s: str) -> bool: return s.isalnum() and s.isascii() def is_strict_number(s: str) -> bool: """Check if a number string is valid in strict mode (no leading zeros).""" if not s: return False # "0" is valid, "01" is not if len(s) > 1 and s[0] == "0": return False return True def is_strict_local_alpha(s: str) -> bool: """Check if a local part is a valid alpha segment in strict mode. Pattern: [0-9]*[a-z][a-z0-9]* Must contain at least one lowercase letter. """ if not s: return False # Must be ASCII alphanumeric if not s.isascii() or not s.isalnum(): return False # Must contain at least one lowercase letter if not any(c.islower() for c in s): return False # Find the first letter; the lowercase-letter check above guarantees one exists. first_letter_idx = next(i for i, c in enumerate(s) if c.isalpha()) # First letter and all after must be lowercase alphanumeric for c in s[first_letter_idx:]: if not (c.islower() or c.isdigit()): return False return True @dataclass(slots=True) class _Cursor: text: str index: int = 0 start: int = 0 end: int = field(init=False) text_lower: str = field(init=False) def __post_init__(self) -> None: self.end = len(self.text) assert 0 <= self.start <= self.index <= self.end <= len(self.text) self.text_lower = self.text.lower() def is_done(self) -> bool: return self.index >= self.end def reset(self, offset: int) -> None: assert self.start <= offset <= self.end self.index = offset def match(self, string: str, *, case_sensitive: bool = False) -> str | None: if self.is_at(string, case_sensitive=case_sensitive): found = self.text[self.index : self.index + len(string)] self.index += len(string) return found return None def match_any( self, strings: tuple[str, ...], *, case_sensitive: bool = False ) -> str | None: for string in strings: if self.is_at(string, case_sensitive=case_sensitive): found = self.text[self.index : self.index + len(string)] self.index += len(string) return found return None def is_at(self, string: str, *, case_sensitive: bool = False) -> bool: haystack = self.text if case_sensitive else self.text_lower return self.index + len(string) <= self.end and haystack.startswith( string, self.index ) def is_at_any( self, strings: tuple[str, ...], *, case_sensitive: bool = False ) -> bool: return any( self.is_at(string, case_sensitive=case_sensitive) for string in strings ) def take_while(self, predicate: Callable[[str], bool]) -> str: start = self.index while not self.is_done() and predicate(self.text[self.index]): self.index += 1 return self.text[start : self.index] def checkpoint(self) -> _CursorCheckpoint: return _CursorCheckpoint(self, self.index) @dataclass(slots=True) class _CursorCheckpoint: cursor: _Cursor index: int committed: bool = False def commit(self) -> None: self.committed = True def __enter__(self) -> _CursorCheckpoint: return self def __exit__(self, exc_type: object, exc: object, tb: object) -> None: if not self.committed: self.cursor.reset(self.index) @dataclass(slots=True) class _ParseDiagnostics: index: int = -1 expected: tuple[str, ...] = () def expect(self, index: int, expected: str | Iterable[str]) -> None: if isinstance(expected, str): expected = (expected,) if index > self.index: self.index = index self.expected = _dedupe_ordered(expected) elif index == self.index: self.expected = _dedupe_ordered((*self.expected, *expected)) def error(self, version: str) -> UnexpectedInputError | None: if self.index < 0 or not self.expected: return None return UnexpectedInputError( version=version, index=self.index, expected=self.expected, ) @dataclass(slots=True) class _PreSegment: tag: str number: int sep_before: Separator | None = None sep_after_tag: Separator | None = None implicit_number: bool = False @dataclass(slots=True) class _PostSegment: number: int tag: str | None = "post" sep_before: Separator | None = None sep_after_tag: Separator | None = None implicit_number: bool = False @dataclass(slots=True) class _DevSegment: number: int tag: str | None = None sep_before: Separator | None = None sep_after_tag: Separator | None = None implicit_number: bool = False SegmentT = TypeVar("SegmentT", _PreSegment, _PostSegment, _DevSegment) @dataclass(slots=True) class _ParsedVersion: release: Iterable[int] v: Literal["v", "V"] | None = None epoch: int = 0 epoch_implicit: bool = True pre: _PreSegment | None = None post: _PostSegment | None = None dev: _DevSegment | None = None local: str | None = None def into_version(self) -> Version: kwargs: dict[str, Any] = dict( v=self.v, epoch=IMPLICIT_ZERO if self.epoch_implicit else self.epoch, release=self.release, local=self.local, ) if self.pre is not None: kwargs["pre_sep1"] = self.pre.sep_before kwargs["pre_tag"] = self.pre.tag kwargs["pre_sep2"] = self.pre.sep_after_tag kwargs["pre"] = ( IMPLICIT_ZERO if self.pre.implicit_number else self.pre.number ) if self.post is not None: kwargs["post"] = ( IMPLICIT_ZERO if self.post.implicit_number else self.post.number ) kwargs["post_tag"] = self.post.tag if self.post.tag is not None: kwargs["post_sep1"] = self.post.sep_before kwargs["post_sep2"] = self.post.sep_after_tag if self.dev is not None: kwargs["dev"] = ( IMPLICIT_ZERO if self.dev.implicit_number else self.dev.number ) kwargs["dev_tag"] = self.dev.tag kwargs["dev_sep1"] = self.dev.sep_before kwargs["dev_sep2"] = self.dev.sep_after_tag return Version(**kwargs) class _SegmentKind(Enum): PRE = auto() POST = auto() DEV = auto() def _validate_strict_local_part(parts: list[str], part: str) -> None: if part.isdigit(): if not is_strict_number(part): raise InvalidLocalError( part, "leading zeros", replacement=str(int(part)), ) elif not is_strict_local_alpha(part): replacement = part.lower() if is_strict_local_alpha(part.lower()) else None raise InvalidLocalError( part, "must be lowercase alphanumeric", replacement=replacement, ) def _validate_strict_number(digits: str, context: str | None = None) -> None: if not is_strict_number(digits): raise LeadingZerosError(digits, context) class Parser: """PEP 440 version parser. In permissive mode it preserves original spelling and accepts the broader set of PEP 440 spellings. In strict mode it enforces the canonical form. """ def __init__(self, version: str, *, strict: bool = False) -> None: self.strict = strict self.version = version self.cursor = _Cursor(self.version) self.diagnostics = _ParseDiagnostics() @property def allowed_separators(self) -> tuple[Separator, ...]: return SEPARATOR_STRICT if self.strict else SEPARATOR @property def pre_tags(self) -> tuple[str, ...]: return PRE_TAG_STRICT if self.strict else PRE_TAG @property def post_tags(self) -> tuple[str, ...]: return POST_TAG_STRICT if self.strict else POST_TAG def parse(self) -> Version: self.skip_surrounding_whitespace() v = self.parse_v_prefix() epoch, epoch_implicit, release = self.parse_epoch_and_release() pre = self.parse_pre() post = self.parse_post() dev = self.parse_dev() local = self.parse_local() self.skip_surrounding_whitespace() self.finish( expected=self.expected_after_segments(pre, post, dev, local), allow_non_strict_pre_tag=pre is None and post is None and dev is None and local is None, ) return _ParsedVersion( v=v, epoch=epoch, epoch_implicit=epoch_implicit, release=release, pre=pre, post=post, dev=dev, local=local, ).into_version() def skip_surrounding_whitespace(self) -> None: self.cursor.take_while(str.isspace) def parse_v_prefix(self) -> Literal["v", "V"] | None: prefix = cast("Literal['v', 'V'] | None", self.cursor.match("v")) if prefix is None: return None if self.strict: raise VPrefixNotAllowedError return prefix def parse_epoch_and_release(self) -> tuple[int, bool, list[int]]: first_number = self.parse_number("release") if first_number is None: raise NoLeadingNumberError( version=self.version, index=self.cursor.index, ) epoch = 0 epoch_implicit = True release_number = first_number with self.cursor.checkpoint() as attempt: if self.cursor.match("!") is not None: parsed_release_number = self.parse_number("release") if parsed_release_number is None: raise NoLeadingNumberError( version=self.version, index=self.cursor.index, ) release_number = parsed_release_number attempt.commit() epoch = first_number epoch_implicit = False return epoch, epoch_implicit, self.parse_release_tail(release_number) def parse_release_tail(self, first_release_number: int) -> list[int]: release = [first_release_number] while True: with self.cursor.checkpoint() as attempt: if self.cursor.match(".") is None: break release_number = self.parse_number("release") if release_number is None: non_strict_separator_error = ( self.non_strict_separator_error_at_index( index=attempt.index, separator=".", tag_index=self.cursor.index, ) ) if non_strict_separator_error is not None: raise non_strict_separator_error non_strict_error = self.non_strict_segment_error_at_cursor( after_release_separator=True, ) if non_strict_error is not None: raise non_strict_error if not self.separator_can_start_following_segment(): self.diagnostics.expect( self.cursor.index, self.expected_after_release_separator(), ) break attempt.commit() release.append(release_number) return release def parse_number(self, context: str | None = None) -> int | None: digits = self.cursor.take_while(is_ascii_digit) if not digits: return None if self.strict: _validate_strict_number(digits, context) return int(digits) if digits.startswith("0"): return ReleaseInt(digits) else: return int(digits) def parse_pre(self) -> _PreSegment | None: return self.parse_segment( kind=_SegmentKind.PRE, tags=self.pre_tags, context="pre-release", allow_leading_separator=not self.strict, allow_number_separator=not self.strict, allow_implicit_number=not self.strict, segment_type=_PreSegment, ) def parse_post(self) -> _PostSegment | None: if not self.strict: with self.cursor.checkpoint() as attempt: if self.cursor.match("-") is not None: number = self.parse_number("post-release") if number is not None: attempt.commit() return _PostSegment(tag=None, number=number) return self.parse_segment( kind=_SegmentKind.POST, tags=self.post_tags, context="post-release", allow_leading_separator=not self.strict, require_leading_separator=self.strict, allow_number_separator=not self.strict, allow_implicit_number=not self.strict, segment_type=_PostSegment, ) def parse_dev(self) -> _DevSegment | None: return self.parse_segment( kind=_SegmentKind.DEV, tags=("dev",), context="development release", allow_leading_separator=not self.strict, require_leading_separator=self.strict, allow_number_separator=not self.strict, allow_implicit_number=not self.strict, segment_type=_DevSegment, ) def parse_local(self) -> str | None: if self.cursor.match("+") is None: return None precursor = "+" parts: list[str] = [] while True: part = self.cursor.take_while(is_ascii_alphanumeric) if not part: raise LocalEmptyError(precursor=precursor) if self.strict: _validate_strict_local_part(parts, part) parts.append(part) separator = self.match_separator() if separator is None: if ( self.strict and not self.cursor.is_done() and self.cursor.text[self.cursor.index] in "-_" ): raise StrictSegmentError( version=self.version, index=self.cursor.index, message=( f"Local version separator " f"{self.cursor.text[self.cursor.index]!r} is not " "allowed in strict mode; use '.'" ), ) break parts.append(separator) precursor = separator return "".join(parts) def parse_segment( self, *, kind: _SegmentKind, tags: tuple[str, ...], context: str, allow_leading_separator: bool = False, require_leading_separator: bool = False, allow_number_separator: bool = False, allow_implicit_number: bool = False, segment_type: type[SegmentT], ) -> SegmentT | None: with self.cursor.checkpoint() as attempt: sep_before = None if allow_leading_separator or require_leading_separator: sep_before = self.match_separator() if require_leading_separator and sep_before is None: return None tag_start = self.cursor.index tag = self.match_tags(tags) if tag is None: return None sep_after_tag = None number = None with self.cursor.checkpoint() as number_attempt: candidate_sep_after_tag = None if allow_number_separator: candidate_sep_after_tag = self.match_separator() number = self.parse_number(context) if number is not None or ( candidate_sep_after_tag is not None and not self.separator_belongs_to_next_segment(kind) ): sep_after_tag = candidate_sep_after_tag number_attempt.commit() implicit_number = number is None if implicit_number: if not allow_implicit_number: if ( self.strict and self.cursor.index < len(self.version) and self.version[self.cursor.index] in SEPARATOR ): number_separator_error = ( self.non_strict_number_separator_error_at_index( index=self.cursor.index, context=context, ) ) if number_separator_error is not None: raise number_separator_error if kind is _SegmentKind.PRE: non_strict_pre_tag = self.non_strict_pre_tag_at_index(tag_start) if non_strict_pre_tag is not None: raise StrictPreTagError( version=self.version, index=tag_start, tag=non_strict_pre_tag, ) raise ImplicitNumberError( context, version=self.version, index=self.cursor.index, ) number = 0 assert number is not None attempt.commit() return segment_type( tag=tag, number=number, sep_before=sep_before, sep_after_tag=sep_after_tag, implicit_number=implicit_number, ) def match_tags(self, tags: tuple[str, ...]) -> str | None: return self.cursor.match_any(tags, case_sensitive=self.strict) def match_separator(self) -> Separator | None: return cast( "Separator | None", self.cursor.match_any(self.allowed_separators), ) def separator_belongs_to_next_segment(self, current: _SegmentKind) -> bool: return self.is_at_tag(self.following_tags(current)) def separator_can_start_following_segment(self) -> bool: if self.strict: return self.is_at_tag((*self.post_tags, "dev")) return self.is_at_tag((*self.pre_tags, *self.post_tags, "dev")) def expected_after_release_separator(self) -> tuple[str, ...]: expected = ["a release number"] if not self.strict: expected.append("a pre-release tag") expected.append("a post-release tag") expected.append("'dev'") return tuple(expected) def following_tags(self, current: _SegmentKind) -> tuple[str, ...]: if current is _SegmentKind.PRE: return *self.post_tags, "dev" if current is _SegmentKind.POST: return ("dev",) return () def is_at_tag(self, tags: tuple[str, ...]) -> bool: return self.cursor.is_at_any(tags) def non_strict_pre_tag_at_cursor(self) -> str | None: return self.non_strict_pre_tag_at_index(self.cursor.index) def non_strict_pre_tag_at_index(self, index: int) -> str | None: if not self.strict: return None version_lower = self.version.lower() for tag in PRE_TAG: if not version_lower.startswith(tag, index): continue found = self.version[index : index + len(tag)] if tag not in PRE_TAG_STRICT or found != tag: return found return None def non_strict_segment_error_at_cursor( self, *, after_release_separator: bool, ) -> StrictParseError | None: if not self.strict: return None index = self.cursor.index non_strict_pre_tag = self.non_strict_pre_tag_at_index(index) if non_strict_pre_tag is not None: return StrictPreTagError( version=self.version, index=index, tag=non_strict_pre_tag, ) if after_release_separator: return self.non_strict_tag_error_at_index( index=index, kind="post-release", tags=POST_TAG, strict_tags=POST_TAG_STRICT, canonical_tag="post", ) or self.non_strict_tag_error_at_index( index=index, kind="development release", tags=DEV_TAG, strict_tags=DEV_TAG, canonical_tag="dev", ) separator = self.cursor.text[index] if index < len(self.version) else "" tag_index = index + 1 if separator in SEPARATOR else index separator_error = None if separator == "-": implicit_post_error = self.non_strict_implicit_post_error_at_index( index=index, ) if implicit_post_error is not None: return implicit_post_error if separator in "-_.": separator_error = self.non_strict_separator_error_at_index( index=index, separator=separator, tag_index=tag_index, ) if separator_error is not None: return separator_error missing_separator_error = self.non_strict_missing_separator_error_at_index( index=tag_index, ) if missing_separator_error is not None: return missing_separator_error return self.non_strict_tag_error_at_index( index=tag_index, kind="post-release", tags=POST_TAG, strict_tags=POST_TAG_STRICT, canonical_tag="post", ) or self.non_strict_tag_error_at_index( index=tag_index, kind="development release", tags=DEV_TAG, strict_tags=DEV_TAG, canonical_tag="dev", ) def non_strict_separator_error_at_index( self, *, index: int, separator: str, tag_index: int, ) -> StrictSegmentError | None: if not self.strict: return None pre_tag = self.pre_tag_at_index(tag_index) if pre_tag is not None: normalized_tag = _normalize_pre_tag(pre_tag) if normalized_tag is not None and normalized_tag != pre_tag: fix = f"omit the separator and use {normalized_tag!r}" else: fix = "omit the separator" return StrictSegmentError( version=self.version, index=index, message=( f"Separator {separator!r} before pre-release tag {pre_tag!r} " f"is not allowed in strict mode; {fix}" ), ) if separator == ".": return None post_tag = self.tag_at_index(tag_index, POST_TAG) if post_tag is not None: return StrictSegmentError( version=self.version, index=index, message=( f"Separator {separator!r} before post-release tag {post_tag!r} " "is not allowed in strict mode; use '.'" ), ) dev_tag = self.tag_at_index(tag_index, DEV_TAG) if dev_tag is not None: return StrictSegmentError( version=self.version, index=index, message=( f"Separator {separator!r} before development release tag " f"{dev_tag!r} is not allowed in strict mode; use '.'" ), ) return None def non_strict_number_separator_error_at_index( self, *, index: int, context: str, ) -> StrictSegmentError | None: if not self.strict: return None separator = self.version[index] number_start = index + 1 if number_start >= len(self.version) or not is_ascii_digit( self.version[number_start] ): return None return StrictSegmentError( version=self.version, index=index, message=( f"Separator {separator!r} before the {context} number is not " "allowed in strict mode; omit the separator" ), ) def non_strict_implicit_post_error_at_index( self, *, index: int, ) -> StrictSegmentError | None: if not self.strict: return None number_start = index + 1 number_end = number_start while number_end < len(self.version) and is_ascii_digit( self.version[number_end] ): number_end += 1 if number_end == number_start: return None number = self.version[number_start:number_end] canonical_number = str(int(number)) return StrictSegmentError( version=self.version, index=index, message=( f"Implicit post-release shorthand '-{number}' is not allowed " f"in strict mode; use '.post{canonical_number}'" ), ) def non_strict_tag_error_at_index( self, *, index: int, kind: str, tags: tuple[str, ...], strict_tags: tuple[str, ...], canonical_tag: str, ) -> StrictSegmentError | None: if not self.strict: return None tag = self.tag_at_index(index, tags) if tag is None: return None if tag.lower() in strict_tags and tag == tag.lower(): return None return StrictSegmentError( version=self.version, index=index, message=( f"{kind.capitalize()} tag {tag!r} is not allowed in strict mode; " f"use {canonical_tag!r}" ), ) def non_strict_missing_separator_error_at_index( self, *, index: int, ) -> StrictSegmentError | None: if not self.strict: return None post_tag = self.tag_at_index(index, POST_TAG) if post_tag is not None: return StrictSegmentError( version=self.version, index=index, message=( f"Post-release tag {post_tag!r} without a leading '.' is " "not allowed in strict mode; use '.post'" ), ) dev_tag = self.tag_at_index(index, DEV_TAG) if dev_tag is not None: return StrictSegmentError( version=self.version, index=index, message=( f"Development release tag {dev_tag!r} without a leading '.' " "is not allowed in strict mode; use '.dev'" ), ) return None def pre_tag_at_index(self, index: int) -> str | None: return self.tag_at_index(index, PRE_TAG) def tag_at_index(self, index: int, tags: tuple[str, ...]) -> str | None: version_lower = self.version.lower() for tag in tags: if version_lower.startswith(tag, index): return self.version[index : index + len(tag)] return None def expected_after_segments( self, pre: _PreSegment | None, post: _PostSegment | None, dev: _DevSegment | None, local: str | None, ) -> tuple[str, ...]: if local is not None: return ("end of version",) if dev is not None: return (self.local_segment_expected(), "end of version") if post is not None: return ( self.dev_segment_expected(), self.local_segment_expected(), "end of version", ) if pre is not None: return ( self.post_segment_expected(), self.dev_segment_expected(), self.local_segment_expected(), "end of version", ) return ( self.pre_segment_expected(), self.post_segment_expected(), self.dev_segment_expected(), self.local_segment_expected(), "end of version", ) def pre_segment_expected(self) -> str: if self.strict: return "a pre-release segment ('a', 'b', or 'rc')" return "a pre-release segment" def post_segment_expected(self) -> str: if self.strict: return "a post-release segment ('.post')" return "a post-release segment" def dev_segment_expected(self) -> str: if self.strict: return "a development release segment ('.dev')" return "a development release segment" def local_segment_expected(self) -> str: return "a local version segment ('+')" def finish( self, *, expected: Iterable[str], allow_non_strict_pre_tag: bool, ) -> None: if not self.cursor.is_done(): diagnostic = self.diagnostics.error(self.version) if diagnostic is not None and diagnostic.index >= self.cursor.index: raise diagnostic if allow_non_strict_pre_tag: non_strict_error = self.non_strict_segment_error_at_cursor( after_release_separator=False, ) if non_strict_error is not None: raise non_strict_error raise UnexpectedInputError( version=self.version, index=self.cursor.index, expected=expected, )