python-statemachine 3.2.0__py3-none-any.whl → 3.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {python_statemachine-3.2.0.dist-info → python_statemachine-3.2.2.dist-info}/METADATA +7 -6
  2. python_statemachine-3.2.2.dist-info/RECORD +73 -0
  3. {python_statemachine-3.2.0.dist-info → python_statemachine-3.2.2.dist-info}/WHEEL +1 -1
  4. statemachine/__init__.py +1 -1
  5. statemachine/callbacks.py +11 -0
  6. statemachine/contrib/diagram/__init__.py +15 -6
  7. statemachine/contrib/diagram/extract.py +3 -3
  8. statemachine/contrib/diagram/formatter.py +4 -2
  9. statemachine/contrib/diagram/graphviz.py +66 -0
  10. statemachine/contrib/diagram/sphinx_ext.py +28 -7
  11. statemachine/dispatcher.py +8 -3
  12. statemachine/engines/async_.py +224 -180
  13. statemachine/engines/base.py +257 -245
  14. statemachine/engines/sync.py +224 -108
  15. statemachine/event.py +29 -10
  16. statemachine/event_data.py +19 -3
  17. statemachine/events.py +5 -2
  18. statemachine/exceptions.py +1 -1
  19. statemachine/factory.py +126 -29
  20. statemachine/io/__init__.py +18 -11
  21. statemachine/io/actions.py +124 -15
  22. statemachine/io/evaluators.py +37 -9
  23. statemachine/io/invoke.py +12 -0
  24. statemachine/io/loader.py +17 -3
  25. statemachine/io/schemas/statechart.schema.json +8 -1
  26. statemachine/io/scxml/__init__.py +6 -3
  27. statemachine/io/scxml/processor.py +1 -1
  28. statemachine/io/scxml/reader.py +24 -9
  29. statemachine/io/system_variables.py +34 -19
  30. statemachine/locale/en/LC_MESSAGES/statemachine.po +58 -17
  31. statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po +66 -17
  32. statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po +66 -17
  33. statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po +60 -17
  34. statemachine/orderedset.py +12 -3
  35. statemachine/registry.py +6 -1
  36. statemachine/spec_parser.py +151 -21
  37. statemachine/state.py +47 -8
  38. statemachine/statemachine.py +42 -19
  39. python_statemachine-3.2.0.dist-info/RECORD +0 -72
  40. {python_statemachine-3.2.0.dist-info → python_statemachine-3.2.2.dist-info}/licenses/LICENSE +0 -0
@@ -1,30 +1,57 @@
1
1
  import asyncio
2
- import contextvars
3
2
  from collections.abc import Callable
4
3
  from itertools import chain
5
- from time import time
4
+ from time import monotonic
6
5
  from typing import TYPE_CHECKING
7
6
 
8
7
  from ..event_data import EventData
9
8
  from ..event_data import TriggerData
10
9
  from ..exceptions import InvalidDefinition
10
+ from ..exceptions import StateMachineError
11
11
  from ..exceptions import TransitionNotAllowed
12
+ from ..i18n import _
12
13
  from ..orderedset import OrderedSet
13
14
  from ..state import State
14
15
  from .base import _ERROR_EXECUTION
15
16
  from .base import BaseEngine
17
+ from .base import _processing_step
18
+ from .base import _running_engine
16
19
 
17
20
  if TYPE_CHECKING:
21
+ from ..statemachine import StateChart
18
22
  from ..transition import Transition
19
23
 
20
- # ContextVar to distinguish reentrant calls (from within callbacks) from
21
- # concurrent external calls. asyncio propagates context to child tasks
22
- # (e.g., those created by asyncio.gather in the callback system), so a
23
- # ContextVar set in the processing loop is visible in all callbacks.
24
- # Independent external coroutines have their own context where this is False.
25
- _in_processing_loop: contextvars.ContextVar[bool] = contextvars.ContextVar(
26
- "_in_processing_loop", default=False
27
- )
24
+
25
+ class _MacrostepCaller:
26
+ """The caller of the external event whose macrostep is running, answered once the
27
+ macrostep ends, so that an exception raised by its internal events or eventless
28
+ transitions still reaches it."""
29
+
30
+ def __init__(self):
31
+ self.future: "asyncio.Future[object] | None" = None
32
+ self.result: object = None
33
+
34
+ def answer(self):
35
+ AsyncEngine._resolve_future(self.future, self.result)
36
+ self.future = None
37
+ self.result = None
38
+
39
+ def reject(self, exc: Exception) -> bool:
40
+ """Reject the caller with ``exc``; returns whether a caller was still waiting."""
41
+ if self.future is None or self.future.done():
42
+ return False
43
+ self.future.set_exception(exc)
44
+ return True
45
+
46
+ def interrupt(self):
47
+ self.reject(
48
+ StateMachineError(
49
+ _(
50
+ "The task running the processing loop was cancelled while it processed "
51
+ "this event."
52
+ )
53
+ )
54
+ )
28
55
 
