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
335 lines
10 KiB
Python
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]
|
|
)
|