python-statemachine 2.1.2__py3-none-any.whl → 2.3.0__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.
statemachine/mixins.py CHANGED
@@ -20,9 +20,7 @@ class MachineMixin:
20
20
  super().__init__(*args, **kwargs)
21
21
  if not self.state_machine_name:
22
22
  raise ValueError(
23
- _("{!r} is not a valid state machine name.").format(
24
- self.state_machine_name
25
- )
23
+ _("{!r} is not a valid state machine name.").format(self.state_machine_name)
26
24
  )
27
25
  machine_cls = registry.get_machine_cls(self.state_machine_name)
28
26
  setattr(
statemachine/signature.py CHANGED
@@ -1,14 +1,16 @@
1
- import itertools
2
1
  from functools import partial
3
2
  from inspect import BoundArguments
4
3
  from inspect import Parameter
5
4
  from inspect import Signature
5
+ from inspect import iscoroutinefunction
6
+ from itertools import chain
6
7
  from types import MethodType
7
8
  from typing import Any
8
9
 
9
10
 
10
11
  def _make_key(method):
11
12
  method = method.func if isinstance(method, partial) else method
13
+ method = method.fget if isinstance(method, property) else method
12
14
  if isinstance(method, MethodType):
13
15
  return hash(
14
16
  (
@@ -22,7 +24,6 @@ def _make_key(method):
22
24
 
23
25
 
24
26
  def signature_cache(user_function):
25
-
26
27
  cache = {}
27
28
  cache_get = cache.get
28
29
 
@@ -51,9 +52,16 @@ class SignatureAdapter(Signature):
51
52
 
52
53
  metadata_to_copy = method.func if isinstance(method, partial) else method
53
54
 
54
- def method_wrapper(*args: Any, **kwargs: Any) -> Any:
55
- ba = sig_bind_expected(*args, **kwargs)
56
- return method(*ba.args, **ba.kwargs)
55
+ if iscoroutinefunction(method):
56
+
57
+ async def method_wrapper(*args: Any, **kwargs: Any) -> Any:
58
+ ba = sig_bind_expected(*args, **kwargs)
59
+ return await method(*ba.args, **ba.kwargs)
60
+ else:
61
+
62
+ async def method_wrapper(*args: Any, **kwargs: Any) -> Any:
63
+ ba = sig_bind_expected(*args, **kwargs)
64
+ return method(*ba.args, **ba.kwargs)
57
65
 
58
66
  method_wrapper.__name__ = metadata_to_copy.__name__
59
67
 
@@ -113,8 +121,7 @@ class SignatureAdapter(Signature):
113
121
  parameters_ex = (param,)
114
122
  break
115
123
  elif (
116
- param.kind == Parameter.VAR_KEYWORD
117
- or param.default is not Parameter.empty
124
+ param.kind == Parameter.VAR_KEYWORD or param.default is not Parameter.empty
118
125
  ):
119
126
  # That's fine too - we have a default value for this
120
127
  # parameter. So, lets start parsing `kwargs`, starting
@@ -161,7 +168,7 @@ class SignatureAdapter(Signature):
161
168
 
162
169
  # Now, we iterate through the remaining parameters to process
163
170
  # keyword arguments
164
- for param in itertools.chain(parameters_ex, parameters):
171
+ for param in chain(parameters_ex, parameters):
165
172
  if param.kind == Parameter.VAR_KEYWORD:
166
173
  # Memorize that we have a '**kwargs'-like parameter
167
174
  kwargs_param = param
statemachine/state.py CHANGED
@@ -4,6 +4,7 @@ from typing import Dict
4
4
  from weakref import ref
5
5
 
6
6
  from .callbacks import CallbackMetaList
7
+ from .callbacks import CallbackPriority
7
8
  from .exceptions import StateMachineError
8
9
  from .i18n import _
9
10
  from .transition import Transition
@@ -107,31 +108,30 @@ class State:
107
108
  self._final = final
108
109
  self._id: str = ""
109
110
  self.transitions = TransitionList()
110
- self.enter = CallbackMetaList().add(enter)
111
- self.exit = CallbackMetaList().add(exit)
111
+ self.enter = CallbackMetaList().add(enter, priority=CallbackPriority.INLINE)
112
+ self.exit = CallbackMetaList().add(exit, priority=CallbackPriority.INLINE)
112
113
 
113
114
  def __eq__(self, other):
114
- return (
115
- isinstance(other, State) and self.name == other.name and self.id == other.id
116
- )
115
+ return isinstance(other, State) and self.name == other.name and self.id == other.id
117
116
 
118
117
  def __hash__(self):
119
118
  return hash(repr(self))
120
119
 
121
- def _setup(self, register):
120
+ def _setup(self):
121
+ self.enter.add("on_enter_state", priority=CallbackPriority.GENERIC, suppress_errors=True)
122
+ self.enter.add(
123
+ f"on_enter_{self.id}", priority=CallbackPriority.NAMING, suppress_errors=True
124
+ )
125
+ self.exit.add("on_exit_state", priority=CallbackPriority.GENERIC, suppress_errors=True)
126
+ self.exit.add(f"on_exit_{self.id}", priority=CallbackPriority.NAMING, suppress_errors=True)
127
+
128
+ def _add_observer(self, register):
122
129
  register(self.enter)
123
130
  register(self.exit)
124
- return self
125
131
 
126
- def _add_observer(self, registry):
127
- self.enter.add(
128
- "on_enter_state", registry=registry, prepend=True, suppress_errors=True
129
- )
130
- self.enter.add(f"on_enter_{self.id}", registry=registry, suppress_errors=True)
131
- self.exit.add(
132
- "on_exit_state", registry=registry, prepend=True, suppress_errors=True
133
- )
134
- self.exit.add(f"on_exit_{self.id}", registry=registry, suppress_errors=True)
132
+ def _check_callbacks(self, check):
133
+ check(self.enter)
134
+ check(self.exit)
135
135
 
136
136
  def __repr__(self):
137
137
  return (
@@ -139,6 +139,9 @@ class State:
139
139
  f"initial={self.initial!r}, final={self.final!r})"
140
140
  )
141
141
 
142
+ def __str__(self):
143
+ return self.name
144
+
142
145
  def __get__(self, machine, owner):
143
146
  if machine is None:
144
147
  return self
@@ -146,14 +149,10 @@ class State:
146
149
 
147
150
  def __set__(self, instance, value):
148
151
  raise StateMachineError(
149
- _("State overriding is not allowed. Trying to add '{}' to {}").format(
150
- value, self.id
151
- )
152
+ _("State overriding is not allowed. Trying to add '{}' to {}").format(value, self.id)
152
153
  )
153
154
 
154
- def for_instance(
155
- self, machine: "StateMachine", cache: Dict["State", "State"]
156
- ) -> "State":
155
+ def for_instance(self, machine: "StateMachine", cache: Dict["State", "State"]) -> "State":
157
156
  if self not in cache:
158
157
  cache[self] = InstanceState(self, machine)
159
158
 
@@ -171,9 +170,7 @@ class State:
171
170
  self.name = self._id.replace("_", " ").capitalize()
172
171
 
173
172
  def _to_(self, *states: "State", **kwargs):
174
- transitions = TransitionList(
175
- Transition(self, state, **kwargs) for state in states
176
- )
173
+ transitions = TransitionList(Transition(self, state, **kwargs) for state in states)
177
174
  self.transitions.add_transitions(transitions)
178
175
  return transitions
179
176
 
@@ -1,9 +1,15 @@
1
1
  from collections import deque
2
+ from copy import deepcopy
2
3
  from functools import partial
3
4
  from typing import TYPE_CHECKING
4
5
  from typing import Any
5
6
  from typing import Dict
6
7
 
8
+ from statemachine.graph import iterate_states_and_transitions
9
+ from statemachine.utils import run_async_from_sync
10
+
11
+ from .callbacks import CallbackMetaList
12
+ from .callbacks import CallbacksExecutor
7
13
  from .callbacks import CallbacksRegistry
8
14
  from .dispatcher import ObjectConfig
9
15
  from .dispatcher import resolver_factory
@@ -68,27 +74,32 @@ class StateMachine(metaclass=StateMachineMetaclass):
68
74
  self.state_field = state_field
69
75
  self.start_value = start_value
70
76
  self.allow_event_without_transition = allow_event_without_transition
77
+ self.__initialized = False
71
78
  self.__rtc = rtc
72
79
  self.__processing: bool = False
73
80
  self._external_queue: deque = deque()
74
81
  self._callbacks_registry = CallbacksRegistry()
75
- self._states_for_instance: Dict["State", "State"] = {}
82
+ self._states_for_instance: Dict[State, State] = {}
83
+ self._observers: Dict[Any, Any] = {}
76
84
 
77
- assert hasattr(self, "_abstract")
78
85
  if self._abstract:
79
86
  raise InvalidDefinition(_("There are no states or transitions."))
80
87
 
81
- initial_transition = Transition(
82
- None, self._get_initial_state(), event="__initial__"
83
- )
84
- self._setup(initial_transition)
85
- self._activate_initial_state(initial_transition)
88
+ self._register_callbacks()
89
+
90
+ # Activate the initial state, this only works if the outer scope is sync code.
91
+ # for async code, the user should manually call `await sm.activate_initial_state()`
92
+ # after state machine creation.
93
+ run_async_from_sync(self.activate_initial_state())
94
+
95
+ def __init_subclass__(cls, strict_states: bool = False):
96
+ cls._strict_states = strict_states
97
+ super().__init_subclass__()
86
98
 
87
99
  if TYPE_CHECKING:
88
100
  """Makes mypy happy with dynamic created attributes"""
89
101
 
90
- def __getattr__(self, attribute: str) -> Any:
91
- ...
102
+ def __getattr__(self, attribute: str) -> Any: ...
92
103
 
93
104
  def __repr__(self):
94
105
  current_state_id = self.current_state.id if self.current_state_value else None
@@ -97,19 +108,47 @@ class StateMachine(metaclass=StateMachineMetaclass):
97
108
  f"current_state={current_state_id!r})"
98
109
  )
99
110
 
111
+ def __deepcopy__(self, memo):
112
+ deepcopy_method = self.__deepcopy__
113
+ self.__deepcopy__ = None
114
+ try:
115
+ cp = deepcopy(self, memo)
116
+ finally:
117
+ self.__deepcopy__ = deepcopy_method
118
+ cp.__deepcopy__ = deepcopy_method
119
+ cp._callbacks_registry.clear()
120
+ cp._register_callbacks()
121
+ cp.add_observer(*cp._observers.keys())
122
+ return cp
123
+
100
124
  def _get_initial_state(self):
101
- current_state_value = (
102
- self.start_value if self.start_value else self.initial_state.value
103
- )
125
+ current_state_value = self.start_value if self.start_value else self.initial_state.value
104
126
  try:
105
127
  return self.states_map[current_state_value]
106
128
  except KeyError as err:
107
129
  raise InvalidStateValue(current_state_value) from err
108
130
 
109
- def _activate_initial_state(self, initial_transition):
131
+ async def activate_initial_state(self):
132
+ """
133
+ Activate the initial state.
134
+
135
+ Called automatically on state machine creation from sync code, but in
136
+ async code, the user must call this method explicitly.
137
+
138
+ Given how async works on python, there's no built-in way to activate the initial state that
139
+ may depend on async code from the StateMachine.__init__ method.
140
+
141
+ We do a `_ensure_is_initialized()` check before each event, but to check the current state
142
+ just before the state machine is created, the user must await the activation of the initial
143
+ state explicitly.
144
+ """
145
+ if self.__initialized:
146
+ return
147
+ self.__initialized = True
110
148
  if self.current_state_value is None:
111
149
  # send an one-time event `__initial__` to enter the current state.
112
150
  # current_state = self.current_state
151
+ initial_transition = Transition(None, self._get_initial_state(), event="__initial__")
113
152
  initial_transition.before.clear()
114
153
  initial_transition.on.clear()
115
154
  initial_transition.after.clear()
@@ -121,65 +160,34 @@ class StateMachine(metaclass=StateMachineMetaclass):
121
160
  ),
122
161
  transition=initial_transition,
123
162
  )
