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,6 +1,7 @@
1
+ from collections.abc import Callable
2
+ from itertools import chain
3
+ from time import monotonic
1
4
  from time import sleep
2
- from time import time
3
- from typing import TYPE_CHECKING
4
5
 
5
6
  from statemachine.event import BoundEvent
6
7
  from statemachine.orderedset import OrderedSet
@@ -8,26 +9,13 @@ from statemachine.orderedset import OrderedSet
8
9
  from ..event_data import TriggerData
9
10
  from ..exceptions import InvalidDefinition
10
11
  from ..exceptions import TransitionNotAllowed
12
+ from ..state import State
13
+ from ..transition import Transition
11
14
  from .base import BaseEngine
12
-
13
- if TYPE_CHECKING:
14
- from ..transition import Transition
15
+ from .base import _processing_step
15
16
 
16
17
 
17
18
  class SyncEngine(BaseEngine):
18
- def _run_microstep(self, enabled_transitions, trigger_data):
19
- """Run a microstep for internal/eventless transitions with error handling.
20
-
21
- Note: microstep() handles its own errors internally, so this try/except
22
- is a safety net that is not expected to be reached in normal operation.
23
- """
24
- try:
25
- self.microstep(list(enabled_transitions), trigger_data)
26
- except InvalidDefinition:
27
- raise
28
- except Exception as e: # pragma: no cover
29
- self._handle_error(e, trigger_data)
30
-
31
19
  def start(self, **kwargs):
32
20
  if self.sm.current_state_value is not None:
33
21
  return
@@ -51,12 +39,13 @@ class SyncEngine(BaseEngine):
51
39
  transitions = self._initial_transitions(trigger_data)
52
40
  self._processing.acquire(blocking=False)
53
41
  try:
54
- self._enter_states(transitions, trigger_data, OrderedSet(), OrderedSet())
42
+ with _processing_step(self):
43
+ self._enter_states(transitions, trigger_data, OrderedSet(), OrderedSet())
55
44
  finally:
56
45
  self._processing.release()
57
46
  return self.processing_loop()
58
47
 
