2026-07-26 23:15:34 +02:00
|
|
|
"""Semantic version comparison for plugin versions.
|
|
|
|
|
|
|
|
|
|
Supports parsing, comparison, and compatibility checks for SemVer strings.
|
|
|
|
|
Handles pre-release versions (e.g. 1.0.0-alpha.1) per SemVer spec.
|
|
|
|
|
|
|
|
|
|
Usage::
|
|
|
|
|
|
|
|
|
|
from app.plugins.semver import SemVer
|
|
|
|
|
|
|
|
|
|
v1 = SemVer.parse("1.2.3")
|
|
|
|
|
v2 = SemVer.parse("1.3.0")
|
|
|
|
|
|
|
|
|
|
if v1 < v2:
|
2026-07-31 00:58:05 +02:00
|
|
|
logger.info(f"{v1} is older than {v2}")
|
2026-07-26 23:15:34 +02:00
|
|
|
|
|
|
|
|
if v1.is_breaking_change(v2):
|
2026-07-31 00:58:05 +02:00
|
|
|
logger.warning("Major version changed — breaking!")
|
2026-07-26 23:15:34 +02:00
|
|
|
|
|
|
|
|
if v2.is_compatible_with(v1):
|
2026-07-31 00:58:05 +02:00
|
|
|
logger.info("v2 is compatible with v1")
|
2026-07-26 23:15:34 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class SemVer:
|
|
|
|
|
"""A semantic version following semver.org spec.
|
|
|
|
|
|
|
|
|
|
Attributes:
|
|
|
|
|
major: Major version (breaking changes).
|
|
|
|
|
minor: Minor version (new features, backward compatible).
|
|
|
|
|
patch: Patch version (bug fixes, backward compatible).
|
|
|
|
|
prerelease: Optional pre-release string (e.g. "alpha.1", "beta.2").
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
major: int
|
|
|
|
|
minor: int
|
|
|
|
|
patch: int
|
|
|
|
|
prerelease: str = ""
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def parse(cls, version: str) -> SemVer:
|
|
|
|
|
"""Parse a SemVer string into a SemVer instance.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
version: Version string like "1.2.3" or "1.2.3-alpha.1".
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
SemVer instance.
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
ValueError: If the version string is not valid SemVer.
|
|
|
|
|
"""
|
|
|
|
|
if not version:
|
|
|
|
|
raise ValueError("Version string is empty")
|
|
|
|
|
|
|
|
|
|
# Strip leading 'v' if present
|
|
|
|
|
version = version.strip().lstrip("v")
|
|
|
|
|
|
|
|
|
|
match = re.match(
|
|
|
|
|
r"^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$",
|
|
|
|
|
version,
|
|
|
|
|
)
|
|
|
|
|
if not match:
|
|
|
|
|
raise ValueError(
|
|
|
|
|
f"Invalid semver '{version}': expected MAJOR.MINOR.PATCH[-prerelease]"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return cls(
|
|
|
|
|
major=int(match.group(1)),
|
|
|
|
|
minor=int(match.group(2)),
|
|
|
|
|
patch=int(match.group(3)),
|
|
|
|
|
prerelease=match.group(4) or "",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def __str__(self) -> str:
|
|
|
|
|
base = f"{self.major}.{self.minor}.{self.patch}"
|
|
|
|
|
if self.prerelease:
|
|
|
|
|
return f"{base}-{self.prerelease}"
|
|
|
|
|
return base
|
|
|
|
|
|
|
|
|
|
def __repr__(self) -> str:
|
|
|
|
|
return f"SemVer({self!s})"
|
|
|
|
|
|
|
|
|
|
def __lt__(self, other: SemVer) -> bool:
|
|
|
|
|
if not isinstance(other, SemVer):
|
|
|
|
|
return NotImplemented
|
|
|
|
|
# Compare major.minor.patch
|
|
|
|
|
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)
|
|
|
|
|
# Pre-release versions are lower than release versions
|
|
|
|
|
if not self.prerelease and other.prerelease:
|
|
|
|
|
return False
|
|
|
|
|
if self.prerelease and not other.prerelease:
|
|
|
|
|
return True
|
|
|
|
|
# Both have pre-release — compare lexically
|
|
|
|
|
return self._compare_prerelease(self.prerelease, other.prerelease) < 0
|
|
|
|
|
|
|
|
|
|
def __eq__(self, other: object) -> bool:
|
|
|
|
|
if not isinstance(other, SemVer):
|
|
|
|
|
return NotImplemented
|
|
|
|
|
return (
|
|
|
|
|
self.major == other.major
|
|
|
|
|
and self.minor == other.minor
|
|
|
|
|
and self.patch == other.patch
|
|
|
|
|
and self.prerelease == other.prerelease
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def __le__(self, other: SemVer) -> bool:
|
|
|
|
|
return self == other or self < other
|
|
|
|
|
|
|
|
|
|
def __gt__(self, other: SemVer) -> bool:
|
|
|
|
|
if not isinstance(other, SemVer):
|
|
|
|
|
return NotImplemented
|
|
|
|
|
return not self <= other
|
|
|
|
|
|
|
|
|
|
def __ge__(self, other: SemVer) -> bool:
|
|
|
|
|
return not self < other
|
|
|
|
|
|
|
|
|
|
def __hash__(self) -> int:
|
|
|
|
|
return hash((self.major, self.minor, self.patch, self.prerelease))
|
|
|
|
|
|
|
|
|
|
# ─── Compatibility checks ───
|
|
|
|
|
|
|
|
|
|
def is_breaking_change(self, other: SemVer) -> bool:
|
|
|
|
|
"""Return True if the major version differs (breaking change)."""
|
|
|
|
|
return self.major != other.major
|
|
|
|
|
|
|
|
|
|
def is_compatible_with(self, min_version: SemVer) -> bool:
|
|
|
|
|
"""Return True if this version satisfies the minimum version requirement.
|
|
|
|
|
|
|
|
|
|
A version is compatible if:
|
|
|
|
|
- Same major version and >= min_version, OR
|
|
|
|
|
- Higher major version (forward compatible)
|
|
|
|
|
"""
|
|
|
|
|
if self.major > min_version.major:
|
|
|
|
|
return True
|
|
|
|
|
if self.major < min_version.major:
|
|
|
|
|
return False
|
|
|
|
|
# Same major — compare minor.patch
|
|
|
|
|
return self >= min_version
|
|
|
|
|
|
|
|
|
|
def is_upgrade_from(self, old_version: SemVer) -> bool:
|
|
|
|
|
"""Return True if this version is newer than old_version."""
|
|
|
|
|
return self > old_version
|
|
|
|
|
|
|
|
|
|
def is_downgrade_from(self, old_version: SemVer) -> bool:
|
|
|
|
|
"""Return True if this version is older than old_version."""
|
|
|
|
|
return self < old_version
|
|
|
|
|
|
|
|
|
|
# ─── Internal helpers ───
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _compare_prerelease(a: str, b: str) -> int:
|
|
|
|
|
"""Compare two pre-release strings per SemVer spec.
|
|
|
|
|
|
|
|
|
|
Numeric identifiers are compared numerically, alphanumeric lexically.
|
|
|
|
|
"""
|
|
|
|
|
a_parts = a.split(".")
|
|
|
|
|
b_parts = b.split(".")
|
|
|
|
|
|
|
|
|
|
for i in range(min(len(a_parts), len(b_parts))):
|
|
|
|
|
ap, bp = a_parts[i], b_parts[i]
|
|
|
|
|
a_is_num = ap.isdigit()
|
|
|
|
|
b_is_num = bp.isdigit()
|
|
|
|
|
|
|
|
|
|
if a_is_num and b_is_num:
|
|
|
|
|
ai, bi = int(ap), int(bp)
|
|
|
|
|
if ai < bi:
|
|
|
|
|
return -1
|
|
|
|
|
if ai > bi:
|
|
|
|
|
return 1
|
|
|
|
|
elif a_is_num and not b_is_num:
|
|
|
|
|
return -1 # Numeric < alphanumeric
|
|
|
|
|
elif not a_is_num and b_is_num:
|
|
|
|
|
return 1 # Alphanumeric > numeric
|
|
|
|
|
else:
|
|
|
|
|
if ap < bp:
|
|
|
|
|
return -1
|
|
|
|
|
if ap > bp:
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
# All compared parts are equal — shorter pre-release is lower
|
|
|
|
|
return len(a_parts) - len(b_parts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def compare_versions(v1: str, v2: str) -> int:
|
|
|
|
|
"""Compare two version strings.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
-1 if v1 < v2
|
|
|
|
|
0 if v1 == v2
|
|
|
|
|
1 if v1 > v2
|
|
|
|
|
"""
|
|
|
|
|
sv1 = SemVer.parse(v1)
|
|
|
|
|
sv2 = SemVer.parse(v2)
|
|
|
|
|
if sv1 < sv2:
|
|
|
|
|
return -1
|
|
|
|
|
if sv1 > sv2:
|
|
|
|
|
return 1
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_breaking_change(old: str, new: str) -> bool:
|
|
|
|
|
"""Check if upgrading from old to new is a breaking change."""
|
|
|
|
|
return SemVer.parse(old).is_breaking_change(SemVer.parse(new))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_compatible(current: str, min_required: str) -> bool:
|
|
|
|
|
"""Check if current version satisfies the minimum required version."""
|
|
|
|
|
return SemVer.parse(current).is_compatible_with(SemVer.parse(min_required))
|