124
- self._activate(event_data)
125
-
126
- def _get_protected_attrs(self):
127
- return {
128
- "_abstract",
129
- "model",
130
- "state_field",
131
- "start_value",
132
- "initial_state",
133
- "final_states",
134
- "states",
135
- "_events",
136
- "states_map",
137
- "send",
138
- } | {s.id for s in self.states}
139
-
140
- def _visit_states_and_transitions(self, visitor):
141
- for state in self.states:
142
- visitor(state)
143
- for transition in state.transitions:
144
- visitor(transition)
145
-
146
- def _setup(self, initial_transition: Transition):
147
- """
148
- Args:
149
- initial_transition: A special :ref:`transition` that triggers the enter on the
150
- `initial` :ref:`State`.
151
- """
152
- machine = ObjectConfig.from_obj(self, skip_attrs=self._get_protected_attrs())
153
- model = ObjectConfig.from_obj(self.model, skip_attrs={self.state_field})
154
- default_resolver = resolver_factory(machine, model)
163
+ await self._activate(event_data)
155
164
 
156
- register = partial(self._callbacks_registry.register, resolver=default_resolver)
165
+ async def _ensure_is_initialized(self):
166
+ await self.activate_initial_state()
157
167
 
158
- observer_visitor = self._build_observers_visitor(machine, model)
159
-
160
- def setup_visitor(visited):
161
- visited._setup(register)
162
- observer_visitor(visited)
163
-
164
- self._visit_states_and_transitions(setup_visitor)
165
-
166
- initial_transition._setup(register)
167
-
168
- def _build_observers_visitor(self, *observers):
169
- registry_callbacks = [
168
+ def _register_callbacks(self):
169
+ self._add_observer(
170
170
  (
171
- self._callbacks_registry.build_register_function_for_resolver(
172
- resolver_factory(observer)
173
- )
171
+ ObjectConfig.from_obj(self, skip_attrs=self._protected_attrs),
172
+ ObjectConfig.from_obj(self.model, skip_attrs={self.state_field}),
174
173
  )
175
- for observer in observers
176
- ]
174
+ )
177
175
 
