Skip to content

Commit 4d49dab

Browse files
authored
fix: make disconnected states validation hierarchy-aware (#573)
The `visit_connected_states` BFS now traverses the state hierarchy: entering a compound/parallel state implicitly enters its initial children, and being in a child implies being in all ancestor states. This removes the need for `validate_disconnected_states = False` in virtually all parallel, compound, and history state examples — the flag was only needed because the validator didn't understand hierarchical entry semantics.
1 parent dd6f3c9 commit 4d49dab

19 files changed

Lines changed: 82 additions & 88 deletions

README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,6 @@ regions reach a final state:
181181
>>> from statemachine import StateChart, State
182182

183183
>>> class DeployPipeline(StateChart):
184-
... validate_disconnected_states = False
185184
... class deploy(State.Parallel):
186185
... class build(State.Compound):
187186
... compiling = State(initial=True)
@@ -219,7 +218,6 @@ of starting from the initial one:
219218
>>> from statemachine import HistoryState, StateChart, State
220219

221220
>>> class EditorWithHistory(StateChart):
222-
... validate_disconnected_states = False
223221
... class editor(State.Compound):
224222
... source = State(initial=True)
225223
... visual = State()

docs/releases/3.0.0.md

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,6 @@ the parent and all descendants. See {ref}`statecharts` for full details.
127127
>>> from statemachine import State, StateChart
128128

129129
>>> class WarOfTheRing(StateChart):
130-
... validate_disconnected_states = False
131130
... class war(State.Parallel):
132131
... class frodos_quest(State.Compound):
133132
... shire = State(initial=True)
@@ -160,7 +159,6 @@ Supports both shallow (`HistoryState()`) and deep (`HistoryState(deep=True)`) hi
160159
>>> from statemachine import HistoryState, State, StateChart
161160

162161
>>> class GollumPersonality(StateChart):
163-
... validate_disconnected_states = False
164162
... class personality(State.Compound):
165163
... smeagol = State(initial=True)
166164
... gollum = State()
@@ -402,15 +400,6 @@ if sm.is_terminated:
402400
print("State machine has finished.")
403401
```
404402

405-
406-
### Disable single graph component validation
407-
408-
Since SCXML don't require that all states should be reachable by transitions, we added a class-level
409-
flag `validate_disconnected_states: bool = True` that can be used to disable this validation.
410-
411-
It's already disabled when parsing SCXML files.
412-
413-
414403
### Typed models with `Generic[TModel]`
415404

416405
`StateChart` now supports a generic type parameter for the model, enabling full type

docs/statecharts.md

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,6 @@ independently — events in one region don't affect others. Use `State.Parallel`
339339
>>> from statemachine import State, StateChart
340340

341341
>>> class WarOfTheRing(StateChart):
342-
... validate_disconnected_states = False
343342
... class war(State.Parallel):
344343
... class frodos_quest(State.Compound):
345344
... shire = State(initial=True)
@@ -373,7 +372,6 @@ state have reached a final state:
373372
>>> from statemachine import State, StateChart
374373

375374
>>> class WarWithDone(StateChart):
376-
... validate_disconnected_states = False
377375
... class war(State.Parallel):
378376
... class quest(State.Compound):
379377
... start_q = State(initial=True)
@@ -396,12 +394,6 @@ True
396394
True
397395

398396
```
399-
400-
```{note}
401-
Parallel states commonly require `validate_disconnected_states = False` because
402-
regions may not be reachable from each other via transitions.
403-
```
404-
405397
(history-states)=
406398
## History pseudo-states
407399

@@ -415,7 +407,6 @@ Import `HistoryState` and place it inside a `State.Compound`:
415407
>>> from statemachine import HistoryState, State, StateChart
416408

417409
>>> class GollumPersonality(StateChart):
418-
... validate_disconnected_states = False
419410
... class personality(State.Compound):
420411
... smeagol = State(initial=True)
421412
... gollum = State()
@@ -454,7 +445,6 @@ state and restores the full hierarchy:
454445
>>> from statemachine import HistoryState, State, StateChart
455446

456447
>>> class DeepMemoryOfMoria(StateChart):
457-
... validate_disconnected_states = False
458448
... class moria(State.Compound):
459449
... class halls(State.Compound):
460450
... entrance = State(initial=True)
@@ -677,7 +667,6 @@ currently active. This is especially useful for cross-region guards in parallel
677667
>>> from statemachine import State, StateChart
678668

679669
>>> class CoordinatedAdvance(StateChart):
680-
... validate_disconnected_states = False
681670
... class forces(State.Parallel):
682671
... class vanguard(State.Compound):
683672
... waiting = State(initial=True)

docs/states.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,6 @@ independently. Define them using `State.Parallel`:
233233
>>> from statemachine import State, StateChart
234234

235235
>>> class WarOfTheRing(StateChart):
236-
... validate_disconnected_states = False
237236
... class war(State.Parallel):
238237
... class quest(State.Compound):
239238
... start = State(initial=True)
@@ -267,7 +266,6 @@ Re-entering via the history state restores the previously active child. Import a
267266
>>> from statemachine import HistoryState, State, StateChart
268267

269268
>>> class WithHistory(StateChart):
270-
... validate_disconnected_states = False
271269
... class mode(State.Compound):
272270
... a = State(initial=True)
273271
... b = State()

docs/transitions.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,6 @@ source and all target states.
486486
>>> from statemachine import State, StateChart
487487

488488
>>> class MiddleEarthJourney(StateChart):
489-
... validate_disconnected_states = False
490489
... class rivendell(State.Compound):
491490
... council = State(initial=True)
492491
... preparing = State()

statemachine/graph.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ def visit_connected_states(state: "State"):
1818
already_visited.add(state)
1919
yield state
2020
visit.extend(t.target for t in state.transitions if t.target)
21+
# Traverse the state hierarchy: entering a compound/parallel state
22+
# implicitly enters its initial children (all children for parallel).
23+
for child in state.states:
24+
if child.initial:
25+
visit.append(child)
26+
for child in state.history:
27+
visit.append(child)
28+
# Being in a child state implies being in all ancestor states.
29+
if state.parent:
30+
visit.append(state.parent)
2131

2232

2333
def disconnected_states(starting_state: "State", all_states: MutableSet["State"]):

statemachine/io/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ def create_machine_class_from_definition(
156156
``history``, and transitions via ``on`` (event-triggered) or
157157
``transitions`` (eventless).
158158
**definition: Additional keyword arguments passed to the metaclass
159-
(e.g., ``validate_disconnected_states=False``).
159+
(e.g., ``validate_final_reachability=False``).
160160
161161
Returns:
162162
A new StateChart subclass configured with the given states and transitions.

tests/examples/statechart_compound_machine.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,6 @@ class QuestMachine(StateChart):
2222
and ``rivendell`` (with council activities). A ``wilderness`` state connects them.
2323
"""
2424

25-
validate_disconnected_states = False
26-
2725
class shire(State.Compound):
2826
bag_end = State("Bag End", initial=True)
2927
green_dragon = State("The Green Dragon")

tests/examples/statechart_history_machine.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,6 @@ class PersonalityMachine(StateChart):
2525
pseudo-state, the previously active personality is restored.
2626
"""
2727

28-
validate_disconnected_states = False
29-
3028
class personality(State.Compound):
3129
smeagol = State("Smeagol", initial=True)
3230
gollum = State("Gollum")
@@ -89,8 +87,6 @@ class personality(State.Compound):
8987
class DeepPersonalityMachine(StateChart):
9088
"""A machine with nested compounds and deep history."""
9189

92-
validate_disconnected_states = False
93-
9490
class realm(State.Compound):
9591
class inner(State.Compound):
9692
entrance = State("Entrance", initial=True)

tests/examples/statechart_in_condition_machine.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@ class FellowshipMachine(StateChart):
2323
only follows Frodo to Mordor after Frodo has already arrived there.
2424
"""
2525

26-
validate_disconnected_states = False
27-
2826
class quest(State.Parallel):
2927
class frodo_path(State.Compound):
3028
shire_f = State("Frodo in Shire", initial=True)

0 commit comments

Comments
 (0)