Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -307,3 +307,6 @@ True
>>> control.completed.is_active
True


There's a lot more to cover, please take a look at our docs:
https://python-statemachine.readthedocs.io.
29 changes: 21 additions & 8 deletions docs/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,21 +332,34 @@ you'll be fine, if you declare an expected parameter, you'll also be covered.

For your convenience, all these parameters are available for you on any Action or Guard:

- `*args`: All positional arguments provided on the {ref}`Event`.

- `**kwargs`: All keyword arguments provided on the {ref}`Event`.
`*args`
: All positional arguments provided on the {ref}`Event`.

- `event_data`: A reference to `EventData` instance.
`**kwargs`
: All keyword arguments provided on the {ref}`Event`.

- `event`: The {ref}`Event` that was triggered.
`event_data`
: A reference to `EventData` instance.

- `source`: The {ref}`State` the statemachine was when the {ref}`Event` started.
`event`
: The {ref}`Event` that was triggered.

- `state`: The current {ref}`State` of the statemachine.
`source`
: The {ref}`State` the statemachine was when the {ref}`Event` started.

- `model`: A reference to the underlying model that holds the current {ref}`State`.
`state`
: The current {ref}`State` of the statemachine.

`target`
: The destination {ref}`State` of the transition.

`model`
: A reference to the underlying model that holds the current {ref}`State`.

`transition`
: The {ref}`Transition` instance that was activated by the {ref}`Event`.

- `transition`: The {ref}`Transition` instance that was activated by the {ref}`Event`.

So, you can implement Actions and Guards like these, but this list is not exaustive, it's only
to give you a few examples... any combination of parameters will work, including extra parameters
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Contents:
transitions
actions
guards
observers
mixins
integrations
diagram
Expand Down
49 changes: 49 additions & 0 deletions docs/observers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@

# Observers

Observers are a way do generically add behavior to a StateMachine without
changing its internal implementation.

One possible use case is to add an observer that prints a log message when the SM runs a
transition or enters a new state.

Giving the {ref}`sphx_glr_auto_examples_traffic_light_machine.py` as example:


```py
>>> from tests.examples.traffic_light_machine import TrafficLightMachine

>>> class LogObserver(object):
... def __init__(self, name):
... self.name = name
...
... def after_transition(self, event, source, target):
... print("{} after: {}--({})-->{}".format(self.name, source.id, event, target.id))
...
... def on_enter_state(self, target, event):
... print("{} enter: {} from {}".format(self.name, target.id, event))


>>> sm = TrafficLightMachine()

>>> sm.add_observer(LogObserver("Paulista Avenue")) # doctest: +ELLIPSIS
TrafficLightMachine...

>>> sm.cycle()
Paulista Avenue enter: yellow from cycle
Paulista Avenue after: green--(cycle)-->yellow
'Running cycle from green to yellow'

```

```{hint}
The `StateMachine` itself is registered as an observer, so by using `.add_observer()` an
external object can have the same level of functionalities provided to the built-in class.
```

