beam: add semver parser/comparator with tests
This commit is contained in:
1 parent
ce0848395a
commit
f2300a8ebb
2 files changed
+152
No files matched your search
@@ -0,0 +1,102 @@
|
||||
"""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}")
|
||||
@@ -0,0 +1,50 @@
|
||||
import pytest
|
||||
from semver import SemVer, SemVerError
|
||||
|
||||
def test_parse_basic():
|
||||
v = SemVer.parse("1.2.3")
|
||||
assert (v.major, v.minor, v.patch) == (1,2,3)
|
||||
assert v.pre == () and v.build == ()
|
||||
|
||||
def test_str_roundtrip():
|
||||
for s in ["1.2.3", "1.2.3-rc.1", "1.2.3+build.5", "1.2.3-rc.1+build.5", "0.0.0"]:
|
||||
assert str(SemVer.parse(s)) == s
|
||||
|
||||
def test_prerelease_order():
|
||||
assert SemVer.parse("1.0.0-alpha") < SemVer.parse("1.0.0-alpha.1")
|
||||
assert SemVer.parse("1.0.0-alpha.1") < SemVer.parse("1.0.0-alpha.beta")
|
||||
assert SemVer.parse("1.0.0-alpha.beta") < SemVer.parse("1.0.0-beta")
|
||||
assert SemVer.parse("1.0.0-beta") < SemVer.parse("1.0.0-beta.2")
|
||||
assert SemVer.parse("1.0.0-beta.2") < SemVer.parse("1.0.0-beta.11")
|
||||
assert SemVer.parse("1.0.0-beta.11") < SemVer.parse("1.0.0-rc.1")
|
||||
assert SemVer.parse("1.0.0-rc.1") < SemVer.parse("1.0.0")
|
||||
|
||||
def test_numeric_vs_string_id():
|
||||
# numeric identifiers always have lower precedence than non-numeric
|
||||
assert SemVer.parse("1.0.0-1") < SemVer.parse("1.0.0-alpha")
|
||||
|
||||
def test_build_ignored_equality():
|
||||
assert SemVer.parse("1.0.0+foo") == SemVer.parse("1.0.0+bar")
|
||||
assert hash(SemVer.parse("1.0.0+foo")) == hash(SemVer.parse("1.0.0+bar"))
|
||||
|
||||
def test_leading_zero_rejected():
|
||||
with pytest.raises(SemVerError):
|
||||
SemVer.parse("1.0.0-01")
|
||||
with pytest.raises(SemVerError):
|
||||
SemVer.parse("1.0.0+01")
|
||||
|
||||
def test_invalid():
|
||||
for s in ["", "1.2", "1.2.3.4", "a.b.c", "1.2.3-", " 1.2.3", "1.2.3 "]:
|
||||
with pytest.raises(SemVerError):
|
||||
SemVer.parse(s)
|
||||
|
||||
def test_bump():
|
||||
v = SemVer.parse("1.2.3-rc.1+build")
|
||||
assert str(v.bump("patch")) == "1.2.4"
|
||||
assert str(v.bump("minor")) == "1.3.0"
|
||||
assert str(v.bump("major")) == "2.0.0"
|
||||
|
||||
def test_sort():
|
||||
versions = ["1.0.0", "2.0.0", "1.0.0-rc.1", "1.5.0", "1.0.0-alpha"]
|
||||
parsed = sorted(SemVer.parse(x) for x in versions)
|
||||
assert [str(x) for x in parsed] == ["1.0.0-alpha", "1.0.0-rc.1", "1.0.0", "1.5.0", "2.0.0"]
|
||||
Reference in new issue
Block a user