pythonpytransitions

Pytransitions queued hierarchical machine ends always in init state


I'm using the pytransitions with HierarchicalMachine class to be able to create small nested machines to complete subtasks inside a bigger state machine. I'm using the queued transitions to be able to invoke a trigger from inside of the state callback.

I would expect the following code ends in prepare_waiting state, but it actually goes back to prepare_init state.

Do you have any idea why this happens?

Code:

from transitions.extensions.factory import HierarchicalMachine
import logging as log

QUEUED = True

class PrepareMachine(HierarchicalMachine):
    def __init__(self):

        states = [
            {"name": "init", "on_enter": self.entry_init},
            {"name": "connecting", "on_enter": self.entry_connecting},
            {"name": "waiting", "on_enter": self.entry_waiting},
        ]

        super().__init__(states=states, initial="init", queued=QUEUED)

    def entry_init(self):
        print("common entry point ...")
        self.to_connecting()

    def entry_connecting(self):
        print("connecting multiple indtruments ...")
        self.to_waiting()

    def entry_waiting(self):
        print("wait for response ...")

class ProductionMachine(HierarchicalMachine):
    def __init__(self):
        prepare = PrepareMachine()
        states = ["init", {"name": "prepare", "children": prepare}]
        super().__init__(states=states, initial="init", queued=QUEUED)
        self.add_transition("start_testing", "init", "prepare")

log.basicConfig(level=log.INFO)
machine = ProductionMachine()
machine.start_testing()
print(machine.state)

Output:

INFO:transitions.core:Finished processing state init exit callbacks.
INFO:transitions.core:Finished processing state prepare enter callbacks.
common entry point ...
INFO:transitions.core:Finished processing state init exit callbacks.
connecting multiple indtruments ...
INFO:transitions.core:Executed callback '<bound method PrepareMachine.entry_connecting of <__main__.PrepareMachine object at 0xb6588bd0>>'
INFO:transitions.core:Finished processing state connecting enter callbacks.
INFO:transitions.core:Finished processing state connecting exit callbacks.
wait for response ...
INFO:transitions.core:Executed callback '<bound method PrepareMachine.entry_waiting of <__main__.PrepareMachine object at 0xb6588bd0>>'
INFO:transitions.core:Finished processing state waiting enter callbacks.
INFO:transitions.core:Executed callback '<bound method PrepareMachine.entry_init of <__main__.PrepareMachine object at 0xb6588bd0>>'
INFO:transitions.core:Finished processing state init enter callbacks.
prepare_init

