Skip to content

Commit 5780184

Browse files
authored
docs: document coroutine function support in invoke (#612)
1 parent f54d9f5 commit 5780184

File tree

2 files changed

+111
-4
lines changed

2 files changed

+111
-4
lines changed

docs/invoke.md

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@ that runs for the duration of a state and is cancelled when the state is exited.
1212
Invoke handlers run **outside** the main state machine processing loop:
1313

1414
- **Sync engine**: each invoke handler runs in a **daemon thread**.
15-
- **Async engine**: each invoke handler runs in a **thread executor**
16-
(`loop.run_in_executor`), wrapped in an `asyncio.Task`. The executor is used because
17-
invoke handlers are expected to perform blocking I/O (network calls, file access,
18-
subprocess communication) that would freeze the event loop if run directly.
15+
- **Async engine**:
16+
- **Sync handlers** run in a **thread executor** (`loop.run_in_executor`), wrapped
17+
in an `asyncio.Task`. The executor is used because blocking I/O (network calls,
18+
file access, subprocess communication) would freeze the event loop if run directly.
19+
- **Coroutine functions** and `IInvoke` handlers with `async def run()` are
20+
**awaited directly** on the event loop, with no executor overhead. This is the
21+
natural choice for non-blocking async I/O (e.g., `aiohttp`, async DB drivers).
1922

2023
When a handler completes, a `done.invoke.<state>.<id>` event is automatically sent back
2124
to the machine. If the handler raises an exception, an `error.execution` event is sent
@@ -270,6 +273,86 @@ Events from cancelled invocations are silently ignored.
270273

271274
```
272275

276+
## Coroutine functions
277+
278+
Coroutine functions (`async def`) can be used as invoke targets. On the async engine,
279+
they are awaited directly on the event loop instead of running in a thread executor.
280+
This is ideal for non-blocking async I/O:
281+
282+
```py
283+
>>> import asyncio
284+
285+
>>> async def async_fetch():
286+
... await asyncio.sleep(0.01) # simulates async I/O
287+
... return {"status": "ok"}
288+
289+
>>> class AsyncLoader(StateChart):
290+
... loading = State(initial=True, invoke=async_fetch)
291+
... ready = State(final=True)
292+
... done_invoke_loading = loading.to(ready)
293+
...
294+
... def on_enter_ready(self, data=None, **kwargs):
295+
... self.result = data
296+
297+
>>> async def main():
298+
... sm = AsyncLoader()
299+
... await sm.activate_initial_state()
300+
... await asyncio.sleep(0.1)
301+
... await sm._processing_loop()
302+
... return sm
303+
304+
>>> sm = asyncio.run(main())
305+
306+
>>> "ready" in sm.configuration_values
307+
True
308+
>>> sm.result
309+
{'status': 'ok'}
310+
311+
```
312+
313+
The `IInvoke` protocol also supports `async def run()`. Since `IInvoke` handlers
314+
are wrapped internally, you need at least one async callback in the machine to
315+
trigger the async engine (e.g., an `async def` action or listener):
316+
317+
```py
318+
>>> class AsyncFetcher:
319+
... async def run(self, ctx: InvokeContext):
320+
... await asyncio.sleep(0.01)
321+
... return "async_fetched"
322+
323+
>>> class AsyncFetcherMachine(StateChart):
324+
... loading = State(initial=True, invoke=AsyncFetcher)
325+
... ready = State(final=True)
326+
... done_invoke_loading = loading.to(ready)
327+
...
328+
... async def on_enter_ready(self, data=None, **kwargs):
329+
... self.result = data
330+
331+
>>> async def run_fetcher():
332+
... sm = AsyncFetcherMachine()
333+
... await sm.activate_initial_state()
334+
... await asyncio.sleep(0.1)
335+
... await sm._processing_loop()
336+
... return sm
337+
338+
>>> sm = asyncio.run(run_fetcher())
339+
340+
>>> "ready" in sm.configuration_values
341+
True
342+
>>> sm.result
343+
'async_fetched'
344+
345+
```
346+
347+
Cancellation of coroutine handlers works through `asyncio.Task.cancel()`, which
348+
raises `CancelledError` at the next `await` point, giving proper async cancellation
349+
semantics without cooperative polling.
350+
351+
```{note}
352+
Coroutine functions automatically select the async engine. Using an `IInvoke` with
353+
`async def run()` on the sync engine raises `InvalidDefinition`.
354+
```
355+
273356
## Event data propagation
274357

275358
When a state with invoke handlers is entered via an event, the keyword arguments from

docs/releases/3.1.0.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,30 @@ machine instance concurrently. This is now documented in the
150150
[#592](https://github.com/fgmacedo/python-statemachine/pull/592).
151151

152152

153+
### Coroutine functions as invoke targets
154+
155+
Invoke now supports `async def` functions and `IInvoke` handlers with `async def run()`.
156+
On the async engine, coroutines are awaited directly on the event loop instead of running
157+
in a thread executor, making invoke a natural fit for non-blocking async I/O
158+
(e.g., `aiohttp`, async DB drivers).
159+
160+
```python
161+
async def fetch_data():
162+
async with aiohttp.ClientSession() as session:
163+
resp = await session.get("https://api.example.com/data")
164+
return await resp.json()
165+
166+
class Loader(StateChart):
167+
loading = State(initial=True, invoke=fetch_data)
168+
ready = State(final=True)
169+
done_invoke_loading = loading.to(ready)
170+
```
171+
172+
See {ref}`invoke:Coroutine functions` for details.
173+
[#611](https://github.com/fgmacedo/python-statemachine/pull/611),
174+
fixes [#610](https://github.com/fgmacedo/python-statemachine/issues/610).
175+
176+
153177
### Bugfixes in 3.1.0
154178

155179
- Fixes silent misuse of `Event()` with multiple positional arguments. Passing more than one

0 commit comments

Comments
 (0)