Hide ioactions, Add doc strings, Add tests

This commit is contained in:
2025-04-29 16:34:20 -04:00
parent e9fdd9cec6
commit 80de941335
32 changed files with 1535 additions and 284 deletions

View File

@ -0,0 +1,78 @@
import pytest
from falyx.action import Action, ChainedAction, ActionGroup, FallbackAction
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType
from falyx.context import ExecutionContext
asyncio_default_fixture_loop_scope = "function"
# --- Helpers ---
async def capturing_hook(context: ExecutionContext):
context.extra["hook_triggered"] = True
# --- Fixtures ---
@pytest.fixture(autouse=True)
def clean_registry():
er.clear()
yield
er.clear()
@pytest.mark.asyncio
async def test_action_callable():
"""Test if Action can be created with a callable."""
action = Action("test_action", lambda: "Hello, World!")
result = await action()
assert result == "Hello, World!"
@pytest.mark.asyncio
async def test_action_async_callable():
"""Test if Action can be created with an async callable."""
async def async_callable():
return "Hello, World!"
action = Action("test_action", async_callable)
result = await action()
assert result == "Hello, World!"
@pytest.mark.asyncio
async def test_action_non_callable():
"""Test if Action raises an error when created with a non-callable."""
with pytest.raises(TypeError):
Action("test_action", 42)
@pytest.mark.asyncio
@pytest.mark.parametrize("return_list, expected", [
(True, [1, 2, 3]),
(False, 3),
])
async def test_chained_action_return_modes(return_list, expected):
chain = ChainedAction(
name="Simple Chain",
actions=[
Action(name="one", action=lambda: 1),
Action(name="two", action=lambda: 2),
Action(name="three", action=lambda: 3),
],
return_list=return_list
)
result = await chain()
assert result == expected
@pytest.mark.asyncio
@pytest.mark.parametrize("return_list, auto_inject, expected", [
(True, True, [1, 2, 3]),
(True, False, [1, 2, 3]),
(False, True, 3),
(False, False, 3),
])
async def test_chained_action_literals(return_list, auto_inject, expected):
chain = ChainedAction(
name="Literal Chain",
actions=[1, 2, 3],
return_list=return_list,
auto_inject=auto_inject,
)
result = await chain()
assert result == expected

View File

View File

View File

@ -0,0 +1,40 @@
import pickle
import warnings
import pytest
from falyx.action import ProcessAction
from falyx.execution_registry import ExecutionRegistry as er
# --- Fixtures ---
@pytest.fixture(autouse=True)
def clean_registry():
er.clear()
yield
er.clear()
def slow_add(x, y):
return x + y
# --- Tests ---
@pytest.mark.asyncio
async def test_process_action_executes_correctly():
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
action = ProcessAction(name="proc", func=slow_add, args=(2, 3))
result = await action()
assert result == 5
unpickleable = lambda x: x + 1
@pytest.mark.asyncio
async def test_process_action_rejects_unpickleable():
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
action = ProcessAction(name="proc_fail", func=unpickleable, args=(2,))
with pytest.raises(pickle.PicklingError, match="Can't pickle"):
await action()

View File

@ -0,0 +1,30 @@
import pytest
from falyx.action import Action, ChainedAction, ActionGroup, FallbackAction
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType
from falyx.context import ExecutionContext
asyncio_default_fixture_loop_scope = "function"
# --- Helpers ---
async def capturing_hook(context: ExecutionContext):
context.extra["hook_triggered"] = True
# --- Fixtures ---
@pytest.fixture
def hook_manager():
hm = HookManager()
hm.register(HookType.BEFORE, capturing_hook)
return hm
@pytest.fixture(autouse=True)
def clean_registry():
er.clear()
yield
er.clear()
def test_action_enable_retry():
"""Test if Action can be created with retry=True."""
action = Action("test_action", lambda: "Hello, World!", retry=True)
assert action.retry_policy.enabled is True

View File

