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.
This commit is contained in:
2026-03-23 20:42:33 -07:00
parent 2d28e92594
commit e4b143ff36
58 changed files with 10163 additions and 50 deletions

View File

@@ -0,0 +1,242 @@
"""
Stage registry - Unified registration for all pipeline stages.
Provides a single registry for sources, effects, displays, and cameras.
Supports plugin discovery via entry points and explicit registration.
"""
from __future__ import annotations
import importlib
import importlib.metadata
import logging
from typing import TYPE_CHECKING, Any, TypeVar
from sideline.pipeline.core import Stage
if TYPE_CHECKING:
from sideline.pipeline.core import Stage
T = TypeVar("T")
logger = logging.getLogger(__name__)
class StageRegistry:
"""Unified registry for all pipeline stage types.
Supports both explicit registration and automatic discovery via entry points.
Plugins can be registered manually or discovered automatically.
"""
_categories: dict[str, dict[str, type[Any]]] = {}
_discovered: bool = False
_instances: dict[str, Stage] = {}
_plugins_discovered: bool = False
_plugin_modules: set[str] = set() # Track loaded plugin modules
@classmethod
def register(cls, category: str, stage_class: type[Any]) -> None:
"""Register a stage class in a category.
Args:
category: Category name (source, effect, display, camera)
stage_class: Stage subclass to register
"""
if category not in cls._categories:
cls._categories[category] = {}
key = getattr(stage_class, "__name__", stage_class.__class__.__name__)
cls._categories[category][key] = stage_class
@classmethod
def get(cls, category: str, name: str) -> type[Any] | None:
"""Get a stage class by category and name."""
return cls._categories.get(category, {}).get(name)
@classmethod
def list(cls, category: str) -> list[str]:
"""List all stage names in a category."""
return list(cls._categories.get(category, {}).keys())
@classmethod
def list_categories(cls) -> list[str]:
"""List all registered categories."""
return list(cls._categories.keys())
@classmethod
def create(cls, category: str, name: str, **kwargs) -> Stage | None:
"""Create a stage instance by category and name."""
stage_class = cls.get(category, name)
if stage_class:
return stage_class(**kwargs)
return None
@classmethod
def create_instance(cls, stage: Stage | type[Stage], **kwargs) -> Stage:
"""Create an instance from a stage class or return as-is."""
if isinstance(stage, Stage):
return stage
if isinstance(stage, type) and issubclass(stage, Stage):
return stage(**kwargs)
raise TypeError(f"Expected Stage class or instance, got {type(stage)}")
@classmethod
def register_instance(cls, name: str, stage: Stage) -> None:
"""Register a stage instance by name."""
cls._instances[name] = stage
@classmethod
def get_instance(cls, name: str) -> Stage | None:
"""Get a registered stage instance by name."""
return cls._instances.get(name)
@classmethod
def register_plugin_module(cls, plugin_module: str) -> None:
"""Register stages from an external plugin module.
The module should define a register_stages(registry) function.
Args:
plugin_module: Full module path (e.g., 'engine.plugins')
"""
if plugin_module in cls._plugin_modules:
logger.debug(f"Plugin module {plugin_module} already loaded")
return
try:
module = importlib.import_module(plugin_module)
if hasattr(module, "register_stages"):
module.register_stages(cls)
cls._plugin_modules.add(plugin_module)
logger.info(f"Registered stages from {plugin_module}")
else:
logger.warning(
f"Plugin module {plugin_module} has no register_stages function"
)
except ImportError as e:
logger.warning(f"Failed to import plugin module {plugin_module}: {e}")
# Backward compatibility alias
register_plugin = register_plugin_module
@classmethod
def discover_plugins(cls) -> None:
"""Auto-discover and register plugins via entry points.
Looks for 'sideline.stages' entry points in installed packages.
Each entry point should point to a register_stages(registry) function.
"""
if cls._plugins_discovered:
return
try:
# Discover entry points for sideline.stages
# Python 3.12+ changed the entry_points() API
try:
entry_points = importlib.metadata.entry_points()
if hasattr(entry_points, "get"):
# Python < 3.12
stages_eps = entry_points.get("sideline.stages", [])
else:
# Python 3.12+
stages_eps = entry_points.select(group="sideline.stages")
except Exception:
# Fallback: try both approaches
try:
entry_points = importlib.metadata.entry_points()
stages_eps = entry_points.get("sideline.stages", [])
except Exception:
stages_eps = []
for ep in stages_eps:
try:
register_func = ep.load()
if callable(register_func):
register_func(cls)
logger.info(f"Discovered and registered plugin: {ep.name}")
except Exception as e:
logger.warning(f"Failed to load entry point {ep.name}: {e}")
cls._plugins_discovered = True
except Exception as e:
logger.warning(f"Failed to discover plugins: {e}")
@classmethod
def get_discovered_modules(cls) -> set[str]:
"""Get set of plugin modules that have been loaded."""
return cls._plugin_modules.copy()
def discover_stages() -> None:
"""Auto-discover and register all stage implementations.
This function now only registers framework-level stages (displays, etc.).
Application-specific stages should be registered via plugins.
"""
if StageRegistry._discovered:
return
# Register display stages (framework-level)
_register_display_stages()
# Discover plugins via entry points
StageRegistry.discover_plugins()
StageRegistry._discovered = True
def _register_display_stages() -> None:
"""Register display backends as stages."""
try:
from sideline.display import DisplayRegistry
except ImportError:
return
DisplayRegistry.initialize()
for backend_name in DisplayRegistry.list_backends():
factory = _DisplayStageFactory(backend_name)
StageRegistry._categories.setdefault("display", {})[backend_name] = factory
class _DisplayStageFactory:
"""Factory that creates DisplayStage instances for a specific backend."""
def __init__(self, backend_name: str):
self._backend_name = backend_name
def __call__(self):
from sideline.display import DisplayRegistry
from sideline.pipeline.adapters import DisplayStage
display = DisplayRegistry.create(self._backend_name)
if display is None:
raise RuntimeError(
f"Failed to create display backend: {self._backend_name}"
)
return DisplayStage(display, name=self._backend_name)
@property
def __name__(self) -> str:
return self._backend_name.capitalize() + "Stage"
# Convenience functions
def register_source(stage_class: type[Stage]) -> None:
"""Register a source stage."""
StageRegistry.register("source", stage_class)
def register_effect(stage_class: type[Stage]) -> None:
"""Register an effect stage."""
StageRegistry.register("effect", stage_class)
def register_display(stage_class: type[Stage]) -> None:
"""Register a display stage."""
StageRegistry.register("display", stage_class)
def register_camera(stage_class: type[Stage]) -> None:
"""Register a camera stage."""
StageRegistry.register("camera", stage_class)