From f2300a8ebbdb680b8345667009a800953fcd1ac1 Mon Sep 17 00:00:00 2001 From: beam Date: Fri, 30 Oct 2026 05:04:33 +0000 Subject: [PATCH] beam: add semver parser/comparator with tests --- wizard-work/beam/semver.py | 102 ++++++++++++++++++++++++++++++++ wizard-work/beam/test_semver.py | 50 ++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 wizard-work/beam/semver.py create mode 100644 wizard-work/beam/test_semver.py diff --git a/wizard-work/beam/semver.py b/wizard-work/beam/semver.py new file mode 100644 index 0000000..6ac27b7 --- /dev/null +++ b/wizard-work/beam/semver.py @@ -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"^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)" + r"(?:-(?P
[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
+    r"(?:\+(?P[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}")
diff --git a/wizard-work/beam/test_semver.py b/wizard-work/beam/test_semver.py
new file mode 100644
index 0000000..2019c56
--- /dev/null
+++ b/wizard-work/beam/test_semver.py
@@ -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"]