Skip to content

Commit 1f6013c

Browse files
authored
feat: add enabled_events() method (#520) (#559)
* fix: re-enqueue initial event when deserializing async state machine (#544) When an async SM is pickled/deepcopied (e.g. via multiprocessing), the engine queue is not preserved. __setstate__ recreated the engine but never called start(), so the __initial__ event was never enqueued and activate_initial_state() would fail with InvalidStateValue. Closes #544 * fix: await async predicates in condition expressions (#535) The boolean expression combinators (custom_not, custom_and, custom_or, build_custom_operator) called predicates synchronously. When predicates were async, they returned unawaited coroutine objects which are always truthy, causing `not` to always return False, `and` to skip evaluation, and `or` to short-circuit incorrectly. Each combinator now checks `isawaitable()` on predicate results and returns a coroutine when needed, which CallbackWrapper.__call__ already knows how to await. Closes #535 * chore: sync pre-commit ruff rev with lockfile (v0.15.0) The pre-commit hook was using ruff v0.8.1 while the lockfile had v0.15.0, causing import sorting differences between local and CI. * fix: address SonarCloud code smells in tests - Add docstrings to empty async on_enter_state methods (S1186) - Use await asyncio.sleep(0) in async test hooks to satisfy S7503 * feat: add `enabled_events()` method to check guard conditions (#520) `allowed_events` returns events reachable from the current state but does not evaluate `cond`/`unless` guards. The new `enabled_events()` method evaluates conditions and returns only events that can actually fire. It accepts `*args`/`**kwargs` forwarded to condition callbacks, works with both sync and async engines, and treats condition exceptions as enabled (permissive behavior). Closes #520
1 parent 570687e commit 1f6013c

6 files changed

Lines changed: 355 additions & 1 deletion

File tree

docs/guards.md

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ To control the evaluation order, declare transitions in the desired order:
4747
```python
4848
# Declare in the order you want them checked:
4949
first = state_a.to(state_b, cond="check1") # Checked FIRST
50-
second = state_a.to(state_c, cond="check2") # Checked SECOND
50+
second = state_a.to(state_c, cond="check2") # Checked SECOND
5151
third = state_a.to(state_d, cond="check3") # Checked THIRD
5252
5353
my_event = first | second | third # Order matches declaration
@@ -159,6 +159,79 @@ So, a condition `s1.to(s2, cond=lambda: [])` will evaluate as `False`, as an emp
159159
**falsy** value.
160160
```
161161

162+
### Checking enabled events
163+
164+
The {ref}`StateMachine.allowed_events` property returns events reachable from the current state,
165+
but it does **not** evaluate `cond`/`unless` guards. To check which events actually have their
166+
conditions satisfied, use {ref}`StateMachine.enabled_events`.
167+
168+
```{testsetup}
169+
170+
>>> from statemachine import StateMachine, State
171+
172+
```
173+
174+
```py
175+
>>> class ApprovalMachine(StateMachine):
176+
... pending = State(initial=True)
177+
... approved = State(final=True)
178+
... rejected = State(final=True)
179+
...
180+
... approve = pending.to(approved, cond="is_manager")
181+
... reject = pending.to(rejected)
182+
...
183+
... is_manager = False
184+
185+
>>> sm = ApprovalMachine()
186+
187+
>>> [e.id for e in sm.allowed_events]
188+
['approve', 'reject']
189+
190+
>>> [e.id for e in sm.enabled_events()]
191+
['reject']
192+
193+
>>> sm.is_manager = True
194+
195+
>>> [e.id for e in sm.enabled_events()]
196+
['approve', 'reject']
197+
198+
```
199+
200+
`enabled_events` is a method (not a property) because conditions may depend on runtime
201+
arguments. Any `*args`/`**kwargs` passed to `enabled_events()` are forwarded to the
202+
condition callbacks, just like when triggering an event:
203+
204+
```py
205+
>>> class TaskMachine(StateMachine):
206+
... idle = State(initial=True)
207+
... running = State(final=True)
208+
...
209+
... start = idle.to(running, cond="has_enough_resources")
210+
...
211+
... def has_enough_resources(self, cpu=0):
212+
... return cpu >= 4
213+
214+
>>> sm = TaskMachine()
215+
216+
>>> sm.enabled_events()
217+
[]
218+
219+
>>> [e.id for e in sm.enabled_events(cpu=8)]
220+
['start']
221+
222+
```
223+
224+
```{tip}
225+
This is useful for UI scenarios where you want to show or hide buttons based on whether
226+
an event's conditions are currently satisfied.
227+
```
228+
229+
```{note}
230+
An event is considered **enabled** if at least one of its transitions from the current state
231+
has all conditions satisfied. If a condition raises an exception, the event is treated as
232+
enabled (permissive behavior).
233+
```
234+
162235
## Validators
163236

164237

statemachine/engines/async_.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,34 @@ async def _trigger(self, trigger_data: TriggerData):
9797

9898
return result if executed else None
9999

100+
async def enabled_events(self, *args, **kwargs):
101+
sm = self.sm
102+
enabled = {}
103+
for transition in sm.current_state.transitions:
104+
for event in transition.events:
105+
if event in enabled:
106+
continue
107+
extended_kwargs = kwargs.copy()
108+
extended_kwargs.update(
109+
{
110+
"machine": sm,
111+
"model": sm.model,
112+
"event": getattr(sm, event),
113+
"source": transition.source,
114+
"target": transition.target,
115+
"state": sm.current_state,
116+
"transition": transition,
117+
}
118+
)
119+
try:
120+
if await sm._callbacks.async_all(
121+
transition.cond.key, *args, **extended_kwargs
122+
):
123+
enabled[event] = getattr(sm, event)
124+
except Exception:
125+
enabled[event] = getattr(sm, event)
126+
return list(enabled.values())
127+
100128
async def _activate(self, trigger_data: TriggerData, transition: "Transition"):
101129
event_data = EventData(trigger_data=trigger_data, transition=transition)
102130
args, kwargs = event_data.args, event_data.extended_kwargs

statemachine/engines/sync.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,32 @@ def _trigger(self, trigger_data: TriggerData):
9999

100100
return result if executed else None
101101

102+
def enabled_events(self, *args, **kwargs):
103+
sm = self.sm
104+
enabled = {}
105+
for transition in sm.current_state.transitions:
106+
for event in transition.events:
107+
if event in enabled:
108+
continue
109+
extended_kwargs = kwargs.copy()
110+
extended_kwargs.update(
111+
{
112+
"machine": sm,
113+
"model": sm.model,
114+
"event": getattr(sm, event),
115+
"source": transition.source,
116+
"target": transition.target,
117+
"state": sm.current_state,
118+
"transition": transition,
119+
}
120+
)
121+
try:
122+
if sm._callbacks.all(transition.cond.key, *args, **extended_kwargs):
123+
enabled[event] = getattr(sm, event)
124+
except Exception:
125+
enabled[event] = getattr(sm, event)
126+
return list(enabled.values())
127+
102128
def _activate(self, trigger_data: TriggerData, transition: "Transition"):
103129
event_data = EventData(trigger_data=trigger_data, transition=transition)
104130
args, kwargs = event_data.args, event_data.extended_kwargs

statemachine/statemachine.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,24 @@ def allowed_events(self) -> "List[Event]":
295295
"""List of the current allowed events."""
296296
return [getattr(self, event) for event in self.current_state.transitions.unique_events]
297297

298+
def enabled_events(self, *args, **kwargs):
299+
"""List of the current enabled events, considering guard conditions.
300+
301+
An event is **enabled** if at least one of its transitions from the current
302+
state has all ``cond``/``unless`` guards satisfied.
303+
304+
Args:
305+
*args: Positional arguments forwarded to condition callbacks.
306+
**kwargs: Keyword arguments forwarded to condition callbacks.
307+
308+
Returns:
309+
A list of enabled :ref:`Event` instances.
310+
"""
311+
result = self._engine.enabled_events(*args, **kwargs)
312+
if not isawaitable(result):
313+
return result
314+
return run_async_from_sync(result)
315+
298316
def _put_nonblocking(self, trigger_data: TriggerData):
299317
"""Put the trigger on the queue without blocking the caller."""
300318
self._engine.put(trigger_data)

tests/test_async.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,81 @@ async def test_async_state_should_be_initialized(async_order_control_machine):
199199

200200
await sm.activate_initial_state()
201201
assert sm.current_state == sm.waiting_for_payment
202+
203+
204+
class TestAsyncEnabledEvents:
205+
async def test_passing_async_condition(self):
206+
class MyMachine(StateMachine):
207+
s0 = State(initial=True)
208+
s1 = State(final=True)
209+
210+
go = s0.to(s1, cond="is_ready")
211+
212+
async def is_ready(self):
213+
return True
214+
215+
sm = MyMachine()
216+
await sm.activate_initial_state()
217+
assert [e.id for e in await sm.enabled_events()] == ["go"]
218+
219+
async def test_failing_async_condition(self):
220+
class MyMachine(StateMachine):
221+
s0 = State(initial=True)
222+
s1 = State(final=True)
223+
224+
go = s0.to(s1, cond="is_ready")
225+
226+
async def is_ready(self):
227+
return False
228+
229+
sm = MyMachine()
230+
await sm.activate_initial_state()
231+
assert await sm.enabled_events() == []
232+
233+
async def test_kwargs_forwarded_to_async_conditions(self):
234+
class MyMachine(StateMachine):
235+
s0 = State(initial=True)
236+
s1 = State(final=True)
237+
238+
go = s0.to(s1, cond="check_value")
239+
240+
async def check_value(self, value=0):
241+
return value > 10
242+
243+
sm = MyMachine()
244+
await sm.activate_initial_state()
245+
assert await sm.enabled_events() == []
246+
assert [e.id for e in await sm.enabled_events(value=20)] == ["go"]
247+
248+
async def test_async_condition_exception_treated_as_enabled(self):
249+
class MyMachine(StateMachine):
250+
s0 = State(initial=True)
251+
s1 = State(final=True)
252+
253+
go = s0.to(s1, cond="bad_cond")
254+
255+
async def bad_cond(self):
256+
raise RuntimeError("boom")
257+
258+
sm = MyMachine()
259+
await sm.activate_initial_state()
260+
assert [e.id for e in await sm.enabled_events()] == ["go"]
261+
262+
async def test_mixed_enabled_and_disabled_async(self):
263+
class MyMachine(StateMachine):
264+
s0 = State(initial=True)
265+
s1 = State()
266+
s2 = State(final=True)
267+
268+
go = s0.to(s1, cond="cond_true")
269+
stop = s0.to(s2, cond="cond_false")
270+
271+
async def cond_true(self):
272+
return True
273+
274+
async def cond_false(self):
275+
return False
276+
277+
sm = MyMachine()
278+
await sm.activate_initial_state()
279+
assert [e.id for e in await sm.enabled_events()] == ["go"]

tests/test_statemachine.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,3 +503,134 @@ def __bool__(self):
503503

504504
machine.produce()
505505
assert model.state == "producing"
506+
507+
508+
class TestEnabledEvents:
509+
def test_no_conditions_same_as_allowed_events(self, campaign_machine):
510+
"""Without conditions, enabled_events should match allowed_events."""
511+
sm = campaign_machine()
512+
assert [e.id for e in sm.enabled_events()] == [e.id for e in sm.allowed_events]
513+
514+
def test_passing_condition_returns_event(self):
515+
class MyMachine(StateMachine):
516+
s0 = State(initial=True)
517+
s1 = State(final=True)
518+
519+
go = s0.to(s1, cond="is_ready")
520+
521+
def is_ready(self):
522+
return True
523+
524+
sm = MyMachine()
525+
assert [e.id for e in sm.enabled_events()] == ["go"]
526+
527+
def test_failing_condition_excludes_event(self):
528+
class MyMachine(StateMachine):
529+
s0 = State(initial=True)
530+
s1 = State(final=True)
531+
532+
go = s0.to(s1, cond="is_ready")
533+
534+
def is_ready(self):
535+
return False
536+
537+
sm = MyMachine()
538+
assert sm.enabled_events() == []
539+
540+
def test_multiple_transitions_one_passes(self):
541+
"""Same event with multiple transitions: included if at least one passes."""
542+
543+
class MyMachine(StateMachine):
544+
s0 = State(initial=True)
545+
s1 = State()
546+
s2 = State(final=True)
547+
548+
go = s0.to(s1, cond="cond_false") | s0.to(s2, cond="cond_true")
549+
550+
def cond_false(self):
551+
return False
552+
553+
def cond_true(self):
554+
return True
555+
556+
sm = MyMachine()
557+
assert [e.id for e in sm.enabled_events()] == ["go"]
558+
559+
def test_final_state_returns_empty(self, campaign_machine):
560+
sm = campaign_machine()
561+
sm.produce()
562+
sm.deliver()
563+
assert sm.enabled_events() == []
564+
565+
def test_kwargs_forwarded_to_conditions(self):
566+
class MyMachine(StateMachine):
567+
s0 = State(initial=True)
568+
s1 = State(final=True)
569+
570+
go = s0.to(s1, cond="check_value")
571+
572+
def check_value(self, value=0):
573+
return value > 10
574+
575+
sm = MyMachine()
576+
assert sm.enabled_events() == []
577+
assert [e.id for e in sm.enabled_events(value=20)] == ["go"]
578+
579+
def test_condition_exception_treated_as_enabled(self):
580+
"""If a condition raises, the event is treated as enabled (permissive)."""
581+
582+
class MyMachine(StateMachine):
583+
s0 = State(initial=True)
584+
s1 = State(final=True)
585+
586+
go = s0.to(s1, cond="bad_cond")
587+
588+
def bad_cond(self):
589+
raise RuntimeError("boom")
590+
591+
sm = MyMachine()
592+
assert [e.id for e in sm.enabled_events()] == ["go"]
593+
594+
def test_mixed_enabled_and_disabled(self):
595+
class MyMachine(StateMachine):
596+
s0 = State(initial=True)
597+
s1 = State()
598+
s2 = State(final=True)
599+
600+
go = s0.to(s1, cond="cond_true")
601+
stop = s0.to(s2, cond="cond_false")
602+
603+
def cond_true(self):
604+
return True
605+
606+
def cond_false(self):
607+
return False
608+
609+
sm = MyMachine()
610+
assert [e.id for e in sm.enabled_events()] == ["go"]
611+
612+
def test_unless_condition(self):
613+
class MyMachine(StateMachine):
614+
s0 = State(initial=True)
615+
s1 = State(final=True)
616+
617+
go = s0.to(s1, unless="is_blocked")
618+
619+
def is_blocked(self):
620+
return True
621+
622+
sm = MyMachine()
623+
assert sm.enabled_events() == []
624+
625+
def test_unless_condition_passes(self):
626+
class MyMachine(StateMachine):
627+
s0 = State(initial=True)
628+
s1 = State(final=True)
629+
630+
go = s0.to(s1, unless="is_blocked")
631+
632+
def is_blocked(self):
633+
return False
634+
635+
sm = MyMachine()
636+
assert [e.id for e in sm.enabled_events()] == ["go"]

0 commit comments

Comments
 (0)