Skip to content

Commit 6d90d2e

Browse files
authored
Check for trap states and states that can't reach a final state (#410)
* Add check for stuck states and states that don't reach a final state. * Document state transition checks, and strict_states flag behaviour.
1 parent 9d177b2 commit 6d90d2e

13 files changed

Lines changed: 205 additions & 35 deletions

docs/states.md

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,57 @@ A {ref}`StateMachine` should have one and only one `initial` {ref}`state`.
2121
The initial {ref}`state` is entered when the machine starts and the corresponding entering
2222
state {ref}`actions` are called if defined.
2323

24+
## State Transitions
25+
26+
All states should have at least one transition to and from another state.
27+
28+
If any states are unreachable from the initial state, an `InvalidDefinition` exception will be thrown.
29+
30+
```py
31+
>>> from statemachine import StateMachine, State
32+
33+
>>> class TrafficLightMachine(StateMachine):
34+
... "A workflow machine"
35+
... red = State('Red', initial=True, value=1)
36+
... green = State('Green', value=2)
37+
... orange = State('Orange', value=3)
38+
... hazard = State('Hazard', value=4)
39+
...
40+
... cycle = red.to(green) | green.to(orange) | orange.to(red)
41+
... blink = hazard.to.itself()
42+
Traceback (most recent call last):
43+
...
44+
InvalidDefinition: There are unreachable states. The statemachine graph should have a single component. Disconnected states: ['hazard']
45+
```
46+
47+
`StateMachine` will also check that all non-final states have an outgoing transition, and warn you if any states would result in
48+
the statemachine becoming trapped in a non-final state with no further transitions possible.
49+
50+
```{note}
51+
This will currently issue a warning, but can be turned into an exception by setting `strict_states=True` on the class.
52+
```
53+
54+
```py
55+
>>> from statemachine import StateMachine, State
56+
57+
>>> class TrafficLightMachine(StateMachine, strict_states=True):
58+
... "A workflow machine"
59+
... red = State('Red', initial=True, value=1)
60+
... green = State('Green', value=2)
61+
... orange = State('Orange', value=3)
62+
... hazard = State('Hazard', value=4)
63+
...
64+
... cycle = red.to(green) | green.to(orange) | orange.to(red)
65+
... fault = red.to(hazard) | green.to(hazard) | orange.to(hazard)
66+
Traceback (most recent call last):
67+
...
68+
InvalidDefinition: All non-final states should have at least one outgoing transition. These states have no outgoing transition: ['hazard']
69+
```
70+
71+
```{warning}
72+
`strict_states=True` will become the default behaviour in future versions.
73+
```
74+
2475

2576
(final-state)=
2677
## Final state
@@ -47,7 +98,35 @@ InvalidDefinition: Cannot declare transitions from final state. Invalid state(s)
4798

4899
```
49100

50-
You can retrieve all final states.
101+
If you mark any states as final, `StateMachine` will check that all non-final states have a path to reach at least one final state.
102+
103+
```{note}
104+
This will currently issue a warning, but can be turned into an exception by setting `strict_states=True` on the class.
105+
```
106+
107+
```py
108+
>>> class CampaignMachine(StateMachine, strict_states=True):
109+
... "A workflow machine"
110+
... draft = State('Draft', initial=True, value=1)
111+
... producing = State('Being produced', value=2)
112+
... abandoned = State('Abandoned', value=3)
113+
... closed = State('Closed', final=True, value=4)
114+
...
115+
... add_job = draft.to.itself() | producing.to.itself()
116+
... produce = draft.to(producing)
117+
... abandon = producing.to(abandoned) | abandoned.to(abandoned)
118+
... deliver = producing.to(closed)
119+
Traceback (most recent call last):
120+
...
121+
InvalidDefinition: All non-final states should have at least one path to a final state. These states have no path to a final state: ['abandoned']
122+
123+
```
124+
125+
```{warning}
126+
`strict_states=True` will become the default behaviour in future versions.
127+
```
128+
129+
You can query a list of all final states from your statemachine.
51130

52131
```py
53132
>>> class CampaignMachine(StateMachine):
@@ -65,6 +144,9 @@ You can retrieve all final states.
65144
>>> machine.final_states
66145
[State('Closed', id='closed', value=3, initial=False, final=True)]
67146

147+
>>> machine.current_state in machine.final_states
148+
False
149+
68150
```
69151

70152
## States from Enum types

docs/transitions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,8 @@ an action that `echoes` back the parameters informed.
219219
```
220220

221221

222-
This action is executed before the transition associated with `cycle` event is activated, so you
223-
can also raise an exception at this point to stop a transition to occur.
222+
This action is executed before the transition associated with `cycle` event is activated.
223+
You can raise an exception at this point to stop a transition from completing.
224224

