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,8 +1,11 @@
1
1
  import logging
2
2
  from collections.abc import Callable
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
5
+ from contextvars import ContextVar
3
6
  from dataclasses import dataclass
4
7
  from dataclasses import field
5
- from itertools import chain
8
+ from heapq import heappop
6
9
  from queue import PriorityQueue
7
10
  from queue import Queue
8
11
  from threading import Lock
@@ -26,6 +29,24 @@ if TYPE_CHECKING:
26
29
 
27
30
  logger = logging.getLogger(__name__)
28
31
 
32
+ _running_engine: "ContextVar[BaseEngine | None]" = ContextVar("_running_engine", default=None)
33
+ """The engine whose step the current thread or task is running, if any.
34
+
35
+ A context variable is per thread and per asyncio task: asyncio copies it into the tasks a
36
+ step creates, so callbacks see it, while another thread or an independent coroutine sending
37
+ to the same machine does not.
38
+ """
39
+
40
+
41
+ @contextmanager
42
+ def _processing_step(engine: "BaseEngine") -> Iterator[None]:
43
+ """Mark the current context as running a step of ``engine``, until the block ends."""
44
+ token = _running_engine.set(engine)
45
+ try:
46
+ yield
47
+ finally:
48
+ _running_engine.reset(token)
49
+
29
50
 
30
51
  @dataclass(frozen=True, unsafe_hash=True, eq=True)
31
52
  class StateTransition:
@@ -33,6 +54,13 @@ class StateTransition:
33
54
  state: State
34
55
 
35
56
 
57
+ #: What entering states needs, worked out before any callback runs: the states in entry order,
58
+ #: those entered by default, the default history content, and the new configuration.
59
+ _EntryPlan = tuple[
60
+ list[StateTransition], OrderedSet[StateTransition], dict[str, Any], OrderedSet[State]
61
+ ]
62
+
63
+
36
64
  class EventQueue:
37
65
  def __init__(self):
38
66
  self.queue: Queue = PriorityQueue()
@@ -41,7 +69,16 @@ class EventQueue:
41
69
  return f"EventQueue({self.queue.queue!r}, size={self.queue.qsize()})"
42
70
 
43
71
  def is_empty(self):
44
- return self.queue.qsize() == 0
72
+ """Whether the queue holds no trigger that has not been cancelled."""
73
+ with self.queue.mutex:
74
+ self._drop_cancelled_head()
75
+ return not self.queue.queue
76
+
77
+ def _drop_cancelled_head(self):
78
+ """Drop the cancelled triggers at the head. The caller holds the queue's mutex."""
79
+ heap = self.queue.queue
80
+ while heap and heap[0].cancelled:
81
+ heappop(heap)
45
82
 
46
83
  def put(self, trigger_data: TriggerData):
47
84
  """Put the trigger on the queue without blocking the caller."""
@@ -51,10 +88,30 @@ class EventQueue:
51
88
  """Pop a trigger from the queue without blocking the caller."""
52
89
  return self.queue.get(block=False)
53
90
 
91
+ def pop_due(self, now: float) -> "TriggerData | None":
92
+ """Pop the trigger at the head if it is due by ``now``.
93
+
94
+ Returns ``None`` when the queue is empty or its head is not yet due. The head stays in
95
+ the queue until it is popped, so a :meth:`cancel` from another thread always finds it.
96
+ """
97
+ with self.queue.mutex:
98
+ self._drop_cancelled_head()
99
+ heap: list[TriggerData] = self.queue.queue
100
+ if not heap or heap[0].execution_time > now:
101
+ return None
102
+ return heappop(heap)
103
+
54
104
  def clear(self):
55
105
  with self.queue.mutex:
56
106
  self.queue.queue.clear()
57
107
 
108
+ def drain(self) -> list[TriggerData]:
109
+ """Remove every trigger from the queue and return those not cancelled."""
110
+ with self.queue.mutex:
111
+ triggers = [trigger for trigger in self.queue.queue if not trigger.cancelled]
112
+ self.queue.queue.clear()
113
+ return triggers
114
+
58
115
  def reject_futures(self, exc: Exception):
