Split the state machine runners off into own file
When using a state machine it is not always desired to use the runner concept, so to ensure both concepts are modular split runners off into there own module. Change-Id: Iac8ce50b0a17468190f3f737663b5b094c324a55
This commit is contained in:
parent
33c7e82b94
commit
bcb61f0b0d
@ -20,7 +20,6 @@ except ImportError:
|
||||
from ordereddict import OrderedDict # noqa
|
||||
|
||||
import collections
|
||||
import weakref
|
||||
|
||||
from debtcollector import removals
|
||||
import prettytable
|
||||
@ -28,11 +27,6 @@ import six
|
||||
|
||||
from automaton import exceptions as excp
|
||||
|
||||
_JUMPER_NOT_FOUND_TPL = ("Unable to progress since no reaction (or"
|
||||
" sent event) has been made available in"
|
||||
" new state '%s' (moved to from state '%s'"
|
||||
" in response to event '%s')")
|
||||
|
||||
|
||||
def _orderedkeys(data, sort=True):
|
||||
if sort:
|
||||
@ -90,13 +84,8 @@ class FiniteMachine(object):
|
||||
self._states = OrderedDict()
|
||||
self._default_start_state = default_start_state
|
||||
self._current = None
|
||||
self._runner = _FiniteRunner(self)
|
||||
self.frozen = False
|
||||
|
||||
@property
|
||||
def runner(self):
|
||||
return self._runner
|
||||
|
||||
@property
|
||||
def default_start_state(self):
|
||||
return self._default_start_state
|
||||
@ -356,48 +345,6 @@ class FiniteMachine(object):
|
||||
return tbl.get_string()
|
||||
|
||||
|
||||
class _FiniteRunner(object):
|
||||
"""Finite machine runner used to run a finite machine."""
|
||||
|
||||
def __init__(self, machine):
|
||||
self._machine = weakref.proxy(machine)
|
||||
|
||||
def run(self, event, initialize=True):
|
||||
"""Runs the state machine, using reactions only."""
|
||||
for transition in self.run_iter(event, initialize=initialize):
|
||||
pass
|
||||
|
||||
def run_iter(self, event, initialize=True):
|
||||
"""Returns a iterator/generator that will run the state machine.
|
||||
|
||||
NOTE(harlowja): only one runner iterator/generator should be active for
|
||||
a machine, if this is not observed then it is possible for
|
||||
initialization and other local state to be corrupted and cause issues
|
||||
when running...
|
||||
"""
|
||||
if initialize:
|
||||
self._machine.initialize()
|
||||
while True:
|
||||
old_state = self._machine.current_state
|
||||
reaction, terminal = self._machine.process_event(event)
|
||||
new_state = self._machine.current_state
|
||||
try:
|
||||
sent_event = yield (old_state, new_state)
|
||||
except GeneratorExit:
|
||||
break
|
||||
if terminal:
|
||||
break
|
||||
if reaction is None and sent_event is None:
|
||||
raise excp.NotFound(_JUMPER_NOT_FOUND_TPL % (new_state,
|
||||
old_state,
|
||||
event))
|
||||
elif sent_event is not None:
|
||||
event = sent_event
|
||||
else:
|
||||
cb, args, kwargs = reaction
|
||||
event = cb(old_state, new_state, event, *args, **kwargs)
|
||||
|
||||
|
||||
class HierarchicalFiniteMachine(FiniteMachine):
|
||||
"""A fsm that understands how to run in a hierarchical mode."""
|
||||
|
||||
@ -408,7 +355,6 @@ class HierarchicalFiniteMachine(FiniteMachine):
|
||||
def __init__(self, default_start_state=None):
|
||||
super(HierarchicalFiniteMachine, self).__init__(
|
||||
default_start_state=default_start_state)
|
||||
self._runner = _HierarchicalRunner(self)
|
||||
self._nested_machines = {}
|
||||
|
||||
@classmethod
|
||||
@ -446,89 +392,3 @@ class HierarchicalFiniteMachine(FiniteMachine):
|
||||
@property
|
||||
def nested_machines(self):
|
||||
return self._nested_machines
|
||||
|
||||
|
||||
class _HierarchicalRunner(object):
|
||||
"""Hierarchical machine runner used to run a hierarchical machine."""
|
||||
|
||||
def __init__(self, machine):
|
||||
self._machine = weakref.proxy(machine)
|
||||
|
||||
def run(self, event, initialize=True):
|
||||
"""Runs the state machine, using reactions only."""
|
||||
for transition in self.run_iter(event, initialize=initialize):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _process_event(machines, event):
|
||||
"""Matches a event to the machine hierarchy.
|
||||
|
||||
If the lowest level machine does not handle the event, then the
|
||||
parent machine is referred to and so on, until there is only one
|
||||
machine left which *must* handle the event.
|
||||
|
||||
The machine whose ``process_event`` does not throw invalid state or
|
||||
not found exceptions is expected to be the machine that should
|
||||
continue handling events...
|
||||
"""
|
||||
while True:
|
||||
machine = machines[-1]
|
||||
try:
|
||||
result = machine.process_event(event)
|
||||
except (excp.InvalidState, excp.NotFound):
|
||||
if len(machines) == 1:
|
||||
raise
|
||||
else:
|
||||
current = machine._current
|
||||
if current is not None and current.on_exit is not None:
|
||||
current.on_exit(current.name, event)
|
||||
machine._current = None
|
||||
machines.pop()
|
||||
else:
|
||||
return result
|
||||
|
||||
def run_iter(self, event, initialize=True):
|
||||
"""Returns a iterator/generator that will run the state machine.
|
||||
|
||||
This will keep a stack (hierarchy) of machines active and jumps through
|
||||
them as needed (depending on which machine handles which event) during
|
||||
the running lifecycle.
|
||||
|
||||
NOTE(harlowja): only one runner iterator/generator should be active for
|
||||
a machine hierarchy, if this is not observed then it is possible for
|
||||
initialization and other local state to be corrupted and causes issues
|
||||
when running...
|
||||
"""
|
||||
machines = [self._machine]
|
||||
if initialize:
|
||||
machines[-1].initialize()
|
||||
while True:
|
||||
old_state = machines[-1].current_state
|
||||
effect = self._process_event(machines, event)
|
||||
new_state = machines[-1].current_state
|
||||
try:
|
||||
machine = effect.machine
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
if machine is not None and machine is not machines[-1]:
|
||||
machine.initialize()
|
||||
machines.append(machine)
|
||||
try:
|
||||
sent_event = yield (old_state, new_state)
|
||||
except GeneratorExit:
|
||||
break
|
||||
if len(machines) == 1 and effect.terminal:
|
||||
# Only allow the top level machine to actually terminate the
|
||||
# execution, the rest of the nested machines must not handle
|
||||
# events if they wish to have the root machine terminate...
|
||||
break
|
||||
if effect.reaction is None and sent_event is None:
|
||||
raise excp.NotFound(_JUMPER_NOT_FOUND_TPL % (new_state,
|
||||
old_state,
|
||||
event))
|
||||
elif sent_event is not None:
|
||||
event = sent_event
|
||||
else:
|
||||
cb, args, kwargs = effect.reaction
|
||||
event = cb(old_state, new_state, event, *args, **kwargs)
|
||||
|
169
automaton/runners.py
Normal file
169
automaton/runners.py
Normal file
@ -0,0 +1,169 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright (C) 2015 Yahoo! Inc. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from automaton import exceptions as excp
|
||||
from automaton import machines
|
||||
|
||||
|
||||
_JUMPER_NOT_FOUND_TPL = ("Unable to progress since no reaction (or"
|
||||
" sent event) has been made available in"
|
||||
" new state '%s' (moved to from state '%s'"
|
||||
" in response to event '%s')")
|
||||
|
||||
|
||||
class FiniteRunner(object):
|
||||
"""Finite machine runner used to run a finite machine.
|
||||
|
||||
Only **one** runner per machine should be active at the same time (aka
|
||||
there should not be multiple runners using the same machine instance at
|
||||
the same time).
|
||||
"""
|
||||
|
||||
def __init__(self, machine):
|
||||
"""Create a runner for the given machine."""
|
||||
if not isinstance(machine, (machines.FiniteMachine,)):
|
||||
raise TypeError("FiniteRunner only works with FiniteMachine(s)")
|
||||
self._machine = machine
|
||||
|
||||
def run(self, event, initialize=True):
|
||||
"""Runs the state machine, using reactions only."""
|
||||
for transition in self.run_iter(event, initialize=initialize):
|
||||
pass
|
||||
|
||||
def run_iter(self, event, initialize=True):
|
||||
"""Returns a iterator/generator that will run the state machine.
|
||||
|
||||
NOTE(harlowja): only one runner iterator/generator should be active for
|
||||
a machine, if this is not observed then it is possible for
|
||||
initialization and other local state to be corrupted and cause issues
|
||||
when running...
|
||||
"""
|
||||
if initialize:
|
||||
self._machine.initialize()
|
||||
while True:
|
||||
old_state = self._machine.current_state
|
||||
reaction, terminal = self._machine.process_event(event)
|
||||
new_state = self._machine.current_state
|
||||
try:
|
||||
sent_event = yield (old_state, new_state)
|
||||
except GeneratorExit:
|
||||
break
|
||||
if terminal:
|
||||
break
|
||||
if reaction is None and sent_event is None:
|
||||
raise excp.NotFound(_JUMPER_NOT_FOUND_TPL % (new_state,
|
||||
old_state,
|
||||
event))
|
||||
elif sent_event is not None:
|
||||
event = sent_event
|
||||
else:
|
||||
cb, args, kwargs = reaction
|
||||
event = cb(old_state, new_state, event, *args, **kwargs)
|
||||
|
||||
|
||||
class HierarchicalRunner(object):
|
||||
"""Hierarchical machine runner used to run a hierarchical machine.
|
||||
|
||||
Only **one** runner per machine should be active at the same time (aka
|
||||
there should not be multiple runners using the same machine instance at
|
||||
the same time).
|
||||
"""
|
||||
|
||||
def __init__(self, machine):
|
||||
"""Create a runner for the given machine."""
|
||||
if not isinstance(machine, (machines.HierarchicalFiniteMachine,)):
|
||||
raise TypeError("HierarchicalRunner only works with"
|
||||
" HierarchicalFiniteMachine(s)")
|
||||
self._machine = machine
|
||||
|
||||
def run(self, event, initialize=True):
|
||||
"""Runs the state machine, using reactions only."""
|
||||
for transition in self.run_iter(event, initialize=initialize):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _process_event(machines, event):
|
||||
"""Matches a event to the machine hierarchy.
|
||||
|
||||
If the lowest level machine does not handle the event, then the
|
||||
parent machine is referred to and so on, until there is only one
|
||||
machine left which *must* handle the event.
|
||||
|
||||
The machine whose ``process_event`` does not throw invalid state or
|
||||
not found exceptions is expected to be the machine that should
|
||||
continue handling events...
|
||||
"""
|
||||
while True:
|
||||
machine = machines[-1]
|
||||
try:
|
||||
result = machine.process_event(event)
|
||||
except (excp.InvalidState, excp.NotFound):
|
||||
if len(machines) == 1:
|
||||
raise
|
||||
else:
|
||||
current = machine._current
|
||||
if current is not None and current.on_exit is not None:
|
||||
current.on_exit(current.name, event)
|
||||
machine._current = None
|
||||
machines.pop()
|
||||
else:
|
||||
return result
|
||||
|
||||
def run_iter(self, event, initialize=True):
|
||||
"""Returns a iterator/generator that will run the state machine.
|
||||
|
||||
This will keep a stack (hierarchy) of machines active and jumps through
|
||||
them as needed (depending on which machine handles which event) during
|
||||
the running lifecycle.
|
||||
|
||||
NOTE(harlowja): only one runner iterator/generator should be active for
|
||||
a machine hierarchy, if this is not observed then it is possible for
|
||||
initialization and other local state to be corrupted and causes issues
|
||||
when running...
|
||||
"""
|
||||
machines = [self._machine]
|
||||
if initialize:
|
||||
machines[-1].initialize()
|
||||
while True:
|
||||
old_state = machines[-1].current_state
|
||||
effect = self._process_event(machines, event)
|
||||
new_state = machines[-1].current_state
|
||||
try:
|
||||
machine = effect.machine
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
if machine is not None and machine is not machines[-1]:
|
||||
machine.initialize()
|
||||
machines.append(machine)
|
||||
try:
|
||||
sent_event = yield (old_state, new_state)
|
||||
except GeneratorExit:
|
||||
break
|
||||
if len(machines) == 1 and effect.terminal:
|
||||
# Only allow the top level machine to actually terminate the
|
||||
# execution, the rest of the nested machines must not handle
|
||||
# events if they wish to have the root machine terminate...
|
||||
break
|
||||
if effect.reaction is None and sent_event is None:
|
||||
raise excp.NotFound(_JUMPER_NOT_FOUND_TPL % (new_state,
|
||||
old_state,
|
||||
event))
|
||||
elif sent_event is not None:
|
||||
event = sent_event
|
||||
else:
|
||||
cb, args, kwargs = effect.reaction
|
||||
event = cb(old_state, new_state, event, *args, **kwargs)
|
@ -19,6 +19,7 @@ import random
|
||||
|
||||
from automaton import exceptions as excp
|
||||
from automaton import machines
|
||||
from automaton import runners
|
||||
|
||||
import six
|
||||
from testtools import testcase
|
||||
@ -50,7 +51,8 @@ class FSMTest(testcase.TestCase):
|
||||
|
||||
def test_bad_start_state(self):
|
||||
m = self._create_fsm('unknown', add_start=False)
|
||||
self.assertRaises(excp.NotFound, m.runner.run, 'unknown')
|
||||
r = runners.FiniteRunner(m)
|
||||
self.assertRaises(excp.NotFound, r.run, 'unknown')
|
||||
|
||||
def test_contains(self):
|
||||
m = self._create_fsm('unknown', add_start=False)
|
||||
@ -92,11 +94,11 @@ class FSMTest(testcase.TestCase):
|
||||
m.initialize()
|
||||
self.assertEqual('down', m.current_state)
|
||||
self.assertFalse(m.terminated)
|
||||
m_runner = m.runner
|
||||
m_runner.run('jump')
|
||||
r = runners.FiniteRunner(m)
|
||||
r.run('jump')
|
||||
self.assertTrue(m.terminated)
|
||||
self.assertEqual('broken', m.current_state)
|
||||
self.assertRaises(excp.InvalidState, m_runner.run,
|
||||
self.assertRaises(excp.InvalidState, r.run,
|
||||
'jump', initialize=False)
|
||||
|
||||
def test_on_enter_on_exit(self):
|
||||
@ -128,7 +130,7 @@ class FSMTest(testcase.TestCase):
|
||||
|
||||
def test_run_iter(self):
|
||||
up_downs = []
|
||||
runner = self.jumper.runner
|
||||
runner = runners.FiniteRunner(self.jumper)
|
||||
for (old_state, new_state) in runner.run_iter('jump'):
|
||||
up_downs.append((old_state, new_state))
|
||||
if len(up_downs) >= 3:
|
||||
@ -142,7 +144,7 @@ class FSMTest(testcase.TestCase):
|
||||
|
||||
def test_run_send(self):
|
||||
up_downs = []
|
||||
runner = self.jumper.runner
|
||||
runner = runners.FiniteRunner(self.jumper)
|
||||
it = runner.run_iter('jump')
|
||||
while True:
|
||||
up_downs.append(it.send(None))
|
||||
@ -157,7 +159,7 @@ class FSMTest(testcase.TestCase):
|
||||
|
||||
def test_run_send_fail(self):
|
||||
up_downs = []
|
||||
runner = self.jumper.runner
|
||||
runner = runners.FiniteRunner(self.jumper)
|
||||
it = runner.run_iter('jump')
|
||||
up_downs.append(six.next(it))
|
||||
self.assertRaises(excp.NotFound, it.send, 'fail')
|
||||
@ -196,8 +198,9 @@ class FSMTest(testcase.TestCase):
|
||||
def test_copy_initialized(self):
|
||||
j = self.jumper.copy()
|
||||
self.assertIsNone(j.current_state)
|
||||
r = runners.FiniteRunner(self.jumper)
|
||||
|
||||
for i, transition in enumerate(self.jumper.runner.run_iter('jump')):
|
||||
for i, transition in enumerate(r.run_iter('jump')):
|
||||
if i == 4:
|
||||
break
|
||||
|
||||
@ -317,7 +320,8 @@ class HFSMTest(FSMTest):
|
||||
def test_phone_dialer_iter(self):
|
||||
dialer, number_calling = self._make_phone_dialer()
|
||||
self.assertEqual(0, len(number_calling))
|
||||
transitions = list(dialer.runner.run_iter('dial'))
|
||||
r = runners.HierarchicalRunner(dialer)
|
||||
transitions = list(r.run_iter('dial'))
|
||||
self.assertEqual(('talk', 'hangup'), transitions[-1])
|
||||
self.assertEqual(len(number_calling),
|
||||
sum(1 if new_state == 'accumulate' else 0
|
||||
@ -326,12 +330,14 @@ class HFSMTest(FSMTest):
|
||||
|
||||
def test_phone_call(self):
|
||||
handler = self._make_phone_call()
|
||||
handler.runner.run('call')
|
||||
r = runners.HierarchicalRunner(handler)
|
||||
r.run('call')
|
||||
self.assertTrue(handler.terminated)
|
||||
|
||||
def test_phone_call_iter(self):
|
||||
handler = self._make_phone_call()
|
||||
transitions = list(handler.runner.run_iter('call'))
|
||||
r = runners.HierarchicalRunner(handler)
|
||||
transitions = list(r.run_iter('call'))
|
||||
self.assertEqual(('talk', 'hangup'), transitions[-1])
|
||||
self.assertEqual(("begin", 'phone'), transitions[0])
|
||||
talk_talk = 0
|
||||
|
@ -16,10 +16,10 @@ Machines
|
||||
Runners
|
||||
-------
|
||||
|
||||
.. autoclass:: automaton.machines._FiniteRunner
|
||||
.. autoclass:: automaton.runners.FiniteRunner
|
||||
:members:
|
||||
|
||||
.. autoclass:: automaton.machines._HierarchicalRunner
|
||||
.. autoclass:: automaton.runners.HierarchicalRunner
|
||||
:members:
|
||||
|
||||
----------
|
||||
|
Loading…
x
Reference in New Issue
Block a user