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

335 lines
10 KiB
Python

import pytest
from falyx.action.action import Action
from falyx.action.action_group import ActionGroup
from falyx.action.chained_action import ChainedAction
from falyx.action.http_action import HTTPAction
from falyx.action.menu_action import MenuAction
from falyx.action.process_action import ProcessAction
from falyx.hook_manager import HookType
from falyx.menu import MenuOption, MenuOptionMap
from falyx.retry import RetryHandler, RetryPolicy
def _retry_hooks(action) -> list:
return [
hook
for hook in action.hooks._hooks[HookType.ON_ERROR]
if isinstance(getattr(hook, "__self__", None), RetryHandler)
]
def _non_retry_error_hooks(action) -> list:
return [
hook
for hook in action.hooks._hooks[HookType.ON_ERROR]
if not isinstance(getattr(hook, "__self__", None), RetryHandler)
]
def _before_hooks(action) -> list:
return list(action.hooks._hooks[HookType.BEFORE])
def test_action_group_clone_recursively_isolates_nested_action_graph():
nested_chain = ChainedAction(
name="nested-chain",
actions=[
Action("step-two", lambda: "two"),
Action("step-three", lambda: "three"),
],
)
original = ActionGroup(
name="group",
actions=[
Action("step-one", lambda: "one"),
nested_chain,
],
)
cloned = original.clone()
assert cloned is not original
assert cloned.actions is not original.actions
assert len(cloned.actions) == len(original.actions)
# Top-level children are cloned.
assert cloned.actions[0] is not original.actions[0]
assert cloned.actions[1] is not original.actions[1]
# Nested action graph is also cloned.
assert isinstance(cloned.actions[1], ChainedAction)
assert cloned.actions[1].actions is not original.actions[1].actions
for cloned_child, original_child in zip(
cloned.actions[1].actions,
original.actions[1].actions,
strict=True,
):
assert cloned_child is not original_child
assert cloned_child.name == original_child.name
# Mutating the clone does not mutate the original.
cloned.actions.append(Action("step-four", lambda: "four"))
assert len(cloned.actions) == 3
assert len(original.actions) == 2
cloned.actions[1].actions.append(Action("step-five", lambda: "five"))
assert len(cloned.actions[1].actions) == 3
assert len(original.actions[1].actions) == 2
def test_menu_action_clone_copies_menu_option_map_and_clones_contained_actions():
menu_options = MenuOptionMap(disable_reserved=True)
menu_options["A"] = MenuOption(
description="Alpha",
action=Action("alpha-action", lambda: "alpha"),
)
original = MenuAction(
name="main-menu",
menu_options=menu_options,
title="Main Menu",
)
cloned = original.clone()
assert cloned is not original
assert cloned.menu_options is not original.menu_options
assert cloned.menu_options["A"] is not original.menu_options["A"]
assert cloned.menu_options["A"].description == original.menu_options["A"].description
# Contained action should also be cloned.
assert cloned.menu_options["A"].action is not original.menu_options["A"].action
assert cloned.menu_options["A"].action.name == original.menu_options["A"].action.name
# Mutating the clone should not affect the original.
cloned.menu_options["A"].description = "Changed"
assert original.menu_options["A"].description == "Alpha"
cloned.menu_options["B"] = MenuOption(
description="Beta",
action=Action("beta-action", lambda: "beta"),
)
assert "B" in cloned.menu_options
assert "B" not in original.menu_options
def test_process_action_clone_does_not_reuse_runtime_only_executor_state():
original = ProcessAction(
name="proc",
action=lambda x: x + 1,
args=(1,),
kwargs={"y": 2},
)
original.executor = object()
cloned = original.clone()
assert cloned is not original
assert cloned.hooks is not original.hooks
assert cloned.args == original.args
assert cloned.kwargs == original.kwargs
assert cloned.executor is not original.executor
def test_http_action_clone_preserves_retry_policy_without_duplicating_spinner_hooks():
retry_policy = RetryPolicy(max_retries=3, delay=1.5, backoff=2.0)
retry_policy.enable_policy()
original = HTTPAction(
name="get-users",
method="GET",
url="https://example.com/api/users",
headers={"Authorization": "Bearer token"},
params={"page": 1},
retry_policy=retry_policy,
spinner=True,
)
before_count = len(original.hooks._hooks[HookType.BEFORE])
teardown_count = len(original.hooks._hooks[HookType.ON_TEARDOWN])
error_count = len(original.hooks._hooks[HookType.ON_ERROR])
cloned = original.clone()
assert cloned is not original
assert cloned.hooks is not original.hooks
assert cloned.retry_policy is not original.retry_policy
assert cloned.retry_policy.enabled is original.retry_policy.enabled
assert cloned.retry_policy.max_retries == original.retry_policy.max_retries
assert cloned.retry_policy.delay == original.retry_policy.delay
assert cloned.retry_policy.backoff == original.retry_policy.backoff
assert len(cloned.hooks._hooks[HookType.BEFORE]) == before_count
assert len(cloned.hooks._hooks[HookType.ON_TEARDOWN]) == teardown_count
assert len(cloned.hooks._hooks[HookType.ON_ERROR]) == error_count
@pytest.mark.asyncio
async def test_action_clone_registers_exactly_one_retry_hook():
async def flaky():
return "ok"
policy = RetryPolicy(max_retries=2, delay=0.1, backoff=2.0)
policy.enable_policy()
original = Action(
"flaky",
flaky,
retry_policy=policy,
)
cloned = original.clone()
original_retry_hooks = _retry_hooks(original)
cloned_retry_hooks = _retry_hooks(cloned)
assert len(original_retry_hooks) == 1
assert len(cloned_retry_hooks) == 1
assert cloned_retry_hooks[0] is not original_retry_hooks[0]
assert getattr(cloned_retry_hooks[0], "__self__", None) is not getattr(
original_retry_hooks[0], "__self__", None
)
def test_action_clone_preserves_non_retry_hooks_without_duplication():
calls = []
async def custom_error_hook(context):
calls.append(context.name)
original = Action("demo", lambda: "ok")
original.hooks.register(HookType.BEFORE, lambda context: None)
original.hooks.register(HookType.ON_ERROR, custom_error_hook)
cloned = original.clone()
assert len(_before_hooks(cloned)) == len(_before_hooks(original))
assert len(_non_retry_error_hooks(cloned)) == len(_non_retry_error_hooks(original))
assert cloned.hooks is not original.hooks
def test_action_clone_copies_retry_policy_without_sharing_it():
policy = RetryPolicy(max_retries=2, delay=0.25, backoff=3.0)
policy.enable_policy()
original = Action(
"demo",
lambda: "ok",
retry_policy=policy,
)
cloned = original.clone()
assert cloned.retry_policy is not original.retry_policy
assert cloned.retry_policy.enabled is original.retry_policy.enabled
assert cloned.retry_policy.max_retries == original.retry_policy.max_retries
assert cloned.retry_policy.delay == original.retry_policy.delay
assert cloned.retry_policy.backoff == original.retry_policy.backoff
cloned.retry_policy.max_retries = 9
assert original.retry_policy.max_retries == 2
@pytest.mark.asyncio
async def test_action_clone_retry_behavior_still_works_independently():
state = {"original": 0, "clone": 0}
async def flaky_original():
if state["original"] == 0:
state["original"] += 1
raise RuntimeError("boom")
return "original-ok"
async def flaky_clone():
if state["clone"] == 0:
state["clone"] += 1
raise RuntimeError("boom")
return "clone-ok"
policy = RetryPolicy(max_retries=1, delay=0.0, backoff=1.0)
policy.enable_policy()
original = Action("orig", flaky_original, retry_policy=policy)
cloned = original.clone()
cloned.action = flaky_clone
original_result = await original()
cloned_result = await cloned()
assert original_result == "original-ok"
assert cloned_result == "clone-ok"
assert state["original"] == 1
assert state["clone"] == 1
def test_http_action_clone_registers_exactly_one_retry_hook():
policy = RetryPolicy(max_retries=2, delay=0.1, backoff=2.0)
policy.enable_policy()
original = HTTPAction(
name="get-users",
method="GET",
url="https://example.com/api/users",
retry_policy=policy,
spinner=True,
)
cloned = original.clone()
original_retry_hooks = _retry_hooks(original)
cloned_retry_hooks = _retry_hooks(cloned)
assert len(original_retry_hooks) == 1
assert len(cloned_retry_hooks) == 1
assert cloned_retry_hooks[0] is not original_retry_hooks[0]
def test_http_action_clone_copies_retry_policy_without_sharing_it():
policy = RetryPolicy(max_retries=3, delay=1.5, backoff=2.0)
policy.enable_policy()
original = HTTPAction(
name="get-users",
method="GET",
url="https://example.com/api/users",
retry_policy=policy,
)
cloned = original.clone()
assert cloned.retry_policy is not original.retry_policy
assert cloned.retry_policy.enabled is original.retry_policy.enabled
assert cloned.retry_policy.max_retries == original.retry_policy.max_retries
assert cloned.retry_policy.delay == original.retry_policy.delay
assert cloned.retry_policy.backoff == original.retry_policy.backoff
def test_http_action_clone_does_not_duplicate_spinner_hooks():
policy = RetryPolicy(max_retries=1, delay=0.0, backoff=1.0)
policy.enable_policy()
original = HTTPAction(
name="get-users",
method="GET",
url="https://example.com/api/users",
retry_policy=policy,
spinner=True,
)
cloned = original.clone()
assert len(cloned.hooks._hooks[HookType.BEFORE]) == len(
original.hooks._hooks[HookType.BEFORE]
)
assert len(cloned.hooks._hooks[HookType.ON_TEARDOWN]) == len(
original.hooks._hooks[HookType.ON_TEARDOWN]
)