Source code for semantic_release.version.version

from __future__ import annotations

import logging
import re
from functools import wraps
from itertools import zip_longest
from typing import Callable, Union, overload

from semantic_release.const import SEMVER_REGEX
from semantic_release.enums import LevelBump
from semantic_release.errors import InvalidVersion
from semantic_release.helpers import check_tag_format

log = logging.getLogger(__name__)


# Very heavily inspired by semver.version:_comparator, I don't think there's
# a cleaner way to do this
# https://github.com/python-semver/python-semver/blob/b5317af9a7e99e6a86df98320e73be72d5adf0de/src/semver/version.py#L32
VersionComparable = Union["Version", str]
VersionComparator = Callable[["Version", "Version"], bool]


@overload
def _comparator(
    *,
    type_guard: bool,
) -> Callable[[VersionComparator], VersionComparator]: ...


@overload
def _comparator(
    method: VersionComparator, *, type_guard: bool = True
) -> VersionComparator: ...


def _comparator(
    method: VersionComparator | None = None, *, type_guard: bool = True
) -> VersionComparator | Callable[[VersionComparator], VersionComparator]:
    """
    wrap a `Version` binop method to guard types and try to parse strings into Versions.
    use `type_guard = False` for `__eq__` and `__neq__` to make them return False if the
    wrong type is used, instead of erroring.
    """
    if method is None:
        return lambda method: _comparator(method, type_guard=type_guard)

    @wraps(method)
    def _wrapper(self: Version, other: VersionComparable) -> bool:
        if not isinstance(other, (str, Version)):
            return False if not type_guard else NotImplemented
        if isinstance(other, str):
            try:
                other_v = self.parse(
                    other,
                    tag_format=self.tag_format,
                    prerelease_token=self.prerelease_token,
                )
            except InvalidVersion as ex:
                raise TypeError(str(ex)) from ex
        else:
            other_v = other

        return method(self, other_v)  # type: ignore[misc]

    return _wrapper