178
- def _add_observer_for_resolver(visited):
179
- for register in registry_callbacks:
180
- visited._add_observer(register)
176
+ check_callbacks = self._callbacks_registry.check
177
+ for visited in iterate_states_and_transitions(self.states):
178
+ try:
179
+ visited._check_callbacks(check_callbacks)
180
+ except Exception as err:
181
+ raise InvalidDefinition(
182
+ f"Error on {visited!s} when resolving callbacks: {err}"
183
+ ) from err
181
184
 
182
- return _add_observer_for_resolver
185
+ def _add_observer(self, observers):
186
+ register = partial(self._callbacks_registry.register, resolver=resolver_factory(observers))
187
+ for visited in iterate_states_and_transitions(self.states):
188
+ visited._add_observer(register)
189
+
190
+ return self
183
191
 
184
192
  def add_observer(self, *observers):
185
193
  """Add an observer.
@@ -191,10 +199,8 @@ class StateMachine(metaclass=StateMachineMetaclass):
191
199
 
192
200
  :ref:`observers`.
193
201
  """
194
-
195
- visitor = self._build_observers_visitor(*observers)
196
- self._visit_states_and_transitions(visitor)
197
- return self
202
+ self._observers.update({o: None for o in observers})
203
+ return self._add_observer(tuple(ObjectConfig.from_obj(o) for o in observers))
198
204
 
