Skip to content

Commit

Permalink
Add a new type of transition callback, prepare
Browse files Browse the repository at this point in the history
Prepare is called as soon as the trigger is executed, allowing a function to be automatically executed before the transition conditions are checked.
This doesn't allow anything new to be done, but does simplify code using the library as functions modifying the model before a transition do not need to be manually invoked by the user.
  • Loading branch information
TheMysteriousX committed Feb 11, 2016
1 parent 98fbc8c commit b85a8bd
Show file tree
Hide file tree
Showing 4 changed files with 79 additions and 10 deletions.
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,39 @@ lump.evaporate()
>>> "where'd all the liquid go?"
```

There is also a `'prepare'` callback that is executed as soon as the trigger runs, before any `'conditions'` are checked:

```python
class Matter(object):
heat = False
attempts = 0
def count_attempts(self): self.attempts += 1
def is_really_hot(self): return self.heat
def heat_up(self): self.heat = random.random() < 0.25
def stats(self): print('It took you %i attempts to melt the lump!' %self.attempts)

states=['solid', 'liquid', 'gas', 'plasma']

transitions = [
{ 'trigger': 'melt', 'source': 'solid', 'dest': 'liquid', 'prepare': ['heat_up', 'count_attempts'], 'conditions': 'is_really_hot', 'after': 'stats'},
]

lump = Matter()
machine = Machine(lump, states, transitions=transitions, initial='solid')
lump.melt()
lump.melt()
lump.melt()
lump.melt()
>>> "It took you 4 attempts to melt the lump!"
```

In summary, callbacks on transitions are executed in the following order:

* `'prepare'` (executed as soon as the trigger is called)
* `'conditions'` / `'unless'` (conditions *may* fail and halt the transition)
* `'before'` (executed while the model is still in the source state)
* `'after'` (executed while the model is in the destination state)

### Passing data
Sometimes you need to pass the callback functions registered at machine initialization some data that reflects the model's current state. Transitions allows you to do this in two different ways.

Expand Down
31 changes: 30 additions & 1 deletion tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,35 @@ def test_before_after_transition_listeners(self):
m.model.move()
self.assertEquals(m.model.level, 3)

def test_prepare(self):
m = Machine(Stuff(), states=['A', 'B', 'C'], initial='A')
m.add_transition('move', 'A', 'B', prepare='increase_level')
m.add_transition('move', 'B', 'C', prepare='increase_level')
m.add_transition('move', 'C', 'A', prepare='increase_level', conditions='this_fails')
m.add_transition('dont_move', 'A', 'C', prepare='increase_level')

m.prepare_move('increase_level')

m.model.move()
self.assertEquals(m.model.state, 'B')
self.assertEquals(m.model.level, 3)

m.model.move()
self.assertEquals(m.model.state, 'C')
self.assertEquals(m.model.level, 5)

# State does not advance, but increase_level still runs
m.model.move()
self.assertEquals(m.model.state, 'C')
self.assertEquals(m.model.level, 7)

# An invalid transition shouldn't execute the callback
with self.assertRaises(MachineError):
m.model.dont_move()

self.assertEquals(m.model.state, 'C')
self.assertEquals(m.model.level, 7)

def test_state_model_change_listeners(self):
s = self.stuff
s.machine.add_transition('go_e', 'A', 'E')
Expand Down Expand Up @@ -339,4 +368,4 @@ def test_pickle(self):
self.assertIsNotNone(dump)
m2 = pickle.loads(dump)
self.assertEqual(m.state, m2.state)
m2.run()
m2.run()
23 changes: 15 additions & 8 deletions transitions/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def check(self, event_data):
*event_data.args, **event_data.kwargs) == self.target

def __init__(self, source, dest, conditions=None, unless=None, before=None,
after=None):
after=None, prepare=None):
"""
Args:
source (string): The name of the source State.
Expand All @@ -105,9 +105,11 @@ def __init__(self, source, dest, conditions=None, unless=None, before=None,
before (string or list): callbacks to trigger before the
transition.
after (string or list): callbacks to trigger after the transition.
prepare (string or list): callbacks to trigger before conditions are checked
"""
self.source = source
self.dest = dest
self.prepare = [] if prepare is None else listify(prepare)
self.before = [] if before is None else listify(before)
self.after = [] if after is None else listify(after)

Expand All @@ -129,6 +131,11 @@ def execute(self, event_data):
logger.info("Initiating transition from state %s to state %s...",
self.source, self.dest)
machine = event_data.machine

for func in self.prepare:
machine.callback(getattr(event_data.model, func), event_data)
logger.info("Executing callback '%s' before conditions." % func)

for c in self.conditions:
if not c.check(event_data):
logger.info("Transition condition failed: %s() does not " +
Expand All @@ -152,10 +159,10 @@ def _change_state(self, event_data):
event_data.machine.get_state(self.dest).enter(event_data)

def add_callback(self, trigger, func):
""" Add a new before or after callback.
""" Add a new before, after, or prepare callback.
Args:
trigger (string): The type of triggering event. Must be one of
'before' or 'after'.
'before', 'after' or 'prepare'.
func (string): The name of the callback function.
"""
callback_list = getattr(self, trigger)
Expand Down Expand Up @@ -240,7 +247,7 @@ def add_callback(self, trigger, func):
""" Add a new before or after callback to all available transitions.
Args:
trigger (string): The type of triggering event. Must be one of
'before' or 'after'.
'before', 'after' or 'prepare'.
func (string): The name of the callback function.
"""
for t in itertools.chain(*self.transitions.values()):
Expand Down Expand Up @@ -403,7 +410,7 @@ def add_states(self, states, on_enter=None, on_exit=None,
self.add_transition('to_%s' % s, '*', s)

def add_transition(self, trigger, source, dest, conditions=None,
unless=None, before=None, after=None):
unless=None, before=None, after=None, prepare=None):
""" Create a new Transition instance and add it to the internal list.
Args:
trigger (string): The name of the method that will trigger the
Expand All @@ -423,7 +430,7 @@ def add_transition(self, trigger, source, dest, conditions=None,
otherwise.
before (string or list): Callables to call before the transition.
after (string or list): Callables to call after the transition.
prepare (string or list): Callables to call when the trigger is activated
"""
if trigger not in self.events:
self.events[trigger] = Event(trigger, self)
Expand All @@ -439,7 +446,7 @@ def add_transition(self, trigger, source, dest, conditions=None,
after = listify(after) + listify(self.after_state_change)

for s in source:
t = self._create_transition(s, dest, conditions, unless, before, after)
t = self._create_transition(s, dest, conditions, unless, before, after, prepare)
self.events[trigger].add_transition(t)

def add_ordered_transitions(self, states=None, trigger='next_state',
Expand Down Expand Up @@ -494,7 +501,7 @@ def __getattr__(self, name):
else:
raise AttributeError("{} does not exist".format(name))
terms = name.split('_')
if terms[0] in ['before', 'after']:
if terms[0] in ['before', 'after', 'prepare']:
name = '_'.join(terms[1:])
if name not in self.events:
raise MachineError('Event "%s" is not registered.' % name)
Expand Down
2 changes: 1 addition & 1 deletion transitions/extensions/nesting.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ def _traverse_nested(self, children):
return names

def add_transition(self, trigger, source, dest, conditions=None,
unless=None, before=None, after=None):
unless=None, before=None, after=None, prepare=None):
if not (trigger.startswith('to_') and source == '*'):
bp_before = None
bp_after = None
Expand Down

0 comments on commit b85a8bd

Please sign in to comment.