59
116
  """Reject all unresolved futures in the queue.
60
117
 
@@ -67,15 +124,17 @@ class EventQueue:
67
124
  if future is not None and not future.done():
68
125
  future.set_exception(exc)
69
126
 
70
- def remove(self, send_id: str):
71
- # We use the internal `queue` to make thins faster as the mutex
72
- # is protecting the block below
127
+ def cancel(self, send_id: str):
128
+ """Cancel every trigger with this ``send_id``, and answer a caller awaiting one with
129
+ ``None``. Each stays where it is, so the heap keeps its order, until :meth:`is_empty`
130
+ drops it from the head."""
73
131
  with self.queue.mutex:
74
- self.queue.queue = [
75
- trigger_data
76
- for trigger_data in self.queue.queue
77
- if trigger_data.send_id != send_id
78
- ]
132
+ for trigger_data in self.queue.queue:
133
+ if trigger_data.send_id == send_id:
134
+ trigger_data.cancelled = True
135
+ future = trigger_data.future
136
+ if future is not None and not future.done():
137
+ future.set_result(None)
79
138
 
80
139
 
81
140
  _ERROR_EXECUTION = "error.execution"
@@ -97,40 +156,55 @@ class BaseEngine:
97
156
  self._debug = logger.debug if logger.isEnabledFor(logging.DEBUG) else lambda *a, **k: None
98
157
  self._root_parallel_final_pending: "State | None" = None
99
158
 
100
- def empty(self): # pragma: no cover
101
- return self.external_queue.is_empty()
102
-
103
159
  def clear_cache(self):
104
160
  """Clears the cache. Should be called at the start of each processing loop."""
105
161
  self._cache.clear()
106
162
 
107
- def put(self, trigger_data: TriggerData, internal: bool = False, _delayed: bool = False):
108
- """Put the trigger on the queue without blocking the caller."""
163
+ def put(self, trigger_data: TriggerData, internal: bool = False):
164
+ """Put the trigger on the queue without blocking the caller.
165
+
166
+ An ``internal`` trigger joins the internal queue only when this machine raises it while
167
+ running a step. Put from outside, including from a step of another machine, there is
168
+ no macrostep to join, so it goes to the external queue, where its caller gets its
169
+ result.
170
+ """
109
171
  if not self.running and not self.sm.allow_event_without_transition:
110
172
  raise TransitionNotAllowed(trigger_data.event, self.sm.configuration)
111
173
 
174
+ internal = internal and _running_engine.get() is self
112
175
  if internal:
113
176
  self.internal_queue.put(trigger_data)
114
177
  else:
115
178
  self.external_queue.put(trigger_data)
116
179
 
117
- if not _delayed:
118
- self._debug(
119
- "%s New event '%s' put on the '%s' queue",
120
- self._log_id,
121
- trigger_data.event,
122
- "internal" if internal else "external",
123
- )
124
-
125
- def pop(self): # pragma: no cover
126
- return self.external_queue.pop()
180
+ self._debug(
181
+ "%s New event '%s' put on the '%s' queue",
182
+ self._log_id,
183
+ trigger_data.event,
184
+ "internal" if internal else "external",
185
+ )
127
186
 
128
187
  def clear(self):
188
+ self.internal_queue.clear()
129
189
  self.external_queue.clear()
130
190
 
191
+ def _drop_external_events(self):
192
+ """Drop the external events left once the machine has stopped, answering a caller
193
+ awaiting one as the stopped machine answers a new event."""
194
+ for trigger_data in self.external_queue.drain():
195
+ future = trigger_data.future
196
+ if future is None or future.done():
197
+ continue
198
+ if self.sm.allow_event_without_transition:
199
+ future.set_result(None)
200
+ else:
201
+ future.set_exception(
202
+ TransitionNotAllowed(trigger_data.event, self.sm.configuration)
203
+ )
204
+
131
205
  def cancel_event(self, send_id: str):
132
206
  """Cancel the event with the given send_id."""
133
- self.external_queue.remove(send_id)
207
+ self.external_queue.cancel(send_id)
134
208
 
135
209
  def _on_error_handler(self) -> "Callable[[Exception], None] | None":
136
210
  """Return a per-block error handler, or ``None``.
@@ -324,114 +398,31 @@ class BaseEngine:
324
398
 
325
399
  return targets
326
400
 
