Skip to content

Commit f43cfc1

Browse files
authored
chore: Add internal engine abstraction (strategy pattern) for sync vs hybrid sync/async (#456)
* chore: Add internal Sync and Async engines
1 parent 6187213 commit f43cfc1

30 files changed

Lines changed: 819 additions & 353 deletions

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
```

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+
```

docs/releases/2.3.0.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,18 @@ async code with a state machine.
3232
... final = State('Final', final=True)
3333
...
3434
... advance = initial.to(final)
35+
...
36+
... async def on_advance(self):
37+
... return 42
38+
39+
3540

3641
>>> async def run_sm():
3742
... sm = AsyncStateMachine()
38-
... await sm.advance()
39-
... print(sm.current_state)
43+
... res = await sm.advance()
44+
... return (42, sm.current_state.name)
4045

4146
>>> asyncio.run(run_sm())
42-
Final
47+
(42, 'Final')
4348

4449
```

docs/releases/2.3.2.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,29 @@
77
Observers are now rebranded to {ref}`listeners`. With expanted support for adding listeners when
88
instantiating a state machine. This allows covering more use cases.
99

10+
### Improved async support
11+
12+
Since version 2.3.0, we have added async support. However, we encountered use cases, such as the [async safety on Django ORM](https://docs.djangoproject.com/en/5.0/topics/async/#async-safety), which expects no running event loop and blocks if it detects one on the current thread.
13+
14+
To address this issue, we developed a solution that maintains a unified API for both synchronous and asynchronous operations while effectively handling these scenarios.
15+
16+
This is achieved through a new concept called "engine," an internal strategy pattern abstraction that manages transitions and callbacks.
17+
18+
There are two engines:
19+
20+
SyncEngine
21+
: Activated if there are no async callbacks. All code runs exactly as it did before version 2.3.0.
22+
23+
AsyncEngine
24+
: 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.
25+
26+
These engines are internal and are activated automatically by inspecting the registered callbacks in the following scenarios:
27+
28+
```{seealso}
29+
See {ref}`async` for more details.
30+
```
31+
32+
1033
### Listeners at class initialization
1134

1235
Listeners are a way to generically add behavior to a state machine without changing its internal implementation.

statemachine/callbacks.py

Lines changed: 53 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
import asyncio
12
from bisect import insort
3+
from collections import Counter
24
from collections import defaultdict
35
from collections import deque
46
from enum import IntEnum
57
from enum import auto
8+
from inspect import isawaitable
9+
from inspect import iscoroutinefunction
610
from typing import Callable
711
from typing import Dict
812
from typing import Generator
@@ -42,7 +46,7 @@ def build_key(self, specs: "CallbackSpecList") -> str:
4246
return f"{self.name}@{id(specs)}"
4347

4448

45-
async def allways_true(*args, **kwargs):
49+
def allways_true(*args, **kwargs):
4650
return True
4751

4852

@@ -86,7 +90,10 @@ def __repr__(self):
8690
return f"{type(self).__name__}({self.func!r}, is_convention={self.is_convention!r})"
8791

8892
def __str__(self):
89-
return getattr(self.func, "__name__", self.func)
93+
name = getattr(self.func, "__name__", self.func)
94+
if self.expected_value is False:
95+
name = f"!{name}"
96+
return name
9097

9198
def __eq__(self, other):
9299
return self.func == other.func and self.group == other.group
@@ -99,9 +106,6 @@ def _update_func(self, func: Callable, attr_name: str):
99106
self.reference = SpecReference.CALLABLE
100107
self.attr_name = attr_name
101108

102-
def _wrap_callable(self, func, _expected_value):
103-
return func
104-
105109
def build(self, resolver) -> Generator["CallbackWrapper", None, None]:
106110
"""
107111
Resolves the `func` into a usable callable.
@@ -111,53 +115,15 @@ def build(self, resolver) -> Generator["CallbackWrapper", None, None]:
111115
can receive arbitrary parameters like `*args, **kwargs`.
112116
"""
113117
for callback in resolver.search(self):
114-
condition = (
115-
next(resolver.search(CallbackSpec(self.cond, CallbackGroup.COND)))
116-
if self.cond is not None
117-
else allways_true
118-
)
118+
condition = self.cond if self.cond is not None else allways_true
119119
yield CallbackWrapper(
120-
callback=self._wrap_callable(callback, self.expected_value),
120+
callback=callback,
121121
condition=condition,
122122
meta=self,
123123
unique_key=callback.unique_key,
124124
)
125125

126126

127-
class BoolCallbackSpec(CallbackSpec):
128-
"""A thin wrapper that register info about actions and guards.
129-
130-
At first, `func` can be a string or a callable, and even if it's already
131-
a callable, his signature can mismatch.
132-
133-
After instantiation, `.setup(resolver)` must be called before any real
134-
call is performed, to allow the proper callback resolution.
135-
"""
136-
137-
def __init__(
138-
self,
139-
func,
140-
group: CallbackGroup,
141-
is_convention=False,
142-
cond=None,
143-
priority: CallbackPriority = CallbackPriority.NAMING,
144-
expected_value=True,
145-
):
146-
super().__init__(
147-
func, group, is_convention, cond, priority=priority, expected_value=expected_value
148-
)
149-
150-
def __str__(self):
151-
name = super().__str__()
152-
return name if self.expected_value else f"!{name}"
153-
154-
def _wrap_callable(self, func, expected_value):
155-
async def bool_wrapper(*args, **kwargs):
156-
return bool(await func(*args, **kwargs)) == expected_value
157-
158-
return bool_wrapper
159-
160-
161127
class SpecListGrouper:
162128
def __init__(
163129
self, list: "CallbackSpecList", group: CallbackGroup, factory=CallbackSpec
@@ -275,9 +241,11 @@ def __init__(
275241
unique_key: str,
276242
) -> None:
277243
self._callback = callback
244+
self._iscoro = iscoroutinefunction(callback)
278245
self.condition = condition
279246
self.meta = meta
280247
self.unique_key = unique_key
248+
self.expected_value = self.meta.expected_value
281249

282250
def __repr__(self):
283251
return f"{type(self).__name__}({self.unique_key})"
@@ -289,7 +257,19 @@ def __lt__(self, other):
289257
return self.meta.priority < other.meta.priority
290258

291259
async def __call__(self, *args, **kwargs):
292-
return await self._callback(*args, **kwargs)
260+
value = self._callback(*args, **kwargs)
261+
if isawaitable(value):
262+
value = await value
263+
264+
if self.expected_value is not None:
265+
return bool(value) == self.expected_value
266+
return value
267+
268+
def call(self, *args, **kwargs):
269+
value = self._callback(*args, **kwargs)
270+
if self.expected_value is not None:
271+
return bool(value) == self.expected_value
272+
return value
293273

294274

295275
class CallbacksExecutor:
@@ -322,23 +302,40 @@ def add(self, items: Iterable[CallbackSpec], resolver: Callable):
322302
self._add(item, resolver)
323303
return self
324304

325-
async def call(self, *args, **kwargs):
305+
async def async_call(self, *args, **kwargs):
306+
return await asyncio.gather(
307+
*(
308+
callback(*args, **kwargs)
309+
for callback in self
310+
if callback.condition(*args, **kwargs)
311+
)
312+
)
313+
314+
async def async_all(self, *args, **kwargs):
315+
coros = [condition(*args, **kwargs) for condition in self]
316+
for coro in asyncio.as_completed(coros):
317+
if not await coro:
318+
return False
319+
return True
320+
321+
def call(self, *args, **kwargs):
326322
return [
327-
await callback(*args, **kwargs)
323+
callback.call(*args, **kwargs)
328324
for callback in self
329-
if await callback.condition(*args, **kwargs)
325+
if callback.condition(*args, **kwargs)
330326
]
331327

332-
async def all(self, *args, **kwargs):
328+
def all(self, *args, **kwargs):
333329
for condition in self:
334-
if not await condition(*args, **kwargs):
330+
if not condition.call(*args, **kwargs):
335331
return False
336332
return True
337333

338334

339335
class CallbacksRegistry:
340336
def __init__(self) -> None:
341337
self._registry: Dict[str, CallbacksExecutor] = defaultdict(CallbacksExecutor)
338+
self._method_types: Counter = Counter()
342339

343340
def clear(self):
344341
self._registry.clear()
@@ -358,3 +355,8 @@ def check(self, specs: CallbackSpecList):
358355
raise AttrNotFound(
359356
_("Did not found name '{}' from model or statemachine").format(meta.func)
360357
)
358+
359+
def async_or_sync(self):
360+
self._method_types.update(
361+
callback._iscoro for executor in self._registry.values() for callback in executor
362+
)

0 commit comments

Comments
 (0)