Skip to content

Commit e2b6907

Browse files
committed
fix: Fix parallel enter/exit and checks
1 parent 1c1018d commit e2b6907

30 files changed

+400
-488
lines changed

docs/releases/3.0.0.md

+81-11
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,21 @@
44

55
## What's new in 3.0.0
66

7-
Statecharts are there! Now the library has support for Compound and Parallel states.
7+
Statecharts are there! 🎉
88

9-
### Python compatibility in 3.0.0
9+
Statecharts are a powerful extension to state machines, in a way to organize complex reactive systems as a hierarchical state machine. They extend the concept of state machines by adding two new kinds of states: **parallel states** and **compound states**.
1010

11-
StateMachine 3.0.0 supports Python 3.9, 3.10, 3.11, 3.12, and 3.13.
11+
**Parallel states** are states that can be active at the same time. They are useful for separating the state machine in multiple orthogonal state machines that can be active at the same time.
12+
13+
**Compound states** are states that have inner states. They are useful for breaking down complex state machines into multiple simpler ones.
14+
15+
The support for statecharts in this release follows the [SCXML specification](https://www.w3.org/TR/scxml/)*, which is a W3C standard for statecharts notation. Adhering as much as possible to this specification ensures compatibility with other tools and platforms that also implement SCXML, but more important,
16+
sets a standard on the expected behaviour that the library should assume on various edge cases, enabling easier integration and interoperability in complex systems.
17+
18+
To verify the standard adoption, now the automated tests suite includes several `.scxml` testcases provided by the W3C group. Many thanks for this amazing work! Some of the tests are still failing, and some of the tags are still not implemented like `<invoke>` , in such cases, we've added an `xfail` mark by including a `test<number>.scxml.md` markdown file with details of the execution output.
19+
20+
While these are exiting news for the library and our community, it also introduces several backwards incompatible changes. Due to the major version release, the new behaviour is assumed by default, but we put
21+
a lot of effort to minimize the changes needed in your codebase, and also introduced a few configuration options that you can enable to restore the old behaviour when possible. The following sections navigate to the new features and includes a migration guide.
1222

1323

1424
### Create state machine class from a dict definition
@@ -73,9 +83,9 @@ A not so usefull example:
7383

7484
### Event matching following SCXML spec
7585

76-
Now events matching follows the SCXML spec.
86+
Now events matching follows the [SCXML spec](https://www.w3.org/TR/scxml/#events):
7787

78-
For example, a transition with an `event` attribute of `"error foo"` will match event names `error`, `error.send`, `error.send.failed`, etc. (or `foo`, `foo.bar` etc.)
88+
> For example, a transition with an `event` attribute of `"error foo"` will match event names `error`, `error.send`, `error.send.failed`, etc. (or `foo`, `foo.bar` etc.)
7989
but would not match events named `errors.my.custom`, `errorhandler.mistake`, `error.send` or `foobar`.
8090

8191
An event designator consisting solely of `*` can be used as a wildcard matching any sequence of tokens, and thus any event.
@@ -90,6 +100,7 @@ TODO: Example of delayed events
90100
Also, delayed events can be revoked by it's `send_id`.
91101

92102

103+
93104
## Bugfixes in 3.0.0
94105

95106
- Fixes [#XXX](https://github.com/fgmacedo/python-statemachine/issues/XXX).
@@ -100,22 +111,27 @@ TODO.
100111

101112
## Backward incompatible changes in 3.0
102113

103-
- Dropped support for Python `3.7` and `3.8`. If you need support for these versios use the 2.* series.
114+
115+
### Python compatibility in 3.0.0
116+
117+
We've dropped support for Python `3.7` and `3.8`. If you need support for these versios use the 2.* series.
118+
119+
StateMachine 3.0.0 supports Python 3.9, 3.10, 3.11, 3.12, and 3.13.
104120

105121

106-
## Non-RTC model removed
122+
### Non-RTC model removed
107123

108124
This option was deprecated on version 2.3.2. Now all new events are put on a queue before being processed.
109125

110126

111-
## Multiple current states
127+
### Multiple current states
112128

113129
Due to the support of compound and parallel states, it's now possible to have multiple active states at the same time.
114130

115-
This introduces an impedance mismatch into the old public API, specifically, `sm.current_state` is deprecated and `sm.current_state_value` can returns a flat value if no compound state or a `list` instead.
131+
This introduces an impedance mismatch into the old public API, specifically, `sm.current_state` is deprecated and `sm.current_state_value` can returns a flat value if no compound state or a `set` instead.
116132

117133
```{note}
118-
To allow a smooth migration, these properties still work as before if there's no compound states in the state machine definition.
134+
To allow a smooth migration, these properties still work as before if there's no compound/parallel states in the state machine definition.
119135
```
120136

121137
Old
@@ -130,9 +146,63 @@ New
130146
def current_state(self) -> "State | MutableSet[State]":
131147
```
132148

133-
We recomend using the new `sm.configuration` that has a stable API returning an `OrderedSet` on all cases:
149+
We **strongly** recomend using the new `sm.configuration` that has a stable API returning an `OrderedSet` on all cases:
134150

135151
```py
136152
@property
137153
def configuration(self) -> OrderedSet["State"]:
138154
```
155+
156+
### Entering and exiting states
157+
158+
Previous versions performed an atomic update of the active state just after the execution of the transition `on` actions.
159+
160+
Now, we follow the [SCXML spec](https://www.w3.org/TR/scxml/#SelectingTransitions):
161+
162+
> To execute a microstep, the SCXML Processor MUST execute the transitions in the corresponding optimal enabled transition set. To execute a set of transitions, the SCXML Processor MUST first exit all the states in the transitions' exit set in exit order. It MUST then execute the executable content contained in the transitions in document order. It MUST then enter the states in the transitions' entry set in entry order.
163+
164+
This introduces backward-incompatible changes, as previously, the `current_state` was never empty, allowing queries on `sm.current_state` or `sm.<any_state>.is_active` even while executing an `on` transition action.
165+
166+
Now, by default, during a transition, all states in the exit set are exited first, performing the `before` and `exit` callbacks. The `on` callbacks are then executed in an intermediate state that contains only the states that will not be exited, which can be an empty set. Following this, the states in the enter set are entered, with `enter` callbacks executed for each state in document order, and finally, the `after` callbacks are executed with the state machine in the final new configuration.
167+
168+
We have added two new keyword arguments available only in the `on` callbacks to assist with queries that were performed against `sm.current_state` or active states using `<state>.is_active`:
169+
170+
- `previous_configuration: OrderedSet[State]`: Contains the set of states that were active before the microstep was taken.
171+
- `new_configuration: OrderedSet[State]`: Contains the set of states that will be active after the microstep finishes.
172+
173+
Additionally, you can create a state machine instance by passing `atomic_configuration_update=True` (default `False`) to restore the old behavior. When set to `False`, the `sm.configuration` will be updated only once per microstep, just after the `on` callbacks with the `new_configuration`, the set of states that should be active after the microstep.
174+
175+
176+
Consider this example that needs to be upgraded:
177+
178+
```py
179+
class ApprovalMachine(StateMachine):
180+
"A workflow"
181+
182+
requested = State(initial=True)
183+
accepted = State()
184+
rejected = State()
185+
completed = State(final=True)
186+
187+
validate = (
188+
requested.to(accepted, cond="is_ok") | requested.to(rejected) | accepted.to(completed)
189+
)
190+
retry = rejected.to(requested)
191+
192+
def on_validate(self):
193+
if self.accepted.is_active and self.model.is_ok():
194+
return "congrats!"
195+
196+
```
197+
The `validate` event is bound to several transitions, and the `on_validate` is expected to return `congrats` only when the state machine was with the `accepted` state active before the event occurs. In the old behavior, checking for `accepted.is_active` evaluates to `True` because the state were not exited before the `on` callback.
198+
199+
Due to the new behaviour, at the time of the `on_validate` call, the state machine configuration (a.k.a the current set of active states) is empty. So at this point in time `accepted.is_active` evaluates to `False`. To mitigate this case, now you can request one of the two new keyword arguments: `previous_configuration` and `new_configration` in `on` callbacks.
200+
201+
New way using `previous_configuration`:
202+
203+
```py
204+
def on_validate(self, previous_configuration):
205+
if self.accepted in previous_configuration and self.model.is_ok():
206+
return "congrats!"
207+
208+
```

docs/transitions.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ Syntax:
8484
>>> draft = State("Draft")
8585

8686
>>> draft.to.itself()
87-
TransitionList([Transition('Draft', 'Draft', event=[], internal=False)])
87+
TransitionList([Transition('Draft', 'Draft', event=[], internal=False, initial=False)])
8888

8989
```
9090

@@ -101,7 +101,7 @@ Syntax:
101101
>>> draft = State("Draft")
102102

103103
>>> draft.to.itself(internal=True)
104-
TransitionList([Transition('Draft', 'Draft', event=[], internal=True)])
104+
TransitionList([Transition('Draft', 'Draft', event=[], internal=True, initial=False)])
105105

106106
```
107107

statemachine/contrib/diagram.py

+1
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ def get_graph(self):
176176
return graph
177177

178178
def _graph_states(self, state, graph, is_root=False):
179+
# TODO: handle parallel states in diagram
179180
initial_node = self._initial_node(state)
180181
initial_subgraph = pydot.Subgraph(
181182
graph_name=f"{initial_node.get_name()}_initial",

statemachine/engines/base.py

+76-29
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def __init__(self, sm: "StateMachine"):
7979
def empty(self):
8080
return self.external_queue.is_empty()
8181

82-
def put(self, trigger_data: TriggerData, internal: bool = False):
82+
def put(self, trigger_data: TriggerData, internal: bool = False, _delayed: bool = False):
8383
"""Put the trigger on the queue without blocking the caller."""
8484
if not self.running and not self.sm.allow_event_without_transition:
8585
raise TransitionNotAllowed(trigger_data.event, self.sm.configuration)
@@ -89,6 +89,13 @@ def put(self, trigger_data: TriggerData, internal: bool = False):
8989
else:
9090
self.external_queue.put(trigger_data)
9191

92+
if not _delayed:
93+
logger.debug(
94+
"New event '%s' put on the '%s' queue",
95+
trigger_data.event,
96+
"internal" if internal else "external",
97+
)
98+
9299
def pop(self):
93100
return self.external_queue.pop()
94101

@@ -268,14 +275,19 @@ def microstep(self, transitions: List[Transition], trigger_data: TriggerData):
268275
"""Process a single set of transitions in a 'lock step'.
269276
This includes exiting states, executing transition content, and entering states.
270277
"""
271-
result = self._execute_transition_content(
272-
transitions, trigger_data, lambda t: t.before.key
273-
)
278+
previous_configuration = self.sm.configuration
279+
try:
280+
result = self._execute_transition_content(
281+
transitions, trigger_data, lambda t: t.before.key
282+
)
274283

275-
states_to_exit = self._exit_states(transitions, trigger_data)
276-
logger.debug("States to exit: %s", states_to_exit)
277-
result += self._execute_transition_content(transitions, trigger_data, lambda t: t.on.key)
278-
self._enter_states(transitions, trigger_data, states_to_exit)
284+
states_to_exit = self._exit_states(transitions, trigger_data)
285+
result += self._enter_states(
286+
transitions, trigger_data, states_to_exit, previous_configuration
287+
)
288+
except Exception:
289+
self.sm.configuration = previous_configuration
290+
raise
279291
self._execute_transition_content(
280292
transitions,
281293
trigger_data,
@@ -291,12 +303,13 @@ def microstep(self, transitions: List[Transition], trigger_data: TriggerData):
291303
return result
292304

293305
def _get_args_kwargs(
294-
self, transition: Transition, trigger_data: TriggerData, set_target_as_state: bool = False
306+
self, transition: Transition, trigger_data: TriggerData, target: "State | None" = None
295307
):
296308
# TODO: Ideally this method should be called only once per microstep/transition
297309
event_data = EventData(trigger_data=trigger_data, transition=transition)
298-
if set_target_as_state:
299-
event_data.state = transition.target
310+
if target:
311+
event_data.state = target
312+
event_data.target = target
300313

301314
args, kwargs = event_data.args, event_data.extended_kwargs
302315

@@ -319,10 +332,13 @@ def _exit_states(self, enabled_transitions: List[Transition], trigger_data: Trig
319332
# for state in states_to_exit:
320333
# self.states_to_invoke.discard(state)
321334

322-
# TODO: Sort states to exit in exit order
323-
# states_to_exit = sorted(states_to_exit, key=self.exit_order)
335+
ordered_states = sorted(
336+
states_to_exit, key=lambda x: x.source and x.source.document_order or 0, reverse=True
337+
)
338+
result = OrderedSet([info.source for info in ordered_states])
339+
logger.debug("States to exit: %s", result)
324340

325-
for info in states_to_exit:
341+
for info in ordered_states:
326342
args, kwargs = self._get_args_kwargs(info.transition, trigger_data)
327343

328344
# # TODO: Update history
@@ -342,22 +358,28 @@ def _exit_states(self, enabled_transitions: List[Transition], trigger_data: Trig
342358
# self.cancel_invoke(invocation)
343359

344360
# Remove state from configuration
345-
# self.sm.configuration -= {info.source} # .discard(info.source)
361+
if not self.sm.atomic_configuration_update:
362+
self.sm.configuration -= {info.source} # .discard(info.source)
346363

347-
return OrderedSet([info.source for info in states_to_exit])
364+
return result
348365

349366
def _execute_transition_content(
350367
self,
351368
enabled_transitions: List[Transition],
352369
trigger_data: TriggerData,
353370
get_key: Callable[[Transition], str],
354371
set_target_as_state: bool = False,
372+
**kwargs_extra,
355373
):
356374
result = []
357375
for transition in enabled_transitions:
376+
target = transition.target if set_target_as_state else None
358377
args, kwargs = self._get_args_kwargs(
359-
transition, trigger_data, set_target_as_state=set_target_as_state
378+
transition,
379+
trigger_data,
380+
target=target,
360381
)
382+
kwargs.update(kwargs_extra)
361383

362384
result += self.sm._callbacks.call(get_key(transition), *args, **kwargs)
363385

@@ -368,6 +390,7 @@ def _enter_states(
368390
enabled_transitions: List[Transition],
369391
trigger_data: TriggerData,
370392
states_to_exit: OrderedSet[State],
393+
previous_configuration: OrderedSet[State],
371394
):
372395
"""Enter the states as determined by the given transitions."""
373396
states_to_enter = OrderedSet[StateTransition]()
@@ -379,29 +402,44 @@ def _enter_states(
379402
enabled_transitions, states_to_enter, states_for_default_entry, default_history_content
380403
)
381404

405+
ordered_states = sorted(
406+
states_to_enter, key=lambda x: x.source and x.source.document_order or 0
407+
)
408+
382409
# We update the configuration atomically
383-
states_targets_to_enter = OrderedSet(
384-
info.target for info in states_to_enter if info.target
410+
states_targets_to_enter = OrderedSet(info.target for info in ordered_states if info.target)
411+
412+
new_configuration = cast(
413+
OrderedSet[State], (previous_configuration - states_to_exit) | states_targets_to_enter
385414
)
386415
logger.debug("States to enter: %s", states_targets_to_enter)
387416

388-
configuration = self.sm.configuration
389-
self.sm.configuration = cast(
390-
OrderedSet[State], (configuration - states_to_exit) | states_targets_to_enter
417+
result = self._execute_transition_content(
418+
enabled_transitions,
419+
trigger_data,
420+
lambda t: t.on.key,
421+
previous_configuration=previous_configuration,
422+
new_configuration=new_configuration,
391423
)
392424

425+
if self.sm.atomic_configuration_update:
426+
self.sm.configuration = new_configuration
427+
393428
# Sort states to enter in entry order
394429
# for state in sorted(states_to_enter, key=self.entry_order): # TODO: order of states_to_enter # noqa: E501
395-
for info in states_to_enter:
430+
for info in ordered_states:
396431
target = info.target
397432
assert target
398433
transition = info.transition
399434
args, kwargs = self._get_args_kwargs(
400-
transition, trigger_data, set_target_as_state=True
435+
transition,
436+
trigger_data,
437+
target=target,
401438
)
402439

403440
# Add state to the configuration
404-
# self.sm.configuration |= {target}
441+
if not self.sm.atomic_configuration_update:
442+
self.sm.configuration |= {target}
405443

406444
# TODO: Add state to states_to_invoke
407445
# self.states_to_invoke.add(state)
@@ -412,7 +450,7 @@ def _enter_states(
412450
# state.is_first_entry = False
413451

414452
# Execute `onentry` handlers
415-
self.sm._callbacks.call(target.enter.key, *args, **kwargs)
453+
on_entry_result = self.sm._callbacks.call(target.enter.key, *args, **kwargs)
416454

417455
# Handle default initial states
418456
# TODO: Handle default initial states
@@ -431,15 +469,24 @@ def _enter_states(
431469
parent = target.parent
432470
grandparent = parent.parent
433471

472+
donedata = {}
473+
for item in on_entry_result:
474+
if not item:
475+
continue
476+
donedata.update(item)
477+
434478
BoundEvent(
435479
f"done.state.{parent.id}",
436480
_sm=self.sm,
437481
internal=True,
438-
).put()
482+
).put(donedata=donedata)
439483

440-
if grandparent.parallel:
484+
if grandparent and grandparent.parallel:
441485
if all(child.final for child in grandparent.states):
442-
BoundEvent(f"done.state.{parent.id}", _sm=self.sm, internal=True).put()
486+
BoundEvent(f"done.state.{parent.id}", _sm=self.sm, internal=True).put(
487+
donedata=donedata
488+
)
489+
return result
443490

444491
def compute_entry_set(
445492
self, transitions, states_to_enter, states_for_default_entry, default_history_content

0 commit comments

Comments
 (0)