Skip to content

Commit 77362a1

Browse files
committed
fix: Better support for targetless transitions
1 parent f552171 commit 77362a1

19 files changed

Lines changed: 70 additions & 400 deletions

statemachine/contrib/diagram.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,15 +153,20 @@ def _transition_as_edge(self, transition):
153153
cond = f"\n[{cond}]"
154154

155155
extra_params = {}
156-
has_substates = transition.source.states or transition.target.states
156+
has_substates = transition.source.states or (
157+
transition.target and transition.target.states
158+
)
157159
if transition.source.states:
158160
extra_params["ltail"] = f"cluster_{transition.source.id}"
159-
if transition.target.states:
161+
if transition.target and transition.target.states:
160162
extra_params["lhead"] = f"cluster_{transition.target.id}"
161163

164+
targetless = transition.target is None
162165
return pydot.Edge(
163166
self._state_id(transition.source),
164-
self._state_id(transition.target),
167+
self._state_id(transition.target)
168+
if not targetless
169+
else self._state_id(transition.source),
165170
label=f"{transition.event}{cond}",
166171
color="blue",
167172
fontname=self.font_name,

statemachine/engines/base.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ def find_lcca(states: List[State]) -> "State | None":
226226

227227
def get_effective_target_states(self, transition: Transition) -> OrderedSet[State]:
228228
# TODO: Handle history states
229-
return OrderedSet([transition.target])
229+
return OrderedSet([transition.target]) if transition.target else OrderedSet()
230230

231231
def select_eventless_transitions(self, trigger_data: TriggerData):
232232
"""
@@ -484,10 +484,10 @@ def _enter_states( # noqa: C901
484484
).put(donedata=donedata)
485485

486486
if grandparent and grandparent.parallel:
487-
if all(child.final for child in grandparent.states):
488-
BoundEvent(f"done.state.{parent.id}", _sm=self.sm, internal=True).put(
489-
donedata=donedata
490-
)
487+
if all(self.is_in_final_state(child) for child in grandparent.states):
488+
BoundEvent(
489+
f"done.state.{grandparent.id}", _sm=self.sm, internal=True
490+
).put(donedata=donedata)
491491
return result
492492

493493
def compute_entry_set(
@@ -506,6 +506,8 @@ def compute_entry_set(
506506
for transition in transitions:
507507
# Process each target state of the transition
508508
for target_state in [transition.target]:
509+
if target_state is None:
510+
continue
509511
info = StateTransition(
510512
transition=transition, target=target_state, source=transition.source
511513
)
@@ -575,7 +577,6 @@ def add_descendant_states_to_enter(
575577
else:
576578
states_to_enter.add(info)
577579
state = info.target
578-
assert state
579580

580581
if state.parallel:
581582
for child_state in state.states:
@@ -662,3 +663,11 @@ def add_ancestor_states_to_enter(
662663
states_for_default_entry,
663664
default_history_content,
664665
)
666+
667+
def is_in_final_state(self, state: State) -> bool:
668+
if state.is_compound:
669+
return any(s.final and s in self.sm.configuration for s in state.states)
670+
elif state.parallel:
671+
return all(self.is_in_final_state(s) for s in state.states)
672+
else:
673+
return False

statemachine/graph.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ def visit_connected_states(state):
1111
continue
1212
already_visited.add(state)
1313
yield state
14-
visit.extend(t.target for t in state.transitions)
14+
visit.extend(t.target for t in state.transitions if t.target)
1515

1616

1717
def iterate_states_and_transitions(states):

statemachine/io/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,8 @@ def create_machine_class_from_definition(
112112
for transition_data in transitions_data:
113113
source = states_instances[state_id]
114114

115-
target = states_instances[transition_data["target"]]
115+
target_state_id = transition_data["target"]
116+
target = states_instances[target_state_id] if target_state_id else None
116117

117118
# TODO: Join `trantion_data.event` with `event_name`
118119
transition = source.to(

statemachine/io/scxml/parser.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,16 +145,16 @@ def parse_state(
145145
child_state = parse_state(child_state_elem, initial_states=initial_states, is_final=True)
146146
state.states[child_state.id] = child_state
147147
for child_state_elem in state_elem.findall("parallel"):
148-
state = parse_state(child_state_elem, initial_states=initial_states, is_parallel=True)
148+
child_state = parse_state(
149+
child_state_elem, initial_states=initial_states, is_parallel=True
150+
)
149151
state.states[child_state.id] = child_state
150152

151153
return state
152154

153155

154156
def parse_transition(trans_elem: ET.Element, initial: bool = False) -> Transition:
155157
target = trans_elem.get("target")
156-
if not target:
157-
raise ValueError("Transition must have a 'target' attribute")
158158

159159
event = trans_elem.get("event")
160160
cond = trans_elem.get("cond")

statemachine/io/scxml/schema.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ class ScriptAction(Action):
102102

103103
@dataclass
104104
class Transition:
105-
target: str
105+
target: "str | None" = None
106106
internal: bool = False
107107
initial: bool = False
108108
event: "str | None" = None

statemachine/state.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def __call__(self, *states: "State", **kwargs):
2929

3030

3131
class _ToState(_TransitionBuilder):
32-
def __call__(self, *states: "State", **kwargs):
32+
def __call__(self, *states: "State | None", **kwargs):
3333
transitions = TransitionList(Transition(self._state, state, **kwargs) for state in states)
3434
self._state.transitions.add_transitions(transitions)
3535
return transitions

statemachine/transition.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class Transition:
4040
def __init__(
4141
self,
4242
source: "State",
43-
target: "State",
43+
target: "State | None" = None,
4444
event=None,
4545
internal=False,
4646
initial=False,
@@ -58,7 +58,7 @@ def __init__(
5858
self.is_self = target is source
5959
"""Is the target state the same as the source state?"""
6060

61-
if internal and not (self.is_self or target.is_descendant(source)):
61+
if internal and not (self.is_self or (target and target.is_descendant(source))):
6262
raise InvalidDefinition(
6363
_(
6464
"Not a valid internal transition from source {source!r}, "
@@ -89,7 +89,7 @@ def __init__(
8989

9090
def __repr__(self):
9191
return (
92-
f"{type(self).__name__}({self.source.name!r}, {self.target.name!r}, "
92+
f"{type(self).__name__}({self.source.name!r}, {self.target and self.target.name!r}, "
9393
f"event={self._events!r}, internal={self.internal!r}, initial={self.initial!r})"
9494
)
9595

tests/scxml/test_scxml_cases.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def on_transition(self, event: Event, source: State, target: State, event_data):
4545
source=f"{source and source.id}",
4646
event=f"{event and event.id}",
4747
data=f"{event_data.trigger_data.kwargs}",
48-
target=f"{target.id}",
48+
target=f"{target and target.id}",
4949
)
5050
)
5151

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

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

0 commit comments

Comments
 (0)