@ -1,28 +1,17 @@
import pytest
import asyncio
import pickle
import warnings
from falyx.action import Action, ChainedAction, ActionGroup, ProcessAction
from falyx.action import Action, ChainedAction, ActionGroup, FallbackAction
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType
from falyx.context import ExecutionContext, ResultsContext
from falyx.context import ExecutionContext
asyncio_default_fixture_loop_scope = "function"
# --- Helpers ---
async def dummy_action(x: int = 0) -> int:
return x + 1
async def capturing_hook(context: ExecutionContext):
context.extra["hook_triggered"] = True
# --- Fixtures ---
@pytest.fixture
def sample_action():
return Action(name="increment", action=dummy_action, kwargs={"x": 5})
@pytest.fixture
def hook_manager():
hm = HookManager()
@ -38,15 +27,18 @@ def clean_registry():
# --- Tests ---
@pytest.mark.asyncio
async def test_action_runs_correctly(sample_action):
async def test_action_runs_correctly():
async def dummy_action(x: int = 0) -> int: return x + 1
sample_action = Action(name="increment", action=dummy_action, kwargs={"x": 5})
result = await sample_action()
assert result == 6
@pytest.mark.asyncio
async def test_action_hook_lifecycle(hook_manager):
async def a1(): return 42
action = Action(
name="hooked",
action=lambda: 42,
action=a1,
hooks=hook_manager
)
@ -58,21 +50,30 @@ async def test_action_hook_lifecycle(hook_manager):
@pytest.mark.asyncio
async def test_chained_action_with_result_injection():
async def a1(): return 1
async def a2(last_result): return last_result + 5
async def a3(last_result): return last_result * 2
actions = [
Action(name="start", action=lambda: 1),
Action(name="add_last", action=lambda last_result: last_result + 5, inject_last_result=True),
Action(name="multiply", action=lambda last_result: last_result * 2, inject_last_result=True)
Action(name="start", action=a1),
Action(name="add_last", action=a2, inject_last_result=True),
Action(name="multiply", action=a3, inject_last_result=True)
]
chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True)
chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True, return_list=True)
result = await chain()
assert result == [1, 6, 12]
chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True)
result = await chain()
assert result == 12
@pytest.mark.asyncio
async def test_action_group_runs_in_parallel():
async def a1(): return 1
async def a2(): return 2
async def a3(): return 3
actions = [
Action(name="a", action=lambda: 1),
Action(name="b", action=lambda: 2),
Action(name="c", action=lambda: 3),
Action(name="a", action=a1),
Action(name="b", action=a2),
Action(name="c", action=a3),
]
group = ActionGroup(name="parallel", actions=actions)
result = await group()
@ -81,39 +82,48 @@ async def test_action_group_runs_in_parallel():
@pytest.mark.asyncio
async def test_chained_action_inject_from_action():
async def a1(last_result): return last_result + 10
async def a2(last_result): return last_result + 5
inner_chain = ChainedAction(
name="inner_chain",
actions=[
Action(name="inner_first", action=lambda last_result: last_result + 10, inject_last_result=True),
Action(name="inner_second", action=lambda last_result: last_result + 5, inject_last_result=True),
]
Action(name="inner_first", action=a1, inject_last_result=True),
Action(name="inner_second", action=a2, inject_last_result=True),
],
return_list=True,
)
async def a3(): return 1
async def a4(last_result): return last_result + 2
actions = [
Action(name="first", action=lambda: 1),
Action(name="second", action=lambda last_result: last_result + 2, inject_last_result=True),
Action(name="first", action=a3),
Action(name="second", action=a4, inject_last_result=True),
inner_chain,
]
outer_chain = ChainedAction(name="test_chain", actions=actions)
outer_chain = ChainedAction(name="test_chain", actions=actions, return_list=True)
result = await outer_chain()
assert result == [1, 3, [13, 18]]
@pytest.mark.asyncio
async def test_chained_action_with_group():
async def a1(last_result): return last_result + 1
async def a2(last_result): return last_result + 2
async def a3(): return 3
group = ActionGroup(
name="group",
actions=[
Action(name="a", action=lambda last_result: last_result + 1, inject_last_result=True),
Action(name="b", action=lambda last_result: last_result + 2, inject_last_result=True),
Action(name="c", action=lambda: 3),
Action(name="a", action=a1, inject_last_result=True),
Action(name="b", action=a2, inject_last_result=True),
Action(name="c", action=a3),
]
)
async def a4(): return 1
async def a5(last_result): return last_result + 2
actions = [
Action(name="first", action=lambda: 1),
Action(name="second", action=lambda last_result: last_result + 2, inject_last_result=True),
Action(name="first", action=a4),
Action(name="second", action=a5, inject_last_result=True),
group,
]
chain = ChainedAction(name="test_chain", actions=actions)
chain = ChainedAction(name="test_chain", actions=actions, return_list=True)
result = await chain()
assert result == [1, 3, [("a", 4), ("b", 5), ("c", 3)]]
@ -161,37 +171,21 @@ async def test_chained_action_rollback_on_failure():
assert rollback_called == ["rolled back"]
def slow_add(x, y):
return x + y
@pytest.mark.asyncio
async def test_process_action_executes_correctly():
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
action = ProcessAction(name="proc", func=slow_add, args=(2, 3))
result = await action()
assert result == 5
unpickleable = lambda x: x + 1
@pytest.mark.asyncio
async def test_process_action_rejects_unpickleable():
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
action = ProcessAction(name="proc_fail", func=unpickleable, args=(2,))
with pytest.raises(pickle.PicklingError, match="Can't pickle"):
await action()
@pytest.mark.asyncio
async def test_register_hooks_recursively_propagates():
hook = lambda ctx: ctx.extra.update({"test_marker": True})
def hook(context):
context.extra.update({"test_marker": True})
chain = ChainedAction(name="chain", actions=[
Action(name="a", action=lambda: 1),
Action(name="b", action=lambda: 2),
])
async def a1(): return 1
async def a2(): return 2
chain = ChainedAction(
name="chain",
actions=[
Action(name="a", action=a1),
Action(name="b", action=a2),
],
)
chain.register_hooks_recursively(HookType.BEFORE, hook)
await chain()
@ -217,14 +211,255 @@ async def test_action_hook_recovers_error():
@pytest.mark.asyncio
async def test_action_group_injects_last_result():
async def a1(last_result): return last_result + 10
async def a2(last_result): return last_result + 20
group = ActionGroup(name="group", actions=[
Action(name="g1", action=lambda last_result: last_result + 10, inject_last_result=True),
Action(name="g2", action=lambda last_result: last_result + 20, inject_last_result=True),
])
chain = ChainedAction(name="with_group", actions=[
Action(name="first", action=lambda: 5),
group,
Action(name="g1", action=a1, inject_last_result=True),
Action(name="g2", action=a2, inject_last_result=True),
])
async def a3(): return 5
chain = ChainedAction(
name="with_group",
actions=[
Action(name="first", action=a3),
group,
],
return_list=True,
)
result = await chain()
result_dict = dict(result[1])
assert result_dict == {"g1": 15, "g2": 25}
@pytest.mark.asyncio
async def test_action_inject_last_result():
async def a1(): return 1
async def a2(last_result): return last_result + 1
a1 = Action(name="a1", action=a1)
a2 = Action(name="a2", action=a2, inject_last_result=True)
chain = ChainedAction(name="chain", actions=[a1, a2])
result = await chain()
assert result == 2
@pytest.mark.asyncio
async def test_action_inject_last_result_fail():
async def a1(): return 1
async def a2(last_result): return last_result + 1
a1 = Action(name="a1", action=a1)
a2 = Action(name="a2", action=a2)
chain = ChainedAction(name="chain", actions=[a1, a2])
with pytest.raises(TypeError) as exc_info:
await chain()
assert "last_result" in str(exc_info.value)
@pytest.mark.asyncio
async def test_chained_action_auto_inject():
async def a1(): return 1
async def a2(last_result): return last_result + 2
a1 = Action(name="a1", action=a1)
a2 = Action(name="a2", action=a2)
chain = ChainedAction(name="chain", actions=[a1, a2], auto_inject=True, return_list=True)
result = await chain()
assert result == [1, 3] # a2 receives last_result=1
@pytest.mark.asyncio
async def test_chained_action_no_auto_inject():
async def a1(): return 1
async def a2(): return 2
a1 = Action(name="a1", action=a1)
a2 = Action(name="a2", action=a2)
chain = ChainedAction(name="no_inject", actions=[a1, a2], auto_inject=False, return_list=True)
result = await chain()
assert result == [1, 2] # a2 does not receive 1
@pytest.mark.asyncio
async def test_chained_action_auto_inject_after_first():
async def a1(): return 1
async def a2(last_result): return last_result + 1
a1 = Action(name="a1", action=a1)
a2 = Action(name="a2", action=a2)
chain = ChainedAction(name="auto_inject", actions=[a1, a2], auto_inject=True)
result = await chain()
assert result == 2 # a2 receives last_result=1
@pytest.mark.asyncio
async def test_chained_action_with_literal_input():
async def a1(last_result): return last_result + " world"
a1 = Action(name="a1", action=a1)
chain = ChainedAction(name="literal_inject", actions=["hello", a1], auto_inject=True)
result = await chain()
assert result == "hello world" # "hello" is injected as last_result
@pytest.mark.asyncio
async def test_chained_action_manual_inject_override():
async def a1(): return 10
async def a2(last_result): return last_result * 2
a1 = Action(name="a1", action=a1)
a2 = Action(name="a2", action=a2, inject_last_result=True)
chain = ChainedAction(name="manual_override", actions=[a1, a2], auto_inject=False)
result = await chain()
assert result == 20 # Even without auto_inject, a2 still gets last_result
@pytest.mark.asyncio
async def test_chained_action_with_mid_literal():
async def fetch_data():
# Imagine this is some dynamic API call
return None # Simulate failure or missing data
async def validate_data(last_result):
if last_result is None:
raise ValueError("Missing data!")
return last_result
async def enrich_data(last_result):
return f"Enriched: {last_result}"
chain = ChainedAction(
name="fallback_pipeline",
actions=[
Action(name="FetchData", action=fetch_data),
"default_value", # <-- literal fallback injected mid-chain
Action(name="ValidateData", action=validate_data),
Action(name="EnrichData", action=enrich_data),
],
auto_inject=True,
return_list=True,
)
result = await chain()
assert result == [None, "default_value", "default_value", "Enriched: default_value"]
@pytest.mark.asyncio
async def test_chained_action_with_mid_fallback():
async def fetch_data():
# Imagine this is some dynamic API call
return None # Simulate failure or missing data
async def validate_data(last_result):
if last_result is None:
raise ValueError("Missing data!")
return last_result
async def enrich_data(last_result):
return f"Enriched: {last_result}"
chain = ChainedAction(
name="fallback_pipeline",
actions=[
Action(name="FetchData", action=fetch_data),
FallbackAction(fallback="default_value"),
Action(name="ValidateData", action=validate_data),
Action(name="EnrichData", action=enrich_data),
],
auto_inject=True,
return_list=True,
)
result = await chain()
assert result == [None, "default_value", "default_value", "Enriched: default_value"]
@pytest.mark.asyncio
async def test_chained_action_with_success_mid_fallback():
async def fetch_data():
# Imagine this is some dynamic API call
return "Result" # Simulate success
async def validate_data(last_result):
if last_result is None:
raise ValueError("Missing data!")
return last_result
async def enrich_data(last_result):
return f"Enriched: {last_result}"
chain = ChainedAction(
name="fallback_pipeline",
actions=[
Action(name="FetchData", action=fetch_data),
FallbackAction(fallback="default_value"),
Action(name="ValidateData", action=validate_data),
Action(name="EnrichData", action=enrich_data),
],
auto_inject=True,
return_list=True,
)
result = await chain()
assert result == ["Result", "Result", "Result", "Enriched: Result"]
@pytest.mark.asyncio
async def test_action_group_partial_failure():
async def succeed(): return "ok"
async def fail(): raise ValueError("oops")
group = ActionGroup(name="partial_group", actions=[
Action(name="succeed_action", action=succeed),
Action(name="fail_action", action=fail),
])
with pytest.raises(Exception) as exc_info:
await group()
assert er.get_by_name("succeed_action")[0].result == "ok"
assert er.get_by_name("fail_action")[0].exception is not None
assert "fail_action" in str(exc_info.value)
@pytest.mark.asyncio
async def test_chained_action_with_nested_group():
async def g1(last_result): return last_result + "10"
async def g2(last_result): return last_result + "20"
group = ActionGroup(
name="nested_group",
actions=[
Action(name="g1", action=g1, inject_last_result=True),
Action(name="g2", action=g2, inject_last_result=True),
],
)
chain = ChainedAction(
name="chain_with_group",
actions=[
"start",
group,
],
auto_inject=True,
return_list=True,
)
result = await chain()
# "start" -> group both receive "start" as last_result
assert result[0] == "start"
assert dict(result[1]) == {"g1": "start10", "g2": "start20"} # Assuming string concatenation for example
@pytest.mark.asyncio
async def test_chained_action_double_fallback():
async def fetch_data(last_result=None):
raise ValueError("No data!") # Simulate failure
async def validate_data(last_result):
if last_result is None:
raise ValueError("No data!")
return last_result
async def enrich(last_result):
return f"Enriched: {last_result}"
chain = ChainedAction(
name="fallback_chain",
actions=[
Action(name="Fetch", action=fetch_data),
FallbackAction(fallback="default1"),
Action(name="Validate", action=validate_data),
Action(name="Fetch", action=fetch_data),
FallbackAction(fallback="default2"),
Action(name="Enrich", action=enrich),
],
auto_inject=True,
return_list=True,
)
result = await chain()
assert result == [None, "default1", "default1", None, "default2", "Enriched: default2"]

