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:
@@ -1,16 +1,89 @@
|
||||
# test_command.py
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from falyx.action import Action, BaseIOAction, ChainedAction
|
||||
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"
|
||||
|
||||
|
||||
# --- Fixtures ---
|
||||
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()
|
||||
@@ -18,12 +91,10 @@ def clean_registry():
|
||||
er.clear()
|
||||
|
||||
|
||||
# --- Dummy Action ---
|
||||
async def dummy_action():
|
||||
return "ok"
|
||||
|
||||
|
||||
# --- Dummy IO Action ---
|
||||
class DummyInputAction(BaseIOAction):
|
||||
async def _run(self, *args, **kwargs):
|
||||
return "needs input"
|
||||
@@ -32,7 +103,6 @@ class DummyInputAction(BaseIOAction):
|
||||
pass
|
||||
|
||||
|
||||
# --- Tests ---
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_creation():
|
||||
"""Test if Command can be created with a callable."""
|
||||
@@ -185,3 +255,642 @@ def test_command_bad_options_manager():
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user