Skip to content

Commit 0e363ce

Browse files
committed
feat: New statechartengine for experimentation without breaking all the tests
1 parent 4de8712 commit 0e363ce

File tree

10 files changed

+213
-48
lines changed

10 files changed

+213
-48
lines changed

docs/_static/custom_machine.css

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
}
3030

31+
3132
/* Gallery Donwload buttons */
3233
div.sphx-glr-download a {
3334
color: #404040 !important;

docs/states.md

+1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ How to define and attach [](actions.md) to {ref}`States`.
1717

1818
A {ref}`StateMachine` should have one and only one `initial` {ref}`state`.
1919

20+
If not specified, the default initial state is the first child state in document order.
2021

2122
The initial {ref}`state` is entered when the machine starts and the corresponding entering
2223
state {ref}`actions` are called if defined.

statemachine/engines/async_.py

+7-15
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,17 @@
1-
from threading import Lock
2-
from typing import TYPE_CHECKING
3-
from weakref import proxy
4-
51
from ..event_data import EventData
62
from ..event_data import TriggerData
73
from ..exceptions import InvalidDefinition
84
from ..exceptions import TransitionNotAllowed
95
from ..i18n import _
106
from ..transition import Transition
11-
12-
if TYPE_CHECKING:
13-
from ..statemachine import StateMachine
7+
from .base import BaseEngine
148

159

16-
class AsyncEngine:
17-
def __init__(self, sm: "StateMachine", rtc: bool = True):
18-
self.sm = proxy(sm)
19-
self._sentinel = object()
10+
class AsyncEngine(BaseEngine):
11+
def __init__(self, sm, rtc: bool = True):
2012
if not rtc:
2113
raise InvalidDefinition(_("Only RTC is supported on async engine"))
22-
self._processing = Lock()
14+
super().__init__(sm, rtc)
2315

2416
async def activate_initial_state(self):
2517
"""
@@ -63,16 +55,16 @@ async def processing_loop(self):
6355
first_result = self._sentinel
6456
try:
6557
# 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()
58+
while self._external_queue:
59+
trigger_data = self._external_queue.popleft()
6860
try:
6961
result = await self._trigger(trigger_data)
7062
if first_result is self._sentinel:
7163
first_result = result
7264
except Exception:
7365
# Whe clear the queue as we don't have an expected behavior
7466
# and cannot keep processing
75-
self.sm._external_queue.clear()
67+
self._external_queue.clear()
7668
raise
7769
finally:
7870
self._processing.release()

statemachine/engines/base.py

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from collections import deque
2+
from threading import Lock
3+
from typing import TYPE_CHECKING
4+
from weakref import proxy
5+
6+
from ..event_data import TriggerData
7+
8+
if TYPE_CHECKING:
9+
from ..statemachine import StateMachine
10+
11+
12+
class BaseEngine:
13+
def __init__(self, sm: "StateMachine", rtc: bool = True) -> None:
14+
self.sm = proxy(sm)
15+
self._external_queue: deque = deque()
16+
self._sentinel = object()
17+
self._rtc = rtc
18+
self._processing = Lock()
19+
self._put_initial_activation_trigger_on_queue()
20+
21+
def _put_nonblocking(self, trigger_data: TriggerData):
22+
"""Put the trigger on the queue without blocking the caller."""
23+
self._external_queue.append(trigger_data)
24+
25+
def _put_initial_activation_trigger_on_queue(self):
26+
# Activate the initial state, this only works if the outer scope is sync code.
27+
# for async code, the user should manually call `await sm.activate_initial_state()`
28+
# after state machine creation.
29+
if self.sm.current_state_value is None:
30+
trigger_data = TriggerData(
31+
machine=self.sm,
32+
event="__initial__",
33+
)
34+
self._put_nonblocking(trigger_data)

statemachine/engines/statechart.py

+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
from ..event_data import EventData
2+
from ..event_data import TriggerData
3+
from ..exceptions import TransitionNotAllowed
4+
from ..transition import Transition
5+
from .base import BaseEngine
6+
7+
8+
class StateChartEngine(BaseEngine):
9+
def __init__(self, sm, rtc: bool = True):
10+
super().__init__(sm, rtc)
11+
self.activate_initial_state()
12+
13+
def activate_initial_state(self):
14+
"""
15+
Activate the initial state.
16+
17+
Called automatically on state machine creation from sync code, but in
18+
async code, the user must call this method explicitly.
19+
20+
Given how async works on python, there's no built-in way to activate the initial state that
21+
may depend on async code from the StateMachine.__init__ method.
22+
"""
23+
return self.processing_loop()
24+
25+
def processing_loop(self):
26+
"""Process event triggers.
27+
28+
The simplest implementation is the non-RTC (synchronous),
29+
where the trigger will be run immediately and the result collected as the return.
30+
31+
.. note::
32+
33+
While processing the trigger, if others events are generated, they
34+
will also be processed immediately, so a "nested" behavior happens.
35+
36+
If the machine is on ``rtc`` model (queued), the event is put on a queue, and only the
37+
first event will have the result collected.
38+
39+
.. note::
40+
While processing the queue items, if others events are generated, they
41+
will be processed sequentially (and not nested).
42+
43+
"""
44+
if not self._rtc:
45+
# The machine is in "synchronous" mode
46+
trigger_data = self._external_queue.popleft()
47+
return self._trigger(trigger_data)
48+
49+
# We make sure that only the first event enters the processing critical section,
50+
# next events will only be put on the queue and processed by the same loop.
51+
if not self._processing.acquire(blocking=False):
52+
return None
53+
54+
# We will collect the first result as the processing result to keep backwards compatibility
55+
# so we need to use a sentinel object instead of `None` because the first result may
56+
# be also `None`, and on this case the `first_result` may be overridden by another result.
57+
first_result = self._sentinel
58+
try:
59+
# Execute the triggers in the queue in FIFO order until the queue is empty
60+
while self._external_queue:
61+
trigger_data = self._external_queue.popleft()
62+
try:
63+
result = self._trigger(trigger_data)
64+
if first_result is self._sentinel:
65+
first_result = result
66+
except Exception:
67+
# Whe clear the queue as we don't have an expected behavior
68+
# and cannot keep processing
69+
self._external_queue.clear()
70+
raise
71+
finally:
72+
self._processing.release()
73+
return first_result if first_result is not self._sentinel else None
74+
75+
def _trigger(self, trigger_data: TriggerData):
76+
event_data = None
77+
if trigger_data.event == "__initial__":
78+
transition = Transition(None, self.sm._get_initial_state(), event="__initial__")
79+
transition._specs.clear()
80+
event_data = EventData(trigger_data=trigger_data, transition=transition)
81+
self._activate(event_data)
82+
return self._sentinel
83+
84+
state = self.sm.current_state
85+
for transition in state.transitions:
86+
if not transition.match(trigger_data.event):
87+
continue
88+
89+
event_data = EventData(trigger_data=trigger_data, transition=transition)
90+
args, kwargs = event_data.args, event_data.extended_kwargs
91+
self.sm._get_callbacks(transition.validators.key).call(*args, **kwargs)
92+
if not self.sm._get_callbacks(transition.cond.key).all(*args, **kwargs):
93+
continue
94+
95+
result = self._activate(event_data)
96+
event_data.result = result
97+
event_data.executed = True
98+
break
99+
else:
100+
if not self.sm.allow_event_without_transition:
101+
raise TransitionNotAllowed(trigger_data.event, state)
102+
103+
return event_data.result if event_data else None
104+
105+
def _activate(self, event_data: EventData):
106+
args, kwargs = event_data.args, event_data.extended_kwargs
107+
transition = event_data.transition
108+
source = event_data.state
109+
target = transition.target
110+
111+
result = self.sm._get_callbacks(transition.before.key).call(*args, **kwargs)
112+
if source is not None and not transition.internal:
113+
self.sm._get_callbacks(source.exit.key).call(*args, **kwargs)
114+
115+
result += self.sm._get_callbacks(transition.on.key).call(*args, **kwargs)
116+
117+
self.sm.current_state = target
118+
event_data.state = target
119+
kwargs["state"] = target
120+
121+
if not transition.internal:
122+
self.sm._get_callbacks(target.enter.key).call(*args, **kwargs)
123+
self.sm._get_callbacks(transition.after.key).call(*args, **kwargs)
124+
125+
if len(result) == 0:
126+
result = None
127+
elif len(result) == 1:
128+
result = result[0]
129+
130+
return result

statemachine/engines/sync.py

+8-17
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,13 @@
1-
from threading import Lock
2-
from typing import TYPE_CHECKING
3-
from weakref import proxy
4-
51
from ..event_data import EventData
62
from ..event_data import TriggerData
73
from ..exceptions import TransitionNotAllowed
84
from ..transition import Transition
9-
10-
if TYPE_CHECKING:
11-
from ..statemachine import StateMachine
5+
from .base import BaseEngine
126

137

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()
8+
class SyncEngine(BaseEngine):
9+
def __init__(self, sm, rtc: bool = True):
10+
super().__init__(sm, rtc)
2011
self.activate_initial_state()
2112

2213
def activate_initial_state(self):
@@ -52,7 +43,7 @@ def processing_loop(self):
5243
"""
5344
if not self._rtc:
5445
# The machine is in "synchronous" mode
55-
trigger_data = self.sm._external_queue.popleft()
46+
trigger_data = self._external_queue.popleft()
5647
return self._trigger(trigger_data)
5748

5849
# We make sure that only the first event enters the processing critical section,
@@ -66,16 +57,16 @@ def processing_loop(self):
6657
first_result = self._sentinel
6758
try:
6859
# 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()
60+
while self._external_queue:
61+
trigger_data = self._external_queue.popleft()
7162
try:
7263
result = self._trigger(trigger_data)
7364
if first_result is self._sentinel:
7465
first_result = result
7566
except Exception:
7667
# Whe clear the queue as we don't have an expected behavior
7768
# and cannot keep processing
78-
self.sm._external_queue.clear()
69+
self._external_queue.clear()
7970
raise
8071
finally:
8172
self._processing.release()

statemachine/factory.py

+11
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .event import Event
1111
from .event import trigger_event_factory
1212
from .exceptions import InvalidDefinition
13+
from .graph import iterate_states
1314
from .graph import iterate_states_and_transitions
1415
from .graph import visit_connected_states
1516
from .i18n import _
@@ -44,6 +45,7 @@ def __init__(
4445

4546
cls.add_inherited(bases)
4647
cls.add_from_attributes(attrs)
48+
cls._unpack_builders_callbacks()
4749

4850
if not cls.states:
4951
return
@@ -196,6 +198,15 @@ def _setup(cls):
196198
"send",
197199
} | {s.id for s in cls.states}
198200

201+
def _unpack_builders_callbacks(cls):
202+
callbacks = {}
203+
for state in iterate_states(cls.states):
204+
if state._callbacks:
205+
callbacks.update(state._callbacks)
206+
del state._callbacks
207+
for key, value in callbacks.items():
208+
setattr(cls, key, value)
209+
199210
def add_inherited(cls, bases):
200211
for base in bases:
201212
for state in getattr(base, "states", []):

statemachine/graph.py

+9
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,12 @@ def iterate_states_and_transitions(states):
1818
for state in states:
1919
yield state
2020
yield from state.transitions
21+
if state.states:
22+
yield from iterate_states_and_transitions(state.states)
23+
24+
25+
def iterate_states(states):
26+
for state in states:
27+
yield state
28+
if state.states:
29+
yield from iterate_states(state.states)

statemachine/state.py

+8-2
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,17 @@ def __new__( # type: ignore [misc]
2323
return super().__new__(cls, classname, bases, attrs) # type: ignore [return-value]
2424

2525
states = []
26+
callbacks = {}
2627
for key, value in attrs.items():
2728
if isinstance(value, State):
2829
value._set_id(key)
2930
states.append(value)
30-
if isinstance(value, TransitionList):
31+
elif isinstance(value, TransitionList):
3132
value.add_event(key)
33+
elif callable(value):
34+
callbacks[key] = value
3235

33-
return State(name=name, states=states, **kwargs)
36+
return State(name=name, states=states, _callbacks=callbacks, **kwargs)
3437

3538

3639
class State:
@@ -51,6 +54,7 @@ class State:
5154
value.
5255
initial: Set ``True`` if the ``State`` is the initial one. There must be one and only
5356
one initial state in a statemachine. Defaults to ``False``.
57+
If not specified, the default initial state is the first child state in document order.
5458
final: Set ``True`` if represents a final state. A machine can have
5559
optionally many final states. Final states have no :ref:`transition` starting from It.
5660
Defaults to ``False``.
@@ -144,6 +148,7 @@ def __init__(
144148
states: Any = None,
145149
enter: Any = None,
146150
exit: Any = None,
151+
_callbacks: Any = None,
147152
):
148153
self.name = name
149154
self.value = value
@@ -153,6 +158,7 @@ def __init__(
153158
self._initial = initial
154159
self._final = final
155160
self._id: str = ""
161+
self._callbacks = _callbacks
156162
self.parent: "State" = None
157163
self.transitions = TransitionList()
158164
self._specs = CallbackSpecList()

0 commit comments

Comments
 (0)