Skip to content

Commit d1a0826

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

File tree

5 files changed

+147
-5
lines changed

5 files changed

+147
-5
lines changed

statemachine/factory.py

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

71-
if not has_events:
72-
raise InvalidDefinition(_("There are no events."))
71+
# TODO: Validate no events if has nested states
72+
# if not has_events:
73+
# raise InvalidDefinition(_("There are no events."))
7374

7475
cls._check_disconnected_state()
7576

@@ -123,6 +124,9 @@ def add_state(cls, id, state: State):
123124
for event in state.transitions.unique_events:
124125
cls.add_event(event)
125126

127+
for substate in state.substates:
128+
cls.add_state(substate.id, substate)
129+
126130
def add_event(cls, event, transitions=None):
127131
if transitions is not None:
128132
transitions.add_event(event)

statemachine/state.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,26 @@
88
from .utils import ugettext as _
99

1010

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

110+
Builder = NestedStateBuilder
111+
90112
def __init__(
91113
self,
92114
name: str = "",
93115
value: Any = None,
94116
initial: bool = False,
95117
final: bool = False,
118+
parallel=False,
119+
substates=None,
96120
enter: Any = None,
97121
exit: Any = None,
98122
):
99123
self.name = name
100124
self.value = value
125+
self.parallel = parallel
126+
self.parent: "State" = None
127+
self.substates = substates or []
101128
self._initial = initial
102129
self._final = final
103130
self._id: str = ""
104131
self._storage: str = ""
105132
self.transitions = TransitionList()
106133
self.enter = Callbacks().add(enter)
107134
self.exit = Callbacks().add(exit)
135+
self._init_substates()
136+
137+
def _init_substates(self):
138+
for substate in self.substates:
139+
substate.parent = self
108140

109141
def __eq__(self, other):
110142
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)