Files
Mainline/sideline/plugins/compatibility.py
David Gwilliam e4b143ff36 feat: Implement Sideline plugin system with consistent terminology
This commit implements the Sideline/Mainline split with a clean plugin architecture:

## Core Changes

### Sideline Framework (New Directory)
- Created  directory containing the reusable pipeline framework
- Moved pipeline core, controllers, adapters, and registry to
- Moved display system to
- Moved effects system to
- Created plugin system with security and compatibility management in
- Created preset pack system with ASCII art encoding in
- Added default font (Corptic) to
- Added terminal ANSI constants to

### Mainline Application (Updated)
- Created  for Mainline stage component registration
- Updated  to register Mainline stages at startup
- Updated  as a compatibility shim re-exporting from sideline

### Terminology Consistency
- : Base class for all pipeline components (sources, effects, displays, cameras)
- : Base class for distributable plugin packages (was )
- : Base class for visual effects (was )
- Backward compatibility aliases maintained for existing code

## Key Features
- Plugin discovery via entry points and explicit registration
- Security permissions system for plugins
- Compatibility management with semantic version constraints
- Preset pack system for distributable configurations
- Default font bundled with Sideline (Corptic.otf)

## Testing
- Updated tests to register Mainline stages before discovery
- All StageRegistry tests passing

Note: This is a major refactoring that separates the framework (Sideline) from the application (Mainline), enabling Sideline to be used by other applications.
2026-03-30 19:41:04 -07:00

260 lines
8.3 KiB
Python

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