Skip to content

Commit e48bde7

Browse files
committed
Merge branch 'release/2.3.2'
2 parents d4f5d80 + 59edeee commit e48bde7

73 files changed

Lines changed: 2180 additions & 884 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ repos:
2121
hooks:
2222
- id: mypy
2323
name: Mypy
24-
entry: poetry run mypy statemachine/ tests/
24+
entry: poetry run mypy --namespace-packages --explicit-package-bases statemachine/ tests/
2525
types: [python]
2626
language: system
2727
pass_filenames: false

README.md

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ Define your state machine:
7777
... | red.to(green)
7878
... )
7979
...
80-
... async def before_cycle(self, event: str, source: State, target: State, message: str = ""):
80+
... def before_cycle(self, event: str, source: State, target: State, message: str = ""):
8181
... message = ". " + message if message else ""
8282
... return f"Running {event} from {source.id} to {target.id}{message}"
8383
...
@@ -120,26 +120,6 @@ Then start sending events to your new state machine:
120120

121121
```
122122

123-
You can use the exactly same state machine from an async codebase:
124-
125-
126-
```py
127-
>>> async def run_sm():
128-
... asm = TrafficLightMachine()
129-
... results = []
130-
... for _i in range(4):
131-
... result = await asm.send("cycle")
132-
... results.append(result)
133-
... return results
134-
135-
>>> asyncio.run(run_sm())
136-
Don't move.
137-
Go ahead!
138-
['Running cycle from green to yellow', 'Running cycle from yellow to red', ...
139-
140-
```
141-
142-
143123
**That's it.** This is all an external object needs to know about your state machine: How to send events.
144124
Ideally, all states, transitions, and actions should be kept internally and not checked externally to avoid unnecessary coupling.
145125

@@ -227,7 +207,7 @@ callback method.
227207
Note how `before_cycle` was declared:
228208

229209
```py
230-
async def before_cycle(self, event: str, source: State, target: State, message: str = ""):
210+
def before_cycle(self, event: str, source: State, target: State, message: str = ""):
231211
message = ". " + message if message else ""
232212
return f"Running {event} from {source.id} to {target.id}{message}"
233213
```

conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import sys
2+
13
import pytest
24

35

@@ -21,3 +23,11 @@ def __init__(self):
2123
doctest_namespace["State"] = State
2224
doctest_namespace["StateMachine"] = StateMachine
2325
doctest_namespace["asyncio"] = ContribAsyncio()
26+
27+
28+
def pytest_ignore_collect(collection_path, path, config):
29+
if sys.version_info >= (3, 10): # noqa: UP036
30+
return None
31+
32+
if "django_project" in str(path):
33+
return True

docs/async.md

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,61 @@
44
Support for async code was added!
55
```
66

7-
The {ref}`StateMachine` has full async suport. You can write async {ref}`actions`, {ref}`guards` and {ref}`event` triggers.
7+
The {ref}`StateMachine` fully supports asynchronous code. You can write async {ref}`actions`, {ref}`guards`, and {ref}`event` 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:
12+
13+
SyncEngine
14+
: Activated if there are no async callbacks. All code runs exactly as it did before version 2.3.0.
15+
16+
AsyncEngine
17+
: 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.
18+
19+
These engines are internal and are activated automatically by inspecting the registered callbacks in the following scenarios:
20+
21+
22+
```{list-table} Sync vs async engines
23+
:widths: 15 10 25 10 10
24+
:header-rows: 1
25+
26+
* - Outer scope
27+
- Async callbacks?
28+
- Engine
29+
- Creates internal loop
30+
- Reuses external loop
31+
* - Sync
32+
- No
33+
- Sync
34+
- No
35+
- No
36+
* - Sync
37+
- Yes
38+
- Async
39+
- Yes
40+
- No
41+
* - Async
42+
- No
43+
- Sync
44+
- No
45+
- No
46+
* - Async
47+
- Yes
48+
- Async
49+
- No
50+
- Yes
51+
52+
```
853

9-
Keeping the same external API do interact both on sync or async codebases.
1054

1155
```{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.
56+
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.
1457
```
1558

1659
## Asynchronous Support
1760

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.
61+
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.
2262

2363

2464
```{seealso}
@@ -49,10 +89,10 @@ Final
4989

5090
```
5191

52-
## Sync codebase with async handlers
92+
## Sync codebase with async callbacks
93+
94+
The same state machine can be executed in a synchronous codebase, even if it contains async callbacks. The callbacks will be awaited using `asyncio.get_event_loop()` if needed.
5395

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.
5696

5797
```py
5898
>>> sm = AsyncStateMachine()
@@ -68,9 +108,12 @@ Final
68108
(initial state activation)=
69109
## Initial State Activation for Async Code
70110

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.
111+
112+
If you perform checks against the `current_state`, like a loop `while sm.current_state.is_final:`, then on async code you must manually
113+
await for the [activate initial state](statemachine.StateMachine.activate_initial_state) to be able to check the current state.
114+
115+
If you don't do any check for current state externally, just ignore this as the initial state is activated automatically before the first event trigger is handled.
116+
74117

75118
```py
76119
>>> async def initialize_sm():
@@ -83,3 +126,7 @@ may contain async callbacks that must be awaited.
83126
Initial
84127

85128
```
129+
130+
```{hint}
131+
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.
132+
```
-3.02 KB
Loading

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ transitions
1212
actions
1313
guards
1414
models
15-
observers
15+
listeners
1616
async
1717
mixins
1818
integrations

docs/listeners.md

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
2+
(observers)=
3+
# Listeners
4+
5+
Listeners are a way to generically add behavior to a state machine without
6+
changing its internal implementation.
7+
8+
One possible use case is to add a listener that prints a log message when the SM runs a
9+
transition or enters a new state.
10+
11+
Giving the {ref}`sphx_glr_auto_examples_traffic_light_machine.py` as example:
12+
13+
14+
```py
15+
>>> from tests.examples.traffic_light_machine import TrafficLightMachine
16+
17+
>>> class LogListener(object):
18+
... def __init__(self, name):
19+
... self.name = name
20+
...
21+
... def after_transition(self, event, source, target):
22+
... print(f"{self.name} after: {source.id}--({event})-->{target.id}")
23+
...
24+
... def on_enter_state(self, target, event):
25+
... print(f"{self.name} enter: {target.id} from {event}")
26+
27+
28+
>>> sm = TrafficLightMachine(listeners=[LogListener("Paulista Avenue")])
29+
Paulista Avenue enter: green from __initial__
30+
31+
>>> sm.cycle()
32+
Paulista Avenue enter: yellow from cycle
33+
Paulista Avenue after: green--(cycle)-->yellow
34+
'Running cycle from green to yellow'
35+
36+
```
37+
38+
## Adding listeners to an instance
39+
40+
Attach listeners to an already running state machine instance using `add_listener`.
41+
42+
Exploring our example, imagine that you can implement the LED panel as a listener, that
43+
reacts to state changes and turn on/off automatically.
44+
45+
46+
``` py
47+
>>> class LedPanel:
48+
...
49+
... def __init__(self, color: str):
50+
... self.color = color
51+
...
52+
... def on_enter_state(self, target: State):
53+
... if target.id == self.color:
54+
... print(f"{self.color} turning on")
55+
...
56+
... def on_exit_state(self, source: State):
57+
... if source.id == self.color:
58+
... print(f"{self.color} turning off")
59+
60+
```
61+
62+
Adding a listener for each traffic light indicator
63+
64+
```
65+
>>> sm.add_listener(LedPanel("green"), LedPanel("yellow"), LedPanel("red")) # doctest: +ELLIPSIS
66+
TrafficLightMachine...
67+
68+
```
69+
70+
Now each "LED panel" reacts to changes in state from the state machine:
71+
72+
```py
73+
>>> sm.cycle()
74+
yellow turning off
75+
Paulista Avenue enter: red from cycle
76+
red turning on
77+
Don't move.
78+
Paulista Avenue after: yellow--(cycle)-->red
79+
'Running cycle from yellow to red'
80+
81+
>>> sm.cycle()
82+
red turning off
83+
Go ahead!
84+
Paulista Avenue enter: green from cycle
85+
green turning on
86+
Paulista Avenue after: red--(cycle)-->green
87+
'Running cycle from red to green'
88+
89+
```
90+
91+
92+
```{hint}
93+
The `StateMachine` itself is registered as a listener, so by using `listeners` an
94+
external object can have the same level of functionalities provided to the built-in class.
95+
```
96+
97+
```{tip}
98+
{ref}`domain models` are also registered as a listener.
99+
```
100+
101+
102+
```{seealso}
103+
See {ref}`actions`, {ref}`validators and guards` for a list of possible callbacks.
104+
105+
And also {ref}`dynamic-dispatch` to know more about how the lib calls methods to match
106+
their signature.
107+
```

docs/mixins.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class.
4444
... state_machine_name = '__main__.CampaignMachineWithKeys'
4545
... state_machine_attr = 'sm'
4646
... state_field_name = 'workflow_step'
47+
... bind_events_as_methods = True
4748
...
4849
... workflow_step = 1
4950
...
@@ -65,7 +66,11 @@ True
6566
>>> model.sm.current_state == model.sm.draft
6667
True
6768

68-
>>> model.sm.cancel()
69+
>>> model.produce() # `bind_events_as_methods = True` adds triggers to events in the mixin instance
70+
>>> model.workflow_step
71+
2
72+
73+
>>> model.sm.cancel() # You can still call the SM directly
6974

7075
>>> model.workflow_step
7176
4

docs/models.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ domain object to hold attributes and methods to be used on the `StateMachine` de
2020
```
2121

2222
```{hint}
23-
Domain models are registered as {ref}`observers`, so you can have the same level of functionalities
23+
Domain models are registered as {ref}`listeners`, so you can have the same level of functionalities
2424
provided to the built-in {ref}`StateMachine`, such as implementing all {ref}`actions` and
2525
{ref}`guards` on your domain model and keeping only the definition of {ref}`states` and
2626
{ref}`transitions` on the {ref}`StateMachine`.

docs/observers.md

Lines changed: 0 additions & 54 deletions
This file was deleted.

0 commit comments

Comments
 (0)