Skip to content

Commit 2dd1233

Browse files
authored
feat: Support for native coroutines (asyncio) (#435)
* tests: New air conditioner example using asyncio * fix: Custom error if the model is initialized with an invalid current state
1 parent 79546a5 commit 2dd1233

32 files changed

Lines changed: 838 additions & 178 deletions

README.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ Define your state machine:
6565
... | red.to(green)
6666
... )
6767
...
68-
... def before_cycle(self, event: str, source: State, target: State, message: str = ""):
68+
... async def before_cycle(self, event: str, source: State, target: State, message: str = ""):
6969
... message = ". " + message if message else ""
7070
... return f"Running {event} from {source.id} to {target.id}{message}"
7171
...
@@ -240,6 +240,34 @@ and in diagrams:
240240

241241
```
242242

243+
## Async support
244+
245+
We support native coroutine using `asyncio`, enabling seamless integration with asynchronous code.
246+
There's no change on the public API of the library to work on async codebases.
247+
248+
249+
```py
250+
>>> class AsyncStateMachine(StateMachine):
251+
... initial = State('Initial', initial=True)
252+
... final = State('Final', final=True)
253+
...
254+
... advance = initial.to(final)
255+
...
256+
... async def on_advance(self):
257+
... return 42
258+
259+
>>> async def run_sm():
260+
... sm = AsyncStateMachine()
261+
... result = await sm.advance()
262+
... print(f"Result is {result}")
263+
... print(sm.current_state)
264+
265+
>>> asyncio.run(run_sm())
266+
Result is 42
267+
Final
268+
269+
```
270+
243271
## A more useful example
244272

245273
A simple didactic state machine for controlling an `Order`:

conftest.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import pytest
2+
3+
4+
@pytest.fixture(autouse=True, scope="session")
5+
def add_doctest_context(doctest_namespace): # noqa: PT004
6+
from statemachine import State
7+
from statemachine import StateMachine
8+
from statemachine.utils import run_async_from_sync
9+
10+
class ContribAsyncio:
11+
"""
12+
Using `run_async_from_sync` to be injected in the doctests to better integration with an
13+
already running loop, as all of our examples are also automated executed as doctests.
14+
15+
On real life code you should use standard `import asyncio; asyncio.run(main())`.
16+
"""
17+
18+
def __init__(self):
19+
self.run = run_async_from_sync
20+
21+
doctest_namespace["State"] = State
22+
doctest_namespace["StateMachine"] = StateMachine
23+
doctest_namespace["asyncio"] = ContribAsyncio()

docs/actions.md

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ outside world, and indeed they are the main reason why they exist at all.
55

66
The main point of introducing a state machine is for the
77
actions to be invoked at the right times, depending on the sequence of events
8-
and the state of the guards.
8+
and the state of the {ref}`conditions`.
99

1010
Actions are most commonly performed on entry or exit of a state, although
1111
it is possible to add them before/after a transition.
@@ -79,7 +79,7 @@ After 'go', on the 'final' state.
7979

8080

8181
```{seealso}
82-
All actions and {ref}`guards` support multiple method signatures. They follow the
82+
All actions and {ref}`conditions` support multiple method signatures. They follow the
8383
{ref}`dynamic-dispatch` method calling implemented on this library.
8484
```
8585

@@ -298,7 +298,7 @@ On loop
298298
In addition to {ref}`actions`, you can specify {ref}`validators and guards` that are checked before a transition is started. They are meant to stop a transition to occur.
299299

300300
```{seealso}
301-
See {ref}`guards` and {ref}`validators`.
301+
See {ref}`conditions` and {ref}`validators`.
302302
```
303303

304304

@@ -377,21 +377,20 @@ For {ref}`RTC model`, only the main event will get its value list, while the cha
377377

378378

379379
(dynamic-dispatch)=
380-
## Dynamic dispatch
380+
(dynamic dispatch)=
381+
## Dependency injection
381382

382-
{ref}`statemachine` implements a custom dispatch mechanism on all those available Actions and
383-
Guards. This means that you can declare an arbitrary number of `*args` and `**kwargs`, and the
384-
library will match your method signature of what's expected to receive with the provided arguments.
383+
{ref}`statemachine` implements a dependency injection mechanism on all available {ref}`Actions` and
384+
{ref}`Conditions` that automatically inspects and matches the expected callback params with those available by the library in conjunction with any values informed when calling an event using `*args` and `**kwargs`.
385385

386-
This means that if on your `on_enter_<state.id>()` or `on_<event>()` method, you need to know
387-
the `source` ({ref}`state`), or the `event` ({ref}`event`), or access a keyword
388-
argument passed with the trigger, just add this parameter to the method and It will be passed
389-
by the dispatch mechanics.
386+
The library ensures that your method signatures match the expected arguments.
387+
388+
For example, if you need to access the source (state), the event (event), or any keyword arguments passed with the trigger in any method, simply include these parameters in the method. They will be automatically passed by the dependency injection dispatch mechanics.
390389

391390
In other words, if you implement a method to handle an event and don't declare any parameter,
392391
you'll be fine, if you declare an expected parameter, you'll also be covered.
393392

394-
For your convenience, all these parameters are available for you on any Action or Guard:
393+
For your convenience, all these parameters are available for you on any callback:
395394

396395

397396
`*args`

docs/async.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Async
2+
3+
```{versionadded} 2.3.0
4+
Support for async code was added!
5+
```
6+
7+
The {ref}`StateMachine` has full async suport. You can write async {ref}`actions`, {ref}`guards` and {ref}`event` triggers.
8+
9+
Keeping the same external API do interact both on sync or async codebases.
10+
11+
```{note}
12+
All the handlers will run on the same thread they're called. So it's not recommended to mix sync with async code unless
13+
you know what you're doing.
14+
```
15+
16+
## Asynchronous Support
17+
18+
We support native coroutine using asyncio, enabling seamless integration with asynchronous code.
19+
There's no change on the public API of the library to work on async codebases.
20+
21+
One requirement is that when running on an async code, you must manually await for the {ref}`initial state activation` to be able to check the current state.
22+
23+
24+
```{seealso}
25+
See {ref}`sphx_glr_auto_examples_air_conditioner_machine.py` for an example of
26+
async code with a state machine.
27+
```
28+
29+
30+
```py
31+
>>> class AsyncStateMachine(StateMachine):
32+
... initial = State('Initial', initial=True)
33+
... final = State('Final', final=True)
34+
...
35+
... advance = initial.to(final)
36+
...
37+
... async def on_advance(self):
38+
... return 42
39+
40+
>>> async def run_sm():
41+
... sm = AsyncStateMachine()
42+
... result = await sm.advance()
43+
... print(f"Result is {result}")
44+
... print(sm.current_state)
45+
46+
>>> asyncio.run(run_sm())
47+
Result is 42
48+
Final
49+
50+
```
51+
52+
## Sync codebase with async handlers
53+
54+
The same state machine can be executed on a sync codebase, even if it contains async handlers. The handlers will be
55+
awaited on an `asyncio.get_event_loop()` if needed.
56+
57+
```py
58+
>>> sm = AsyncStateMachine()
59+
>>> result = sm.advance()
60+
>>> print(f"Result is {result}")
61+
Result is 42
62+
>>> print(sm.current_state)
63+
Final
64+
65+
```
66+
67+
68+
(initial state activation)=
69+
## Initial State Activation for Async Code
70+
71+
When working with asynchronous state machines from async code, users must manually [activate initial state](statemachine.StateMachine.activate_initial_state) to be able to check the current state. This change ensures proper state initialization and
72+
execution flow given that Python don't allow awaiting at class initalization time and the initial state activation
73+
may contain async callbacks that must be awaited.
74+
75+
```py
76+
>>> async def initialize_sm():
77+
... sm = AsyncStateMachine()
78+
... await sm.activate_initial_state()
79+
... return sm
80+
81+
>>> sm = asyncio.run(initialize_sm())
82+
>>> print(sm.current_state)
83+
Initial
84+
85+
```

docs/conf.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import sys
1616

1717
import sphinx_rtd_theme
18+
from sphinx_gallery import gen_gallery
1819

1920
# If extensions (or modules to document with autodoc) are in another
2021
# directory, add these directories to sys.path here. If the directory is
@@ -271,3 +272,11 @@
271272
"image_scrapers": (MachineScraper(project_root),),
272273
"reset_modules": [],
273274
}
275+
276+
277+
def dummy_write_computation_times(gallery_conf, target_dir, costs):
278+
"patch gen_gallery to disable write_computation_times"
279+
pass
280+
281+
282+
gen_gallery.write_computation_times = dummy_write_computation_times

docs/guards.md

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,32 @@
11
(validators-and-guards)=
2-
# Validators and guards
2+
(validators and guards)=
3+
# Conditions and Validators
34

4-
Validations and Guards are checked before a transition is started. They are meant to stop a
5+
Conditions and Validations are checked before a transition is started. They are meant to prevent or stop a
56
transition to occur.
67

7-
The main difference is that {ref}`validators` raise exceptions to stop the flow, and {ref}`guards`
8+
The main difference is that {ref}`validators` raise exceptions to stop the flow, and {ref}`conditions`
89
act like predicates that shall resolve to a ``boolean`` value.
910

1011
```{seealso}
1112
Please see {ref}`dynamic-dispatch` to know more about how this lib supports multiple signatures
1213
for all the available callbacks, being validators and guards or {ref}`actions`.
1314
```
1415

15-
## Guards
16+
(guards)=
17+
## Conditions
1618

17-
Also known as **Conditional transition**.
19+
This feature is also known as a **Conditional Transition**.
1820

19-
A guard is a condition that may be checked when a {ref}`statemachine` wants to handle
20-
an {ref}`event`. A guard is declared on the {ref}`transition`, and when that {ref}`transition`
21-
would trigger, then the guard (if any) is checked. If the guard is `True`
22-
then the transition does happen. If the guard is `False`, the transition
23-
is ignored.
21+
A conditional transition occurs only if specific conditions or criteria are met. In addition to checking if there is a transition handling the event in the current state, you can register callbacks that are evaluated based on other factors or inputs at runtime.
2422

25-
When {ref}`transitions` have guards, then it's possible to define two or more
26-
transitions for the same {ref}`event` from the same {ref}`state`. When the {ref}`event` happens, then
27-
the guarded transitions are checked, one by one, and the first transition
28-
whose guard is true will be used, and the others will be ignored.
23+
When a transition is conditional, it includes a condition (also known as a _guard_) that must be satisfied for the transition to take place. If the condition is not met, the transition does not occur, and the state machine remains in its current state or follows an alternative path.
2924

30-
A guard is generally a boolean function or boolean variable and must not have any side effects.
31-
Side effects are reserved for {ref}`actions`.
25+
This feature allows for multiple transitions on the same {ref}`event`, with each {ref}`transition` checked in the order they are declared. A condition acts like a predicate (a function that evaluates to true/false) and is checked when a {ref}`statemachine` handles an {ref}`event` with a transition from the current state bound to this event. The first transition that meets the conditions (if any) is executed. If none of the transitions meet the conditions, the state machine either raises an exception or does nothing (see the `allow_event_without_transition` parameter of {ref}`StateMachine`).
26+
27+
When {ref}`transitions` have guards, it is possible to define two or more transitions for the same {ref}`event` from the same {ref}`state`. When the {ref}`event` occurs, the guarded transitions are checked one by one, and the first transition whose guard is true will be executed, while the others will be ignored.
28+
29+
A condition is generally a boolean function, property, or attribute, and must not have any side effects. Side effects are reserved for {ref}`actions`.
3230

3331
There are two variations of Guard clauses available:
3432

@@ -44,6 +42,11 @@ unless
4442
* Single condition: `unless="condition"`
4543
* Multiple conditions: `unless=["condition1", "condition2"]`
4644

45+
```{seealso}
46+
See {ref}`sphx_glr_auto_examples_air_conditioner_machine.py` for an example of
47+
combining multiple transitions to the same event.
48+
```
49+
4750
```{hint}
4851
In Python, a boolean value is either `True` or `False`. However, there are also specific values that
4952
are considered "**falsy**" and will evaluate as `False` when used in a boolean context.

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ actions
1313
guards
1414
models
1515
observers
16+
async
1617
mixins
1718
integrations
1819
diagram

