Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,8 @@ The full list of generic callbacks:
| `on_enter_state()` | Enter | Runs when entering any state. |
| `on_invoke_state()` | Invoke | Runs when spawning invoke handlers for any state. See {ref}`invoke`. |
| `after_transition()` | After | Runs after all state changes. |
| `on_defer_event()` | Defer | Runs when a state defers an event. See {ref}`deferral`. |
| `on_replay_event()` | Replay | Runs when a deferred event is sent again. See {ref}`deferral`. |

```{note}
`prepare_event()` is also a generic callback, but it serves a special purpose —
Expand Down
205 changes: 205 additions & 0 deletions docs/deferral.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
(deferral)=

# Event deferral

```{versionadded} 3.3.0
```

```{seealso}
See {ref}`processing-model` for how events are queued and consumed, and {ref}`events`
for how they are declared and sent.
```

By default, an event that no active transition handles is dropped (or rejected, depending on
`allow_event_without_transition`). Sometimes the event is not wrong, only early: the
machine just is not ready for it yet. A state can **defer** such events. They are kept in a
queue instead of being discarded, and sent again as soon as the machine reaches a
configuration that no longer defers them.

This is the UML statechart notion of a *deferred event*.

## Deferring events in a state

Pass the ids of the events to the `defer` parameter of {ref}`State`:

```py
>>> from statemachine import State, StateChart

>>> class Council(StateChart):
... gathering = State(initial=True, defer=["speak"])
... deliberating = State()
... decided = State(final=True)
...
... convene = gathering.to(deliberating)
... speak = deliberating.to(decided)

>>> sm = Council()
>>> sm.send("speak")
>>> sm.configuration_values
OrderedSet(['gathering'])

>>> sm.pending_deferred_event_ids
['speak']

```

The `speak` event arrived while the council was still `gathering`, which has no transition for it
and defers it. Nothing happened, but the event was remembered. Once the machine leaves
`gathering`, the queued event is put back at the end of the external queue and processed
like any other:

```py
>>> sm.send("convene")
>>> sm.configuration_values
OrderedSet(['decided'])

>>> sm.pending_deferred_event_ids
[]

```

Some details worth knowing:

- An event is only deferred when **no transition is enabled** for it. If the active state
has a transition for the event, the transition runs as usual.
- Deferred events are replayed in the order they were sent (first in, first out), with the
arguments they were sent with.
- An event that is replayed into a configuration that still defers it stays in the queue.
- Deferral takes precedence over `allow_event_without_transition`: a deferred event is queued
whether the machine would otherwise ignore it or raise `TransitionNotAllowed`.
- The `defer` list does not need to name events declared in the class (see
{ref}`validate_deferred_events <validating-deferred-events>`).
- A final state cannot defer events, since the machine does not process events after it
terminates. The queue is cleared at that point.

## Deferral in compound and parallel states

A compound state defers events for all of its descendants. Use the `defer` class keyword:

```py
>>> class Journey(StateChart):
... class mordor(State.Compound, defer=["rest"]):
... gates = State(initial=True)
... mount_doom = State()
...
... climb = gates.to(mount_doom)
...
... shire = State()
... done = State(final=True)
...
... return_home = mordor.to(shire)
... rest = shire.to(done)

>>> sm = Journey()
>>> sm.send("rest")
>>> sm.send("climb")
>>> sm.pending_deferred_event_ids
['rest']

>>> sm.send("return_home")
>>> sm.configuration_values
OrderedSet(['done'])

```

A state defers an event if it lists the event or any of its ancestors does.
`State.deferred_events` holds what a state declares itself, and
`State.deferred_events_recursive` adds the ids declared by its ancestors.

In a parallel state there are several active atomic states at once, and the event is deferred
**only if all of them defer it**. If any region can still react to the event, it is processed
as usual and nothing is queued.

## Callbacks

Two optional callbacks follow the same pattern as `on_enter` and `on_exit`:

| Callback | When |
|---|---|
| `on_defer` | An event was added to the deferred queue. |
| `on_replay` | An event left the queue to be processed again. |

Both can be given inline to the state, or found by naming convention: `on_defer_event()` and
`on_replay_event()` for every state, `on_defer_<state_id>()` and `on_replay_<state_id>()` for a
specific one.

```py
>>> class Messenger(StateChart):
... riding = State(initial=True, defer=["deliver"])
... arrived = State()
... delivered = State(final=True)
...
... arrive = riding.to(arrived)
... deliver = arrived.to(delivered)
...
... def on_defer_riding(self, event):
... print(f"{event} has to wait")
...
... def on_replay_event(self, event, state):
... print(f"{event} is back while in {state.id}")

