Different source and target for same event
Hello!
Let's assume the following state machine:
stateDiagram-v2
[*] --> A
A --> B: event_2
A --> C: event_3
B --> A: event_1
B --> C: event_4
C --> D: event_4
D --> A: event_1
So event_1 triggers the transition from:
- B --> A
- D --> A
But event_4 triggers the transition from (different source and target in each case):
- B --> C
- C --> D
If I understood correctly this would result in the following code:
from finite_state_machine import StateMachine, transition
class ExampleStateMachine(StateMachine):
initial_state = "A"
def __init__(self):
self.state = self.initial_state
super().__init__()
@transition(source=["B", "D"], target="A")
def event_1(self):
print("Transitioning to A by event_1")
@transition(source="A", target="B")
def event_2(self):
print("Transitioning to B by event_2")
@transition(source="A", target="C")
def event_3(self):
print("Transitioning to C by event_3")
@transition(source="B", target="C")
@transition(source="C", target="D")
def event_4(self):
pass
The problem is with event_4 since it would require two different transitions
@transition(source="C", target="D")
@transition(source="B", target="C")
def event_4(self):
pass
But when I try to run this machine, I get the following error message:
fsm = ExampleStateMachine()
fsm.event_2()
fsm.event_1()
fsm.event_2()
fsm.event_4() # <- InvalidStartState exception raised here
fsm.event_4()
fsm.event_1()
fsm.event_3()
fsm.event_4()
fsm.event_1()
finite_state_machine.exceptions.InvalidStartState: Current state is B. event_4 allows transitions from ['C'].
Now I am not sure where my thinking error is. Is my code wrong or are multiple transitions really not supported?
Thanks a lot!
Possible workaround inspired by:
- https://github.com/Shuttl-Tech/simple-state-machine#what-if-my-function-has-2or-more-possible-transitions
- https://stackoverflow.com/a/35448590/9907540
from finite_state_machine import StateMachine, transition
class ExampleStateMachine(StateMachine):
initial_state = "A"
def __init__(self):
self.state = self.initial_state
super().__init__()
@transition(source=["B", "D"], target="A")
def event_1(self):
print("Transitioning to A by event_1")
@transition(source="A", target="B")
def event_2(self):
print("Transitioning to B by event_2")
@transition(source="A", target="C")
def event_3(self):
print("Transitioning to C by event_3")
@transition(source="B", target="C")
def _transition_to_C(self):
print("Transitioning to C by event_4")
@transition(source="C", target="D")
def _transition_to_D(self):
print("Transitioning to D by event_4")
def event_4(self):
if self.state == "C":
self._transition_to_D()
if self.state == "B":
self._transition_to_C()
Running this machine seems to work fine:
fsm = ExampleStateMachine()
fsm.event_2()
fsm.event_1()
fsm.event_2()
fsm.event_4() # <- works
fsm.event_4()
fsm.event_1()
fsm.event_3()
fsm.event_4()
fsm.event_1()
Transitioning to B by event_2
Transitioning to A by event_1
Transitioning to B by event_2
Transitioning to C by event_4
Transitioning to D by event_4
Transitioning to A by event_1
Transitioning to C by event_3
Transitioning to D by event_4
Transitioning to A by event_1
For reference, the desired finite state machine implemented with transitions which you also talked about in your talk:
from transitions import Machine, State
class SomeClass:
pass
class StateA(State):
def enter(self, event_data):
print("Entering State A")
def exit(self, event_data):
print("Exiting State A")
class StateB(State):
def enter(self, event_data):
print("Entering State B")
def exit(self, event_data):
print("Exiting State B")
class StateC(State):
def enter(self, event_data):
print("Entering State C")
def exit(self, event_data):
print("Exiting State C")
class StateD(State):
def enter(self, event_data):
print("Entering State D")
def exit(self, event_data):
print("Exiting State D")
states = [StateA(name="A"), StateB(name="B"), StateC(name="C"), StateD(name="D")]
transitions = [
{"trigger": "event_2", "source": "A", "dest": "B"},
{"trigger": "event_3", "source": "A", "dest": "C"},
{"trigger": "event_1", "source": "B", "dest": "A"},
{"trigger": "event_4", "source": "B", "dest": "C"},
{"trigger": "event_4", "source": "C", "dest": "D"},
{"trigger": "event_1", "source": "D", "dest": "A"},
]
some_object = SomeClass()
machine = Machine(some_object, states=states, transitions=transitions, initial="A")
Here, the finite state machine works as expected:
print(f"Initial State: {some_object.state}")
print()
print("Calling event_2")
some_object.event_2()
print(f"Now in State: {some_object.state}")
print()
print("Calling event_1")
some_object.event_1()
print(f"Now in State: {some_object.state}")
print()
print("Calling event_2")
some_object.event_2()
print(f"Now in State: {some_object.state}")
print()
print("Calling event_4")
some_object.event_4() # <- works
print(f"Now in State: {some_object.state}")
print()
print("Calling event_4")
some_object.event_4()
print(f"Now in State: {some_object.state}")
print()
print("Calling event_1")
some_object.event_1()
print(f"Now in State: {some_object.state}")
print()
print("Calling event_3")
some_object.event_3()
print(f"Now in State: {some_object.state}")
print()
print("Calling event_4")
some_object.event_4()
print(f"Now in State: {some_object.state}")
print()
print("Calling event_1")
some_object.event_1()
print(f"Now in State: {some_object.state}")
Initial State: A
Calling event_2
Exiting State A
Entering State B
Now in State: B
Calling event_1
Exiting State B
Entering State A
Now in State: A
Calling event_2
Exiting State A
Entering State B
Now in State: B
Calling event_4
Exiting State B
Entering State C
Now in State: C
Calling event_4
Exiting State C
Entering State D
Now in State: D
Calling event_1
Exiting State D
Entering State A
Now in State: A
Calling event_3
Exiting State A
Entering State C
Now in State: C
Calling event_4
Exiting State C
Entering State D
Now in State: D
Calling event_1
Exiting State D
Entering State A
Now in State: A
And illegal transitions are still not possible:
print(f"Current State: {some_object.state}")
print("Calling event_4")
some_object.event_4()
print(f"Now in State: {some_object.state}")
Current State: A
Calling event_4
->
transitions.core.MachineError: "Can't trigger event event_4 from state A!"
@ptrstn Thanks for the detailed write-up!
Right now each transition function is limited to a single transition decorator. The workaround you posted looks like it works, but definitely not the cleanest solution as you are working around the limitations of this library.
A couple of months ago, I refactored the @transition decorator implementation from a function to a class. With this new class-based approach, we can store metadata about each State Machine inside of the class object. When performing a transition, we can do a match on source state to make sure the correct transition decorator is applied.
Things I'm thinking about
- does it make sense to have more than 1 decorator per transition function?
- very easy to add another function with a decorator to do the same thing, but this could result in code duplication
- I have built state machines using transitions that only used a
proceedtransition function to move the machine thru the various states so I get the use case
- what happens if 2 transition decorators have the same source state? which decorator is respected? should we raise an Exception if a state machine is built this way?
- how does django-fsm handle this situation? I would need to work through an example
- re: simple state machine's solution for handling payment failure and payment success
- there is an
on_errorparameter that would be a better solution for this use case
- there is an
What are your thoughts?
(Right now it's Advent of Code season, I probably won't be able to try out a solution until next year. Definitely open to discussing it further to find the best solution)
- does it make sense to have more than 1 decorator per transition function?
I was thinking the same first, but I am not sure how else you would address this problem. Maybe a dictionary of transition values rather than just a single value or list? I think this would break the current public interface though.
- what happens if 2 transition decorators have the same source state? which decorator is respected? should we raise an Exception if a state machine is built this way?
I would raise an AmbiguousTransition or UndeterministicBehaviour Exception or something like that.
- how does django-fsm handle this situation? I would need to work through an example
Unfortunately I have no experience with this package, but from what I've read in their GitHub documentation, ~it looks like they also only support one @transition decorator~
Update: see https://github.com/alysivji/finite-state-machine/issues/35#issuecomment-988388607
I just checked what the transitions package does when two transitions have the same source state. It seems like it just does whatever was set first.
stateDiagram-v2
[*] --> A
A --> B: event_2
A --> C: event_3
B --> A: event_1
B --> C: event_4
C --> D: event_4
C --> A: event_4
D --> A: event_1
transitions = [
{"trigger": "event_2", "source": "A", "dest": "B"},
{"trigger": "event_3", "source": "A", "dest": "C"},
{"trigger": "event_1", "source": "B", "dest": "A"},
{"trigger": "event_4", "source": "B", "dest": "C"},
{"trigger": "event_4", "source": "C", "dest": "A"},
{"trigger": "event_4", "source": "C", "dest": "D"},
{"trigger": "event_1", "source": "D", "dest": "A"},
]
-> ignores {"trigger": "event_4", "source": "C", "dest": "D"}
transitions = [
{"trigger": "event_2", "source": "A", "dest": "B"},
{"trigger": "event_3", "source": "A", "dest": "C"},
{"trigger": "event_1", "source": "B", "dest": "A"},
{"trigger": "event_4", "source": "B", "dest": "C"},
{"trigger": "event_4", "source": "C", "dest": "D"},
{"trigger": "event_4", "source": "C", "dest": "A"},
{"trigger": "event_1", "source": "D", "dest": "A"},
]
-> ignores {"trigger": "event_4", "source": "C", "dest": "A"}