Files

103 lines
3.5 KiB
Python

"""Semantic Versioning 2.0.0 parsing and comparison.
Handles the tricky bits: prerelease precedence (numeric vs identifier),
build metadata ignored in equality, leading zeros forbidden in numeric
identifiers, and whitespace rejection.
"""
import re
_SEMVER_RE = re.compile(
r"^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"
r"(?:-(?P<pre>[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
r"(?:\+(?P<build>[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$"
)
class SemVerError(ValueError):
pass
class SemVer:
__slots__ = ("major", "minor", "patch", "pre", "build")
def __init__(self, major, minor, patch, pre=(), build=()):
self.major = major
self.minor = minor
self.patch = patch
self.pre = tuple(pre)
self.build = tuple(build)
@classmethod
def parse(cls, s):
if not isinstance(s, str) or s != s.strip() or not s:
raise SemVerError("version must be a non-empty trimmed string")
m = _SEMVER_RE.fullmatch(s)
if not m:
raise SemVerError(f"invalid semver: {s!r}")
pre = tuple(m.group("pre").split(".")) if m.group("pre") else ()
build = tuple(m.group("build").split(".")) if m.group("build") else ()
# validate numeric identifiers have no leading zeros
for ident in pre:
if ident.isdigit() and len(ident) > 1 and ident[0] == "0":
raise SemVerError(f"leading zero in numeric prerelease id: {ident!r}")
for ident in build:
if ident.isdigit() and len(ident) > 1 and ident[0] == "0":
raise SemVerError(f"leading zero in numeric build id: {ident!r}")
return cls(int(m.group("major")), int(m.group("minor")), int(m.group("patch")), pre, build)
def _pre_key(self):
# A version without prerelease has HIGHER precedence than one with.
if not self.pre:
return (1, )
out = (0, )
for ident in self.pre:
if ident.isdigit():
out += (0, int(ident))
else:
out += (1, ident)
return out
def __eq__(self, other):
if not isinstance(other, SemVer):
return NotImplemented
return (self.major, self.minor, self.patch, self.pre) == (other.major, other.minor, other.patch, other.pre)
def __lt__(self, other):
if not isinstance(other, SemVer):
return NotImplemented
if (self.major, self.minor, self.patch) != (other.major, other.minor, other.patch):
return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)
return self._pre_key() < other._pre_key()
def __le__(self, other):
return self == other or self < other
def __gt__(self, other):
return not (self <= other)
def __ge__(self, other):
return not (self < other)
def __hash__(self):
return hash((self.major, self.minor, self.patch, self.pre))
def __str__(self):
s = f"{self.major}.{self.minor}.{self.patch}"
if self.pre:
s += "-" + ".".join(self.pre)
if self.build:
s += "+" + ".".join(self.build)
return s
def __repr__(self):
return f"SemVer({self!s})"
def bump(self, part):
if part == "major":
return SemVer(self.major + 1, 0, 0)
if part == "minor":
return SemVer(self.major, self.minor + 1, 0)
if part == "patch":
return SemVer(self.major, self.minor, self.patch + 1)
raise SemVerError(f"unknown bump part: {part!r}")