feat(core): advance options/state handling and workflow execution integration
- extend OptionsManager to support multi-namespace option resolution and toggling - integrate OptionsManager more deeply across Action, ChainedAction, and ActionGroup - propagate shared runtime configuration through execution layers - refine action composition model (sequential + parallel execution semantics) - improve lifecycle consistency across BaseAction, Action, ChainedAction, and ActionGroup - begin aligning execution flow with centralized context and options handling wip: routing and root option parsing behavior still in progress
This commit is contained in:
230
tests/test_actions/test_action_basic.py
Normal file
230
tests/test_actions/test_action_basic.py
Normal file
@@ -0,0 +1,230 @@
|
||||
import pytest
|
||||
|
||||
from falyx.action import (
|
||||
Action,
|
||||
ActionGroup,
|
||||
ChainedAction,
|
||||
FallbackAction,
|
||||
LiteralInputAction,
|
||||
)
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
|
||||
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!"
|
||||
assert (
|
||||
str(action)
|
||||
== "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False, rollback=False)"
|
||||
)
|
||||
assert (
|
||||
repr(action)
|
||||
== "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False, rollback=False)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chained_action():
|
||||
"""Test if ChainedAction can be created and used."""
|
||||
action1 = Action("one", lambda: 1)
|
||||
action2 = Action("two", lambda: 2)
|
||||
chain = ChainedAction(
|
||||
name="Simple Chain",
|
||||
actions=[action1, action2],
|
||||
return_list=True,
|
||||
)
|
||||
|
||||
print(chain)
|
||||
result = await chain()
|
||||
assert result == [1, 2]
|
||||
assert (
|
||||
str(chain)
|
||||
== "ChainedAction(name=Simple Chain, actions=['one', 'two'], args=(), kwargs={}, auto_inject=False, return_list=True)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_action_group():
|
||||
"""Test if ActionGroup can be created and used."""
|
||||
action1 = Action("one", lambda: 1)
|
||||
action2 = Action("two", lambda: 2)
|
||||
group = ActionGroup(
|
||||
name="Simple Group",
|
||||
actions=[action1, action2],
|
||||
)
|
||||
|
||||
print(group)
|
||||
result = await group()
|
||||
assert result == [("one", 1), ("two", 2)]
|
||||
assert (
|
||||
str(group)
|
||||
== "ActionGroup(name=Simple Group, actions=['one', 'two'], args=(), kwargs={}, inject_last_result=False, inject_into=last_result)"
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_literal_input_action():
|
||||
"""Test if LiteralInputAction can be created and used."""
|
||||
action = LiteralInputAction("Hello, World!")
|
||||
result = await action()
|
||||
assert result == "Hello, World!"
|
||||
assert action.value == "Hello, World!"
|
||||
assert str(action) == "LiteralInputAction(value='Hello, World!')"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_action():
|
||||
"""Test if FallbackAction can be created and used."""
|
||||
action = FallbackAction("Fallback value")
|
||||
chain = ChainedAction(
|
||||
name="Fallback Chain",
|
||||
actions=[
|
||||
Action(name="one", action=lambda: None),
|
||||
action,
|
||||
],
|
||||
)
|
||||
result = await chain()
|
||||
assert result == "Fallback value"
|
||||
assert str(action) == "FallbackAction(fallback='Fallback value')"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_action_from_chain():
|
||||
"""Test if an action can be removed from a chain."""
|
||||
action1 = Action(name="one", action=lambda: 1)
|
||||
action2 = Action(name="two", action=lambda: 2)
|
||||
chain = ChainedAction(
|
||||
name="Simple Chain",
|
||||
actions=[action1, action2],
|
||||
)
|
||||
|
||||
assert len(chain.actions) == 2
|
||||
|
||||
# Remove the first action
|
||||
chain.remove_action(action1.name)
|
||||
|
||||
assert len(chain.actions) == 1
|
||||
assert chain.actions[0] == action2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_action_in_chain():
|
||||
"""Test if an action can be checked for presence in a chain."""
|
||||
action1 = Action(name="one", action=lambda: 1)
|
||||
action2 = Action(name="two", action=lambda: 2)
|
||||
chain = ChainedAction(
|
||||
name="Simple Chain",
|
||||
actions=[action1, action2],
|
||||
)
|
||||
|
||||
assert chain.has_action(action1.name) is True
|
||||
assert chain.has_action(action2.name) is True
|
||||
|
||||
# Remove the first action
|
||||
chain.remove_action(action1.name)
|
||||
|
||||
assert chain.has_action(action1.name) is False
|
||||
assert chain.has_action(action2.name) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_action_from_chain():
|
||||
"""Test if an action can be retrieved from a chain."""
|
||||
action1 = Action(name="one", action=lambda: 1)
|
||||
action2 = Action(name="two", action=lambda: 2)
|
||||
chain = ChainedAction(
|
||||
name="Simple Chain",
|
||||
actions=[action1, action2],
|
||||
)
|
||||
|
||||
assert chain.get_action(action1.name) == action1
|
||||
assert chain.get_action(action2.name) == action2
|
||||
|
||||
# Remove the first action
|
||||
chain.remove_action(action1.name)
|
||||
|
||||
assert chain.get_action(action1.name) is None
|
||||
assert chain.get_action(action2.name) == action2
|
||||
0
tests/test_actions/test_action_fallback.py
Normal file
0
tests/test_actions/test_action_fallback.py
Normal file
0
tests/test_actions/test_action_hooks.py
Normal file
0
tests/test_actions/test_action_hooks.py
Normal file
46
tests/test_actions/test_action_process.py
Normal file
46
tests/test_actions/test_action_process.py
Normal file
@@ -0,0 +1,46 @@
|
||||
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", action=slow_add, args=(2, 3))
|
||||
result = await action()
|
||||
assert result == 5
|
||||
|
||||
|
||||
unpickleable = lambda x: x + 1 # noqa: E731
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_action_rejects_unpickleable():
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
|
||||
action = ProcessAction(name="proc_fail", action=unpickleable, args=(2,))
|
||||
with pytest.raises(pickle.PicklingError, match="Can't pickle"):
|
||||
await action()
|
||||
36
tests/test_actions/test_action_retries.py
Normal file
36
tests/test_actions/test_action_retries.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import pytest
|
||||
|
||||
from falyx.action import Action, ChainedAction
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.retry_utils import enable_retries_recursively
|
||||
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
|
||||
|
||||
# --- Fixtures ---
|
||||
@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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enable_retries_recursively():
|
||||
"""Test if Action can be created with retry=True."""
|
||||
action = Action("test_action", lambda: "Hello, World!")
|
||||
assert action.retry_policy.enabled is False
|
||||
|
||||
chained_action = ChainedAction(
|
||||
name="Chained Action",
|
||||
actions=[action],
|
||||
)
|
||||
|
||||
enable_retries_recursively(chained_action, policy=None)
|
||||
assert action.retry_policy.enabled is True
|
||||
588
tests/test_actions/test_actions.py
Normal file
588
tests/test_actions/test_actions.py
Normal file
@@ -0,0 +1,588 @@
|
||||
import pytest
|
||||
|
||||
from falyx.action import Action, ActionGroup, ChainedAction, FallbackAction
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookManager, HookType
|
||||
|
||||
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()
|
||||
|
||||
|
||||
# --- Tests ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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=a1, hooks=hook_manager)
|
||||
|
||||
await action()
|
||||
|
||||
context = er.get_latest()
|
||||
assert context.name == "hooked"
|
||||
assert context.extra.get("hook_triggered") is True
|
||||
|
||||
|
||||
@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=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, 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=a1),
|
||||
Action(name="b", action=a2),
|
||||
Action(name="c", action=a3),
|
||||
]
|
||||
group = ActionGroup(name="parallel", actions=actions)
|
||||
result = await group()
|
||||
result_dict = dict(result)
|
||||
assert result_dict == {"a": 1, "b": 2, "c": 3}
|
||||
|
||||
|
||||
@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=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=a3),
|
||||
Action(name="second", action=a4, inject_last_result=True),
|
||||
inner_chain,
|
||||
]
|
||||
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=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=a4),
|
||||
Action(name="second", action=a5, inject_last_result=True),
|
||||
group,
|
||||
]
|
||||
chain = ChainedAction(name="test_chain", actions=actions, return_list=True)
|
||||
result = await chain()
|
||||
assert result == [1, 3, [("a", 4), ("b", 5), ("c", 3)]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_action_error_triggers_error_hook():
|
||||
def fail():
|
||||
raise ValueError("boom")
|
||||
|
||||
hooks = HookManager()
|
||||
flag = {}
|
||||
|
||||
async def error_hook(ctx):
|
||||
flag["called"] = True
|
||||
|
||||
hooks.register(HookType.ON_ERROR, error_hook)
|
||||
action = Action(name="fail_action", action=fail, hooks=hooks)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await action()
|
||||
|
||||
assert flag.get("called") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chained_action_rollback_on_failure():
|
||||
rollback_called = []
|
||||
|
||||
async def success():
|
||||
return "ok"
|
||||
|
||||
async def fail():
|
||||
raise RuntimeError("fail")
|
||||
|
||||
async def rollback_fn():
|
||||
rollback_called.append("rolled back")
|
||||
|
||||
actions = [
|
||||
Action(name="ok", action=success, rollback=rollback_fn),
|
||||
Action(name="fail", action=fail, rollback=rollback_fn),
|
||||
]
|
||||
|
||||
chain = ChainedAction(name="chain", actions=actions)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await chain()
|
||||
|
||||
assert rollback_called == ["rolled back"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_hooks_recursively_propagates():
|
||||
def hook(context):
|
||||
context.extra.update({"test_marker": True})
|
||||
|
||||
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()
|
||||
|
||||
for ctx in er.get_by_name("a") + er.get_by_name("b"):
|
||||
assert ctx.extra.get("test_marker") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_action_hook_recovers_error():
|
||||
async def flaky():
|
||||
raise ValueError("fail")
|
||||
|
||||
async def recovery_hook(ctx):
|
||||
ctx.result = 99
|
||||
ctx.exception = None
|
||||
|
||||
hooks = HookManager()
|
||||
hooks.register(HookType.ON_ERROR, recovery_hook)
|
||||
action = Action(name="recovering", action=flaky, hooks=hooks)
|
||||
|
||||
result = await action()
|
||||
assert result == 99
|
||||
|
||||
|
||||
@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=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",
|
||||
]
|
||||
28
tests/test_actions/test_chained_action_empty.py
Normal file
28
tests/test_actions/test_chained_action_empty.py
Normal file
@@ -0,0 +1,28 @@
|
||||
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)
|
||||
100
tests/test_actions/test_load_file_action.py
Normal file
100
tests/test_actions/test_load_file_action.py
Normal file
@@ -0,0 +1,100 @@
|
||||
import pytest
|
||||
from rich.text import Text
|
||||
|
||||
from falyx.action import LoadFileAction
|
||||
from falyx.console import console as falyx_console
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_json_file_action(tmp_path):
|
||||
mock_data = '{"key": "value"}'
|
||||
file = tmp_path / "test.json"
|
||||
file.write_text(mock_data)
|
||||
action = LoadFileAction(name="load-file", file_path=file, file_type="json")
|
||||
result = await action()
|
||||
assert result == {"key": "value"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_yaml_file_action(tmp_path):
|
||||
mock_data = "key: value"
|
||||
file = tmp_path / "test.yaml"
|
||||
file.write_text(mock_data)
|
||||
action = LoadFileAction(name="load-file", file_path=file, file_type="yaml")
|
||||
result = await action()
|
||||
assert result == {"key": "value"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_toml_file_action(tmp_path):
|
||||
mock_data = 'key = "value"'
|
||||
file = tmp_path / "test.toml"
|
||||
file.write_text(mock_data)
|
||||
action = LoadFileAction(name="load-file", file_path=file, file_type="toml")
|
||||
result = await action()
|
||||
assert result == {"key": "value"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_csv_file_action(tmp_path):
|
||||
mock_data = "key,value\nfoo,bar"
|
||||
file = tmp_path / "test.csv"
|
||||
file.write_text(mock_data)
|
||||
action = LoadFileAction(name="load-file", file_path=file, file_type="csv")
|
||||
result = await action()
|
||||
print(result)
|
||||
assert result == [["key", "value"], ["foo", "bar"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_tsv_file_action(tmp_path):
|
||||
mock_data = "key\tvalue\nfoo\tbar"
|
||||
file = tmp_path / "test.tsv"
|
||||
file.write_text(mock_data)
|
||||
action = LoadFileAction(name="load-file", file_path=file, file_type="tsv")
|
||||
result = await action()
|
||||
assert result == [["key", "value"], ["foo", "bar"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_file_action_invalid_path():
|
||||
action = LoadFileAction(
|
||||
name="load-file", file_path="non_existent_file.json", file_type="json"
|
||||
)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await action()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_file_action_invalid_json(tmp_path):
|
||||
invalid_json = '{"key": "value"' # Missing closing brace
|
||||
file = tmp_path / "invalid.json"
|
||||
file.write_text(invalid_json)
|
||||
action = LoadFileAction(name="load-file", file_path=file, file_type="json")
|
||||
with pytest.raises(ValueError):
|
||||
await action()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_file_action_unsupported_type(tmp_path):
|
||||
file = tmp_path / "test.txt"
|
||||
file.write_text("Just some text")
|
||||
with pytest.raises(ValueError):
|
||||
LoadFileAction(name="load-file", file_path=file, file_type="unsupported")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preview_of_load_file_action(tmp_path):
|
||||
mock_data = '{"key": "value"}'
|
||||
file = tmp_path / "test.json"
|
||||
file.write_text(mock_data)
|
||||
action = LoadFileAction(name="load-file", file_path=file, file_type="json")
|
||||
with falyx_console.capture() as capture:
|
||||
await action.preview()
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert "LoadFileAction" in captured
|
||||
assert "test.json" in captured
|
||||
assert "load-file" in captured
|
||||
assert "JSON" in captured
|
||||
assert "key" in captured
|
||||
assert "value" in captured
|
||||
198
tests/test_actions/test_stress_actions.py
Normal file
198
tests/test_actions/test_stress_actions.py
Normal file
@@ -0,0 +1,198 @@
|
||||
import pytest
|
||||
|
||||
from falyx.action import Action, ActionGroup, ChainedAction, FallbackAction
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
|
||||
# --- 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
|
||||
Reference in New Issue
Block a user