pytransitions / transitions

A lightweight, object-oriented finite state machine implementation in Python with many extensions
MIT License
5.68k stars 530 forks source link

HierarchicalMachine Exception when executing transition on child machine with 'after' parameter #498

Closed S1MP50N closed 3 years ago

S1MP50N commented 3 years ago

Hi,

I am attempting to create a set of nested hierarchical state machines. As part of the application logic handling, I want to use the "after" callback on specific transitions to perform code actions. In order to keep the functionality co-located with each discrete machine the after callback methods are defined within each Machine class. This results in an exception that the 'after' method cannot be found when invoking events on the parent fsm? This feels incorrect? It would make sense that it should reference the child Machine class for the method (either if not found in the parent or as an override of the parents method possibly).

I have created a simplified code example which demonstrates the exception.

from transitions.extensions import HierarchicalMachine
class ChildFSM(HierarchicalMachine):

    def top_bottom_transition(self):
        print("top -> bottom")

    def bottom_top_transition(self):
        print("bottom -> top")

    def __init__(self):
        states = ['top', 'bottom']
        HierarchicalMachine.__init__(self, states=states, initial='top', auto_transitions=False)
        self.add_transition('down', 'top', 'bottom', after='top_bottom_transition')
        self.add_transition('up', 'bottom', 'top', after='bottom_top_transition')

class ParentFSM(HierarchicalMachine):

    def init_running_transition(self):
        print("init->running")

    def __init__(self, running):
        states = ['init', {'name':'running', 'children': running}]
        HierarchicalMachine.__init__(self, states=states, initial='init', auto_transitions=False)
        self.add_transition('start', 'init', 'running', after='init_running_transition')

childFSM = ChildFSM()

parentFSM = ParentFSM(childFSM)
print (parentFSM.state)

parentFSM.start()
print (parentFSM.state)

parentFSM.down()
print (parentFSM.state)

parentFSM.up()
print (parentFSM.state)

Error

$ python3.6 ParentChild.py 
init
init->running
running_top
Traceback (most recent call last):
  File "/usr/lib/python3.6/site-packages/transitions/core.py", line 1210, in __getattr__
    return self.__getattribute__(name)
AttributeError: 'ParentFSM' object has no attribute 'top_bottom_transition'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/usr/lib/python3.6/site-packages/transitions/core.py", line 1118, in resolve_callable
    func = getattr(event_data.model, func)
  File "/usr/lib/python3.6/site-packages/transitions/core.py", line 1213, in __getattr__
    raise AttributeError("'{}' does not exist on <Machine@{}>".format(name, id(self)))
AttributeError: 'top_bottom_transition' does not exist on <Machine@123145295245384>

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/usr/lib/python3.6/site-packages/transitions/core.py", line 1125, in resolve_callable
    mod, name = func.rsplit('.', 1)
ValueError: not enough values to unpack (expected 2, got 1)

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "ParentChild.py", line 36, in <module>
    parentFSM.down()
  File "/usr/lib/python3.6/site-packages/transitions/extensions/nesting.py", line 797, in trigger_event
    res = self._trigger_event(_model, _trigger, None, *args, **kwargs)
  File "/usr/lib/python3.6/site-packages/transitions/extensions/nesting.py", line 985, in _trigger_event
    tmp = self._trigger_event(_model, _trigger, value, *args, **kwargs)
  File "/usr/lib/python3.6/site-packages/transitions/extensions/nesting.py", line 989, in _trigger_event
    tmp = self.events[_trigger].trigger(_model, self, *args, **kwargs)
  File "/usr/lib/python3.6/site-packages/transitions/extensions/nesting.py", line 112, in trigger
    return _machine._process(func)
  File "/usr/lib/python3.6/site-packages/transitions/core.py", line 1148, in _process
    return trigger()
  File "/usr/lib/python3.6/site-packages/transitions/extensions/nesting.py", line 127, in _trigger
    res = self._process(event_data)
  File "/usr/lib/python3.6/site-packages/transitions/extensions/nesting.py", line 143, in _process
    if trans.execute(event_data):
  File "/usr/lib/python3.6/site-packages/transitions/core.py", line 274, in execute
    event_data.machine.callbacks(itertools.chain(self.after, event_data.machine.after_state_change), event_data)
  File "/usr/lib/python3.6/site-packages/transitions/core.py", line 1083, in callbacks
    self.callback(func, event_data)
  File "/usr/lib/python3.6/site-packages/transitions/core.py", line 1100, in callback
    func = self.resolve_callable(func, event_data)
  File "/usr/lib/python3.6/site-packages/transitions/core.py", line 1132, in resolve_callable
    "model nor imported from a module." % func)
AttributeError: Callable with name 'top_bottom_transition' could neither be retrieved from the passed model nor imported from a module.

Any assistance would be appreciated.

Thanks, Shaun

aleneum commented 3 years ago

Hello @S1MP50N,

transitions differentiates between a Machine which defines transitions, events and state objects and a Model which is the actual stateful object and defines all callbacks that should be resolved by name. When you do not explicitely pass a model to a machine, the machine itself will act as a model.

The documentation states that

However, since 0.8.0 (Nested)State instances are just referenced which means changes in one machine's collection of states and events will influence the other machine instance. Models and their state will not be shared though.

When you instantiate your parent machine and pass an already instantiated child machine, it will only reference state objects and transitions from the child machine. But it will be the parent that acts as a model. There have been several issues discussing this (e.g. #457, #456). You could either split your machine and model definitions or pass callbacks by reference instead of by name (self.top_bottom_transition instead of 'top_bottom_transition')

from transitions.extensions import HierarchicalMachine

class ChildFSM(HierarchicalMachine):

    def top_bottom_transition(self):
        print("top -> bottom")

    def bottom_top_transition(self):
        print("bottom -> top")

    def __init__(self):
        states = ['top', 'bottom']
        HierarchicalMachine.__init__(self, states=states, initial='top', auto_transitions=False)
        self.add_transition('down', 'top', 'bottom', after=self.top_bottom_transition)
        self.add_transition('up', 'bottom', 'top', after=self.bottom_top_transition)

class ParentFSM(HierarchicalMachine):

    def init_running_transition(self):
        print("init->running")

    def __init__(self, running):
        states = ['init', {'name': 'running', 'children': running}]
        HierarchicalMachine.__init__(self, states=states, initial='init', auto_transitions=False)
        self.add_transition('start', 'init', 'running', after=self.init_running_transition)

childFSM = ChildFSM()

parentFSM = ParentFSM(childFSM)
print(parentFSM.state)

parentFSM.start()
print(parentFSM.state)

parentFSM.down()
print(parentFSM.state)

parentFSM.up()
print(parentFSM.state)

However, childFSM's state will be independent of the state of parentFSM.

S1MP50N commented 3 years ago

Hi @aleneum,

Thanks for the quick and comprehensive reply. Still digesting what you have shared but had a follow up query.

I think the pass by reference is the simplest fix for the problem I presented. I would like to try and maintain the "siloing" of the FSM content as much as possible. Creating a common shared model class would negate that. Likewise any of the attempts to the find callbacks would probably start imposing limits on what engineers on my team could do or use, which I really don't like.

Assuming the pass by reference approach, we were hoping to load the state and transition information from JSON. Something that might be generated by another set of tooling. In this case, I assume it would revert to being broken as we cannot define the reference as part of that file?

It may not be a deal breaker to configure inside python but it was an area we were actively looking towards.

Thanks, Shaun

aleneum commented 3 years ago

Hello Shaun (@S1MP50N),

configuring state machines via JSON or YAML is no problem (see first passage in FAQ). There is a third way how callbacks can be passed: You can pass a callbacks full path in a module. Consider this example:

File structure:

- ./modules
-- __init__.py
-- foo.py
-- bar.py
./main.py

foo.py

from transitions.extensions import HierarchicalMachine

def do_something():
    print("Do something foo style")

def is_always_true():
    print("check something foo style")
    return True

FOO_CONFIG = {
    "name": "Foo",
    "states": ["a", "b", "c"],
    "transitions": [
        {"trigger": "go", "source": "a", "dest": "b", "after": "modules.foo.do_something"},
        {"trigger": "go", "source": "b", "dest": "c", "conditions": "modules.foo.is_always_true"}
    ],
    "initial": "a"
}

class FooFSM(HierarchicalMachine):

    def __init__(self):
        super().__init__(**FOO_CONFIG)

bar.py

from transitions.extensions import HierarchicalMachine

def do_something():
    print("Do something bar style")

def is_always_true():
    print("check something bar style")
    return False

BAR_CONFIG = {
    "name": "Bar",
    "states": ["1", "2", "3"],
    "transitions": [
        {"trigger": "go", "source": "1", "dest": "2", "after": "modules.bar.do_something"},
        {"trigger": "go", "source": "2", "dest": "3", "conditions": "modules.bar.is_always_true"}
    ],
    "initial": "1"
}

class BarFSM(HierarchicalMachine):

    def __init__(self):
        super().__init__(**BAR_CONFIG)

main.py

from transitions.extensions.nesting import HierarchicalMachine

from modules.foo import FooFSM
from modules.bar import BarFSM

CONFIG = {
    "name": "HSM",
    "states": [
        "A",
        {"name": "B", "children": FooFSM()},
        {"name": "C", "children": BarFSM()},
    ],
    "initial": "A"
}

machine = HierarchicalMachine(**CONFIG)
assert machine.is_A()
assert machine.to_B()
assert machine.is_B_a()
assert machine.go()
assert machine.go()
assert machine.is_B_c()
assert machine.to_C()
assert machine.go()
assert not machine.go()
assert machine.is_C_2()

I used a Python dict rather than JSON but converting one into the other should not be too difficult. If you want to reuse the CONFIG dictionaries and skip instantiation you have to alter the config slightly:

import copy
from modules.foo import FOO_CONFIG
foo_config = copy.deepcopy(FOO_CONFIG)
foo_config['name'] = 'B'
foo_config['children'] = foo_config.pop('states', [])
# ...
CONFIG = {
    "name": "HSM",
    "states": [
        "A",
        foo_config,
#      ...
    ],
    "initial": "A"
}

This has the advantage that FooFSM and BarFSM could derive from Machine instead of HierarchicalMachine. When you use HSM variants derived from MarkupMachine (e.g. HierarchicalMarkupMachine; MarkupMachine is also briefly introduced in the FAQ) you can retrieve a machine configuration with machine_config = machine.markup. This is quite useful to keep track of machine configurations and send their configs around.

aleneum commented 3 years ago

Just fyi, I am working on a little change that will allow using either 'children' or 'states' in a configuration for easier reuse. The changes mentioned in the last codeblock (renaming) will become optional.

aleneum commented 3 years ago

With 0a58778, you can pass a config as a 'state entry' without further tinkering:

from modules.foo import FOO_CONFIG
from modules.bar import BAR_CONFIG

CONFIG = {
    "name": "HSM",
    "states": [
        "A",
        FOO_CONFIG,
        BAR_CONFIG
    ],
#    "transitions": [...]
    "initial": "A"
}

machine = HierarchicalMachine(**CONFIG)
assert machine.is_A()
machine.to_Foo()
assert machine.is_Foo_a()
S1MP50N commented 3 years ago

Hi @aleneum,

Brilliant! Replying to JSON stuff first; I had considered using the module level includes for bounding each sub FSM. Coming from a C++ background I was reticent to put code outside of the class for encapsulation purposes, but I think in this, the file level scoping is good enough. Appreciate the examples it makes it very clear.

With regards to the latest modifications; excellent! that is a very useful clean up of the declaration syntax and makes it much cleaner. Will definitely consider using that as part of the model configuration.

Appreciate your very quick responses and support, really looking forward to using transitions to solve the problem at hand.

I am going to close this for now as you have address my immediate issues, thank you again.

Thanks, Shaun