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
308 lines
9.6 KiB
Python
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
|