diff --git a/automaton/machines.py b/automaton/machines.py index 7f1891d..2c5bc76 100644 --- a/automaton/machines.py +++ b/automaton/machines.py @@ -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) diff --git a/automaton/runners.py b/automaton/runners.py new file mode 100644 index 0000000..3bb28ed --- /dev/null +++ b/automaton/runners.py @@ -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) diff --git a/automaton/tests/test_fsm.py b/automaton/tests/test_fsm.py index 5370d6a..0b7e43f 100644 --- a/automaton/tests/test_fsm.py +++ b/automaton/tests/test_fsm.py @@ -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 diff --git a/doc/source/api.rst b/doc/source/api.rst index be1670a..8b9b3cb 100644 --- a/doc/source/api.rst +++ b/doc/source/api.rst @@ -16,10 +16,10 @@ Machines Runners ------- -.. autoclass:: automaton.machines._FiniteRunner +.. autoclass:: automaton.runners.FiniteRunner :members: -.. autoclass:: automaton.machines._HierarchicalRunner +.. autoclass:: automaton.runners.HierarchicalRunner :members: ----------