>>> sm = Messenger()
>>> sm.send("deliver")
deliver has to wait

>>> sm.send("arrive")
deliver is back while in arrived

```

The callbacks run once for each active state that declares them, and receive the usual
{ref}`dependency injection <dependency-injection>` arguments: `machine`, `model`, `event`,
`state` and `source` (the same state) and `target`, which is always `None`. Arguments passed
to `send()` are available too. With the async engine the callbacks can be coroutines.

## Inspecting the queue

The {ref}`StateChart` instance exposes the queue:

- `deferred_events`: a copy of the queue, as a list of `TriggerData`.
- `pending_deferred_event_ids`: the ids of the queued events, in order.
- `is_event_deferred(event_id)`: whether an event would be deferred in the current
configuration.
- `clear_deferred_queue()`: discards the queued events without replaying them.

```py
>>> sm = Council()
>>> sm.is_event_deferred("speak")
True

>>> sm.send("speak")
>>> [str(trigger.event) for trigger in sm.deferred_events]
['speak']

>>> sm.clear_deferred_queue()
>>> sm.send("convene")
>>> sm.configuration_values
OrderedSet(['deliberating'])

```

The queue is runtime state: it is not part of the serialized machine, so a pickled or copied
machine starts with an empty queue.

(validating-deferred-events)=

## Validating deferred events

A typo in a `defer` list is silent by default, because the event might be sent by code that is
not declared in the class. Set `validate_deferred_events = True` to require every deferred id
to be a declared event:

```py
>>> from statemachine.exceptions import InvalidDefinition

>>> try:
... class Lookout(StateChart):
... validate_deferred_events = True
... watching = State(initial=True, defer=["relieved"])
... relieved = State(final=True)
... relieve = watching.to(relieved)
... except InvalidDefinition as e:
... print(e)
State 'watching' defers unknown event(s): ['relieved']. Declared events are: ['relieve']

```
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ guards

statechart
processing_model
deferral
error_handling
async
listeners
Expand Down
40 changes: 40 additions & 0 deletions docs/releases/3.3.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# StateChart 3.3.0

*Not released yet*

## What's new in 3.3.0

### Event deferral

A state can now **defer** events: when an event arrives and no transition handles it, the
engine keeps it in a queue instead of discarding it, and sends it again once the machine
reaches a configuration that no longer defers it. This is the UML statechart *deferred event*.

```py
>>> from statemachine import State, StateChart

>>> class Council(StateChart):
... gathering = State(initial=True, defer=["speak"])
... deliberating = State()
... decided = State(final=True)
...
... convene = gathering.to(deliberating)
... speak = deliberating.to(decided)

>>> sm = Council()
>>> sm.send("speak")
>>> sm.pending_deferred_event_ids
['speak']

>>> sm.send("convene")
>>> sm.configuration_values
OrderedSet(['decided'])

```

Compound states defer events for their children (`class mordor(State.Compound, defer=["rest"])`),
and in parallel states an event is deferred only when every active atomic state defers it.
States also accept `on_defer` and `on_replay` callbacks, `StateChart` exposes
`deferred_events`, `pending_deferred_event_ids`, `is_event_deferred()` and
`clear_deferred_queue()`, and the opt-in `validate_deferred_events` flag checks the deferred
ids against the declared events. See {ref}`deferral`.
1 change: 1 addition & 0 deletions docs/releases/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Requires Python 3.10+.
```{toctree}
:maxdepth: 2