Solution

  • Short: self in callbacks of PrepareMachine does not refer to the right model.

    Long:

    What is happening and why?

    To understand why this is happening one has to consider the concept of pytransitions that splits a state machine into a 'rule book' (e.g. Machine) containing all state, event and transition definition and the stateful object which is usually referred to as the model. All convenience functions such as trigger functions (methods named after transition names) or auto transitions (such as to_<state>) are attached to the model.

    machine = Machine(model=model, states=states, transitions=transitions, initial="initial")
    assert model.is_initial()  # 
    model.to_work()  # auto transition
    assert model.is_work()
    machine.to_initial() <-- will raise an exception
    

    When you do not pass a model parameter to a Machine, the machine itself will act as a model and thus get all the convenience and trigger functions attached to it.

    machine = Machine(states=states, transitions=transitions, initial="A")
    assert machine.is_A()
    machine.to_B()
    assert machine.is_B()
    

    So, in your example, prepare = PrepareMachine() makes prepare act as its own model and machine = ProductionMachine() makes machine the model of ProductionMachine. This is why you can call for instance prepare.to_connecting() because prepapre acts a model, too. However, not the model you want that is machine. So if we change your example slightly things might get a bit clearer:

    class ProductionMachine(HierarchicalMachine):
        def __init__(self):
            self.prepare = PrepareMachine()
            states = ["init", {"name": "prepare", "children": self.prepare}]
    # [...]
    machine = ProductionMachine()
    machine.start_testing()
    print(machine.state)  #  >>> prepare_init
    print(machine.prepare.state)  # >>> waiting
    

    With machine.start_testing() you let machine enter prepare_init and consequently call PrepareMachine.entry_init. In this method you call self.to_connecting() which triggers a transition of prepare to connecting and NOT machine. When prepare enters connecting, PrepareMachine.entry_connecting will be called and self (aka prepare) will transition once again with to_waiting. As both, PrepareMachine and ProductionMachine, process events queued, prepare will finish to_connecting and instantly process to_waiting. At this point machine is still processing entry_init since self.to_connection (aka prepare.to_connecting) has not returned yet. So when prepare finally reaching the waiting state, machine will return and log that it is now done with processing transitions callbacks for start_testing. The model machine did not revert to prepare_init but all the processing of prepare happens WHILE start_testing is processed and this causes the log messages of start_testing wrapping all the other messages.

    How to achieve what you want?

    We want to trigger the events (to_connecting/waiting) on the correct model (machine). There are multiple approaches to this. First, I would recommend to define 'proper' transitions instead of relying on auto transitions. Auto transitions are passed to machine as well (so machine.to_connecting will work), things could get messy when you have multiple substates with the same name.

    Option A: Get the correct model from event_data.

    When you pass send_event=True to Machine constructors, every callback can (and must) accept an EventData object that contains all information about the currently processed transition. This includes the model.

            transitions = [
                ["connect", "init", "connecting"],
                ["connected", "connecting", "waiting"]
            ]
    
            states = [
                {"name": "init", "on_enter": self.entry_init},
                {"name": "connecting", "on_enter": self.entry_connecting},
                {"name": "waiting", "on_enter": self.entry_waiting},
            ]
    # ...
    
        def entry_init(self, event_data):
            print("common entry point ...")
            event_data.model.connect()
            # we could use event_data.model.to_connecting() as well
            # but I'd recommend defining transitions with 'proper' names
            # focused on events
    
        def entry_connecting(self, event_data):
            print("connecting multiple instruments ...")
            event_data.model.connected()
    
        def entry_waiting(self, event_data):
            print("wait for response ...")
    # ...
    
            super().__init__(states=states, transitions=transitions, initial="init", queued=QUEUED, send_event=True)
    

    Option B: Use callback names instead of references and pass them directly to on_enter.

    When callback parameters are names, transitions will resolve callbacks on the currently processed model. The parameter on_enter allows to pass multiple callbacks and also mix references and strings. So your code could look like this.

    from transitions.extensions.factory import HierarchicalMachine
    import logging as log
    
    QUEUED = False
    
    
    class PrepareMachine(HierarchicalMachine):
        def __init__(self):
         
            transitions = [
                ["connect", "init", "connecting"],
                ["connected", "connecting", "waiting"]
            ]
    
            states = [
                {"name": "init", "on_enter": [self.entry_init, "connect"]},
                {"name": "connecting", "on_enter": [self.entry_connecting, "connected"]},
                {"name": "waiting", "on_enter": self.entry_waiting},
            ]
            super().__init__(states=states, transitions=transitions, initial="init", queued=QUEUED)
    
        def entry_init(self):
            print("common entry point ...")
    
        def entry_connecting(self):
            print("connecting multiple indtruments ...")
    
        def entry_waiting(self):
            print("wait for response ...")
    
    
    class ProductionMachine(HierarchicalMachine):
        def __init__(self):
            self.prepare = PrepareMachine()
            states = ["init", {"name": "prepare", "children": self.prepare}]
            super().__init__(states=states, initial="init", queued=QUEUED)
            self.add_transition("start_testing", "init", "prepare")
    
    log.basicConfig(level=log.INFO)
    machine = ProductionMachine()
    machine.start_testing()
    assert machine.is_prepare_waiting()
    

    Note that I had to switch QUEUED=False since in transitions 0.8.8 and earlier, there is a bug related to queued processing of nested transitions. UPDATE: This bug has been fixed in transitions 0.8.9 which was released just now. QUEUED=True should now work as well.