Skip to content

Commit 2d7a551

Browse files
authored
docs: comprehensive v3 documentation rewrite (#582)
* docs: comprehensive v3 documentation rewrite Rewrite core documentation pages for the v3 release with SCXML-compliant statechart semantics, consistent narrative, and testable doctests throughout.
1 parent 300cb34 commit 2d7a551

35 files changed

Lines changed: 4600 additions & 2894 deletions

docs/actions.md

Lines changed: 477 additions & 350 deletions
Large diffs are not rendered by default.

docs/async.md

Lines changed: 81 additions & 153 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,28 @@
1-
# Async
1+
(async)=
2+
# Async support
23

3-
```{versionadded} 2.3.0
4-
Support for async code was added!
5-
```
6-
7-
The {ref}`StateChart` fully supports asynchronous code. You can write async {ref}`actions`, {ref}`guards`, and {ref}`events` triggers, while maintaining the same external API for both synchronous and asynchronous codebases.
8-
9-
This is achieved through a new concept called **engine**, an internal strategy pattern abstraction that manages transitions and callbacks.
10-
11-
There are two engines, {ref}`SyncEngine` and {ref}`AsyncEngine`.
12-
13-
14-
## Sync vs async engines
15-
16-
Engines are internal and are activated automatically by inspecting the registered callbacks in the following scenarios.
17-
18-
19-
```{list-table} Sync vs async engines
20-
:header-rows: 1
21-
22-
* - Outer scope
23-
- Async callbacks?
24-
- Engine
25-
- Creates internal loop
26-
- Reuses external loop
27-
* - Sync
28-
- No
29-
- SyncEngine
30-
- No
31-
- No
32-
* - Sync
33-
- Yes
34-
- AsyncEngine
35-
- Yes
36-
- No
37-
* - Async
38-
- No
39-
- SyncEngine
40-
- No
41-
- No
42-
* - Async
43-
- Yes
44-
- AsyncEngine
45-
- No
46-
- Yes
47-
48-
```
49-
50-
Outer scope
51-
: The context in which the state machine **instance** is created.
52-
53-
Async callbacks?
54-
: Indicates whether the state machine has declared asynchronous callbacks or conditions.
55-
56-
Engine
57-
: The engine that will be utilized.
58-
59-
Creates internal loop
60-
: Specifies whether the state machine initiates a new event loop if no [asyncio loop is running](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.get_running_loop).
61-
62-
Reuses external loop
63-
: Indicates whether the state machine reuses an existing [asyncio loop](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.get_running_loop) if one is already running.
64-
65-
66-
67-
```{note}
68-
All handlers will run on the same thread they are called. Therefore, mixing synchronous and asynchronous code is not recommended unless you are confident in your implementation.
4+
```{seealso}
5+
New to statecharts? See [](concepts.md) for an overview of how states,
6+
transitions, events, and actions fit together.
697
```
708

71-
### SyncEngine
72-
Activated if there are no async callbacks. All code runs exactly as it did before version 2.3.0.
73-
There's no event loop.
74-
75-
### AsyncEngine
76-
Activated if there is at least one async callback. The code runs asynchronously and requires a running event loop, which it will create if none exists.
9+
The public API is the same for synchronous and asynchronous code. If the
10+
state machine has at least one `async` callback, the engine switches to
11+
{ref}`AsyncEngine <asyncengine>` automatically — no configuration needed.
7712

13+
All statechart features — compound states, parallel states, history
14+
pseudo-states, eventless transitions, `done.state` events — work
15+
identically in both engines.
7816

7917

80-
## Asynchronous Support
81-
82-
We support native coroutine callbacks using asyncio, enabling seamless integration with asynchronous code. There is no change in the public API of the library to work with asynchronous codebases.
83-
84-
85-
```{seealso}
86-
See {ref}`sphx_glr_auto_examples_air_conditioner_machine.py` for an example of
87-
async code with a state machine.
88-
```
18+
## Writing async callbacks
8919

20+
Declare any callback as `async def` and the engine handles the rest:
9021

9122
```py
9223
>>> class AsyncStateMachine(StateChart):
93-
... initial = State('Initial', initial=True)
94-
... final = State('Final', final=True)
24+
... initial = State("Initial", initial=True)
25+
... final = State("Final", final=True)
9526
...
9627
... keep = initial.to.itself(internal=True)
9728
... advance = initial.to(final)
@@ -111,13 +42,12 @@ Result is 42
11142

11243
```
11344

114-
## Sync codebase with async callbacks
115-
116-
The same state machine with async callbacks can be executed in a synchronous codebase,
117-
even if the calling context don't have an asyncio loop.
118-
119-
If needed, the state machine will create a loop using `asyncio.new_event_loop()` and callbacks will be awaited using `loop.run_until_complete()`.
45+
### Using from synchronous code
12046

47+
The same state machine can be used from a synchronous context — even
48+
without a running `asyncio` loop. The engine creates one internally
49+
with `asyncio.new_event_loop()` and awaits callbacks using
50+
`loop.run_until_complete()`:
12151

12252
```py
12353
>>> sm = AsyncStateMachine()
@@ -131,88 +61,63 @@ Result is 42
13161

13262

13363
(initial state activation)=
134-
## Initial State Activation for Async Code
13564

65+
## Initial state activation
13666

137-
If **on async code** you perform checks against the `configuration`, like a loop `while not sm.is_terminated:`, then you must manually
138-
await for the [activate initial state](statemachine.StateChart.activate_initial_state) to be able to check the configuration.
139-
140-
```{hint}
141-
This manual initial state activation on async is because Python don't allow awaiting at class initalization time and the initial state activation may contain async callbacks that must be awaited.
142-
```
143-
144-
If you don't do any check for configuration externally, just ignore this as the initial state is activated automatically before the first event trigger is handled.
145-
146-
You get an error checking the configuration before the initial state activation:
67+
In async code, Python cannot `await` during `__init__`, so the initial
68+
state is **not** activated at instantiation time. If you inspect
69+
`configuration` immediately after creating the instance, it won't reflect
70+
the initial state:
14771

14872
```py
149-
>>> async def initialize_sm():
73+
>>> async def show_problem():
15074
... sm = AsyncStateMachine()
15175
... print(list(sm.configuration_values))
15276

153-
>>> asyncio.run(initialize_sm())
77+
>>> asyncio.run(show_problem())
15478
[None]
15579

15680
```
15781

158-
You can activate the initial state explicitly:
159-
82+
To fix this, explicitly await
83+
{func}`activate_initial_state() <statemachine.StateChart.activate_initial_state>`
84+
before inspecting the configuration:
16085

16186
```py
162-
>>> async def initialize_sm():
87+
>>> async def correct_init():
16388
... sm = AsyncStateMachine()
16489
... await sm.activate_initial_state()
16590
... print(list(sm.configuration_values))
16691

167-
>>> asyncio.run(initialize_sm())
92+
>>> asyncio.run(correct_init())
16893
['initial']
16994

17095
```
17196

172-
Or just by sending an event. The first event activates the initial state automatically
173-
before the event is handled:
97+
```{tip}
98+
If you don't inspect the configuration before sending the first event,
99+
you can skip this step — the first `send()` activates the initial state
100+
automatically.
101+
```
174102

175103
```py
176-
>>> async def initialize_sm():
104+
>>> async def auto_activate():
177105
... sm = AsyncStateMachine()
178-
... await sm.keep() # first event activates the initial state before the event is handled
106+
... await sm.keep() # activates initial state before handling the event
179107
... print(list(sm.configuration_values))
180108

181-
>>> asyncio.run(initialize_sm())
109+
>>> asyncio.run(auto_activate())
182110
['initial']
183111

184112
```
185113

186-
## StateChart async support
187114

188-
```{versionadded} 3.0.0
189-
```
115+
## Concurrent event sending
190116

191-
`StateChart` works identically with the async engine. All statechart features —
192-
compound states, parallel states, history pseudo-states, eventless transitions,
193-
and `done.state` events — are fully supported in async code. The same
194-
`activate_initial_state()` pattern applies:
195-
196-
```py
197-
>>> async def run():
198-
... sm = AsyncStateMachine()
199-
... await sm.activate_initial_state()
200-
... result = await sm.send("advance")
201-
... return result
202-
203-
>>> asyncio.run(run())
204-
42
205-
206-
```
207-
208-
### Concurrent event sending
209-
210-
```{versionadded} 3.0.0
211-
```
212-
213-
When multiple coroutines send events concurrently (e.g., via `asyncio.gather`),
214-
each caller receives its own event's result — even though only one coroutine
215-
actually runs the processing loop at a time.
117+
A benefit exclusive to the async engine: when multiple coroutines send
118+
events concurrently (e.g., via `asyncio.gather`), each caller receives
119+
its own event's result — even though only one coroutine runs the
120+
processing loop at a time. The sync engine does not support this pattern.
216121

217122
```py
218123
>>> class ConcurrentSC(StateChart):
@@ -246,28 +151,51 @@ actually runs the processing loop at a time.
246151

247152
Under the hood, the async engine attaches an `asyncio.Future` to each
248153
externally enqueued event. The coroutine that acquires the processing lock
249-
resolves each event's future as it processes the queue. Callers that couldn't
154+
resolves each event's future as it processes the queue. Callers that didn't
250155
acquire the lock simply `await` their future.
251156

252157
```{note}
253158
Futures are only created for **external** events sent from outside the
254-
processing loop. Events triggered from within callbacks (reentrant calls)
255-
follow the existing run-to-completion (RTC) model — they are enqueued and
256-
processed within the current macrostep, and the callback receives ``None``.
159+
processing loop. Events triggered from within callbacks (via `send()` or
160+
`raise_()`) follow the {ref}`run-to-completion <rtc-model>` model — they
161+
are enqueued and processed within the current macrostep.
257162
```
258163

259164
If an exception occurs during processing (with `error_on_execution=False`),
260165
the exception is routed to the caller whose event caused it. Other callers
261166
whose events were still pending will also receive the exception, since the
262167
processing loop clears the queue on failure.
263168

264-
### Async-specific limitations
265169

266-
- **Initial state activation**: In async code, you must `await sm.activate_initial_state()`
267-
before inspecting `sm.configuration`. In sync code this happens
268-
automatically at instantiation time.
269-
- **Delayed events**: Both sync and async engines support `delay=` on `send()`. The async
270-
engine uses `asyncio.sleep()` internally, so it integrates naturally with event loops.
271-
- **Thread safety**: The processing loop uses a non-blocking lock (`_processing.acquire`).
272-
All callbacks run on the same thread they are called from — do not share a state machine
273-
instance across threads without external synchronization.
170+
(syncengine)=
171+
(asyncengine)=
172+
173+
## Engine selection
174+
175+
The engine is selected automatically when the state machine is
176+
instantiated, based on the registered callbacks:
177+
178+
| Outer scope | Async callbacks? | Engine | Event loop |
179+
|---|---|---|---|
180+
| Sync | No | SyncEngine | None |
181+
| Sync | Yes | AsyncEngine | Creates internal loop |
182+
| Async | No | SyncEngine | None |
183+
| Async | Yes | AsyncEngine | Reuses running loop |
184+
185+
**Outer scope** is the context where the state machine instance is created.
186+
**Async callbacks** means at least one `async def` callback or condition is
187+
declared on the machine, its model, or its listeners.
188+
189+
```{note}
190+
All callbacks run on the same thread they are called from. Mixing
191+
synchronous and asynchronous code is supported but requires care —
192+
avoid sharing a state machine instance across threads without external
193+
synchronization.
194+
```
195+
196+
197+
```{seealso}
198+
See {ref}`processing model <macrostep-microstep>` for how the engine
199+
processes events, and {ref}`behaviour` for the behavioral attributes
200+
that affect processing.
201+
```

0 commit comments

Comments
 (0)