59
- def processing_loop(self, caller_future=None): # noqa: C901
48
+ def processing_loop(self):
60
49
  """Process event triggers.
61
50
 
62
51
  The event is put on a queue, and only the first event will have the result collected.
@@ -77,104 +66,231 @@ class SyncEngine(BaseEngine):
77
66
  self._debug("%s Processing loop started: %s", self._log_id, self.sm.current_state_value)
78
67
  first_result = self._sentinel
79
68
  try:
80
- took_events = True
81
- while took_events and self.running:
82
- self.clear_cache()
83
- took_events = False
84
- # Execute the triggers in the queue in FIFO order until the queue is empty
85
- # while self._running and not self.external_queue.is_empty():
86
- macrostep_done = False
87
- enabled_transitions: "OrderedSet[Transition] | None" = None
88
-
89
- # handles eventless transitions and internal events
90
- while not macrostep_done:
91
- self._microstep_count = 0
92
- self._debug(
93
- "%s Macrostep %d: eventless/internal queue",
94
- self._log_id,
95
- self._macrostep_count,
96
- )
97
-
98
- self.clear_cache()
99
- internal_event = TriggerData(
100
- self.sm, event=None
101
- ) # this one is a "null object"
102
- enabled_transitions = self.select_eventless_transitions(internal_event)
103
- if not enabled_transitions:
104
- if self.internal_queue.is_empty():
105
- macrostep_done = True
106
- else:
107
- internal_event = self.internal_queue.pop()
108
- enabled_transitions = self.select_transitions(internal_event)
109
- if enabled_transitions:
110
- self._debug(
111
- "%s Enabled transitions: %s", self._log_id, enabled_transitions
112
- )
113
- took_events = True
114
- self._run_microstep(enabled_transitions, internal_event)
115
-
116
- # Spawn invoke handlers for states entered during this macrostep.
117
- self._invoke_manager.spawn_pending_sync()
118
- self._check_root_final_state()
119
-
120
- # Process remaining internal events before external events.
121
- # Note: the macrostep loop above already drains the internal queue,
122
- # so this is a safety net per SCXML spec for invoke-generated events.
123
- while not self.internal_queue.is_empty(): # pragma: no cover
124
- internal_event = self.internal_queue.pop()
125
- enabled_transitions = self.select_transitions(internal_event)
126
- if enabled_transitions:
127
- self._run_microstep(enabled_transitions, internal_event)
128
-
129
- # Process external events
130
- self._debug("%s Macrostep %d: external queue", self._log_id, self._macrostep_count)
131
- while not self.external_queue.is_empty():
69
+ with _processing_step(self):
70
+ took_events = True
71
+ while took_events and self.running:
132
72
  self.clear_cache()
133
- took_events = True
134
- external_event = self.external_queue.pop()
135
- current_time = time()
136
- if external_event.execution_time > current_time:
137
- self.put(external_event, _delayed=True)
138
- sleep(self.sm._loop_sleep_in_ms)
139
- # Break to Phase 1 so internal events and eventless
140
- # transitions can be processed while we wait.
73
+ try:
74
+ took_events = self._run_macrostep()
75
+ # Spawn invoke handlers for states entered during this macrostep.
76
+ self._invoke_manager.spawn_pending_sync()
77
+ except Exception:
78
+ self.clear()
79
+ raise
80
+ self._check_root_final_state()
81
+ if not self.running:
141
82
  break
142
83
 
143
- self._macrostep_count += 1
144
- self._microstep_count = 0
145
- self._debug(
146
- "%s macrostep %d: event=%s",
147
- self._log_id,
148
- self._macrostep_count,
149
- external_event.event,
84
+ took_external_events, first_result = self._take_due_external_event(
85
+ first_result
150
86
  )
87
+ took_events = took_events or took_external_events
151
88
 
152
- # Finalize + autoforward for active invocations
153
- self._invoke_manager.handle_external_event(external_event)
154
-
155
- enabled_transitions = self.select_transitions(external_event)
156
- self._debug("%s Enabled transitions: %s", self._log_id, enabled_transitions)
157
- if enabled_transitions:
158
- try:
159
- result = self.microstep(list(enabled_transitions), external_event)
160
- if first_result is self._sentinel:
161
- first_result = result
162
-
163
- except Exception:
164
- # We clear the queue as we don't have an expected behavior
165
- # and cannot keep processing
166
- self.clear()
167
- raise
168
-
169
- else:
170
- if not self.sm.allow_event_without_transition:
171
- raise TransitionNotAllowed(external_event.event, self.sm.configuration)
172
-
89
+ if not self.running:
90
+ self._drop_external_events()
173
91
  finally:
174
92
  self._processing.release()
175
93
  self._debug("%s Processing loop ended", self._log_id)
176
94
  return first_result if first_result is not self._sentinel else None
177
95
 
96
+ def _run_macrostep(self) -> bool:
97
+ """Take eventless transitions and internal events until neither enables a transition.
98
+
99
+ Returns whether any transition was taken. The internal queue is empty on return.
100
+ """
101
+ took_events = False
102
+ while True:
103
+ self._microstep_count = 0
104
+ self._debug(
105
+ "%s Macrostep %d: eventless/internal queue",
106
+ self._log_id,
107
+ self._macrostep_count,
108
+ )
109
+
110
+ self.clear_cache()
111
+ internal_event = TriggerData(self.sm, event=None) # this one is a "null object"
112
+ enabled_transitions = self.select_eventless_transitions(internal_event)
113
+ if not enabled_transitions:
114
+ if self.internal_queue.is_empty():
115
+ return took_events
116
+ internal_event = self.internal_queue.pop()
117
+ enabled_transitions = self.select_transitions(internal_event)
118
+ if enabled_transitions:
119
+ self._debug("%s Enabled transitions: %s", self._log_id, enabled_transitions)
120
+ took_events = True
121
+ self.microstep(list(enabled_transitions), internal_event)
122
+
123
+ def _take_due_external_event(self, first_result):
124
+ """Take the external event at the head of the queue, if it is due.
125
+
126
+ One at a time, so the macrostep that follows runs the internal events and eventless
127
+ transitions it enabled before the next external event is taken.
128
+
129
+ Returns whether the queue held an event, and ``first_result`` updated with the result
130
+ of the first event that enabled a transition.
131
+ """
132
+ self._debug("%s Macrostep %d: external queue", self._log_id, self._macrostep_count)
133
+ if self.external_queue.is_empty():
134
+ return False, first_result
135
+
136
+ self.clear_cache()
137
+ external_event = self.external_queue.pop_due(monotonic())
138
+ if external_event is None:
139
+ sleep(self.sm._loop_sleep_in_ms)
140
+ return True, first_result
141
+
142
+ self._macrostep_count += 1
143
+ self._microstep_count = 0
144
+ self._debug(
145
+ "%s macrostep %d: event=%s",
146
+ self._log_id,
147
+ self._macrostep_count,
148
+ external_event.event,
149
+ )
150
+
151
+ result = self._take_external_event(external_event)
152
+ if first_result is self._sentinel:
153
+ first_result = result
154
+ return True, first_result
155
+
156
+ def _take_external_event(self, external_event: TriggerData):
157
+ """Run one external event.
158
+
159
+ Returns the event's microstep result, or the sentinel when it enabled no transition.
160
+ """
161
+ # Finalize + autoforward for active invocations
162
+ self._invoke_manager.handle_external_event(external_event)
163
+
164
+ enabled_transitions = self.select_transitions(external_event)
165
+ self._debug("%s Enabled transitions: %s", self._log_id, enabled_transitions)
166
+ if not enabled_transitions:
167
+ if not self.sm.allow_event_without_transition:
168
+ raise TransitionNotAllowed(external_event.event, self.sm.configuration)
169
+ return self._sentinel
170
+
171
+ try:
172
+ return self.microstep(list(enabled_transitions), external_event)
173
+ except Exception:
174
+ # We clear the queue as we don't have an expected behavior
175
+ # and cannot keep processing
176
+ self.clear()
177
+ raise
178
+
179
+ def select_eventless_transitions(self, trigger_data: TriggerData):
180
+ """
181
+ Select the eventless transitions that match the trigger data.
182
+ """
183
+ return self._select_transitions(trigger_data, lambda t, _e: t.is_eventless)
184
+
185
+ def select_transitions(self, trigger_data: TriggerData) -> OrderedSet[Transition]:
186
+ """
187
+ Select the transitions that match the trigger data.
188
+ """
189
+ return self._select_transitions(trigger_data, lambda t, e: t.match(e))
190
+
191
+ def _first_transition_that_matches(
192
+ self,
193
+ state: State,
194
+ trigger_data: TriggerData,
195
+ predicate: Callable,
196
+ ) -> "Transition | None":
197
+ for s in chain([state], state.ancestors()):
198
+ transition: Transition
199
+ for transition in s.transitions:
200
+ if (
201
+ not transition.initial
202
+ and predicate(transition, trigger_data.event)
203
+ and self._conditions_match(transition, trigger_data)
204
+ ):
205
+ return transition
206
+ return None
207
+
208
+ def _select_transitions(
209
+ self, trigger_data: TriggerData, predicate: Callable
210
+ ) -> OrderedSet[Transition]:
211
+ """Select the transitions that match the trigger data."""
212
+ enabled_transitions = OrderedSet[Transition]()
213
+
214
+ # Get atomic states, TODO: sorted by document order
215
+ atomic_states = (state for state in self.sm.configuration if state.is_atomic)
216
+
217
+ for state in atomic_states:
218
+ transition = self._first_transition_that_matches(state, trigger_data, predicate)
219
+ if transition is not None:
220
+ enabled_transitions.add(transition)
221
+
222
+ return self._filter_conflicting_transitions(enabled_transitions)
223
+
224
+ def microstep(self, transitions: list[Transition], trigger_data: TriggerData):
225
+ """Process a single set of transitions in a 'lock step'.
226
+ This includes exiting states, executing transition content, and entering states.
227
+ """
228
+ self._microstep_count += 1
229
+ self._debug(
230
+ "%s macro:%d micro:%d transitions: %s",
231
+ self._log_id,
232
+ self._macrostep_count,
233
+ self._microstep_count,
234
+ transitions,
235
+ )
236
+ previous_configuration = self.sm.configuration
237
+ try:
238
+ result = self._execute_transition_content(
239
+ transitions, trigger_data, lambda t: t.before.key
240
+ )
241
+
242
+ states_to_exit = self._exit_states(transitions, trigger_data)
243
+ result += self._enter_states(
244
+ transitions, trigger_data, states_to_exit, previous_configuration
245
+ )
246
+ except InvalidDefinition:
247
+ self.sm.configuration = previous_configuration
248
+ raise
249
+ except Exception as e:
250
+ self.sm.configuration = previous_configuration
251
+ self._handle_error(e, trigger_data)
252
+ return None
253
+
254
+ try:
255
+ self._execute_transition_content(
256
+ transitions,
257
+ trigger_data,
258
+ lambda t: t.after.key,
259
+ set_target_as_state=True,
260
+ )
261
+ except InvalidDefinition:
262
+ raise
263
+ except Exception as e:
264
+ self._handle_error(e, trigger_data)
265
+
266
+ if len(result) == 0:
267
+ result = None
268
+ elif len(result) == 1:
269
+ result = result[0]
270
+
271
+ return result
272
+
273
+ def _exit_states(
274
+ self, enabled_transitions: list[Transition], trigger_data: TriggerData
275
+ ) -> OrderedSet[State]:
276
+ """Compute and process the states to exit for the given transitions."""
277
+ ordered_states, result = self._prepare_exit_states(enabled_transitions)
278
+ on_error = self._on_error_handler()
279
+
280
+ for info in ordered_states:
281
+ # Cancel invocations for this state before executing exit handlers.
282
+ self._invoke_manager.cancel_for_state(info.state)
283
+
284
+ args, kwargs = self._get_args_kwargs(info.transition, trigger_data, source=info.state)
285
+
286
+ # Execute `onexit` handlers — same per-block error isolation as onentry.
287
+ self._debug("%s Exiting state: %s", self._log_id, info.state)
288
+ self.sm._callbacks.call(info.state.exit.key, *args, on_error=on_error, **kwargs)
289
+
290
+ self._remove_state_from_configuration(info.state)
291
+
292
+ return result
293
+
178
294
  def enabled_events(self, *args, **kwargs):
179
295
  sm = self.sm
180
296
  enabled = {}
statemachine/event.py CHANGED
@@ -16,22 +16,22 @@ if TYPE_CHECKING:
16
16
  from .transition_list import TransitionList
17
17
 
18
18
 
19
- def _expand_event_id(key: str) -> str:
20
- """Apply naming conventions for special event prefixes.
19
+ def _expand_event_id(key: str) -> list[str]:
20
+ """Return the event ids an attribute name declares.
21
21
 
22
- Converts underscore-based Python attribute names to their dot-separated
23
- event equivalents. Returns a space-separated string so ``Events.add()``
24
- registers both forms.
22
+ ``done_invoke_X``, ``done_state_X`` and ``error_X`` also declare their SCXML dotted form
23
+ (``done.invoke.X``, ``done.state.X``, ``error.X``), each registered as its own event. Any
24
+ other name declares only itself.
25
25
  """
