02.03.04 — chain_actions and ChainRunner#
Source file:
src/ocarina/dsl/testing_with_railway/chain_actions.pyThe primitive that makes the DSL “flat.” Without it, an N-step scenario is a staircase of
.then(...).failure(...).success(...).execute(). With it, it’s a list.
“Parenthesis hell”#
Solution:
# Flat, readable syntax runner = chain_actions( action1.failure(h1).success(h2), action2.failure(h3).success(h4), action3.failure(h5).success(h6) ) chain = runner.run() # Execute when readyBenefits:
- Lazy evaluation: Build chain without executing
- Flat syntax: No deep nesting
- Automatic short-circuiting: Stops on first failure
- Composability: ChainRunner is a value
(Excerpt from the docstring.)
ChainRunner[T]#
@final
class ChainRunner[T]:
def __init__(self, *, thunk: Thunk[ActionChain[T]]) -> None:
self._thunk = thunk
def run(self) -> ActionChain[T]:
return self._thunk()@final, no user inheritance.- Kwargs-only constructor (
*, thunk: ...) blocks the classic slip:ChainRunner(no_idea), which would have been ambiguous. - The
thunkis aThunk[ActionChain[T]]— i.e.Callable[[], ActionChain[T]]. No side effect runs before.run(). .run()returns theActionChain[T]: you get the state machine back (see02-action-chain-states.md) and can queryhas_failed,is_ok,result.
chain_actions[T](first, *rest)#
def chain_actions[T](
first: ActionSuccess[T], *rest: ActionSuccess[T]
) -> ChainRunner[T]:
def thunk() -> ActionChain[T]:
def reducer(chain: ActionChain[T], step: ActionSuccess[T]) -> ActionChain[T]:
if chain.has_failed():
return chain
return (
chain.then(step.__action__)
.failure(step.__failure_handler__)
.success(step.__success_handler__)
.execute()
)
return reduce(reducer, rest, first.execute())
return ChainRunner(thunk=thunk)It’s a fold left:
| FP concept | Python realization |
|---|---|
Initial element (init) | first.execute() — runs the first act, you already have an ActionChain[T] |
Iteration (xs) | rest (the argument *) |
Reducer ((acc, x) -> acc') | reducer(chain, step) -> ActionChain[T] |
| Short-circuit | if chain.has_failed(): return chain |
| Iterative composition | chain.then(step.__action__).failure(...).success(...).execute() |
Why first and *rest are separate#
- Strict typing: we guarantee at least one
ActionSuccess[T].chain_actions()with no args won’t compile. - Fold seed: a fold needs an initial value (
ActionChain[T]), so the first act has to be executed before the loop. With*all, we’d have to invent an empty neutralActionChain[T](“the empty rail”) and carry a “nothing done” state. - Readability:
chain_actions(action1.failure...success(...), action2.failure...success(...), ...)reads naturally.
Why nested functions#
def thunk() -> ActionChain[T]:
def reducer(...): ...
return reduce(reducer, rest, first.execute())
return ChainRunner(thunk=thunk)thunkcloses overfirstandrest. Sochain_actionsis a closure on its arguments. Whenrunner.run()fires later, thethunk()replays those captures.reducerlives insidethunk: it doesn’t need to capture external variables, and keeping it here instead of at module level avoids leaking a private symbol to the rest of the package.
Composition: ChainRunner#
- Store a
ChainRunnerin a variable and reuse it. - Pass it as an argument.
- Multiply a list of
ChainRunners:[runner] * 5runs the execution 5 times when the sequence plays. The Holy Book calls it out (Scenarios composability chapter, Repetitions section). - Aliasing is trivial:
click_confirm_cookies = drive_page(
act(on_homepage, confirm_cookie_banner)
.failure(log_error_with_current_url("Failed to dismiss banner..."))
.success(log_success_with_current_url_and_take_screenshot("Banner dismissed!"))
)
# … later …
return [
match_page(branches=[
when(check_that_page.has_cookies_banner, name="cookies", then=[click_confirm_cookies]),
when(check_that_page.has_not_cookies_banner, name="no cookies", then=[]),
]),
drive_page(...),
]drive_page is a monomorphism of chain_actions#
The framework’s drive_page is just:
def drive_page(
first: ActionSuccess[TPOM], *rest: ActionSuccess[TPOM]
) -> ChainRunner[TPOM]:
return chain_actions(first, *rest)See 06-drive-page.md for why the alias exists.
Annotated execution trace#
Concrete case: three acts, the second fails.
runner = drive_page(act1, act2, act3) # ⚠️ nothing executed yet
chain = runner.run() # ▶︎ execution:
┌─ first.execute() = ActionChain(ok=True, result=Ok(...)) ← fold's initial state
│
├─ reduce(reducer, [act2, act3], <ActionChain above>)
│ │
│ ├─ reducer(<ok chain>, act2) :
│ │ chain.then(act2.__action__) → ActionStart (success rail)
│ │ .failure(act2.__failure_handler__) → ActionFailure
│ │ .success(act2.__success_handler__) → ActionSuccess
│ │ .execute() → action raises → Fail
│ │ → failure_handler(exc) ❌
│ │ → ActionChain(ok=False, result=Fail(exc))
│ │
│ └─ reducer(<failed chain>, act3) :
│ chain.has_failed() → True → return chain ⚠️ act3 never executed
│
└─ return ActionChain(ok=False, result=Fail(exc))functools#
from functools import reduceNo toolz, no funcy, no homemade wrapper. Just functools.reduce — Python’s canonical fold signature.
Dedicated tests#
tests/scenarios/test_railway_and_action_chain.py covers:
- chaining 3+ acts in sequence,
- short-circuit after the 2nd failure (the 3rd never runs),
actcounting throughActCounter(the counter doesn’t tick on short-circuited steps).