Files
falyx/tests/test_execution_registry.py
Roland Thomas efe3f5fd99 feat(core): clone commands and actions when binding runtimes
Add clone support across Action types and Command so commands can be safely
registered or runner-bound without mutating the original instances.

- clone BaseAction implementations across simple, composite, IO, prompt, file,
  HTTP, process, and signal actions
- bind cloned commands in Falyx.add_command_from_command() and CommandRunner
- preserve local never_prompt settings when cloning actions
- rename shared runtime state from options to options_manager for consistency
- seed root and execution option namespaces consistently
- apply scoped root and namespace option overrides during routing and dispatch
- improve namespace completion by delegating option suggestions to FalyxParser
- enrich missing-value errors and error hints
2026-06-07 13:04:35 -04:00

308 lines
9.6 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Iterator
import pytest
from rich.console import Console
from rich.table import Table
from falyx.execution_registry import ExecutionRegistry
@dataclass
class DummyAction:
ignore_in_history: bool = False
class DummyContext:
def __init__(
self,
name: str,
*,
result: Any = None,
exception: Exception | None = None,
traceback: str = "",
signature: str | None = None,
start_time: float | None = 1_700_000_000.0,
end_time: float | None = 1_700_000_001.0,
duration: float | None = 1.25,
ignore_in_history: bool = False,
) -> None:
self.index = -1
self.name = name
self.result = result
self.exception = exception
self.traceback = traceback
self.signature = signature or f"{name}()"
self.start_time = start_time
self.end_time = end_time
self.duration = duration
self.action = DummyAction(ignore_in_history=ignore_in_history)
self.success = exception is None
def to_log_line(self) -> str:
return f"log:{self.name}:{self.index}"
class CaptureConsole:
def __init__(self) -> None:
self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
def print(self, *args: Any, **kwargs: Any) -> None:
self.printed.append((args, kwargs))
def rendered_text(self) -> str:
output = Console(record=True, width=160)
for args, kwargs in self.printed:
output.print(*args, **kwargs)
return output.export_text()
@pytest.fixture(autouse=True)
def isolated_registry() -> Iterator[CaptureConsole]:
original_console = ExecutionRegistry._console
capture = CaptureConsole()
ExecutionRegistry._console = capture # type: ignore[assignment]
ExecutionRegistry._store_by_name.clear()
ExecutionRegistry._store_by_index.clear()
ExecutionRegistry._store_all.clear()
ExecutionRegistry._index = 0
yield capture
ExecutionRegistry._store_by_name.clear()
ExecutionRegistry._store_by_index.clear()
ExecutionRegistry._store_all.clear()
ExecutionRegistry._index = 0
ExecutionRegistry._console = original_console
def record_context(*args: Any, **kwargs: Any) -> DummyContext:
context = DummyContext(*args, **kwargs)
ExecutionRegistry.record(context) # type: ignore[arg-type]
return context
def latest_printed_table(console: CaptureConsole) -> Table:
assert console.printed
table = console.printed[-1][0][0]
assert isinstance(table, Table)
return table
def test_record_assigns_indexes_and_populates_all_lookup_stores() -> None:
first = record_context("Build", result="ok")
second = record_context("Build", result="again")
other = record_context("Deploy", result="done")
assert first.index == 0
assert second.index == 1
assert other.index == 2
assert ExecutionRegistry.get_all() == [first, second, other]
assert ExecutionRegistry.get_by_name("Build") == [first, second]
assert ExecutionRegistry.get_by_name("missing") == []
assert ExecutionRegistry._store_by_index == {0: first, 1: second, 2: other}
assert ExecutionRegistry.get_latest() is other
def test_clear_removes_all_recorded_contexts() -> None:
record_context("Build", result="ok")
ExecutionRegistry.clear()
assert ExecutionRegistry.get_all() == []
assert ExecutionRegistry.get_by_name("Build") == []
assert ExecutionRegistry._store_by_index == {}
def test_summary_clear_clears_registry_and_prints_confirmation(
isolated_registry: CaptureConsole,
) -> None:
record_context("Build", result="ok")
ExecutionRegistry.summary(clear=True)
assert ExecutionRegistry.get_all() == []
assert "Execution history cleared" in isolated_registry.rendered_text()
def test_summary_last_result_skips_ignored_contexts(
isolated_registry: CaptureConsole,
) -> None:
visible = record_context("Visible", result={"answer": 42})
record_context("Ignored", result="do not show", ignore_in_history=True)
ExecutionRegistry.summary(last_result=True)
assert isolated_registry.printed[0][0] == (f"{visible.signature}:",)
assert isolated_registry.printed[1][0] == (visible.result,)
def test_summary_last_result_prints_traceback_when_latest_visible_context_failed(
isolated_registry: CaptureConsole,
) -> None:
failed = record_context("Fail", exception=RuntimeError("boom"), traceback="TRACEBACK")
ExecutionRegistry.summary(last_result=True)
assert isolated_registry.printed[0][0] == (f"{failed.signature}:",)
assert isolated_registry.printed[1][0] == ("TRACEBACK",)
def test_summary_last_result_reports_when_all_contexts_are_ignored(
isolated_registry: CaptureConsole,
) -> None:
record_context("Ignored", result="hidden", ignore_in_history=True)
ExecutionRegistry.summary(last_result=True)
assert "No valid executions found" in isolated_registry.rendered_text()
def test_summary_result_index_prints_result_for_existing_context(
isolated_registry: CaptureConsole,
) -> None:
context = record_context("Build", result=["artifact.whl"])
ExecutionRegistry.summary(result_index=context.index)
assert isolated_registry.printed[0][0] == (f"{context.signature}:",)
assert isolated_registry.printed[1][0] == (context.result,)
def test_summary_result_index_prints_traceback_for_failed_context(
isolated_registry: CaptureConsole,
) -> None:
context = record_context("Fail", exception=ValueError("bad"), traceback="STACK")
ExecutionRegistry.summary(result_index=context.index)
assert isolated_registry.printed[0][0] == (f"{context.signature}:",)
assert isolated_registry.printed[1][0] == ("STACK",)
def test_summary_result_index_reports_missing_index(
isolated_registry: CaptureConsole,
) -> None:
ExecutionRegistry.summary(result_index=99)
assert "No execution found for index 99" in isolated_registry.rendered_text()
def test_summary_name_filter_reports_missing_action(
isolated_registry: CaptureConsole,
) -> None:
record_context("Build", result="ok")
ExecutionRegistry.summary(name="Deploy")
assert "No executions found for action 'Deploy'" in isolated_registry.rendered_text()
def test_summary_name_filter_renders_only_matching_contexts(
isolated_registry: CaptureConsole,
) -> None:
record_context("Build", result="ok")
record_context("Deploy", result="done")
record_context("Build", result="again")
ExecutionRegistry.summary(name="Build")
table = latest_printed_table(isolated_registry)
assert table.title == "📊 Execution History for 'Build'"
assert len(table.rows) == 2
rendered = isolated_registry.rendered_text()
assert "Build" in rendered
assert "Deploy" not in rendered
def test_summary_index_filter_renders_existing_context(
isolated_registry: CaptureConsole,
capsys: pytest.CaptureFixture[str],
) -> None:
first = record_context("Build", result="ok")
second = record_context("Deploy", result="done")
ExecutionRegistry.summary(index=second.index)
table = latest_printed_table(isolated_registry)
assert table.title == f"📊 Execution History for Index {second.index}"
assert len(table.rows) == 1
rendered = isolated_registry.rendered_text()
assert "Deploy" in rendered
assert "Build" not in rendered
# The implementation currently prints the filtered context list directly.
assert str([second]) in capsys.readouterr().out
assert first.index == 0
def test_summary_index_filter_reports_missing_index(
isolated_registry: CaptureConsole,
) -> None:
ExecutionRegistry.summary(index=12)
assert "No execution found for index 12" in isolated_registry.rendered_text()
def test_summary_status_success_filters_out_errors_and_truncates_long_results(
isolated_registry: CaptureConsole,
) -> None:
long_result = "x" * 80
record_context("Success", result=long_result)
record_context("Failure", exception=RuntimeError("boom"))
ExecutionRegistry.summary(status="success")
table = latest_printed_table(isolated_registry)
assert len(table.rows) == 1
rendered = isolated_registry.rendered_text()
assert "Success" in rendered
assert "Failure" not in rendered
assert "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..." in rendered
def test_summary_status_error_filters_out_successes(
isolated_registry: CaptureConsole,
) -> None:
record_context("Success", result="ok")
record_context("Failure", exception=RuntimeError("boom"))
ExecutionRegistry.summary(status="error")
table = latest_printed_table(isolated_registry)
assert len(table.rows) == 1
rendered = isolated_registry.rendered_text()
assert "Failure" in rendered
assert "RuntimeError" in rendered
assert "Success" not in rendered
def test_summary_uses_na_for_missing_timestamps_and_duration(
isolated_registry: CaptureConsole,
) -> None:
record_context("Pending", result=None, start_time=None, end_time=None, duration=None)
ExecutionRegistry.summary()
rendered = isolated_registry.rendered_text()
assert "Pending" in rendered
assert "n/a" in rendered
def test_summary_defaults_to_all_contexts(
isolated_registry: CaptureConsole,
) -> None:
record_context("One", result="ok")
record_context("Two", exception=RuntimeError("boom"))
ExecutionRegistry.summary()
table = latest_printed_table(isolated_registry)
assert table.title == "📊 Execution History"
assert len(table.rows) == 2
rendered = isolated_registry.rendered_text()
assert "One" in rendered
assert "Two" in rendered