221 lines
6.2 KiB
Python
221 lines
6.2 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import contextlib
|
||
|
|
import io
|
||
|
|
from dataclasses import dataclass, field
|
||
|
|
from typing import Any, Callable, Dict, List, Optional, Sequence
|
||
|
|
|
||
|
|
from SYS import pipeline as ctx
|
||
|
|
from SYS.models import PipelineStageContext
|
||
|
|
from SYS.rich_display import capture_rich_output
|
||
|
|
|
||
|
|
|
||
|
|
CmdletCallable = Callable[[Any, Sequence[str], Dict[str, Any]], int]
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(slots=True)
|
||
|
|
class CmdletRunResult:
|
||
|
|
"""Programmatic result for a single cmdlet invocation."""
|
||
|
|
|
||
|
|
name: str
|
||
|
|
args: Sequence[str]
|
||
|
|
exit_code: int = 0
|
||
|
|
emitted: List[Any] = field(default_factory=list)
|
||
|
|
|
||
|
|
# Best-effort: cmdlets can publish tables/items via pipeline state even when
|
||
|
|
# they don't emit pipeline items.
|
||
|
|
result_table: Optional[Any] = None
|
||
|
|
result_items: List[Any] = field(default_factory=list)
|
||
|
|
result_subject: Optional[Any] = None
|
||
|
|
|
||
|
|
stdout: str = ""
|
||
|
|
stderr: str = ""
|
||
|
|
error: Optional[str] = None
|
||
|
|
|
||
|
|
|
||
|
|
def _normalize_cmd_name(name: str) -> str:
|
||
|
|
return str(name or "").replace("_", "-").strip().lower()
|
||
|
|
|
||
|
|
|
||
|
|
def resolve_cmdlet(cmd_name: str) -> Optional[CmdletCallable]:
|
||
|
|
"""Resolve a cmdlet callable by name from the registry (aliases supported)."""
|
||
|
|
try:
|
||
|
|
from SYS.cmdlet_catalog import ensure_registry_loaded
|
||
|
|
|
||
|
|
ensure_registry_loaded()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
try:
|
||
|
|
import cmdlet as cmdlet_pkg
|
||
|
|
|
||
|
|
return cmdlet_pkg.get(cmd_name)
|
||
|
|
except Exception:
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def run_cmdlet(
|
||
|
|
cmd: str | CmdletCallable,
|
||
|
|
args: Sequence[str] | None,
|
||
|
|
config: Dict[str, Any],
|
||
|
|
*,
|
||
|
|
piped: Any = None,
|
||
|
|
isolate: bool = True,
|
||
|
|
capture_output: bool = True,
|
||
|
|
stage_index: int = 0,
|
||
|
|
total_stages: int = 1,
|
||
|
|
pipe_index: Optional[int] = None,
|
||
|
|
worker_id: Optional[str] = None,
|
||
|
|
) -> CmdletRunResult:
|
||
|
|
"""Run a single cmdlet programmatically and return structured results.
|
||
|
|
|
||
|
|
This is intended for TUI/webapp consumers that want cmdlet behavior without
|
||
|
|
going through the interactive CLI loop.
|
||
|
|
|
||
|
|
Notes:
|
||
|
|
- When `isolate=True` (default) this runs inside `ctx.new_pipeline_state()` so
|
||
|
|
global CLI pipeline state is not mutated.
|
||
|
|
- Output capturing covers both normal `print()` and Rich output via
|
||
|
|
`capture_rich_output()`.
|
||
|
|
"""
|
||
|
|
|
||
|
|
normalized_args: Sequence[str] = list(args or [])
|
||
|
|
|
||
|
|
if isinstance(cmd, str):
|
||
|
|
name = _normalize_cmd_name(cmd)
|
||
|
|
cmd_fn = resolve_cmdlet(name)
|
||
|
|
else:
|
||
|
|
name = getattr(cmd, "__name__", "cmdlet")
|
||
|
|
cmd_fn = cmd
|
||
|
|
|
||
|
|
result = CmdletRunResult(name=name, args=normalized_args)
|
||
|
|
|
||
|
|
if not callable(cmd_fn):
|
||
|
|
result.exit_code = 1
|
||
|
|
result.error = f"Unknown command: {name}"
|
||
|
|
result.stderr = result.error
|
||
|
|
return result
|
||
|
|
|
||
|
|
stage_ctx = PipelineStageContext(
|
||
|
|
stage_index=int(stage_index),
|
||
|
|
total_stages=int(total_stages),
|
||
|
|
pipe_index=pipe_index,
|
||
|
|
worker_id=worker_id,
|
||
|
|
)
|
||
|
|
|
||
|
|
stdout_buffer = io.StringIO()
|
||
|
|
stderr_buffer = io.StringIO()
|
||
|
|
|
||
|
|
stage_text = " ".join([name, *list(normalized_args)]).strip()
|
||
|
|
|
||
|
|
state_cm = ctx.new_pipeline_state() if isolate else contextlib.nullcontext()
|
||
|
|
|
||
|
|
with state_cm:
|
||
|
|
# Keep behavior predictable: start from a clean slate.
|
||
|
|
try:
|
||
|
|
ctx.reset()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
try:
|
||
|
|
ctx.set_stage_context(stage_ctx)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
try:
|
||
|
|
ctx.set_current_cmdlet_name(name)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
try:
|
||
|
|
ctx.set_current_stage_text(stage_text)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
try:
|
||
|
|
ctx.set_current_command_text(stage_text)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
try:
|
||
|
|
run_cm = (
|
||
|
|
capture_rich_output(stdout=stdout_buffer, stderr=stderr_buffer)
|
||
|
|
if capture_output
|
||
|
|
else contextlib.nullcontext()
|
||
|
|
)
|
||
|
|
with run_cm:
|
||
|
|
with (
|
||
|
|
contextlib.redirect_stdout(stdout_buffer)
|
||
|
|
if capture_output
|
||
|
|
else contextlib.nullcontext()
|
||
|
|
):
|
||
|
|
with (
|
||
|
|
contextlib.redirect_stderr(stderr_buffer)
|
||
|
|
if capture_output
|
||
|
|
else contextlib.nullcontext()
|
||
|
|
):
|
||
|
|
result.exit_code = int(cmd_fn(piped, list(normalized_args), config))
|
||
|
|
except Exception as exc:
|
||
|
|
result.exit_code = 1
|
||
|
|
result.error = f"{type(exc).__name__}: {exc}"
|
||
|
|
finally:
|
||
|
|
result.stdout = stdout_buffer.getvalue()
|
||
|
|
result.stderr = stderr_buffer.getvalue()
|
||
|
|
|
||
|
|
# Prefer cmdlet emits (pipeline semantics).
|
||
|
|
try:
|
||
|
|
result.emitted = list(stage_ctx.emits or [])
|
||
|
|
except Exception:
|
||
|
|
result.emitted = []
|
||
|
|
|
||
|
|
# Mirror CLI behavior: if cmdlet emitted items and there is no overlay table,
|
||
|
|
# make emitted items the last result items for downstream consumers.
|
||
|
|
try:
|
||
|
|
has_overlay = bool(ctx.get_display_table())
|
||
|
|
except Exception:
|
||
|
|
has_overlay = False
|
||
|
|
|
||
|
|
if result.emitted and not has_overlay:
|
||
|
|
try:
|
||
|
|
ctx.set_last_result_items_only(list(result.emitted))
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
# Best-effort snapshot of visible results.
|
||
|
|
try:
|
||
|
|
result.result_table = (
|
||
|
|
ctx.get_display_table() or ctx.get_current_stage_table() or ctx.get_last_result_table()
|
||
|
|
)
|
||
|
|
except Exception:
|
||
|
|
result.result_table = None
|
||
|
|
|
||
|
|
try:
|
||
|
|
result.result_items = list(ctx.get_last_result_items() or [])
|
||
|
|
except Exception:
|
||
|
|
result.result_items = []
|
||
|
|
|
||
|
|
try:
|
||
|
|
result.result_subject = ctx.get_last_result_subject()
|
||
|
|
except Exception:
|
||
|
|
result.result_subject = None
|
||
|
|
|
||
|
|
# Cleanup stage-local markers.
|
||
|
|
try:
|
||
|
|
ctx.clear_current_stage_text()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
try:
|
||
|
|
ctx.clear_current_cmdlet_name()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
try:
|
||
|
|
ctx.clear_current_command_text()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
try:
|
||
|
|
ctx.set_stage_context(None)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
return result
|