3.3.0
3.2.2
3.2.1
3.2.0
Expand Down
3 changes: 3 additions & 0 deletions docs/states.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ True
| `enter` | `None` | Callback(s) to run when entering this state. See {ref}`state-actions`. |
| `exit` | `None` | Callback(s) to run when leaving this state. See {ref}`state-actions`. |
| `invoke` | `None` | Background work spawned on entry, cancelled on exit. See {ref}`invoke-actions`. |
| `defer` | `None` | Ids of events to keep in a queue, instead of discarding them, while this state is active. See {ref}`deferral`. |
| `on_defer` | `None` | Callback(s) to run when an event is deferred by this state. See {ref}`deferral`. |
| `on_replay` | `None` | Callback(s) to run when a deferred event is sent again. See {ref}`deferral`. |

```py
>>> class CampaignMachine(StateChart):
Expand Down
26 changes: 24 additions & 2 deletions docs/validations.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,26 @@ The `donedata` parameter can only be used on states marked as `final=True`:
```


### Deferred events

A final state cannot defer events, since a terminated machine processes none:

```py
>>> try:
... class Bad(StateChart):
... a = State(initial=True)
... b = State(final=True, defer=["go"])
... go = a.to(b)
... except InvalidDefinition as e:
... print(e)
Cannot defer events on final states.

```

Optionally, set `validate_deferred_events = True` to also require every deferred event id to
be a declared event. See {ref}`validating-deferred-events`.


### Invalid listener entries

Entries in the `listeners` class attribute must be classes, callables, or
Expand Down Expand Up @@ -279,9 +299,11 @@ Expressions support `and`, `or`, `not`, and parentheses. See
| Internal transition targets | Class definition| No |
| Initial transitions have no cond | Class definition| No |
| `donedata` on final states only | Class definition| No |
| Final states do not defer events | Class definition| No |
| Deferred events are declared | Class definition| `validate_deferred_events` (off by default) |
| Invalid listener entries | Class definition| No |
| Callback resolution | Instance creation | No |
| Boolean expression parsing | Instance creation | No |

All configurable flags default to `True`. Set them to `False` on the class
to disable the corresponding check.
All configurable flags default to `True`, except `validate_deferred_events`, which is opt-in.
Set a flag to `False` on the class to disable the corresponding check.
2 changes: 2 additions & 0 deletions statemachine/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ class CallbackGroup(IntEnum):
ENTER = auto()
EXIT = auto()
INVOKE = auto()
DEFER = auto()
REPLAY = auto()
VALIDATOR = auto()
BEFORE = auto()
ON = auto()
Expand Down
35 changes: 32 additions & 3 deletions statemachine/engines/async_.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,34 @@ async def _enter_states( # noqa: C901
if target.final:
self._handle_final_state(target, on_entry_result)

await self._replay_deferred_events()
return result

async def _defer_event(self, trigger_data: TriggerData):
self._debug("%s Deferring event '%s'", self._log_id, trigger_data.event)
self._deferred_queue.append(trigger_data)
on_error = self._on_error_handler()
for state in self.sm.configuration:
if state.on_defer.key in self.sm._callbacks:
await self.sm._callbacks.async_call(
state.on_defer.key,
*trigger_data.args,
on_error=on_error,
**self._deferral_callback_kwargs(trigger_data, state),
)

async def _replay_deferred_events(self):
on_error = self._on_error_handler()
for trigger_data in self._release_deferred_events():
for state in self.sm.configuration:
if state.on_replay.key in self.sm._callbacks:
await self.sm._callbacks.async_call(
state.on_replay.key,
*trigger_data.args,
on_error=on_error,
**self._deferral_callback_kwargs(trigger_data, state),
)

async def microstep(self, transitions: "list[Transition]", trigger_data: TriggerData):
self._microstep_count += 1
self._debug(
Expand Down Expand Up @@ -471,15 +497,18 @@ async def processing_loop( # noqa: C901
if first_result is self._sentinel:
first_result = result
else:
if not self.sm.allow_event_without_transition:
if self._is_event_deferred(self._event_id(external_event)):
await self._defer_event(external_event)
self._resolve_future(event_future, None)
elif not self.sm.allow_event_without_transition:
tna = TransitionNotAllowed(
external_event.event, self.sm.configuration
)
self._reject_future(event_future, tna)
self._reject_pending_futures(tna)
raise tna
# Event allowed but no transition — resolve with None
self._resolve_future(event_future, None)
else:
self._resolve_future(event_future, None)
except Exception as exc:
self._reject_future(event_future, exc)
self._reject_pending_futures(exc)
Expand Down
Loading