bazarr/libs/semver/version.py

742 lines
24 KiB
Python

"""Version handling by a semver compatible version class."""
import re
from functools import wraps
from typing import (
Any,
ClassVar,
Dict,
Iterable,
Optional,
Pattern,
SupportsInt,
Tuple,
Union,
cast,
Callable,
Collection,
Type,
TypeVar,
)
from ._types import (
VersionTuple,
VersionDict,
VersionIterator,
String,
VersionPart,
)
# These types are required here because of circular imports
Comparable = Union["Version", Dict[str, VersionPart], Collection[VersionPart], str]
Comparator = Callable[["Version", Comparable], bool]
T = TypeVar("T", bound="Version")
def _comparator(operator: Comparator) -> Comparator:
"""Wrap a Version binary op method in a type-check."""
@wraps(operator)
def wrapper(self: "Version", other: Comparable) -> bool:
comparable_types = (
Version,
dict,
tuple,
list,
*String.__args__, # type: ignore
)
if not isinstance(other, comparable_types):
return NotImplemented
return operator(self, other)
return wrapper
def _cmp(a, b): # TODO: type hints
"""Return negative if a<b, zero if a==b, positive if a>b."""
return (a > b) - (a < b)
class Version:
"""
A semver compatible version class.
See specification at https://semver.org.
:param major: version when you make incompatible API changes.
:param minor: version when you add functionality in a backwards-compatible manner.
:param patch: version when you make backwards-compatible bug fixes.
:param prerelease: an optional prerelease string
:param build: an optional build string
"""
__slots__ = ("_major", "_minor", "_patch", "_prerelease", "_build")
#: The names of the different parts of a version
NAMES: ClassVar[Tuple[str, ...]] = tuple([item[1:] for item in __slots__])
#: Regex for number in a prerelease
_LAST_NUMBER: ClassVar[Pattern[str]] = re.compile(r"(?:[^\d]*(\d+)[^\d]*)+")
#: Regex template for a semver version
_REGEX_TEMPLATE: ClassVar[
str
] = r"""
^
(?P<major>0|[1-9]\d*)
(?:
\.
(?P<minor>0|[1-9]\d*)
(?:
\.
(?P<patch>0|[1-9]\d*)
){opt_patch}
){opt_minor}
(?:-(?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<build>
[0-9a-zA-Z-]+
(?:\.[0-9a-zA-Z-]+)*
))?
$
"""
#: Regex for a semver version
_REGEX: ClassVar[Pattern[str]] = re.compile(
_REGEX_TEMPLATE.format(opt_patch="", opt_minor=""),
re.VERBOSE,
)
#: Regex for a semver version that might be shorter
_REGEX_OPTIONAL_MINOR_AND_PATCH: ClassVar[Pattern[str]] = re.compile(
_REGEX_TEMPLATE.format(opt_patch="?", opt_minor="?"),
re.VERBOSE,
)
def __init__(
self,
major: SupportsInt,
minor: SupportsInt = 0,
patch: SupportsInt = 0,
prerelease: Optional[Union[String, int]] = None,
build: Optional[Union[String, int]] = None,
):
# Build a dictionary of the arguments except prerelease and build
version_parts = {"major": int(major), "minor": int(minor), "patch": int(patch)}
for name, value in version_parts.items():
if value < 0:
raise ValueError(
"{!r} is negative. A version can only be positive.".format(name)
)
self._major = version_parts["major"]
self._minor = version_parts["minor"]
self._patch = version_parts["patch"]
self._prerelease = None if prerelease is None else str(prerelease)
self._build = None if build is None else str(build)
@classmethod
def _nat_cmp(cls, a, b): # TODO: type hints
def cmp_prerelease_tag(a, b):
if isinstance(a, int) and isinstance(b, int):
return _cmp(a, b)
elif isinstance(a, int):
return -1
elif isinstance(b, int):
return 1
else:
return _cmp(a, b)
a, b = a or "", b or ""
a_parts, b_parts = a.split("."), b.split(".")
a_parts = [int(x) if re.match(r"^\d+$", x) else x for x in a_parts]
b_parts = [int(x) if re.match(r"^\d+$", x) else x for x in b_parts]
for sub_a, sub_b in zip(a_parts, b_parts):
cmp_result = cmp_prerelease_tag(sub_a, sub_b)
if cmp_result != 0:
return cmp_result
else:
return _cmp(len(a), len(b))
@property
def major(self) -> int:
"""The major part of a version (read-only)."""
return self._major
@major.setter
def major(self, value):
raise AttributeError("attribute 'major' is readonly")
@property
def minor(self) -> int:
"""The minor part of a version (read-only)."""
return self._minor
@minor.setter
def minor(self, value):
raise AttributeError("attribute 'minor' is readonly")
@property
def patch(self) -> int:
"""The patch part of a version (read-only)."""
return self._patch
@patch.setter
def patch(self, value):
raise AttributeError("attribute 'patch' is readonly")
@property
def prerelease(self) -> Optional[str]:
"""The prerelease part of a version (read-only)."""
return self._prerelease
@prerelease.setter
def prerelease(self, value):
raise AttributeError("attribute 'prerelease' is readonly")
@property
def build(self) -> Optional[str]:
"""The build part of a version (read-only)."""
return self._build
@build.setter
def build(self, value):
raise AttributeError("attribute 'build' is readonly")
def to_tuple(self) -> VersionTuple:
"""
Convert the Version object to a tuple.
.. versionadded:: 2.10.0
Renamed :meth:`Version._astuple` to :meth:`Version.to_tuple` to
make this function available in the public API.
:return: a tuple with all the parts
>>> semver.Version(5, 3, 1).to_tuple()
(5, 3, 1, None, None)
"""
return (self.major, self.minor, self.patch, self.prerelease, self.build)
def to_dict(self) -> VersionDict:
"""
Convert the Version object to an dict.
.. versionadded:: 2.10.0
Renamed :meth:`Version._asdict` to :meth:`Version.to_dict` to
make this function available in the public API.
:return: an dict with the keys in the order ``major``, ``minor``,
``patch``, ``prerelease``, and ``build``.
>>> semver.Version(3, 2, 1).to_dict()
{'major': 3, 'minor': 2, 'patch': 1, 'prerelease': None, 'build': None}
"""
return dict(
major=self.major,
minor=self.minor,
patch=self.patch,
prerelease=self.prerelease,
build=self.build,
)
def __iter__(self) -> VersionIterator:
"""Return iter(self)."""
yield from self.to_tuple()
@staticmethod
def _increment_string(string: str) -> str:
"""
Look for the last sequence of number(s) in a string and increment.
:param string: the string to search for.
:return: the incremented string
Source:
http://code.activestate.com/recipes/442460-increment-numbers-in-a-string/#c1
"""
match = Version._LAST_NUMBER.search(string)
if match:
next_ = str(int(match.group(1)) + 1)
start, end = match.span(1)
string = string[: max(end - len(next_), start)] + next_ + string[end:]
return string
def bump_major(self) -> "Version":
"""
Raise the major part of the version, return a new object but leave self
untouched.
:return: new object with the raised major part
>>> ver = semver.parse("3.4.5")
>>> ver.bump_major()
Version(major=4, minor=0, patch=0, prerelease=None, build=None)
"""
cls = type(self)
return cls(self._major + 1)
def bump_minor(self) -> "Version":
"""
Raise the minor part of the version, return a new object but leave self
untouched.
:return: new object with the raised minor part
>>> ver = semver.parse("3.4.5")
>>> ver.bump_minor()
Version(major=3, minor=5, patch=0, prerelease=None, build=None)
"""
cls = type(self)
return cls(self._major, self._minor + 1)
def bump_patch(self) -> "Version":
"""
Raise the patch part of the version, return a new object but leave self
untouched.
:return: new object with the raised patch part
>>> ver = semver.parse("3.4.5")
>>> ver.bump_patch()
Version(major=3, minor=4, patch=6, prerelease=None, build=None)
"""
cls = type(self)
return cls(self._major, self._minor, self._patch + 1)
def bump_prerelease(self, token: Optional[str] = "rc") -> "Version":
"""
Raise the prerelease part of the version, return a new object but leave
self untouched.
:param token: defaults to ``'rc'``
:return: new :class:`Version` object with the raised prerelease part.
The original object is not modified.
>>> ver = semver.parse("3.4.5")
>>> ver.bump_prerelease().prerelease
'rc.2'
>>> ver.bump_prerelease('').prerelease
'1'
>>> ver.bump_prerelease(None).prerelease
'rc.1'
"""
cls = type(self)
if self._prerelease is not None:
prerelease = self._prerelease
elif token == "":
prerelease = "0"
elif token is None:
prerelease = "rc.0"
else:
prerelease = str(token) + ".0"
prerelease = cls._increment_string(prerelease)
return cls(self._major, self._minor, self._patch, prerelease)
def bump_build(self, token: Optional[str] = "build") -> "Version":
"""
Raise the build part of the version, return a new object but leave self
untouched.
:param token: defaults to ``'build'``
:return: new :class:`Version` object with the raised build part.
The original object is not modified.
>>> ver = semver.parse("3.4.5-rc.1+build.9")
>>> ver.bump_build()
Version(major=3, minor=4, patch=5, prerelease='rc.1', \
build='build.10')
"""
cls = type(self)
if self._build is not None:
build = self._build
elif token == "":
build = "0"
elif token is None:
build = "build.0"
else:
build = str(token) + ".0"
# self._build or (token or "build") + ".0"
build = cls._increment_string(build)
if self._build is not None:
build = self._build
elif token == "":
build = "0"
elif token is None:
build = "build.0"
else:
build = str(token) + ".0"
# self._build or (token or "build") + ".0"
build = cls._increment_string(build)
return cls(self._major, self._minor, self._patch, self._prerelease, build)
def compare(self, other: Comparable) -> int:
"""
Compare self with other.
:param other: the second version
:return: The return value is negative if ver1 < ver2,
zero if ver1 == ver2 and strictly positive if ver1 > ver2
>>> semver.compare("2.0.0")
-1
>>> semver.compare("1.0.0")
1
>>> semver.compare("2.0.0")
0
>>> semver.compare(dict(major=2, minor=0, patch=0))
0
"""
cls = type(self)
if isinstance(other, String.__args__): # type: ignore
other = cls.parse(other)
elif isinstance(other, dict):
other = cls(**other)
elif isinstance(other, (tuple, list)):
other = cls(*other)
elif not isinstance(other, cls):
raise TypeError(
f"Expected str, bytes, dict, tuple, list, or {cls.__name__} instance, "
f"but got {type(other)}"
)
v1 = self.to_tuple()[:3]
v2 = other.to_tuple()[:3]
x = _cmp(v1, v2)
if x:
return x
rc1, rc2 = self.prerelease, other.prerelease
rccmp = self._nat_cmp(rc1, rc2)
if not rccmp:
return 0
if not rc1:
return 1
elif not rc2:
return -1
return rccmp
def next_version(self, part: str, prerelease_token: str = "rc") -> "Version":
"""
Determines next version, preserving natural order.
.. versionadded:: 2.10.0
This function is taking prereleases into account.
The "major", "minor", and "patch" raises the respective parts like
the ``bump_*`` functions. The real difference is using the
"prerelease" part. It gives you the next patch version of the
prerelease, for example:
>>> str(semver.parse("0.1.4").next_version("prerelease"))
'0.1.5-rc.1'
:param part: One of "major", "minor", "patch", or "prerelease"
:param prerelease_token: prefix string of prerelease, defaults to 'rc'
:return: new object with the appropriate part raised
"""
cls = type(self)
# "build" is currently not used, that's why we use [:-1]
validparts = cls.NAMES[:-1]
if part not in validparts:
raise ValueError(
f"Invalid part. Expected one of {validparts}, but got {part!r}"
)
version = self
if (version.prerelease or version.build) and (
part == "patch"
or (part == "minor" and version.patch == 0)
or (part == "major" and version.minor == version.patch == 0)
):
return version.replace(prerelease=None, build=None)
# Only check the main parts:
if part in cls.NAMES[:3]:
return getattr(version, "bump_" + part)()
if not version.prerelease:
version = version.bump_patch()
return version.bump_prerelease(prerelease_token)
@_comparator
def __eq__(self, other: Comparable) -> bool: # type: ignore
return self.compare(other) == 0
@_comparator
def __ne__(self, other: Comparable) -> bool: # type: ignore
return self.compare(other) != 0
@_comparator
def __lt__(self, other: Comparable) -> bool:
return self.compare(other) < 0
@_comparator
def __le__(self, other: Comparable) -> bool:
return self.compare(other) <= 0
@_comparator
def __gt__(self, other: Comparable) -> bool:
return self.compare(other) > 0
@_comparator
def __ge__(self, other: Comparable) -> bool:
return self.compare(other) >= 0
def __getitem__(
self, index: Union[int, slice]
) -> Union[int, Optional[str], Tuple[Union[int, str], ...]]:
"""
self.__getitem__(index) <==> self[index] Implement getitem.
If the part requested is undefined, or a part of the range requested
is undefined, it will throw an index error.
Negative indices are not supported.
:param index: a positive integer indicating the
offset or a :func:`slice` object
:raises IndexError: if index is beyond the range or a part is None
:return: the requested part of the version at position index
>>> ver = semver.Version.parse("3.4.5")
>>> ver[0], ver[1], ver[2]
(3, 4, 5)
"""
if isinstance(index, int):
index = slice(index, index + 1)
index = cast(slice, index)
if (
isinstance(index, slice)
and (index.start is not None and index.start < 0)
or (index.stop is not None and index.stop < 0)
):
raise IndexError("Version index cannot be negative")
part = tuple(
filter(lambda p: p is not None, cast(Iterable, self.to_tuple()[index]))
)
if len(part) == 1:
return part[0]
elif not part:
raise IndexError("Version part undefined")
return part
def __repr__(self) -> str:
s = ", ".join("%s=%r" % (key, val) for key, val in self.to_dict().items())
return "%s(%s)" % (type(self).__name__, s)
def __str__(self) -> str:
version = "%d.%d.%d" % (self.major, self.minor, self.patch)
if self.prerelease:
version += "-%s" % self.prerelease
if self.build:
version += "+%s" % self.build
return version
def __hash__(self) -> int:
return hash(self.to_tuple()[:4])
def finalize_version(self) -> "Version":
"""
Remove any prerelease and build metadata from the version.
:return: a new instance with the finalized version string
>>> str(semver.Version.parse('1.2.3-rc.5').finalize_version())
'1.2.3'
"""
cls = type(self)
return cls(self.major, self.minor, self.patch)
def match(self, match_expr: str) -> bool:
"""
Compare self to match a match expression.
:param match_expr: optional operator and version; valid operators are
``<`` smaller than
``>`` greater than
``>=`` greator or equal than
``<=`` smaller or equal than
``==`` equal
``!=`` not equal
:return: True if the expression matches the version, otherwise False
>>> semver.Version.parse("2.0.0").match(">=1.0.0")
True
>>> semver.Version.parse("1.0.0").match(">1.0.0")
False
>>> semver.Version.parse("4.0.4").match("4.0.4")
True
"""
prefix = match_expr[:2]
if prefix in (">=", "<=", "==", "!="):
match_version = match_expr[2:]
elif prefix and prefix[0] in (">", "<"):
prefix = prefix[0]
match_version = match_expr[1:]
elif match_expr and match_expr[0] in "0123456789":
prefix = "=="
match_version = match_expr
else:
raise ValueError(
"match_expr parameter should be in format <op><ver>, "
"where <op> is one of "
"['<', '>', '==', '<=', '>=', '!=']. "
"You provided: %r" % match_expr
)
possibilities_dict = {
">": (1,),
"<": (-1,),
"==": (0,),
"!=": (-1, 1),
">=": (0, 1),
"<=": (-1, 0),
}
possibilities = possibilities_dict[prefix]
cmp_res = self.compare(match_version)
return cmp_res in possibilities
@classmethod
def parse(
cls: Type[T], version: String, optional_minor_and_patch: bool = False
) -> T:
"""
Parse version string to a Version instance.
.. versionchanged:: 2.11.0
Changed method from static to classmethod to
allow subclasses.
.. versionchanged:: 3.0.0
Added optional parameter ``optional_minor_and_patch`` to allow
optional minor and patch parts.
:param version: version string
:param optional_minor_and_patch: if set to true, the version string to parse \
can contain optional minor and patch parts. Optional parts are set to zero.
By default (False), the version string to parse has to follow the semver
specification.
:return: a new :class:`Version` instance
:raises ValueError: if version is invalid
:raises TypeError: if version contains the wrong type
>>> semver.Version.parse('3.4.5-pre.2+build.4')
Version(major=3, minor=4, patch=5, \
prerelease='pre.2', build='build.4')
"""
if isinstance(version, bytes):
version = version.decode("UTF-8")
elif not isinstance(version, String.__args__): # type: ignore
raise TypeError("not expecting type '%s'" % type(version))
if optional_minor_and_patch:
match = cls._REGEX_OPTIONAL_MINOR_AND_PATCH.match(version)
else:
match = cls._REGEX.match(version)
if match is None:
raise ValueError(f"{version} is not valid SemVer string")
matched_version_parts: Dict[str, Any] = match.groupdict()
if not matched_version_parts["minor"]:
matched_version_parts["minor"] = 0
if not matched_version_parts["patch"]:
matched_version_parts["patch"] = 0
return cls(**matched_version_parts)
def replace(self, **parts: Union[int, Optional[str]]) -> "Version":
"""
Replace one or more parts of a version and return a new :class:`Version`
object, but leave self untouched.
.. versionadded:: 2.9.0
Added :func:`Version.replace`
:param parts: the parts to be updated. Valid keys are:
``major``, ``minor``, ``patch``, ``prerelease``, or ``build``
:return: the new :class:`~semver.version.Version` object with
the changed parts
:raises TypeError: if ``parts`` contain invalid keys
"""
version = self.to_dict()
version.update(parts)
try:
return type(self)(**version) # type: ignore
except TypeError:
unknownkeys = set(parts) - set(self.to_dict())
error = "replace() got %d unexpected keyword argument(s): %s" % (
len(unknownkeys),
", ".join(unknownkeys),
)
raise TypeError(error)
@classmethod
def is_valid(cls, version: str) -> bool:
"""
Check if the string is a valid semver version.
.. versionadded:: 2.9.1
.. versionchanged:: 3.0.0
Renamed from :meth:`~semver.version.Version.isvalid`
:param version: the version string to check
:return: True if the version string is a valid semver version, False
otherwise.
"""
try:
cls.parse(version)
return True
except ValueError:
return False
def is_compatible(self, other: "Version") -> bool:
"""
Check if current version is compatible with other version.
The result is True, if either of the following is true:
* both versions are equal, or
* both majors are equal and higher than 0. Same for both minors.
Both pre-releases are equal, or
* both majors are equal and higher than 0. The minor of b's
minor version is higher then a's. Both pre-releases are equal.
The algorithm does *not* check patches.
.. versionadded:: 3.0.0
:param other: the version to check for compatibility
:return: True, if ``other`` is compatible with the old version,
otherwise False
>>> Version(1, 1, 0).is_compatible(Version(1, 0, 0))
False
>>> Version(1, 0, 0).is_compatible(Version(1, 1, 0))
True
"""
if not isinstance(other, Version):
raise TypeError(f"Expected a Version type but got {type(other)}")
# All major-0 versions should be incompatible with anything but itself
if (0 == self.major == other.major) and (self[:4] != other[:4]):
return False
return (
(self.major == other.major)
and (other.minor >= self.minor)
and (self.prerelease == other.prerelease)
)
#: Keep the VersionInfo name for compatibility
VersionInfo = Version