327
- def select_eventless_transitions(self, trigger_data: TriggerData):
328
- """
329
- Select the eventless transitions that match the trigger data.
330
- """
331
- return self._select_transitions(trigger_data, lambda t, _e: t.is_eventless)
332
-
333
- def select_transitions(self, trigger_data: TriggerData) -> OrderedSet[Transition]:
334
- """
335
- Select the transitions that match the trigger data.
336
- """
337
- return self._select_transitions(trigger_data, lambda t, e: t.match(e))
338
-
339
- def _first_transition_that_matches(
401
+ def _get_args_kwargs(
340
402
  self,
341
- state: State,
403
+ transition: Transition,
342
404
  trigger_data: TriggerData,
343
- predicate: Callable,
344
- ) -> "Transition | None":
345
- for s in chain([state], state.ancestors()):
346
- transition: Transition
347
- for transition in s.transitions:
348
- if (
349
- not transition.initial
350
- and predicate(transition, trigger_data.event)
351
- and self._conditions_match(transition, trigger_data)
352
- ):
353
- return transition
354
- return None
355
-
356
- def _select_transitions(
357
- self, trigger_data: TriggerData, predicate: Callable
358
- ) -> OrderedSet[Transition]:
359
- """Select the transitions that match the trigger data."""
360
- enabled_transitions = OrderedSet[Transition]()
361
-
362
- # Get atomic states, TODO: sorted by document order
363
- atomic_states = (state for state in self.sm.configuration if state.is_atomic)
364
-
365
- for state in atomic_states:
366
- transition = self._first_transition_that_matches(state, trigger_data, predicate)
367
- if transition is not None:
368
- enabled_transitions.add(transition)
369
-
370
- return self._filter_conflicting_transitions(enabled_transitions)
371
-
372
- def microstep(self, transitions: list[Transition], trigger_data: TriggerData):
373
- """Process a single set of transitions in a 'lock step'.
374
- This includes exiting states, executing transition content, and entering states.
375
- """
376
- self._microstep_count += 1
377
- self._debug(
378
- "%s macro:%d micro:%d transitions: %s",
379
- self._log_id,
380
- self._macrostep_count,
381
- self._microstep_count,
382
- transitions,
383
- )
384
- previous_configuration = self.sm.configuration
385
- try:
386
- result = self._execute_transition_content(
387
- transitions, trigger_data, lambda t: t.before.key
388
- )
389
-
390
- states_to_exit = self._exit_states(transitions, trigger_data)
391
- result += self._enter_states(
392
- transitions, trigger_data, states_to_exit, previous_configuration
393
- )
394
- except InvalidDefinition:
395
- self.sm.configuration = previous_configuration
396
- raise
397
- except Exception as e:
398
- self.sm.configuration = previous_configuration
399
- self._handle_error(e, trigger_data)
400
- return None
401
-
402
- try:
403
- self._execute_transition_content(
404
- transitions,
405
- trigger_data,
406
- lambda t: t.after.key,
407
- set_target_as_state=True,
408
- )
409
- except InvalidDefinition:
410
- raise
411
- except Exception as e:
412
- self._handle_error(e, trigger_data)
413
-
414
- if len(result) == 0:
415
- result = None
416
- elif len(result) == 1:
417
- result = result[0]
418
-
419
- return result
420
-
421
- def _get_args_kwargs(
422
- self, transition: Transition, trigger_data: TriggerData, target: "State | None" = None
405
+ target: "State | None" = None,
406
+ source: "State | None" = None,
423
407
  ):
424
408
  # Generate a unique key for the cache, the cache is invalidated once per loop
425
- cache_key = (id(transition), id(trigger_data), id(target))
409
+ cache_key = (id(transition), id(trigger_data), id(target), id(source))
426
410
 
427
411
  # Check the cache for existing results
428
412
  if cache_key in self._cache:
429
413
  return self._cache[cache_key]
430
414
 
431
415
  event_data = EventData(trigger_data=trigger_data, transition=transition)
416
+ # Bind `state`/`target` to the individual state being entered, and
417
+ # `state`/`source` to the individual state being exited, so the generic
418
+ # `on_enter_state`/`on_exit_state` callbacks stay symmetric when crossing
419
+ # compound state boundaries (the transition only knows its own endpoints).
432
420
  if target:
433
421
  event_data.state = target
434
422
  event_data.target = target
423
+ if source:
424
+ event_data.state = source
425
+ event_data.source = source
435
426
 
436
427
  args, kwargs = event_data.args, event_data.extended_kwargs
437
428
 
@@ -468,7 +459,7 @@ class BaseEngine:
468
459
  state = info.state
469
460
  for history in state.history:
470
461
  if history.type.is_deep:
471
- history_value = [s for s in self.sm.configuration if s.is_descendant(state)] # noqa: E501
462
+ history_value = [s for s in self.sm.configuration if s.is_descendant(state)]
472
463
  else: # shallow history
473
464
  history_value = [s for s in self.sm.configuration if s.parent == state]
474
465
 
@@ -488,29 +479,6 @@ class BaseEngine:
488
479
  if not self.sm.atomic_configuration_update:
489
480
  self.sm._config.discard(state)
490
481
 
491
- def _exit_states(
492
- self, enabled_transitions: list[Transition], trigger_data: TriggerData
493
- ) -> OrderedSet[State]:
494
- """Compute and process the states to exit for the given transitions."""
495
- ordered_states, result = self._prepare_exit_states(enabled_transitions)
496
- on_error = self._on_error_handler()
497
-
498
- for info in ordered_states:
499
- # Cancel invocations for this state before executing exit handlers.
500
- if info.state is not None: # pragma: no branch
501
- self._invoke_manager.cancel_for_state(info.state)
502
-
503
- args, kwargs = self._get_args_kwargs(info.transition, trigger_data)
504
-
505
- # Execute `onexit` handlers — same per-block error isolation as onentry.
506
- if info.state is not None: # pragma: no branch
507
- self._debug("%s Exiting state: %s", self._log_id, info.state)
508
- self.sm._callbacks.call(info.state.exit.key, *args, on_error=on_error, **kwargs)
509
-
510
- self._remove_state_from_configuration(info.state)
511
-
512
- return result
513
-
514
482
  def _execute_transition_content(
515
483
  self,
516
484
  enabled_transitions: list[Transition],
@@ -538,7 +506,7 @@ class BaseEngine:
538
506
  enabled_transitions: list[Transition],
539
507
  states_to_exit: OrderedSet[State],
540
508
  previous_configuration: OrderedSet[State],
541
- ) -> "tuple[list[StateTransition], OrderedSet[StateTransition], dict[str, Any], OrderedSet[State]]": # noqa: E501
509
+ ) -> _EntryPlan:
542
510
  """Compute entry set, ordering, and new configuration. Pure computation, no callbacks.
543
511
 
544
512
  Returns:
@@ -579,7 +547,7 @@ class BaseEngine:
579
547
  self.running = False
580
548
  try:
581
549
  self._invoke_manager.cancel_all()
582
- except Exception: # pragma: no cover
550
+ except Exception:
583
551
  self._debug("%s Error stopping engine", self._log_id, exc_info=True)
584
552
 
585
553
  def __del__(self):
@@ -622,7 +590,7 @@ class BaseEngine:
622
590
  if grandparent.parent is None:
623
591
  self._root_parallel_final_pending = grandparent
624
592
 
625
- def _enter_states( # noqa: C901
593
+ def _enter_states(
626
594
  self,
627
595
  enabled_transitions: list[Transition],
628
596
  trigger_data: TriggerData,
@@ -744,7 +712,7 @@ class BaseEngine:
744
712
  default_history_content,
745
713
  )
746
714
 
747
- def add_descendant_states_to_enter( # noqa: C901
715
+ def add_descendant_states_to_enter(
748
716
  self,
749
717
  info: StateTransition,
750
718
  states_to_enter,
@@ -764,121 +732,164 @@ class BaseEngine:
764
732
  state = info.state
765
733
 
766
734
  if state and state.is_history:
767
- # Handle history state
768
- state = cast(HistoryState, state)
769
- parent_id = state.parent and state.parent.id
770
- default_history_content[parent_id] = [info]
771
- if state.id in self.sm.history_values:
772
- self._debug(
773
- "%s History state '%s.%s' %s restoring: '%s'",
774
- self._log_id,
775
- state.parent,
776
- state,
777
- state.type.value,
778
- [s.id for s in self.sm.history_values[state.id]],
779
- )
780
- for history_state in self.sm.history_values[state.id]:
781
- info_to_add = StateTransition(transition=info.transition, state=history_state)
782
- if state.type.is_deep:
783
- states_to_enter.add(info_to_add)
784
- else:
785
- self.add_descendant_states_to_enter(
786
- info_to_add,
787
- states_to_enter,
788
- states_for_default_entry,
789
- default_history_content,
790
- )
791
- for history_state in self.sm.history_values[state.id]:
792
- info_to_add = StateTransition(transition=info.transition, state=history_state)
793
- self.add_ancestor_states_to_enter(
794
- info_to_add,
795
- state.parent,
796
- states_to_enter,
797
- states_for_default_entry,
798
- default_history_content,
799
- )
800
- else:
801
- # Handle default history content
802
- self._debug(
803
- "%s History state '%s.%s' default content: %s",
804
- self._log_id,
805
- state.parent,
806
- state,
807
- [t.target.id for t in state.transitions if t.target],
808
- )
809
-
810
- for transition in state.transitions:
811
- target = cast(State, transition.target)
812
- info_history = StateTransition(transition=transition, state=target)
813
- default_history_content[parent_id].append(info_history)
814
- self.add_descendant_states_to_enter(
815
- info_history,
816
- states_to_enter,
817
- states_for_default_entry,
818
- default_history_content,
819
- ) # noqa: E501
820
- for transition in state.transitions:
821
- target = cast(State, transition.target)
822
- info_history = StateTransition(transition=transition, state=target)
823
-
824
- self.add_ancestor_states_to_enter(
825
- info_history,
826
- state.parent,
827
- states_to_enter,
828
- states_for_default_entry,
829
- default_history_content,
830
- ) # noqa: E501
735
+ self._add_history_states_to_enter(
736
+ info,
737
+ cast(HistoryState, state),
738
+ states_to_enter,
739
+ states_for_default_entry,
740
+ default_history_content,
741
+ )
831
742
  return
832
743
 
833
744
  # Add the state to the entry set
834
- if (
835
- self.sm.enable_self_transition_entries
836
- or not info.transition.internal
837
- or not (
838
- info.transition.is_self
839
- or (
840
- info.transition.target
841
- and info.transition.target.is_descendant(info.transition.source)
842
- )
843
- )
844
- ):
745
+ if self._enters_target(info.transition):
845
746
  states_to_enter.add(info)
846
747
  state = info.state
847
748
 
848
749
  if state.parallel:
849
- for child_state in state.states:
850
- if not any( # pragma: no branch
851
- s.state.is_descendant(child_state) for s in states_to_enter
852
- ):
853
- info_to_add = StateTransition(transition=info.transition, state=child_state)
750
+ self._add_regions_to_enter(
751
+ info, states_to_enter, states_for_default_entry, default_history_content
752
+ )
753
+ elif state.is_compound:
754
+ self._add_initial_states_to_enter(
755
+ info, states_to_enter, states_for_default_entry, default_history_content
756
+ )
757
+
758
+ def _enters_target(self, transition: Transition) -> bool:
759
+ """Whether taking ``transition`` enters its target.
760
+
761
+ An internal transition to its own source, or to a descendant of it, does not, unless
762
+ the machine enables self-transition entries.
763
+ """
764
+ return (
765
+ self.sm.enable_self_transition_entries
766
+ or not transition.internal
767
+ or not (
768
+ transition.is_self
769
+ or (transition.target and transition.target.is_descendant(transition.source))
770
+ )
771
+ )
772
+
773
+ def _add_history_states_to_enter(
774
+ self,
775
+ info: StateTransition,
776
+ state: HistoryState,
777
+ states_to_enter,
778
+ states_for_default_entry,
779
+ default_history_content,
780
+ ):
781
+ """Add the states a history state stands for: those it recorded, or its default."""
782
+ parent_id = state.parent and state.parent.id
783
+ default_history_content[parent_id] = [info]
784
+ if state.id in self.sm.history_values:
785
+ self._debug(
786
+ "%s History state '%s.%s' %s restoring: '%s'",
787
+ self._log_id,
788
+ state.parent,
789
+ state,
790
+ state.type.value,
791
+ [s.id for s in self.sm.history_values[state.id]],
792
+ )
793
+ for history_state in self.sm.history_values[state.id]:
794
+ info_to_add = StateTransition(transition=info.transition, state=history_state)
795
+ if state.type.is_deep:
796
+ states_to_enter.add(info_to_add)
797
+ else:
854
798
  self.add_descendant_states_to_enter(
855
799
  info_to_add,
856
800
  states_to_enter,
857
801
  states_for_default_entry,
858
802
  default_history_content,
859
803
  )
860
- elif state.is_compound:
861
- states_for_default_entry.add(info)
862
- transition = next(t for t in state.transitions if t.initial)
863
- # Process all targets (supports multi-target initial transitions for parallel regions)
864
- for initial_target in transition.targets:
865
- info_initial = StateTransition(transition=transition, state=initial_target)
804
+ for history_state in self.sm.history_values[state.id]:
805
+ info_to_add = StateTransition(transition=info.transition, state=history_state)
806
+ self.add_ancestor_states_to_enter(
807
+ info_to_add,
808
+ state.parent,
809
+ states_to_enter,
810
+ states_for_default_entry,
811
+ default_history_content,
812
+ )
813
+ else:
814
+ # Handle default history content
815
+ self._debug(
816
+ "%s History state '%s.%s' default content: %s",
817
+ self._log_id,
818
+ state.parent,
819
+ state,
820
+ [t.target.id for t in state.transitions if t.target],
821
+ )
822
+
823
+ for transition in state.transitions:
824
+ target = cast(State, transition.target)
825
+ info_history = StateTransition(transition=transition, state=target)
826
+ default_history_content[parent_id].append(info_history)
866
827
  self.add_descendant_states_to_enter(
867
- info_initial,
828
+ info_history,
868
829
  states_to_enter,
869
830
  states_for_default_entry,
870
831
  default_history_content,
871
832
  )
872
- for initial_target in transition.targets:
873
- info_initial = StateTransition(transition=transition, state=initial_target)
833
+ for transition in state.transitions:
834
+ target = cast(State, transition.target)
835
+ info_history = StateTransition(transition=transition, state=target)
836
+
874
837
  self.add_ancestor_states_to_enter(
875
- info_initial,
876
- state,
838
+ info_history,
839
+ state.parent,
877
840
  states_to_enter,
878
841
  states_for_default_entry,
879
842
  default_history_content,
880
843
  )
881
844
 
845
+ def _add_regions_to_enter(
846
+ self,
847
+ info: StateTransition,
848
+ states_to_enter,
849
+ states_for_default_entry,
850
+ default_history_content,
851
+ ):
852
+ """Add each region of a parallel state, unless a state inside it is already entered."""
853
+ for child_state in info.state.states:
854
+ if not any(s.state.is_descendant(child_state) for s in states_to_enter):
855
+ info_to_add = StateTransition(transition=info.transition, state=child_state)
856
+ self.add_descendant_states_to_enter(
857
+ info_to_add,
858
+ states_to_enter,
859
+ states_for_default_entry,
860
+ default_history_content,
861
+ )
862
+
863
+ def _add_initial_states_to_enter(
864
+ self,
865
+ info: StateTransition,
866
+ states_to_enter,
867
+ states_for_default_entry,
868
+ default_history_content,
869
+ ):
870
+ """Add the states a compound state's initial transition targets."""
871
+ state = info.state
872
+ states_for_default_entry.add(info)
873
+ transition = next(t for t in state.transitions if t.initial)
874
+ # Process all targets (supports multi-target initial transitions for parallel regions)
875
+ for initial_target in transition.targets:
876
+ info_initial = StateTransition(transition=transition, state=initial_target)
877
+ self.add_descendant_states_to_enter(
878
+ info_initial,
879
+ states_to_enter,
880
+ states_for_default_entry,
881
+ default_history_content,
882
+ )
883
+ for initial_target in transition.targets:
884
+ info_initial = StateTransition(transition=transition, state=initial_target)
885
+ self.add_ancestor_states_to_enter(
886
+ info_initial,
887
+ state,
888
+ states_to_enter,
889
+ states_for_default_entry,
890
+ default_history_content,
891
+ )
892
+
882
893
  def add_ancestor_states_to_enter(
883
894
  self,
884
895
  info: StateTransition,
@@ -941,9 +952,10 @@ class BaseEngine:
941
952
  self.running = False
942
953
 
943
954
  def is_in_final_state(self, state: State) -> bool:
944
- if state.is_compound:
945
- return any(s.final and s in self.sm.configuration for s in state.states)
946
- elif state.parallel: # pragma: no cover — requires nested parallel-in-parallel
955
+ """Whether every region of a parallel state, or a compound state, rests in a final child.
956
+
957
+ An atomic state has no children, so it never does.
958
+ """
959
+ if state.parallel:
947
960
  return all(self.is_in_final_state(s) for s in state.states)
948
- else: # pragma: no cover — atomic states are never "in final state"
949
- return False
961
+ return any(s.final and s in self.sm.configuration for s in state.states)