Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implementation feedback: Machine fails to transition to a new state before raising. #517

Open
comalice opened this issue Jan 20, 2025 · 3 comments

Comments

@comalice
Copy link
Contributor

comalice commented Jan 20, 2025

OS: Windows 11
python-statemachine: 2.5.0

Unable to transition states at the right time. (on does not execute when I expected it to)

I am attempting to catch an exception, transition to a new state, and then raise the exception for the user to handle. Here is my minimal reproduction:

from statemachine import StateMachine, State


class ExceptionStateMachineExample(StateMachine):
    # States
    start = State(initial=True)
    exception_raised = State()
    end = State(final=True)

    # Transitions
    run = start.to(end, on="_do_action")
    error_occurred = start.to(exception_raised)

    def _do_action(self):
        try:
            raise AttributeError()
        except Exception as e:
            self.error_occurred()
            raise e

    # Debug actions
    def before_transition(self, event, state):
        print(f"Before '{event}', on the '{state.id}' state.")
        return "before_transition_return"

    def on_transition(self, event, state):
        print(f"On '{event}', on the '{state.id}' state.")
        return "on_transition_return"

    def on_exit_state(self, event, state):
        print(f"Exiting '{state.id}' state from '{event}' event.")

    def on_enter_state(self, event, state):
        print(f"Entering '{state.id}' state from '{event}' event.")

    def after_transition(self, event, state):
        print(f"After '{event}', on the '{state.id}' state.")


s = ExceptionStateMachineExample()
try:
    s.run()
except AttributeError:
    pass

assert s.current_state.id == "exception_raised"

And the output appears as:

$ python.exe exception_example.py 
exception_example.py:4: UserWarning: All non-final states should have at least one outgoing transition. These states have no outgoing transition: ['exception_raised']
  class ExceptionStateMachineExample(StateMachine):
.venv\lib\site-packages\statemachine\factory.py:130: UserWarning: All non-final states should have at least one path to a final state. These states have no path to a final state: ['exception_raised']
  warnings.warn(message, UserWarning, stacklevel=1)
Traceback (most recent call last):
  File "exception_example.py", line 46, in <module>
    assert s.current_state.id == "exception_raised"
AssertionError
Entering 'start' state from '__initial__' event.
Before 'run', on the 'start' state.
Exiting 'start' state from 'run' event.
On 'run', on the 'start' state.

Process finished with exit code 1

We enter the run state, encounter an exception in the on action, catch the exception in the action, attempt to transition to a new state, and then re-raise the exception.

Looking at the debug output from the transition decorators, we see that the error_encountered transition is never triggered.

If we comment out the re-raise, so that _do_action is thus:

    def _do_action(self):
        try:
            raise AttributeError()
        except Exception as e:
            self.error_occurred()
            # raise e

We see that the machine has already entered the end state by the time the action is run:

$ python.exe exception_example.py 
exception_example.py:4: UserWarning: All non-final states should have at least one outgoing transition. These states have no outgoing transition: ['exception_raised']
  class ExceptionStateMachineExample(StateMachine):
C:\source\git\gtd_test_stepper\.venv\lib\site-packages\statemachine\factory.py:130: UserWarning: All non-final states should have at least one path to a final state. These states have no path to a final state: ['exception_raised']
  warnings.warn(message, UserWarning, stacklevel=1)
Traceback (most recent call last):
  File "C:\source\git\gtd_test_stepper\gtd_test_stepper\exception_example.py", line 42, in <module>
    s.run()
  File ".venv\lib\site-packages\statemachine\event.py", line 133, in __call__
    result = machine._processing_loop()
  File ".venv\lib\site-packages\statemachine\statemachine.py", line 112, in _processing_loop
    return self._engine.processing_loop()
  File ".venv\lib\site-packages\statemachine\engines\sync.py", line 67, in processing_loop
    result = self._trigger(trigger_data)
  File ".venv\lib\site-packages\statemachine\engines\sync.py", line 98, in _trigger
    raise TransitionNotAllowed(trigger_data.event, state)
statemachine.exceptions.TransitionNotAllowed: Can't error_occurred when in End.
Entering 'start' state from '__initial__' event.
Before 'run', on the 'start' state.
Exiting 'start' state from 'run' event.
On 'run', on the 'start' state.
Entering 'end' state from 'run' event.
After 'run', on the 'end' state.

Process finished with exit code 1

I would expect that the on action is executed in order, rather then deferred until after the transition to the next stage.

Alternative implementation, but not able to raise.

One way around this could be making the on action a cond, and eliminate the error_encountered transition.

