"""Hybrid Preset-Graph Configuration System This module provides a configuration format that combines the simplicity of presets with the flexibility of graphs. Example: [pipeline] source = "headlines" camera = { mode = "scroll", speed = 1.0 } effects = [ { name = "noise", intensity = 0.3 }, { name = "fade", intensity = 0.5 } ] display = { backend = "terminal" } This is much more concise than the verbose node-based graph DSL while providing the same flexibility. """ from dataclasses import dataclass, field from typing import Any, Dict, List, Optional from pathlib import Path from engine.pipeline.graph import Graph, NodeType from engine.pipeline.graph_adapter import graph_to_pipeline @dataclass class EffectConfig: """Configuration for a single effect.""" name: str intensity: float = 1.0 enabled: bool = True params: Dict[str, Any] = field(default_factory=dict) @dataclass class CameraConfig: """Configuration for camera.""" mode: str = "scroll" speed: float = 1.0 @dataclass class DisplayConfig: """Configuration for display.""" backend: str = "terminal" positioning: str = "mixed" @dataclass class PipelineConfig: """Hybrid pipeline configuration combining preset simplicity with graph flexibility. This format provides a concise way to define pipelines that's 70% smaller than the verbose node-based DSL while maintaining full flexibility. Example: [pipeline] source = "headlines" camera = { mode = "scroll", speed = 1.0 } effects = [ { name = "noise", intensity = 0.3 }, { name = "fade", intensity = 0.5 } ] display = { backend = "terminal", positioning = "mixed" } """ source: str = "headlines" camera: Optional[CameraConfig] = None effects: List[EffectConfig] = field(default_factory=list) display: Optional[DisplayConfig] = None viewport_width: int = 80 viewport_height: int = 24 @classmethod def from_preset(cls, preset_name: str) -> "PipelineConfig": """Create PipelineConfig from a preset name. Args: preset_name: Name of preset (e.g., "upstream-default") Returns: PipelineConfig instance """ from engine.pipeline import get_preset preset = get_preset(preset_name) if not preset: raise ValueError(f"Preset '{preset_name}' not found") # Convert preset to PipelineConfig effects = [EffectConfig(name=e, intensity=1.0) for e in preset.effects] return cls( source=preset.source, camera=CameraConfig(mode=preset.camera, speed=preset.camera_speed), effects=effects, display=DisplayConfig( backend=preset.display, positioning=preset.positioning ), viewport_width=preset.viewport_width, viewport_height=preset.viewport_height, ) def to_graph(self) -> Graph: """Convert hybrid config to Graph representation.""" graph = Graph() # Add source node graph.node("source", NodeType.SOURCE, source=self.source) # Add camera node if configured if self.camera: graph.node( "camera", NodeType.CAMERA, mode=self.camera.mode, speed=self.camera.speed, ) # Add effect nodes for effect in self.effects: # Handle both EffectConfig objects and dictionaries if isinstance(effect, dict): name = effect.get("name", "") intensity = effect.get("intensity", 1.0) enabled = effect.get("enabled", True) params = effect.get("params", {}) else: name = effect.name intensity = effect.intensity enabled = effect.enabled params = effect.params if name: graph.node( name, NodeType.EFFECT, effect=name, intensity=intensity, enabled=enabled, **params, ) # Add display node if isinstance(self.display, dict): display_backend = self.display.get("backend", "terminal") display_positioning = self.display.get("positioning", "mixed") elif self.display: display_backend = self.display.backend display_positioning = self.display.positioning else: display_backend = "terminal" display_positioning = "mixed" graph.node( "display", NodeType.DISPLAY, backend=display_backend, positioning=display_positioning, ) # Create linear connections # Build chain: source -> camera -> effects... -> display chain = ["source"] if self.camera: chain.append("camera") # Add all effects in order for effect in self.effects: name = effect.get("name", "") if isinstance(effect, dict) else effect.name if name: chain.append(name) chain.append("display") # Connect all nodes in chain for i in range(len(chain) - 1): graph.connect(chain[i], chain[i + 1]) return graph def to_pipeline(self, viewport_width: int = 80, viewport_height: int = 24): """Convert to Pipeline instance.""" graph = self.to_graph() return graph_to_pipeline(graph, viewport_width, viewport_height) def load_hybrid_config(toml_path: str | Path) -> PipelineConfig: """Load hybrid configuration from TOML file. Args: toml_path: Path to TOML file Returns: PipelineConfig instance """ import tomllib with open(toml_path, "rb") as f: data = tomllib.load(f) return parse_hybrid_config(data) def parse_hybrid_config(data: Dict[str, Any]) -> PipelineConfig: """Parse hybrid configuration from dictionary. Expected format: { "pipeline": { "source": "headlines", "camera": {"mode": "scroll", "speed": 1.0}, "effects": [ {"name": "noise", "intensity": 0.3}, {"name": "fade", "intensity": 0.5} ], "display": {"backend": "terminal"} } } """ pipeline_data = data.get("pipeline", {}) # Parse camera config camera = None if "camera" in pipeline_data: camera_data = pipeline_data["camera"] if isinstance(camera_data, dict): camera = CameraConfig( mode=camera_data.get("mode", "scroll"), speed=camera_data.get("speed", 1.0), ) elif isinstance(camera_data, str): camera = CameraConfig(mode=camera_data) # Parse effects list effects = [] if "effects" in pipeline_data: effects_data = pipeline_data["effects"] if isinstance(effects_data, list): for effect_item in effects_data: if isinstance(effect_item, dict): effects.append( EffectConfig( name=effect_item.get("name", ""), intensity=effect_item.get("intensity", 1.0), enabled=effect_item.get("enabled", True), params=effect_item.get("params", {}), ) ) elif isinstance(effect_item, str): effects.append(EffectConfig(name=effect_item)) # Parse display config display = None if "display" in pipeline_data: display_data = pipeline_data["display"] if isinstance(display_data, dict): display = DisplayConfig( backend=display_data.get("backend", "terminal"), positioning=display_data.get("positioning", "mixed"), ) elif isinstance(display_data, str): display = DisplayConfig(backend=display_data) # Parse viewport settings viewport_width = pipeline_data.get("viewport_width", 80) viewport_height = pipeline_data.get("viewport_height", 24) return PipelineConfig( source=pipeline_data.get("source", "headlines"), camera=camera, effects=effects, display=display, viewport_width=viewport_width, viewport_height=viewport_height, )