Skip to content

Commit 7faf7fa

Browse files
authored
refactor!: remove strict_states, add validate_trap_states and validate_final_reachability (#568)
Replace the `strict_states` class parameter (introduced in v2.2.0) with two independent class-level attributes that default to `True`: - `validate_trap_states`: non-final states must have outgoing transitions - `validate_final_reachability`: when final states exist, all non-final states must have a path to at least one final state This fulfils the v2.2.0 promise that `strict_states=True` would become the default in the next major release. The warning-based behavior is removed — violations now always raise `InvalidDefinition`. BREAKING CHANGE: `strict_states=True/False` no longer accepted. Use `validate_trap_states = False` / `validate_final_reachability = False` to opt out, or (recommended) mark terminal states as `final=True`.
1 parent 8117a5e commit 7faf7fa

18 files changed

Lines changed: 206 additions & 96 deletions

docs/releases/2.2.0.md

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -39,21 +39,21 @@ and warn you if any states would result in the statemachine becoming trapped in
3939
This will currently issue a warning, but can be turned into an exception by setting `strict_states=True` on the class.
4040
```
4141

42-
```py
43-
>>> from statemachine import StateMachine, State
42+
```python
43+
from statemachine import StateMachine, State
4444

45-
>>> class TrafficLightMachine(StateMachine, strict_states=True):
46-
... "A workflow machine"
47-
... red = State('Red', initial=True, value=1)
48-
... green = State('Green', value=2)
49-
... orange = State('Orange', value=3)
50-
... hazard = State('Hazard', value=4)
51-
...
52-
... cycle = red.to(green) | green.to(orange) | orange.to(red)
53-
... fault = red.to(hazard) | green.to(hazard) | orange.to(hazard)
54-
Traceback (most recent call last):
55-
...
56-
InvalidDefinition: All non-final states should have at least one outgoing transition. These states have no outgoing transition: ['hazard']
45+
class TrafficLightMachine(StateMachine, strict_states=True):
46+
"A workflow machine"
47+
red = State('Red', initial=True, value=1)
48+
green = State('Green', value=2)
49+
orange = State('Orange', value=3)
50+
hazard = State('Hazard', value=4)
51+
52+
cycle = red.to(green) | green.to(orange) | orange.to(red)
53+
fault = red.to(hazard) | green.to(hazard) | orange.to(hazard)
54+
55+
# InvalidDefinition: All non-final states should have at least one outgoing transition.
56+
# These states have no outgoing transition: ['hazard']
5757
```
5858

5959
```{warning}

docs/releases/3.0.0.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,3 +580,41 @@ The short-name lookup (by `cls.__name__`) that was deprecated since v0.8 has bee
580580

581581
If you use `get_machine_cls()` (e.g., via `MachineMixin`), make sure you pass the fully-qualified
582582
dotted path.
583+
584+
585+
### `strict_states` parameter removed
586+
587+
The `strict_states` class parameter (introduced in v2.2.0) has been removed and replaced by
588+
two independent class-level attributes that default to `True`:
589+
590+
- `validate_trap_states`: non-final states must have at least one outgoing transition.
591+
- `validate_final_reachability`: when final states exist, all non-final states must have
592+
a path to at least one final state.
593+
594+
**Migration:**
595+
596+
- Remove `strict_states=True` — this is now the default behavior.
597+
- **Recommended:** fix your state machine definition so that terminal states are marked
598+
`final=True`:
599+
600+
```py
601+
>>> from statemachine import State, StateChart
602+
603+
>>> class MySM(StateChart):
604+
... s1 = State(initial=True)
605+
... s2 = State(final=True)
606+
... go = s1.to(s2)
607+
608+
```
609+
610+
- If you intentionally have non-final trap states, replace `strict_states=False` with
611+
`validate_trap_states = False` and/or `validate_final_reachability = False`:
612+
613+
```py
614+
>>> class MySM(StateChart):
615+
... validate_trap_states = False
616+
... s1 = State(initial=True)
617+
... s2 = State()
618+
... go = s1.to(s2)
619+
620+
```

docs/releases/upgrade_2x_to_3.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ defaults. Review this guide to understand what changed and adopt the new APIs at
1919
7. Review `on` callbacks that query `is_active` or `current_state` during transitions.
2020
8. If using `States.from_enum`, note that `use_enum_instance` now defaults to `True`.
2121
9. If using `get_machine_cls()` with short names, switch to fully-qualified names.
22+
10. Remove `strict_states=True/False` — replace with `validate_trap_states` / `validate_final_reachability`.
2223

2324
---
2425

@@ -369,6 +370,44 @@ from statemachine import Event # unchanged
369370
```
370371

371372

373+
## `strict_states` removed — use `validate_trap_states` / `validate_final_reachability`
374+
375+
The `strict_states` class parameter has been removed. The two validations it controlled are now
376+
always-on by default, each controlled by its own class-level attribute.
377+
378+
**Before (2.x):**
379+
380+
```python
381+
class MyMachine(StateMachine, strict_states=True):
382+
# raises InvalidDefinition for trap states and unreachable final states
383+
...
384+
385+
class MyMachine(StateMachine, strict_states=False):
386+
# only warns (default in 2.x)
387+
...
388+
```
389+
390+
**After (3.0) — recommended: fix the definition by marking terminal states as `final`:**
391+
392+
```python
393+
class MyMachine(StateMachine):
394+
s1 = State(initial=True)
395+
s2 = State(final=True) # was State() — now correctly marked as final
396+
go = s1.to(s2)
397+
```
398+
399+
**After (3.0) — opt out if you intentionally have non-final trap states:**
400+
401+
```python
402+
class MyMachine(StateMachine):
403+
validate_trap_states = False # allow non-final states without outgoing transitions
404+
validate_final_reachability = False # allow non-final states without path to final
405+
...
406+
```
407+
408+
The two flags are independent — you can disable one while keeping the other enabled.
409+
410+
372411
## New features overview
373412

374413
For full details on all new features, see the {ref}`3.0.0 release notes <StateMachine 3.0.0>`.

docs/states.md

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -44,17 +44,13 @@ Traceback (most recent call last):
4444
InvalidDefinition: There are unreachable states. The statemachine graph should have a single component. Disconnected states: ['hazard']
4545
```
4646

47-
`StateChart` 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-
```
47+
`StateChart` will also check that all non-final states have an outgoing transition.
48+
If any non-final state has no outgoing transitions, an `InvalidDefinition` exception is raised.
5349

5450
```py
5551
>>> from statemachine import StateChart, State
5652

57-
>>> class TrafficLightMachine(StateChart, strict_states=True):
53+
>>> class TrafficLightMachine(StateChart):
5854
... "A workflow machine"
5955
... red = State('Red', initial=True, value=1)
6056
... green = State('Green', value=2)
@@ -68,8 +64,19 @@ Traceback (most recent call last):
6864
InvalidDefinition: All non-final states should have at least one outgoing transition. These states have no outgoing transition: ['hazard']
6965
```
7066

71-
```{warning}
72-
`strict_states=True` will become the default behaviour in future versions.
67+
You can disable this check by setting `validate_trap_states = False` on the class:
68+
69+
```py
70+
>>> class TrafficLightMachine(StateChart):
71+
... validate_trap_states = False
72+
... red = State('Red', initial=True, value=1)
73+
... green = State('Green', value=2)
74+
... orange = State('Orange', value=3)
75+
... hazard = State('Hazard', value=4)
76+
...
77+
... cycle = red.to(green) | green.to(orange) | orange.to(red)
78+
... fault = red.to(hazard) | green.to(hazard) | orange.to(hazard)
79+
7380
```
7481

7582

@@ -100,12 +107,8 @@ InvalidDefinition: Cannot declare transitions from final state. Invalid state(s)
100107

101108
If you mark any states as final, `StateChart` will check that all non-final states have a path to reach at least one final state.
102109

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-
107110
```py
108-
>>> class CampaignMachine(StateChart, strict_states=True):
111+
>>> class CampaignMachine(StateChart):
109112
... "A workflow machine"
110113
... draft = State('Draft', initial=True, value=1)
111114
... producing = State('Being produced', value=2)
@@ -122,9 +125,7 @@ InvalidDefinition: All non-final states should have at least one path to a final
122125

123126
```
124127

125-
```{warning}
126-
`strict_states=True` will become the default behaviour in future versions.
127-
```
128+
You can disable this check by setting `validate_final_reachability = False` on the class.
128129

129130
You can query a list of all final states from your statemachine.
130131

docs/transitions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ State machine class level. The name will be converted to an {ref}`Event`:
185185

186186
>>> class SimpleSM(StateChart):
187187
... initial = State(initial=True)
188-
... final = State()
188+
... final = State(final=True)
189189
...
190190
... start = initial.to(final) # start is a name that will be converted to an `Event`
191191

@@ -207,7 +207,7 @@ To declare an explicit event you must also import the {ref}`Event`:
207207

208208
>>> class SimpleSM(StateChart):
209209
... initial = State(initial=True)
210-
... final = State()
210+
... final = State(final=True)
211211
...
212212
... start = Event(
213213
... initial.to(final),

statemachine/factory.py

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import warnings
21
from typing import Any
32
from typing import Dict
43
from typing import List
@@ -28,12 +27,18 @@ class StateMachineMetaclass(type):
2827
validate_disconnected_states: bool = True
2928
"""If `True`, the state machine will validate that there are no unreachable states."""
3029

30+
validate_trap_states: bool = True
31+
"""If ``True``, non-final states without outgoing transitions raise ``InvalidDefinition``."""
32+
33+
validate_final_reachability: bool = True
34+
"""If ``True`` and final states exist, non-final states without a path to any final
35+
state raise ``InvalidDefinition``."""
36+
3137
def __init__(
3238
cls,
3339
name: str,
3440
bases: Tuple[type],
3541
attrs: Dict[str, Any],
36-
strict_states: bool = False,
3742
) -> None:
3843
super().__init__(name, bases, attrs)
3944
registry.register(cls)
@@ -46,7 +51,6 @@ def __init__(
4651
"""Map of ``state.value`` to the corresponding :ref:`state`."""
4752

4853
cls._abstract = True
49-
cls._strict_states = strict_states
5054
cls._events: Dict[Event, None] = {} # used Dict to preserve order and avoid duplicates
5155
cls._protected_attrs: set = set()
5256
cls._events_to_update: Dict[Event, Optional[Event]] = {}
@@ -173,30 +177,30 @@ def _check_final_states(cls):
173177
)
174178

175179
def _check_trap_states(cls):
180+
if not cls.validate_trap_states:
181+
return
176182
trap_states = [s for s in cls.states if not s.final and not s.transitions]
177183
if trap_states:
178-
message = _(
179-
"All non-final states should have at least one outgoing transition. "
180-
"These states have no outgoing transition: {!r}"
181-
).format([s.id for s in trap_states])
182-
if cls._strict_states:
183-
raise InvalidDefinition(message)
184-
else:
185-
warnings.warn(message, UserWarning, stacklevel=4)
184+
raise InvalidDefinition(
185+
_(
186+
"All non-final states should have at least one outgoing transition. "
187+
"These states have no outgoing transition: {!r}"
188+
).format([s.id for s in trap_states])
189+
)
186190

187191
def _check_reachable_final_states(cls):
192+
if not cls.validate_final_reachability:
193+
return
188194
if not any(s.final for s in cls.states):
189195
return # No need to check final reachability
190196
disconnected_states = list(states_without_path_to_final_states(cls.states))
191197
if disconnected_states:
192-
message = _(
193-
"All non-final states should have at least one path to a final state. "
194-
"These states have no path to a final state: {!r}"
195-
).format([s.id for s in disconnected_states])
196-
if cls._strict_states:
197-
raise InvalidDefinition(message)
198-
else:
199-
warnings.warn(message, UserWarning, stacklevel=1)
198+
raise InvalidDefinition(
199+
_(
200+
"All non-final states should have at least one path to a final state. "
201+
"These states have no path to a final state: {!r}"
202+
).format([s.id for s in disconnected_states])
203+
)
200204

201205
def _check_disconnected_state(cls):
202206
if not cls.validate_disconnected_states:

statemachine/io/scxml/processor.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ def process_definition(self, definition, location: str):
105105
"states": states_dict,
106106
"prepare_event": self._prepare_event,
107107
"validate_disconnected_states": False,
108+
"validate_trap_states": False,
109+
"validate_final_reachability": False,
108110
"start_configuration_values": list(definition.initial_states),
109111
},
110112
)

statemachine/statemachine.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,6 @@ class StateChart(Generic[TModel], metaclass=StateMachineMetaclass):
126126
"""List of top-level :ref:`State` objects marked as ``final``."""
127127

128128
_abstract: bool
129-
_strict_states: bool
130129
_events: "Dict[Event, None]"
131130
_protected_attrs: set
132131
_specs: CallbackSpecList
@@ -181,10 +180,6 @@ def _processing_loop(self, caller_future: "Any | None" = None) -> Any:
181180
return result
182181
return run_async_from_sync(result)
183182

184-
def __init_subclass__(cls, strict_states: bool = False):
185-
cls._strict_states = strict_states
186-
super().__init_subclass__()
187-
188183
def __repr__(self):
189184
configuration_ids = [s.id for s in self.configuration]
190185
return (

tests/examples/statechart_error_handling_machine.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ class QuestNoCatch(StateChart):
8989
error_on_execution = False
9090

9191
safe = State("Safe", initial=True)
92-
danger_zone = State("Danger Zone")
92+
danger_zone = State("Danger Zone", final=True)
9393

9494
venture = safe.to(danger_zone)
9595

tests/test_async.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ async def test_async_error_on_execution_in_condition():
216216

217217
class SM(StateChart):
218218
s1 = State(initial=True)
219-
s2 = State()
219+
s2 = State(final=True)
220220
error_state = State(final=True)
221221

222222
go = s1.to(s2, cond="bad_cond")
@@ -236,7 +236,7 @@ async def test_async_error_on_execution_in_transition():
236236

237237
class SM(StateChart):
238238
s1 = State(initial=True)
239-
s2 = State()
239+
s2 = State(final=True)
240240
error_state = State(final=True)
241241

242242
go = s1.to(s2, on="bad_action")
@@ -276,7 +276,7 @@ async def test_async_invalid_definition_in_transition_propagates():
276276

277277
class SM(StateChart):
278278
s1 = State(initial=True)
279-
s2 = State()
279+
s2 = State(final=True)
280280

281281
go = s1.to(s2, on="bad_action")
282282

@@ -338,7 +338,7 @@ async def test_async_engine_invalid_definition_in_condition_propagates():
338338

339339
class SM(StateChart):
340340
s1 = State(initial=True)
341-
s2 = State()
341+
s2 = State(final=True)
342342

343343
go = s1.to(s2, cond="bad_cond")
344344

@@ -357,7 +357,7 @@ async def test_async_engine_invalid_definition_in_transition_propagates():
357357

358358
class SM(StateChart):
359359
s1 = State(initial=True)
360-
s2 = State()
360+
s2 = State(final=True)
361361

362362
go = s1.to(s2, on="bad_action")
363363

@@ -494,7 +494,7 @@ async def test_duplicate_event_across_transitions_deduplicated(self):
494494

495495
class MyMachine(StateChart):
496496
s0 = State(initial=True)
497-
s1 = State()
497+
s1 = State(final=True)
498498
s2 = State(final=True)
499499

500500
go = s0.to(s1, cond="cond_a") | s0.to(s2, cond="cond_b")
@@ -514,7 +514,7 @@ async def cond_b(self):
514514
async def test_mixed_enabled_and_disabled_async(self):
515515
class MyMachine(StateChart):
516516
s0 = State(initial=True)
517-
s1 = State()
517+
s1 = State(final=True)
518518
s2 = State(final=True)
519519

520520
go = s0.to(s1, cond="cond_true")

0 commit comments

Comments
 (0)