Skip to content

Commit

Permalink
feat: Improved Event class (#488)
Browse files Browse the repository at this point in the history
  • Loading branch information
fgmacedo authored Nov 4, 2024
1 parent 1275cd4 commit f22bae0
Show file tree
Hide file tree
Showing 11 changed files with 508 additions and 86 deletions.
7 changes: 7 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@
:members:
```

## Event (class)

```{eval-rst}
.. autoclass:: statemachine.event.Event
:members:
```

## EventData

```{eval-rst}
Expand Down
168 changes: 151 additions & 17 deletions docs/transitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ And these transitions are assigned to the {ref}`event` `cycle` defined at the cl

```{note}
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`.
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.
```

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

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


### Declaring events

The simplest way to declare an {ref}`event` is by assiging a transitions list to a name at the
State machine class level. The name will be converted to an {ref}`Event (class)`:

```py
>>> from statemachine import Event

>>> class SimpleSM(StateMachine):
... initial = State(initial=True)
... final = State()
...
... start = initial.to(final) # start is a name that will be converted to an `Event`

>>> isinstance(SimpleSM.start, Event)
True
>>> sm = SimpleSM()
>>> sm.start() # call `start` event

```

```{versionadded} 2.6.7
You can also explict declare an {ref}`Event` instance, this helps IDEs to know that the event is callable and also with transtation strings.
```

To declare an explicit event you must also import the {ref}`Event (class)`:

```py
>>> from statemachine import Event

>>> class SimpleSM(StateMachine):
... initial = State(initial=True)
... final = State()
...
... start = Event(
... initial.to(final),
... name="Start the state machine" # optional name, if not provided it will be derived from id
... )

>>> SimpleSM.start.name
'Start the state machine'

>>> sm = SimpleSM()
>>> sm.start() # call `start` event

```

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.

```py
>>> from statemachine import State, StateMachine, Event

>>> class TrafficLightMachine(StateMachine):
... "A traffic light machine"
...
... green = State(initial=True)
... yellow = State()
... red = State()
...
... slowdown = Event(name="Slowing down")
...
... cycle = Event(
... green.to(yellow, event=slowdown)
... | yellow.to(red, event=Event("stop", name="Please stop!"))
... | red.to(green, event="go"),
... name="Loop",
... )
...
... def on_transition(self, event_data, event: Event):
... # The `event` parameter can be declared as `str` or `Event`, since `Event` is a subclass of `str`
... # Note also that in this example, we're using `on_transition` instead of `on_cycle`, as this
... # binds the action to run for every transition instead of a specific event ID.
... assert event_data.event == event
... return (
... f"Running {event.name} from {event_data.transition.source.id} to "
... f"{event_data.transition.target.id}"
... )

>>> # Event IDs
>>> TrafficLightMachine.cycle.id
'cycle'
>>> TrafficLightMachine.slowdown.id
'slowdown'
>>> TrafficLightMachine.stop.id
'stop'
>>> TrafficLightMachine.go.id
'go'

>>> # Event names
>>> TrafficLightMachine.cycle.name
'Loop'
>>> TrafficLightMachine.slowdown.name
'Slowing down'
>>> TrafficLightMachine.stop.name
'Please stop!'
>>> TrafficLightMachine.go.name
'go'

>>> sm = TrafficLightMachine()

>>> sm.cycle() # Your IDE is happy because it now knows that `cycle` is callable!
'Running Loop from green to yellow'

>>> sm.send("cycle") # You can also use `send` in order to process dynamic event sources
'Running Loop from yellow to red'

>>> sm.send("cycle")
'Running Loop from red to green'

>>> sm.send("slowdown")
'Running Slowing down from green to yellow'

>>> sm.send("stop")
'Running Please stop! from yellow to red'

>>> sm.send("go")
'Running go from red to green'

```

```{tip}
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.

```

```{note}
In order to allow the seamless upgrade from using strings to `Event` instances, the {ref}`Event (class)` inherits from `str`.

Note that this is just an implementation detail and can change in the future.

>>> isinstance(TrafficLightMachine.cycle, str)
True

```


```{warning}
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.

In the next major release, `Event.name` will default to a capitalized version of `id` (i.e., `Event.id.replace("_", " ").capitalize()`).

Starting from version 2.3.7, use `Event.id` to check for event identifiers instead of `Event.name`.

```


### Triggering events

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


You can invoke the event in an imperative syntax:

```py
>>> machine = TrafficLightMachine()

>>> machine.cycle()
Running cycle from green to yellow
'Running Loop from green to yellow'

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

```py
>>> machine.send("cycle")
Running cycle from yellow to red
'Running Loop from yellow to red'

>>> machine.current_state.id
'red'

```

You can also pass positional and keyword arguments, that will be propagated
to the actions and guards. In this example, the :code:`TrafficLightMachine` implements
an action that `echoes` back the parameters informed.

```{literalinclude} ../tests/examples/traffic_light_machine.py
:language: python
:linenos:
:emphasize-lines: 10
:lines: 12-21
```


This action is executed before the transition associated with `cycle` event is activated.
You can raise an exception at this point to stop a transition from completing.

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

>>> machine.cycle()
Running cycle from red to green
'Running Loop from red to green'

>>> machine.current_state.id
'green'
Expand Down
3 changes: 2 additions & 1 deletion statemachine/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from .event import Event
from .state import State
from .statemachine import StateMachine

__author__ = """Fernando Macedo"""
__email__ = "[email protected]"
__version__ = "2.3.6"

__all__ = ["StateMachine", "State"]
__all__ = ["StateMachine", "State", "Event"]
3 changes: 2 additions & 1 deletion statemachine/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from .callbacks import SPECS_ALL
from .callbacks import SpecReference
from .event import Event
from .signature import SignatureAdapter

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

if getattr(func, "_is_sm_event", False):
if isinstance(func, Event):
yield event_method(name, func, config.resolver_id)
continue

Expand Down
106 changes: 79 additions & 27 deletions statemachine/event.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
from inspect import isawaitable
from typing import TYPE_CHECKING
from typing import List
from uuid import uuid4

from statemachine.utils import run_async_from_sync

from .event_data import TriggerData

if TYPE_CHECKING:
from .statemachine import StateMachine
from .transition_list import TransitionList


_event_data_kwargs = {
"event_data",
Expand All @@ -20,46 +24,94 @@
}


class Event:
def __init__(self, name: str):
self.name: str = name
class Event(str):
id: str
"""The event identifier."""

name: str
"""The event name."""

_sm: "StateMachine | None" = None
"""The state machine instance."""

_transitions: "TransitionList | None" = None
_has_real_id = False

def __new__(
cls,
transitions: "str | TransitionList | None" = None,
id: "str | None" = None,
name: "str | None" = None,
_sm: "StateMachine | None" = None,
):
if isinstance(transitions, str):
id = transitions
transitions = None

_has_real_id = id is not None
id = str(id) if _has_real_id else f"__event__{uuid4().hex}"

instance = super().__new__(cls, id)
instance.id = id
if name:
instance.name = name
elif _has_real_id:
instance.name = str(id).replace("_", " ").capitalize()
else:
instance.name = ""
if transitions:
instance._transitions = transitions
instance._has_real_id = _has_real_id
instance._sm = _sm
return instance

def __repr__(self):
return f"{type(self).__name__}({self.name!r})"
return f"{type(self).__name__}({self.id!r})"

def is_same_event(self, *_args, event: "str | None" = None, **_kwargs) -> bool:
return self == event

def __get__(self, instance, owner):
"""By implementing this method `Event` can be used as a property descriptor
When attached to a SM class, if the user tries to get the `Event` instance,
we intercept here and return a `BoundEvent` instance, so the user can call
it as a method with the correct SM instance.
"""
if instance is None:
return self
return BoundEvent(id=self.id, name=self.name, _sm=instance)

def __call__(self, *args, **kwargs):
"""Send this event to the current state machine."""
# The `__call__` is declared here to help IDEs knowing that an `Event`
# can be called as a method. But it is not meant to be called without
# an SM instance. Such SM instance is provided by `__get__` method when
# used as a property descriptor.

def trigger(self, machine: "StateMachine", *args, **kwargs):
machine = self._sm
kwargs = {k: v for k, v in kwargs.items() if k not in _event_data_kwargs}
trigger_data = TriggerData(
machine=machine,
event=self.name,
event=self,
args=args,
kwargs=kwargs,
)
machine._put_nonblocking(trigger_data)
return machine._processing_loop()


def trigger_event_factory(event_instance: Event):
"""Build a method that sends specific `event` to the machine"""

def trigger_event(self, *args, **kwargs):
result = event_instance.trigger(self, *args, **kwargs)
result = machine._processing_loop()
if not isawaitable(result):
return result
return run_async_from_sync(result)

trigger_event.name = event_instance.name # type: ignore[attr-defined]
trigger_event.identifier = event_instance.name # type: ignore[attr-defined]
trigger_event._is_sm_event = True # type: ignore[attr-defined]
return trigger_event


def same_event_cond_builder(expected_event: str):
"""
Builds a condition method that evaluates to ``True`` when the expected event is received.
"""
def split( # type: ignore[override]
self, sep: "str | None" = None, maxsplit: int = -1
) -> List["Event"]:
result = super().split(sep, maxsplit)
if len(result) == 1:
return [self]
return [Event(event) for event in result]

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

return cond
class BoundEvent(Event):
pass
Loading

0 comments on commit f22bae0

Please sign in to comment.