Files
sideline/sideline/preset_packs/manager.py
David Gwilliam 98a5862c74 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-23 20:42:33 -07:00

195 lines
6.3 KiB
Python

"""
Preset pack manager for loading, validating, and managing preset packs.
"""
import logging
import os
from typing import Dict, List, Optional
import tomli
from sideline.preset_packs.pack_format import PresetPack, PresetPackMetadata
from sideline.preset_packs.encoder import PresetPackEncoder
from sideline.plugins.compatibility import CompatibilityManager
logger = logging.getLogger(__name__)
class PresetPackManager:
"""Manages preset pack loading and validation."""
def __init__(self, pack_dir: Optional[str] = None):
"""Initialize preset pack manager.
Args:
pack_dir: Directory to search for preset packs
"""
self.pack_dir = pack_dir or os.path.expanduser("~/.config/sideline/packs")
self._packs: Dict[str, PresetPack] = {}
def load_pack(self, pack_path: str) -> Optional[PresetPack]:
"""Load a preset pack from a file.
Args:
pack_path: Path to the preset pack file (.tpack or .toml)
Returns:
Loaded PresetPack or None if failed
"""
try:
with open(pack_path, "rb") as f:
# Try loading as TOML first
if pack_path.endswith(".toml"):
data = tomli.load(f)
pack = PresetPack.from_dict(data)
elif pack_path.endswith(".tpack"):
# Load ASCII art encoded pack
content = f.read().decode("utf-8")
pack = self._load_ascii_pack(content)
else:
logger.error(f"Unknown file format: {pack_path}")
return None
# Validate compatibility
if not CompatibilityManager.validate_compatibility(
pack.metadata.sideline_version
):
error = CompatibilityManager.get_compatibility_error(
pack.metadata.sideline_version
)
logger.warning(f"Pack {pack.metadata.name} incompatible: {error}")
return None
# Store pack
self._packs[pack.metadata.name] = pack
logger.info(
f"Loaded preset pack: {pack.metadata.name} v{pack.metadata.version}"
)
return pack
except Exception as e:
logger.error(f"Failed to load preset pack {pack_path}: {e}")
return None
def _load_ascii_pack(self, content: str) -> PresetPack:
"""Load pack from ASCII art encoded content."""
# Extract TOML from ASCII art
toml_data = PresetPackEncoder.decode_toml(content)
# Parse TOML
import tomli
data = tomli.loads(toml_data)
return PresetPack.from_dict(data)
def load_directory(self, directory: Optional[str] = None) -> List[PresetPack]:
"""Load all preset packs from a directory.
Args:
directory: Directory to search (defaults to pack_dir)
Returns:
List of loaded PresetPack objects
"""
directory = directory or self.pack_dir
if not os.path.exists(directory):
logger.warning(f"Preset pack directory does not exist: {directory}")
return []
loaded = []
for filename in os.listdir(directory):
if filename.endswith((".toml", ".tpack")):
path = os.path.join(directory, filename)
pack = self.load_pack(path)
if pack:
loaded.append(pack)
return loaded
def save_pack(
self, pack: PresetPack, output_path: str, format: str = "toml"
) -> bool:
"""Save a preset pack to a file.
Args:
pack: PresetPack to save
output_path: Path to save the pack
format: Output format ("toml" or "tpack")
Returns:
True if successful, False otherwise
"""
try:
if format == "toml":
import tomli_w
with open(output_path, "w") as f:
tomli_w.dump(pack.to_dict(), f)
elif format == "tpack":
# Encode as ASCII art
toml_data = self._pack_to_toml(pack)
ascii_art = PresetPackEncoder.encode_toml(toml_data, pack.metadata.name)
with open(output_path, "w") as f:
f.write(ascii_art)
else:
logger.error(f"Unknown format: {format}")
return False
logger.info(f"Saved preset pack: {output_path}")
return True
except Exception as e:
logger.error(f"Failed to save preset pack: {e}")
return False
def _pack_to_toml(self, pack: PresetPack) -> str:
"""Convert PresetPack to TOML string."""
import tomli_w
return tomli_w.dumps(pack.to_dict())
def get_pack(self, name: str) -> Optional[PresetPack]:
"""Get a loaded preset pack by name."""
return self._packs.get(name)
def list_packs(self) -> List[str]:
"""List all loaded preset pack names."""
return list(self._packs.keys())
def register_pack_plugins(self, pack: PresetPack):
"""Register all plugins from a preset pack.
Args:
pack: PresetPack containing plugins
"""
from sideline.pipeline import StageRegistry
for plugin_entry in pack.plugins:
try:
# Decode plugin code
code = PresetPackEncoder.decode_plugin_code(plugin_entry.encoded_code)
# Execute plugin code to get the class
local_ns = {}
exec(code, local_ns)
# Find the plugin class (first class defined)
plugin_class = None
for obj in local_ns.values():
if isinstance(obj, type) and hasattr(obj, "metadata"):
plugin_class = obj
break
if plugin_class:
# Register the plugin
StageRegistry.register(plugin_entry.category, plugin_class)
logger.info(f"Registered plugin: {plugin_entry.name}")
else:
logger.warning(f"No plugin class found in {plugin_entry.name}")
except Exception as e:
logger.error(f"Failed to register plugin {plugin_entry.name}: {e}")