Skip to content

Commit f22bae0

Browse files
authored
feat: Improved Event class (#488)
1 parent 1275cd4 commit f22bae0

11 files changed

Lines changed: 508 additions & 86 deletions

File tree

docs/api.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@
6666
:members:
6767
```
6868

69+
## Event (class)
70+
71+
```{eval-rst}
72+
.. autoclass:: statemachine.event.Event
73+
:members:
74+
```
75+
6976
## EventData
7077

7178
```{eval-rst}

docs/transitions.md

Lines changed: 151 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ And these transitions are assigned to the {ref}`event` `cycle` defined at the cl
4747

4848
```{note}
4949
50-
In fact, before the full class body is evaluated, the assigments of transitions are instances of [](statemachine.transition_list.TransitionList). When the state machine is evaluated by our custom [metaclass](https://docs.python.org/3/reference/datamodel.html#metaclasses), these names will be transformed into a method that triggers an {ref}`Event`.
50+
In fact, before the full class body is evaluated, the assigments of transitions are instances of [](statemachine.transition_list.TransitionList). When the state machine is evaluated by our custom [metaclass](https://docs.python.org/3/reference/datamodel.html#metaclasses), these names will be transformed into {ref}`Event` instances.
5151
5252
```
5353

@@ -171,6 +171,153 @@ initiates a change in the state of the system.
171171

172172
In `python-statemachine`, an event is specified as an attribute of the state machine class declaration or directly on the {ref}`event` parameter on a {ref}`transition`.
173173

174+
175+
### Declaring events
176+
177+
The simplest way to declare an {ref}`event` is by assiging a transitions list to a name at the
178+
State machine class level. The name will be converted to an {ref}`Event (class)`:
179+
180+
```py
181+
>>> from statemachine import Event
182+
183+
>>> class SimpleSM(StateMachine):
184+
... initial = State(initial=True)
185+
... final = State()
186+
...
187+
... start = initial.to(final) # start is a name that will be converted to an `Event`
188+
189+
>>> isinstance(SimpleSM.start, Event)
190+
True
191+
>>> sm = SimpleSM()
192+
>>> sm.start() # call `start` event
193+
194+
```
195+
196+
```{versionadded} 2.6.7
197+
You can also explict declare an {ref}`Event` instance, this helps IDEs to know that the event is callable and also with transtation strings.
198+
```
199+
200+
To declare an explicit event you must also import the {ref}`Event (class)`:
201+
202+
```py
203+
>>> from statemachine import Event
204+
205+
>>> class SimpleSM(StateMachine):
206+
... initial = State(initial=True)
207+
... final = State()
208+
...
209+
... start = Event(
210+
... initial.to(final),
211+
... name="Start the state machine" # optional name, if not provided it will be derived from id
212+
... )
213+
214+
>>> SimpleSM.start.name
215+
'Start the state machine'
216+
217+
>>> sm = SimpleSM()
218+
>>> sm.start() # call `start` event
219+
220+
```
221+
222+
An {ref}`Event (class)` instance or an event id string can also be used as the `event` parameter of a {ref}`transition`. So you can mix these options as you need.
223+
224+
```py
225+
>>> from statemachine import State, StateMachine, Event
226+
227+
>>> class TrafficLightMachine(StateMachine):
228+
... "A traffic light machine"
229+
...
230+
... green = State(initial=True)
231+
... yellow = State()
232+
... red = State()
233+
...
234+
... slowdown = Event(name="Slowing down")
235+
...
236+
... cycle = Event(
237+
... green.to(yellow, event=slowdown)
238+
... | yellow.to(red, event=Event("stop", name="Please stop!"))
239+
... | red.to(green, event="go"),
240+
... name="Loop",
241+
... )
242+
...
243+
... def on_transition(self, event_data, event: Event):
244+
... # The `event` parameter can be declared as `str` or `Event`, since `Event` is a subclass of `str`
245+
... # Note also that in this example, we're using `on_transition` instead of `on_cycle`, as this
246+
... # binds the action to run for every transition instead of a specific event ID.
247+
... assert event_data.event == event
248+
... return (
249+
... f"Running {event.name} from {event_data.transition.source.id} to "
250+
... f"{event_data.transition.target.id}"
251+
... )
252+
253+
>>> # Event IDs
254+
>>> TrafficLightMachine.cycle.id
255+
'cycle'
256+
>>> TrafficLightMachine.slowdown.id
257+
'slowdown'
258+
>>> TrafficLightMachine.stop.id
259+
'stop'
260+
>>> TrafficLightMachine.go.id
261+
'go'
262+
263+
>>> # Event names
264+
>>> TrafficLightMachine.cycle.name
265+
'Loop'
266+
>>> TrafficLightMachine.slowdown.name
267+
'Slowing down'
268+
>>> TrafficLightMachine.stop.name
269+
'Please stop!'
270+
>>> TrafficLightMachine.go.name
271+
'go'
272+
273+
>>> sm = TrafficLightMachine()
274+
275+
>>> sm.cycle() # Your IDE is happy because it now knows that `cycle` is callable!
276+
'Running Loop from green to yellow'
277+
278+
>>> sm.send("cycle") # You can also use `send` in order to process dynamic event sources
279+
'Running Loop from yellow to red'
280+
281+
>>> sm.send("cycle")
282+
'Running Loop from red to green'
283+
284+
>>> sm.send("slowdown")
285+
'Running Slowing down from green to yellow'
286+
287+
>>> sm.send("stop")
288+
'Running Please stop! from yellow to red'
289+
290+
>>> sm.send("go")
291+
'Running go from red to green'
292+
293+
```
294+
295+
```{tip}
296+
Avoid mixing these options within the same project; instead, choose the one that best serves your use case. Declaring events as strings has been the standard approach since the library’s inception and can be considered syntactic sugar, as the state machine metaclass will convert all events to {ref}`Event (class)` instances under the hood.
297+
298+
```
299+
300+
```{note}
301+
In order to allow the seamless upgrade from using strings to `Event` instances, the {ref}`Event (class)` inherits from `str`.
302+
303+
Note that this is just an implementation detail and can change in the future.
304+
305+
>>> isinstance(TrafficLightMachine.cycle, str)
306+
True
307+
308+
```
309+
310+
311+
```{warning}
312+
An {ref}`Event` declared as string will have its `name` set equal to its `id`. This is for backward compatibility when migrating from previous versions.
313+
314+
In the next major release, `Event.name` will default to a capitalized version of `id` (i.e., `Event.id.replace("_", " ").capitalize()`).
315+
316+
Starting from version 2.3.7, use `Event.id` to check for event identifiers instead of `Event.name`.
317+
318+
```
319+
320+
174321
### Triggering events
175322

176323
Triggering an event on a state machine means invoking or sending a signal, initiating the
@@ -188,14 +335,13 @@ associated with the transition
188335
See {ref}`actions` and {ref}`validators and guards`.
189336
```
190337

191-
192338
You can invoke the event in an imperative syntax:
193339

194340
```py
195341
>>> machine = TrafficLightMachine()
196342

197343
>>> machine.cycle()
198-
Running cycle from green to yellow
344+
'Running Loop from green to yellow'
199345

200346
>>> machine.current_state.id
201347
'yellow'
@@ -206,25 +352,13 @@ Or in an event-oriented style, events are `send`:
206352

207353
```py
208354
>>> machine.send("cycle")
209-
Running cycle from yellow to red
355+
'Running Loop from yellow to red'
210356

211357
>>> machine.current_state.id
212358
'red'
213359

214360
```
215361

216-
You can also pass positional and keyword arguments, that will be propagated
217-
to the actions and guards. In this example, the :code:`TrafficLightMachine` implements
218-
an action that `echoes` back the parameters informed.
219-
220-
```{literalinclude} ../tests/examples/traffic_light_machine.py
221-
:language: python
222-
:linenos:
223-
:emphasize-lines: 10
224-
:lines: 12-21
225-
```
226-
227-
228362
This action is executed before the transition associated with `cycle` event is activated.
229363
You can raise an exception at this point to stop a transition from completing.
230364

@@ -233,7 +367,7 @@ You can raise an exception at this point to stop a transition from completing.
233367
'red'
234368

235369
>>> machine.cycle()
236-
Running cycle from red to green
370+
'Running Loop from red to green'
237371

238372
>>> machine.current_state.id
239373
'green'

statemachine/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
from .event import Event
12
from .state import State
23
from .statemachine import StateMachine
34

45
__author__ = """Fernando Macedo"""
56
__email__ = "fgmacedo@gmail.com"
67
__version__ = "2.3.6"
78

8-
__all__ = ["StateMachine", "State"]
9+
__all__ = ["StateMachine", "State", "Event"]

statemachine/dispatcher.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from .callbacks import SPECS_ALL
1212
from .callbacks import SpecReference
13+
from .event import Event
1314
from .signature import SignatureAdapter
1415

1516
if TYPE_CHECKING:
@@ -121,7 +122,7 @@ def search_name(self, name) -> Generator["Callable", None, None]:
121122
yield attr_method(name, config.obj, config.resolver_id)
122123
continue
123124

124-
if getattr(func, "_is_sm_event", False):
125+
if isinstance(func, Event):
125126
yield event_method(name, func, config.resolver_id)
126127
continue
127128

statemachine/event.py

Lines changed: 79 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
from inspect import isawaitable
22
from typing import TYPE_CHECKING
3+
from typing import List
4+
from uuid import uuid4
35

46
from statemachine.utils import run_async_from_sync
57

68
from .event_data import TriggerData
79

810
if TYPE_CHECKING:
911
from .statemachine import StateMachine
12+
from .transition_list import TransitionList
13+
1014

1115
_event_data_kwargs = {
1216
"event_data",
@@ -20,46 +24,94 @@
2024
}
2125

2226

23-
class Event:
24-
def __init__(self, name: str):
25-
self.name: str = name
27+
class Event(str):
28+
id: str
29+
"""The event identifier."""
30+
31+
name: str
32+
"""The event name."""
33+
34+
_sm: "StateMachine | None" = None
35+
"""The state machine instance."""
36+
37+
_transitions: "TransitionList | None" = None
38+
_has_real_id = False
39+
40+
def __new__(
41+
cls,
42+
transitions: "str | TransitionList | None" = None,
43+
id: "str | None" = None,
44+
name: "str | None" = None,
45+
_sm: "StateMachine | None" = None,
46+
):
47+
if isinstance(transitions, str):
48+
id = transitions
49+
transitions = None
50+
51+
_has_real_id = id is not None
52+
id = str(id) if _has_real_id else f"__event__{uuid4().hex}"
53+
54+
instance = super().__new__(cls, id)
55+
instance.id = id
56+
if name:
57+
instance.name = name
58+
elif _has_real_id:
59+
instance.name = str(id).replace("_", " ").capitalize()
60+
else:
61+
instance.name = ""
62+
if transitions:
63+
instance._transitions = transitions
64+
instance._has_real_id = _has_real_id
65+
instance._sm = _sm
66+
return instance
2667

2768
def __repr__(self):
28-
return f"{type(self).__name__}({self.name!r})"
69+
return f"{type(self).__name__}({self.id!r})"
70+
71+
def is_same_event(self, *_args, event: "str | None" = None, **_kwargs) -> bool:
72+
return self == event
73+
74+
def __get__(self, instance, owner):
75+
"""By implementing this method `Event` can be used as a property descriptor
76+
77+
When attached to a SM class, if the user tries to get the `Event` instance,
78+
we intercept here and return a `BoundEvent` instance, so the user can call
79+
it as a method with the correct SM instance.
80+
81+
"""
82+
if instance is None:
83+
return self
84+
return BoundEvent(id=self.id, name=self.name, _sm=instance)
85+
86+
def __call__(self, *args, **kwargs):
87+
"""Send this event to the current state machine."""
88+
# The `__call__` is declared here to help IDEs knowing that an `Event`
89+
# can be called as a method. But it is not meant to be called without
90+
# an SM instance. Such SM instance is provided by `__get__` method when
91+
# used as a property descriptor.
2992

30-
def trigger(self, machine: "StateMachine", *args, **kwargs):
93+
machine = self._sm
3194
kwargs = {k: v for k, v in kwargs.items() if k not in _event_data_kwargs}
3295
trigger_data = TriggerData(
3396
machine=machine,
34-
event=self.name,
97+
event=self,
3598
args=args,
3699
kwargs=kwargs,
37100
)
38101
machine._put_nonblocking(trigger_data)
39-
return machine._processing_loop()
40-
41-
42-
def trigger_event_factory(event_instance: Event):
43-
"""Build a method that sends specific `event` to the machine"""
44-
45-
def trigger_event(self, *args, **kwargs):
46-
result = event_instance.trigger(self, *args, **kwargs)
102+
result = machine._processing_loop()
47103
if not isawaitable(result):
48104
return result
49105
return run_async_from_sync(result)
50106

51-
trigger_event.name = event_instance.name # type: ignore[attr-defined]
52-
trigger_event.identifier = event_instance.name # type: ignore[attr-defined]
53-
trigger_event._is_sm_event = True # type: ignore[attr-defined]
54-
return trigger_event
55-
56-
57-
def same_event_cond_builder(expected_event: str):
58-
"""
59-
Builds a condition method that evaluates to ``True`` when the expected event is received.
60-
"""
107+
def split( # type: ignore[override]
108+
self, sep: "str | None" = None, maxsplit: int = -1
109+
) -> List["Event"]:
110+
result = super().split(sep, maxsplit)
111+
if len(result) == 1:
112+
return [self]
113+
return [Event(event) for event in result]
61114

62-
def cond(*args, event: "str | None" = None, **kwargs) -> bool:
63-
return event == expected_event
64115

65-
return cond
116+
class BoundEvent(Event):
117+
pass

0 commit comments

Comments
 (0)