Skip to content

Commit a710687

Browse files
committed
feat: support multi-target transitions in Transition class
The `target` parameter now accepts `State | List[State] | None`, enabling SCXML parallel region entry where a single transition targets multiple states. Internally stores as `_targets` list with backward-compatible `target` property (returns first target) and new `targets` property (returns full list).
1 parent 388d86d commit a710687

1 file changed

Lines changed: 35 additions & 7 deletions

File tree

statemachine/transition.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from copy import deepcopy
22
from typing import TYPE_CHECKING
3+
from typing import List
34

45
from .callbacks import CallbackGroup
56
from .callbacks import CallbackPriority
@@ -17,7 +18,9 @@ class Transition:
1718
1819
Args:
1920
source (State): The origin state of the transition.
20-
target (State): The target state of the transition.
21+
target: The target state(s) of the transition. Can be a single ``State``, a list of
22+
states (for multi-target transitions, e.g. SCXML parallel region entry), or ``None``
23+
(targetless transition).
2124
event (Optional[Union[str, List[str]]]): List of designators of events that trigger this
2225
transition. Can be either a list of strings, or a space-separated string list of event
2326
descriptors.
@@ -40,7 +43,7 @@ class Transition:
4043
def __init__(
4144
self,
4245
source: "State",
43-
target: "State | None" = None,
46+
target: "State | List[State] | None" = None,
4447
event=None,
4548
internal=False,
4649
initial=False,
@@ -52,18 +55,26 @@ def __init__(
5255
after=None,
5356
):
5457
self.source = source
55-
self.target = target
58+
if isinstance(target, list):
59+
self._targets: "List[State]" = target
60+
elif target is not None:
61+
self._targets = [target]
62+
else:
63+
self._targets = []
5664
self.internal = internal
5765
self.initial = initial
58-
self.is_self = target is source
66+
first_target = self._targets[0] if self._targets else None
67+
self.is_self = first_target is source
5968
"""Is the target state the same as the source state?"""
6069

61-
if internal and not (self.is_self or (target and target.is_descendant(source))):
70+
if internal and not (
71+
self.is_self or (first_target and first_target.is_descendant(source))
72+
):
6273
raise InvalidDefinition(
6374
_(
6475
"Not a valid internal transition from source {source!r}, "
6576
"target {target!r} should be self or a descendant."
66-
).format(source=source, target=target)
77+
).format(source=source, target=first_target)
6778
)
6879

6980
if initial and any([cond, unless, event]):
@@ -87,6 +98,23 @@ def __init__(
8798
.add(unless, priority=CallbackPriority.INLINE, expected_value=False)
8899
)
89100

101+
@property
102+
def target(self) -> "State | None":
103+
"""Primary target state (first target for multi-target transitions)."""
104+
return self._targets[0] if self._targets else None
105+
106+
@target.setter
107+
def target(self, value: "State | None"):
108+
if value is None:
109+
self._targets = []
110+
else:
111+
self._targets = [value]
112+
113+
@property
114+
def targets(self) -> "List[State]":
115+
"""All target states. For single-target transitions, returns a one-element list."""
116+
return self._targets
117+
90118
def __repr__(self):
91119
return (
92120
f"{type(self).__name__}({self.source.name!r}, {self.target and self.target.name!r}, "
@@ -147,7 +175,7 @@ def add_event(self, value):
147175

148176
def _copy_with_args(self, **kwargs):
149177
source = kwargs.pop("source", self.source)
150-
target = kwargs.pop("target", self.target)
178+
target = kwargs.pop("target", list(self._targets) if self._targets else None)
151179
event = kwargs.pop("event", self.event)
152180
internal = kwargs.pop("internal", self.internal)
153181
new_transition = Transition(

0 commit comments

Comments
 (0)