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