1

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
Jan Krejci
  • 85
  • 8
  • So after some debugging, I found out that entry_init doesn't work in queued fashion and is kept on the call stack until the end. Therefore the prepare_init state at the end. The entry_connecting works as expected. But no solution yet. – Jan Krejci Sep 02 '21 at 08:55

1 Answers1

0

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.

aleneum
  • 2,083
  • 12
  • 29
  • 1
    Many thanks for the deep explanation. I'm already using events and "proper transitions", I just wanted to make the example as short and simple as possible. Both solutions work for me, I like solution A more as the event is triggered directly inside the callback function. If you are in Prague, the beer is on me :) – Jan Krejci Sep 02 '21 at 10:50
  • @HonzaKrejci: Best offer. There are hardly any better combinations than (czech) beer and Prague (in my world at least) :). Your example was well-written and helped me track a bug. FYI: 0.8.9 was just released. `QUEUED=True` should now work as well. – aleneum Sep 02 '21 at 11:08
  • Thanks again, It's outstanding support. I have corrected my code and updated to 0.8.9. QUEUED=True works for my example and also for the real-life code as expected. – Jan Krejci Sep 02 '21 at 12:05