[docs]class Version: _VERSION_REGEX = SEMVER_REGEX def __init__( self, major: int, minor: int, patch: int, *, prerelease_token: str = "rc", # noqa: S107 prerelease_revision: int | None = None, build_metadata: str = "", tag_format: str = "v{version}", ) -> None: self.major = major self.minor = minor self.patch = patch self.prerelease_token = prerelease_token self.prerelease_revision = prerelease_revision self.build_metadata = build_metadata self._tag_format = tag_format @property def tag_format(self) -> str: return self._tag_format @tag_format.setter def tag_format(self, new_format: str) -> None: check_tag_format(new_format) self._tag_format = new_format # Maybe cache?
[docs] @classmethod def parse( cls, version_str: str, tag_format: str = "v{version}", prerelease_token: str = "rc", # noqa: S107 ) -> Version: """ Parse version string to a Version instance. Inspired by `semver.version:VersionInfo.parse`, this implementation doesn't allow optional minor and patch versions. :param prerelease_token: will be ignored if the version string is a prerelease, the parsed token from `version_str` will be used instead. """ if not isinstance(version_str, str): raise InvalidVersion(f"{version_str!r} cannot be parsed as a Version") log.debug("attempting to parse string %r as Version", version_str) match = cls._VERSION_REGEX.fullmatch(version_str) if not match: raise InvalidVersion(f"{version_str!r} is not a valid Version") prerelease = match.group("prerelease") if prerelease: pm = re.match(r"(?P<token>[a-zA-Z0-9-\.]+)\.(?P<revision>\d+)", prerelease) if not pm: raise NotImplementedError( f"{cls.__qualname__} currently supports only prereleases " r"of the format (-([a-zA-Z0-9-])\.\(\d+)), for example " r"'1.2.3-my-custom-3rc.4'." ) prerelease_token, prerelease_revision = pm.groups() log.debug( "parsed prerelease_token %s, prerelease_revision %s from version " "string %s", prerelease_token, prerelease_revision, version_str, ) else: prerelease_revision = None log.debug("version string %s parsed as a non-prerelease", version_str) build_metadata = match.group("buildmetadata") or "" log.debug( "parsed build metadata %r from version string %s", build_metadata, version_str, ) return Version( int(match.group("major")), int(match.group("minor")), int(match.group("patch")), prerelease_token=prerelease_token, prerelease_revision=( int(prerelease_revision) if prerelease_revision else None ), build_metadata=build_metadata, tag_format=tag_format, )
@property def is_prerelease(self) -> bool: return self.prerelease_revision is not None def __str__(self) -> str: full = f"{self.major}.{self.minor}.{self.patch}" prerelease = ( f"-{self.prerelease_token}.{self.prerelease_revision}" if self.prerelease_revision else "" ) build_metadata = f"+{self.build_metadata}" if self.build_metadata else "" return f"{full}{prerelease}{build_metadata}" def __repr__(self) -> str: prerelease_token_repr = ( repr(self.prerelease_token) if self.prerelease_token is not None else None ) prerelease_revision_repr = ( repr(self.prerelease_revision) if self.prerelease_revision is not None else None ) build_metadata_repr = ( repr(self.build_metadata) if self.build_metadata is not None else None ) return ( f"{type(self).__qualname__}(" + ", ".join( ( f"major={self.major}", f"minor={self.minor}", f"patch={self.patch}", f"prerelease_token={prerelease_token_repr}", f"prerelease_revision={prerelease_revision_repr}", f"build_metadata={build_metadata_repr}", f"tag_format={self.tag_format!r}", ) ) + ")" )
[docs] def as_tag(self) -> str: return self.tag_format.format(version=str(self))
[docs] def as_semver_tag(self) -> str: return f"v{self!s}"
[docs] def bump(self, level: LevelBump) -> Version: """ Return a new Version instance according to the level specified to bump. Note this will intentionally drop the build metadata - that should be added elsewhere for the specific build producing this version. """ if type(level) != LevelBump: raise TypeError(f"Unexpected level {level!r}: expected {LevelBump!r}") log.debug("performing a %s level bump", level) if level is LevelBump.MAJOR: return Version( self.major + 1, 0, 0, prerelease_token=self.prerelease_token, prerelease_revision=1 if self.is_prerelease else None, tag_format=self.tag_format, ) if level is LevelBump.MINOR: return Version( self.major, self.minor + 1, 0, prerelease_token=self.prerelease_token, prerelease_revision=1 if self.is_prerelease else None, tag_format=self.tag_format, ) if level is LevelBump.PATCH: return Version( self.major, self.minor, self.patch + 1, prerelease_token=self.prerelease_token, prerelease_revision=1 if self.is_prerelease else None, tag_format=self.tag_format, ) if level is LevelBump.PRERELEASE_REVISION: return Version( self.major, self.minor, self.patch, prerelease_token=self.prerelease_token, prerelease_revision=1 if not self.is_prerelease else (self.prerelease_revision or 0) + 1, tag_format=self.tag_format, ) # for consistency, this creates a new instance regardless # only other option is level is LevelBump.NO_RELEASE return Version( self.major, self.minor, self.patch, prerelease_token=self.prerelease_token, prerelease_revision=self.prerelease_revision, tag_format=self.tag_format, )
# Enables Version + LevelBump.<level> __add__ = bump def __hash__(self) -> int: # If we use str(self) we don't capture tag_format, so another # instance with a tag_format "special_{version}_format" would # collide with an instance using "v{version}"/other format return hash(self.__repr__()) @_comparator(type_guard=False) def __eq__(self, other: Version) -> bool: # type: ignore[override] # https://semver.org/#spec-item-11 - # build metadata is not used for comparison return all( getattr(self, attr) == getattr(other, attr) for attr in ( "major", "minor", "patch", "prerelease_token", "prerelease_revision", ) ) @_comparator(type_guard=False) def __neq__(self, other: Version) -> bool: return not self.__eq__(other) # mypy wants to compare signature types with __lt__, # but can't because of the decorator @_comparator def __gt__(self, other: Version) -> bool: # type: ignore[has-type] # https://semver.org/#spec-item-11 - # build metadata is not used for comparison # Note we only support the following versioning currently, which # is a subset of the full spec: # (\d+\.\d+\.\d+)(-\w+\.\d+)?(\+.*)? if self.major != other.major: return self.major > other.major if self.minor != other.minor: return self.minor > other.minor if self.patch != other.patch: return self.patch > other.patch # If just one is a prerelease, then self > other if other is the prerelease # If neither are prereleases then they're equal (so return False) if not (self.is_prerelease and other.is_prerelease): return other.is_prerelease # If both are prereleases... # According to the semver spec 11.4 there are many other rules for # comparing precedence of pre-release versions. Here we just compare # the prerelease tokens, and their revision numbers if self.prerelease_token != other.prerelease_token: for self_tk, other_tk in zip_longest( self.prerelease_token.split("."), other.prerelease_token.split("."), fillvalue=None, ): if self_tk == other_tk: continue if (self_tk is None) ^ (other_tk is None): # Longest token (i.e. non-None) is greater return other_tk is None # Lexical sort, e.g. "rc" > "beta" > "alpha" # we have eliminated that one or both might be None above, # but mypy doesn't recognise this return self_tk > other_tk # type: ignore[operator] # We have eliminated that one or both aren't prereleases by the above return self.prerelease_revision > other.prerelease_revision # type: ignore[operator] # noqa: E501 # mypy wants to compare signature types with __le__, # but can't because of the decorator @_comparator def __ge__(self, other: Version) -> bool: # type: ignore[has-type] return self.__gt__(other) or self.__eq__(other) @_comparator def __lt__(self, other: Version) -> bool: return not (self.__gt__(other) or self.__eq__(other)) @_comparator def __le__(self, other: Version) -> bool: return not self.__gt__(other) def __sub__(self, other: Version) -> LevelBump: if not isinstance(other, Version): return NotImplemented if self.major != other.major: return LevelBump.MAJOR if self.minor != other.minor: return LevelBump.MINOR if self.patch != other.patch: return LevelBump.PATCH if self.is_prerelease ^ other.is_prerelease: return max( self.finalize_version() - other.finalize_version(), LevelBump.PRERELEASE_REVISION, ) if self.prerelease_revision != other.prerelease_revision: return LevelBump.PRERELEASE_REVISION return LevelBump.NO_RELEASE
[docs] def to_prerelease( self, token: str | None = None, revision: int | None = None ) -> Version: return Version( self.major, self.minor, self.patch, prerelease_token=token or self.prerelease_token, prerelease_revision=(revision or self.prerelease_revision) or 1, tag_format=self.tag_format, )
[docs] def finalize_version(self) -> Version: return Version( self.major, self.minor, self.patch, prerelease_token=self.prerelease_token, tag_format=self.tag_format, )