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:
242
sideline/pipeline/registry.py
Normal file
242
sideline/pipeline/registry.py
Normal 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)
|
||||
Reference in New Issue
Block a user