Skip to content

Commit ba50602

Browse files
committed
feat: Nested states (compound / parallel)
1 parent 08d93ce commit ba50602

File tree

7 files changed

+156
-9
lines changed

7 files changed

+156
-9
lines changed

statemachine/callbacks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ def __str__(self):
8686
return ", ".join(str(c) for c in self)
8787

8888
def setup(self, resolver):
89-
"""Validate configuracions"""
89+
"""Validate configurations"""
9090
self._resolver = resolver
9191
self.items = [
9292
callback for callback in self.items if callback.setup(self._resolver)

statemachine/factory.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,13 @@ def __init__(cls, name, bases, attrs):
3030
def _set_special_states(cls):
3131
if not cls.states:
3232
return
33-
initials = [s for s in cls.states if s.initial]
33+
initials = [s for s in cls.states if s.initial and not s.parent]
3434
if len(initials) != 1:
3535
raise InvalidDefinition(
3636
_(
3737
"There should be one and only one initial state. "
38-
"Your currently have these: {!r}".format(initials)
39-
)
38+
"Your currently have these: {0}"
39+
).format(", ".join(s.id for s in initials))
4040
)
4141
cls.initial_state = initials[0]
4242
cls.final_states = [state for state in cls.states if state.final]
@@ -70,8 +70,9 @@ def _check(cls):
7070
if not cls.states:
7171
raise InvalidDefinition(_("There are no states."))
7272

73-
if not cls._events:
74-
raise InvalidDefinition(_("There are no events."))
73+
# TODO: Validate no events if has nested states
74+
# if not cls._events:
75+
# raise InvalidDefinition(_("There are no events."))
7576

7677
cls._check_disconnected_state()
7778

@@ -127,6 +128,9 @@ def add_state(cls, id, state):
127128
for event in state.transitions.unique_events:
128129
cls.add_event(event)
129130

131+
for substate in state.substates:
132+
cls.add_state(substate.id, substate)
133+
130134
def add_event(cls, event, transitions=None):
131135
if transitions is not None:
132136
transitions.add_event(event)

statemachine/state.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,26 @@
77
from .utils import ugettext as _
88

99

10+
class NestedStateFactory(type):
11+
def __new__(cls, classname, bases, attrs, name=None, initial=False, parallel=False):
12+
13+
if not bases:
14+
return super().__new__(cls, classname, bases, attrs)
15+
16+
substates = []
17+
for key, value in attrs.items():
18+
if not isinstance(value, State):
19+
continue
20+
value._set_id(key)
21+
substates.append(value)
22+
23+
return State(name, initial=initial, parallel=parallel, substates=substates)
24+
25+
26+
class NestedStateBuilder(metaclass=NestedStateFactory):
27+
pass
28+
29+
1030
class State:
1131
"""
1232
A State in a state machine describes a particular behaviour of the machine.
@@ -73,18 +93,36 @@ class State:
7393
7494
"""
7595

96+
Builder = NestedStateBuilder
97+
7698
def __init__(
77-
self, name, value=None, initial=False, final=False, enter=None, exit=None
99+
self,
100+
name,
101+
value=None,
102+
initial=False,
103+
final=False,
104+
parallel=False,
105+
substates=None,
106+
enter=None,
107+
exit=None,
78108
):
79-
# type: (str, Optional[Any], bool, bool, Optional[Any], Optional[Any]) -> None
109+
# type: (str, Optional[Any], bool, bool, bool, Optional[Any], Optional[Any], Optional[Any]) -> None # noqa
80110
self.name = name
81111
self.value = value
112+
self.parallel = parallel
113+
self.parent: "State" = None
114+
self.substates = substates or []
82115
self._id = None # type: Optional[str]
83116
self._initial = initial
84117
self.transitions = TransitionList()
85118
self._final = final
86119
self.enter = Callbacks().add(enter)
87120
self.exit = Callbacks().add(exit)
121+
self._init_substates()
122+
123+
def _init_substates(self):
124+
for substate in self.substates:
125+
substate.parent = self
88126

89127
def _setup(self, resolver):
90128
self.enter.setup(resolver)

statemachine/statemachine.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,6 @@ def current_state_value(self, value):
124124

125125
@property
126126
def current_state(self):
127-
# type: () -> Optional[State]
128127
return self.states_map.get(self.current_state_value, None)
129128

130129
@current_state.setter
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)