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

897 lines
26 KiB
Python

# test_command.py
import logging
from collections.abc import Callable
from types import SimpleNamespace
from typing import Any
import pytest
from pydantic import ValidationError
import falyx.command as command_module
from falyx.action import Action, BaseAction, BaseIOAction, ChainedAction
from falyx.command import Command
from falyx.exceptions import CommandArgumentError, InvalidHookError, NotAFalyxError
from falyx.execution_option import ExecutionOption
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType
from falyx.parser.command_argument_parser import CommandArgumentParser
from falyx.retry import RetryPolicy
from falyx.signals import CancelSignal
asyncio_default_fixture_loop_scope = "function"
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))
class FakeBaseAction(BaseAction):
def __init__(
self,
name: str = "FakeAction",
*,
result: Any = "ok",
infer_target: Callable[..., Any] | None = None,
metadata: dict[str, Any] | None = None,
never_prompt: bool | None = None,
) -> None:
super().__init__(name, never_prompt=never_prompt)
self.result = result
self.infer_target = infer_target or (lambda: None)
self.metadata = metadata
self.preview_calls = 0
self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
async def _run(self, *args: Any, **kwargs: Any) -> Any:
self.calls.append((args, kwargs))
return self.result
async def preview(self, parent=None):
self.preview_calls += 1
if parent is not None:
parent.add("fake preview")
return None
def get_infer_target(self):
return self.infer_target, self.metadata
def clone(self) -> "FakeBaseAction":
return FakeBaseAction(
self.name,
result=self.result,
infer_target=self.infer_target,
metadata=self.metadata,
never_prompt=self.local_never_prompt,
)
def make_command(**overrides: Any) -> Command:
defaults = dict(
key="D",
description="Deploy command",
action=lambda *args, **kwargs: {"args": args, "kwargs": kwargs},
auto_args=False,
)
defaults.update(overrides)
return Command.build(**defaults)
def formatted_plain_text(formatted_text) -> str:
return "".join(fragment for _, fragment in list(formatted_text))
@pytest.fixture(autouse=True)
def clean_registry():
er.clear()
yield
er.clear()
async def dummy_action():
return "ok"
class DummyInputAction(BaseIOAction):
async def _run(self, *args, **kwargs):
return "needs input"
async def preview(self, parent=None):
pass
@pytest.mark.asyncio
async def test_command_creation():
"""Test if Command can be created with a callable."""
action = Action("test_action", dummy_action)
cmd = Command(key="TEST", description="Test Command", action=action)
assert cmd.key == "TEST"
assert cmd.description == "Test Command"
assert cmd.action == action
result = await cmd()
assert result == "ok"
assert cmd.result == "ok"
def test_command_str():
"""Test if Command string representation is correct."""
action = Action("test_action", dummy_action)
cmd = Command(key="TEST", description="Test Command", action=action)
print(cmd)
assert (
str(cmd)
== "Command(key='TEST', description='Test Command' action='Action(name='test_action', action=dummy_action, args=(), kwargs={}, retry=False, rollback=False)')"
)
def test_enable_retry():
"""Command should enable retry if action is an Action and retry is set to True."""
cmd = Command(
key="A",
description="Retry action",
action=Action(
name="retry_action",
action=lambda: 42,
),
retry=True,
)
assert cmd.retry is True
assert cmd.action.retry_policy.enabled is True
def test_enable_retry_with_retry_policy():
"""Command should enable retry if action is an Action and retry_policy is set."""
retry_policy = RetryPolicy(
max_retries=3,
delay=1,
backoff=2,
enabled=True,
)
cmd = Command(
key="B",
description="Retry action with policy",
action=Action(
name="retry_action_with_policy",
action=lambda: 42,
),
retry_policy=retry_policy,
)
assert cmd.action.retry_policy.enabled is True
assert cmd.action.retry_policy == retry_policy
def test_enable_retry_not_action():
"""Command should not enable retry if action is not an Action."""
cmd = Command(
key="C",
description="Retry action",
action=DummyInputAction(
name="dummy_input_action",
),
retry=True,
)
assert cmd.retry is True
with pytest.raises(Exception) as exc_info:
assert cmd.action.retry_policy.enabled is False
assert "'DummyInputAction' object has no attribute 'retry_policy'" in str(
exc_info.value
)
def test_chain_retry_all():
"""retry_all should retry all Actions inside a ChainedAction recursively."""
chain = ChainedAction(
name="ChainWithRetry",
actions=[
Action(name="action1", action=lambda: 1),
Action(name="action2", action=lambda: 2),
],
)
cmd = Command(
key="D",
description="Chain with retry",
action=chain,
retry_all=True,
)
assert cmd.retry_all is True
assert cmd.retry_policy.enabled is True
assert chain.actions[0].retry_policy.enabled is True
assert chain.actions[1].retry_policy.enabled is True
def test_chain_retry_all_not_base_action():
"""retry_all should not be set if action is not a ChainedAction."""
cmd = Command(
key="E",
description="Chain with retry",
action=DummyInputAction(
name="dummy_input_action",
),
retry_all=True,
)
assert cmd.retry_all is True
with pytest.raises(Exception) as exc_info:
assert cmd.action.retry_policy.enabled is False
assert "'DummyInputAction' object has no attribute 'retry_policy'" in str(
exc_info.value
)
@pytest.mark.asyncio
async def test_command_exception_handling():
"""Test if Command handles exceptions correctly."""
async def bad_action():
raise ZeroDivisionError("This is a test exception")
cmd = Command(key="TEST", description="Test Command", action=bad_action)
with pytest.raises(ZeroDivisionError):
await cmd()
assert cmd.result is None
assert isinstance(cmd._context.exception, ZeroDivisionError)
def test_command_bad_action():
"""Test if Command raises an exception when action is not callable."""
with pytest.raises(TypeError) as exc_info:
Command(key="TEST", description="Test Command", action="not_callable")
assert str(exc_info.value) == "Action must be a callable or an instance of BaseAction"
def test_command_bad_options_manager():
"""Test if Command raises an exception when options_manager is not a dict or callable."""
with pytest.raises(ValidationError) as exc_info:
Command(
key="TEST",
description="Test Command",
action=dummy_action,
options_manager="not_a_dict_or_callable",
)
assert "Input should be an instance of OptionsManager" in str(exc_info.value)
@pytest.mark.asyncio
async def test_resolve_args_uses_custom_parser_and_splits_string_input() -> None:
seen: list[list[str]] = []
def custom_parser(tokens: list[str]):
seen.append(tokens)
return (("parsed",), {"tokens": tokens}, {"summary": True})
command = make_command(custom_parser=custom_parser)
args, kwargs, execution_args = await command.resolve_args("--name 'Ada Lovelace'")
assert seen == [["--name", "Ada Lovelace"]]
assert args == ("parsed",)
assert kwargs == {"tokens": ["--name", "Ada Lovelace"]}
assert execution_args == {"summary": True}
@pytest.mark.asyncio
async def test_resolve_args_rejects_non_callable_custom_parser() -> None:
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
command.custom_parser = object()
with pytest.raises(NotAFalyxError, match="custom_parser must be a callable"):
await command.resolve_args([])
@pytest.mark.asyncio
async def test_resolve_args_wraps_bad_shell_input_for_custom_parser() -> None:
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
with pytest.raises(CommandArgumentError, match="Failed to parse arguments"):
await command.resolve_args("'unterminated")
@pytest.mark.asyncio
async def test_resolve_args_wraps_bad_shell_input_for_command_argument_parser() -> None:
command = make_command()
with pytest.raises(CommandArgumentError, match="Failed to parse arguments"):
await command.resolve_args("'unterminated")
@pytest.mark.asyncio
async def test_resolve_args_rejects_missing_parser_when_no_custom_parser_exists() -> None:
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
command.custom_parser = None
command.arg_parser = None
with pytest.raises(NotAFalyxError, match="Command has no parser configured"):
await command.resolve_args([])
@pytest.mark.asyncio
async def test_resolve_args_rejects_invalid_arg_parser_instance() -> None:
command = make_command()
command.arg_parser = object()
with pytest.raises(NotAFalyxError, match="arg_parser must be an instance"):
await command.resolve_args([])
@pytest.mark.asyncio
async def test_explicit_argument_definitions_are_added_to_default_parser() -> None:
command = make_command(
arguments=[
{
"flags": ("target",),
"help": "Deployment target",
},
{
"flags": ("--region",),
"default": "us-east",
},
]
)
args, kwargs, execution_args = await command.resolve_args(
["api", "--region", "us-west"]
)
assert args == ("api",)
assert kwargs == {"region": "us-west"}
assert execution_args == {}
@pytest.mark.asyncio
async def test_argument_config_callback_configures_existing_parser() -> None:
def configure(parser: CommandArgumentParser) -> None:
parser.add_argument("--region", default="us-east")
command = make_command(argument_config=configure)
args, kwargs, execution_args = await command.resolve_args(["--region", "us-west"])
assert args == ()
assert kwargs == {"region": "us-west"}
assert execution_args == {}
def test_base_action_inference_merges_action_metadata() -> None:
def deploy(region: str) -> None:
return None
action = FakeBaseAction(
infer_target=deploy,
metadata={"region": {"help": "Region from action metadata"}},
)
command = Command.build(
key="D",
description="Deploy command",
action=action,
auto_args=True,
)
assert command.arg_metadata["region"] == {"help": "Region from action metadata"}
assert isinstance(command.arg_parser, CommandArgumentParser)
assert "region" in command.arg_parser._positional
def test_build_validates_parser_runtime_dependencies_and_retry_policy() -> None:
with pytest.raises(NotAFalyxError, match="arg_parser"):
make_command(arg_parser=object())
with pytest.raises(NotAFalyxError, match="options_manager"):
make_command(options_manager=object())
with pytest.raises(InvalidHookError, match="HookManager"):
make_command(hooks=object())
with pytest.raises(NotAFalyxError, match="retry_policy"):
make_command(retry_policy=object())
def test_build_normalizes_execution_options_and_registers_hook_lists() -> None:
async def before(_context) -> None:
return None
async def success(_context) -> None:
return None
async def error(_context) -> None:
return None
async def after(_context) -> None:
return None
async def teardown(_context) -> None:
return None
command = make_command(
execution_options=["summary", ExecutionOption.CONFIRM],
before_hooks=[before],
success_hooks=[success],
error_hooks=[error],
after_hooks=[after],
teardown_hooks=[teardown],
spinner=True,
)
assert ExecutionOption.SUMMARY in command.execution_options
assert ExecutionOption.CONFIRM in command.execution_options
assert before in command.hooks._hooks[HookType.BEFORE]
assert success in command.hooks._hooks[HookType.ON_SUCCESS]
assert error in command.hooks._hooks[HookType.ON_ERROR]
assert after in command.hooks._hooks[HookType.AFTER]
assert teardown in command.hooks._hooks[HookType.ON_TEARDOWN]
assert command.hooks._hooks[HookType.BEFORE]
assert command.hooks._hooks[HookType.ON_TEARDOWN]
def test_model_post_init_warns_for_retry_flags_on_plain_callable(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING):
make_command(retry=True, retry_all=True)
assert "Retry requested" in caplog.text
assert "Retry all requested" in caplog.text
def test_retry_all_for_base_action_enables_policy_recursively(
monkeypatch: pytest.MonkeyPatch,
) -> None:
action = FakeBaseAction()
calls: list[tuple[BaseAction, RetryPolicy]] = []
def fake_enable_retries_recursively(
base_action: BaseAction, policy: RetryPolicy
) -> None:
calls.append((base_action, policy))
monkeypatch.setattr(
command_module,
"enable_retries_recursively",
fake_enable_retries_recursively,
)
command = Command.build(
key="D",
description="Deploy command",
action=action,
retry_all=True,
auto_args=False,
)
assert command.retry_policy.enabled is True
assert calls == [(action, command.retry_policy)]
def test_logging_hooks_are_registered_on_base_action() -> None:
action = FakeBaseAction()
Command.build(
key="D",
description="Deploy command",
action=action,
logging_hooks=True,
auto_args=False,
)
assert any(action.hooks._hooks.values())
def test_ignore_in_history_is_copied_to_base_action() -> None:
action = FakeBaseAction()
Command.build(
key="D",
description="Deploy command",
action=action,
ignore_in_history=True,
auto_args=False,
)
assert action.ignore_in_history is True
def test_retry_flag_enables_retry_on_action_instance() -> None:
action = Action("DeployAction", lambda: "ok")
Command.build(
key="D",
description="Deploy command",
action=action,
retry=True,
auto_args=False,
)
assert action.retry_policy.enabled is True
def test_confirmation_prompt_uses_custom_message() -> None:
command = make_command(confirm_message="Ship it?")
assert list(command._confirmation_prompt) == [("class:confirm", "Ship it?")]
def test_confirmation_prompt_describes_default_callable_with_static_inputs() -> None:
def deploy() -> str:
return "ok"
command = Command.build(
key="D",
description="Deploy command",
action=deploy,
args=("api",),
kwargs={"region": "us-east"},
auto_args=False,
)
plain_text = formatted_plain_text(command._confirmation_prompt)
assert "Confirm execution of" in plain_text
assert "D" in plain_text
assert "Deploy command" in plain_text
assert "calls" in plain_text
assert "args=('api',)" in plain_text
assert "kwargs={'region': 'us-east'}" in plain_text
def test_confirmation_prompt_uses_base_action_name() -> None:
command = Command.build(
key="D",
description="Deploy command",
action=FakeBaseAction("DeployAction"),
auto_args=False,
)
assert "DeployAction" in formatted_plain_text(command._confirmation_prompt)
@pytest.mark.asyncio
async def test_confirmation_cancel_previews_then_raises_cancel_signal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
command = make_command(confirm=True, preview_before_confirm=True)
previewed: list[str] = []
confirmed_prompts: list[Any] = []
async def fake_preview(self: Command) -> None:
previewed.append(self.key)
async def fake_confirm(prompt) -> bool:
confirmed_prompts.append(prompt)
return False
monkeypatch.setattr(Command, "preview", fake_preview)
monkeypatch.setattr(command_module, "confirm_async", fake_confirm)
with pytest.raises(CancelSignal, match="Cancelled by confirmation"):
await command()
assert previewed == ["D"]
assert confirmed_prompts
@pytest.mark.asyncio
async def test_confirmation_accepts_and_executes_action(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[str] = []
async def fake_confirm(_prompt) -> bool:
return True
def action() -> str:
calls.append("ran")
return "done"
monkeypatch.setattr(command_module, "confirm_async", fake_confirm)
command = Command.build(
key="D",
description="Deploy command",
action=action,
confirm=True,
preview_before_confirm=False,
auto_args=False,
)
assert await command() == "done"
assert calls == ["ran"]
def test_get_option_returns_default_when_no_options_manager_is_available() -> None:
command = make_command()
command.options_manager = None
assert command.get_option("missing", "fallback") == "fallback"
def test_primary_alias_falls_back_to_command_key() -> None:
assert make_command(aliases=[]).primary_alias == "D"
def test_usage_reports_no_arguments_when_parser_is_absent() -> None:
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
assert command.usage == "No arguments defined."
def test_usage_delegates_to_arg_parser_when_available() -> None:
command = make_command(aliases=["deploy"])
assert "D" in command.usage
assert "deploy" in command.usage
def test_help_signature_full_mode_includes_help_text_and_tags() -> None:
command = make_command(help_text="Detailed deploy help", tags=["deploy", "cloud"])
usage, description, tags = command.help_signature
assert "D" in usage
assert "Detailed deploy help" in description
assert "deploy, cloud" in tags
def test_help_signature_simple_mode_uses_key_and_aliases() -> None:
command = make_command(
aliases=["deploy"],
help_text="Detailed deploy help",
simple_help_signature=True,
)
usage, description, tags = command.help_signature
assert "D" in usage
assert "deploy" in usage
assert "Detailed deploy help" in description
assert tags == ""
def test_log_summary_delegates_to_existing_context() -> None:
command = make_command()
calls: list[str] = []
command._context = SimpleNamespace(log_summary=lambda: calls.append("logged"))
command.log_summary()
assert calls == ["logged"]
def test_render_usage_prefers_custom_usage(monkeypatch: pytest.MonkeyPatch) -> None:
captured = CaptureConsole()
monkeypatch.setattr(command_module, "console", captured)
command = make_command(custom_usage=lambda: "custom usage")
command.render_usage()
assert captured.printed[0][0] == ("custom usage",)
def test_render_usage_falls_back_to_command_key_without_parser(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured = CaptureConsole()
monkeypatch.setattr(command_module, "console", captured)
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
command.render_usage()
assert captured.printed[0][0] == ("[bold]usage:[/] D",)
def test_render_help_and_tldr_custom_renderers_return_true(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured = CaptureConsole()
monkeypatch.setattr(command_module, "console", captured)
command = make_command(
custom_help=lambda: "custom help",
custom_tldr=lambda: "custom tldr",
)
assert command.render_help() is True
assert command.render_tldr() is True
assert [printed[0][0] for printed in captured.printed] == [
"custom help",
"custom tldr",
]
def test_render_help_and_tldr_return_false_without_parser_or_custom_renderer() -> None:
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
assert command.render_help() is False
assert command.render_tldr() is False
@pytest.mark.asyncio
async def test_preview_renders_plain_callable_details(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured = CaptureConsole()
monkeypatch.setattr(command_module, "console", captured)
def deploy() -> str:
return "ok"
command = Command.build(
key="D",
description="Deploy command",
action=deploy,
args=("api",),
kwargs={"region": "us-east"},
help_text="Preview help",
auto_args=False,
)
await command.preview()
rendered = "\n".join(str(args[0]) for args, _ in captured.printed)
assert "Command:" in rendered
assert "Preview help" in rendered
assert "Would call:" in rendered
assert "args=('api',), kwargs={'region': 'us-east'}" in rendered
@pytest.mark.asyncio
async def test_preview_renders_base_action_tree(monkeypatch: pytest.MonkeyPatch) -> None:
captured = CaptureConsole()
monkeypatch.setattr(command_module, "console", captured)
action = FakeBaseAction("DeployAction")
command = Command.build(
key="D",
description="Deploy command",
action=action,
help_text="Preview help",
auto_args=False,
)
await command.preview()
assert action.preview_calls == 1
assert captured.printed
@pytest.mark.asyncio
async def test_call_merges_static_and_invocation_inputs_and_triggers_hooks() -> None:
events: list[tuple[str, Any]] = []
async def before(context) -> None:
events.append(("before", context.args))
async def success(context) -> None:
events.append(("success", context.result))
async def after(context) -> None:
events.append(("after", context.result))
async def teardown(context) -> None:
events.append(("teardown", context.result))
def action(*args: Any, **kwargs: Any) -> dict[str, Any]:
return {"args": args, "kwargs": kwargs}
command = Command.build(
key="D",
description="Deploy command",
action=action,
args=("static",),
kwargs={"region": "us-east"},
before_hooks=[before],
success_hooks=[success],
after_hooks=[after],
teardown_hooks=[teardown],
auto_args=False,
)
result = await command("runtime", region="us-west")
assert result == {
"args": ("runtime", "static"),
"kwargs": {"region": "us-west"},
}
assert command.result == result
assert events == [
("before", ("runtime", "static")),
("success", result),
("after", result),
("teardown", result),
]
@pytest.mark.asyncio
async def test_call_triggers_error_after_and_teardown_hooks_on_failure() -> None:
events: list[tuple[str, str | None]] = []
async def on_error(context) -> None:
events.append(("error", str(context.exception)))
async def after(context) -> None:
events.append(("after", str(context.exception)))
async def teardown(context) -> None:
events.append(("teardown", str(context.exception)))
def action() -> None:
raise RuntimeError("boom")
command = Command.build(
key="D",
description="Deploy command",
action=action,
error_hooks=[on_error],
after_hooks=[after],
teardown_hooks=[teardown],
auto_args=False,
)
with pytest.raises(RuntimeError, match="boom"):
await command()
assert events == [
("error", "boom"),
("after", "boom"),
("teardown", "boom"),
]
def test_str_includes_command_identity() -> None:
text = str(make_command())
assert "Command(key='D'" in text
assert "Deploy command" in text
def test_clone_with_overrides_clones_parser_hooks_and_base_action() -> None:
action = FakeBaseAction("DeployAction")
async def before(_context) -> None:
return None
command = Command.build(
key="D",
description="Deploy command",
action=action,
aliases=["deploy"],
before_hooks=[before],
auto_args=False,
)
clone = command.clone_with_overrides(
key="P",
description="Promote command",
aliases=["promote"],
)
assert clone.key == "P"
assert clone.description == "Promote command"
assert clone.aliases == ["promote"]
assert clone.action is not command.action
assert isinstance(clone.action, FakeBaseAction)
assert clone.hooks is not command.hooks
assert before in clone.hooks._hooks[HookType.BEFORE]
assert isinstance(clone.arg_parser, CommandArgumentParser)
assert clone.arg_parser.command_key == "P"
def test_clone_with_overrides_can_replace_action_and_execution_options() -> None:
command = make_command(execution_options=["summary"])
def replacement() -> str:
return "replacement"
clone = command.clone_with_overrides(
action=replacement,
execution_options=[ExecutionOption.CONFIRM],
simple_help_signature=True,
)
assert clone.action is not command.action
assert ExecutionOption.CONFIRM in clone.execution_options
assert ExecutionOption.SUMMARY not in clone.execution_options
assert clone.simple_help_signature is True