docs/releases/2.3.0.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# StateMachine 2.3.0
2+
3+
*Not released yet*
4+
5+
## What's new in 2.3.0
6+
7+
In this release, we conducted a significant update focusing on adding asynchronous support, and enhancing overall functionality. In fact, the approach we took was to go all the way down changing the internals of the library to be fully async, keeping only the current external API as a thin sync wrapper.
8+
9+
Here are the major changes and new features:
10+
11+
### Asynchronous Support in 2.3.0
12+
13+
This release introduces native coroutine support using asyncio, enabling seamless integration with asynchronous code.
14+
15+
Now you can send and await for events, and also write async {ref}`Actions`, {ref}`Conditions` and {ref}`Validators`.
16+
17+
18+
```{seealso}
19+
See {ref}`sphx_glr_auto_examples_air_conditioner_machine.py` for an example of
20+
async code with a state machine.
21+
```
22+
23+
24+
```py
25+
>>> class AsyncStateMachine(StateMachine):
26+
... initial = State('Initial', initial=True)
27+
... final = State('Final', final=True)
28+
...
29+
... advance = initial.to(final)
30+
31+
>>> async def run_sm():
32+
... sm = AsyncStateMachine()
33+
... await sm.advance()
34+
... print(sm.current_state)
35+
36+
>>> asyncio.run(run_sm())
37+
Final
38+
39+
```
40+
41+
42+
### Manual Initial State Activation for Async Code
43+
44+
When working with asynchronous state machines from async code, users must manually activate
45+
the initial state . This change ensures proper state initialization and execution flow given that
46+
Python don't allow awaiting at class initalization time and the initial state activation may contain
47+
async callbacks that must be awaited.
48+
49+
```py
50+
>>> async def initialize_sm():
51+
... sm = AsyncStateMachine()
52+
... await sm.activate_initial_state()
53+
... return sm
54+
55+
>>> sm = asyncio.run(initialize_sm())
56+
>>> print(sm.current_state)
57+
Initial
58+
59+
```

docs/releases/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Below are release notes through StateMachine and its patch releases.
1515
```{toctree}
1616
:maxdepth: 2
1717
18+
2.3.0
1819
2.2.0
1920
2.1.2
2021
2.1.1

poetry.lock

Lines changed: 19 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)