j
This commit is contained in:
220
SYS/cmdlet_api.py
Normal file
220
SYS/cmdlet_api.py
Normal file
@@ -0,0 +1,220 @@
|
||||
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
|
||||
Reference in New Issue
Block a user