Skip to content

Commit 932cdfc

Browse files
committed
feat: Nested states (compound / parallel)
1 parent 6dbc55c commit 932cdfc

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
@@ -84,7 +84,7 @@ def __str__(self):
8484
return ", ".join(str(c) for c in self)
8585

8686
def setup(self, resolver):
87-
"""Validate configuracions"""
87+
"""Validate configurations"""
8888
self._resolver = resolver
8989
self.items = [
9090
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}"
39-
).format([s.id for s in initials])
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]
@@ -68,8 +68,9 @@ def _check(cls):
6868
if not cls.states:
6969
raise InvalidDefinition(_("There are no states."))
7070

71-
if not cls._events:
72-
raise InvalidDefinition(_("There are no events."))
71+
# TODO: Validate no events if has nested states
72+
# if not cls._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):
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: 40 additions & 2 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.
@@ -74,19 +94,37 @@ class State:
7494
7595
"""
7696

97+
Builder = NestedStateBuilder
98+
7799
def __init__(
78-
self, name, value=None, initial=False, final=False, enter=None, exit=None
100+
self,
101+
name,
102+
value=None,
103+
initial=False,
104+
final=False,
105+
parallel=False,
106+
substates=None,
107+
enter=None,
108+
exit=None,
79109
):
80-
# type: (str, Optional[Any], bool, bool, Optional[Any], Optional[Any]) -> None
110+
# type: (str, Optional[Any], bool, bool, bool, Optional[Any], Optional[Any], Optional[Any]) -> None # noqa
81111
self.name = name
82112
self.value = value
113+
self.parallel = parallel
114+
self.parent: "State" = None
115+
self.substates = substates or []
83116
self._id = None # type: Optional[str]
84117
self._storage = ""
85118
self._initial = initial
86119
self.transitions = TransitionList()
87120
self._final = final
88121
self.enter = Callbacks().add(enter)
89122
self.exit = Callbacks().add(exit)
123+
self._init_substates()
124+
125+
def _init_substates(self):
126+
for substate in self.substates:
127+
substate.parent = self
90128

91129
def __eq__(self, other):
92130
return (

statemachine/statemachine.py

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

137137
@property
138138
def current_state(self):
139-
# type: () -> Optional[State]
140139
return self.states_map.get(self.current_state_value, None)
141140

142141
@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)