import itertools import operator import re from functools import partial from typing import ( Any, Callable, Dict, Iterable, Optional, Sequence, Set, Tuple, Union, cast, overload, ) import attr from attr import Attribute, converters from attr.validators import and_, deep_iterable, in_, instance_of, optional from . import _segments as segment from ._helpers import IMPLICIT_ZERO, UNSET, Infinity, UnsetType, last from ._parse import parse from ._typing import ImplicitZero, NormalizedPreTag, PostTag, PreTag, Separator POST_TAGS: Set[PostTag] = {"post", "rev", "r"} SEPS: Set[Separator] = {".", "-", "_"} PRE_TAGS: Set[PreTag] = {"c", "rc", "alpha", "a", "beta", "b", "preview", "pre"} _ValidatorType = Callable[[Any, "Attribute[Any]", Any], None] def unset_or(validator: _ValidatorType) -> _ValidatorType: def validate(inst: Any, attr: "Attribute[Any]", value: Any) -> None: if value is UNSET: return validator(inst, attr, value) return validate def implicit_or( validator: Union[_ValidatorType, Sequence[_ValidatorType]] ) -> _ValidatorType: if isinstance(validator, Sequence): validator = and_(*validator) def validate(inst: Any, attr: "Attribute[Any]", value: Any) -> None: if value == IMPLICIT_ZERO: return validator(inst, attr, value) return validate def not_bool(inst: Any, attr: "Attribute[Any]", value: Any) -> None: if isinstance(value, bool): raise TypeError( "'{name}' must not be a bool (got {value!r})".format( name=attr.name, value=value ) ) def is_non_negative(inst: Any, attr: "Attribute[Any]", value: Any) -> None: if value < 0: raise ValueError( "'{name}' must be non-negative (got {value!r})".format( name=attr.name, value=value ) ) def non_empty(inst: Any, attr: "Attribute[Any]", value: Any) -> None: if not value: raise ValueError(f"'{attr.name}' cannot be empty") def check_by(by: int, current: Optional[int]) -> None: if not isinstance(by, int): raise TypeError("by must be an integer") if current is None and by < 0: raise ValueError("Cannot bump by negative amount when current value is unset.") validate_post_tag: _ValidatorType = unset_or(optional(in_(POST_TAGS))) validate_pre_tag: _ValidatorType = optional(in_(PRE_TAGS)) validate_sep: _ValidatorType = optional(in_(SEPS)) validate_sep_or_unset: _ValidatorType = unset_or(optional(in_(SEPS))) is_bool: _ValidatorType = instance_of(bool) is_int: _ValidatorType = instance_of(int) is_str: _ValidatorType = instance_of(str) is_tuple: _ValidatorType = instance_of(tuple) # "All numeric components MUST be non-negative integers." num_comp = [not_bool, is_int, is_non_negative] release_validator = deep_iterable(and_(*num_comp), and_(is_tuple, non_empty)) def convert_release(release: Union[int, Iterable[int]]) -> Tuple[int, ...]: if isinstance(release, Iterable) and not isinstance(release, str): return tuple(release) elif isinstance(release, int): return (release,) # The input value does not conform to the function type, let it pass through # to the validator return release def convert_local(local: Optional[str]) -> Optional[str]: if isinstance(local, str): return local.lower() return local def convert_implicit(value: Union[ImplicitZero, int]) -> int: """This function is a lie, since mypy's attrs plugin takes the argument type as that of the constructed __init__. The lie is required because we aren't dealing with ImplicitZero until __attrs_post_init__. """ return value # type: ignore[return-value] @attr.s(frozen=True, repr=False, eq=False) class Version: """ :param release: Numbers for the release segment. :param v: Optional preceding v character. :param epoch: `Version epoch`_. Implicitly zero but hidden by default. :param pre_tag: `Pre-release`_ identifier, typically `a`, `b`, or `rc`. Required to signify a pre-release. :param pre: `Pre-release`_ number. May be ``''`` to signify an `implicit pre-release number`_. :param post: `Post-release`_ number. May be ``''`` to signify an `implicit post release number`_. :param dev: `Developmental release`_ number. May be ``''`` to signify an `implicit development release number`_. :param local: `Local version`_ segment. :param pre_sep1: Specify an alternate separator before the pre-release segment. The normal form is `None`. :param pre_sep2: Specify an alternate separator between the identifier and number. The normal form is ``'.'``. :param post_sep1: Specify an alternate separator before the post release segment. The normal form is ``'.'``. :param post_sep2: Specify an alternate separator between the identifier and number. The normal form is ``'.'``. :param dev_sep: Specify an alternate separator before the development 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`_. .. 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 .. attribute:: release A tuple of integers giving the components of the release segment of this :class:`Version` instance; that is, the ``1.2.3`` part of the version number, including trailing zeros but not including the epoch or any prerelease/development/postrelease suffixes .. attribute:: v Whether this :class:`Version` instance includes a preceding v character. .. attribute:: epoch An integer giving the version epoch of this :class:`Version` instance. :attr:`epoch_implicit` may be `True` if this number is zero. .. attribute:: pre_tag If this :class:`Version` instance represents a pre-release, this attribute will be the pre-release identifier. One of `a`, `b`, `rc`, `c`, `alpha`, `beta`, `preview`, or `pre`. **Note:** you should not use this attribute to check or compare pre-release identifiers. Use :meth:`is_alpha`, :meth:`is_beta`, and :meth:`is_release_candidate` instead. .. attribute:: pre If this :class:`Version` instance 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. .. attribute:: post If this :class:`Version` instance 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. .. attribute:: dev If this :class:`Version` instance represents a development release, this attribute will be the development release number (an integer); otherwise, it will be `None`. :attr:`dev_implicit` may be `True` if this number is zero. .. attribute:: local A string representing the local version portion of this :class:`Version` instance if it has one, or ``None`` otherwise. .. attribute:: pre_sep1 The separator before the pre-release identifier. .. attribute:: pre_sep2 The seperator between the pre-release identifier and number. .. attribute:: post_sep1 The separator before the post release identifier. .. attribute:: post_sep2 The seperator between the post release identifier and number. .. attribute:: dev_sep The separator before the develepment release identifier. .. attribute:: post_tag If this :class:`Version` instance 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. .. _`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 """ release: Tuple[int, ...] = attr.ib( converter=convert_release, validator=release_validator ) v: bool = attr.ib(default=False, validator=is_bool) epoch: int = attr.ib( default=cast(int, IMPLICIT_ZERO), converter=convert_implicit, validator=implicit_or(num_comp), ) pre_tag: Optional[PreTag] = attr.ib(default=None, validator=validate_pre_tag) pre: Optional[int] = attr.ib( default=None, converter=converters.optional(convert_implicit), validator=implicit_or(optional(num_comp)), ) post: Optional[int] = attr.ib( default=None, converter=converters.optional(convert_implicit), validator=implicit_or(optional(num_comp)), ) dev: Optional[int] = attr.ib( default=None, converter=converters.optional(convert_implicit), validator=implicit_or(optional(num_comp)), ) local: Optional[str] = attr.ib( default=None, converter=convert_local, validator=optional(is_str) ) pre_sep1: Optional[Separator] = attr.ib(default=None, validator=validate_sep) pre_sep2: Optional[Separator] = attr.ib(default=None, validator=validate_sep) post_sep1: Optional[Separator] = attr.ib( default=UNSET, validator=validate_sep_or_unset ) post_sep2: Optional[Separator] = attr.ib( default=UNSET, validator=validate_sep_or_unset ) dev_sep: Optional[Separator] = attr.ib( default=UNSET, validator=validate_sep_or_unset ) post_tag: Optional[PostTag] = attr.ib(default=UNSET, validator=validate_post_tag) epoch_implicit: bool = attr.ib(default=False, init=False) pre_implicit: bool = attr.ib(default=False, init=False) post_implicit: bool = attr.ib(default=False, init=False) dev_implicit: bool = attr.ib(default=False, init=False) _key = attr.ib(init=False) def __attrs_post_init__(self) -> None: set_ = partial(object.__setattr__, self) if self.epoch == IMPLICIT_ZERO: set_("epoch", 0) set_("epoch_implicit", True) self._validate_pre(set_) self._validate_post(set_) self._validate_dev(set_) set_( "_key", _cmpkey( self.epoch, self.release, _normalize_pre_tag(self.pre_tag), self.pre, self.post, self.dev, self.local, ), ) def _validate_pre(self, set_: Callable[[str, Any], None]) -> None: if self.pre_tag is None: if self.pre is not None: raise ValueError("Must set pre_tag if pre is given.") if self.pre_sep1 is not None or self.pre_sep2 is not None: raise ValueError("Cannot set pre_sep1 or pre_sep2 without pre_tag.") else: if self.pre == IMPLICIT_ZERO: set_("pre", 0) set_("pre_implicit", True) elif self.pre is None: raise ValueError("Must set pre if pre_tag is given.") def _validate_post(self, set_: Callable[[str, Any], None]) -> None: got_post_tag = self.post_tag is not UNSET got_post = self.post is not None got_post_sep1 = self.post_sep1 is not UNSET got_post_sep2 = self.post_sep2 is not UNSET # post_tag relies on post if got_post_tag and not got_post: raise ValueError("Must set post if post_tag is given.") if got_post: if not got_post_tag: # user gets the default for post_tag set_("post_tag", "post") if self.post == IMPLICIT_ZERO: set_("post_implicit", True) set_("post", 0) # Validate parameters for implicit post-release (post_tag=None). # An implicit post-release is e.g. '1-2' (== '1.post2') if self.post_tag is None: if self.post_implicit: raise ValueError( "Implicit post releases (post_tag=None) require a numerical " "value for 'post' argument." ) if got_post_sep1 or got_post_sep2: raise ValueError( "post_sep1 and post_sep2 cannot be set for implicit post " "releases (post_tag=None)" ) if self.pre_implicit: raise ValueError( "post_tag cannot be None with an implicit pre-release (pre='')." ) set_("post_sep1", "-") elif self.post_tag is UNSET: if got_post_sep1 or got_post_sep2: raise ValueError("Cannot set post_sep1 or post_sep2 without post_tag.") set_("post_tag", None) if not got_post_sep1 and self.post_sep1 is UNSET: set_("post_sep1", None if self.post is None else ".") if not got_post_sep2: set_("post_sep2", None) assert self.post_sep1 is not UNSET assert self.post_sep2 is not UNSET def _validate_dev(self, set_: Callable[[str, Any], None]) -> None: if self.dev == IMPLICIT_ZERO: set_("dev_implicit", True) set_("dev", 0) elif self.dev is None: if self.dev_sep is not UNSET: raise ValueError("Cannot set dev_sep without dev.") if self.dev_sep is UNSET: set_("dev_sep", None if self.dev is None else ".") @classmethod def parse(cls, version: str, strict: bool = False) -> "Version": """ :param version: Version number as defined in `PEP 440`_. :type version: str :param strict: Enable strict parsing of the canonical PEP 440 format. :type strict: bool .. _`PEP 440`: https://www.python.org/dev/peps/pep-0440/ :raises ParseError: If version is not valid for the given value of `strict`. .. doctest:: :options: -IGNORE_EXCEPTION_DETAIL >>> Version.parse('1.dev') >>> Version.parse('1.dev', strict=True) Traceback (most recent call last): ... parver.ParseError: Expected int at position (1, 6) => '1.dev*'. """ segments = parse(version, strict=strict) kwargs: Dict[str, Any] = dict() for s in segments: if isinstance(s, segment.Epoch): kwargs["epoch"] = s.value elif isinstance(s, segment.Release): kwargs["release"] = s.value elif isinstance(s, segment.Pre): kwargs["pre"] = s.value kwargs["pre_tag"] = s.tag kwargs["pre_sep1"] = s.sep1 kwargs["pre_sep2"] = s.sep2 elif isinstance(s, segment.Post): kwargs["post"] = s.value kwargs["post_tag"] = s.tag kwargs["post_sep1"] = s.sep1 kwargs["post_sep2"] = s.sep2 elif isinstance(s, segment.Dev): kwargs["dev"] = s.value kwargs["dev_sep"] = s.sep elif isinstance(s, segment.Local): kwargs["local"] = s.value elif isinstance(s, segment.V): kwargs["v"] = True else: raise TypeError(f"Unexpected segment: {segment}") return cls(**kwargs) def normalize(self) -> "Version": return Version( release=self.release, epoch=IMPLICIT_ZERO if self.epoch == 0 else self.epoch, pre_tag=_normalize_pre_tag(self.pre_tag), pre=self.pre, post=self.post, dev=self.dev, local=_normalize_local(self.local), ) def __str__(self) -> str: parts = [] if self.v: parts.append("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_sep is not None: parts.append(self.dev_sep) parts.append("dev") 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) -> bool: return self._compare(other, operator.lt) def __le__(self, other: Any) -> bool: return self._compare(other, operator.le) def __eq__(self, other: Any) -> bool: return self._compare(other, operator.eq) def __ge__(self, other: Any) -> bool: return self._compare(other, operator.ge) def __gt__(self, other: Any) -> bool: return self._compare(other, operator.gt) def __ne__(self, other: Any) -> bool: return self._compare(other, operator.ne) def _compare(self, other: Any, method: Callable[[Any, Any], bool]) -> bool: 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 :class:`Version` instance. """ return str(self).split("+", 1)[0] def base_version(self) -> "Version": """Return a new :class:`Version` instance for the base version of the current instance. The base version is the public version of the project without any pre or post release markers. See also: :meth:`clear` and :meth:`replace`. """ return self.replace(pre=None, post=None, dev=None, local=None) @property def is_prerelease(self) -> bool: """A boolean value indicating whether this :class:`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 :class:`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 :class:`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 :class:`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 :class:`Version` instance represents a post-release. """ return self.post is not None @property def is_devrelease(self) -> bool: """A boolean value indicating whether this :class:`Version` instance represents a development release. """ return self.dev is not None def _attrs_as_init(self) -> Dict[str, Any]: d = attr.asdict(self, filter=lambda attr, _: attr.init) if self.epoch_implicit: d["epoch"] = IMPLICIT_ZERO if self.pre_implicit: d["pre"] = IMPLICIT_ZERO if self.post_implicit: d["post"] = IMPLICIT_ZERO if self.dev_implicit: d["dev"] = IMPLICIT_ZERO if self.pre is None: del d["pre"] del d["pre_tag"] del d["pre_sep1"] del d["pre_sep2"] if self.post is None: del d["post"] del d["post_tag"] del d["post_sep1"] del d["post_sep2"] elif self.post_tag is None: del d["post_sep1"] del d["post_sep2"] if self.dev is None: del d["dev"] del d["dev_sep"] return d def replace( self, release: Union[int, Iterable[int], UnsetType] = UNSET, v: Union[bool, UnsetType] = UNSET, epoch: Union[ImplicitZero, int, UnsetType] = UNSET, pre_tag: Union[PreTag, None, UnsetType] = UNSET, pre: Union[ImplicitZero, int, None, UnsetType] = UNSET, post: Union[ImplicitZero, int, None, UnsetType] = UNSET, dev: Union[ImplicitZero, int, None, UnsetType] = UNSET, local: Union[str, None, UnsetType] = UNSET, pre_sep1: Union[Separator, None, UnsetType] = UNSET, pre_sep2: Union[Separator, None, UnsetType] = UNSET, post_sep1: Union[Separator, None, UnsetType] = UNSET, post_sep2: Union[Separator, None, UnsetType] = UNSET, dev_sep: Union[Separator, None, UnsetType] = UNSET, post_tag: Union[PostTag, None, UnsetType] = UNSET, ) -> "Version": """Return a new :class:`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 :class:`Version` instance manually. """ kwargs = 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_sep=dev_sep, post_tag=post_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_sep", None) d.update(kwargs) return Version(**d) def _set_release( self, index: int, value: Optional[int] = None, bump: bool = True ) -> "Version": if not isinstance(index, int): raise TypeError("index must be an integer") if index < 0: raise ValueError("index cannot be negative") 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: if value is None: return n + 1 return value if bump: return 0 return n new_release = itertools.starmap(new_parts, enumerate(release)) return self.replace(release=new_release) def bump_epoch(self, *, by: int = 1) -> "Version": """Return a new :class:`Version` instance with the epoch number bumped. :param by: How much to bump the number by. :type by: int :raises TypeError: `by` is not an integer. .. doctest:: >>> Version.parse('1.4').bump_epoch() >>> Version.parse('2!1.4').bump_epoch(by=-1) """ check_by(by, self.epoch) epoch = by - 1 if self.epoch is None else self.epoch + by return self.replace(epoch=epoch) def bump_release(self, *, index: int) -> "Version": """Return a new :class:`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. :type index: int :raises TypeError: `index` is not an integer. :raises ValueError: `index` is negative. .. doctest:: >>> v = Version.parse('1.4') >>> v.bump_release(index=0) >>> v.bump_release(index=1) >>> v.bump_release(index=2) >>> v.bump_release(index=3) .. seealso:: For more control over the value that is bumped to, see :meth:`bump_release_to`. For fine-grained control, :meth:`set_release` may be used to set the value at a specific index without setting subsequenct indices to zero. """ return self._set_release(index=index) def bump_release_to(self, *, index: int, value: int) -> "Version": """Return a new :class:`Version` instance with the release number bumped at the given `index` to `value`. May be used for versioning schemes such as `CalVer`_. .. _`CalVer`: https://calver.org :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. :type index: int :param value: Value to bump to. This may be any value, but subsequent indices will be set to zero like a normal version bump. :type value: int :raises TypeError: `index` is not an integer. :raises ValueError: `index` is negative. .. testsetup:: import datetime .. doctest:: >>> v = Version.parse('18.4') >>> v.bump_release_to(index=0, value=20) >>> v.bump_release_to(index=1, value=10) For a project using `CalVer`_ with format ``YYYY.MM.MICRO``, this method could be used to set the date parts: .. doctest:: >>> v = Version.parse('2018.4.1') >>> v = v.bump_release_to(index=0, value=2018) >>> v = v.bump_release_to(index=1, value=10) >>> v .. seealso:: For typical use cases, see :meth:`bump_release`. For fine-grained control, :meth:`set_release` may be used to set the value at a specific index without setting subsequenct indices to zero. """ return self._set_release(index=index, value=value) def set_release(self, *, index: int, value: int) -> "Version": """Return a new :class:`Version` instance with the release number at the given `index` set to `value`. :param index: Index of the release number tuple to set. It is not limited to the current size of the tuple. Intermediate indices will be set to zero. :type index: int :param value: Value to set. :type value: int :raises TypeError: `index` is not an integer. :raises ValueError: `index` is negative. .. doctest:: >>> v = Version.parse('1.2.3') >>> v.set_release(index=0, value=3) >>> v.set_release(index=1, value=4) .. seealso:: For typical use cases, see :meth:`bump_release`. """ return self._set_release(index=index, value=value, bump=False) def bump_pre(self, tag: Optional[PreTag] = None, *, by: int = 1) -> "Version": """Return a new :class:`Version` instance with the pre-release number bumped. :param tag: Pre-release tag. Required if not already set. :type tag: str :param by: How much to bump the number by. :type by: int :raises ValueError: Trying to call ``bump_pre(tag=None)`` on a :class:`Version` instance that is not already a pre-release. :raises ValueError: Calling the method with a `tag` not equal to the current :attr:`post_tag`. See :meth:`replace` instead. :raises TypeError: `by` is not an integer. .. doctest:: >>> Version.parse('1.4').bump_pre('a') >>> Version.parse('1.4b1').bump_pre() >>> Version.parse('1.4b1').bump_pre(by=-1) """ check_by(by, self.pre) pre = by - 1 if self.pre is None else self.pre + by if self.pre_tag is None: if tag is None: raise ValueError("Cannot bump without pre_tag. Use .bump_pre('')") else: # This is an error because different tags have different meanings if tag is not None and self.pre_tag != tag: raise ValueError( "Cannot bump with pre_tag mismatch ({0} != {1}). Use " ".replace(pre_tag={1!r})".format(self.pre_tag, tag) ) tag = self.pre_tag return self.replace(pre=pre, pre_tag=tag) @overload def bump_post(self, tag: Optional[PostTag], *, by: int = 1) -> "Version": pass @overload def bump_post(self, *, by: int = 1) -> "Version": pass def bump_post( self, tag: Union[PostTag, None, UnsetType] = UNSET, *, by: int = 1 ) -> "Version": """Return a new :class:`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. :type tag: str :param by: How much to bump the number by. :type by: int :raises TypeError: `by` is not an integer. .. doctest:: >>> Version.parse('1.4').bump_post() >>> Version.parse('1.4.post0').bump_post(tag=None) >>> Version.parse('1.4_post-1').bump_post(tag='rev') >>> Version.parse('1.4.post2').bump_post(by=-1) """ check_by(by, self.post) post = by - 1 if self.post is None else self.post + by if tag is UNSET and self.post is not None: tag = self.post_tag return self.replace(post=post, post_tag=tag) def bump_dev(self, *, by: int = 1) -> "Version": """Return a new :class:`Version` instance with the development release number bumped. :param by: How much to bump the number by. :type by: int :raises TypeError: `by` is not an integer. .. doctest:: >>> Version.parse('1.4').bump_dev() >>> Version.parse('1.4_dev1').bump_dev() >>> Version.parse('1.4.dev3').bump_dev(by=-1) """ check_by(by, self.dev) dev = by - 1 if self.dev is None else self.dev + by return self.replace(dev=dev) def truncate(self, *, min_length: int = 1) -> "Version": """Return a new :class:`Version` instance with trailing zeros removed from the release segment. :param min_length: Minimum number of parts to keep. :type min_length: int .. doctest:: >>> Version.parse('0.1.0').truncate() >>> Version.parse('1.0.0').truncate(min_length=2) >>> Version.parse('1').truncate(min_length=2) """ if not isinstance(min_length, int): raise TypeError("min_length must be an integer") if min_length < 1: raise ValueError("min_length must be positive") 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 _normalize_pre_tag(pre_tag: Optional[PreTag]) -> Optional[NormalizedPreTag]: if pre_tag is None: return None if pre_tag == "alpha": pre_tag = "a" elif pre_tag == "beta": pre_tag = "b" elif pre_tag in {"c", "pre", "preview"}: pre_tag = "rc" return cast(NormalizedPreTag, pre_tag) def _normalize_local(local: Optional[str]) -> Optional[str]: if local is None: return None return ".".join(map(str, _parse_local_version(local))) def _cmpkey( epoch: int, release: Tuple[int, ...], pre_tag: Optional[NormalizedPreTag], pre_num: Optional[int], post: Optional[int], dev: Optional[int], local: Optional[str], ) -> Any: # 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 = 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 # type: ignore[assignment] # Versions without a pre-release (except as noted above) should sort after # those with one. elif pre_num is None: pre = Infinity # type: ignore[assignment] # Versions without a post segment should sort before those with one. if post is None: post = -Infinity # type: ignore[assignment] # Versions without a development segment should sort after those with one. if dev is None: dev = Infinity # type: ignore[assignment] if local is None: # Versions without a local segment should sort before those with one. local = -Infinity # type: ignore[assignment] 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 = tuple( # type: ignore[assignment] (i, "") if isinstance(i, int) else (-Infinity, i) for i in _parse_local_version(local) ) return epoch, release, pre, post, dev, local _local_version_separators = re.compile(r"[._-]") @overload def _parse_local_version(local: str) -> Tuple[Union[str, int], ...]: pass @overload def _parse_local_version(local: None) -> None: pass def _parse_local_version(local: Optional[str]) -> Optional[Tuple[Union[str, int], ...]]: """ 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