pytransitions / transitions

A lightweight, object-oriented finite state machine implementation in Python with many extensions
MIT License
5.49k stars 524 forks source link

Omit on_enter / on_exit events for reflexsive triggers #555

Closed Blindfreddy closed 2 years ago

Blindfreddy commented 2 years ago

You have an idea for a feature that would make transitions more helpful or easier to use? Great! We are looking forward to your suggestion.

Is your feature request related to a problem? Please describe.

Currently, when a machine is in a given state and a transition results in the same state, the machine exits the current state and enters it, ie. on_exit and on_enter callbacks are called, despite the machine already being in the target state.

I find this highly unintuitive and had to write code to check the current state and omit calling a transition that would result in the same state. A workaround would be to exclude the current state from allowed source states, but this would result in an exception being triggered, so masking / compensation would still be required.

An example is a light switch. When one presses the on-switch of a light that is already on, it doesn't first turn off and then on again. It just stays on.

Describe the solution you'd like

I would like it to be configurable so that 'on_exit' and 'on_enter' events (and possible others) are NOT fired when a transition results in the same state. Perhaps a boolean 'reflexive_trigger_state_events'=<True/False>. If True, the current behavior is kept, if False the abovementioned events don't fire.

Additional context

A simple example shows the problem - note a the end that the state ON is kept but exited and then re-entered.

from transitions import Machine, State

class Switch(object):
    states = [State(name='ON', on_enter=['on_enter_ON'], on_exit=['on_exit_ON']),
              State(name='OFF', on_enter=['on_enter_OFF'], on_exit=['on_exit_OFF'])]
    transitions = [
        { 'trigger': 'swon', 'source': '*', 'dest': 'ON' },
        { 'trigger': 'swoff', 'source': '*', 'dest': 'OFF' },
    ]
    def __init__(self, name):
        self.name = name
        self.machine = Machine(model=self, states=Switch.states, transitions=Switch.transitions, initial="OFF") 

    def on_enter_ON(self):
        print("    callback on_enter_ON()")

    def on_enter_OFF(self):
        print("    callback on_enter_OFF()")

    def on_exit_ON(self):
        print("    callback on_exit_ON()")

    def on_exit_OFF(self):
        print("    callback on_exit_OFF()")

def main():
    sw = Switch('my_switch')
    print(f"machine m initialized, state {sw.state}")
    print("switching on.")
    sw.swon()
    print("switching on again.")
    sw.swon()

if __name__ == "__main__":
    main()

Output:

machine m initialized, state OFF
switching on.
    callback on_exit_OFF()
    callback on_enter_ON()
switching on again.
    callback on_exit_ON().      <-- this event should not fire b/c switch is already on
    callback on_enter_ON().    <-- same for this event

Note how a switch which is already in the ON state exits from state ON and re-enters state ON when switched on again.

aleneum commented 2 years ago

Hello @Blindfreddy,

thank you for the suggestion. Currently, I would say that this use case can be covered with internal transitions.

transitions distinguished between reflexive and internal transitions. Reflexive transitions leave the state and reenter it again. As far as I know, this is the common behavior for 'self transitions' across statechart/FSM libraries. Wildcards will treat transitions from/to the same state as reflexive transitions.

Since transitions are evaluated in the order they were added, you can make use of internal transitions (with dest=None) to prevent an actual state change:

    transitions = [
        {'trigger': 'swon', 'source': 'ON', 'dest': None},
        {'trigger': 'swon', 'source': '*', 'dest': 'ON'},
        {'trigger': 'swoff', 'source': 'OFF', 'dest': None},
        {'trigger': 'swoff', 'source': '*', 'dest': 'OFF'},
    ]

If you dont want to explicitly model internal transitions you could make a custom machine inherit from Machine and always add an internal transitions with the same event name.

class MyMachine(Machine):

    def add_transition(self, trigger, source, dest, conditions=None,
                       unless=None, before=None, after=None, prepare=None, **kwargs):
        if source == "*":
            super().add_transition(trigger, dest, None, conditions, unless, before, after, prepare, **kwargs)
        super().add_transition(trigger, source, dest, conditions, unless, before, after, prepare, **kwargs)

MyMachine.add_transition could be a bit smarter by omitting the reflexive transition that is automatically added by just forwarding the wildcard.

Blindfreddy commented 2 years ago

Thanks for this suggestion, I tend to agree that the UC can be covered by internal transitions as suggested above. I will try it out in my application to confirm. When I first started using transitions they didn't exist, yet.

Separately, I'd still suggest to make this behavior an option/configuration/feature of the library, because I still find it unintuitive behavior, at least in some circumstances. On the other hand, it may well be the desired/required behavior in some circumstances, e.g the mentioned StateCharts. That indicates a configurable behavior, to me. To that end a new Machine extension (aka mixins in your documentation) which implements above extension of add_transition might be an idea. Perhaps named 'StickyStatemachine' or similar, where the word 'sticky' indicates that reflexive transitions don't exit/re-enter when source and dest states are identical.

aleneum commented 2 years ago

Thank you for your feedback, @Blindfreddy. I get your point but right now I am not convinced that another parameter is a good idea. For newcomers, the amount of parameters is already quite daunting as far as I can tell and this is why I try to follow standards when possible. According to the Wikipedia passage about internal transitions the difference between self/reflexive transitions and internal transitions is the fact that reflexive transitions will exit and re-enter the source and target state:

In the absence of entry and exit actions, internal transitions would be identical to self-transitions (transitions in which the target state is the same as the source state). In fact, in a classical Mealy machine, actions are associated exclusively with state transitions, so the only way to execute actions without changing state is through a self-transition [...]. However, in the presence of entry and exit actions, as in UML statecharts, a self-transition involves the execution of exit and entry actions and therefore it is distinctively different from an internal transition.

As of now, I would conclude that transitions already offers the means to design and configure self-transitions and internal transitions and thus state transitions either with exit/enter callbacks or without them.

This is of course no decision meant to last forever. I am open to further discussion and should the community favor another option to make reflexive transitions act as internal transitions I am willing to implement and maintain this. For now I will close this issue. But again, feel free to comment anyway.

harrisonmg commented 1 year ago

My two cents: I think this feature would be especially useful for hierarchical state machines. I use super states to abstract out some transitions from multiple sub states, but I don't necessary want that trigger to cause reflexive transitions. For example:

from transitions.extensions import HierarchicalMachine

states = [
    {'name': 'a', 'children': ['b', 'c', 'd'], 'initial': 'b'}
]

transitions = [
    ['button_pressed', 'a', 'd']
]

machine = HierarchicalMachine(states=states, transitions=transitions, initial='a')

This allows entry and exit actions to contain operations that we only want to do on state change. This is important when any kind of physical operation is involved or when state entry involves kicking off a separate task.

I also use triggers to handle commands from users or separate services. This feature would additionally make it trivial to handle duplicate commands. For example: a user spams a button, multiple services send the same trigger, or a service wants to send a trigger without knowledge of the current state.

For now, I will try to implement this in an HSM subclass and reply if I find a good way. Manually adding the internal transitions is possible but scales quite poorly.