View File

@ -0,0 +1,27 @@
import pytest
from falyx.action import ChainedAction
from falyx.exceptions import EmptyChainError
@pytest.mark.asyncio
async def test_chained_action_raises_empty_chain_error_when_no_actions():
"""A ChainedAction with no actions should raise an EmptyChainError immediately."""
chain = ChainedAction(name="empty_chain", actions=[])
with pytest.raises(EmptyChainError) as exc_info:
await chain()
assert "No actions to execute." in str(exc_info.value)
assert "empty_chain" in str(exc_info.value)
@pytest.mark.asyncio
async def test_chained_action_raises_empty_chain_error_when_actions_are_none():
"""A ChainedAction with None as actions should raise an EmptyChainError immediately."""
chain = ChainedAction(name="none_chain", actions=None)
with pytest.raises(EmptyChainError) as exc_info:
await chain()
assert "No actions to execute." in str(exc_info.value)
assert "none_chain" in str(exc_info.value)

223
tests/test_command.py Normal file
View File

@ -0,0 +1,223 @@
# test_command.py
import pytest
from falyx.action import Action, ActionGroup, ChainedAction
from falyx.command import Command
from falyx.io_action import BaseIOAction
from falyx.execution_registry import ExecutionRegistry as er
from falyx.retry import RetryPolicy
asyncio_default_fixture_loop_scope = "function"
# --- Fixtures ---
@pytest.fixture(autouse=True)
def clean_registry():
er.clear()
yield
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"
async def preview(self, parent=None):
pass
# --- Tests ---
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
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
)
assert str(cmd) == "Command(key='TEST', description='Test Command' action='Action(name=test_action, action=dummy_action)')"
@pytest.mark.parametrize(
"action_factory, expected_requires_input",
[
(lambda: Action(name="normal", action=dummy_action), False),
(lambda: DummyInputAction(name="io"), True),
(lambda: ChainedAction(name="chain", actions=[DummyInputAction(name="io")]), True),
(lambda: ActionGroup(name="group", actions=[DummyInputAction(name="io")]), True),
]
)
def test_command_requires_input_detection(action_factory, expected_requires_input):
action = action_factory()
cmd = Command(
key="TEST",
description="Test Command",
action=action
)
assert cmd.requires_input == expected_requires_input
if expected_requires_input:
assert cmd.hidden is True
else:
assert cmd.hidden is False
def test_requires_input_flag_detected_for_baseioaction():
"""Command should automatically detect requires_input=True for BaseIOAction."""
cmd = Command(
key="X",
description="Echo input",
action=DummyInputAction(name="dummy"),
)
assert cmd.requires_input is True
assert cmd.hidden is True
def test_requires_input_manual_override():
"""Command manually set requires_input=False should not auto-hide."""
cmd = Command(
key="Y",
description="Custom input command",
action=DummyInputAction(name="dummy"),
requires_input=False,
)
assert cmd.requires_input is False
assert cmd.hidden is False
def test_default_command_does_not_require_input():
"""Normal Command without IO Action should not require input."""
cmd = Command(
key="Z",
description="Simple action",
action=lambda: 42,
)
assert cmd.requires_input is False
assert cmd.hidden is False
def test_chain_requires_input():
"""If first action in a chain requires input, the command should require input."""
chain = ChainedAction(
name="ChainWithInput",
actions=[
DummyInputAction(name="dummy"),
Action(name="action1", action=lambda: 1),
],
)
cmd = Command(
key="A",
description="Chain with input",
action=chain,
)
assert cmd.requires_input is True
assert cmd.hidden is True
def test_group_requires_input():
"""If any action in a group requires input, the command should require input."""
group = ActionGroup(
name="GroupWithInput",
actions=[
Action(name="action1", action=lambda: 1),
DummyInputAction(name="dummy"),
],
)
cmd = Command(
key="B",
description="Group with input",
action=group,
)
assert cmd.requires_input is True
assert cmd.hidden is True
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,
retry=True,
)
assert cmd.retry is True
with pytest.raises(Exception) as exc_info:
assert cmd.action.retry_policy.enabled is False
assert "'function' 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,
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 "'function' object has no attribute 'retry_policy'" in str(exc_info.value)

