Skip to content

Commit 6cb83f5

Browse files
committed
fix: Initials of scxml is respected; Added validate_disconnected_states parameter since scxml don't expect that the graph should have a single component
1 parent 29c22fb commit 6cb83f5

File tree

9 files changed

+53
-123
lines changed

9 files changed

+53
-123
lines changed

docs/releases/3.0.0.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,13 @@ TODO: Example of delayed events
100100
Also, delayed events can be revoked by it's `send_id`.
101101

102102

103+
### Disable single graph component validation.
104+
105+
Since SCXML don't require that all states should be reachable by transitions, we added a class-level
106+
flag `validate_disconnected_states: bool = True` that can be used to disable this validation.
107+
108+
It's already disabled when parsing SCXML files.
109+
103110

104111
## Bugfixes in 3.0.0
105112

statemachine/factory.py

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@
1111
from .callbacks import CallbackSpecList
1212
from .event import Event
1313
from .exceptions import InvalidDefinition
14+
from .graph import disconnected_states
1415
from .graph import iterate_states
1516
from .graph import iterate_states_and_transitions
16-
from .graph import visit_connected_states
17+
from .graph import states_without_path_to_final_states
1718
from .i18n import _
1819
from .state import State
1920
from .states import States
@@ -24,6 +25,9 @@
2425
class StateMachineMetaclass(type):
2526
"Metaclass for constructing StateMachine classes"
2627

28+
validate_disconnected_states: bool = True
29+
"""If `True`, the state machine will validate that there are no unreachable states."""
30+
2731
def __init__(
2832
cls,
2933
name: str,
@@ -179,7 +183,7 @@ def _check_trap_states(cls):
179183
def _check_reachable_final_states(cls):
180184
if not any(s.final for s in cls.states):
181185
return # No need to check final reachability
182-
disconnected_states = cls._states_without_path_to_final_states()
186+
disconnected_states = list(states_without_path_to_final_states(cls.states))
183187
if disconnected_states:
184188
message = _(
185189
"All non-final states should have at least one path to a final state. "
@@ -190,26 +194,18 @@ def _check_reachable_final_states(cls):
190194
else:
191195
warnings.warn(message, UserWarning, stacklevel=1)
192196

193-
def _states_without_path_to_final_states(cls):
194-
return [
195-
state
196-
for state in cls.states
197-
if not state.final and not any(s.final for s in visit_connected_states(state))
198-
]
199-
200-
def _disconnected_states(cls, starting_state):
201-
visitable_states = set(visit_connected_states(starting_state))
202-
return set(cls.states) - visitable_states
203-
204197
def _check_disconnected_state(cls):
205-
disconnected_states = cls._disconnected_states(cls.initial_state)
206-
if disconnected_states:
198+
if not cls.validate_disconnected_states:
199+
return
200+
assert cls.initial_state
201+
states = disconnected_states(cls.initial_state, set(cls.states_map.values()))
202+
if states:
207203
raise InvalidDefinition(
208204
_(
209205
"There are unreachable states. "
210206
"The statemachine graph should have a single component. "
211207
"Disconnected states: {}"
212-
).format([s.id for s in disconnected_states])
208+
).format([s.id for s in states])
213209
)
214210

215211
def _setup(cls):

statemachine/graph.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
from collections import deque
2+
from typing import TYPE_CHECKING
3+
from typing import Iterable
4+
from typing import MutableSet
25

6+
if TYPE_CHECKING:
7+
from .state import State
38

4-
def visit_connected_states(state):
5-
visit = deque()
9+
10+
def visit_connected_states(state: "State"):
11+
visit = deque["State"]()
612
already_visited = set()
713
visit.append(state)
814
while visit:
@@ -14,16 +20,29 @@ def visit_connected_states(state):
1420
visit.extend(t.target for t in state.transitions if t.target)
1521

1622

17-
def iterate_states_and_transitions(states):
23+
def disconnected_states(starting_state: "State", all_states: MutableSet["State"]):
24+
visitable_states = set(visit_connected_states(starting_state))
25+
return all_states - visitable_states
26+
27+
28+
def iterate_states_and_transitions(states: Iterable["State"]):
1829
for state in states:
1930
yield state
2031
yield from state.transitions
2132
if state.states:
2233
yield from iterate_states_and_transitions(state.states)
2334

2435

25-
def iterate_states(states):
36+
def iterate_states(states: Iterable["State"]):
2637
for state in states:
2738
yield state
2839
if state.states:
2940
yield from iterate_states(state.states)
41+
42+
43+
def states_without_path_to_final_states(states: Iterable["State"]):
44+
return (
45+
state
46+
for state in states
47+
if not state.final and not any(s.final for s in visit_connected_states(state))
48+
)

statemachine/io/scxml/parser.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,9 @@ def parse_scxml(scxml_content: str) -> StateMachineDefinition: # noqa: C901
9292
for state, parents in visit_states(definition.states.values(), []):
9393
if state.id in definition.initial_states:
9494
not_found.remove(state.id)
95-
if parents:
96-
topmost_state = parents[0]
97-
topmost_state.initial = True
98-
definition.initial_states.add(topmost_state.id)
95+
for parent in parents:
96+
parent.initial = True
97+
definition.initial_states.add(parent.id)
9998
if not not_found:
10099
break
101100

statemachine/io/scxml/processor.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,14 @@ def process_definition(self, definition, location: str):
8888
initial_state["enter"] = []
8989
if isinstance(initial_state["enter"], list):
9090
initial_state["enter"].insert(0, datamodel)
91-
self._add(location, {"states": states_dict, "prepare_event": self._prepare_event})
91+
self._add(
92+
location,
93+
{
94+
"states": states_dict,
95+
"prepare_event": self._prepare_event,
96+
"validate_disconnected_states": False,
97+
},
98+
)
9299

93100
def _prepare_event(self, *args, **kwargs):
94101
machine = kwargs["machine"]

tests/scxml/w3c/mandatory/test355.scxml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ we enter s0 first we succeed, if s1, failure. -->
66

77
<state id="s0">
88
<transition target="pass" />
9-
<transition target="s1" cond="1 &lt; 0" /><!-- fgm: added to have a single component in graph -->
109
</state>
1110

1211
<state id="s1">

tests/scxml/w3c/mandatory/test413.scxml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ states we should not enter all have immediate transitions to failure in them -->
1616
we're in
1717
either s2p112 or s2p122, but not both of them -->
1818
<transition target="fail" />
19-
<transition target="s1" cond="1 &lt; 0" /><!-- fgm: added to have a single component in graph -->
2019

2120
<state id="s2p11" initial="s2p111">
2221
<state id="s2p111">

tests/scxml/w3c/mandatory/test576.fail.md

Lines changed: 0 additions & 59 deletions
This file was deleted.

tests/scxml/w3c/optional/test448.fail.md

Lines changed: 0 additions & 37 deletions
This file was deleted.

0 commit comments

Comments
 (0)