fgmacedo / python-statemachine

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

@<transition>.cond decorator bug, general question about running code on every transition even if TransitionNotAllowed is raised #428

Closed lucaswhipple-sfn closed 4 months ago

lucaswhipple-sfn commented 4 months ago

Description

I am trying to add a cond callback to a transitionlist.

My minimal example SHOULD work - according to this section of the docs the @cycle.cond decorator syntax should add a condition to my transitionlist. However, I always get an error when I try this method (see output below)

More broadly, I have a block of code I want to run each time any transition is called, even if a TransitionNotAllowed error is raised.

Given the resolution order documented in the docs (repeated below) validators() (attached to the transition) conditions() (attached to the transition) unless() (attached to the transition) beforetransition() before() on_exit_state() onexit() ontransition() on() on_enter_state() onenter() after_() after_transition()

If I always want to have code run each time a transition is called, I have to associate it with a Validator or a Condition. I don't want to have to define it for each individual state because my actual state machine is quite large.

I could bind the code to the on_transition method, but if I understand correctly this won't run if a transition not allowed is raised (see my second example)

Is there a good way to ensure code runs:

  1. Every time a particular transition is called, regardless of if it raises a TransitionNotAllowed?
  2. Every time any transition is called, regardless of if it raises a TransitionNotAllowed?

What I Did

Here is a minimal example:

from statemachine import StateMachine, State, exceptions
class ExampleStateMachine(StateMachine):
    idle = State("idle")
    moving = State("Moving!")
    stopped = State("Stopped")

    start_moving = moving.from_(idle,stopped)
    move_from_stop = stopped.to(moving)
    stop_from_moving = moving.to(stopped)
    cycle = move_from_stop | stop_from_moving

    @cycle.cond
    def cycle_cond(self):
        print("This should run")
        return True

exm = ExampleStateMachine()
exm.cycle()

Output:

minimal_cycle_example.py", line 12, in ExampleStateMachine
    @cycle.cond
     ^^^^^^^^^^
AttributeError: 'TransitionList' object has no attribute 'cond'

Alternative version, where I try to run code in on_transition:

from statemachine import StateMachine, State, exceptions
class ExampleStateMachine(StateMachine):
    idle = State("idle", initial = True)
    moving = State("Moving!")
    stopped = State("Stopped")
    forbidden_state = State("Forbidden!")

    start_moving = moving.from_(idle,stopped)
    move_from_stop = stopped.to(moving)
    stop_from_moving = moving.to(stopped)
    cycle = move_from_stop | stop_from_moving

    #@cycle.cond
    def cycle_cond(self):
        print("This should run")
        return True

    def on_transition(self):
        print("This should print on a transition!")

    def always_return_false(self):
        return False

    forbidden_transition = idle.to(forbidden_state, cond = always_return_false)

exm = ExampleStateMachine()
exm.forbidden_transition()

Output:


    raise TransitionNotAllowed(event_data.event, event_data.state)
statemachine.exceptions.TransitionNotAllowed: Can't forbidden_transition when in idle.
lucaswhipple-sfn commented 4 months ago

Update:

I made a mock machine that works, but it uses the private method _add_callback to add a callback to a transition list.

This has a disadvantage - it binds the condition to every transition in the transitionlist, which for some cases may be desirable, but I can imagine only wanting it bound to the transitionlist instead of the elements of the transitionlist.

I think this will resolve my issue in the short-term, but I think it would be worthwhile to fix the decorator bug and also perhaps correct the documentation.

from statemachine import StateMachine, State, exceptions
from statemachine.exceptions import TransitionNotAllowed
class ExampleStateMachine(StateMachine):
    idle = State("idle", initial = True)
    moving = State("Moving!")
    stopped = State("Stopped")
    forbidden_state = State("Forbidden!")

    start_moving = moving.from_(idle,stopped)
    move_from_stop = stopped.to(moving)
    stop_from_moving = moving.to(stopped)
    cycle = move_from_stop | stop_from_moving

    #@cycle.cond
    def cycle_cond(self):
        print("This should run")
        return True

    def on_transition(self):
        print("This should print on a transition!")

    def always_return_false(self):
        return False

    forbidden_transition = idle.to(forbidden_state, cond = always_return_false)
    cycle._add_callback(cycle_cond, "cond")

exm = ExampleStateMachine()
try:
    exm.forbidden_transition()
except TransitionNotAllowed:
    print("Transition not allowed")
exm.start_moving()
exm.cycle()
print(exm.current_state)
exm.move_from_stop()

output:

Transition not allowed
This should print on a transition!
This should run
This should print on a transition!
State('Stopped', id='stopped', value='stopped', initial=False, final=False)
This should run
This should print on a transition!
lucaswhipple-sfn commented 4 months ago

Well, this is embarrassing - I was using statemachines version 1.0.3. Now that I've upgraded, the decorator works. Sorry!

Working example -

from statemachine import StateMachine, State, exceptions
from statemachine.exceptions import TransitionNotAllowed
class ExampleStateMachine(StateMachine):
    idle = State("idle", initial = True)
    moving = State("Moving!")
    stopped = State("Stopped")
    forbidden_state = State("Forbidden!")

    start_moving = moving.from_(idle,stopped)
    move_from_stop = stopped.to(moving)
    stop_from_moving = moving.to(stopped)
    cycle = move_from_stop | stop_from_moving
    forbidden_transition = idle.to(forbidden_state, cond = "always_return_false")

    @cycle.cond
    def cycle_cond(self):
        print("This should run")
        return True

    def on_transition(self):
        print("This should print on a transition!")

    def always_return_false(self):
        return False

    #cycle._add_callback(cycle_cond, "cond")

exm = ExampleStateMachine()
try:
    exm.forbidden_transition()
except TransitionNotAllowed:
    print("Transition not allowed")
exm.start_moving()
exm.cycle()
print(exm.current_state)
exm.move_from_stop()

output

Transition not allowed
This should print on a transition!
This should run
This should print on a transition!
State('Stopped', id='stopped', value='stopped', initial=False, final=False)
This should run
This should print on a transition!
fgmacedo commented 4 months ago

Hi @lucaswhipple-sfn , nice it worked as expected!