From 4ca80458e640b4331984438e8096d74044fa24a1 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Thu, 19 Feb 2026 10:30:06 -0300 Subject: [PATCH] fix: make disconnected states validation hierarchy-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 2 - docs/releases/3.0.0.md | 11 --- docs/statecharts.md | 11 --- docs/states.md | 2 - docs/transitions.md | 1 - statemachine/graph.py | 10 +++ statemachine/io/__init__.py | 2 +- tests/examples/statechart_compound_machine.py | 2 - tests/examples/statechart_history_machine.py | 4 -- .../statechart_in_condition_machine.py | 2 - tests/examples/statechart_parallel_machine.py | 2 - tests/test_contrib_diagram.py | 8 --- tests/test_statechart_compound.py | 6 -- tests/test_statechart_error.py | 2 - tests/test_statechart_eventless.py | 2 - tests/test_statechart_history.py | 16 ----- tests/test_statechart_in_condition.py | 8 --- tests/test_statechart_parallel.py | 8 --- tests/test_statemachine.py | 71 +++++++++++++++++++ 19 files changed, 82 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index cc09112e..fa09d5cb 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,6 @@ regions reach a final state: >>> from statemachine import StateChart, State >>> class DeployPipeline(StateChart): -... validate_disconnected_states = False ... class deploy(State.Parallel): ... class build(State.Compound): ... compiling = State(initial=True) @@ -219,7 +218,6 @@ of starting from the initial one: >>> from statemachine import HistoryState, StateChart, State >>> class EditorWithHistory(StateChart): -... validate_disconnected_states = False ... class editor(State.Compound): ... source = State(initial=True) ... visual = State() diff --git a/docs/releases/3.0.0.md b/docs/releases/3.0.0.md index 33ac372d..89973cd7 100644 --- a/docs/releases/3.0.0.md +++ b/docs/releases/3.0.0.md @@ -127,7 +127,6 @@ the parent and all descendants. See {ref}`statecharts` for full details. >>> from statemachine import State, StateChart >>> class WarOfTheRing(StateChart): -... validate_disconnected_states = False ... class war(State.Parallel): ... class frodos_quest(State.Compound): ... shire = State(initial=True) @@ -160,7 +159,6 @@ Supports both shallow (`HistoryState()`) and deep (`HistoryState(deep=True)`) hi >>> from statemachine import HistoryState, State, StateChart >>> class GollumPersonality(StateChart): -... validate_disconnected_states = False ... class personality(State.Compound): ... smeagol = State(initial=True) ... gollum = State() @@ -402,15 +400,6 @@ if sm.is_terminated: print("State machine has finished.") ``` - -### Disable single graph component validation - -Since SCXML don't require that all states should be reachable by transitions, we added a class-level -flag `validate_disconnected_states: bool = True` that can be used to disable this validation. - -It's already disabled when parsing SCXML files. - - ### Typed models with `Generic[TModel]` `StateChart` now supports a generic type parameter for the model, enabling full type diff --git a/docs/statecharts.md b/docs/statecharts.md index 282459f6..b95d5420 100644 --- a/docs/statecharts.md +++ b/docs/statecharts.md @@ -339,7 +339,6 @@ independently — events in one region don't affect others. Use `State.Parallel` >>> from statemachine import State, StateChart >>> class WarOfTheRing(StateChart): -... validate_disconnected_states = False ... class war(State.Parallel): ... class frodos_quest(State.Compound): ... shire = State(initial=True) @@ -373,7 +372,6 @@ state have reached a final state: >>> from statemachine import State, StateChart >>> class WarWithDone(StateChart): -... validate_disconnected_states = False ... class war(State.Parallel): ... class quest(State.Compound): ... start_q = State(initial=True) @@ -396,12 +394,6 @@ True True ``` - -```{note} -Parallel states commonly require `validate_disconnected_states = False` because -regions may not be reachable from each other via transitions. -``` - (history-states)= ## History pseudo-states @@ -415,7 +407,6 @@ Import `HistoryState` and place it inside a `State.Compound`: >>> from statemachine import HistoryState, State, StateChart >>> class GollumPersonality(StateChart): -... validate_disconnected_states = False ... class personality(State.Compound): ... smeagol = State(initial=True) ... gollum = State() @@ -454,7 +445,6 @@ state and restores the full hierarchy: >>> from statemachine import HistoryState, State, StateChart >>> class DeepMemoryOfMoria(StateChart): -... validate_disconnected_states = False ... class moria(State.Compound): ... class halls(State.Compound): ... entrance = State(initial=True) @@ -677,7 +667,6 @@ currently active. This is especially useful for cross-region guards in parallel >>> from statemachine import State, StateChart >>> class CoordinatedAdvance(StateChart): -... validate_disconnected_states = False ... class forces(State.Parallel): ... class vanguard(State.Compound): ... waiting = State(initial=True) diff --git a/docs/states.md b/docs/states.md index 35c9fda4..bcc77a78 100644 --- a/docs/states.md +++ b/docs/states.md @@ -233,7 +233,6 @@ independently. Define them using `State.Parallel`: >>> from statemachine import State, StateChart >>> class WarOfTheRing(StateChart): -... validate_disconnected_states = False ... class war(State.Parallel): ... class quest(State.Compound): ... start = State(initial=True) @@ -267,7 +266,6 @@ Re-entering via the history state restores the previously active child. Import a >>> from statemachine import HistoryState, State, StateChart >>> class WithHistory(StateChart): -... validate_disconnected_states = False ... class mode(State.Compound): ... a = State(initial=True) ... b = State() diff --git a/docs/transitions.md b/docs/transitions.md index 13ca640c..98c7e2ef 100644 --- a/docs/transitions.md +++ b/docs/transitions.md @@ -486,7 +486,6 @@ source and all target states. >>> from statemachine import State, StateChart >>> class MiddleEarthJourney(StateChart): -... validate_disconnected_states = False ... class rivendell(State.Compound): ... council = State(initial=True) ... preparing = State() diff --git a/statemachine/graph.py b/statemachine/graph.py index 14145fb3..b6f7315e 100644 --- a/statemachine/graph.py +++ b/statemachine/graph.py @@ -18,6 +18,16 @@ def visit_connected_states(state: "State"): already_visited.add(state) yield state visit.extend(t.target for t in state.transitions if t.target) + # Traverse the state hierarchy: entering a compound/parallel state + # implicitly enters its initial children (all children for parallel). + for child in state.states: + if child.initial: + visit.append(child) + for child in state.history: + visit.append(child) + # Being in a child state implies being in all ancestor states. + if state.parent: + visit.append(state.parent) def disconnected_states(starting_state: "State", all_states: MutableSet["State"]): diff --git a/statemachine/io/__init__.py b/statemachine/io/__init__.py index f8d7a302..5105842f 100644 --- a/statemachine/io/__init__.py +++ b/statemachine/io/__init__.py @@ -156,7 +156,7 @@ def create_machine_class_from_definition( ``history``, and transitions via ``on`` (event-triggered) or ``transitions`` (eventless). **definition: Additional keyword arguments passed to the metaclass - (e.g., ``validate_disconnected_states=False``). + (e.g., ``validate_final_reachability=False``). Returns: A new StateChart subclass configured with the given states and transitions. diff --git a/tests/examples/statechart_compound_machine.py b/tests/examples/statechart_compound_machine.py index 98562ff9..613172f3 100644 --- a/tests/examples/statechart_compound_machine.py +++ b/tests/examples/statechart_compound_machine.py @@ -22,8 +22,6 @@ class QuestMachine(StateChart): and ``rivendell`` (with council activities). A ``wilderness`` state connects them. """ - validate_disconnected_states = False - class shire(State.Compound): bag_end = State("Bag End", initial=True) green_dragon = State("The Green Dragon") diff --git a/tests/examples/statechart_history_machine.py b/tests/examples/statechart_history_machine.py index c9baf95e..d8a4ac6e 100644 --- a/tests/examples/statechart_history_machine.py +++ b/tests/examples/statechart_history_machine.py @@ -25,8 +25,6 @@ class PersonalityMachine(StateChart): pseudo-state, the previously active personality is restored. """ - validate_disconnected_states = False - class personality(State.Compound): smeagol = State("Smeagol", initial=True) gollum = State("Gollum") @@ -89,8 +87,6 @@ class personality(State.Compound): class DeepPersonalityMachine(StateChart): """A machine with nested compounds and deep history.""" - validate_disconnected_states = False - class realm(State.Compound): class inner(State.Compound): entrance = State("Entrance", initial=True) diff --git a/tests/examples/statechart_in_condition_machine.py b/tests/examples/statechart_in_condition_machine.py index 2e54bf90..3599a400 100644 --- a/tests/examples/statechart_in_condition_machine.py +++ b/tests/examples/statechart_in_condition_machine.py @@ -23,8 +23,6 @@ class FellowshipMachine(StateChart): only follows Frodo to Mordor after Frodo has already arrived there. """ - validate_disconnected_states = False - class quest(State.Parallel): class frodo_path(State.Compound): shire_f = State("Frodo in Shire", initial=True) diff --git a/tests/examples/statechart_parallel_machine.py b/tests/examples/statechart_parallel_machine.py index d2b96f42..d5ba271e 100644 --- a/tests/examples/statechart_parallel_machine.py +++ b/tests/examples/statechart_parallel_machine.py @@ -22,8 +22,6 @@ class WarMachine(StateChart): Gandalf's defense of the realms. """ - validate_disconnected_states = False - class war(State.Parallel): class frodos_quest(State.Compound): shire = State("The Shire", initial=True) diff --git a/tests/test_contrib_diagram.py b/tests/test_contrib_diagram.py index 9e868709..9b87cf1d 100644 --- a/tests/test_contrib_diagram.py +++ b/tests/test_contrib_diagram.py @@ -139,8 +139,6 @@ def test_parallel_state_diagram(): """Diagram renders parallel state with dashed style.""" class SM(StateChart): - validate_disconnected_states: bool = False - class p(State.Parallel, name="p"): class r1(State.Compound, name="r1"): a = State(initial=True) @@ -167,8 +165,6 @@ def test_nested_compound_state_diagram(): """Diagram renders nested compound states.""" class SM(StateChart): - validate_disconnected_states: bool = False - class outer(State.Compound, name="Outer"): class inner(State.Compound, name="Inner"): deep = State(initial=True) @@ -296,8 +292,6 @@ def test_parallel_state_label_indicator(): """Parallel subgraph label includes a visual indicator.""" class SM(StateChart): - validate_disconnected_states: bool = False - class p(State.Parallel, name="p"): class r1(State.Compound, name="r1"): a = State(initial=True) @@ -353,8 +347,6 @@ def test_compound_and_parallel_mixed(): """Full diagram with compound and parallel states renders without error.""" class SM(StateChart): - validate_disconnected_states: bool = False - class top(State.Compound, name="Top"): class par(State.Parallel, name="Par"): class region1(State.Compound, name="Region1"): diff --git a/tests/test_statechart_compound.py b/tests/test_statechart_compound.py index c36d8193..3908d8d4 100644 --- a/tests/test_statechart_compound.py +++ b/tests/test_statechart_compound.py @@ -111,8 +111,6 @@ async def test_cross_compound_transition(self, sm_runner): """Transition from one compound to another removes old children.""" class MiddleEarthJourney(StateChart): - validate_disconnected_states = False - class rivendell(State.Compound): council = State(initial=True) preparing = State() @@ -148,8 +146,6 @@ async def test_enter_compound_lands_on_initial(self, sm_runner): """Entering a compound from outside lands on the initial child.""" class MiddleEarthJourney(StateChart): - validate_disconnected_states = False - class rivendell(State.Compound): council = State(initial=True) preparing = State() @@ -192,8 +188,6 @@ async def test_multiple_compound_sequential_traversal(self, sm_runner): """Traverse all three compounds sequentially.""" class MiddleEarthJourney(StateChart): - validate_disconnected_states = False - class rivendell(State.Compound): council = State(initial=True) preparing = State(final=True) diff --git a/tests/test_statechart_error.py b/tests/test_statechart_error.py index 18eea66a..ea96755f 100644 --- a/tests/test_statechart_error.py +++ b/tests/test_statechart_error.py @@ -38,8 +38,6 @@ async def test_error_in_parallel_region_isolation(self, sm_runner): """Error in one parallel region; error.execution handles the exit.""" class ParallelError(StateChart): - validate_disconnected_states = False - class fronts(State.Parallel): class battle_a(State.Compound): fighting = State(initial=True) diff --git a/tests/test_statechart_eventless.py b/tests/test_statechart_eventless.py index 757f8789..4e69eb68 100644 --- a/tests/test_statechart_eventless.py +++ b/tests/test_statechart_eventless.py @@ -134,8 +134,6 @@ async def test_eventless_with_in_condition(self, sm_runner): """Eventless transition guarded by In('state_id').""" class CoordinatedAdvance(StateChart): - validate_disconnected_states = False - class forces(State.Parallel): class vanguard(State.Compound): waiting = State(initial=True) diff --git a/tests/test_statechart_history.py b/tests/test_statechart_history.py index 774273ce..520590ca 100644 --- a/tests/test_statechart_history.py +++ b/tests/test_statechart_history.py @@ -20,8 +20,6 @@ async def test_shallow_history_remembers_last_child(self, sm_runner): """Exit compound, re-enter via history -> restores last active child.""" class GollumPersonality(StateChart): - validate_disconnected_states = False - class personality(State.Compound): smeagol = State(initial=True) gollum = State() @@ -49,8 +47,6 @@ async def test_shallow_history_default_on_first_visit(self, sm_runner): """No prior visit -> history uses default transition target.""" class GollumPersonality(StateChart): - validate_disconnected_states = False - class personality(State.Compound): smeagol = State(initial=True) gollum = State() @@ -73,8 +69,6 @@ async def test_deep_history_remembers_full_descendant(self, sm_runner): """Deep history restores the exact leaf in a nested compound.""" class DeepMemoryOfMoria(StateChart): - validate_disconnected_states = False - class moria(State.Compound): class halls(State.Compound): entrance = State(initial=True) @@ -107,8 +101,6 @@ async def test_multiple_exits_and_reentries(self, sm_runner): """History updates each time we exit the compound.""" class GollumPersonality(StateChart): - validate_disconnected_states = False - class personality(State.Compound): smeagol = State(initial=True) gollum = State() @@ -140,8 +132,6 @@ async def test_history_after_state_change(self, sm_runner): """Change state within compound, exit, re-enter -> new state restored.""" class GollumPersonality(StateChart): - validate_disconnected_states = False - class personality(State.Compound): smeagol = State(initial=True) gollum = State() @@ -163,8 +153,6 @@ async def test_shallow_only_remembers_immediate_child(self, sm_runner): """Shallow history in nested compound restores direct child, not grandchild.""" class ShallowMoria(StateChart): - validate_disconnected_states = False - class moria(State.Compound): class halls(State.Compound): entrance = State(initial=True) @@ -196,8 +184,6 @@ async def test_history_values_dict_populated(self, sm_runner): """sm.history_values[history_id] has saved states after exit.""" class GollumPersonality(StateChart): - validate_disconnected_states = False - class personality(State.Compound): smeagol = State(initial=True) gollum = State() @@ -221,8 +207,6 @@ async def test_history_with_default_transition(self, sm_runner): """HistoryState with explicit default .to() transition.""" class GollumPersonality(StateChart): - validate_disconnected_states = False - class personality(State.Compound): smeagol = State(initial=True) gollum = State() diff --git a/tests/test_statechart_in_condition.py b/tests/test_statechart_in_condition.py index a1ad380b..593ea6c4 100644 --- a/tests/test_statechart_in_condition.py +++ b/tests/test_statechart_in_condition.py @@ -19,8 +19,6 @@ async def test_in_condition_true_enables_transition(self, sm_runner): """In('state_id') when state is active -> transition fires.""" class Fellowship(StateChart): - validate_disconnected_states = False - class positions(State.Parallel): class frodo(State.Compound): shire_f = State(initial=True) @@ -62,8 +60,6 @@ async def test_in_with_parallel_regions(self, sm_runner): """Cross-region In() evaluation in parallel states.""" class FellowshipCoordination(StateChart): - validate_disconnected_states = False - class mission(State.Parallel): class scouts(State.Compound): scouting = State(initial=True) @@ -117,8 +113,6 @@ async def test_in_combined_with_event(self, sm_runner): """Event + In() guard together.""" class CombinedGuard(StateChart): - validate_disconnected_states = False - class positions(State.Parallel): class scout(State.Compound): out = State(initial=True) @@ -145,8 +139,6 @@ async def test_in_with_eventless_transition(self, sm_runner): """Eventless + In() guard.""" class EventlessIn(StateChart): - validate_disconnected_states = False - class coordination(State.Parallel): class leader(State.Compound): planning = State(initial=True) diff --git a/tests/test_statechart_parallel.py b/tests/test_statechart_parallel.py index 4eea4b63..835451dc 100644 --- a/tests/test_statechart_parallel.py +++ b/tests/test_statechart_parallel.py @@ -18,8 +18,6 @@ class TestParallelStates: @pytest.fixture() def war_of_the_ring_cls(self): class WarOfTheRing(StateChart): - validate_disconnected_states = False - class war(State.Parallel): class frodos_quest(State.Compound): shire = State(initial=True) @@ -82,8 +80,6 @@ async def test_exit_parallel_exits_all_regions(self, sm_runner): """Transition out of a parallel clears everything.""" class WarWithExit(StateChart): - validate_disconnected_states = False - class war(State.Parallel): class front_a(State.Compound): fighting = State(initial=True, final=True) @@ -134,8 +130,6 @@ async def test_parallel_done_when_all_regions_final(self, sm_runner): """done.state fires when ALL regions reach a final state.""" class TwoTowers(StateChart): - validate_disconnected_states = False - class battle(State.Parallel): class helms_deep(State.Compound): fighting = State(initial=True) @@ -165,8 +159,6 @@ async def test_parallel_not_done_when_one_region_final(self, sm_runner): """Parallel not done when only one region reaches final.""" class TwoTowers(StateChart): - validate_disconnected_states = False - class battle(State.Parallel): class helms_deep(State.Compound): fighting = State(initial=True) diff --git a/tests/test_statemachine.py b/tests/test_statemachine.py index 8973d0d7..5bc7f85c 100644 --- a/tests/test_statemachine.py +++ b/tests/test_statemachine.py @@ -1,6 +1,7 @@ import pytest from statemachine.orderedset import OrderedSet +from statemachine import HistoryState from statemachine import State from statemachine import StateChart from statemachine import exceptions @@ -373,6 +374,76 @@ class BrokenTrafficLightMachine(StateChart): validate = yellow.to(green) +def test_disconnected_validation_bypassed_by_flag(): + """Setting validate_disconnected_states=False allows unreachable states.""" + + class DisconnectedButAllowed(StateChart): + validate_disconnected_states = False + green = State(initial=True) + yellow = State() + blue = State() # unreachable, but flag disables the check + + cycle = green.to(yellow) | yellow.to(green) + blink = blue.to.itself() + + assert "green" in DisconnectedButAllowed.states_map + + +def test_parallel_states_reachable_without_disabling_flag(): + """Substates of parallel regions are reachable via hierarchy.""" + + class ParallelMachine(StateChart): + class top(State.Parallel): + class region1(State.Compound): + a = State(initial=True) + b = State(final=True) + go = a.to(b) + + class region2(State.Compound): + c = State(initial=True) + d = State(final=True) + go2 = c.to(d) + + assert "a" in ParallelMachine.states_map + assert "c" in ParallelMachine.states_map + + +def test_compound_substates_reachable_without_disabling_flag(): + """Substates of a compound state are reachable via hierarchy.""" + + class CompoundMachine(StateChart): + start = State(initial=True) + + class parent(State.Compound): + child1 = State(initial=True) + child2 = State(final=True) + inner = child1.to(child2) + + enter = start.to(parent) + + assert "child1" in CompoundMachine.states_map + assert "child2" in CompoundMachine.states_map + + +def test_history_state_reachable_without_disabling_flag(): + """History states and their parent compound are reachable via hierarchy.""" + + class HistoryMachine(StateChart): + outside = State(initial=True) + + class compound(State.Compound): + a = State(initial=True) + b = State() + h = HistoryState() + go = a.to(b) + + enter_via_history = outside.to(compound.h) + leave = compound.to(outside) + + assert "compound" in HistoryMachine.states_map + assert "a" in HistoryMachine.states_map + + def test_state_value_is_correct(): STATE_NEW = 0 STATE_DRAFT = 1