Files
falyx/tests/test_runner/test_command_runner.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

669 lines
22 KiB
Python

import asyncio
import logging
import sys
import pytest
from rich.console import Console
from rich.text import Text
from falyx import Falyx
from falyx.action import Action
from falyx.command import Command
from falyx.command_runner import CommandRunner
from falyx.console import console as falyx_console
from falyx.console import error_console
from falyx.exceptions import (
CommandArgumentError,
FalyxError,
InvalidHookError,
NotAFalyxError,
)
from falyx.hook_manager import HookManager, HookType
from falyx.options_manager import OptionsManager
from falyx.parser import CommandArgumentParser
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
async def ok_action(*args, **kwargs):
falyx_console.print("Action executed with args:", args, "and kwargs:", kwargs)
return "ok"
async def failing_action(*args, **kwargs):
raise RuntimeError("boom")
async def throw_error_action(error: str):
if error == "QuitSignal":
raise QuitSignal("Quit signal triggered.")
elif error == "BackSignal":
raise BackSignal("Back signal triggered.")
elif error == "CancelSignal":
raise CancelSignal("Cancel signal triggered.")
elif error == "ValueError":
raise ValueError("This is a ValueError.")
elif error == "HelpSignal":
raise HelpSignal("Help signal triggered.")
elif error == "FalyxError":
raise FalyxError("This is a FalyxError.")
else:
raise asyncio.CancelledError("An error occurred in the action.")
@pytest.fixture
def command_throwing_error():
command = Command(
key="E",
description="Error Command",
action=Action("throw_error", throw_error_action),
execution_options=["retry"],
)
return command
@pytest.fixture
def command_with_parser():
command = Command(
key="T",
description="Test Command",
action=ok_action,
)
command.arg_parser.add_argument("--foo", type=int, help="A business argument.")
return command
@pytest.fixture
def command_with_no_parser():
command = Command(
key="T",
description="Test Command",
action=ok_action,
execution_options=["summary"],
)
command.arg_parser = None
return command
@pytest.fixture
def command_with_custom_parser():
def parse_args_split(arg_list):
return (arg_list,), {}, {"custom_execution_arg": True}
command = Command(
key="T",
description="Test Command",
action=ok_action,
execution_options=["summary"],
)
command.custom_parser = parse_args_split
return command
@pytest.fixture
def command_with_failing_action():
command = Command(
key="T",
description="Test Command",
action=failing_action,
execution_options=["summary", "retry"],
)
command.arg_parser.add_argument("--foo", type=int, help="A business argument.")
return command
@pytest.fixture
def command_build_with_all_execution_options():
return Command.build(
key="T",
description="Test Command",
action=ok_action,
execution_options=["summary", "retry", "confirm"],
)
@pytest.fixture
def console():
return Console(record=True)
@pytest.mark.asyncio
async def test_command_runner_initialization(
command_with_parser,
command_with_no_parser,
command_with_custom_parser,
):
runner = CommandRunner(command_with_parser, program="test_program")
assert runner.command == command_with_parser
assert runner.program == "test_program"
assert runner.command.arg_parser.program == "test_program"
assert isinstance(runner.options_manager, OptionsManager)
assert isinstance(runner.runner_hooks, HookManager)
assert runner.console == falyx_console
assert runner.command.options_manager == runner.options_manager
assert runner.command.arg_parser.options_manager == runner.options_manager
assert runner.command.options_manager == runner.options_manager
assert runner.executor.options_manager == runner.options_manager
assert runner.executor.hooks == runner.runner_hooks
assert runner.options_manager.get("summary", namespace_name="execution") is None
runner_no_parser = CommandRunner(command_with_no_parser)
assert runner_no_parser.command == command_with_no_parser
assert runner_no_parser.command.arg_parser is None
CommandRunner(command_with_no_parser)
with pytest.raises(
NotAFalyxError,
match="Command has no parser configured. Provide a custom_parser or CommandArgumentParser.",
):
await runner_no_parser.run("--summary")
runner_custom_parser = CommandRunner(command_with_custom_parser)
assert runner_custom_parser.command == command_with_custom_parser
assert runner_custom_parser.command.custom_parser is not None
def test_command_runner_initialization_with_custom_options(command_with_parser):
custom_options = OptionsManager([("default", {"summary": True})])
runner = CommandRunner(command_with_parser, options_manager=custom_options)
assert runner.options_manager == custom_options
assert runner.options_manager.get("summary", namespace_name="default") is True
assert runner.command.options_manager == runner.options_manager
assert runner.command.arg_parser.options_manager == runner.options_manager
assert runner.command.options_manager == runner.options_manager
def test_command_runner_initialization_with_custom_console(command_with_parser):
custom_console = Console()
runner = CommandRunner(command_with_parser, console=custom_console)
assert runner.console == custom_console
def test_command_runner_initialization_with_custom_hooks(command_with_parser):
custom_hooks = HookManager()
custom_hooks.register("before", lambda context: print("Before hook"))
runner = CommandRunner(command_with_parser, runner_hooks=custom_hooks)
assert runner.runner_hooks == custom_hooks
assert runner.executor.hooks == custom_hooks
assert runner.runner_hooks._hooks[HookType.BEFORE]
def test_command_runner_initialization_with_all_bad_components(command_with_parser):
custom_options = "Not an OptionsManager"
custom_console = 23456
custom_hooks = "Not a HookManager"
with pytest.raises(
NotAFalyxError, match="options_manager must be an instance of OptionsManager"
):
CommandRunner(
command_with_parser,
options_manager=custom_options,
)
with pytest.raises(
NotAFalyxError, match="console must be an instance of rich.Console"
):
CommandRunner(
command_with_parser,
console=custom_console,
)
with pytest.raises(
InvalidHookError, match="hooks must be an instance of HookManager"
):
CommandRunner(
command_with_parser,
runner_hooks=custom_hooks,
)
@pytest.mark.asyncio
async def test_command_runner_run(command_with_parser):
runner = CommandRunner(command_with_parser)
with falyx_console.capture() as capture:
result = await runner.run("--foo 42")
captured = Text.from_ansi(capture.get()).plain
assert result == "ok"
assert "Action executed with args:" in captured
assert "and kwargs:" in captured
assert "{'foo': 42}" in captured
falyx_console.clear()
with falyx_console.capture() as capture:
result = await runner.run(["--foo", "123"])
captured = Text.from_ansi(capture.get()).plain
assert result == "ok"
assert "Action executed with args:" in captured
assert "and kwargs:" in captured
assert "{'foo': 123}" in captured
@pytest.mark.asyncio
async def test_command_runner_run_with_failing_action(command_with_failing_action):
runner = CommandRunner(command_with_failing_action)
with pytest.raises(RuntimeError, match="boom"):
await runner.run("--foo 42")
with pytest.raises(FalyxError, match="boom"):
await runner.run("--foo 42", wrap_errors=True)
@pytest.mark.asyncio
async def test_command_runner_debug_statement(command_with_parser, caplog):
logging.getLogger("falyx").setLevel(logging.DEBUG)
runner = CommandRunner(command_with_parser)
await runner.run("--foo 42")
print(command_with_parser.get_option("verbose", namespace_name="root"))
assert (
"Executing command 'Test Command' with args=(), kwargs={'foo': 42}" in caplog.text
)
@pytest.mark.asyncio
async def test_command_runner_run_with_retries_non_action(
command_with_failing_action, caplog
):
runner = CommandRunner(command_with_failing_action)
with pytest.raises(RuntimeError, match="boom"):
await runner.run("--foo 42 --retries 2")
assert "Retry requested, but action is not an Action instance." in caplog.text
@pytest.mark.asyncio
async def test_command_runner_run_with_retries_with_action(
command_throwing_error, caplog
):
runner = CommandRunner(command_throwing_error)
with pytest.raises(asyncio.CancelledError, match="An error occurred in the action."):
await runner.run("Other")
with pytest.raises(ValueError, match="This is a ValueError."):
await runner.run("ValueError --retries 2")
assert "[throw_error] Retry attempt 1/2 failed due to 'ValueError'." in caplog.text
assert "[throw_error] Retry attempt 2/2 failed due to 'ValueError'." in caplog.text
assert "[throw_error] All 2 retries failed." in caplog.text
@pytest.mark.asyncio
async def test_command_runner_run_with_retries_delay_with_action(
command_throwing_error, caplog
):
runner = CommandRunner(command_throwing_error)
with pytest.raises(asyncio.CancelledError, match="An error occurred in the action."):
await runner.run("Other")
with pytest.raises(ValueError, match="This is a ValueError."):
await runner.run("ValueError --retries 2 --retry-delay 1.0 --retry-backoff 2.0")
assert "[throw_error] Retry attempt 1/2 failed due to 'ValueError'." in caplog.text
assert "[throw_error] Retry attempt 2/2 failed due to 'ValueError'." in caplog.text
assert "[throw_error] All 2 retries failed." in caplog.text
@pytest.mark.asyncio
async def test_command_runner_run_from_command_build_with_all_execution_options(
command_build_with_all_execution_options,
):
runner = CommandRunner.from_command(command_build_with_all_execution_options)
with falyx_console.capture() as capture:
result = await runner.run("--summary")
captured = Text.from_ansi(capture.get()).plain
assert result == "ok"
assert "Action executed with args:" in captured
assert "and kwargs:" in captured
assert "Execution History" in captured
with falyx_console.capture() as capture:
result = await runner.run("--summary", summary_last_result=True)
captured = Text.from_ansi(capture.get()).plain
assert result == "ok"
assert "Action executed with args:" in captured
assert "and kwargs:" in captured
assert "Command(key='T', description='Test Command' action=" in captured
assert "ok" in captured
with falyx_console.capture() as capture:
result = await runner.run("--summary", summary_last_result=False)
captured = Text.from_ansi(capture.get()).plain
assert result == "ok"
assert "Action executed with args:" in captured
assert "and kwargs:" in captured
assert "Execution History" in captured
@pytest.mark.asyncio
async def test_command_runner_from_command_bad_command():
with pytest.raises(NotAFalyxError, match="command must be an instance of Command"):
CommandRunner.from_command("Not a Command")
with pytest.raises(
InvalidHookError, match="runner_hooks must be an instance of HookManager"
):
CommandRunner.from_command(
Command(
key="T",
description="Test Command",
action=ok_action,
),
runner_hooks="Not a HookManager",
)
@pytest.mark.asyncio
async def test_command_runner_build():
runner = CommandRunner.build(
key="T",
description="Test Command",
action=ok_action,
execution_options=["summary", "retry"],
)
assert isinstance(runner, CommandRunner)
with falyx_console.capture() as capture:
result = await runner.run("--summary --retries 2")
captured = Text.from_ansi(capture.get()).plain
assert result == "ok"
assert "Action executed with args:" in captured
assert "and kwargs:" in captured
assert "Execution History" in captured
@pytest.mark.asyncio
async def test_command_runner_build_with_bad_execution_options():
with pytest.raises(
ValueError,
match="Invalid ExecutionOption: 'invalid_option'. Must be one of:",
):
CommandRunner.build(
key="T",
description="Test Command",
action=ok_action,
execution_options=["summary", "invalid_option"],
)
@pytest.mark.asyncio
async def test_command_runner_build_with_bad_runner_hooks():
with pytest.raises(
InvalidHookError, match="runner_hooks must be an instance of HookManager"
):
CommandRunner.build(
key="T",
description="Test Command",
action=ok_action,
runner_hooks="Not a HookManager",
)
@pytest.mark.asyncio
async def test_command_runner_uses_sys_argv(command_with_parser, monkeypatch):
runner = CommandRunner(command_with_parser)
test_args = ["program_name", "--foo", "42"]
monkeypatch.setattr(sys, "argv", test_args)
with falyx_console.capture() as capture:
result = await runner.run()
captured = Text.from_ansi(capture.get()).plain
assert result == "ok"
assert "Action executed with args:" in captured
assert "and kwargs:" in captured
assert "{'foo': 42}" in captured
@pytest.mark.asyncio
async def test_command_runner_cli(command_with_parser):
runner = CommandRunner(command_with_parser)
with falyx_console.capture() as capture:
await runner.cli("--foo 42")
captured = Text.from_ansi(capture.get()).plain
assert "Action executed with args:" in captured
assert "and kwargs:" in captured
assert "{'foo': 42}" in captured
@pytest.mark.asyncio
async def test_command_runnner_run_propogates_exeptions(command_throwing_error):
runner = CommandRunner(command_throwing_error)
with pytest.raises(QuitSignal, match="Quit signal triggered."):
await runner.run("QuitSignal")
with pytest.raises(BackSignal, match="Back signal triggered."):
await runner.run("BackSignal")
with pytest.raises(CancelSignal, match="Cancel signal triggered."):
await runner.run("CancelSignal")
with pytest.raises(ValueError, match="This is a ValueError."):
await runner.run("ValueError")
with pytest.raises(HelpSignal, match="Help signal triggered."):
await runner.run("HelpSignal")
with pytest.raises(asyncio.CancelledError, match="An error occurred in the action."):
await runner.run("Other")
with pytest.raises(
CommandArgumentError,
match=r"\[E\] Failed to parse arguments: No closing quotation",
):
await runner.run("Mismatched'")
@pytest.mark.asyncio
async def test_command_runner_cli_with_failing_action(command_with_failing_action):
runner = CommandRunner(command_with_failing_action)
with pytest.raises(SystemExit, match="1"):
await runner.cli("--foo 42")
with pytest.raises(SystemExit, match="2"):
await runner.cli("--foo 42 --bar 123")
with falyx_console.capture() as capture:
with pytest.raises(SystemExit, match="0"):
await runner.cli(["--help"])
captured = Text.from_ansi(capture.get()).plain
assert "usage: falyx" in captured
assert "--foo" in captured
assert "summary" in captured
assert "retries" in captured
assert "A business argument." in captured
@pytest.mark.asyncio
async def test_command_runner_cli_exceptions(command_throwing_error):
runner = CommandRunner(command_throwing_error)
with falyx_console.capture() as capture:
with pytest.raises(SystemExit, match="0"):
await runner.cli(["--help"])
captured = Text.from_ansi(capture.get()).plain
assert "falyx [--help]" in captured
assert "usage:" in captured
assert "positional:" in captured
assert "options:" in captured
with falyx_console.capture() as capture:
with pytest.raises(SystemExit, match="2"):
await runner.cli(["--not-an-arg"])
captured = Text.from_ansi(capture.get()).plain
assert "falyx [--help]" in captured
assert "usage:" in captured
assert "positional:" in captured
assert "options:" in captured
falyx_console.clear()
with error_console.capture() as capture:
with pytest.raises(SystemExit, match="1"):
await runner.cli(["FalyxError"])
captured = Text.from_ansi(capture.get()).plain
assert "This is a FalyxError." in captured
assert "error:" in captured
falyx_console.clear()
with falyx_console.capture() as capture:
with pytest.raises(SystemExit, match="130"):
await runner.cli(["QuitSignal"])
captured = Text.from_ansi(capture.get()).plain
with falyx_console.capture() as capture:
with pytest.raises(SystemExit, match="1"):
await runner.cli(["BackSignal"])
captured = Text.from_ansi(capture.get()).plain
with falyx_console.capture() as capture:
with pytest.raises(SystemExit, match="1"):
await runner.cli(["CancelSignal"])
captured = Text.from_ansi(capture.get()).plain
with falyx_console.capture() as capture:
with pytest.raises(SystemExit, match="1"):
await runner.cli(["Other"])
captured = Text.from_ansi(capture.get()).plain
@pytest.mark.asyncio
async def test_command_runner_cli_uses_sys_argv(command_with_parser, monkeypatch):
runner = CommandRunner(command_with_parser)
test_args = ["program_name", "--foo", "42"]
monkeypatch.setattr(sys, "argv", test_args)
with falyx_console.capture() as capture:
await runner.cli()
captured = Text.from_ansi(capture.get()).plain
assert "Action executed with args:" in captured
assert "and kwargs:" in captured
assert "{'foo': 42}" in captured
@pytest.mark.asyncio
async def test_command_runner_run_error(command_with_parser):
runner = CommandRunner(command_with_parser)
with pytest.raises(FalyxError, match="requires either"):
await runner.run(["--foo", "42"], raise_on_error=False, wrap_errors=False)
await runner.run(["--foo", "42"], raise_on_error=False, wrap_errors=True)
await runner.run(["--foo", "42"], raise_on_error=True, wrap_errors=False)
def test_command_runner_from_command_reuses_custom_options_manager_and_seeds_missing_namespaces():
flx = Falyx(program="source")
original = flx.add_command(
key="D",
description="Deploy",
action=lambda: "ok",
)
custom_options = OptionsManager([("default", {"summary": True})])
runner = CommandRunner.from_command(
original,
options_manager=custom_options,
)
assert runner.command is not original
assert runner.options_manager is custom_options
assert runner.command.options_manager is custom_options
assert runner.options_manager.get("summary", namespace_name="default") is True
assert runner.options_manager.get_namespace("root") == {}
assert runner.options_manager.get_namespace("execution") == {}
assert original.options_manager is flx.options_manager
assert original.options_manager is not custom_options
@pytest.mark.asyncio
async def test_command_runner_root_options_affect_cloned_command_without_mutating_original(
monkeypatch,
):
flx = Falyx(program="source")
original = flx.add_command(
key="D",
description="Deploy",
action=Action("deploy-action", lambda: "ok"),
confirm=True,
)
original.options_manager.set("never_prompt", False, "root")
original.options_manager.set("verbose", False, "root")
runner_options = OptionsManager()
runner_options.from_mapping({}, "root")
runner_options.from_mapping({}, "execution")
runner_options.set("never_prompt", True, "root")
runner_options.set("verbose", True, "root")
runner = CommandRunner.from_command(
original,
options_manager=runner_options,
)
calls = {"preview": 0, "confirm": 0}
async def fake_preview(self):
calls["preview"] += 1
async def fake_confirm(*args, **kwargs):
calls["confirm"] += 1
return True
monkeypatch.setattr(Command, "preview", fake_preview)
monkeypatch.setattr("falyx.command.confirm_async", fake_confirm)
result = await runner.run([])
assert result == "ok"
assert calls["preview"] == 0
assert calls["confirm"] == 0
assert runner.command.get_option("never_prompt", namespace_name="root") is True
assert runner.command.get_option("verbose", namespace_name="root") is True
assert runner.command.action.get_option("verbose", namespace_name="root") is True
assert runner.command.action.never_prompt is True
assert original.get_option("never_prompt", namespace_name="root") is False
assert original.get_option("verbose", namespace_name="root") is False
assert original.options_manager is flx.options_manager
assert original.options_manager is not runner.options_manager
@pytest.mark.asyncio
async def test_command_runner_from_command_with_custom_options_preserves_parity_and_isolation():
falyx = Falyx("Custom Options Parity Test")
def add(x: int, y: int) -> int:
return x + y
command = falyx.add_command(
key="A",
description="Add",
action=add,
)
custom_options = OptionsManager([("default", {"summary": True})])
runner = CommandRunner.from_command(
command,
options_manager=custom_options,
)
falyx_result = await falyx.execute_command("A 2 3")
runner_result = await runner.run(["2", "3"])
assert falyx_result == 5
assert runner_result == 5
assert falyx_result == runner_result
assert runner.options_manager is custom_options
assert runner.command.options_manager is custom_options
assert runner.options_manager.get("summary", namespace_name="default") is True
assert runner.options_manager.get_namespace("root") == {}
assert runner.options_manager.get_namespace("execution") == {}
assert runner.command is not command
assert command.options_manager is falyx.options_manager
assert command.options_manager is not runner.options_manager