""" Compatibility management for Sideline plugins. Provides semantic version constraint checking and validation. """ import re from dataclasses import dataclass from typing import Optional, Tuple @dataclass class Version: """Semantic version representation.""" major: int minor: int patch: int pre_release: Optional[str] = None build_metadata: Optional[str] = None @classmethod def parse(cls, version_str: str) -> "Version": """Parse version string into Version object. Supports formats like: - 1.2.3 - 1.2.3-alpha - 1.2.3-beta.1 - 1.2.3+build.123 """ # Remove build metadata if present if "+" in version_str: version_str, build_metadata = version_str.split("+", 1) else: build_metadata = None # Parse pre-release if present pre_release = None if "-" in version_str: version_str, pre_release = version_str.split("-", 1) # Parse major.minor.patch parts = version_str.split(".") if len(parts) != 3: raise ValueError(f"Invalid version format: {version_str}") try: major = int(parts[0]) minor = int(parts[1]) patch = int(parts[2]) except ValueError: raise ValueError(f"Invalid version numbers: {version_str}") return cls(major, minor, patch, pre_release, build_metadata) def __str__(self) -> str: result = f"{self.major}.{self.minor}.{self.patch}" if self.pre_release: result += f"-{self.pre_release}" if self.build_metadata: result += f"+{self.build_metadata}" return result def __lt__(self, other: "Version") -> bool: if not isinstance(other, Version): return NotImplemented # Compare major.minor.patch if (self.major, self.minor, self.patch) < ( other.major, other.minor, other.patch, ): return True if (self.major, self.minor, self.patch) > ( other.major, other.minor, other.patch, ): return False # Pre-release versions have lower precedence if self.pre_release and not other.pre_release: return True if not self.pre_release and other.pre_release: return False if self.pre_release and other.pre_release: return self.pre_release < other.pre_release return False def __le__(self, other: "Version") -> bool: return self < other or self == other def __gt__(self, other: "Version") -> bool: return not (self <= other) def __ge__(self, other: "Version") -> bool: return not (self < other) def __eq__(self, other: object) -> bool: if not isinstance(other, Version): return NotImplemented return ( self.major == other.major and self.minor == other.minor and self.patch == other.patch and self.pre_release == other.pre_release and self.build_metadata == other.build_metadata ) def __ne__(self, other: object) -> bool: return not self.__eq__(other) class VersionConstraint: """Semantic version constraint parser and evaluator.""" def __init__(self, constraint: str): """Parse version constraint string. Supports formats: - "1.2.3" - exact version - ">=1.2.3" - minimum version - "<2.0.0" - maximum version - ">=1.0.0,<2.0.0" - version range - "~1.2.3" - pessimistic constraint (>=1.2.3,<1.3.0) - "^1.2.3" - caret constraint (>=1.2.3,<2.0.0) """ self.constraint_str = constraint self.min_version: Optional[Version] = None self.max_version: Optional[Version] = None self.exact_version: Optional[Version] = None self._parse(constraint) def _parse(self, constraint: str) -> None: """Parse constraint string.""" # Handle comma-separated constraints if "," in constraint: parts = [p.strip() for p in constraint.split(",")] for part in parts: self._parse_single(part) else: self._parse_single(constraint) def _parse_single(self, constraint: str) -> None: """Parse a single constraint.""" constraint = constraint.strip() # Exact version if not any(op in constraint for op in [">=", "<=", ">", "<", "~", "^"]): self.exact_version = Version.parse(constraint) return # Operator-based constraints if ">=" in constraint: op, version_str = constraint.split(">=", 1) self.min_version = Version.parse(version_str.strip()) elif "<=" in constraint: op, version_str = constraint.split("<=", 1) self.max_version = Version.parse(version_str.strip()) elif ">" in constraint: op, version_str = constraint.split(">", 1) # Strict greater than - increment patch version v = Version.parse(version_str.strip()) self.min_version = Version(v.major, v.minor, v.patch + 1) elif "<" in constraint: op, version_str = constraint.split("<", 1) # Strict less than - decrement patch version (simplified) v = Version.parse(version_str.strip()) self.max_version = Version(v.major, v.minor, v.patch - 1) elif "~" in constraint: # Pessimistic constraint: ~1.2.3 means >=1.2.3,<1.3.0 version_str = constraint[1:] # Remove ~ v = Version.parse(version_str.strip()) self.min_version = v self.max_version = Version(v.major, v.minor + 1, 0) elif "^" in constraint: # Caret constraint: ^1.2.3 means >=1.2.3,<2.0.0 version_str = constraint[1:] # Remove ^ v = Version.parse(version_str.strip()) self.min_version = v self.max_version = Version(v.major + 1, 0, 0) def is_compatible(self, version: Version | str) -> bool: """Check if a version satisfies this constraint.""" if isinstance(version, str): version = Version.parse(version) # Exact version match if self.exact_version is not None: return version == self.exact_version # Check minimum version if self.min_version is not None: if version < self.min_version: return False # Check maximum version if self.max_version is not None: if version >= self.max_version: return False return True def __str__(self) -> str: return self.constraint_str class CompatibilityManager: """Manages plugin compatibility with Sideline.""" @classmethod def get_sideline_version(cls) -> Version: """Get the current Sideline version.""" # Import here to avoid circular imports import sideline return Version.parse(sideline.__version__) @classmethod def validate_compatibility(cls, plugin_version_constraint: str) -> bool: """Validate plugin is compatible with current Sideline version. Args: plugin_version_constraint: Version constraint string from plugin metadata Returns: True if compatible, False otherwise """ try: sideline_version = cls.get_sideline_version() constraint = VersionConstraint(plugin_version_constraint) return constraint.is_compatible(sideline_version) except Exception as e: # If parsing fails, consider incompatible import logging logger = logging.getLogger(__name__) logger.warning(f"Failed to validate compatibility: {e}") return False @classmethod def get_compatibility_error(cls, plugin_version_constraint: str) -> Optional[str]: """Get compatibility error message if incompatible. Returns: Error message string or None if compatible """ if cls.validate_compatibility(plugin_version_constraint): return None sideline_version = cls.get_sideline_version() return ( f"Plugin requires Sideline {plugin_version_constraint}, " f"but current version is {sideline_version}" )