Semver Checker — Python source
Parse, compare, and validate Semantic Versioning 2.0.0 strings. Check which of two versions is greater (with full prerelease precedence), test whether a version satisfies an npm-style range (^, ~, comparators, hyphen, ||), and bump major/minor/patch/prerelease. Runs 100% client-side.
This is the Python implementation — the same logic the interactive tool runs, in a shareable, citable form.
"""Semantic Versioning 2.0.0 toolkit — pure logic, Python polyglot port.
Language: Python
CosmoDev polyglot showcase port of the ``semver`` tool.
Ported from src/lib/semver.ts — display source, part of CosmoDev's
polyglot tool pages.
Implements semver parsing, precedence comparison (including prerelease
ordering), npm-style range satisfaction (``^``, ``~``, comparators, ``*``,
AND, ``||``, hyphen ranges), and version bumping. Fully deterministic:
every function depends only on its inputs. Stdlib only — no external
dependencies.
The public surface mirrors the TypeScript reference: parse_semver,
format, compare, satisfies, bump.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import List, Optional
# ─── Semver model ────────────────────────────────────────────────────────────
@dataclass
class Semver:
"""A parsed semantic version.
``prerelease`` and ``build`` are lists of dot-separated identifiers.
Build metadata is informational only — it never affects precedence.
"""
major: int
minor: int
patch: int
prerelease: List[str] = field(default_factory=list)
build: List[str] = field(default_factory=list)
# ─── Parsing ─────────────────────────────────────────────────────────────────
#
# Regex pieces mirror the semver-2.0.0 ABNF. Numeric fields forbid leading
# zeros (0|[1-9]\d*); identifiers allow alphanumerics and hyphens. A leading
# ``v``/``V`` and surrounding whitespace are stripped before matching, to
# tolerate the common ``v1.2.3`` shorthand.
_IDENT = r"(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)"
_PRE = rf"({_IDENT}(?:\.{_IDENT})*)"
_BUILD = r"([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)"
_NUM = r"(0|[1-9]\d*)"
SEMVER_RE = re.compile(rf"^{_NUM}\.{_NUM}\.{_NUM}(?:-{_PRE})?(?:\+{_BUILD})?$")
def parse_semver(v: str) -> Optional[Semver]:
"""Parse a strict semver string. A leading ``v``/``V`` is tolerated.
Returns ``None`` when the string is not valid semver.
"""
t = re.sub(r"^[vV]", "", v.strip())
m = SEMVER_RE.match(t)
if not m:
return None
return Semver(
major=int(m.group(1)),
minor=int(m.group(2)),
patch=int(m.group(3)),
prerelease=m.group(4).split(".") if m.group(4) else [],
build=m.group(5).split(".") if m.group(5) else [],
)
def format(s: Semver) -> str:
"""Render a Semver back to its canonical string form."""
out = f"{s.major}.{s.minor}.{s.patch}"
if s.prerelease:
out += "-" + ".".join(s.prerelease)
if s.build:
out += "+" + ".".join(s.build)
return out
# ─── Precedence comparison ───────────────────────────────────────────────────
_NUMERIC = re.compile(r"^[0-9]+$")
def _cmp_ident(x: str, y: str) -> int:
"""Compare two prerelease identifiers.
Per semver: numeric identifiers always rank lower than alphanumeric;
numerics compare by integer value; alphanumerics compare lexicographically.
"""
xn, yn = bool(_NUMERIC.match(x)), bool(_NUMERIC.match(y))
if xn and yn:
a, b = int(x), int(y)
return (a > b) - (a < b)
if xn:
return -1 # numeric always lower than alphanumeric
if yn:
return 1
return (x > y) - (x < y)
def _cmp_prerelease(a: List[str], b: List[str]) -> int:
"""Compare two prerelease arrays per semver precedence.
A release with NO prerelease has HIGHER precedence than one with a
prerelease (so 1.0.0 > 1.0.0-alpha).
"""
if not a and not b:
return 0
if not a:
return 1 # no prerelease > prerelease
if not b:
return -1
for x, y in zip(a, b):
c = _cmp_ident(x, y)
if c != 0:
return c
# All shared identifiers equal → a larger set of fields wins.
return (len(a) > len(b)) - (len(a) < len(b))
def compare(a: Semver, b: Semver) -> int:
"""Compare two semvers by precedence. Build metadata is ignored.
Returns -1 if a<b, 0 if equal, 1 if a>b.
"""
if a.major != b.major:
return (a.major > b.major) - (a.major < b.major)
if a.minor != b.minor:
return (a.minor > b.minor) - (a.minor < b.minor)
if a.patch != b.patch:
return (a.patch > b.patch) - (a.patch < b.patch)
return _cmp_prerelease(a.prerelease, b.prerelease)
# ─── Range satisfaction (npm-style) ──────────────────────────────────────────
@dataclass
class _RangeVer:
"""A partial version for ranges. ``None`` means wildcard — the field was
either absent (``1.2``) or explicit (``1.2.x``)."""
major: Optional[int]
minor: Optional[int]
patch: Optional[int]
# A single atomic comparator: an operator and a (full) version.
_OP = str # one of '>=', '>', '<=', '<', '='
@dataclass
class _Test:
op: _OP
v: Semver
def _sem(major: int, minor: int, patch: int) -> Semver:
return Semver(major, minor, patch, [], [])
def _parse_range_ver(s: str) -> Optional[_RangeVer]:
"""Parse a (possibly partial) range version: ``1``, ``1.2``, ``1.2.3``,
``1.x``, ``*``. Returns ``None`` when unparseable."""
t = re.sub(r"^[vV]", "", s.strip())
if t in ("", "*", "x", "X"):
return _RangeVer(None, None, None)
parts = t.split(".")
if len(parts) > 3:
return None
def part(p: str):
# Returns None (wildcard), an int, or sentinel True (invalid).
if p in ("x", "X", "*"):
return None
if _NUMERIC.match(p):
return int(p)
return True
major = part(parts[0])
if major is True:
return None
minor = part(parts[1]) if len(parts) >= 2 else None
if minor is True:
return None
patch = part(parts[2]) if len(parts) >= 3 else None
if patch is True:
return None
# Wildcards cascade downward: ``1.x`` becomes {1, None, None}.
if major is None:
return _RangeVer(None, None, None)
if minor is None:
return _RangeVer(major, None, None)
return _RangeVer(major, minor, patch)
def _range_ver_tests(op: str, rv: _RangeVer) -> List[_Test]:
"""Build the test list for a plain comparator (``>=``, ``>``, ``<=``, ``<``,
``=``/bare). A bare ``1.2`` desugars to ``>=1.2.0 <1.3.0`` — partial
versions act as ranges. A wildcard matches anything."""
if rv.major is None:
return [] # wildcard → matches anything
M = rv.major
if op in ("=", "bare"):
if rv.minor is None:
return [_Test(">=", _sem(M, 0, 0)), _Test("<", _sem(M + 1, 0, 0))]
if rv.patch is None:
return [_Test(">=", _sem(M, rv.minor, 0)), _Test("<", _sem(M, rv.minor + 1, 0))]
return [_Test("=", _sem(M, rv.minor, rv.patch))]
if op == ">=":
if rv.minor is None:
return [_Test(">=", _sem(M, 0, 0))]
if rv.patch is None:
return [_Test(">=", _sem(M, rv.minor, 0))]
return [_Test(">=", _sem(M, rv.minor, rv.patch))]
if op == ">":
if rv.minor is None:
return [_Test(">=", _sem(M + 1, 0, 0))]
if rv.patch is None:
return [_Test(">=", _sem(M, rv.minor + 1, 0))]
return [_Test(">", _sem(M, rv.minor, rv.patch))]
if op == "<=":
if rv.minor is None:
return [_Test("<", _sem(M + 1, 0, 0))]
if rv.patch is None:
return [_Test("<", _sem(M, rv.minor + 1, 0))]
return [_Test("<=", _sem(M, rv.minor, rv.patch))]
if op == "<":
if rv.minor is None:
return [_Test("<", _sem(M, 0, 0))]
if rv.patch is None:
return [_Test("<", _sem(M, rv.minor, 0))]
return [_Test("<", _sem(M, rv.minor, rv.patch))]
return []
def _caret_tests(rv: _RangeVer) -> List[_Test]:
"""Caret (``^``) range: compatible-with, never breaking the left-most
non-zero component. ``^1.2.3`` → ``>=1.2.3 <2.0.0``; ``^0.2.3`` →
``>=0.2.3 <0.3.0``; ``^0.0.3`` → ``>=0.0.3 <0.0.4``."""
if rv.major is None:
return []
M = rv.major
lo = _Test(">=", _sem(M, rv.minor or 0, rv.patch or 0))
if rv.minor is None:
hi = _Test("<", _sem(M + 1, 0, 0)) # ^1 → <2.0.0, ^0 → <1.0.0
elif rv.patch is None:
hi = _Test("<", _sem(M + 1, 0, 0)) if M > 0 else _Test("<", _sem(0, rv.minor + 1, 0))
else:
if M > 0:
hi = _Test("<", _sem(M + 1, 0, 0))
elif rv.minor > 0:
hi = _Test("<", _sem(0, rv.minor + 1, 0))
else:
hi = _Test("<", _sem(0, 0, rv.patch + 1)) # ^0.0.3 → <0.0.4
return [lo, hi]
def _tilde_tests(rv: _RangeVer) -> List[_Test]:
"""Tilde (``~``) range: patch-level changes only (or minor-level for
partials). ``~1.2.3`` → ``>=1.2.3 <1.3.0``; ``~1`` → ``>=1.0.0 <2.0.0``."""
if rv.major is None:
return []
M = rv.major
lo = _Test(">=", _sem(M, rv.minor or 0, rv.patch or 0))
hi = (
_Test("<", _sem(M + 1, 0, 0))
if rv.minor is None
else _Test("<", _sem(M, rv.minor + 1, 0))
)
return [lo, hi]
def _parse_comparator(token: str) -> Optional[List[_Test]]:
"""Parse a single comparator token into a list of tests (all must hold).
Returns ``None`` when the token is unparseable."""
t = token.strip()
if t in ("", "*"):
return []
if t[0] == "^":
rv = _parse_range_ver(t[1:])
return _caret_tests(rv) if rv else None
if t[0] == "~":
rv = _parse_range_ver(t[1:])
return _tilde_tests(rv) if rv else None
op = "bare"
rest = t
if t.startswith(">="):
op, rest = ">=", t[2:]
elif t.startswith("<="):
op, rest = "<=", t[2:]
elif t.startswith(">"):
op, rest = ">", t[1:]
elif t.startswith("<"):
op, rest = "<", t[1:]
elif t.startswith("="):
op, rest = "=", t[1:]
rv = _parse_range_ver(rest)
return _range_ver_tests(op, rv) if rv else None
def _check(test: _Test, v: Semver) -> bool:
"""Evaluate a single test against a concrete version."""
c = compare(v, test.v)
if test.op == ">":
return c > 0
if test.op == ">=":
return c >= 0
if test.op == "<":
return c < 0
if test.op == "<=":
return c <= 0
return c == 0 # '='
_HYPHEN_RE = re.compile(r"\s+-\s+")
def _clause_matches(v: Semver, clause: str) -> bool:
"""Evaluate one AND-clause (already split from ``||``)."""
c = clause.strip()
if c in ("", "*"):
return True
# Hyphen range: "1.2.3 - 2.3.4" → >=lower <=upper (partials apply).
if _HYPHEN_RE.search(c):
parts = _HYPHEN_RE.split(c)
if len(parts) == 2:
lo = _parse_range_ver(parts[0])
hi = _parse_range_ver(parts[1])
if not lo or not hi:
return False
tests = _range_ver_tests(">=", lo) + _range_ver_tests("<=", hi)
return all(_check(t, v) for t in tests)
tokens = [tok for tok in c.split() if tok]
tests: List[_Test] = []
for tok in tokens:
ts = _parse_comparator(tok)
if ts is None:
return False # invalid comparator → clause unsatisfiable
tests.extend(ts)
return all(_check(t, v) for t in tests) if tests else True
def satisfies(version: str, range: str) -> bool:
"""Does ``version`` satisfy the npm-style ``range``?
Supports ``^``, ``~``, comparators (``>=``, ``<=``, ``>``, ``<``, ``=``),
``*``, partials (``1.2``, ``1``), hyphen ranges (``1.2.3 - 2.3.4``),
space-separated AND, and ``||`` OR. An invalid version or wholly-unparseable
range yields ``False``; ``*``/empty matches all.
"""
v = parse_semver(version)
if not v:
return False
return any(_clause_matches(v, clause) for clause in range.split("||"))
def bump(v: str, kind: str) -> str:
"""Bump a version by ``kind``.
``major``/``minor``/``patch`` drop any prerelease and produce a clean
release; ``prerelease`` bumps the trailing numeric prerelease identifier
(appending ``-0`` / ``.1`` when there is none / a non-numeric tail).
Invalid input is returned unchanged.
"""
s = parse_semver(v)
if not s:
return v
major, minor, patch, prerelease = s.major, s.minor, s.patch, s.prerelease
if kind == "major":
return f"{major + 1}.0.0"
if kind == "minor":
return f"{major}.{minor + 1}.0"
if kind == "patch":
return f"{major}.{minor}.{patch + 1}"
if kind == "prerelease":
if not prerelease:
return f"{major}.{minor}.{patch + 1}-0"
last = prerelease[-1]
if _NUMERIC.match(last):
prerelease[-1] = str(int(last) + 1)
return f"{major}.{minor}.{patch}-" + ".".join(prerelease)
return f"{major}.{minor}.{patch}-" + ".".join(prerelease + ["1"])
return v
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →