{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Frequently asked questions\n", "\n", "* [How do I load/save state machine configurations with json/yaml](#How-do-I-load/save-state-machine-configurations-with-json/yaml)\n", "* [How to use transitions with django models?](#How-to-use-transitions-with-django-models?)\n", "* [transitions memory footprint is too large for my Django app and adding models takes too long.](#transitions-memory-footprint-is-too-large-for-my-Django-app-and-adding-models-takes-too-long.) \n", "* [Is there a during callback which is called when no transition has been successful?](#Is-there-a-'during'-callback-which-is-called-when-no-transition-has-been-successful?)\n", "* [How to have a dynamic transition destination based on a function's return value?](#How-to-have-a-dynamic-transition-destination-based-on-a-function's-return-value)\n", "* [Machine.get_triggers should only show valid transitions based on some conditions.](#Machine.get_triggers-should-only-show-valid-transitions-based-on-some-conditions.)\n", "* [Transitions does not add convencience methods to my model](#Transitions-does-not-add-convencience-methods-to-my-model)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### How do I load/save state machine configurations with json/yaml\n", "\n", "The easiest way to load a configuration is by making sure it is structured just as the `Machine` constructor. Your first level elements should be `name`, `transitions`, `states` and so on. When your yaml/json configuration is loaded, you can add your model programatically and pass the whole object to `Machine`.\n", "\n", "#### Loading a JSON configuration" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from transitions import Machine\n", "import json\n", "\n", "\n", "class Model:\n", "\n", " def say_hello(self, name):\n", " print(f\"Hello {name}!\")\n", "\n", "\n", "# import json\n", "json_config = \"\"\"\n", "{\n", " \"name\": \"MyMachine\",\n", " \"states\": [\n", " \"A\",\n", " \"B\",\n", " { \"name\": \"C\", \"on_enter\": \"say_hello\" }\n", " ],\n", " \"transitions\": [\n", " [\"go\", \"A\", \"B\"],\n", " {\"trigger\": \"hello\", \"source\": \"*\", \"dest\": \"C\"}\n", " ],\n", " \"initial\": \"A\"\n", "}\n", "\"\"\"\n", "\n", "model = Model()\n", "\n", "config = json.loads(json_config)\n", "config['model'] = model # adding a model to the configuration\n", "m = Machine(**config) # **config unpacks arguments as kwargs\n", "assert model.is_A()\n", "model.go()\n", "assert model.is_B()\n", "model.hello(\"world\") # >>> Hello world!\n", "assert model.state == 'C'" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Loading a YAML configuration\n", "\n", "This example uses [pyyaml](https://pypi.org/project/PyYAML/)." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from transitions import Machine\n", "import yaml\n", "\n", "\n", "class Model:\n", "\n", " def say_hello(self, name):\n", " print(f\"Hello {name}!\")\n", "\n", " \n", "yaml_config = \"\"\"\n", "---\n", "\n", "name: \"MyMachine\"\n", "\n", "states:\n", " - \"A\"\n", " - \"B\"\n", " - name: \"C\"\n", " on_enter: \"say_hello\"\n", "\n", "transitions:\n", " - [\"go\", \"A\", \"B\"]\n", " - {trigger: \"hello\", source: \"*\", dest: \"C\"}\n", "\n", "initial: \"A\"\n", "\"\"\"\n", "\n", "model = Model()\n", "\n", "config = yaml.safe_load(yaml_config) \n", "config['model'] = model # adding a model to the configuration\n", "m = Machine(**config) # **config unpacks arguments as kwargs\n", "assert model.is_A()\n", "model.go()\n", "assert model.is_B()\n", "model.hello(\"world\") # >>> Hello world!\n", "assert model.state == 'C'" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Exporting YAML or JSON\n", "\n", "A default `Machine` does not keep track of its configuration but `transitions.extensions.markup.MarkupMachine` does. \n", "`MarkupMachine` cannot just be used to export your configuration but also to visualize or instrospect your configuration conveniently.\n", "Is is also the foundation for `GraphMachine`. You will see that `MarkupMachine` will always export every attribute even unset values. This makes such exports visually cluttered but easier to automatically process.\n", "If you plan to use such a configuration with a 'normal' `Machine`, you should remove the `models` attribute from the markup since `Machine` cannot process it properly.\n", "If you pass the (stored and loaded) configuration to another `MarkupMachine` however, it will attempt to create and initialize models for you." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "scrolled": true }, "outputs": [], "source": [ "#export\n", "from transitions.extensions.markup import MarkupMachine\n", "import json\n", "import yaml\n", "\n", "\n", "class Model:\n", "\n", " def say_hello(self, name):\n", " print(f\"Hello {name}!\")\n", "\n", "\n", "model = Model()\n", "m = MarkupMachine(model=None, name=\"ExportedMachine\")\n", "m.add_state('A')\n", "m.add_state('B')\n", "m.add_state('C', on_enter='say_hello')\n", "m.add_transition('go', 'A', 'B')\n", "m.add_transition(trigger='hello', source='*', dest='C')\n", "m.initial = 'A'\n", "m.add_model(model)\n", "model.go()\n", "\n", "print(\"JSON:\")\n", "print(json.dumps(m.markup, indent=2))\n", "print('\\nYAML:')\n", "print(yaml.dump(m.markup))\n", "\n", "config2 = json.loads(json.dumps(m.markup)) # simulate saving and loading\n", "m2 = MarkupMachine(markup=config2)\n", "model2 = m2.models[0] # get the initialized model\n", "assert model2.is_B() # the model state was preserved\n", "model2.hello('again') # >>> Hello again!\n", "assert model2.state == 'C'" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### How to use `transitions` with django models?\n", "\n", "In [this comment](https://github.com/pytransitions/transitions/issues/146#issuecomment-300277397) **proofit404** provided a nice example about how to use `transitions` and django together:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from django.db import models\n", "from django.db.models.signals import post_init\n", "from django.dispatch import receiver\n", "from django.utils.translation import ugettext_lazy as _\n", "from transitions import Machine\n", "\n", "\n", "class ModelWithState(models.Model):\n", " ASLEEP = 'asleep'\n", " HANGING_OUT = 'hanging out'\n", " HUNGRY = 'hungry'\n", " SWEATY = 'sweaty'\n", " SAVING_THE_WORLD = 'saving the world'\n", " STATE_TYPES = [\n", " (ASLEEP, _('asleep')),\n", " (HANGING_OUT, _('hanging out')),\n", " (HUNGRY, _('hungry')),\n", " (SWEATY, _('sweaty')),\n", " (SAVING_THE_WORLD, _('saving the world')),\n", " ]\n", " state = models.CharField(\n", " _('state'),\n", " max_length=100,\n", " choices=STATE_TYPES,\n", " default=ASLEEP,\n", " help_text=_('actual state'),\n", " )\n", "\n", "\n", "@receiver(post_init, sender=ModelWithState)\n", "def init_state_machine(instance, **kwargs):\n", "\n", " states = [state for state, _ in instance.STATE_TYPES]\n", " machine = instance.machine = Machine(model=instance, states=states, initial=instance.state)\n", " machine.add_transition('work_out', instance.HANGING_OUT, instance.HUNGRY)\n", " machine.add_transition('eat', instance.HUNGRY, instance.HANGING_OUT)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### `transitions` memory footprint is too large for my Django app and adding models takes too long.\n", "\n", "We analyzed the memory footprint of `transitions` in [this discussion](https://github.com/pytransitions/transitions/issues/146) and could verify that the standard approach is not suitable to handle thousands of models. However, with a static (class) machine and some `__getattribute__` tweaking we can keep the convenience loss minimal:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from transitions import Machine\n", "from functools import partial\n", "from mock import MagicMock\n", "\n", "\n", "class Model(object):\n", "\n", " machine = Machine(model=None, states=['A', 'B', 'C'], initial=None,\n", " transitions=[\n", " {'trigger': 'go', 'source': 'A', 'dest': 'B', 'before': 'before'},\n", " {'trigger': 'check', 'source': 'B', 'dest': 'C', 'conditions': 'is_large'},\n", " ], finalize_event='finalize')\n", "\n", " def __init__(self):\n", " self.state = 'A'\n", " self.before = MagicMock()\n", " self.after = MagicMock()\n", " self.finalize = MagicMock()\n", "\n", " @staticmethod\n", " def is_large(value=0):\n", " return value > 9000\n", "\n", " def __getattribute__(self, item):\n", " try:\n", " return super(Model, self).__getattribute__(item)\n", " except AttributeError:\n", " if item in self.machine.events:\n", " return partial(self.machine.events[item].trigger, self)\n", " raise\n", "\n", "\n", "model = Model()\n", "model.go()\n", "assert model.state == 'B'\n", "assert model.before.called\n", "assert model.finalize.called\n", "model.check()\n", "assert model.state == 'B'\n", "model.check(value=500)\n", "assert model.state == 'B'\n", "model.check(value=9001)\n", "assert model.state == 'C'\n", "assert model.finalize.call_count == 4" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Is there a 'during' callback which is called when no transition has been successful?\n", "\n", "Currently, `transitions` has no such callback. This example from the issue discussed [here](https://github.com/pytransitions/transitions/issues/342) might give you a basic idea about how to extend `Machine` with such a feature:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from transitions.core import Machine, State, Event, EventData, listify\n", "\n", "\n", "class DuringState(State):\n", "\n", " # add `on_during` to the dynamic callback methods\n", " # this way on_during_ can be recognized by `Machine`\n", " dynamic_methods = State.dynamic_methods + ['on_during']\n", " \n", " # parse 'during' and remove the keyword before passing the rest along to state\n", " def __init__(self, *args, **kwargs):\n", " during = kwargs.pop('during', [])\n", " self.on_during = listify(during)\n", " super(DuringState, self).__init__(*args, **kwargs)\n", "\n", " def during(self, event_data):\n", " for handle in self.on_during:\n", " event_data.machine.callback(handle, event_data)\n", "\n", "\n", "class DuringEvent(Event):\n", "\n", " def _trigger(self, model, *args, **kwargs):\n", " # a successful transition returns `res=True` if res is False, we know that\n", " # no transition has been executed\n", " res = super(DuringEvent, self)._trigger(model, *args, **kwargs)\n", " if res is False:\n", " state = self.machine.get_state(model.state)\n", " event_data = EventData(state, self, self.machine, model, args=args, kwargs=kwargs)\n", " event_data.result = res\n", " state.during(event_data)\n", " return res\n", "\n", "\n", "class DuringMachine(Machine):\n", " # we need to override the state and event classes used by `Machine`\n", " state_cls = DuringState\n", " event_cls = DuringEvent\n", "\n", "\n", "class Model:\n", "\n", " def on_during_A(self):\n", " print(\"Dynamically assigned callback\")\n", "\n", " def another_callback(self):\n", " print(\"Explicitly assigned callback\")\n", "\n", "\n", "model = Model()\n", "machine = DuringMachine(model=model, states=[{'name': 'A', 'during': 'another_callback'}, 'B'],\n", " transitions=[['go', 'B', 'A']], initial='A', ignore_invalid_triggers=True)\n", "machine.add_transition('test', source='A', dest='A', conditions=lambda: False)\n", "\n", "assert not model.go()\n", "assert not model.test()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### How to have a dynamic transition destination based on a function's return value\n", "\n", "This has been a feature request [here](https://github.com/pytransitions/transitions/issues/269). We'd encourage to write a wrapper which converts a condensed statement into individual condition-based transitions. However, a less expressive version could look like this:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from transitions import Machine, Transition\n", "from six import string_types\n", "\n", "class DependingTransition(Transition):\n", "\n", " def __init__(self, source, dest, conditions=None, unless=None, before=None,\n", " after=None, prepare=None, **kwargs):\n", "\n", " self._result = self._dest = None\n", " super(DependingTransition, self).__init__(source, dest, conditions, unless, before, after, prepare)\n", " if isinstance(dest, dict):\n", " try:\n", " self._func = kwargs.pop('depends_on')\n", " except KeyError:\n", " raise AttributeError(\"A multi-destination transition requires a 'depends_on'\")\n", " else:\n", " # use base version in case transition does not need special handling\n", " self.execute = super(DependingTransition, self).execute\n", "\n", " def execute(self, event_data):\n", " func = getattr(event_data.model, self._func) if isinstance(self._func, string_types) \\\n", " else self._func\n", " self._result = func(*event_data.args, **event_data.kwargs)\n", " super(DependingTransition, self).execute(event_data)\n", "\n", " @property\n", " def dest(self):\n", " return self._dest[self._result] if self._result is not None else self._dest\n", "\n", " @dest.setter\n", " def dest(self, value):\n", " self._dest = value\n", "\n", "# subclass Machine to use DependingTransition instead of standard Transition\n", "class DependingMachine(Machine):\n", " transition_cls = DependingTransition\n", " \n", "\n", "def func(value):\n", " return value\n", "\n", "m = DependingMachine(states=['A', 'B', 'C', 'D'], initial='A')\n", "# define a dynamic transition with a 'depends_on' function which will return the required value\n", "m.add_transition(trigger='shuffle', source='A', dest=({1: 'B', 2: 'C', 3: 'D'}), depends_on=func)\n", "m.shuffle(value=2) # func returns 2 which makes the transition dest to be 'C'\n", "assert m.is_C()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Note that this solution has some drawbacks. For instance, the generated graph might not include all possible outcomes." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### `Machine.get_triggers` should only show valid transitions based on some conditions.\n", "\n", "This has been requested [here](https://github.com/pytransitions/transitions/issues/256). `Machine.get_triggers` is usually quite naive and only checks for theoretically possible transitions. If you need more sophisticated peeking, this `PeekMachine._can_trigger` might be a solution:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from transitions import Machine, EventData\n", "from functools import partial\n", "\n", "\n", "class Model(object):\n", "\n", " def fails(self, condition=False):\n", " return False\n", "\n", " def success(self, condition=False):\n", " return True\n", "\n", " # condition is passed by EventData\n", " def depends_on(self, condition=False):\n", " return condition\n", "\n", " def is_state_B(self, condition=False):\n", " return self.state == 'B'\n", "\n", "\n", "class PeekMachine(Machine):\n", "\n", " def _can_trigger(self, model, *args, **kwargs):\n", " # We can omit the first two arguments state and event since they are only needed for \n", " # actual state transitions. We do have to pass the machine (self) and the model as well as \n", " # args and kwargs meant for the callbacks.\n", " e = EventData(None, None, self, model, args, kwargs)\n", "\n", " return [trigger_name for trigger_name in self.get_triggers(model.state)\n", " if any(all(c.check(e) for c in t.conditions)\n", " for ts in self.events[trigger_name].transitions.values()\n", " for t in ts)]\n", "\n", " # override Machine.add_model to assign 'can_trigger' to the model\n", " def add_model(self, model, initial=None):\n", " super(PeekMachine, self).add_model(model, initial)\n", " setattr(model, 'can_trigger', partial(self._can_trigger, model))\n", "\n", "\n", "states = ['A', 'B', 'C', 'D']\n", "transitions = [\n", " dict(trigger='go_A', source='*', dest='A', conditions=['depends_on']), # only available when condition=True is passed\n", " dict(trigger='go_B', source='*', dest='B', conditions=['success']), # always available\n", " dict(trigger='go_C', source='*', dest='C', conditions=['fails']), # never available\n", " dict(trigger='go_D', source='*', dest='D', conditions=['is_state_B']), # only available in state B\n", " dict(trigger='reset', source='D', dest='A', conditions=['success', 'depends_on']), # only available in state D when condition=True is passed\n", " dict(trigger='forwards', source='A', dest='D', conditions=['success', 'fails']), # never available\n", "]\n", "\n", "model = Model()\n", "machine = PeekMachine(model, states=states, transitions=transitions, initial='A', auto_transitions=False)\n", "assert model.can_trigger() == ['go_B']\n", "assert set(model.can_trigger(condition=True)) == set(['go_A', 'go_B'])\n", "model.go_B(condition=True)\n", "assert set(model.can_trigger()) == set(['go_B', 'go_D'])\n", "model.go_D()\n", "assert model.can_trigger() == ['go_B']\n", "assert set(model.can_trigger(condition=True)) == set(['go_A', 'go_B', 'reset'])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Transitions does not add convencience methods to my model\n", "\n", "There is a high chance that your model *already contained* a `trigger` method or methods with the same name as your even trigger. In this case, `transitions` will not add convenience methods to not accidentaly break your model and only emit a warning. If you defined these methods on purpose and *want* them to be overrided or maybe even call *both* -- the trigger event AND your predefined method, you can extend/override `Machine._checked_assignment` which is always called when something needs to be added to a model:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from transitions import State, Machine\n", "\n", "class StateMachineModel:\n", "\n", " state = None\n", "\n", " def __init__(self):\n", " pass\n", "\n", " def transition_one(self):\n", " print('transitioning states...')\n", "\n", " def transition_two(self):\n", " print('transitioning states...')\n", "\n", "\n", "class OverrideMachine(Machine):\n", "\n", " def _checked_assignment(self, model, name, func):\n", " setattr(model, name, func)\n", "\n", "\n", "class CallingMachine(Machine):\n", "\n", " def _checked_assignment(self, model, name, func):\n", " if hasattr(model, name):\n", " predefined_func = getattr(model, name)\n", " def nested_func(*args, **kwargs):\n", " predefined_func()\n", " func(*args, **kwargs)\n", " setattr(model, name, nested_func)\n", " else:\n", " setattr(model, name, func)\n", "\n", "\n", "states = [State(name='A'), State(name='B'), State(name='C'), State(name='D')]\n", "transitions = [\n", " {'trigger': 'transition_one', 'source': 'A', 'dest': 'B'},\n", " {'trigger': 'transition_two', 'source': 'B', 'dest': 'C'},\n", " {'trigger': 'transition_three', 'source': 'C', 'dest': 'D'}\n", "]\n", "state_machine_model = StateMachineModel()\n", "\n", "print('OverrideMachine ...')\n", "state_machine = OverrideMachine(model=state_machine_model, states=states, transitions=transitions, initial=states[0])\n", "print('state_machine_model (current state): %s' % state_machine_model.state)\n", "state_machine_model.transition_one()\n", "print('state_machine_model (current state): %s' % state_machine_model.state)\n", "state_machine_model.transition_two()\n", "print('state_machine_model (current state): %s' % state_machine_model.state)\n", "\n", "print('\\nCallingMachine ...')\n", "state_machine_model = StateMachineModel()\n", "state_machine = CallingMachine(model=state_machine_model, states=states, transitions=transitions, initial=states[0])\n", "print('state_machine_model (current state): %s' % state_machine_model.state)\n", "state_machine_model.transition_one()\n", "print('state_machine_model (current state): %s' % state_machine_model.state)\n", "state_machine_model.transition_two()\n", "print('state_machine_model (current state): %s' % state_machine_model.state)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [] } ], "metadata": { "kernelspec": { "display_name": "Python 3.8.3 64-bit ('transitions': conda)", "language": "python", "name": "python38364bittransitionsconda9f9fdeb4313741768b0dccf7fd8ce480" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.8.3" } }, "nbformat": 4, "nbformat_minor": 2 }