29
56
 
30
57
  class AsyncEngine(BaseEngine):
@@ -34,23 +61,27 @@ class AsyncEngine(BaseEngine):
34
61
  All pure-computation helpers are inherited from :class:`BaseEngine`.
35
62
  """
36
63
 
37
- def put(self, trigger_data: TriggerData, internal: bool = False, _delayed: bool = False):
38
- """Override to attach an asyncio.Future for external events.
64
+ def __init__(self, sm: "StateChart"):
65
+ super().__init__(sm)
66
+ self._lock_waiters: "set[asyncio.Future[object]]" = set()
39
67
 
40
- Futures are only created when:
41
- - The event is external (not internal)
68
+ def put(self, trigger_data: TriggerData, internal: bool = False):
69
+ """Override to attach an asyncio.Future to a trigger put from outside the loop.
70
+
71
+ Such a trigger always lands on the external queue, and its caller awaits the future
72
+ for its result. Futures are only created when:
42
73
  - No future is already attached
43
74
  - There is a running asyncio loop
44
75
  - The call is NOT from within the processing loop (reentrant calls
45
76
  from callbacks must not get futures, as that would deadlock)
46
77
  """
47
- if not internal and trigger_data.future is None and not _in_processing_loop.get():
78
+ if trigger_data.future is None and _running_engine.get() is None:
48
79
  try:
49
80
  loop = asyncio.get_running_loop()
50
81
  trigger_data.future = loop.create_future()
51
82
  except RuntimeError:
52
83
  pass # No running loop — sync caller
53
- super().put(trigger_data, internal=internal, _delayed=_delayed)
84
+ super().put(trigger_data, internal=internal)
54
85
 
55
86
  @staticmethod
56
87
  def _resolve_future(future: "asyncio.Future[object] | None", result):
@@ -58,12 +89,6 @@ class AsyncEngine(BaseEngine):
58
89
  if future is not None and not future.done():
59
90
  future.set_result(result)
60
91
 
61
- @staticmethod
62
- def _reject_future(future: "asyncio.Future[object] | None", exc: Exception):
63
- """Reject a future with the given exception, if present and not yet done."""
64
- if future is not None and not future.done():
65
- future.set_exception(exc)
66
-
67
92
  def _reject_pending_futures(self, exc: Exception):
68
93
  """Reject all unresolved futures in the external queue."""
69
94
  self.external_queue.reject_futures(exc)
@@ -71,17 +96,27 @@ class AsyncEngine(BaseEngine):
71
96
  # --- Callback dispatch overrides (async versions of BaseEngine methods) ---
72
97
 
73
98
  async def _get_args_kwargs(
74
- self, transition: "Transition", trigger_data: TriggerData, target: "State | None" = None
99
+ self,
100
+ transition: "Transition",
101
+ trigger_data: TriggerData,
102
+ target: "State | None" = None,
103
+ source: "State | None" = None,
75
104
  ):
76
- cache_key = (id(transition), id(trigger_data), id(target))
105
+ cache_key = (id(transition), id(trigger_data), id(target), id(source))
77
106
 
78
107
  if cache_key in self._cache:
79
108
  return self._cache[cache_key]
80
109
 
81
110
  event_data = EventData(trigger_data=trigger_data, transition=transition)
111
+ # See the sync engine for the rationale: bind `state`/`target` to the
112
+ # entered state and `state`/`source` to the exited state, keeping the
113
+ # generic enter/exit callbacks symmetric across compound boundaries.
82
114
  if target:
83
115
  event_data.state = target
84
116
  event_data.target = target