199
205
  def _repr_html_(self):
200
206
  return f'<div class="statemachine">{self._repr_svg_()}</div>'
@@ -214,8 +220,7 @@ class StateMachine(metaclass=StateMachineMetaclass):
214
220
  This is a low level API, that can be used to assign any valid state value
215
221
  completely bypassing all the hooks and validations.
216
222
  """
217
- value = getattr(self.model, self.state_field, None)
218
- return value
223
+ return getattr(self.model, self.state_field, None)
219
224
 
220
225
  @current_state_value.setter
221
226
  def current_state_value(self, value):
@@ -231,11 +236,23 @@ class StateMachine(metaclass=StateMachineMetaclass):
231
236
  completely bypassing all the hooks and validations.
232
237
  """
233
238
 
234
- state: State = self.states_map[self.current_state_value].for_instance(
235
- machine=self,
236
- cache=self._states_for_instance,
237
- )
238
- return state
239
+ try:
240
+ state: State = self.states_map[self.current_state_value].for_instance(
241
+ machine=self,
242
+ cache=self._states_for_instance,
243
+ )
244
+ return state
245
+ except KeyError as err:
246
+ if self.current_state_value is None:
247
+ raise InvalidStateValue(
248
+ self.current_state_value,
249
+ _(
250
+ "There's no current state set. In async code, "
251
+ "did you activate the initial state? "
252
+ "(e.g., `await sm.activate_initial_state()`)"
253
+ ),
254
+ ) from err
255
+ raise InvalidStateValue(self.current_state_value) from err
239
256
 
