Skip to content

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 →