khh
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled
This commit is contained in:
@@ -1,18 +1,16 @@
|
||||
"""Pipeline execution utilities for the Textual UI.
|
||||
|
||||
This module mirrors the CLI pipeline behaviour while exposing a class-based
|
||||
interface that the TUI can call. It keeps all pipeline/cmdlet integration in
|
||||
one place so the interface layer stays focused on presentation.
|
||||
The TUI is a frontend to the CLI, so it must use the same pipeline executor
|
||||
implementation as the CLI (`CLI.PipelineExecutor`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import shlex
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Sequence
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
@@ -23,11 +21,10 @@ for path in (ROOT_DIR, BASE_DIR):
|
||||
sys.path.insert(0, str_path)
|
||||
|
||||
import pipeline as ctx
|
||||
from cmdlet import REGISTRY
|
||||
from config import get_local_storage_path, load_config
|
||||
from SYS.worker_manager import WorkerManager
|
||||
|
||||
from CLI import MedeiaCLI
|
||||
from CLI import ConfigLoader, PipelineExecutor as CLIPipelineExecutor, WorkerManagerRegistry
|
||||
from SYS.logger import set_debug
|
||||
from rich_display import capture_rich_output
|
||||
from result_table import ResultTable
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -73,24 +70,16 @@ class PipelineRunResult:
|
||||
}
|
||||
|
||||
|
||||
class PipelineExecutor:
|
||||
"""Thin wrapper over the cmdlet registry + pipeline context."""
|
||||
class PipelineRunner:
|
||||
"""TUI wrapper that delegates to the canonical CLI pipeline executor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
worker_manager: Optional[WorkerManager] = None,
|
||||
) -> None:
|
||||
self._config = config or load_config()
|
||||
self._worker_manager = worker_manager
|
||||
if self._worker_manager is None:
|
||||
self._worker_manager = self._ensure_worker_manager()
|
||||
if self._worker_manager:
|
||||
self._config["_worker_manager"] = self._worker_manager
|
||||
def __init__(self) -> None:
|
||||
self._config_loader = ConfigLoader(root=ROOT_DIR)
|
||||
self._executor = CLIPipelineExecutor(config_loader=self._config_loader)
|
||||
self._worker_manager = None
|
||||
|
||||
@property
|
||||
def worker_manager(self) -> Optional[WorkerManager]:
|
||||
def worker_manager(self):
|
||||
return self._worker_manager
|
||||
|
||||
def run_pipeline(
|
||||
@@ -98,290 +87,214 @@ class PipelineExecutor:
|
||||
pipeline_text: str,
|
||||
*,
|
||||
seeds: Optional[Any] = None,
|
||||
isolate: bool = False,
|
||||
on_log: Optional[Callable[[str], None]] = None,
|
||||
) -> PipelineRunResult:
|
||||
"""Execute a pipeline string and return structured results.
|
||||
snapshot: Optional[Dict[str, Any]] = None
|
||||
if isolate:
|
||||
snapshot = self._snapshot_ctx_state()
|
||||
|
||||
Args:
|
||||
pipeline_text: Raw pipeline text entered by the user.
|
||||
on_log: Optional callback that receives human-readable log lines.
|
||||
"""
|
||||
normalized = pipeline_text.strip()
|
||||
normalized = str(pipeline_text or "").strip()
|
||||
result = PipelineRunResult(pipeline=normalized, success=False)
|
||||
if not normalized:
|
||||
result.error = "Pipeline is empty"
|
||||
return result
|
||||
|
||||
tokens = self._tokenize(normalized)
|
||||
stages = self._split_stages(tokens)
|
||||
if not stages:
|
||||
result.error = "Pipeline contains no stages"
|
||||
try:
|
||||
from cli_syntax import validate_pipeline_text
|
||||
|
||||
syntax_error = validate_pipeline_text(normalized)
|
||||
if syntax_error:
|
||||
result.error = syntax_error.message
|
||||
result.stderr = syntax_error.message
|
||||
return result
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
tokens = shlex.split(normalized)
|
||||
except Exception as exc:
|
||||
result.error = f"Syntax error: {exc}"
|
||||
result.stderr = result.error
|
||||
return result
|
||||
|
||||
if not tokens:
|
||||
result.error = "Pipeline contains no tokens"
|
||||
return result
|
||||
|
||||
config = self._config_loader.load()
|
||||
try:
|
||||
set_debug(bool(config.get("debug", False)))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self._worker_manager = WorkerManagerRegistry.ensure(config)
|
||||
except Exception:
|
||||
self._worker_manager = None
|
||||
|
||||
ctx.reset()
|
||||
ctx.set_current_command_text(normalized)
|
||||
|
||||
if seeds is not None:
|
||||
try:
|
||||
# Mirror CLI behavior: treat seeds as output of a virtual previous stage.
|
||||
if not isinstance(seeds, list):
|
||||
seeds = [seeds]
|
||||
setter = getattr(ctx, "set_last_result_items_only", None)
|
||||
if callable(setter):
|
||||
setter(seeds)
|
||||
else:
|
||||
ctx.set_last_items(list(seeds))
|
||||
ctx.set_last_result_items_only(list(seeds))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
stdout_buffer = io.StringIO()
|
||||
stderr_buffer = io.StringIO()
|
||||
piped_result: Any = None
|
||||
worker_session = self._start_worker_session(normalized)
|
||||
|
||||
try:
|
||||
with contextlib.redirect_stdout(stdout_buffer), contextlib.redirect_stderr(
|
||||
stderr_buffer
|
||||
):
|
||||
for index, stage_tokens in enumerate(stages):
|
||||
stage = self._execute_stage(
|
||||
index=index,
|
||||
total=len(stages),
|
||||
stage_tokens=stage_tokens,
|
||||
piped_input=piped_result,
|
||||
on_log=on_log,
|
||||
)
|
||||
result.stages.append(stage)
|
||||
|
||||
if stage.status != "completed":
|
||||
result.error = stage.error or f"Stage {stage.name} failed"
|
||||
return result
|
||||
|
||||
if index == len(stages) - 1:
|
||||
result.emitted = stage.emitted
|
||||
result.result_table = stage.result_table
|
||||
else:
|
||||
piped_result = stage.emitted
|
||||
|
||||
result.success = True
|
||||
return result
|
||||
with capture_rich_output(stdout=stdout_buffer, stderr=stderr_buffer):
|
||||
with contextlib.redirect_stdout(stdout_buffer), contextlib.redirect_stderr(stderr_buffer):
|
||||
if on_log:
|
||||
on_log("Executing pipeline via CLI executor...")
|
||||
self._executor.execute_tokens(list(tokens))
|
||||
except Exception as exc:
|
||||
result.error = f"{type(exc).__name__}: {exc}"
|
||||
finally:
|
||||
try:
|
||||
ctx.clear_current_command_text()
|
||||
except Exception:
|
||||
pass
|
||||
result.stdout = stdout_buffer.getvalue()
|
||||
result.stderr = stderr_buffer.getvalue()
|
||||
ctx.clear_current_command_text()
|
||||
if worker_session is not None:
|
||||
status = "completed" if result.success else "error"
|
||||
worker_session.finish(status=status, message=result.error or "")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stage execution helpers
|
||||
# ------------------------------------------------------------------
|
||||
def _execute_stage(
|
||||
self,
|
||||
*,
|
||||
index: int,
|
||||
total: int,
|
||||
stage_tokens: Sequence[str],
|
||||
piped_input: Any,
|
||||
on_log: Optional[Callable[[str], None]],
|
||||
) -> PipelineStageResult:
|
||||
if not stage_tokens:
|
||||
return PipelineStageResult(name="(empty)", args=[], status="skipped")
|
||||
|
||||
cmd_name = stage_tokens[0].replace("_", "-").lower()
|
||||
stage_args = stage_tokens[1:]
|
||||
stage = PipelineStageResult(name=cmd_name, args=stage_args)
|
||||
|
||||
if cmd_name.startswith("@"):
|
||||
return self._apply_selection_stage(
|
||||
token=cmd_name,
|
||||
stage=stage,
|
||||
piped_input=piped_input,
|
||||
on_log=on_log,
|
||||
)
|
||||
|
||||
cmd_fn = REGISTRY.get(cmd_name)
|
||||
if not cmd_fn:
|
||||
stage.status = "failed"
|
||||
stage.error = f"Unknown command: {cmd_name}"
|
||||
return stage
|
||||
|
||||
pipeline_ctx = ctx.PipelineStageContext(stage_index=index, total_stages=total, pipe_index=index)
|
||||
ctx.set_stage_context(pipeline_ctx)
|
||||
|
||||
# Pull the canonical state out of pipeline context.
|
||||
table = None
|
||||
try:
|
||||
return_code = cmd_fn(piped_input, list(stage_args), self._config)
|
||||
except Exception as exc: # pragma: no cover - surfaced in UI
|
||||
stage.status = "failed"
|
||||
stage.error = f"{type(exc).__name__}: {exc}"
|
||||
if on_log:
|
||||
on_log(stage.error)
|
||||
return stage
|
||||
finally:
|
||||
ctx.set_stage_context(None)
|
||||
|
||||
emitted = list(getattr(pipeline_ctx, "emits", []) or [])
|
||||
stage.emitted = emitted
|
||||
|
||||
# Capture the ResultTable if the cmdlet set one
|
||||
# Check display table first (overlay), then last result table
|
||||
stage.result_table = ctx.get_display_table() or ctx.get_last_result_table()
|
||||
|
||||
if return_code != 0:
|
||||
stage.status = "failed"
|
||||
stage.error = f"Exit code {return_code}"
|
||||
else:
|
||||
stage.status = "completed"
|
||||
stage.error = None
|
||||
|
||||
worker_id = self._current_worker_id()
|
||||
if self._worker_manager and worker_id:
|
||||
label = f"[Stage {index + 1}/{total}] {cmd_name} {stage.status}"
|
||||
self._worker_manager.log_step(worker_id, label)
|
||||
|
||||
# Don't clear the table if we just captured it, but ensure items are set for next stage
|
||||
# If we have a table, we should probably keep it in ctx for history if needed
|
||||
# But for pipeline execution, we mainly care about passing items to next stage
|
||||
# ctx.set_last_result_table(None, emitted) <-- This was clearing it
|
||||
|
||||
# Ensure items are available for next stage
|
||||
ctx.set_last_items(emitted)
|
||||
return stage
|
||||
|
||||
def _apply_selection_stage(
|
||||
self,
|
||||
*,
|
||||
token: str,
|
||||
stage: PipelineStageResult,
|
||||
piped_input: Any,
|
||||
on_log: Optional[Callable[[str], None]],
|
||||
) -> PipelineStageResult:
|
||||
# Bare '@' means use the subject associated with the current result table (e.g., the file shown in a tag/URL view)
|
||||
if token == "@":
|
||||
subject = ctx.get_last_result_subject()
|
||||
if subject is None:
|
||||
stage.status = "failed"
|
||||
stage.error = "Selection requested (@) but there is no current result context"
|
||||
return stage
|
||||
stage.emitted = subject if isinstance(subject, list) else [subject]
|
||||
ctx.set_last_items(stage.emitted)
|
||||
stage.status = "completed"
|
||||
if on_log:
|
||||
on_log("Selected current table subject via @")
|
||||
return stage
|
||||
|
||||
selection = self._parse_selection(token)
|
||||
items = piped_input or []
|
||||
if not isinstance(items, list):
|
||||
items = list(items if isinstance(items, Sequence) else [items])
|
||||
|
||||
if not items:
|
||||
stage.status = "failed"
|
||||
stage.error = "Selection requested but there is no upstream data"
|
||||
return stage
|
||||
|
||||
if selection is None:
|
||||
stage.emitted = list(items)
|
||||
else:
|
||||
zero_based = sorted(i - 1 for i in selection if i > 0)
|
||||
stage.emitted = [items[i] for i in zero_based if 0 <= i < len(items)]
|
||||
|
||||
if not stage.emitted:
|
||||
stage.status = "failed"
|
||||
stage.error = "Selection matched no rows"
|
||||
return stage
|
||||
|
||||
ctx.set_last_items(stage.emitted)
|
||||
ctx.set_last_result_table(None, stage.emitted)
|
||||
stage.status = "completed"
|
||||
if on_log:
|
||||
on_log(f"Selected {len(stage.emitted)} item(s) via {token}")
|
||||
return stage
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Worker/session helpers
|
||||
# ------------------------------------------------------------------
|
||||
def _start_worker_session(self, pipeline_text: str) -> Optional[_WorkerSession]:
|
||||
manager = self._ensure_worker_manager()
|
||||
if manager is None:
|
||||
return None
|
||||
|
||||
worker_id = f"tui_pipeline_{uuid.uuid4().hex[:8]}"
|
||||
tracked = manager.track_worker(
|
||||
worker_id,
|
||||
worker_type="pipeline",
|
||||
title="Pipeline run",
|
||||
description=pipeline_text,
|
||||
pipe=pipeline_text,
|
||||
)
|
||||
if not tracked:
|
||||
return None
|
||||
|
||||
manager.log_step(worker_id, "Pipeline started")
|
||||
self._config["_current_worker_id"] = worker_id
|
||||
return _WorkerSession(manager=manager, worker_id=worker_id, config=self._config)
|
||||
|
||||
def _ensure_worker_manager(self) -> Optional[WorkerManager]:
|
||||
if self._worker_manager:
|
||||
return self._worker_manager
|
||||
library_root = get_local_storage_path(self._config)
|
||||
if not library_root:
|
||||
return None
|
||||
try:
|
||||
self._worker_manager = WorkerManager(Path(library_root), auto_refresh_interval=0)
|
||||
self._config["_worker_manager"] = self._worker_manager
|
||||
table = ctx.get_display_table() or ctx.get_current_stage_table() or ctx.get_last_result_table()
|
||||
except Exception:
|
||||
self._worker_manager = None
|
||||
return self._worker_manager
|
||||
table = None
|
||||
|
||||
def _current_worker_id(self) -> Optional[str]:
|
||||
worker_id = self._config.get("_current_worker_id")
|
||||
return str(worker_id) if worker_id else None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Parsing helpers
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _tokenize(pipeline_text: str) -> List[str]:
|
||||
items: List[Any] = []
|
||||
try:
|
||||
return shlex.split(pipeline_text)
|
||||
except ValueError:
|
||||
return pipeline_text.split()
|
||||
items = list(ctx.get_last_result_items() or [])
|
||||
except Exception:
|
||||
items = []
|
||||
|
||||
if table is None and items:
|
||||
try:
|
||||
synth = ResultTable("Results")
|
||||
for item in items:
|
||||
synth.add_result(item)
|
||||
table = synth
|
||||
except Exception:
|
||||
table = None
|
||||
|
||||
result.emitted = items
|
||||
result.result_table = table
|
||||
|
||||
combined = (result.stdout + "\n" + result.stderr).strip().lower()
|
||||
failure_markers = (
|
||||
"unknown command:",
|
||||
"pipeline order error:",
|
||||
"invalid selection:",
|
||||
"invalid pipeline syntax",
|
||||
"failed to execute pipeline",
|
||||
"[error]",
|
||||
)
|
||||
if result.error:
|
||||
result.success = False
|
||||
elif any(m in combined for m in failure_markers):
|
||||
result.success = False
|
||||
if not result.error:
|
||||
result.error = "Pipeline failed"
|
||||
else:
|
||||
result.success = True
|
||||
|
||||
if isolate and snapshot is not None:
|
||||
try:
|
||||
self._restore_ctx_state(snapshot)
|
||||
except Exception:
|
||||
# Best-effort; isolation should never break normal operation.
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _split_stages(tokens: Sequence[str]) -> List[List[str]]:
|
||||
stages: List[List[str]] = []
|
||||
current: List[str] = []
|
||||
for token in tokens:
|
||||
if token == "|":
|
||||
if current:
|
||||
stages.append(current)
|
||||
current = []
|
||||
else:
|
||||
current.append(token)
|
||||
if current:
|
||||
stages.append(current)
|
||||
return stages
|
||||
def _snapshot_ctx_state() -> Dict[str, Any]:
|
||||
"""Best-effort snapshot of pipeline context so TUI popups don't clobber UI state."""
|
||||
|
||||
@staticmethod
|
||||
def _parse_selection(token: str) -> Optional[Sequence[int]]:
|
||||
parsed = MedeiaCLI.parse_selection_syntax(token)
|
||||
return sorted(parsed) if parsed else None
|
||||
def _copy(val: Any) -> Any:
|
||||
if isinstance(val, list):
|
||||
return val.copy()
|
||||
if isinstance(val, dict):
|
||||
return val.copy()
|
||||
return val
|
||||
|
||||
snap: Dict[str, Any] = {}
|
||||
keys = [
|
||||
"_LIVE_PROGRESS",
|
||||
"_CURRENT_CONTEXT",
|
||||
"_LAST_SEARCH_QUERY",
|
||||
"_PIPELINE_REFRESHED",
|
||||
"_PIPELINE_LAST_ITEMS",
|
||||
"_LAST_RESULT_TABLE",
|
||||
"_LAST_RESULT_ITEMS",
|
||||
"_LAST_RESULT_SUBJECT",
|
||||
"_RESULT_TABLE_HISTORY",
|
||||
"_RESULT_TABLE_FORWARD",
|
||||
"_CURRENT_STAGE_TABLE",
|
||||
"_DISPLAY_ITEMS",
|
||||
"_DISPLAY_TABLE",
|
||||
"_DISPLAY_SUBJECT",
|
||||
"_PIPELINE_LAST_SELECTION",
|
||||
"_PIPELINE_COMMAND_TEXT",
|
||||
"_CURRENT_CMDLET_NAME",
|
||||
"_CURRENT_STAGE_TEXT",
|
||||
"_PIPELINE_VALUES",
|
||||
"_PENDING_PIPELINE_TAIL",
|
||||
"_PENDING_PIPELINE_SOURCE",
|
||||
"_UI_LIBRARY_REFRESH_CALLBACK",
|
||||
]
|
||||
|
||||
class _WorkerSession:
|
||||
"""Minimal worker session wrapper for the TUI executor."""
|
||||
for k in keys:
|
||||
snap[k] = _copy(getattr(ctx, k, None))
|
||||
|
||||
def __init__(self, *, manager: WorkerManager, worker_id: str, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
self._manager = manager
|
||||
self.worker_id = worker_id
|
||||
self._config = config
|
||||
|
||||
def finish(self, *, status: str, message: str) -> None:
|
||||
# Deepen copies where nested lists are common.
|
||||
try:
|
||||
self._manager.finish_worker(self.worker_id, result=status, error_msg=message)
|
||||
self._manager.log_step(self.worker_id, f"Pipeline {status}")
|
||||
hist = list(getattr(ctx, "_RESULT_TABLE_HISTORY", []) or [])
|
||||
snap["_RESULT_TABLE_HISTORY"] = [
|
||||
(t, (items.copy() if isinstance(items, list) else list(items) if items else []), subj)
|
||||
for (t, items, subj) in hist
|
||||
if isinstance((t, items, subj), tuple)
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
if self._config and self._config.get("_current_worker_id") == self.worker_id:
|
||||
self._config.pop("_current_worker_id", None)
|
||||
|
||||
try:
|
||||
fwd = list(getattr(ctx, "_RESULT_TABLE_FORWARD", []) or [])
|
||||
snap["_RESULT_TABLE_FORWARD"] = [
|
||||
(t, (items.copy() if isinstance(items, list) else list(items) if items else []), subj)
|
||||
for (t, items, subj) in fwd
|
||||
if isinstance((t, items, subj), tuple)
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
tail = list(getattr(ctx, "_PENDING_PIPELINE_TAIL", []) or [])
|
||||
snap["_PENDING_PIPELINE_TAIL"] = [list(stage) for stage in tail if isinstance(stage, list)]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
values = getattr(ctx, "_PIPELINE_VALUES", None)
|
||||
if isinstance(values, dict):
|
||||
snap["_PIPELINE_VALUES"] = values.copy()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return snap
|
||||
|
||||
@staticmethod
|
||||
def _restore_ctx_state(snapshot: Dict[str, Any]) -> None:
|
||||
for k, v in (snapshot or {}).items():
|
||||
try:
|
||||
setattr(ctx, k, v)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user