1

I am using the transitions FSM library. Imagine having an application FSM using the following code:

from transitions import Machine

import os

class Application(object):

  states = ["idle", "data_loaded"]

  def __init__(self):
    self.data = None
    machine = Machine(model=self, states=Application.states, initial="idle")
    machine.add_transition("filename_dropped",
                           source="idle",
                           dest="data_loaded",
                           before="load_data",
                           conditions="is_valid_filename")
    self.machine = machine

  def drop_filename(self, filename):
    try:
      self.filename_dropped(filename)
    except IOError as exc:
      print "Oops: %s" % str(exc)

  def load_data(self, filename):
    with open(filename) as file:
      self.data = file.read()

  def is_valid_filename(self, filename):
    return os.path.isfile(filename)

It can throw an IOError within load_data. My question is whether it is safe to raise exceptions (either implicitly like in this example or explicitly) from within before callbacks? In case of an IOError the transition is not taking place, the state of this example remains idle and any after callbacks are not being invoked. However, I wonder whether the internal state of the machine instance might get corrupted.

Additional question: Are there any better ways to signal errors with concrete information to the application? In this very example I could use the condition to load the file, but this seems ugly and I would need some additional attribute to keep track of the error etc.

Thanks for any help or advice.

jruizaranguren
  • 12,679
  • 7
  • 55
  • 73
Daniel
  • 311
  • 3
  • 12
  • About your additional question: What do you not like about raising Exceptions to inform your application about errors? – aleneum Sep 07 '16 at 10:34
  • I am fine with raising exceptions, i was referring to the example with the file load that I don't want to open that file twice (once in the condition and once in the 'before' slot). I am probably using the condition feature here in an unusual way. It is probably meant for some things that are allowed based on some additional information. – Daniel Sep 07 '16 at 12:55

1 Answers1

3

However, I wonder whether the internal state of the machine instance might get corrupted.

It is fine to work with raising Exceptions in callback functions unless you plan to use the queued feature of transitions.

Transitions are executed in the following order:

prepare -> conditions -> before -> on_exit -> set_state -> on_enter -> after

If anything before set_state raises an Exception or a function in conditions does not return True, the transition is halted.

Your model might be in an undefined state though. If you rely on some 'clean up' or 'tear down' in State.on_enter or after:

from transitions import Machine
class Model:
    def __init__(self):
        self.busy = False
    def before(self):
        self.busy = True
        raise Exception('oops')
    def after(self):
        # if state transition is done, reset busy
        self.busy = False

model = Model()
m = Machine(model, states=['A','B'], initial='A',
            transitions=[{'trigger':'go', 'source':'A', 'dest':'B',
                          'before':'before', 'after':'after'}])
try:
  model.go()
except Exception as e:
  print "Exception: %s" % e # Exception: oops
print "State: %s" % model.state # State: A
print "Model busy: %r" % model.busy # Model busy: True

Are there any better ways to signal errors with concrete information to the application?

It depends on what you want to achieve. Raising Errors/Exceptions usually halts the execution of a current task. In my oppinion this is pretty much THE way to propagate issues. If you want to handle the error and represent the error as a state, I would not consider using conditions ugly. Valid transitions with the same trigger are evaluated in the order they were added. With this in mind and the use of unless which is negated notation for conditions, your code could look like this:

from transitions import Machine
import os


class Application(object):
  states = ["idle", "data_loaded", "filename_invalid", "data_invalid"]

  transitions = [
    {'trigger': 'filename_dropped', 'source': 'idle', 
     'dest': 'filename_invalid', 'unless': 'is_valid_filename'},
    {'trigger':'filename_dropped', 'source': 'idle',
     'dest':'data_invalid', 'unless': 'is_valid_data'},
    {'trigger':'filename_dropped', 'source': 'idle',
     'dest':'data_loaded'}
  ]

  def __init__(self):
    self.data = None
    machine = Machine(model=self, states=Application.states,
                      transitions=Application.transitions, initial="idle")
    self.machine = machine

  def drop_filename(self, filename):
      self.filename_dropped(filename)
      if self.is_data_loaded():
          print "Data loaded"

  # renamed load_data
  def is_valid_data(self, filename):
    try:
      with open(filename) as file:
        self.data = file.read()
    except IOError as exc:
      print "File loading error: %s" % str(exc)
      return False
    return True

  def is_valid_filename(self, filename):
    return os.path.isfile(filename)

app = Application()
app.drop_filename('test.txt')
# >>> Data loaded
print app.state
# >>> data_loaded
app.to_idle()
app.drop_filename('missing.txt')
print app.state
# >>> filename_invalid
app.to_idle()
app.drop_filename('secret.txt')
# >>> File loading error: [Errno 13] Permission denied: 'secret.txt'
print app.state
# >>> data_invalid

This will create this state machine:

enter image description here

aleneum
  • 2,083
  • 12
  • 29
  • Thank you very much for this detailed answer. I now understand the conditions/unless feature much better. I was using it the wrong way, I guess. This example of the "file reader" makes much more sense. – Daniel Sep 07 '16 at 12:59