Search code examples
pythontransitionfsmpytransitions

Nesting in Pytransitions


I have been looking at closed issues on github, SO, and googling to solve this issue. But I haven't been able to solve my problem and this seems to be the right place. I already opened an issue on github, but I am not sure if that was the right thing to do. I am making a state machine which can include several substates, which are also all state machines. So this basically boils down to reusing HSM according to readme.

my highest level SM looks like this:

from transitions.extensions import LockedHierarchicalMachine as Machine
from coordination.running import RunningStateMachine

logging.basicConfig(level=logging.ERROR)
logging.getLogger("transitions").setLevel(logging.INFO)

class RPPStateMachine(Machine):
    def __init__(self, name):
        self._running = RunningStateMachine()
        self.name = name
        states = [
            "init",
            {"name": "running", "children": self._running},
            "stop",
        ]

        Machine.__init__(self, states=states, initial="init")

        self.add_transition("e_run", "init", "run", after=self.run_machine)
        self.add_transition("e_stop", "*", "stop")

    def run_machine(self):
        self._running.initialize()

As you see a state machine with three states init, running and stop. Once the event e_run() is sent via something like

machine = RPPStateMachine("my_machine")
machine.e_run()

machine transitions to running state.

I do it in an indirect way because I wanted things to happen automatically. e_run() causes transition to running and afterwards run_machine calls initialize method of running class, which fires an event to start up the chain of events. Below I show running and that clears thing up.

So the running state is defined as

from transitions.extensions import LockedHierarchicalMachine as Machine
from coordination.test_mode import TestingStateMachine
from coordination.release_mode import ReleaseStateMachine

class RunningStateMachine(Machine):
    def __init__(self):
        self._test_mode = TestingStateMachine()
        self._release_demo = ReleaseStateMachine()
        states = [
            "init",
            "configuration",
            "idle",
            {"name": "test_mode", "children": self._test_mode},
            {"name": "release_mode", "children": self._release_mode},
        ]

        Machine.__init__(self, states=states, initial="init")
        self.add_transition("e_start_running", "init", "configuration", after=self.configuration)
        self.add_transition("e_success_config", "configuration", "idle")
        self.add_transition("e_test_mode", "idle", "test_mode")
        self.add_transition("e_release_mode", "idle", "release_mode")
        self.add_transition("e_start_running", "idle", "init")

    def initialize(self):
        print("Initialization step for running, emitting e_start.")
        self.e_start_running()

    def configuration(self):
        print("Configuring...")
        print( "Current state: " + self.state)

        self.e_success_config()

which similar to its parent, is composed of a few states and a few substates. I have also enabled logging to see which states I enter and exit. To my experience, nesting state machines is very useful as you can reuse the states you have written before. Besides as your state machine grows, it helps to keep things more modular. So no state becomes huge and difficult to read/understand.

So the unusual behavior is that when e_run() is called I get prints of

INFO:transitions.core:Entered state running
INFO:transitions.core:Entered state running_init
Initialization step for running, emitting e_start.
INFO:transitions.core:Exited state init
INFO:transitions.core:Entered state configuration
Configuring...
current state: configuration
INFO:transitions.core:Exited state configuration
INFO:transitions.core:Entered state idle

As you see

machine.state
>>> 'running_init'

while

machine._running.state
>>> 'idle'

I can of course move the transition definitions to the parent state, but that's unhandy. I cannot do that for all sub-states. Obviously, I want each substate to be responsible for it's own behavior. What is the common practice here? Is this a bug or intended behavior?

How can I neatly nest state machines under each other?


Solution

  • As of transitions 0.7.1, passing a state machine as a child of another state machine will copy all states of the passed machine to the parent. The passed state machine stays unaltered (as we discussed here).

    from transitions.extensions import MachineFactory
    
    HSM = MachineFactory.get_predefined(nested=True)
    
    fsm = HSM(states=['A', 'B'], initial='A')
    hsm = HSM(states=['1', {'name': '2', 'children': fsm}])
    
    # states object have been copied instead of referenced, they are not identical
    assert fsm.states['A'] is not hsm.states['2_A']
    hsm.to_2_A()
    
    # both machines work with different models
    assert fsm.models[0] is not hsm.models[0]
    assert fsm.state is not hsm.state
    

    The currently recommended workflow is to split models and machines and consider machines only as some sort of 'blueprint' for its parent:

    from transitions.extensions import MachineFactory
    
    
    class Model:
        pass
    
    
    HSM = MachineFactory.get_predefined(nested=True)
    
    # creating fsm as a blueprint, it does not need a model
    fsm = HSM(model=None, states=['A', 'B'], initial='A')
    # use a model AND also
    model = Model()
    hsm = HSM(model=['self', model], states=['1', {'name': '2', 'children': fsm}])
    
    # will only update the machine's state
    hsm.to_1()
    assert model.state != hsm.state
    # will update ALL model states
    hsm.dispatch("to_2_B")
    assert model.state == hsm.state
    

    However, this does not replace a properly isolated (and/or scoped) nesting of machines into parent machines. A feature draft has been created and will hopefully be realized in the foreseeable future.