Files
sideline/engine/app/main.py
David Gwilliam b1bf739324 Add pipeline mutation commands to REPL
- Add help text for add_stage, remove_stage, swap_stages, move_stage commands
- Implement _cmd_add_stage, _cmd_remove_stage, _cmd_swap_stages, _cmd_move_stage methods
- Update _handle_pipeline_mutation in main.py and pipeline_runner.py
- Fix fragile test by testing output buffer directly instead of rendered output
2026-03-22 17:15:05 -07:00

602 lines
22 KiB
Python

"""
Main entry point and CLI argument parsing for the application.
"""
import sys
import time
from engine import config
from engine.display import BorderMode, DisplayRegistry
from engine.effects import get_registry
from engine.fetch import fetch_all, fetch_all_fast, fetch_poetry, load_cache, save_cache
from engine.pipeline import (
Pipeline,
PipelineConfig,
PipelineContext,
list_presets,
)
from engine.pipeline.adapters import (
CameraStage,
DataSourceStage,
EffectPluginStage,
create_stage_from_display,
create_stage_from_effect,
)
from engine.pipeline.params import PipelineParams
from engine.pipeline.ui import UIConfig, UIPanel
from engine.pipeline.validation import validate_pipeline_config
try:
from engine.display.backends.websocket import WebSocketDisplay
except ImportError:
WebSocketDisplay = None
from .pipeline_runner import run_pipeline_mode
def _handle_pipeline_mutation(pipeline: Pipeline, command: dict) -> bool:
"""Handle pipeline mutation commands from REPL or other external control.
Args:
pipeline: The pipeline to mutate
command: Command dictionary with 'action' and other parameters
Returns:
True if command was successfully handled, False otherwise
"""
action = command.get("action")
if action == "add_stage":
stage_name = command.get("stage")
stage_type = command.get("stage_type")
print(
f" [Pipeline] add_stage command received: name='{stage_name}', type='{stage_type}'"
)
# Note: Dynamic stage creation is complex and requires stage factory support
# For now, we acknowledge the command but don't actually add the stage
return True
elif action == "remove_stage":
stage_name = command.get("stage")
if stage_name:
result = pipeline.remove_stage(stage_name)
print(f" [Pipeline] Removed stage '{stage_name}': {result is not None}")
return result is not None
elif action == "replace_stage":
stage_name = command.get("stage")
print(f" [Pipeline] replace_stage command received: {command}")
return True
elif action == "swap_stages":
stage1 = command.get("stage1")
stage2 = command.get("stage2")
if stage1 and stage2:
result = pipeline.swap_stages(stage1, stage2)
print(f" [Pipeline] Swapped stages '{stage1}' and '{stage2}': {result}")
return result
elif action == "move_stage":
stage_name = command.get("stage")
after = command.get("after")
before = command.get("before")
if stage_name:
result = pipeline.move_stage(stage_name, after, before)
print(f" [Pipeline] Moved stage '{stage_name}': {result}")
return result
elif action == "enable_stage":
stage_name = command.get("stage")
if stage_name:
result = pipeline.enable_stage(stage_name)
print(f" [Pipeline] Enabled stage '{stage_name}': {result}")
return result
elif action == "disable_stage":
stage_name = command.get("stage")
if stage_name:
result = pipeline.disable_stage(stage_name)
print(f" [Pipeline] Disabled stage '{stage_name}': {result}")
return result
elif action == "cleanup_stage":
stage_name = command.get("stage")
if stage_name:
pipeline.cleanup_stage(stage_name)
print(f" [Pipeline] Cleaned up stage '{stage_name}'")
return True
elif action == "can_hot_swap":
stage_name = command.get("stage")
if stage_name:
can_swap = pipeline.can_hot_swap(stage_name)
print(f" [Pipeline] Can hot-swap '{stage_name}': {can_swap}")
return True
return False
def main():
"""Main entry point - all modes now use presets or CLI construction."""
if config.PIPELINE_DIAGRAM:
try:
from engine.pipeline import generate_pipeline_diagram
except ImportError:
print("Error: pipeline diagram not available")
return
print(generate_pipeline_diagram())
return
# Check for direct pipeline construction flags
if "--pipeline-source" in sys.argv:
# Construct pipeline directly from CLI args
run_pipeline_mode_direct()
return
preset_name = None
if config.PRESET:
preset_name = config.PRESET
elif config.PIPELINE_MODE:
preset_name = config.PIPELINE_PRESET
else:
preset_name = "demo"
available = list_presets()
if preset_name not in available:
print(f"Error: Unknown preset '{preset_name}'")
print(f"Available presets: {', '.join(available)}")
sys.exit(1)
run_pipeline_mode(preset_name)
def run_pipeline_mode_direct():
"""Construct and run a pipeline directly from CLI arguments.
Usage:
python -m engine.app --pipeline-source headlines --pipeline-effects noise,fade --display null
python -m engine.app --pipeline-source fixture --pipeline-effects glitch --pipeline-ui --display null
Flags:
--pipeline-source <source>: Headlines, fixture, poetry, empty, pipeline-inspect
--pipeline-effects <effects>: Comma-separated list (noise, fade, glitch, firehose, hud, tint, border, crop)
--pipeline-camera <type>: scroll, feed, horizontal, omni, floating, bounce
--pipeline-display <display>: terminal, pygame, websocket, null, multi:term,pygame
--pipeline-ui: Enable UI panel (BorderMode.UI)
--pipeline-border <mode>: off, simple, ui
"""
import engine.effects.plugins as effects_plugins
from engine.camera import Camera
from engine.data_sources.pipeline_introspection import PipelineIntrospectionSource
from engine.data_sources.sources import EmptyDataSource, ListDataSource
from engine.pipeline.adapters import (
FontStage,
ViewportFilterStage,
)
# Discover and register all effect plugins
effects_plugins.discover_plugins()
# Parse CLI arguments
source_name = None
effect_names = []
camera_type = None
display_name = None
ui_enabled = False
border_mode = BorderMode.OFF
source_items = None
allow_unsafe = False
viewport_width = None
viewport_height = None
i = 1
argv = sys.argv
while i < len(argv):
arg = argv[i]
if arg == "--pipeline-source" and i + 1 < len(argv):
source_name = argv[i + 1]
i += 2
elif arg == "--pipeline-effects" and i + 1 < len(argv):
effect_names = [e.strip() for e in argv[i + 1].split(",") if e.strip()]
i += 2
elif arg == "--pipeline-camera" and i + 1 < len(argv):
camera_type = argv[i + 1]
i += 2
elif arg == "--viewport" and i + 1 < len(argv):
vp = argv[i + 1]
try:
viewport_width, viewport_height = map(int, vp.split("x"))
except ValueError:
print("Error: Invalid viewport format. Use WxH (e.g., 40x15)")
sys.exit(1)
i += 2
elif arg == "--pipeline-display" and i + 1 < len(argv):
display_name = argv[i + 1]
i += 2
elif arg == "--pipeline-ui":
ui_enabled = True
i += 1
elif arg == "--pipeline-border" and i + 1 < len(argv):
mode = argv[i + 1]
if mode == "simple":
border_mode = True
elif mode == "ui":
border_mode = BorderMode.UI
else:
border_mode = False
i += 2
elif arg == "--allow-unsafe":
allow_unsafe = True
i += 1
else:
i += 1
if not source_name:
print("Error: --pipeline-source is required")
print(
"Usage: python -m engine.app --pipeline-source <source> [--pipeline-effects <effects>] ..."
)
sys.exit(1)
print(" \033[38;5;245mDirect pipeline construction\033[0m")
print(f" Source: {source_name}")
print(f" Effects: {effect_names}")
print(f" Camera: {camera_type}")
print(f" Display: {display_name}")
print(f" UI Enabled: {ui_enabled}")
# Create initial config and params
params = PipelineParams()
params.source = source_name
params.camera_mode = camera_type if camera_type is not None else ""
params.effect_order = effect_names
params.border = border_mode
# Create minimal config for validation
config_obj = PipelineConfig(
source=source_name,
display=display_name or "", # Will be filled by validation
camera=camera_type if camera_type is not None else "",
effects=effect_names,
)
# Run MVP validation
result = validate_pipeline_config(config_obj, params, allow_unsafe=allow_unsafe)
if result.warnings and not allow_unsafe:
print(" \033[38;5;226mWarning: MVP validation found issues:\033[0m")
for warning in result.warnings:
print(f" - {warning}")
if result.changes:
print(" \033[38;5;226mApplied MVP defaults:\033[0m")
for change in result.changes:
print(f" {change}")
if not result.valid:
print(
" \033[38;5;196mPipeline configuration invalid and could not be fixed\033[0m"
)
sys.exit(1)
# Show MVP summary
print(" \033[38;5;245mMVP Configuration:\033[0m")
print(f" Source: {result.config.source}")
print(f" Display: {result.config.display}")
print(f" Camera: {result.config.camera or 'static (none)'}")
print(f" Effects: {result.config.effects if result.config.effects else 'none'}")
print(f" Border: {result.params.border}")
# Load source items
if source_name == "headlines":
cached = load_cache()
if cached:
source_items = cached
else:
source_items = fetch_all_fast()
if source_items:
import threading
def background_fetch():
full_items, _, _ = fetch_all()
save_cache(full_items)
background_thread = threading.Thread(
target=background_fetch, daemon=True
)
background_thread.start()
elif source_name == "fixture":
source_items = load_cache()
if not source_items:
print(" \033[38;5;196mNo fixture cache available\033[0m")
sys.exit(1)
elif source_name == "poetry":
source_items, _, _ = fetch_poetry()
elif source_name == "empty" or source_name == "pipeline-inspect":
source_items = []
else:
print(f" \033[38;5;196mUnknown source: {source_name}\033[0m")
sys.exit(1)
if source_items is not None:
print(f" \033[38;5;82mLoaded {len(source_items)} items\033[0m")
# Set border mode
if ui_enabled:
border_mode = BorderMode.UI
# Build pipeline using validated config and params
params = result.params
params.viewport_width = viewport_width if viewport_width is not None else 80
params.viewport_height = viewport_height if viewport_height is not None else 24
ctx = PipelineContext()
ctx.params = params
# Create display using validated display name
display_name = result.config.display or "terminal" # Default to terminal if empty
# Warn if display was auto-selected (not explicitly specified)
if not display_name:
print(
" \033[38;5;226mWarning: No --pipeline-display specified, using default: terminal\033[0m"
)
print(
" \033[38;5;245mTip: Use --pipeline-display null for headless mode (useful for testing)\033[0m"
)
display = DisplayRegistry.create(display_name)
# Set positioning mode
if "--positioning" in sys.argv:
idx = sys.argv.index("--positioning")
if idx + 1 < len(sys.argv):
params.positioning = sys.argv[idx + 1]
if not display:
print(f" \033[38;5;196mFailed to create display: {display_name}\033[0m")
sys.exit(1)
display.init(0, 0)
# Create pipeline using validated config
pipeline = Pipeline(config=result.config, context=ctx)
# Add stages
# Source stage
if source_name == "pipeline-inspect":
introspection_source = PipelineIntrospectionSource(
pipeline=None,
viewport_width=params.viewport_width,
viewport_height=params.viewport_height,
)
pipeline.add_stage(
"source", DataSourceStage(introspection_source, name="pipeline-inspect")
)
elif source_name == "empty":
empty_source = EmptyDataSource(
width=params.viewport_width, height=params.viewport_height
)
pipeline.add_stage("source", DataSourceStage(empty_source, name="empty"))
else:
list_source = ListDataSource(source_items, name=source_name)
pipeline.add_stage("source", DataSourceStage(list_source, name=source_name))
# Add viewport filter and font for headline sources
if source_name in ["headlines", "poetry", "fixture"]:
pipeline.add_stage(
"viewport_filter", ViewportFilterStage(name="viewport-filter")
)
pipeline.add_stage("font", FontStage(name="font"))
else:
# Fallback to simple conversion for other sources
from engine.pipeline.adapters import SourceItemsToBufferStage
pipeline.add_stage("render", SourceItemsToBufferStage(name="items-to-buffer"))
# Add camera
speed = getattr(params, "camera_speed", 1.0)
camera = None
if camera_type == "feed":
camera = Camera.feed(speed=speed)
elif camera_type == "scroll":
camera = Camera.scroll(speed=speed)
elif camera_type == "horizontal":
camera = Camera.horizontal(speed=speed)
elif camera_type == "omni":
camera = Camera.omni(speed=speed)
elif camera_type == "floating":
camera = Camera.floating(speed=speed)
elif camera_type == "bounce":
camera = Camera.bounce(speed=speed)
if camera:
pipeline.add_stage("camera", CameraStage(camera, name=camera_type))
# Add effects
effect_registry = get_registry()
for effect_name in effect_names:
effect = effect_registry.get(effect_name)
if effect:
pipeline.add_stage(
f"effect_{effect_name}", create_stage_from_effect(effect, effect_name)
)
# Add display
pipeline.add_stage("display", create_stage_from_display(display, display_name))
pipeline.build()
if not pipeline.initialize():
print(" \033[38;5;196mFailed to initialize pipeline\033[0m")
sys.exit(1)
# Create UI panel if border mode is UI
ui_panel = None
if params.border == BorderMode.UI:
ui_panel = UIPanel(UIConfig(panel_width=24, start_with_preset_picker=True))
# Enable raw mode for terminal input if supported
if hasattr(display, "set_raw_mode"):
display.set_raw_mode(True)
for stage in pipeline.stages.values():
if isinstance(stage, EffectPluginStage):
effect = stage._effect
enabled = effect.config.enabled if hasattr(effect, "config") else True
stage_control = ui_panel.register_stage(stage, enabled=enabled)
stage_control.effect = effect # type: ignore[attr-defined]
if ui_panel.stages:
first_stage = next(iter(ui_panel.stages))
ui_panel.select_stage(first_stage)
ctrl = ui_panel.stages[first_stage]
if hasattr(ctrl, "effect"):
effect = ctrl.effect
if hasattr(effect, "config"):
config = effect.config
try:
import dataclasses
if dataclasses.is_dataclass(config):
for field_name, field_obj in dataclasses.fields(config):
if field_name == "enabled":
continue
value = getattr(config, field_name, None)
if value is not None:
ctrl.params[field_name] = value
ctrl.param_schema[field_name] = {
"type": type(value).__name__,
"min": 0
if isinstance(value, (int, float))
else None,
"max": 1 if isinstance(value, float) else None,
"step": 0.1 if isinstance(value, float) else 1,
}
except Exception:
pass
# Check for REPL effect in pipeline
repl_effect = None
for stage in pipeline.stages.values():
if isinstance(stage, EffectPluginStage) and stage._effect.name == "repl":
repl_effect = stage._effect
print(
" \033[38;5;46mREPL effect detected - Interactive mode enabled\033[0m"
)
break
# Enable raw mode for REPL if present and not already enabled
# Also enable for UI border mode (already handled above)
if repl_effect and ui_panel is None and hasattr(display, "set_raw_mode"):
display.set_raw_mode(True)
# Run pipeline loop
from engine.display import render_ui_panel
ctx.set("display", display)
ctx.set("items", source_items)
ctx.set("pipeline", pipeline)
ctx.set("pipeline_order", pipeline.execution_order)
current_width = params.viewport_width
current_height = params.viewport_height
# Only get dimensions from display if viewport wasn't explicitly set
if "--viewport" not in sys.argv and hasattr(display, "get_dimensions"):
current_width, current_height = display.get_dimensions()
params.viewport_width = current_width
params.viewport_height = current_height
print(" \033[38;5;82mStarting pipeline...\033[0m")
print(" \033[38;5;245mPress Ctrl+C to exit\033[0m\n")
try:
frame = 0
while True:
params.frame_number = frame
ctx.params = params
result = pipeline.execute(source_items)
if not result.success:
error_msg = f" ({result.error})" if result.error else ""
print(f" \033[38;5;196mPipeline execution failed{error_msg}\033[0m")
break
# Render with UI panel
if ui_panel is not None:
buf = render_ui_panel(
result.data, current_width, current_height, ui_panel
)
display.show(buf, border=False)
else:
display.show(result.data, border=border_mode)
# Handle keyboard events if UI is enabled
if ui_panel is not None:
# Try pygame first
if hasattr(display, "_pygame"):
try:
import pygame
for event in pygame.event.get():
if event.type == pygame.KEYDOWN:
ui_panel.process_key_event(event.key, event.mod)
except (ImportError, Exception):
pass
# Try terminal input
elif hasattr(display, "get_input_keys"):
try:
keys = display.get_input_keys()
for key in keys:
ui_panel.process_key_event(key, 0)
except Exception:
pass
# --- REPL Input Handling ---
if repl_effect and hasattr(display, "get_input_keys"):
# Get keyboard input (non-blocking)
keys = display.get_input_keys(timeout=0.0)
for key in keys:
if key == "ctrl_c":
# Request quit when Ctrl+C is pressed
if hasattr(display, "request_quit"):
display.request_quit()
else:
raise KeyboardInterrupt()
elif key == "return":
# Get command string before processing
cmd_str = repl_effect.state.current_command
if cmd_str:
repl_effect.process_command(cmd_str, ctx)
# Check for pending pipeline mutations
pending = repl_effect.get_pending_command()
if pending:
_handle_pipeline_mutation(pipeline, pending)
elif key == "up":
repl_effect.navigate_history(-1)
elif key == "down":
repl_effect.navigate_history(1)
elif key == "backspace":
repl_effect.backspace()
elif len(key) == 1:
repl_effect.append_to_command(key)
# --- End REPL Input Handling ---
# Check for quit request
if hasattr(display, "is_quit_requested") and display.is_quit_requested():
if hasattr(display, "clear_quit_request"):
display.clear_quit_request()
raise KeyboardInterrupt()
time.sleep(1 / 60)
frame += 1
except KeyboardInterrupt:
pipeline.cleanup()
display.cleanup()
print("\n \033[38;5;245mPipeline stopped\033[0m")
return
pipeline.cleanup()
display.cleanup()
print("\n \033[38;5;245mPipeline stopped\033[0m")