Compare Semantic Versions Without Dependencies

Parse and compare semantic version strings with prerelease precedence, then sort a mixed release list correctly.

PythonCli Tooling

Code

snippet
from __future__ import annotations

import re
from dataclasses import dataclass
from functools import total_ordering

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<prerelease>[0-9A-Za-z.-]+))?"
    r"(?:\+(?P<build>[0-9A-Za-z.-]+))?$"
)


@total_ordering
@dataclass(frozen=True)
class Version:
    major: int
    minor: int
    patch: int
    prerelease: tuple[str, ...] = ()

    @classmethod
    def parse(cls, text: str) -> Version:
        match = SEMVER_RE.match(text.strip())
        if match is None:
            raise ValueError(f"invalid semantic version: {text!r}")
        prerelease = match.group("prerelease")
        return cls(
            major=int(match.group("major")),
            minor=int(match.group("minor")),
            patch=int(match.group("patch")),
            prerelease=tuple(prerelease.split(".")) if prerelease else (),
        )

    def _rank(self) -> tuple[object, ...]:
        if not self.prerelease:
            return (1,)
        identifiers = tuple(
            (0, int(part)) if part.isdigit() else (1, part)
            for part in self.prerelease
        )
        return (0, identifiers)

    def __lt__(self, other: Version) -> bool:
        return (self.major, self.minor, self.patch, self._rank()) < (
            other.major,
            other.minor,
            other.patch,
            other._rank(),
        )

    def __str__(self) -> str:
        base = f"{self.major}.{self.minor}.{self.patch}"
        if self.prerelease:
            return f"{base}-{'.'.join(self.prerelease)}"
        return base


def main() -> None:
    raw = [
        "1.0.0",
        "1.0.0-alpha",
        "1.0.0-alpha.1",
        "1.0.0-beta",
        "1.0.0-rc.1",
        "1.10.0",
        "2.0.0",
    ]
    ordered = [str(version) for version in sorted(Version.parse(item) for item in raw)]
    print(" < ".join(ordered))
    assert ordered == [
        "1.0.0-alpha",
        "1.0.0-alpha.1",
        "1.0.0-beta",
        "1.0.0-rc.1",
        "1.0.0",
        "1.10.0",
        "2.0.0",
    ]
    assert Version.parse("1.0.0+build.9") == Version.parse("1.0.0")
    try:
        Version.parse("1.0")
    except ValueError as exc:
        print(f"rejected: {exc}")


if __name__ == "__main__":
    main()