```{seealso}
See {ref}`actions`, {ref}`validators-and-guards` for a list of possible callbacks.

And also {ref}`dynamic-dispatch` to know more about how the lib calls methods to match
their signature.
```
12 changes: 12 additions & 0 deletions docs/releases/1.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,18 @@ def action_or_guard_method_name(self, *args, event_data, event, source, state, m
See {ref}`dynamic-dispatch` for more details.
```

### Add observers to a running StateMachine

Observers are a way do generically add behaviour to a StateMachine without
changing it's internal implementation.

The `StateMachine` itself is registered as an observer, so by using `StateMachine.add_observer()`
an external object can have the same level of functionalities provided to the built-in class.

```{seealso}
See {ref}`observers` for more details.
```

## Minor features in 1.0

- Fixed mypy complaining about incorrect type for ``StateMachine`` class.
Expand Down
6 changes: 4 additions & 2 deletions statemachine/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,14 @@ def clear(self):
def call(self, *args, **kwargs):
return [callback(*args, **kwargs) for callback in self.items]

def _add(self, func, prepend=False, **kwargs):
def _add(self, func, resolver=None, prepend=False, **kwargs):
if func in self.items:
return

resolver = resolver or self._resolver

callback = self.factory(func, **kwargs)
if self._resolver is not None and not callback.setup(self._resolver):
if resolver is not None and not callback.setup(resolver):
return

if prepend:
Expand Down
6 changes: 1 addition & 5 deletions statemachine/contrib/diagram.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import pydot
import importlib

from ..factory import StateMachineMetaclass
from ..statemachine import BaseStateMachine


Expand All @@ -29,13 +28,10 @@ def __init__(self, machine):

def _get_graph(self):
machine = self.machine
sm_class = (
machine if isinstance(machine, StateMachineMetaclass) else machine.__class__
)
return pydot.Dot(
"list",
graph_type="digraph",
label=sm_class.__name__,
label=machine.name,
fontsize=self.state_font_size,
rankdir=self.graph_rankdir,
)
Expand Down
4 changes: 2 additions & 2 deletions statemachine/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ class ObjectConfig(namedtuple("ObjectConfig", "obj skip_attrs")):

@classmethod
def from_obj(cls, obj):
if isinstance(obj, (tuple, list)):
return cls(*obj)
if isinstance(obj, ObjectConfig):
return obj
else:
return cls(obj, set())

Expand Down
1 change: 1 addition & 0 deletions statemachine/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ def __init__(cls, name, bases, attrs):
super(StateMachineMetaclass, cls).__init__(name, bases, attrs)
registry.register(cls)
cls._abstract = True
cls.name = cls.__name__
cls.states = []
cls._events = OrderedDict()
cls.states_map = {}
Expand Down
11 changes: 7 additions & 4 deletions statemachine/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,13 @@ def __init__(
def _setup(self, resolver):
self.enter.setup(resolver)
self.exit.setup(resolver)
self.enter.add("on_enter_state", prepend=True, suppress_errors=True)
self.enter.add("on_enter_{}".format(self.id), suppress_errors=True)
self.exit.add("on_exit_state", prepend=True, suppress_errors=True)
self.exit.add("on_exit_{}".format(self.id), suppress_errors=True)

def _add_observer(self, *resolvers):
for r in resolvers:
self.enter.add("on_enter_state", resolver=r, prepend=True, suppress_errors=True)
self.enter.add("on_enter_{}".format(self.id), resolver=r, suppress_errors=True)
self.exit.add("on_exit_state", resolver=r, prepend=True, suppress_errors=True)
self.exit.add("on_exit_{}".format(self.id), resolver=r, suppress_errors=True)

def __repr__(self):
return "{}({!r}, id={!r}, value={!r}, initial={!r}, final={!r})".format(
Expand Down
44 changes: 26 additions & 18 deletions statemachine/statemachine.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ def __init__(self, model=None, state_field="state", start_value=None):
self.state_field = state_field
self.start_value = start_value

self._setup()
self._activate_initial_state()
initial_transition = Transition(None, None, event="__initial__")
self._setup(initial_transition)
self._activate_initial_state(initial_transition)

def __repr__(self):
return "{}(model={!r}, state_field={!r}, current_state={!r})".format(
Expand All @@ -38,7 +39,7 @@ def __repr__(self):
self.current_state.id if self.current_state else None,
)

def _activate_initial_state(self):
def _activate_initial_state(self, initial_transition):

current_state_value = (
self.start_value if self.start_value else self.initial_state.value
Expand All @@ -52,15 +53,14 @@ def _activate_initial_state(self):

# send an one-time event `__initial__` to enter the current state.
# current_state = self.current_state
transition = Transition(None, initial_state, event="__initial__")
transition._setup(self._get_resolver())
transition.before.clear()
transition.on.clear()
transition.after.clear()
initial_transition.target = initial_state
initial_transition.before.clear()
initial_transition.on.clear()
initial_transition.after.clear()
event_data = EventData(
self,
transition.event,
transition=transition,
initial_transition.event,
transition=initial_transition,
)
self._activate(event_data)

Expand All @@ -82,17 +82,25 @@ def _get_protected_attrs(self):
| {e for e in self._events.keys()}
)

def _get_resolver(self):
def _visit_states_and_transitions(self, visitor):
for state in self.states:
visitor(state)
for transition in state.transitions:
visitor(transition)

def _setup(self, initial_transition):
machine = ObjectConfig(self, skip_attrs=self._get_protected_attrs())
model = ObjectConfig(self.model, skip_attrs={self.state_field})
return resolver_factory(machine, model)
default_resolver = resolver_factory(machine, model)

def _setup(self):
resolver = self._get_resolver()
for state in self.states:
state._setup(resolver)
for transition in state.transitions:
transition._setup(resolver)
initial_transition._setup(default_resolver)
self._visit_states_and_transitions(lambda x: x._setup(default_resolver))
self.add_observer(machine, model)

def add_observer(self, *observers):
resolvers = [resolver_factory(ObjectConfig.from_obj(o)) for o in observers]
self._visit_states_and_transitions(lambda x: x._add_observer(*resolvers))
return self

def _repr_html_(self):
return '<div class="statemachine">{}</div>'.format(self._repr_svg_())
Expand Down
53 changes: 18 additions & 35 deletions statemachine/transition.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# coding: utf-8
import warnings
from functools import partial

from .callbacks import Callbacks, ConditionWrapper

Expand Down Expand Up @@ -46,16 +47,8 @@ def __init__(
self.target = target
self._events = Events().add(event)
self.validators = Callbacks().add(validators)
self.before = (
Callbacks()
.add("before_transition", suppress_errors=True)
.add(before)
)
self.on = (
Callbacks()
.add("on_transition", suppress_errors=True)
.add(on)
)
self.before = Callbacks().add(before)
self.on = Callbacks().add(on)
self.after = Callbacks().add(after)
self.cond = (
Callbacks(factory=ConditionWrapper)
Expand All @@ -75,31 +68,21 @@ def _setup(self, resolver):
self.on.setup(resolver)
self.after.setup(resolver)

self.before.add(
[
pattern.format(event)
for pattern in ["before_{}"]
for event in self._events
],
suppress_errors=True,
)
self.on.add(
[
pattern.format(event)
for pattern in ["on_{}"]
for event in self._events
],
suppress_errors=True,
)
self.after.add(
[
pattern.format(event)
for pattern in ["after_{}"]
for event in self._events
]
+ ["after_transition"],
suppress_errors=True,
)
def _add_observer(self, *resolvers):
for r in resolvers:
before = partial(self.before.add, resolver=r, suppress_errors=True)
on = partial(self.on.add, resolver=r, suppress_errors=True)
after = partial(self.after.add, resolver=r, suppress_errors=True)

before("before_transition", prepend=True)
on("on_transition", prepend=True)

for event in self._events:
before("before_{}".format(event))
on("on_{}".format(event))
after("after_{}".format(event))

after("after_transition")

def _eval_cond(self, event_data):
return all(
Expand Down
32 changes: 32 additions & 0 deletions tests/test_observer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@


EXPECTED_LOG = """Frodo on: draft--(add_job)-->draft
Frodo enter: draft from add_job
Frodo on: draft--(produce)-->producing
Frodo enter: producing from produce
"""


class TestObserver:

def test_add_log_observer(self, campaign_machine, capsys):

class LogObserver(object):
def __init__(self, name):
self.name = name

def on_transition(self, event, state, target):
print("{} on: {}--({})-->{}".format(self.name, state.id, event, target.id))

def on_enter_state(self, target, event):
print("{} enter: {} from {}".format(self.name, target.id, event))

sm = campaign_machine()

sm.add_observer(LogObserver("Frodo"))

sm.add_job()
sm.produce()

captured = capsys.readouterr()
assert captured.out == EXPECTED_LOG