diff --git a/README.rst b/README.rst index 3d7442e9..90b84869 100644 --- a/README.rst +++ b/README.rst @@ -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. diff --git a/docs/actions.md b/docs/actions.md index 3d266f55..bd92b7b9 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -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 diff --git a/docs/index.rst b/docs/index.rst index 17742a0d..89862862 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -12,6 +12,7 @@ Contents: transitions actions guards + observers mixins integrations diagram diff --git a/docs/observers.md b/docs/observers.md new file mode 100644 index 00000000..be08980a --- /dev/null +++ b/docs/observers.md @@ -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. +``` diff --git a/docs/releases/1.0.0.md b/docs/releases/1.0.0.md index 29d83d81..3828c244 100644 --- a/docs/releases/1.0.0.md +++ b/docs/releases/1.0.0.md @@ -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. diff --git a/statemachine/callbacks.py b/statemachine/callbacks.py index 7ea4c4e1..97239512 100644 --- a/statemachine/callbacks.py +++ b/statemachine/callbacks.py @@ -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: diff --git a/statemachine/contrib/diagram.py b/statemachine/contrib/diagram.py index 05d004ac..c945e249 100644 --- a/statemachine/contrib/diagram.py +++ b/statemachine/contrib/diagram.py @@ -2,7 +2,6 @@ import pydot import importlib -from ..factory import StateMachineMetaclass from ..statemachine import BaseStateMachine @@ -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, ) diff --git a/statemachine/dispatcher.py b/statemachine/dispatcher.py index 5922e42f..125e160b 100644 --- a/statemachine/dispatcher.py +++ b/statemachine/dispatcher.py @@ -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()) diff --git a/statemachine/factory.py b/statemachine/factory.py index dc9ed388..b376d424 100644 --- a/statemachine/factory.py +++ b/statemachine/factory.py @@ -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 = {} diff --git a/statemachine/state.py b/statemachine/state.py index 034b6b02..a60d9f80 100644 --- a/statemachine/state.py +++ b/statemachine/state.py @@ -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( diff --git a/statemachine/statemachine.py b/statemachine/statemachine.py index aadff890..9d32608c 100644 --- a/statemachine/statemachine.py +++ b/statemachine/statemachine.py @@ -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( @@ -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 @@ -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) @@ -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 '