Files
leocrm/app/plugins/semver.py
T

216 lines
6.5 KiB
Python
Raw Permalink Normal View History

"""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:
logger.info(f"{v1} is older than {v2}")
if v1.is_breaking_change(v2):
logger.warning("Major version changed — breaking!")
if v2.is_compatible_with(v1):
logger.info("v2 is compatible with v1")
"""
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))