225225
```py
226226
>>> machine.current_state.id

statemachine/factory.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import warnings
12
from typing import TYPE_CHECKING
23
from typing import Any
34
from typing import Dict
@@ -18,7 +19,15 @@
1819

1920

2021
class StateMachineMetaclass(type):
21-
def __init__(cls, name: str, bases: Tuple[type], attrs: Dict[str, Any]):
22+
"Metaclass for constructing StateMachine classes"
23+
24+
def __init__(
25+
cls,
26+
name: str,
27+
bases: Tuple[type],
28+
attrs: Dict[str, Any],
29+
strict_states: bool = False,
30+
) -> None:
2231
super().__init__(name, bases, attrs)
2332
registry.register(cls)
2433
cls.name = cls.__name__
@@ -27,6 +36,7 @@ def __init__(cls, name: str, bases: Tuple[type], attrs: Dict[str, Any]):
2736
"""Map of ``state.value`` to the corresponding :ref:`state`."""
2837

2938
cls._abstract = True
39+
cls._strict_states = strict_states
3040
cls._events: Dict[str, Event] = {}
3141

3242
cls.add_inherited(bases)
@@ -66,14 +76,16 @@ def _check(cls):
6676
cls._check_initial_state()
6777
cls._check_final_states()
6878
cls._check_disconnected_state()
79+
cls._check_trap_states()
80+
cls._check_reachable_final_states()
6981

7082
def _check_initial_state(cls):
7183
initials = [s for s in cls.states if s.initial]
7284
if len(initials) != 1:
7385
raise InvalidDefinition(
7486
_(
7587
"There should be one and only one initial state. "
76-
"Your currently have these: {!r}"
88+
"You currently have these: {!r}"
7789
).format([s.id for s in initials])
7890
)
7991

@@ -89,6 +101,40 @@ def _check_final_states(cls):
89101
).format([s.id for s in final_state_with_invalid_transitions])
90102
)
91103

104+
def _check_trap_states(cls):
105+
trap_states = [s for s in cls.states if not s.final and not s.transitions]
106+
if trap_states:
107+
message = _(
108+
"All non-final states should have at least one outgoing transition. "
109+
"These states have no outgoing transition: {!r}"
110+
).format([s.id for s in trap_states])
111+
if cls._strict_states:
112+
raise InvalidDefinition(message)
113+
else:
114+
warnings.warn(message, UserWarning, stacklevel=1)
115+
116+
def _check_reachable_final_states(cls):
117+
if not any(s.final for s in cls.states):
118+
return # No need to check final reachability
119+
disconnected_states = cls._states_without_path_to_final_states()
120+
if disconnected_states:
121+
message = _(
122+
"All non-final states should have at least one path to a final state. "
123+
"These states have no path to a final state: {!r}"
124+
).format([s.id for s in disconnected_states])
125+
if cls._strict_states:
126+
raise InvalidDefinition(message)
127+
else:
128+
warnings.warn(message, UserWarning, stacklevel=1)
129+
130+
def _states_without_path_to_final_states(cls):
131+
return [
132+
state
133+
for state in cls.states
134+
if not state.final
135+
and not any(s.final for s in visit_connected_states(state))
136+
]
137+
92138
def _disconnected_states(cls, starting_state):
93139
visitable_states = set(visit_connected_states(starting_state))
94140
return set(cls.states) - visitable_states

statemachine/statemachine.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ def __init__(
8484
self._setup(initial_transition)
8585
self._activate_initial_state(initial_transition)
8686

87+
def __init_subclass__(cls, strict_states: bool = False):
88+
cls._strict_states = strict_states
89+
super().__init_subclass__()
90+
8791
if TYPE_CHECKING:
8892
"""Makes mypy happy with dynamic created attributes"""
8993

tests/conftest.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class CampaignMachine(StateMachine):
2323
"A workflow machine"
2424
draft = State(initial=True)
2525
producing = State("Being produced")
26-
closed = State()
26+
closed = State(final=True)
2727

2828
add_job = draft.to(draft) | producing.to(producing)
2929
produce = draft.to(producing)
@@ -42,7 +42,7 @@ class CampaignMachine(StateMachine):
4242
"A workflow machine"
4343
draft = State(initial=True)
4444
producing = State("Being produced")
45-
closed = State()
45+
closed = State(final=True)
4646

4747
add_job = draft.to(draft) | producing.to(producing)
4848
produce = draft.to(producing, validators="can_produce")
@@ -84,7 +84,7 @@ class CampaignMachineWithKeys(StateMachine):
8484
"A workflow machine"
8585
draft = State(initial=True, value=1)
8686
producing = State("Being produced", value=2)
87-
closed = State(value=3)
87+
closed = State(value=3, final=True)
8888

8989
add_job = draft.to(draft) | producing.to(producing)
9090
produce = draft.to(producing)
@@ -164,7 +164,7 @@ class ApprovalMachine(StateMachine):
164164
accepted = State()
165165
rejected = State()
166166

167-
completed = State()
167+
completed = State(final=True)
168168

169169
validate = requested.to(accepted, cond="is_ok") | requested.to(rejected)
170170

tests/test_callbacks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ def race_uppercase(race):
184184
assert race_uppercase("Hobbit") == "HOBBIT"
185185

186186
def test_decorate_unbounded_machine_methods(self):
187-
class MiniHeroJourneyMachine(StateMachine):
187+
class MiniHeroJourneyMachine(StateMachine, strict_states=False):
188188

189189
ordinary_world = State(initial=True)
190190
call_to_adventure = State()

tests/test_multiple_destinations.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ def test_transition_to_first_that_executes_if_multiple_targets():
5252
class ApprovalMachine(StateMachine):
5353
"A workflow"
5454
requested = State(initial=True)
55-
accepted = State()
56-
rejected = State()
55+
accepted = State(final=True)
56+
rejected = State(final=True)
5757

5858
validate = requested.to(accepted, rejected)
5959

@@ -70,8 +70,8 @@ def never_will_pass(event_data):
7070
class ApprovalMachine(StateMachine):
7171
"A workflow"
7272
requested = State(initial=True)
73-
accepted = State()
74-
rejected = State()
73+
accepted = State(final=True)
74+
rejected = State(final=True)
7575

7676
validate = (
7777
requested.to(accepted, cond=never_will_pass)
@@ -97,8 +97,8 @@ def test_check_invalid_reference_to_conditions():
9797
class ApprovalMachine(StateMachine):
9898
"A workflow"
9999
requested = State(initial=True)
100-
accepted = State()
101-
rejected = State()
100+
accepted = State(final=True)
101+
rejected = State(final=True)
102102

103103
validate = requested.to(accepted, cond="not_found_condition") | requested.to(
104104
rejected
@@ -114,7 +114,7 @@ class ApprovalMachine(StateMachine):
114114
requested = State(initial=True)
115115
accepted = State()
116116
rejected = State()
117-
completed = State()
117+
completed = State(final=True)
118118

119119
validate = (
120120
requested.to(accepted, cond="is_ok")
@@ -179,8 +179,8 @@ def test_multiple_values_returned_with_multiple_targets():
179179
class ApprovalMachine(StateMachine):
180180
"A workflow"
181181
requested = State(initial=True)
182-
accepted = State()
183-
denied = State()
182+
accepted = State(final=True)
183+
denied = State(final=True)
184184

185185
@requested.to(accepted, denied)
186186
def validate(self):
@@ -206,7 +206,7 @@ def test_multiple_targets_using_or_starting_from_same_origin(
206206
):
207207
class InvoiceStateMachine(StateMachine):
208208
unpaid = State(initial=True)
209-
paid = State()
209+
paid = State(final=True)
210210
failed = State()
211211

212212
pay = (

tests/test_rtc.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ def chained_after_sm_class(): # noqa: C901
1313
class ChainedSM(StateMachine):
1414
a = State(initial=True)
1515
b = State()
16-
c = State()
16+
c = State(final=True)
1717

1818
t1 = a.to(b, after="t1") | b.to(c)
1919

@@ -52,7 +52,7 @@ class ChainedSM(StateMachine):
5252
s1 = State(initial=True)
5353
s2 = State()
5454
s3 = State()
55-
s4 = State()
55+
s4 = State(final=True)
5656

5757
t1 = s1.to(s2)
5858
t2a = s2.to(s2)

tests/test_statemachine.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ def test_machine_should_activate_initial_state():
5454
class CampaignMachine(StateMachine):
5555
"A workflow machine"
5656
producing = State()
57-
closed = State()
57+
closed = State(final=True)
5858
draft = State(initial=True)
5959

6060
add_job = draft.to(draft) | producing.to(producing)
@@ -381,7 +381,7 @@ def test_state_value_is_correct():
381381
STATE_NEW = 0
382382
STATE_DRAFT = 1
383383

384-
class ValueTestModel(StateMachine):
384+
class ValueTestModel(StateMachine, strict_states=False):
385385
new = State(STATE_NEW, value=STATE_NEW, initial=True)
386386
draft = State(STATE_DRAFT, value=STATE_DRAFT)
387387

tests/test_statemachine_bounded_transitions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def state_machine(event_mock):
1818
class CampaignMachine(StateMachine):
1919
draft = State(initial=True)
2020
producing = State()
21-
closed = State()
21+
closed = State(final=True)
2222

2323
add_job = draft.to(draft) | producing.to(producing)
2424
produce = draft.to(producing)

0 commit comments

Comments
 (0)