python-statemachine 2.3.1__py3-none-any.whl → 2.3.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.
- {python_statemachine-2.3.1.dist-info → python_statemachine-2.3.2.dist-info}/METADATA +3 -23
- python_statemachine-2.3.2.dist-info/RECORD +31 -0
- statemachine/__init__.py +1 -1
- statemachine/callbacks.py +183 -111
- statemachine/contrib/diagram.py +7 -4
- statemachine/dispatcher.py +127 -135
- statemachine/engines/__init__.py +0 -0
- statemachine/engines/async_.py +136 -0
- statemachine/engines/sync.py +139 -0
- statemachine/event.py +9 -28
- statemachine/event_data.py +2 -4
- statemachine/events.py +2 -2
- statemachine/factory.py +10 -11
- statemachine/mixins.py +10 -4
- statemachine/registry.py +5 -8
- statemachine/signature.py +6 -5
- statemachine/state.py +13 -17
- statemachine/statemachine.py +111 -167
- statemachine/states.py +4 -3
- statemachine/transition.py +22 -45
- statemachine/transition_list.py +6 -8
- statemachine/utils.py +1 -1
- python_statemachine-2.3.1.dist-info/RECORD +0 -28
- {python_statemachine-2.3.1.dist-info → python_statemachine-2.3.2.dist-info}/LICENSE +0 -0
- {python_statemachine-2.3.1.dist-info → python_statemachine-2.3.2.dist-info}/WHEEL +0 -0
statemachine/dispatcher.py
CHANGED
|
@@ -1,159 +1,151 @@
|
|
|
1
|
-
from
|
|
1
|
+
from dataclasses import dataclass
|
|
2
2
|
from operator import attrgetter
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
3
4
|
from typing import Any
|
|
5
|
+
from typing import Callable
|
|
4
6
|
from typing import Generator
|
|
7
|
+
from typing import Iterable
|
|
8
|
+
from typing import Set
|
|
5
9
|
from typing import Tuple
|
|
6
10
|
|
|
11
|
+
from statemachine.callbacks import SpecReference
|
|
12
|
+
|
|
7
13
|
from .signature import SignatureAdapter
|
|
8
14
|
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .callbacks import CallbackSpec
|
|
17
|
+
from .callbacks import CallbackSpecList
|
|
18
|
+
|
|
9
19
|
|
|
10
|
-
|
|
11
|
-
|
|
20
|
+
@dataclass
|
|
21
|
+
class Listener:
|
|
22
|
+
"""Object reference that provides attributes to be used as callbacks.
|
|
12
23
|
|
|
13
24
|
Args:
|
|
14
25
|
obj: Any object that will serve as lookup for attributes.
|
|
15
26
|
skip_attrs: Protected attrs that will be ignored on the search.
|
|
16
27
|
"""
|
|
17
28
|
|
|
29
|
+
obj: object
|
|
30
|
+
all_attrs: Set[str]
|
|
31
|
+
resolver_id: str
|
|
32
|
+
|
|
18
33
|
@classmethod
|
|
19
|
-
def from_obj(cls, obj, skip_attrs=None) -> "
|
|
20
|
-
if isinstance(obj,
|
|
34
|
+
def from_obj(cls, obj, skip_attrs=None) -> "Listener":
|
|
35
|
+
if isinstance(obj, Listener):
|
|
21
36
|
return obj
|
|
22
37
|
else:
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def __init__(self, attribute, resolver_id) -> None:
|
|
28
|
-
self.attribute = attribute
|
|
29
|
-
self.resolver_id = resolver_id
|
|
30
|
-
self._cache = None
|
|
31
|
-
self.unique_key = f"{attribute}@{resolver_id}"
|
|
32
|
-
|
|
33
|
-
def __repr__(self):
|
|
34
|
-
return f"{type(self).__name__}({self.unique_key})"
|
|
35
|
-
|
|
36
|
-
def wrap(self): # pragma: no cover
|
|
37
|
-
pass
|
|
38
|
-
|
|
39
|
-
async def __call__(self, *args: Any, **kwds: Any) -> Any:
|
|
40
|
-
if self._cache is None:
|
|
41
|
-
self._cache = self.wrap()
|
|
42
|
-
assert self._cache
|
|
43
|
-
return await self._cache(*args, **kwds)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
class CallableSearchResult(WrapSearchResult):
|
|
47
|
-
def __init__(self, attribute, a_callable, resolver_id) -> None:
|
|
48
|
-
self.a_callable = a_callable
|
|
49
|
-
super().__init__(attribute, resolver_id)
|
|
50
|
-
|
|
51
|
-
def wrap(self):
|
|
52
|
-
return SignatureAdapter.wrap(self.a_callable)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
class AttributeCallableSearchResult(WrapSearchResult):
|
|
56
|
-
def __init__(self, attribute, obj, resolver_id) -> None:
|
|
57
|
-
self.obj = obj
|
|
58
|
-
super().__init__(attribute, resolver_id)
|
|
59
|
-
|
|
60
|
-
def wrap(self):
|
|
61
|
-
# if `attr` is not callable, then it's an attribute or property,
|
|
62
|
-
# so `func` contains it's current value.
|
|
63
|
-
# we'll build a method that get's the fresh value for each call
|
|
64
|
-
getter = attrgetter(self.attribute)
|
|
65
|
-
|
|
66
|
-
async def wrapper(*args, **kwargs):
|
|
67
|
-
return getter(self.obj)
|
|
68
|
-
|
|
69
|
-
return wrapper
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
class EventSearchResult(WrapSearchResult):
|
|
73
|
-
def __init__(self, attribute, func, resolver_id) -> None:
|
|
74
|
-
self.func = func
|
|
75
|
-
super().__init__(attribute, resolver_id)
|
|
76
|
-
|
|
77
|
-
def wrap(self):
|
|
78
|
-
"Events already have the 'machine' parameter defined."
|
|
38
|
+
if skip_attrs is None:
|
|
39
|
+
skip_attrs = set()
|
|
40
|
+
all_attrs = set(dir(obj)) - skip_attrs
|
|
41
|
+
return cls(obj, all_attrs, str(id(obj)))
|
|
79
42
|
|
|
80
|
-
async def wrapper(*args, **kwargs):
|
|
81
|
-
kwargs.pop("machine", None)
|
|
82
|
-
return await self.func(*args, **kwargs)
|
|
83
43
|
|
|
84
|
-
|
|
44
|
+
@dataclass
|
|
45
|
+
class Listeners:
|
|
46
|
+
"""Listeners that provides attributes to be used as callbacks."""
|
|
85
47
|
|
|
48
|
+
items: Tuple[Listener, ...]
|
|
49
|
+
all_attrs: Set[str]
|
|
86
50
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if not
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_listeners(cls, listeners: Iterable["Listener"]) -> "Listeners":
|
|
53
|
+
listeners = tuple(listeners)
|
|
54
|
+
all_attrs = set().union(*(listener.all_attrs for listener in listeners))
|
|
55
|
+
return cls(listeners, all_attrs)
|
|
56
|
+
|
|
57
|
+
def resolve(self, specs: "CallbackSpecList", registry):
|
|
58
|
+
found_convention_specs = specs.conventional_specs & self.all_attrs
|
|
59
|
+
filtered_specs = [
|
|
60
|
+
spec for spec in specs if not spec.is_convention or spec.func in found_convention_specs
|
|
61
|
+
]
|
|
62
|
+
if not filtered_specs:
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
for spec in filtered_specs:
|
|
66
|
+
registry[spec.group.build_key(specs)]._add(spec, self)
|
|
67
|
+
|
|
68
|
+
def search(self, spec: "CallbackSpec") -> Generator["Callable", None, None]:
|
|
69
|
+
if spec.reference is SpecReference.NAME:
|
|
70
|
+
yield from self._search_name(spec.func)
|
|
71
|
+
return
|
|
72
|
+
elif spec.reference is SpecReference.CALLABLE:
|
|
73
|
+
yield self._search_callable(spec)
|
|
74
|
+
return
|
|
75
|
+
elif spec.reference is SpecReference.PROPERTY:
|
|
76
|
+
result = self._search_property(spec)
|
|
77
|
+
if result is not None:
|
|
78
|
+
yield result
|
|
79
|
+
return
|
|
80
|
+
else: # never reached here from tests but put an exception for safety. pragma: no cover
|
|
81
|
+
raise ValueError(f"Invalid reference {spec.reference}")
|
|
82
|
+
|
|
83
|
+
def _search_property(self, spec) -> "Callable | None":
|
|
84
|
+
# if the attr is a property, we'll try to find the object that has the
|
|
85
|
+
# property on the configs
|
|
86
|
+
attr_name = spec.attr_name
|
|
87
|
+
if attr_name not in self.all_attrs:
|
|
88
|
+
return None
|
|
89
|
+
for config in self.items:
|
|
90
|
+
func = getattr(type(config.obj), attr_name, None)
|
|
91
|
+
if func is not None and func is spec.func:
|
|
92
|
+
return attr_method(attr_name, config.obj, config.resolver_id)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def _search_callable(self, spec) -> "Callable":
|
|
96
|
+
# if the attr is an unbounded method, we'll try to find the bounded method
|
|
97
|
+
# on the self
|
|
98
|
+
if not spec.is_bounded:
|
|
99
|
+
for config in self.items:
|
|
100
|
+
func = getattr(config.obj, spec.attr_name, None)
|
|
101
|
+
if func is not None and func.__func__ is spec.func:
|
|
102
|
+
return callable_method(spec.attr_name, func, config.resolver_id)
|
|
103
|
+
|
|
104
|
+
return callable_method(spec.func, spec.func, None)
|
|
105
|
+
|
|
106
|
+
def _search_name(self, name) -> Generator["Callable", None, None]:
|
|
107
|
+
for config in self.items:
|
|
108
|
+
if name not in config.all_attrs:
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
func = getattr(config.obj, name)
|
|
112
|
+
if not callable(func):
|
|
113
|
+
yield attr_method(name, config.obj, config.resolver_id)
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
if getattr(func, "_is_sm_event", False):
|
|
117
|
+
yield event_method(name, func, config.resolver_id)
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
yield callable_method(name, func, config.resolver_id)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def callable_method(attribute, a_callable, resolver_id) -> Callable:
|
|
124
|
+
method = SignatureAdapter.wrap(a_callable)
|
|
125
|
+
method.unique_key = f"{attribute}@{resolver_id}" # type: ignore[attr-defined]
|
|
126
|
+
method.__name__ = a_callable.__name__
|
|
127
|
+
method.__doc__ = a_callable.__doc__
|
|
128
|
+
return method
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def attr_method(attribute, obj, resolver_id) -> Callable:
|
|
132
|
+
getter = attrgetter(attribute)
|
|
133
|
+
|
|
134
|
+
def method(*args, **kwargs):
|
|
135
|
+
return getter(obj)
|
|
136
|
+
|
|
137
|
+
method.unique_key = f"{attribute}@{resolver_id}" # type: ignore[attr-defined]
|
|
138
|
+
return method
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def event_method(attribute, func, resolver_id) -> Callable:
|
|
142
|
+
def method(*args, **kwargs):
|
|
143
|
+
kwargs.pop("machine", None)
|
|
144
|
+
return func(*args, **kwargs)
|
|
145
|
+
|
|
146
|
+
method.unique_key = f"{attribute}@{resolver_id}" # type: ignore[attr-defined]
|
|
147
|
+
return method
|
|
155
148
|
|
|
156
149
|
|
|
157
150
|
def resolver_factory_from_objects(*objects: Tuple[Any, ...]):
|
|
158
|
-
|
|
159
|
-
return resolver_factory(configs)
|
|
151
|
+
return Listeners.from_listeners(Listener.from_obj(o) for o in objects)
|
|
File without changes
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from threading import Lock
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
from weakref import proxy
|
|
4
|
+
|
|
5
|
+
from ..event_data import EventData
|
|
6
|
+
from ..event_data import TriggerData
|
|
7
|
+
from ..exceptions import InvalidDefinition
|
|
8
|
+
from ..exceptions import TransitionNotAllowed
|
|
9
|
+
from ..i18n import _
|
|
10
|
+
from ..transition import Transition
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ..statemachine import StateMachine
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AsyncEngine:
|
|
17
|
+
def __init__(self, sm: "StateMachine", rtc: bool = True):
|
|
18
|
+
self.sm = proxy(sm)
|
|
19
|
+
self._sentinel = object()
|
|
20
|
+
if not rtc:
|
|
21
|
+
raise InvalidDefinition(_("Only RTC is supported on async engine"))
|
|
22
|
+
self._processing = Lock()
|
|
23
|
+
|
|
24
|
+
async def activate_initial_state(self):
|
|
25
|
+
"""
|
|
26
|
+
Activate the initial state.
|
|
27
|
+
|
|
28
|
+
Called automatically on state machine creation from sync code, but in
|
|
29
|
+
async code, the user must call this method explicitly.
|
|
30
|
+
|
|
31
|
+
Given how async works on python, there's no built-in way to activate the initial state that
|
|
32
|
+
may depend on async code from the StateMachine.__init__ method.
|
|
33
|
+
"""
|
|
34
|
+
return await self._processing_loop()
|
|
35
|
+
|
|
36
|
+
async def _processing_loop(self):
|
|
37
|
+
"""Process event triggers.
|
|
38
|
+
|
|
39
|
+
The simplest implementation is the non-RTC (synchronous),
|
|
40
|
+
where the trigger will be run immediately and the result collected as the return.
|
|
41
|
+
|
|
42
|
+
.. note::
|
|
43
|
+
|
|
44
|
+
While processing the trigger, if others events are generated, they
|
|
45
|
+
will also be processed immediately, so a "nested" behavior happens.
|
|
46
|
+
|
|
47
|
+
If the machine is on ``rtc`` model (queued), the event is put on a queue, and only the
|
|
48
|
+
first event will have the result collected.
|
|
49
|
+
|
|
50
|
+
.. note::
|
|
51
|
+
While processing the queue items, if others events are generated, they
|
|
52
|
+
will be processed sequentially (and not nested).
|
|
53
|
+
|
|
54
|
+
"""
|
|
55
|
+
# We make sure that only the first event enters the processing critical section,
|
|
56
|
+
# next events will only be put on the queue and processed by the same loop.
|
|
57
|
+
if not self._processing.acquire(blocking=False):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
# We will collect the first result as the processing result to keep backwards compatibility
|
|
61
|
+
# so we need to use a sentinel object instead of `None` because the first result may
|
|
62
|
+
# be also `None`, and on this case the `first_result` may be overridden by another result.
|
|
63
|
+
first_result = self._sentinel
|
|
64
|
+
try:
|
|
65
|
+
# Execute the triggers in the queue in FIFO order until the queue is empty
|
|
66
|
+
while self.sm._external_queue:
|
|
67
|
+
trigger_data = self.sm._external_queue.popleft()
|
|
68
|
+
try:
|
|
69
|
+
result = await self._trigger(trigger_data)
|
|
70
|
+
if first_result is self._sentinel:
|
|
71
|
+
first_result = result
|
|
72
|
+
except Exception:
|
|
73
|
+
# Whe clear the queue as we don't have an expected behavior
|
|
74
|
+
# and cannot keep processing
|
|
75
|
+
self.sm._external_queue.clear()
|
|
76
|
+
raise
|
|
77
|
+
finally:
|
|
78
|
+
self._processing.release()
|
|
79
|
+
return first_result if first_result is not self._sentinel else None
|
|
80
|
+
|
|
81
|
+
async def _trigger(self, trigger_data: TriggerData):
|
|
82
|
+
event_data = None
|
|
83
|
+
if trigger_data.event == "__initial__":
|
|
84
|
+
transition = Transition(None, self.sm._get_initial_state(), event="__initial__")
|
|
85
|
+
transition._specs.clear()
|
|
86
|
+
event_data = EventData(trigger_data=trigger_data, transition=transition)
|
|
87
|
+
await self._activate(event_data)
|
|
88
|
+
return self._sentinel
|
|
89
|
+
|
|
90
|
+
state = self.sm.current_state
|
|
91
|
+
for transition in state.transitions:
|
|
92
|
+
if not transition.match(trigger_data.event):
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
event_data = EventData(trigger_data=trigger_data, transition=transition)
|
|
96
|
+
args, kwargs = event_data.args, event_data.extended_kwargs
|
|
97
|
+
await self.sm._get_callbacks(transition.validators.key).async_call(*args, **kwargs)
|
|
98
|
+
if not await self.sm._get_callbacks(transition.cond.key).async_all(*args, **kwargs):
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
result = await self._activate(event_data)
|
|
102
|
+
event_data.result = result
|
|
103
|
+
event_data.executed = True
|
|
104
|
+
break
|
|
105
|
+
else:
|
|
106
|
+
if not self.sm.allow_event_without_transition:
|
|
107
|
+
raise TransitionNotAllowed(trigger_data.event, state)
|
|
108
|
+
|
|
109
|
+
return event_data.result if event_data else None
|
|
110
|
+
|
|
111
|
+
async def _activate(self, event_data: EventData):
|
|
112
|
+
args, kwargs = event_data.args, event_data.extended_kwargs
|
|
113
|
+
transition = event_data.transition
|
|
114
|
+
source = event_data.state
|
|
115
|
+
target = transition.target
|
|
116
|
+
|
|
117
|
+
result = await self.sm._get_callbacks(transition.before.key).async_call(*args, **kwargs)
|
|
118
|
+
if source is not None and not transition.internal:
|
|
119
|
+
await self.sm._get_callbacks(source.exit.key).async_call(*args, **kwargs)
|
|
120
|
+
|
|
121
|
+
result += await self.sm._get_callbacks(transition.on.key).async_call(*args, **kwargs)
|
|
122
|
+
|
|
123
|
+
self.sm.current_state = target
|
|
124
|
+
event_data.state = target
|
|
125
|
+
kwargs["state"] = target
|
|
126
|
+
|
|
127
|
+
if not transition.internal:
|
|
128
|
+
await self.sm._get_callbacks(target.enter.key).async_call(*args, **kwargs)
|
|
129
|
+
await self.sm._get_callbacks(transition.after.key).async_call(*args, **kwargs)
|
|
130
|
+
|
|
131
|
+
if len(result) == 0:
|
|
132
|
+
result = None
|
|
133
|
+
elif len(result) == 1:
|
|
134
|
+
result = result[0]
|
|
135
|
+
|
|
136
|
+
return result
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from threading import Lock
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
from weakref import proxy
|
|
4
|
+
|
|
5
|
+
from ..event_data import EventData
|
|
6
|
+
from ..event_data import TriggerData
|
|
7
|
+
from ..exceptions import TransitionNotAllowed
|
|
8
|
+
from ..transition import Transition
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ..statemachine import StateMachine
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SyncEngine:
|
|
15
|
+
def __init__(self, sm: "StateMachine", rtc: bool = True):
|
|
16
|
+
self.sm = proxy(sm)
|
|
17
|
+
self._sentinel = object()
|
|
18
|
+
self._rtc = rtc
|
|
19
|
+
self._processing = Lock()
|
|
20
|
+
self.activate_initial_state()
|
|
21
|
+
|
|
22
|
+
def activate_initial_state(self):
|
|
23
|
+
"""
|
|
24
|
+
Activate the initial state.
|
|
25
|
+
|
|
26
|
+
Called automatically on state machine creation from sync code, but in
|
|
27
|
+
async code, the user must call this method explicitly.
|
|
28
|
+
|
|
29
|
+
Given how async works on python, there's no built-in way to activate the initial state that
|
|
30
|
+
may depend on async code from the StateMachine.__init__ method.
|
|
31
|
+
"""
|
|
32
|
+
return self._processing_loop()
|
|
33
|
+
|
|
34
|
+
def _processing_loop(self):
|
|
35
|
+
"""Process event triggers.
|
|
36
|
+
|
|
37
|
+
The simplest implementation is the non-RTC (synchronous),
|
|
38
|
+
where the trigger will be run immediately and the result collected as the return.
|
|
39
|
+
|
|
40
|
+
.. note::
|
|
41
|
+
|
|
42
|
+
While processing the trigger, if others events are generated, they
|
|
43
|
+
will also be processed immediately, so a "nested" behavior happens.
|
|
44
|
+
|
|
45
|
+
If the machine is on ``rtc`` model (queued), the event is put on a queue, and only the
|
|
46
|
+
first event will have the result collected.
|
|
47
|
+
|
|
48
|
+
.. note::
|
|
49
|
+
While processing the queue items, if others events are generated, they
|
|
50
|
+
will be processed sequentially (and not nested).
|
|
51
|
+
|
|
52
|
+
"""
|
|
53
|
+
if not self._rtc:
|
|
54
|
+
# The machine is in "synchronous" mode
|
|
55
|
+
trigger_data = self.sm._external_queue.popleft()
|
|
56
|
+
return self._trigger(trigger_data)
|
|
57
|
+
|
|
58
|
+
# We make sure that only the first event enters the processing critical section,
|
|
59
|
+
# next events will only be put on the queue and processed by the same loop.
|
|
60
|
+
if not self._processing.acquire(blocking=False):
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
# We will collect the first result as the processing result to keep backwards compatibility
|
|
64
|
+
# so we need to use a sentinel object instead of `None` because the first result may
|
|
65
|
+
# be also `None`, and on this case the `first_result` may be overridden by another result.
|
|
66
|
+
first_result = self._sentinel
|
|
67
|
+
try:
|
|
68
|
+
# Execute the triggers in the queue in FIFO order until the queue is empty
|
|
69
|
+
while self.sm._external_queue:
|
|
70
|
+
trigger_data = self.sm._external_queue.popleft()
|
|
71
|
+
try:
|
|
72
|
+
result = self._trigger(trigger_data)
|
|
73
|
+
if first_result is self._sentinel:
|
|
74
|
+
first_result = result
|
|
75
|
+
except Exception:
|
|
76
|
+
# Whe clear the queue as we don't have an expected behavior
|
|
77
|
+
# and cannot keep processing
|
|
78
|
+
self.sm._external_queue.clear()
|
|
79
|
+
raise
|
|
80
|
+
finally:
|
|
81
|
+
self._processing.release()
|
|
82
|
+
return first_result if first_result is not self._sentinel else None
|
|
83
|
+
|
|
84
|
+
def _trigger(self, trigger_data: TriggerData):
|
|
85
|
+
event_data = None
|
|
86
|
+
if trigger_data.event == "__initial__":
|
|
87
|
+
transition = Transition(None, self.sm._get_initial_state(), event="__initial__")
|
|
88
|
+
transition._specs.clear()
|
|
89
|
+
event_data = EventData(trigger_data=trigger_data, transition=transition)
|
|
90
|
+
self._activate(event_data)
|
|
91
|
+
return self._sentinel
|
|
92
|
+
|
|
93
|
+
state = self.sm.current_state
|
|
94
|
+
for transition in state.transitions:
|
|
95
|
+
if not transition.match(trigger_data.event):
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
event_data = EventData(trigger_data=trigger_data, transition=transition)
|
|
99
|
+
args, kwargs = event_data.args, event_data.extended_kwargs
|
|
100
|
+
self.sm._get_callbacks(transition.validators.key).call(*args, **kwargs)
|
|
101
|
+
if not self.sm._get_callbacks(transition.cond.key).all(*args, **kwargs):
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
result = self._activate(event_data)
|
|
105
|
+
event_data.result = result
|
|
106
|
+
event_data.executed = True
|
|
107
|
+
break
|
|
108
|
+
else:
|
|
109
|
+
if not self.sm.allow_event_without_transition:
|
|
110
|
+
raise TransitionNotAllowed(trigger_data.event, state)
|
|
111
|
+
|
|
112
|
+
return event_data.result if event_data else None
|
|
113
|
+
|
|
114
|
+
def _activate(self, event_data: EventData):
|
|
115
|
+
args, kwargs = event_data.args, event_data.extended_kwargs
|
|
116
|
+
transition = event_data.transition
|
|
117
|
+
source = event_data.state
|
|
118
|
+
target = transition.target
|
|
119
|
+
|
|
120
|
+
result = self.sm._get_callbacks(transition.before.key).call(*args, **kwargs)
|
|
121
|
+
if source is not None and not transition.internal:
|
|
122
|
+
self.sm._get_callbacks(source.exit.key).call(*args, **kwargs)
|
|
123
|
+
|
|
124
|
+
result += self.sm._get_callbacks(transition.on.key).call(*args, **kwargs)
|
|
125
|
+
|
|
126
|
+
self.sm.current_state = target
|
|
127
|
+
event_data.state = target
|
|
128
|
+
kwargs["state"] = target
|
|
129
|
+
|
|
130
|
+
if not transition.internal:
|
|
131
|
+
self.sm._get_callbacks(target.enter.key).call(*args, **kwargs)
|
|
132
|
+
self.sm._get_callbacks(transition.after.key).call(*args, **kwargs)
|
|
133
|
+
|
|
134
|
+
if len(result) == 0:
|
|
135
|
+
result = None
|
|
136
|
+
elif len(result) == 1:
|
|
137
|
+
result = result[0]
|
|
138
|
+
|
|
139
|
+
return result
|
statemachine/event.py
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
from
|
|
1
|
+
from inspect import isawaitable
|
|
2
2
|
from typing import TYPE_CHECKING
|
|
3
3
|
|
|
4
4
|
from statemachine.utils import run_async_from_sync
|
|
5
5
|
|
|
6
|
-
from .event_data import EventData
|
|
7
6
|
from .event_data import TriggerData
|
|
8
|
-
from .exceptions import TransitionNotAllowed
|
|
9
7
|
|
|
10
8
|
if TYPE_CHECKING:
|
|
11
9
|
from .statemachine import StateMachine
|
|
@@ -18,42 +16,25 @@ class Event:
|
|
|
18
16
|
def __repr__(self):
|
|
19
17
|
return f"{type(self).__name__}({self.name!r})"
|
|
20
18
|
|
|
21
|
-
|
|
19
|
+
def trigger(self, machine: "StateMachine", *args, **kwargs):
|
|
22
20
|
trigger_data = TriggerData(
|
|
23
21
|
machine=machine,
|
|
24
22
|
event=self.name,
|
|
25
23
|
args=args,
|
|
26
24
|
kwargs=kwargs,
|
|
27
25
|
)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return await machine._process(trigger_wrapper)
|
|
31
|
-
|
|
32
|
-
async def _trigger(self, trigger_data: TriggerData):
|
|
33
|
-
event_data = None
|
|
34
|
-
await trigger_data.machine._ensure_is_initialized()
|
|
35
|
-
|
|
36
|
-
state = trigger_data.machine.current_state
|
|
37
|
-
for transition in state.transitions:
|
|
38
|
-
if not transition.match(trigger_data.event):
|
|
39
|
-
continue
|
|
40
|
-
|
|
41
|
-
event_data = EventData(trigger_data=trigger_data, transition=transition)
|
|
42
|
-
if await transition.execute(event_data):
|
|
43
|
-
event_data.executed = True
|
|
44
|
-
break
|
|
45
|
-
else:
|
|
46
|
-
if not trigger_data.machine.allow_event_without_transition:
|
|
47
|
-
raise TransitionNotAllowed(trigger_data.event, state)
|
|
48
|
-
|
|
49
|
-
return event_data.result if event_data else None
|
|
26
|
+
machine._put_nonblocking(trigger_data)
|
|
27
|
+
return machine._processing_loop()
|
|
50
28
|
|
|
51
29
|
|
|
52
30
|
def trigger_event_factory(event_instance: Event):
|
|
53
31
|
"""Build a method that sends specific `event` to the machine"""
|
|
54
32
|
|
|
55
33
|
def trigger_event(self, *args, **kwargs):
|
|
56
|
-
|
|
34
|
+
result = event_instance.trigger(self, *args, **kwargs)
|
|
35
|
+
if not isawaitable(result):
|
|
36
|
+
return result
|
|
37
|
+
return run_async_from_sync(result)
|
|
57
38
|
|
|
58
39
|
trigger_event.name = event_instance.name # type: ignore[attr-defined]
|
|
59
40
|
trigger_event.identifier = event_instance.name # type: ignore[attr-defined]
|
|
@@ -66,7 +47,7 @@ def same_event_cond_builder(expected_event: str):
|
|
|
66
47
|
Builds a condition method that evaluates to ``True`` when the expected event is received.
|
|
67
48
|
"""
|
|
68
49
|
|
|
69
|
-
def cond(event: str) -> bool:
|
|
50
|
+
def cond(*args, event: "str | None" = None, **kwargs) -> bool:
|
|
70
51
|
return event == expected_event
|
|
71
52
|
|
|
72
53
|
return cond
|
statemachine/event_data.py
CHANGED
|
@@ -47,16 +47,14 @@ class EventData:
|
|
|
47
47
|
"""The destination :ref:`State` of the :ref:`transition`."""
|
|
48
48
|
|
|
49
49
|
result: "Any | None" = None
|
|
50
|
+
|
|
50
51
|
executed: bool = False
|
|
51
52
|
|
|
52
53
|
def __post_init__(self):
|
|
53
54
|
self.state = self.transition.source
|
|
54
55
|
self.source = self.transition.source
|
|
55
56
|
self.target = self.transition.target
|
|
56
|
-
|
|
57
|
-
@property
|
|
58
|
-
def machine(self):
|
|
59
|
-
return self.trigger_data.machine
|
|
57
|
+
self.machine = self.trigger_data.machine
|
|
60
58
|
|
|
61
59
|
@property
|
|
62
60
|
def event(self):
|
statemachine/events.py
CHANGED