fgmacedo / python-statemachine

Python Finite State Machines made easy.
MIT License
854 stars 84 forks source link

Event "Union" does not execute transition actions #453

Closed ostetzer closed 2 months ago

ostetzer commented 2 months ago

Description

Describe what you were trying to get done. As in the traffic light example, I combined several transitions int a "cycle" with a union. e.g. shutdown_cycle = stop | shutdown My expectation is, that when I call that "union" event, then the appropriate transition (based on curernt state) is executed.

Tell us what happened, what went wrong, and what you expected to happen. When I tried that, the state change did happen, but the actions of a the transitions are not executed.

In the example, I call the shutdown-cycle twice from state running and i expect the transitions stop and shutdown to be executed. However, the functions on_stop() and on_shutdown() are not called/executed when i call shutdown_cycle()

What I Did

I created a minimum example to demonstrate my findings. IS my expectations wrong?

Minimum example:

from statemachine import StateMachine, State

class test_FSM(StateMachine):
    init = State(initial=True)
    standby = State()
    running = State()
    off = State(final=True)

    start = init.to(standby)
    run = standby.to(running)
    stop = running.to(standby)
    shutdown = standby.to(off)

    shutdown_cycle = stop | shutdown

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

    def on_start(self):
        print("initialize to standby")

    def on_run(self):
        print("changed to running")

    def on_stop(self):
        print("stopping")

    def on_shutdown(self):
        print("shutting down")

if __name__ == '__main__':
    test_machine = test_FSM()
    test_machine.start()
    test_machine.run()
    print(test_machine.current_state)
    test_machine.shutdown_cycle()
    print(test_machine.current_state)
    test_machine.shutdown_cycle()

output:

initialize to standby
changed to running
Running
Standby
Off
ostetzer commented 2 months ago

I have experimented a bit more. When I define the event like this:

stop = running.to(standby, event="shutdown_cycle")
shutdown = standby.to(off, event="shutdown_cycle")

the function on_stop() or on_shutdown() is also not called, when I call the event like in the example above.

fgmacedo commented 2 months ago

Hi @ostetzer , how are you? Thanks for getting in touch.

Sorry about the misleading behavior. This theme is a huge opportunity to improve the docs.

This is indeed the expected behavior... To make it clear, I've created a graph representation of the example state machine you provided:

>>> test_machine._graph().write_png("test_FSM.png")

test_FSM

So, what's going on?

Note that on this graph, you have the transitions represented as the edges (arcs) that connect the states, and the events as labels attached to these edges.

Note that the transition is created using the pattern <origin>.to(<destination>), as in running.to(standby) by example.

When we assign the transition list to a variable at the class level, we're defining an event. So by declaring stop = running.to(standby), we're defining an event called stop, that will be bound to the transition running.to(standby).

And by declaring shutdown_cycle = stop | shutdown, we're assigning the two list of transitions at stop and shutdown to another event called shutdown_cycle.

Given that you're binding actions by naming convention, by definition:

The action will be registered for every Transition associated with the event.

The key here is that using this naming convention pattern, the action is associated with the event name, not with the transition. The action name matters and must match the pattern of the event.

Why the actions on_stop or on_shutdown are not performed when I trigger the event shutdown_cycle: Answer: Because these actions are related to the events stop or shutdown, not to the event shutdown_cycle.

How to accomplish the expected behaviour

You can explicitly bind the action to the transition itself, using params, like running.to(standby, on="on_stop"). Note that now the name of the event does not matter, the only requirement is to have a method, attribute or property with the name specified.

class test_FSM(StateMachine):
    init = State(initial=True)
    standby = State()
    running = State()
    off = State(final=True)

    start = init.to(standby)
    run = standby.to(running)
    stop = running.to(standby, on="on_stop")
    shutdown = standby.to(off, on="on_shutdown")

    shutdown_cycle = stop | shutdown

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

    def on_start(self):
        print("initialize to standby")

    def on_run(self):
        print("changed to running")

    def on_stop(self):
        print("stopping")

    def on_shutdown(self):
        print("shutting down")

So if you go with this alternative, I suggest changing the action name to another thing not similar to the naming convention, just to make explicitly that you're binding an action to the transition itself.

Like this:

class test_FSM(StateMachine):
    init = State(initial=True)
    standby = State()
    running = State()
    off = State(final=True)

    start = init.to(standby)
    run = standby.to(running)
    stop = running.to(standby, on="_on_stop")
    shutdown = standby.to(off, on="_on_shutdown")

    shutdown_cycle = stop | shutdown

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

    def on_start(self):
        print("initialize to standby")

    def on_run(self):
        print("changed to running")

    def _on_stop(self):
        print("stopping")

    def _on_shutdown(self):
        print("shutting down")

The last possibility is to explicitly bind the action to a transitions list using decorators, again, with this binding the name of the action method does not matter.

class test_FSM(StateMachine):
    init = State(initial=True)
    standby = State()
    running = State()
    off = State(final=True)

    start = init.to(standby)
    run = standby.to(running)
    stop = running.to(standby)
    shutdown = standby.to(off)

    shutdown_cycle = stop | shutdown

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

    def on_start(self):
        print("initialize to standby")

    def on_run(self):
        print("changed to running")

    @stop.on
    def _on_stop(self):
        print("stopping")

    @shutdown.on
    def _on_shutdown(self):
        print("shutting down")

Extra

Just to make a point on how things work internally, the <event_name> = <source_state>.to(<target_state>) is only syntatic sugar for <source_state>.to(<target_state>, event="<event_name>).

Example that also works as you expect:

class test_FSM(StateMachine):
    init = State(initial=True)
    standby = State()
    running = State()
    off = State(final=True)

    init.to(standby, event="start")
    standby.to(running, event="run")
    running.to(standby, event=["stop", "shutdown_cycle"], on="_on_stop")
    standby.to(off, event=["shutdown", "shutdown_cycle"], on="_on_shutdown")

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

    def on_start(self):
        print("initialize to standby")

    def on_run(self):
        print("changed to running")

    def _on_stop(self):
        print("stopping")

    def _on_shutdown(self):
        print("shutting down")

What do you think about this alternative syntax? :)

Please let me know if I have clarified the behavior, or if you have any other questions.

ostetzer commented 2 months ago

Thank you so much for this detailed answer with even different options. Yes, please add this to the documentation asI hope it might help others too. I like the decorator version and will implement this. Thanks a lot! :-)

fgmacedo commented 2 months ago

You're very welcome! I'm glad the options were helpful. I'll definitely document this for others too. Best!