#!/usr/bin/env python
# -*- coding: utf-8 -*-
import logging
import re
import sys
from dataclasses import asdict, dataclass
from typing import Any, List, Mapping, Tuple, TypedDict, Union, overload
from typing_extensions import NotRequired, Self, TypeAlias
from pythonwrench.typing import NoneType, isinstance_generic
PreRelease: TypeAlias = Union[int, str, None, List[Union[int, str]]]
BuildMetadata: TypeAlias = Union[int, str, None, List[Union[int, str]]]
# Pattern of https://semver.org/
_VERSION_PATTERN = r"^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
_VERSION_FORMAT = r"{major}.{minor}.{patch}"
_VERSION_KEYS = ("major", "minor", "patch", "prerelease", "buildmetadata")
logger = logging.getLogger(__name__)
[docs]
class VersionDict(TypedDict):
"""TypedDict which represents a Version."""
major: int
minor: int
patch: int
prerelease: NotRequired[PreRelease]
buildmetadata: NotRequired[BuildMetadata]
VersionTuple: TypeAlias = Union[
Tuple[int, int, int],
Tuple[int, int, int, PreRelease],
Tuple[int, int, int, PreRelease, BuildMetadata],
]
VersionDictLike: TypeAlias = Mapping[str, Union[int, PreRelease, BuildMetadata]]
VersionTupleLike: TypeAlias = Tuple[Union[int, PreRelease, BuildMetadata], ...]
VersionLike: TypeAlias = Union["Version", str, VersionDictLike, VersionTupleLike]
[docs]
@dataclass(init=False, eq=False)
class Version:
"""Version utility class following Semantic Versioning (SemVer) spec.
Version format is: MAJOR.MINOR.PATCH[-PRERELEASE][+BUILDMETADATA]
Based on https://semver.org/ version 2.0.0.
"""
major: int
minor: int
patch: int
prerelease: PreRelease
buildmetadata: BuildMetadata
@overload
def __init__(
self,
version: Self,
/,
) -> None: ...
@overload
def __init__(
self,
version_str: str,
/,
) -> None: ...
@overload
def __init__(
self,
version_dict: VersionDictLike,
/,
) -> None: ...
@overload
def __init__(
self,
version_tuple: VersionTupleLike,
/,
) -> None: ...
@overload
def __init__(
self,
major: int,
minor: int,
patch: int,
prerelease: PreRelease = None,
buildmetadata: BuildMetadata = None,
) -> None: ...
def __init__(self, *args, **kwargs) -> None:
has_1_pos_arg = len(args) == 1 and len(kwargs) == 0
# Version
if has_1_pos_arg and isinstance(args[0], Version):
version = args[0]
version_dict = version.to_dict(exclude_none=False)
# Version str
elif has_1_pos_arg and isinstance(args[0], str):
version_str = args[0]
version_dict = _parse_version_str(version_str)
# Version dict
elif has_1_pos_arg and isinstance_generic(args[0], VersionDictLike):
version_dict = args[0]
# Version tuple
elif has_1_pos_arg and isinstance_generic(args[0], VersionTupleLike):
version_tuple = args[0]
version_dict = dict(zip(_VERSION_KEYS, version_tuple))
# Version args/kwargs
else:
version_dict = dict(zip(_VERSION_KEYS, args))
intersection = tuple(set(version_dict.keys()).intersection(kwargs.keys()))
if len(intersection) > 0:
msg = f"Got multiple values for argument(s) {intersection}. (with {args=} and {kwargs=})"
raise TypeError(msg)
version_dict.update(kwargs) # type: ignore
invalid = tuple(set(version_dict.keys()).difference(_VERSION_KEYS))
if len(invalid) > 0:
msg = f"Got an unexpected arguments {invalid=}. (with {args=} and {kwargs=})"
raise TypeError(msg)
if not isinstance_generic(version_dict, VersionDict):
msg = f"Invalid argument {args=} and {kwargs=}. (invalid argument types, expected (major=int, minor=int, patch=int, prerelease={PreRelease}, buildmetadata={BuildMetadata}))"
raise ValueError(msg)
major = version_dict["major"]
minor = version_dict["minor"]
patch = version_dict["patch"]
prerelease = version_dict.get("prerelease", None)
buildmetadata = version_dict.get("buildmetadata", None)
self.major = major # type: ignore
self.minor = minor # type: ignore
self.patch = patch # type: ignore
self.prerelease = prerelease # type: ignore
self.buildmetadata = buildmetadata # type: ignore
[docs]
@classmethod
def from_dict(cls, version_dict: VersionDictLike) -> Self:
return cls(version_dict)
[docs]
@classmethod
def from_str(cls, version_str: str) -> Self:
return cls(version_str)
[docs]
@classmethod
def from_tuple(cls, version_tuple: VersionTupleLike) -> Self:
return cls(version_tuple)
[docs]
@classmethod
def python(cls, releaselevel_in_metadata: bool = False) -> Self:
"""Create an instance of Version with Python version.
Note: Python 'micro' value is mapped to 'patch'.
"""
if releaselevel_in_metadata:
buildmetadata = sys.version_info.releaselevel
else:
buildmetadata = None
return cls(
major=sys.version_info.major,
minor=sys.version_info.minor,
patch=sys.version_info.micro,
buildmetadata=buildmetadata,
)
@property
def micro(self) -> int:
"""Getter alias of 'patch'."""
return self.patch
@micro.setter
def micro(self, new_value: int) -> None:
"""Setter alias of 'patch'."""
self.patch = new_value
[docs]
def without_prerelease(self) -> "Version":
return Version(self.major, self.minor, self.patch, None, self.buildmetadata)
[docs]
def next_major(
self,
keep_prerelease: bool = False,
keep_buildmetadata: bool = False,
) -> "Version":
prerelease = self.prerelease if keep_prerelease else None
buildmetadata = self.buildmetadata if keep_buildmetadata else None
return Version(
major=self.major + 1,
minor=0,
patch=0,
prerelease=prerelease,
buildmetadata=buildmetadata,
)
[docs]
def next_minor(
self,
keep_prerelease: bool = False,
keep_buildmetadata: bool = False,
) -> "Version":
prerelease = self.prerelease if keep_prerelease else None
buildmetadata = self.buildmetadata if keep_buildmetadata else None
return Version(
major=self.major,
minor=self.minor + 1,
patch=0,
prerelease=prerelease,
buildmetadata=buildmetadata,
)
[docs]
def next_patch(
self,
keep_prerelease: bool = False,
keep_buildmetadata: bool = False,
) -> "Version":
prerelease = self.prerelease if keep_prerelease else None
buildmetadata = self.buildmetadata if keep_buildmetadata else None
return Version(
major=self.major,
minor=self.minor,
patch=self.patch + 1,
prerelease=prerelease,
buildmetadata=buildmetadata,
)
[docs]
def to_dict(self, exclude_none: bool = True) -> VersionDict:
version_dict = asdict(self)
if exclude_none:
version_dict = {k: v for k, v in version_dict.items() if v is not None}
return version_dict # type: ignore
[docs]
def to_str(self) -> str:
kwds = dict(
major=self.major,
minor=self.minor,
patch=self.patch,
)
version_str = _VERSION_FORMAT.format(**kwds)
if self.prerelease is not None:
version_str = f"{version_str}-{self.prerelease}"
if self.buildmetadata is not None:
version_str = f"{version_str}+{self.buildmetadata}"
return version_str
[docs]
def to_tuple(
self,
exclude_none: bool = True,
) -> VersionTuple:
version_tuple = tuple(self.to_dict(exclude_none).values())
return version_tuple # type: ignore
[docs]
def equals(self, other: VersionLike, *, ignore_buildmetadata: bool = False) -> bool:
if isinstance(other, (Mapping, tuple, str)):
other = Version(other)
# note: use self.__class__ to avoid error cause by 'pytest -v test' collect
elif not isinstance(other, (Version, self.__class__)):
return False
return (
self.major == other.major
and self.minor == other.minor
and self.patch == other.patch
and self.prerelease == other.prerelease
and (ignore_buildmetadata or self.buildmetadata == other.buildmetadata)
)
def __str__(self) -> str:
return self.to_str()
def __eq__(self, other: Any) -> bool:
return self.equals(other)
def __lt__(self, other: VersionLike) -> bool:
return _compare_lt(self, other)
def __le__(self, other: VersionLike) -> bool:
return (self == other) or (self < other)
def __gt__(self, other: VersionLike) -> bool:
return _compare_lt(other, self)
def __ge__(self, other: VersionLike) -> bool:
return (self == other) or (self > other)
def _compare_lt(
x: Union[Version, Mapping, tuple, str], y: Union[Version, Mapping, tuple, str]
) -> bool:
if isinstance(x, (Mapping, tuple, str)):
x = Version(x)
if isinstance(y, (Mapping, tuple, str)):
y = Version(y)
self_tuple = x.to_tuple(exclude_none=False)
other_tuple = y.to_tuple(exclude_none=False)
self_tuple = self_tuple[:4]
other_tuple = other_tuple[:4]
for self_v, other_v in zip(self_tuple, other_tuple):
if self_v == other_v:
continue
if self_v is None and other_v is not None:
return False
if self_v is not None and other_v is None:
return True
if isinstance(self_v, (int, str, NoneType)):
self_v = [self_v]
elif not isinstance(self_v, list):
raise TypeError(f"Invalid argument type {type(self_v)}.")
if isinstance(other_v, (int, str, NoneType)):
other_v = [other_v]
elif not isinstance(other_v, list):
raise TypeError(f"Invalid argument type {type(other_v)}.")
minlen = min(len(self_v), len(other_v))
if len(self_v) != len(other_v) and self_v[:minlen] == other_v[:minlen]:
return len(self_v) < len(other_v)
for self_vi, other_vi in zip(self_v, other_v):
if self_vi == other_vi:
continue
if isinstance(self_vi, int) and isinstance(other_vi, int):
return self_vi < other_vi
if isinstance(self_vi, int) and isinstance(other_vi, str):
return True
if isinstance(self_vi, str) and isinstance(other_vi, int):
return False
if isinstance(self_vi, str) and isinstance(other_vi, str):
return self_vi < other_vi
msg = f"Invalid attribute type {self_vi=} and {other_vi=}."
raise TypeError(msg)
return False
def _parse_version_str(version_str: str) -> VersionDict:
version_match = re.match(_VERSION_PATTERN, version_str)
if version_match is None:
msg = f"Invalid argument {version_str=}. (not a version)"
raise ValueError(msg)
version_dict = version_match.groupdict()
result = {}
for k, v in version_dict.items():
if isinstance(v, str) and "." in v:
v = v.split(".")
else:
v = [v]
v = [int(vi) if isinstance(vi, str) and vi.isdigit() else vi for vi in v]
if len(v) == 1:
v = v[0]
result[k] = v
return result # type: ignore