View File

@ -0,0 +1,200 @@
import pytest
import asyncio
from falyx.action import Action, ChainedAction, ActionGroup, FallbackAction
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType
from falyx.context import ExecutionContext
# --- Fixtures ---
@pytest.fixture(autouse=True)
def clean_registry():
er.clear()
yield
er.clear()
# --- Stress Tests ---
@pytest.mark.asyncio
async def test_action_group_partial_failure():
async def succeed():
return "ok"
async def fail():
raise ValueError("oops")
group = ActionGroup(
name="partial_group",
actions=[
Action(name="succeed_action", action=succeed),
Action(name="fail_action", action=fail),
],
)
with pytest.raises(Exception) as exc_info:
await group()
assert "fail_action" in str(exc_info.value)
@pytest.mark.asyncio
async def test_chained_action_with_nested_group():
group = ActionGroup(
name="nested_group",
actions=[
Action(
name="g1",
action=lambda last_result: f"{last_result} + 10",
inject_last_result=True,
),
Action(
name="g2",
action=lambda last_result: f"{last_result} + 20",
inject_last_result=True,
),
],
)
chain = ChainedAction(
name="chain_with_group",
actions=[
"start",
group,
],
auto_inject=True,
return_list=True,
)
result = await chain()
assert result[0] == "start"
result_dict = dict(result[1])
assert result_dict == {"g1": "start + 10", "g2": "start + 20"}
@pytest.mark.asyncio
async def test_chained_action_with_error_mid_fallback():
async def ok():
return 1
async def fail():
raise RuntimeError("bad")
chain = ChainedAction(
name="group_with_fallback",
actions=[
Action(name="ok", action=ok),
Action(name="fail", action=fail),
FallbackAction(fallback="recovered"),
],
return_list=True,
)
result = await chain()
assert result == [1, None, "recovered"]
@pytest.mark.asyncio
async def test_chained_action_double_fallback():
async def fetch_data():
return None
async def validate_data(last_result):
if last_result is None:
raise ValueError("No data!")
return last_result
async def enrich(last_result):
return f"Enriched: {last_result}"
chain = ChainedAction(
name="fallback_chain",
actions=[
Action(name="Fetch", action=fetch_data),
FallbackAction(fallback="default1"),
Action(name="Validate", action=validate_data),
FallbackAction(fallback="default2"),
Action(name="Enrich", action=enrich),
],
auto_inject=True,
return_list=True,
)
result = await chain()
assert result == [None, "default1", "default1", "default1", "Enriched: default1"]
@pytest.mark.asyncio
async def test_large_chain_stress():
chain = ChainedAction(
name="large_chain",
actions=[
Action(
name=f"a{i}",
action=lambda last_result: (
last_result + 1 if last_result is not None else 0
),
inject_last_result=True,
)
for i in range(50)
],
auto_inject=True,
)
result = await chain()
assert result == 49 # Start from 0 and add 1 fifty times
@pytest.mark.asyncio
async def test_nested_chain_inside_group():
inner_chain = ChainedAction(
name="inner",
actions=[
1,
Action(
name="a",
action=lambda last_result: last_result + 1,
inject_last_result=True,
),
Action(
name="b",
action=lambda last_result: last_result + 2,
inject_last_result=True,
),
],
)
group = ActionGroup(
name="outer_group",
actions=[
Action(name="starter", action=lambda: 10),
inner_chain,
],
)
result = await group()
result_dict = dict(result)
assert result_dict["starter"] == 10
assert result_dict["inner"] == 4
@pytest.mark.asyncio
async def test_mixed_sync_async_actions():
async def async_action(last_result):
return last_result + 5
def sync_action(last_result):
return last_result * 2
chain = ChainedAction(
name="mixed_chain",
actions=[
Action(name="start", action=lambda: 1),
Action(name="double", action=sync_action, inject_last_result=True),
Action(name="plus_five", action=async_action, inject_last_result=True),
],
)
result = await chain()
assert result == 7