240
257
  @current_state.setter
241
258
  def current_state(self, value):
@@ -248,12 +265,9 @@ class StateMachine(metaclass=StateMachineMetaclass):
248
265
  @property
249
266
  def allowed_events(self):
250
267
  """List of the current allowed events."""
251
- return [
252
- getattr(self, event)
253
- for event in self.current_state.transitions.unique_events
254
- ]
268
+ return [getattr(self, event) for event in self.current_state.transitions.unique_events]
255
269
 
256
- def _process(self, trigger):
270
+ async def _process(self, trigger):
257
271
  """Process event triggers.
258
272
 
259
273
  The simplest implementation is the non-RTC (synchronous),
@@ -274,7 +288,7 @@ class StateMachine(metaclass=StateMachineMetaclass):
274
288
  """
275
289
  if not self.__rtc:
276
290
  # The machine is in "synchronous" mode
277
- return trigger()
291
+ return await trigger()
278
292
 
279
293
  # The machine is in "queued" mode
280
294
  # Add the trigger to queue and start processing in a loop.
@@ -285,9 +299,9 @@ class StateMachine(metaclass=StateMachineMetaclass):
285
299
  if self.__processing:
286
300
  return
287
301
 
288
- return self._processing_loop()
302
+ return await self._processing_loop()
289
303
 
290
- def _processing_loop(self):
304
+ async def _processing_loop(self):
291
305
  """Execute the triggers in the queue in order until the queue is empty"""
292
306
  self.__processing = True
293
307
 
@@ -300,7 +314,7 @@ class StateMachine(metaclass=StateMachineMetaclass):
300
314
  while self._external_queue:
301
315
  trigger = self._external_queue.popleft()
302
316
  try:
303
- result = trigger()
317
+ result = await trigger()
304
318
  if first_result is sentinel:
305
319
  first_result = result
306
320
  except Exception:
@@ -312,20 +326,18 @@ class StateMachine(metaclass=StateMachineMetaclass):
312
326
  self.__processing = False
313
327
  return first_result if first_result is not sentinel else None
314
328
 
