From 7b614f32f5c99b51a67afee4758dd43032455a25 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Tue, 21 Jul 2026 17:37:32 -0300 Subject: [PATCH 1/2] test: cover callable/lambda guards resolution (#638) Signed-off-by: Fernando Macedo --- tests/test_callbacks.py | 96 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/tests/test_callbacks.py b/tests/test_callbacks.py index e593c75b..8e2ad642 100644 --- a/tests/test_callbacks.py +++ b/tests/test_callbacks.py @@ -354,6 +354,102 @@ class ExampleStateMachine(StateChart): ExampleStateMachine() +class TestIssue638: + """Guards passed as plain callables (e.g. lambdas) are resolved unbound. + + Dependency injection works by parameter name, so ``self`` is only injected when + the callable is an attribute of the class and gets resolved to a bound method. + Plain callables must declare injectable names (e.g. ``machine``) instead. + + See: https://github.com/fgmacedo/python-statemachine/issues/638 + """ + + def test_lambda_cond_with_machine_param(self): + class ExampleStateMachine(StateChart): + created = State(initial=True) + started = State(final=True) + + start = created.to(started, cond=lambda machine: machine.can_start) + + def __init__(self, can_start: bool): + self.can_start = can_start + super().__init__() + + sm = ExampleStateMachine(can_start=False) + sm.send("start") + assert "created" in sm.configuration_values + + sm = ExampleStateMachine(can_start=True) + sm.send("start") + assert "started" in sm.configuration_values + + def test_lambda_cond_without_params(self): + flags = {"can_start": False} + + class ExampleStateMachine(StateChart): + created = State(initial=True) + started = State(final=True) + + start = created.to(started, cond=lambda: flags["can_start"]) + + sm = ExampleStateMachine() + sm.send("start") + assert "created" in sm.configuration_values + + flags["can_start"] = True + sm.send("start") + assert "started" in sm.configuration_values + + def test_lambda_cond_with_event_kwargs(self): + class ExampleStateMachine(StateChart): + created = State(initial=True) + started = State(final=True) + + start = created.to(started, cond=lambda priority=0: priority > 5) + + sm = ExampleStateMachine() + sm.send("start", priority=1) + assert "created" in sm.configuration_values + + sm.send("start", priority=9) + assert "started" in sm.configuration_values + + def test_lambda_cond_with_self_param_is_called_unbound(self): + class ExampleStateMachine(StateChart): + catch_errors_as_events = False + + created = State(initial=True) + started = State(final=True) + + start = created.to(started, cond=lambda self: True) + + sm = ExampleStateMachine() + with pytest.raises(TypeError, match="missing 1 required positional argument: 'self'"): + sm.send("start") + + def test_class_body_function_cond_by_reference_is_bound(self): + class ExampleStateMachine(StateChart): + created = State(initial=True) + started = State(final=True) + + def can_start(self) -> bool: + return self.flag + + start = created.to(started, cond=can_start) + + def __init__(self, flag: bool): + self.flag = flag + super().__init__() + + sm = ExampleStateMachine(flag=False) + sm.send("start") + assert "created" in sm.configuration_values + + sm = ExampleStateMachine(flag=True) + sm.send("start") + assert "started" in sm.configuration_values + + class TestVisitConditionFalse: """visit/async_visit skip callbacks whose condition returns False.""" From 0da2b8ff9ad8fec44d3dad6d2f83197ab9d5c327 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Tue, 21 Jul 2026 17:37:52 -0300 Subject: [PATCH 2/2] docs: document callable conditions and self vs machine injection (#638) Signed-off-by: Fernando Macedo --- docs/guards.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/docs/guards.md b/docs/guards.md index 8c16cbd4..8ce5a0ec 100644 --- a/docs/guards.md +++ b/docs/guards.md @@ -132,6 +132,71 @@ example combining multiple transitions on the same event. ``` +(callable conditions)= + +### Callable conditions + +Besides strings, `cond` and `unless` also accept callables directly, including +lambdas, allowing simple guards to be defined inline: + +```py +>>> class Turnstile(StateChart): +... locked = State(initial=True) +... unlocked = State(final=True) +... +... coin = locked.to(unlocked, cond=lambda machine: machine.credits > 0) +... +... credits = 0 + +>>> sm = Turnstile() +>>> sm.send("coin") +>>> "locked" in sm.configuration_values +True + +>>> sm.credits = 1 +>>> sm.send("coin") +>>> "unlocked" in sm.configuration_values +True + +``` + +Like any callback, a callable condition receives its arguments via +{ref}`dependency injection `: declare only the +parameters you need, using the injectable names (`machine`, `event`, +`source`, `target`, `model`, ...) or the arguments passed when triggering +the event: + +```py +>>> class Dispatcher(StateChart): +... idle = State(initial=True) +... dispatched = State(final=True) +... +... dispatch = idle.to(dispatched, cond=lambda priority=0: priority > 5) + +>>> sm = Dispatcher() +>>> sm.send("dispatch", priority=1) +>>> "idle" in sm.configuration_values +True + +>>> sm.send("dispatch", priority=9) +>>> "dispatched" in sm.configuration_values +True + +``` + +```{warning} +Plain callables, like lambdas and functions defined outside the class body, +are called **unbound**, so `self` is not injected. A guard declared as +`cond=lambda self: ...` fails with a `TypeError` when evaluated. Use +`machine` instead of `self` to receive the state machine instance. + +`self` is only available when the callable resolves to a bound method, +which happens when it is an attribute of the state machine class: either +referenced by name (`cond="my_condition"`) or defined in the class body +and passed by reference (`cond=my_condition`). +``` + + (condition expressions)= ### Condition expressions