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,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,100 +398,6 @@ 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(
340
- self,
341
- state: State,
342
- 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
401
  def _get_args_kwargs(
422
402
  self,
423
403
  transition: Transition,
@@ -479,7 +459,7 @@ class BaseEngine:
479
459
  state = info.state
480
460
  for history in state.history:
481
461
  if history.type.is_deep:
482
- 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)]
483
463
  else: # shallow history
484
464
  history_value = [s for s in self.sm.configuration if s.parent == state]
485
465
 
@@ -499,29 +479,6 @@ class BaseEngine:
499
479
  if not self.sm.atomic_configuration_update:
500
480
  self.sm._config.discard(state)
501
481
 
502
- def _exit_states(
503
- self, enabled_transitions: list[Transition], trigger_data: TriggerData
504
- ) -> OrderedSet[State]:
505
- """Compute and process the states to exit for the given transitions."""
506
- ordered_states, result = self._prepare_exit_states(enabled_transitions)
507
- on_error = self._on_error_handler()
508
-
509
- for info in ordered_states:
510
- # Cancel invocations for this state before executing exit handlers.
511
- if info.state is not None: # pragma: no branch
512
- self._invoke_manager.cancel_for_state(info.state)
513
-
514
- args, kwargs = self._get_args_kwargs(info.transition, trigger_data, source=info.state)
515
-
516
- # Execute `onexit` handlers — same per-block error isolation as onentry.
517
- if info.state is not None: # pragma: no branch
518
- self._debug("%s Exiting state: %s", self._log_id, info.state)
519
- self.sm._callbacks.call(info.state.exit.key, *args, on_error=on_error, **kwargs)
520
-
521
- self._remove_state_from_configuration(info.state)
522
-
523
- return result
524
-
525
482
  def _execute_transition_content(
526
483
  self,
527
484
  enabled_transitions: list[Transition],
@@ -549,7 +506,7 @@ class BaseEngine:
549
506
  enabled_transitions: list[Transition],
550
507
  states_to_exit: OrderedSet[State],
551
508
  previous_configuration: OrderedSet[State],
552
- ) -> "tuple[list[StateTransition], OrderedSet[StateTransition], dict[str, Any], OrderedSet[State]]": # noqa: E501
509
+ ) -> _EntryPlan:
553
510
  """Compute entry set, ordering, and new configuration. Pure computation, no callbacks.
554
511
 
555
512
  Returns:
@@ -590,7 +547,7 @@ class BaseEngine:
590
547
  self.running = False
591
548
  try:
592
549
  self._invoke_manager.cancel_all()
593
- except Exception: # pragma: no cover
550
+ except Exception:
594
551
  self._debug("%s Error stopping engine", self._log_id, exc_info=True)
595
552
 
596
553
  def __del__(self):
@@ -633,7 +590,7 @@ class BaseEngine:
633
590
  if grandparent.parent is None:
634
591
  self._root_parallel_final_pending = grandparent
635
592
 
636
- def _enter_states( # noqa: C901
593
+ def _enter_states(
637
594
  self,
638
595
  enabled_transitions: list[Transition],
639
596
  trigger_data: TriggerData,
@@ -755,7 +712,7 @@ class BaseEngine:
755
712
  default_history_content,
756
713
  )
757
714
 
758
- def add_descendant_states_to_enter( # noqa: C901
715
+ def add_descendant_states_to_enter(
759
716
  self,
760
717
  info: StateTransition,
761
718
  states_to_enter,
@@ -775,121 +732,164 @@ class BaseEngine:
775
732
  state = info.state
776
733
 
777
734
  if state and state.is_history:
778
- # Handle history state
779
- state = cast(HistoryState, state)
780
- parent_id = state.parent and state.parent.id
781
- default_history_content[parent_id] = [info]
782
- if state.id in self.sm.history_values:
783
- self._debug(
784
- "%s History state '%s.%s' %s restoring: '%s'",
785
- self._log_id,
786
- state.parent,
787
- state,
788
- state.type.value,
789
- [s.id for s in self.sm.history_values[state.id]],
790
- )
791
- for history_state in self.sm.history_values[state.id]:
792
- info_to_add = StateTransition(transition=info.transition, state=history_state)
793
- if state.type.is_deep:
794
- states_to_enter.add(info_to_add)
795
- else:
796
- self.add_descendant_states_to_enter(
797
- info_to_add,
798
- states_to_enter,
799
- states_for_default_entry,
800
- default_history_content,
801
- )
802
- for history_state in self.sm.history_values[state.id]:
803
- info_to_add = StateTransition(transition=info.transition, state=history_state)
804
- self.add_ancestor_states_to_enter(
805
- info_to_add,
806
- state.parent,
807
- states_to_enter,
808
- states_for_default_entry,
809
- default_history_content,
810
- )
811
- else:
812
- # Handle default history content
813
- self._debug(
814
- "%s History state '%s.%s' default content: %s",
815
- self._log_id,
816
- state.parent,
817
- state,
818
- [t.target.id for t in state.transitions if t.target],
819
- )
820
-
821
- for transition in state.transitions:
822
- target = cast(State, transition.target)
823
- info_history = StateTransition(transition=transition, state=target)
824
- default_history_content[parent_id].append(info_history)
825
- self.add_descendant_states_to_enter(
826
- info_history,
827
- states_to_enter,
828
- states_for_default_entry,
829
- default_history_content,
830
- ) # noqa: E501
831
- for transition in state.transitions:
832
- target = cast(State, transition.target)
833
- info_history = StateTransition(transition=transition, state=target)
834
-
835
- self.add_ancestor_states_to_enter(
836
- info_history,
837
- state.parent,
838
- states_to_enter,
839
- states_for_default_entry,
840
- default_history_content,
841
- ) # 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
+ )
842
742
  return
843
743
 
844
744
  # Add the state to the entry set
845
- if (
846
- self.sm.enable_self_transition_entries
847
- or not info.transition.internal
848
- or not (
849
- info.transition.is_self
850
- or (
851
- info.transition.target
852
- and info.transition.target.is_descendant(info.transition.source)
853
- )
854
- )
855
- ):
745
+ if self._enters_target(info.transition):
856
746
  states_to_enter.add(info)
857
747
  state = info.state
858
748
 
859
749
  if state.parallel:
860
- for child_state in state.states:
861
- if not any( # pragma: no branch
862
- s.state.is_descendant(child_state) for s in states_to_enter
863
- ):
864
- 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:
865
798
  self.add_descendant_states_to_enter(
866
799
  info_to_add,
867
800
  states_to_enter,
868
801
  states_for_default_entry,
869
802
  default_history_content,
870
803
  )
871
- elif state.is_compound:
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)
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)
877
827
  self.add_descendant_states_to_enter(
878
- info_initial,
828
+ info_history,
879
829
  states_to_enter,
880
830
  states_for_default_entry,
881
831
  default_history_content,
882
832
  )
883
- for initial_target in transition.targets:
884
- 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
+
885
837
  self.add_ancestor_states_to_enter(
886
- info_initial,
887
- state,
838
+ info_history,
839
+ state.parent,
888
840
  states_to_enter,
889
841
  states_for_default_entry,
890
842
  default_history_content,
891
843
  )
892
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
+
893
893
  def add_ancestor_states_to_enter(
894
894
  self,
895
895
  info: StateTransition,
@@ -952,9 +952,10 @@ class BaseEngine:
952
952
  self.running = False
953
953
 
954
954
  def is_in_final_state(self, state: State) -> bool:
955
- if state.is_compound:
956
- return any(s.final and s in self.sm.configuration for s in state.states)
957
- 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:
958
960
  return all(self.is_in_final_state(s) for s in state.states)
959
- else: # pragma: no cover — atomic states are never "in final state"
960
- return False
961
+ return any(s.final and s in self.sm.configuration for s in state.states)