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
Short: self
in callbacks of PrepareMachine
does not refer to the right model.
Long:
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.
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.
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)
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.