"""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))