python-statemachine 3.2.1__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.
@@ -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)
@@ -113,7 +138,7 @@ class AsyncEngine(BaseEngine):
113
138
  transition.cond.key, *args, on_error=on_error, **kwargs
114
139
  )
115
140
 
116
- async def _first_transition_that_matches( # type: ignore[override]
141
+ async def _first_transition_that_matches(
117
142
  self,
118
143
  state: State,
119
144
  trigger_data: TriggerData,
@@ -130,7 +155,7 @@ class AsyncEngine(BaseEngine):
130
155
  return transition
131
156
  return None
132
157
 
133
- async def _select_transitions( # type: ignore[override]
158
+ async def _select_transitions(
134
159
  self, trigger_data: TriggerData, predicate: Callable
135
160
  ) -> "OrderedSet[Transition]":
136
161
  enabled_transitions: "OrderedSet[Transition]" = OrderedSet()
@@ -147,7 +172,7 @@ class AsyncEngine(BaseEngine):
147
172
  async def select_eventless_transitions(self, trigger_data: TriggerData):
148
173
  return await self._select_transitions(trigger_data, lambda t, _e: t.is_eventless)
149
174
 
150
- async def select_transitions(self, trigger_data: TriggerData) -> "OrderedSet[Transition]": # type: ignore[override]
175
+ async def select_transitions(self, trigger_data: TriggerData) -> "OrderedSet[Transition]":
151
176
  return await self._select_transitions(trigger_data, lambda t, e: t.match(e))
152
177
 
153
178
  async def _execute_transition_content(
@@ -172,7 +197,7 @@ class AsyncEngine(BaseEngine):
172
197
 
173
198
  return result
174
199
 
175
- async def _exit_states( # type: ignore[override]
200
+ async def _exit_states(
176
201
  self, enabled_transitions: "list[Transition]", trigger_data: TriggerData
177
202
  ) -> "OrderedSet[State]":
178
203
  ordered_states, result = self._prepare_exit_states(enabled_transitions)
@@ -180,24 +205,22 @@ class AsyncEngine(BaseEngine):
180
205
 
181
206
  for info in ordered_states:
182
207
  # Cancel invocations for this state before executing exit handlers.
183
- if info.state is not None: # pragma: no branch
184
- self._invoke_manager.cancel_for_state(info.state)
208
+ self._invoke_manager.cancel_for_state(info.state)
185
209
 
186
210
  args, kwargs = await self._get_args_kwargs(
187
211
  info.transition, trigger_data, source=info.state
188
212
  )
189
213
 
190
- if info.state is not None: # pragma: no branch
191
- self._debug("%s Exiting state: %s", self._log_id, info.state)
192
- await self.sm._callbacks.async_call(
193
- info.state.exit.key, *args, on_error=on_error, **kwargs
194
- )
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
+ )
195
218
 
196
219
  self._remove_state_from_configuration(info.state)
197
220
 
198
221
  return result
199
222
 
200
- async def _enter_states( # noqa: C901
223
+ async def _enter_states(
201
224
  self,
202
225
  enabled_transitions: "list[Transition]",
203
226
  trigger_data: TriggerData,
@@ -329,19 +352,6 @@ class AsyncEngine(BaseEngine):
329
352
 
330
353
  # --- Engine loop ---
331
354
 
332
- async def _run_microstep(self, enabled_transitions, trigger_data): # pragma: no cover
333
- """Run a microstep for internal/eventless transitions with error handling.
334
-
335
- Note: microstep() handles its own errors internally, so this try/except
336
- is a safety net that is not expected to be reached in normal operation.
337
- """
338
- try:
339
- await self.microstep(list(enabled_transitions), trigger_data)
340
- except InvalidDefinition:
341
- raise
342
- except Exception as e:
343
- self._handle_error(e, trigger_data)
344
-
345
355
  async def activate_initial_state(self, **kwargs):
346
356
  """Activate the initial state.
347
357
 
@@ -354,162 +364,184 @@ class AsyncEngine(BaseEngine):
354
364
  """
355
365
  return await self.processing_loop()
356
366
 
357
- async def processing_loop( # noqa: C901
358
- self, caller_future: "asyncio.Future[object] | None" = None
359
- ):
360
- """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.
361
369
 
362
- Phase 1: Eventless transitions + internal queue until quiescence.
363
- Phase 2: Remaining internal events (safety net for invoke-generated events).
364
- 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.
365
373
 
366
374
  When ``caller_future`` is provided, the caller can ``await`` it to
367
375
  receive its own event's result — even if another coroutine holds the
368
- processing lock.
376
+ processing lock. If that coroutine is cancelled, the caller takes the loop over.
369
377
  """
370
- if not self._processing.acquire(blocking=False):
371
- # Another coroutine holds the lock and will process our event.
372
- # Await the caller's future so we get our own result back.
373
- if caller_future is not None:
374
- return await caller_future
375
- 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)
376
410
 
377
- _ctx_token = _in_processing_loop.set(True)
411
+ async def _run_processing_loop(self, caller_future: "asyncio.Future[object] | None"):
378
412
  self._debug("%s Processing loop started: %s", self._log_id, self.sm.current_state_value)
379
413
  first_result = self._sentinel
414
+ macrostep = _MacrostepCaller()
380
415
  try:
381
- took_events = True
382
- while took_events and self.running:
383
- self.clear_cache()
384
- took_events = False
385
- macrostep_done = False
386
-
387
- # Phase 1: eventless transitions and internal events
388
- while not macrostep_done:
389
- self._microstep_count = 0
390
- self._debug(
391
- "%s Macrostep %d: eventless/internal queue",
392
- self._log_id,
393
- self._macrostep_count,
394
- )
395
-
416
+ with _processing_step(self):
417
+ took_events = True
418
+ while took_events and self.running:
396
419
  self.clear_cache()
397
- internal_event = TriggerData(self.sm, event=None) # null object for eventless
398
- enabled_transitions = await self.select_eventless_transitions(internal_event)
399
- if not enabled_transitions:
400
- if self.internal_queue.is_empty():
401
- macrostep_done = True
402
- else:
403
- internal_event = self.internal_queue.pop()
404
- enabled_transitions = await self.select_transitions(internal_event)
405
- if enabled_transitions:
406
- self._debug(
407
- "%s Enabled transitions: %s", self._log_id, enabled_transitions
408
- )
409
- took_events = True
410
- await self._run_microstep(enabled_transitions, internal_event)
411
-
412
- # Spawn invoke handlers for states entered during this macrostep.
413
- await self._invoke_manager.spawn_pending_async()
414
- self._check_root_final_state()
415
-
416
- # Phase 2: remaining internal events
417
- while not self.internal_queue.is_empty(): # pragma: no cover
418
- internal_event = self.internal_queue.pop()
419
- enabled_transitions = await self.select_transitions(internal_event)
420
- if enabled_transitions:
421
- await self._run_microstep(enabled_transitions, internal_event)
422
-
423
- # Phase 3: external events
424
- self._debug("%s Macrostep %d: external queue", self._log_id, self._macrostep_count)
425
- while not self.external_queue.is_empty():
426
- self.clear_cache()
427
- took_events = True
428
- external_event = self.external_queue.pop()
429
- current_time = time()
430
- if external_event.execution_time > current_time:
431
- self.put(external_event, _delayed=True)
432
- await asyncio.sleep(self.sm._loop_sleep_in_ms)
433
- # Break to Phase 1 so internal events and eventless
434
- # transitions can be processed while we wait.
435
- break
436
-
437
- self._macrostep_count += 1
438
- self._microstep_count = 0
439
- self._debug(
440
- "%s macrostep %d: event=%s",
441
- self._log_id,
442
- self._macrostep_count,
443
- external_event.event,
444
- )
420
+ took_events = await self._run_macrostep()
445
421
 
446
- # Handle lazy initial state activation.
447
- # Break out of phase 3 so the outer loop restarts from phase 1
448
- # (eventless/internal), ensuring internal events queued during
449
- # initial entry are processed before any external events.
450
- if external_event.event == "__initial__":
451
- transitions = self._initial_transitions(external_event)
452
- await self._enter_states(
453
- transitions, external_event, OrderedSet(), OrderedSet()
454
- )
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:
455
427
  break
456
428
 
457
- # Finalize + autoforward for active invocations
458
- 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
459
433
 
460
- event_future = external_event.future
461
- try:
462
- enabled_transitions = await self.select_transitions(external_event)
463
- self._debug(
464
- "%s Enabled transitions: %s", self._log_id, enabled_transitions
465
- )
466
- if enabled_transitions:
467
- result = await self.microstep(
468
- list(enabled_transitions), external_event
469
- )
470
- self._resolve_future(event_future, result)
471
- if first_result is self._sentinel:
472
- first_result = result
473
- else:
474
- if not self.sm.allow_event_without_transition:
475
- tna = TransitionNotAllowed(
476
- external_event.event, self.sm.configuration
477
- )
478
- self._reject_future(event_future, tna)
479
- self._reject_pending_futures(tna)
480
- raise tna
481
- # Event allowed but no transition — resolve with None
482
- self._resolve_future(event_future, None)
483
- except Exception as exc:
484
- self._reject_future(event_future, exc)
485
- self._reject_pending_futures(exc)
486
- self.clear()
487
- raise
434
+ macrostep.answer()
435
+ if not self.running:
436
+ self._drop_external_events()
488
437
 
489
438
  except Exception as exc:
490
- if caller_future is not None:
491
- # Route the exception to the caller's future if still pending.
492
- # If already resolved (caller's own event succeeded before a
493
- # later event failed), suppress the exception — the caller will
494
- # get their successful result via ``await future`` below, and
495
- # the failing event's exception was already routed to *its*
496
- # caller's future by ``_reject_future(event_future, ...)``.
497
- self._reject_future(caller_future, exc)
498
- 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:
499
443
  raise
500
- finally:
501
- _in_processing_loop.reset(_ctx_token)
502
- 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
503
450
 
504
451
  self._debug("%s Processing loop ended", self._log_id)
505
452
  result = first_result if first_result is not self._sentinel else None
506
- # If the caller has a future, await it (already resolved by now).
507
453
  if caller_future is not None:
508
454
  # Resolve the future if it wasn't processed (e.g. machine terminated).
509
455
  self._resolve_future(caller_future, result)
510
456
  return await caller_future
511
457
  return result
512
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
+
513
545
  async def enabled_events(self, *args, **kwargs):
514
546
  sm = self.sm
515
547
  enabled = {}