26
26
  if key.startswith("done_invoke_"):
27
27
  suffix = key[len("done_invoke_") :]
28
- return f"{key} done.invoke.{suffix}"
28
+ return [key, f"done.invoke.{suffix}"]
29
29
  if key.startswith("done_state_"):
30
30
  suffix = key[len("done_state_") :]
31
- return f"{key} done.state.{suffix}"
31
+ return [key, f"done.state.{suffix}"]
32
32
  if key.startswith("error_"):
33
- return f"{key} {key.replace('_', '.')}"
34
- return key
33
+ return [key, key.replace("_", ".")]
34
+ return [key]
35
35
 
36
36
 
37
37
  _event_data_kwargs = {
@@ -46,6 +46,20 @@ _event_data_kwargs = {
46
46
  }
47
47
 
48
48
 
49
+ def raise_delayed_internal(event_id: str):
50
+ """Reject an event that asks to be both delayed and internal.
51
+
52
+ The internal queue runs within the current macrostep, which takes no time.
53
+ """
54
+ raise InvalidDefinition(
55
+ _(
56
+ "Event {id!r} cannot be both delayed and internal: the internal queue runs "
57
+ "within the current macrostep, which takes no time. Drop 'internal' to "
58
+ "deliver it later on the external queue, or drop 'delay' to run it now."
59
+ ).format(id=event_id)
60
+ )
61
+
62
+
49
63
  class Event(AddCallbacksMixin, str):
50
64
  """An event triggers a signal that something has happened.
51
65
 
@@ -100,6 +114,9 @@ class Event(AddCallbacksMixin, str):
100
114
  _has_real_id = id is not None
101
115
  id = str(id) if _has_real_id else f"__event__{uuid4().hex}"
102
116
 
117
+ if delay and internal and _has_real_id:
118
+ raise_delayed_internal(id)
119
+
103
120
  instance = super().__new__(cls, id)
104
121
  instance.id = id
105
122
  instance.delay = delay
@@ -146,7 +163,9 @@ class Event(AddCallbacksMixin, str):
146
163
  """
147
164
  if instance is None:
148
165
  return self
149
- return BoundEvent(id=self.id, name=self.name, delay=self.delay, _sm=instance)
166
+ return BoundEvent(
167
+ id=self.id, name=self.name, delay=self.delay, internal=self.internal, _sm=instance
168
+ )
150
169
 
151
170
  def put(self, *args, send_id: "str | None" = None, **kwargs):
152
171
  # The `__call__` is declared here to help IDEs knowing that an `Event`
@@ -1,6 +1,7 @@
1
1
  from dataclasses import dataclass
2
2
  from dataclasses import field
3
- from time import time
3
+ from itertools import count
4
+ from time import monotonic
4
5
  from typing import TYPE_CHECKING
5
6
  from typing import Any
6
7
 
@@ -10,6 +11,9 @@ if TYPE_CHECKING:
10
11
  from .statemachine import StateChart
11
12
  from .transition import Transition
12
13
 
14
+ _sequence = count()
15
+ """Numbers each trigger as it is created, so triggers due at the same time keep their order."""
16
+
13
17
 
14
18
  @dataclass(order=True)
15
19
  class TriggerData:
@@ -25,7 +29,13 @@ class TriggerData:
25
29
  """
26
30
 
27
31
  execution_time: float = field(default=0.0)
28
- """The time at which the :ref:`Event` should run."""
32
+ """The time at which the :ref:`Event` should run, on the :func:`time.monotonic` clock, so
33
+ that a step of the wall clock neither holds nor hastens it."""
34
+
35
+ sequence: int = field(default=0, init=False)
36
+ """The order this trigger was created in, which breaks a tie between triggers due at the
37
+ same time: the queues are priority queues, and without it two triggers stamped alike come
38
+ out in no particular order."""
29
39
 
30
40
  model: Any = field(init=False, compare=False)
31
41
  """A reference to the underlying model that holds the current :ref:`State`."""
@@ -43,10 +53,16 @@ class TriggerData:
43
53
  result (or exception), allowing the caller to ``await`` it.
44
54
  """
45
55
 
56
+ cancelled: bool = field(default=False, compare=False, init=False)
57
+ """Whether :meth:`~statemachine.StateChart.cancel_event` cancelled this trigger. A cancelled
58
+ trigger stays in its queue, so the triggers around it keep their due order, and is dropped
59
+ when it reaches the head."""
60
+
46
61
  def __post_init__(self):
47
62
  self.model = self.machine.model
48
63
  delay = self.event.delay if self.event and self.event.delay else 0
49
- self.execution_time = time() + (delay / 1000)
64
+ self.execution_time = monotonic() + (delay / 1000)
65
+ self.sequence = next(_sequence)
50
66
 
51
67
 
52
68
  @dataclass
statemachine/events.py CHANGED
@@ -18,6 +18,9 @@ class Events:
18
18
  def __iter__(self):
19
19
  return iter(self._items)
20
20
 
21
+ def __contains__(self, event):
22
+ return event in self._items
23
+
21
24
  def add(self, events):
22
25
  if events is None:
23
26
  return self
@@ -39,9 +42,9 @@ class Events:
39
42
  return self.is_empty
40
43
  return any(e.match(event) for e in self)
41
44
 
42
- def _replace(self, old, new):
45
+ def _replace(self, old, new_events):
43
46
  self._items.remove(old)
44
- self._items.append(new)
47
+ self.add(new_events)
45
48
 
46
49
  @property
47
50
  def is_empty(self):