315
- def _activate(self, event_data: EventData):
329
+ async def _activate(self, event_data: EventData):
316
330
  transition = event_data.transition
317
331
  source = event_data.state
318
332
  target = transition.target
319
333
 
320
- result = self._callbacks_registry[transition.before].call(
334
+ result = await self._callbacks(transition.before).call(
321
335
  *event_data.args, **event_data.extended_kwargs
322
336
  )
323
337
  if source is not None and not transition.internal:
324
- self._callbacks_registry[source.exit].call(
325
- *event_data.args, **event_data.extended_kwargs
326
- )
338
+ await self._callbacks(source.exit).call(*event_data.args, **event_data.extended_kwargs)
327
339
 
328
- result += self._callbacks_registry[transition.on].call(
340
+ result += await self._callbacks(transition.on).call(
329
341
  *event_data.args, **event_data.extended_kwargs
330
342
  )
331
343
 
@@ -333,10 +345,10 @@ class StateMachine(metaclass=StateMachineMetaclass):
333
345
  event_data.state = target
334
346
 
335
347
  if not transition.internal:
336
- self._callbacks_registry[target.enter].call(
348
+ await self._callbacks(target.enter).call(
337
349
  *event_data.args, **event_data.extended_kwargs
338
350
  )
339
- self._callbacks_registry[transition.after].call(
351
+ await self._callbacks(transition.after).call(
340
352
  *event_data.args, **event_data.extended_kwargs
341
353
  )
342
354
 
@@ -347,7 +359,20 @@ class StateMachine(metaclass=StateMachineMetaclass):
347
359
 
348
360
  return result
349
361
 
350
- def send(self, event, *args, **kwargs):
362
+ def send(self, event: str, *args, **kwargs):
363
+ """Send an :ref:`Event` to the state machine.
364
+
365
+ This is a thin wrapper around :meth:`async_send` to allow synchronous
366
+ code to send events.
367
+
368
+ .. seealso::
369
+
370
+ See: :ref:`triggering events`.
371
+
372
+ """
373
+ return run_async_from_sync(self.async_send(event, *args, **kwargs))
374
+
375
+ async def async_send(self, event: str, *args, **kwargs):
351
376
  """Send an :ref:`Event` to the state machine.
352
377
 
353
378
  .. seealso::
@@ -355,5 +380,8 @@ class StateMachine(metaclass=StateMachineMetaclass):
355
380
  See: :ref:`triggering events`.
356
381
 
357
382
  """
358
- event = Event(event)
359
- return event.trigger(self, *args, **kwargs)
383
+ event_instance: Event = Event(event)
384
+ return await event_instance.trigger(self, *args, **kwargs)
385
+
386
+ def _callbacks(self, meta_list: CallbackMetaList) -> CallbacksExecutor:
387
+ return self._callbacks_registry[meta_list]
statemachine/states.py CHANGED
@@ -15,21 +15,23 @@ class States:
15
15
  Helps creating :ref:`StateMachine`'s :ref:`state` definitions from other
16
16
  sources, like an ``Enum`` class, using :meth:`States.from_enum`.
17
17
 
18
+ >>> states_def = [('open', {'initial': True}), ('closed', {'final': True})]
19
+
18
20
  >>> from statemachine import StateMachine
19
21
  >>> class SM(StateMachine):
20
22
  ...
21
23
  ... states = States({
22
- ... name: State(initial=idx == 0) for idx, name in enumerate(["initial", "final"])
24
+ ... name: State(**params) for name, params in states_def
23
25
  ... })
24
26
  ...
25
- ... finish = states.initial.to(states.final)
27
+ ... close = states.open.to(states.closed)
26
28
 
27
29
  And states can be used as usual.
28
30
 
29
31
  >>> sm = SM()
30
- >>> sm.send("finish")
32
+ >>> sm.send("close")
31
33
  >>> sm.current_state.id
32
- 'final'
34
+ 'closed'
33
35
 
34
36
  """
35
37