class ExceptionStateMachineExample(StateMachine):
    # States
    start = State(initial=True)
    exception_raised = State()
    end = State(final=True)

    # Transitions
    run = (start.to(end, cond="_do_action")
           | start.to(exception_raised))

    def _do_action(self) -> bool:
        try:
            # Do work that fails.
            raise AttributeError()
            return True
        except Exception as e:
            return False

    # Debug actions
    def before_transition(self, event, state):
        print(f"Before '{event}', on the '{state.id}' state.")
        return "before_transition_return"

    def on_transition(self, event, state):
        print(f"On '{event}', on the '{state.id}' state.")
        return "on_transition_return"

    def on_exit_state(self, event, state):
        print(f"Exiting '{state.id}' state from '{event}' event.")

    def on_enter_state(self, event, state):
        print(f"Entering '{state.id}' state from '{event}' event.")

    def after_transition(self, event, state):
        print(f"After '{event}', on the '{state.id}' state.")


s = ExceptionStateMachineExample()
try:
    s.run()
except AttributeError:
    pass

assert s.current_state.id == "exception_raised"

Which results in:

$ python exception_example.py
Entering 'start' state from '__initial__' event.
Before 'run', on the 'start' state.
Exiting 'start' state from 'run' event.
On 'run', on the 'start' state.
Entering 'exception_raised' state from 'run' event.
After 'run', on the 'exception_raised' state.

Process finished with exit code 0

I am unsure as to how I would raise the original exception here. I suppose I could add an action, ie start.to(exception_raised, on="_reraise_exception"), but then I'd need to handle passing the exception object to this action. (Maybe using a class instance variable? Doing it this way has some code-strink about it.)

Conclusion

I'd like to be able to transition, then raise from within an action so that the user of my code has to handle the exception as well as recovery from the exception.

Do you have any recommendations as to how I might manage this?

@comalice
Copy link
Contributor Author

Looking at the debug output a little more, I see my assumption on execution order has been refuted from the beginning:

  • enter
  • before
  • exiting
  • on

I would expect the on step to execute before exiting a state?

@comalice
Copy link
Contributor Author

What I am trying to do is this:

  1. transition from a non-running state to a running state
  2. do some action while in the running state
  3. inspect the output of the action (pass, fail, exception raised) and transition to the appropriate followup state

It almost seems like I need to transition to an 'exception raised' state, and then raise the exception for the user to deal with?

@comalice
Copy link
Contributor Author

comalice commented Jan 20, 2025

The following appears to work well:

from statemachine import StateMachine, State


class ExceptionStateMachineExample(StateMachine):
    # States
    start = State(initial=True)
    running = State()
    exception_raised = State()
    end = State(final=True)

    # Transitions
    run = start.to(running, on="_do_action")  # <-- As action 'on transition named run'
    _pass = running.to(end)
    fail = running.to(exception_raised, after="_raise_exception")  # <-- We raise an exception in an action _after_ the transition to the new state is complete.

    def _do_action(self):  # <-- no longer returns anything
        try:
            # Do work that fails.
            raise AttributeError()

            # If nothing bad happened.
            self._pass()  # <-- We only do a state transition here, we don't return anything.
        except Exception as e:
            # Something bad happened.
            self.fail(e)

    def _raise_exception(self, exc: Exception):
        raise exc

    # Debug actions
    def before_transition(self, event, state):
        print(f"Before '{event}', on the '{state.id}' state.")
        return "before_transition_return"

    def on_transition(self, event, state):
        print(f"On '{event}', on the '{state.id}' state.")
        return "on_transition_return"

    def on_exit_state(self, event, state):
        print(f"Exiting '{state.id}' state from '{event}' event.")

    def on_enter_state(self, event, state):
        print(f"Entering '{state.id}' state from '{event}' event.")

    def after_transition(self, event, state):
        print(f"After '{event}', on the '{state.id}' state.")


s = ExceptionStateMachineExample()
try:
    s.run()
except AttributeError:
    print("Got attribute error")

assert s.current_state.id == "exception_raised"

With output

$ python.exe exception_example.py 
Entering 'start' state from '__initial__' event.
Before 'run', on the 'start' state.
Exiting 'start' state from 'run' event.
On 'run', on the 'start' state.
Entering 'running' state from 'run' event.
After 'run', on the 'running' state.
Before 'fail', on the 'running' state.
Exiting 'running' state from 'fail' event.
On 'fail', on the 'running' state.
Entering 'exception_raised' state from 'fail' event.
Got attribute error

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant