Skip to content

Commit adce934

Browse files
committed
feat: Nested states (compound / parallel)
1 parent 9d177b2 commit adce934

5 files changed

Lines changed: 144 additions & 2 deletions

File tree

statemachine/factory.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,9 @@ def _check(cls):
6060
if not has_states:
6161
raise InvalidDefinition(_("There are no states."))
6262

63-
if not has_events:
64-
raise InvalidDefinition(_("There are no events."))
63+
# TODO: Validate no events if has nested states
64+
# if not has_events:
65+
# raise InvalidDefinition(_("There are no events."))
6566

6667
cls._check_initial_state()
6768
cls._check_final_states()
@@ -151,6 +152,9 @@ def add_state(cls, id, state: State):
151152
for event in state.transitions.unique_events:
152153
cls.add_event(event)
153154

155+
for substate in state.substates:
156+
cls.add_state(substate.id, substate)
157+
154158
def add_event(cls, event, transitions=None):
155159
if transitions is not None:
156160
transitions.add_event(event)

statemachine/state.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,26 @@
1313
from .statemachine import StateMachine
1414

1515

16+
class NestedStateFactory(type):
17+
def __new__(cls, classname, bases, attrs, name=None, initial=False, parallel=False):
18+
19+
if not bases:
20+
return super().__new__(cls, classname, bases, attrs)
21+
22+
substates = []
23+
for key, value in attrs.items():
24+
if not isinstance(value, State):
25+
continue
26+
value._set_id(key)
27+
substates.append(value)
28+
29+
return State(name, initial=initial, parallel=parallel, substates=substates)
30+
31+
32+
class NestedStateBuilder(metaclass=NestedStateFactory):
33+
pass
34+
35+
1636
class State:
1737
"""
1838
A State in a :ref:`StateMachine` describes a particular behavior of the machine.
@@ -92,23 +112,35 @@ class State:
92112
93113
"""
94114

115+
Builder = NestedStateBuilder
116+
95117
def __init__(
96118
self,
97119
name: str = "",
98120
value: Any = None,
99121
initial: bool = False,
100122
final: bool = False,
123+
parallel=False,
124+
substates=None,
101125
enter: Any = None,
102126
exit: Any = None,
103127
):
104128
self.name = name
105129
self.value = value
130+
self.parallel = parallel
131+
self.parent: "State" = None
132+
self.substates = substates or []
106133
self._initial = initial
107134
self._final = final
108135
self._id: str = ""
109136
self.transitions = TransitionList()
110137
self.enter = CallbackMetaList().add(enter)
111138
self.exit = CallbackMetaList().add(exit)
139+
self._init_substates()
140+
141+
def _init_substates(self):
142+
for substate in self.substates:
143+
substate.parent = self
112144

113145
def __eq__(self, other):
114146
return (
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""
2+
Microwave machine
3+
=================
4+
5+
Example that exercises the Compound and Parallel states.
6+
7+
Compound
8+
--------
9+
10+
If there are more than one substates, one of them is usually designated as the initial state of
11+
that compound state.
12+
13+
When a compound state is active, its substates behave as though they were an active state machine:
14+
Exactly one child state must also be active. This means that:
15+
16+
When a compound state is entered, it must also enter exactly one of its substates, usually its
17+
initial state.
18+
When an event happens, the substates have priority when it comes to selecting which transition to
19+
follow. If a substate happens to handles an event, the event is consumed, it isn’t passed to the
20+
parent compound state.
21+
When a substate transitions to another substate, both “inside” the compound state, the compound
22+
state does not exit or enter; it remains active.
23+
When a compound state exits, its substate is simultaneously exited too. (Technically, the substate
24+
exits first, then its parent.)
25+
Compound states may be nested, or include parallel states.
26+
27+
The opposite of a compound state is an atomic state, which is a state with no substates.
28+
29+
A compound state is allowed to define transitions to its child states. Normally, when a transition
30+
leads from a state, it causes that state to be exited. For transitions from a compound state to
31+
one of its descendants, it is possible to define a transition that avoids exiting and entering
32+
the compound state itself, such transitions are called local transitions.
33+
34+
35+
"""
36+
from statemachine import State
37+
from statemachine import StateMachine
38+
39+
40+
class MicroWave(StateMachine):
41+
class oven(State.Builder, name="Oven", initial=True, parallel=True):
42+
class engine(State.Builder, name="Engine"):
43+
off = State("Off", initial=True)
44+
45+
class on(State.Builder, name="On"):
46+
idle = State("Idle", initial=True)
47+
cooking = State("Cooking")
48+
49+
idle.to(cooking, cond="closed.is_active")
50+
cooking.to(idle, cond="open.is_active")
51+
cooking.to.itself(internal=True, on="increment_timer")
52+
53+
turn_off = on.to(off)
54+
turn_on = off.to(on)
55+
on.to(off, cond="cook_time_is_over") # eventless transition
56+
57+
class door(State.Builder, name="Door"):
58+
closed = State("Closed", initial=True)
59+
open = State("Open")
60+
61+
door_open = closed.to(open)
62+
door_close = open.to(closed)
63+
64+
def __init__(self):
65+
self.cook_time = 5
66+
self.door_closed = True
67+
self.timer = 0
68+
super().__init__()

tests/test_compound.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import pytest
2+
3+
from statemachine import State
4+
from statemachine import StateMachine
5+
6+
7+
@pytest.fixture()
8+
def compound_engine_cls():
9+
class TestMachine(StateMachine):
10+
class engine(State.Builder, name="Engine", initial=True):
11+
off = State("Off", initial=True)
12+
on = State("On")
13+
14+
turn_off = on.to(off)
15+
turn_on = off.to(on)
16+
17+
return TestMachine
18+
19+
20+
class TestNestedDeclarations:
21+
def test_capture_constructor_arguments(self, compound_engine_cls):
22+
sm = compound_engine_cls()
23+
assert isinstance(sm.engine, State)
24+
assert sm.engine.name == "Engine"
25+
assert sm.engine.initial is True
26+
27+
def test_list_children_states(self, compound_engine_cls):
28+
sm = compound_engine_cls()
29+
assert [s.id for s in sm.engine.children] == ["off", "on"]
30+
31+
def test_list_events(self, compound_engine_cls):
32+
sm = compound_engine_cls()
33+
assert [e.name for e in sm.events] == ["turn_off", "turn_on"]

tests/test_nested.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
def test_nested_sm():
2+
from tests.examples.microwave_inheritance_machine import MicroWave
3+
4+
sm = MicroWave()
5+
assert sm.current_state.id == "oven"

0 commit comments

Comments
 (0)