117
+ if source:
118
+ event_data.state = source
119
+ event_data.source = source
85
120
 
86
121
  args, kwargs = event_data.args, event_data.extended_kwargs
87
122
 
@@ -103,7 +138,7 @@ class AsyncEngine(BaseEngine):
103
138
  transition.cond.key, *args, on_error=on_error, **kwargs
104
139
  )
105
140
 
106
- async def _first_transition_that_matches( # type: ignore[override]
141
+ async def _first_transition_that_matches(
107
142
  self,
108
143
  state: State,
109
144
  trigger_data: TriggerData,
@@ -120,7 +155,7 @@ class AsyncEngine(BaseEngine):
120
155
  return transition
121
156
  return None
122
157
 
123
- async def _select_transitions( # type: ignore[override]
158
+ async def _select_transitions(
124
159
  self, trigger_data: TriggerData, predicate: Callable
125
160
  ) -> "OrderedSet[Transition]":
126
161
  enabled_transitions: "OrderedSet[Transition]" = OrderedSet()
@@ -137,7 +172,7 @@ class AsyncEngine(BaseEngine):
137
172
  async def select_eventless_transitions(self, trigger_data: TriggerData):
138
173
  return await self._select_transitions(trigger_data, lambda t, _e: t.is_eventless)
139
174
 
140
- async def select_transitions(self, trigger_data: TriggerData) -> "OrderedSet[Transition]": # type: ignore[override]
175
+ async def select_transitions(self, trigger_data: TriggerData) -> "OrderedSet[Transition]":
141
176
  return await self._select_transitions(trigger_data, lambda t, e: t.match(e))
142
177
 
143
178
  async def _execute_transition_content(
@@ -162,7 +197,7 @@ class AsyncEngine(BaseEngine):
162
197
 
163
198
  return result
164
199
 
165
- async def _exit_states( # type: ignore[override]
200
+ async def _exit_states(
166
201
  self, enabled_transitions: "list[Transition]", trigger_data: TriggerData
167
202
  ) -> "OrderedSet[State]":
168
203
  ordered_states, result = self._prepare_exit_states(enabled_transitions)
@@ -170,22 +205,22 @@ class AsyncEngine(BaseEngine):
170
205
 
171
206
  for info in ordered_states:
172
207
  # Cancel invocations for this state before executing exit handlers.
173
- if info.state is not None: # pragma: no branch
174
- self._invoke_manager.cancel_for_state(info.state)
208
+ self._invoke_manager.cancel_for_state(info.state)
175
209
 
176
- args, kwargs = await self._get_args_kwargs(info.transition, trigger_data)
210
+ args, kwargs = await self._get_args_kwargs(
211
+ info.transition, trigger_data, source=info.state
212
+ )
177
213
 
178
- if info.state is not None: # pragma: no branch
179
- self._debug("%s Exiting state: %s", self._log_id, info.state)
180
- await self.sm._callbacks.async_call(
181
- info.state.exit.key, *args, on_error=on_error, **kwargs
182
- )
214
+ self._debug("%s Exiting state: %s", self._log_id, info.state)
215
+ await self.sm._callbacks.async_call(
216
+ info.state.exit.key, *args, on_error=on_error, **kwargs
217
+ )
183
218
 
184
219
  self._remove_state_from_configuration(info.state)
185
220
 
186
221
  return result
187
222
 
188
- async def _enter_states( # noqa: C901
223
+ async def _enter_states(
189
224
  self,
190
225
  enabled_transitions: "list[Transition]",
191
226
  trigger_data: TriggerData,
@@ -317,19 +352,6 @@ class AsyncEngine(BaseEngine):
317
352
 
318
353
  # --- Engine loop ---
319
354
 
320
- async def _run_microstep(self, enabled_transitions, trigger_data): # pragma: no cover
321
- """Run a microstep for internal/eventless transitions with error handling.
322
-
323
- Note: microstep() handles its own errors internally, so this try/except
324
- is a safety net that is not expected to be reached in normal operation.
325
- """
326
- try:
327
- await self.microstep(list(enabled_transitions), trigger_data)
328
- except InvalidDefinition:
329
- raise
330
- except Exception as e:
331
- self._handle_error(e, trigger_data)
332
-
333
355
  async def activate_initial_state(self, **kwargs):
334
356
  """Activate the initial state.
335
357
 
@@ -342,162 +364,184 @@ class AsyncEngine(BaseEngine):
342
364
  """
343
365
  return await self.processing_loop()
344
366
 
345
- async def processing_loop( # noqa: C901
346
- self, caller_future: "asyncio.Future[object] | None" = None
347
- ):
348
- """Process event triggers with the 3-phase macrostep architecture.
367
+ async def processing_loop(self, caller_future: "asyncio.Future[object] | None" = None):
368
+ """Process event triggers, a macrostep at a time.
349
369
 
350
- Phase 1: Eventless transitions + internal queue until quiescence.
351
- Phase 2: Remaining internal events (safety net for invoke-generated events).
352
- Phase 3: External events.
370
+ Each macrostep takes eventless transitions and internal events until neither enables a
371
+ transition, spawns the invocations of the states it entered, and then takes the
372
+ external events that are due.
353
373
 
354
374
  When ``caller_future`` is provided, the caller can ``await`` it to
355
375
  receive its own event's result — even if another coroutine holds the
356
- processing lock.
376
+ processing lock. If that coroutine is cancelled, the caller takes the loop over.
357
377
  """
358
- if not self._processing.acquire(blocking=False):
359
- # Another coroutine holds the lock and will process our event.
360
- # Await the caller's future so we get our own result back.
361
- if caller_future is not None:
362
- return await caller_future
363
- return None
378
+ while not self._processing.acquire(blocking=False):
379
+ if caller_future is None:
380
+ return None
381
+ await self._wait_for_result_or_release(caller_future)
382
+ if caller_future.done():
383
+ return caller_future.result()
384
+
385
+ try:
386
+ return await self._run_processing_loop(caller_future)
387
+ finally:
388
+ self._processing.release()
389
+ self._wake_lock_waiters()
390
+
391
+ async def _wait_for_result_or_release(self, caller_future: "asyncio.Future[object]"):
392
+ """Wait until the caller's event is answered or the processing lock is released.
393
+
394
+ A caller cancelled here cancels its future, so its event, still processed, answers
395
+ nobody.
396
+ """
397
+ released = caller_future.get_loop().create_future()
398
+ self._lock_waiters.add(released)
399
+ try:
400
+ await asyncio.wait((caller_future, released), return_when=asyncio.FIRST_COMPLETED)
401
+ except BaseException:
402
+ caller_future.cancel()
403
+ raise
404
+ finally:
405
+ self._lock_waiters.discard(released)
406
+
407
+ def _wake_lock_waiters(self):
408
+ for released in self._lock_waiters:
409
+ self._resolve_future(released, None)
364
410
 
365
- _ctx_token = _in_processing_loop.set(True)
411
+ async def _run_processing_loop(self, caller_future: "asyncio.Future[object] | None"):
366
412
  self._debug("%s Processing loop started: %s", self._log_id, self.sm.current_state_value)
367
413
  first_result = self._sentinel
414
+ macrostep = _MacrostepCaller()
368
415
  try:
369
- took_events = True
370
- while took_events and self.running:
371
- self.clear_cache()
372
- took_events = False
373
- macrostep_done = False
374
-
375
- # Phase 1: eventless transitions and internal events
376
- while not macrostep_done:
377
- self._microstep_count = 0
378
- self._debug(
379
- "%s Macrostep %d: eventless/internal queue",
380
- self._log_id,
381
- self._macrostep_count,
382
- )
383
-
416
+ with _processing_step(self):
417
+ took_events = True
418
+ while took_events and self.running:
384
419
  self.clear_cache()
385
- internal_event = TriggerData(self.sm, event=None) # null object for eventless
386
- enabled_transitions = await self.select_eventless_transitions(internal_event)
387
- if not enabled_transitions:
388
- if self.internal_queue.is_empty():
389
- macrostep_done = True
390
- else:
391
- internal_event = self.internal_queue.pop()
392
- enabled_transitions = await self.select_transitions(internal_event)
393
- if enabled_transitions:
394
- self._debug(
395
- "%s Enabled transitions: %s", self._log_id, enabled_transitions
396
- )
397
- took_events = True
398
- await self._run_microstep(enabled_transitions, internal_event)
399
-
400
- # Spawn invoke handlers for states entered during this macrostep.
401
- await self._invoke_manager.spawn_pending_async()
402
- self._check_root_final_state()
403
-
404
- # Phase 2: remaining internal events
405
- while not self.internal_queue.is_empty(): # pragma: no cover
406
- internal_event = self.internal_queue.pop()
407
- enabled_transitions = await self.select_transitions(internal_event)
408
- if enabled_transitions:
409
- await self._run_microstep(enabled_transitions, internal_event)
410
-
411
- # Phase 3: external events
412
- self._debug("%s Macrostep %d: external queue", self._log_id, self._macrostep_count)
413
- while not self.external_queue.is_empty():
414
- self.clear_cache()
415
- took_events = True
416
- external_event = self.external_queue.pop()
417
- current_time = time()
418
- if external_event.execution_time > current_time:
419
- self.put(external_event, _delayed=True)
420
- await asyncio.sleep(self.sm._loop_sleep_in_ms)
421
- # Break to Phase 1 so internal events and eventless
422
- # transitions can be processed while we wait.
423
- break
420
+ took_events = await self._run_macrostep()
424
421
 
425
- self._macrostep_count += 1
426
- self._microstep_count = 0
427
- self._debug(
428
- "%s macrostep %d: event=%s",
429
- self._log_id,
430
- self._macrostep_count,
431
- external_event.event,
432
- )
433
-
434
- # Handle lazy initial state activation.
435
- # Break out of phase 3 so the outer loop restarts from phase 1
436
- # (eventless/internal), ensuring internal events queued during
437
- # initial entry are processed before any external events.
438
- if external_event.event == "__initial__":
439
- transitions = self._initial_transitions(external_event)
440
- await self._enter_states(
441
- transitions, external_event, OrderedSet(), OrderedSet()
442
- )
422
+ # Spawn invoke handlers for states entered during this macrostep.
423
+ await self._invoke_manager.spawn_pending_async()
424
+ macrostep.answer()
425
+ self._check_root_final_state()
426
+ if not self.running:
443
427
  break
444
428
 
445
- # Finalize + autoforward for active invocations
446
- self._invoke_manager.handle_external_event(external_event)
429
+ took_external_events, first_result = await self._take_due_external_event(
430
+ first_result, macrostep
431
+ )
432
+ took_events = took_events or took_external_events
447
433
 
448
- event_future = external_event.future
449
- try:
450
- enabled_transitions = await self.select_transitions(external_event)
451
- self._debug(
452
- "%s Enabled transitions: %s", self._log_id, enabled_transitions
453
- )
454
- if enabled_transitions:
455
- result = await self.microstep(
456
- list(enabled_transitions), external_event
457
- )
458
- self._resolve_future(event_future, result)
459
- if first_result is self._sentinel:
460
- first_result = result
461
- else:
462
- if not self.sm.allow_event_without_transition:
463
- tna = TransitionNotAllowed(
464
- external_event.event, self.sm.configuration
465
- )
466
- self._reject_future(event_future, tna)
467
- self._reject_pending_futures(tna)
468
- raise tna
469
- # Event allowed but no transition — resolve with None
470
- self._resolve_future(event_future, None)
471
- except Exception as exc:
472
- self._reject_future(event_future, exc)
473
- self._reject_pending_futures(exc)
474
- self.clear()
475
- raise
434
+ macrostep.answer()
435
+ if not self.running:
436
+ self._drop_external_events()
476
437
 
477
438
  except Exception as exc:
478
- if caller_future is not None:
479
- # Route the exception to the caller's future if still pending.
480
- # If already resolved (caller's own event succeeded before a
481
- # later event failed), suppress the exception — the caller will
482
- # get their successful result via ``await future`` below, and
483
- # the failing event's exception was already routed to *its*
484
- # caller's future by ``_reject_future(event_future, ...)``.
485
- self._reject_future(caller_future, exc)
486
- else:
439
+ answered = caller_future is None or caller_future.done()
440
+ self._reject_pending_futures(exc)
441
+ self.clear()
442
+ if not macrostep.reject(exc) and answered:
487
443
  raise
488
- finally:
489
- _in_processing_loop.reset(_ctx_token)
490
- self._processing.release()
444
+ except BaseException:
445
+ self.internal_queue.clear()
446
+ macrostep.interrupt()
447
+ if caller_future is not None:
448
+ caller_future.cancel()
449
+ raise
491
450
 
492
451
  self._debug("%s Processing loop ended", self._log_id)
493
452
  result = first_result if first_result is not self._sentinel else None
494
- # If the caller has a future, await it (already resolved by now).
495
453
  if caller_future is not None:
496
454
  # Resolve the future if it wasn't processed (e.g. machine terminated).
497
455
  self._resolve_future(caller_future, result)
498
456
  return await caller_future
499
457
  return result
500
458
 
459
+ async def _run_macrostep(self) -> bool:
460
+ """Take eventless transitions and internal events until neither enables a transition.
461
+
462
+ Returns whether any transition was taken. The internal queue is empty on return.
463
+ """
464
+ took_events = False
465
+ while True:
466
+ self._microstep_count = 0
467
+ self._debug(
468
+ "%s Macrostep %d: eventless/internal queue",
469
+ self._log_id,
470
+ self._macrostep_count,
471
+ )
472
+
473
+ self.clear_cache()
474
+ internal_event = TriggerData(self.sm, event=None) # null object for eventless
475
+ enabled_transitions = await self.select_eventless_transitions(internal_event)
476
+ if not enabled_transitions:
477
+ if self.internal_queue.is_empty():
478
+ return took_events
479
+ internal_event = self.internal_queue.pop()
480
+ enabled_transitions = await self.select_transitions(internal_event)
481
+ if enabled_transitions:
482
+ self._debug("%s Enabled transitions: %s", self._log_id, enabled_transitions)
483
+ took_events = True
484
+ await self.microstep(list(enabled_transitions), internal_event)
485
+
486
+ async def _take_due_external_event(self, first_result, macrostep: "_MacrostepCaller"):
487
+ """Take the external event at the head of the queue, if it is due.
488
+
489
+ One at a time, so the macrostep that follows runs the internal events and eventless
490
+ transitions it enabled before the next external event is taken. The event's caller is
491
+ left in ``macrostep``, to be answered once that macrostep ends.
492
+
493
+ Returns whether the queue held an event, and ``first_result`` updated with the result
494
+ of the first event that enabled a transition.
495
+ """
496
+ self._debug("%s Macrostep %d: external queue", self._log_id, self._macrostep_count)
497
+ if self.external_queue.is_empty():
498
+ return False, first_result
499
+
500
+ self.clear_cache()
501
+ external_event = self.external_queue.pop_due(monotonic())
502
+ if external_event is None:
503
+ await asyncio.sleep(self.sm._loop_sleep_in_ms)
504
+ return True, first_result
505
+
506
+ self._macrostep_count += 1
507
+ self._microstep_count = 0
508
+ self._debug(
509
+ "%s macrostep %d: event=%s",
510
+ self._log_id,
511
+ self._macrostep_count,
512
+ external_event.event,
513
+ )
514
+
515
+ # Handle lazy initial state activation.
516
+ if external_event.event == "__initial__":
517
+ transitions = self._initial_transitions(external_event)
518
+ await self._enter_states(transitions, external_event, OrderedSet(), OrderedSet())
519
+ return True, first_result
520
+
521
+ macrostep.future = external_event.future
522
+ result = await self._take_external_event(external_event)
523
+ if result is not self._sentinel:
524
+ macrostep.result = result
525
+ if first_result is self._sentinel:
526
+ first_result = result
527
+ return True, first_result
528
+
529
+ async def _take_external_event(self, external_event: TriggerData):
530
+ """Run one external event.
531
+
532
+ Returns the event's microstep result, or the sentinel when it enabled no transition.
533
+ """
534
+ # Finalize + autoforward for active invocations
535
+ self._invoke_manager.handle_external_event(external_event)
536
+
537
+ enabled_transitions = await self.select_transitions(external_event)
538
+ self._debug("%s Enabled transitions: %s", self._log_id, enabled_transitions)
539
+ if enabled_transitions:
540
+ return await self.microstep(list(enabled_transitions), external_event)
541
+ if not self.sm.allow_event_without_transition:
542
+ raise TransitionNotAllowed(external_event.event, self.sm.configuration)
543
+ return self._sentinel
544
+
501
545
  async def enabled_events(self, *args, **kwargs):
502
546
  sm = self.sm
503
547
  enabled = {}