├── .coveragerc ├── .gitignore ├── README.md ├── async_fsm ├── __init__.py ├── exceptions.py ├── state_machine.py └── tests │ ├── test_fsm.yaml │ └── test_state_machine.py ├── requirements.txt ├── setup.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | .tox/ 3 | .cache/ 4 | __pycache__/ 5 | *.egg-info/ 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # async-fsm 2 | 3 | This is a helper class for building state machines which need to be 4 | drivable by asynchronous events, launch some side effects in parallel when they 5 | transition from one state to another, and optionally transition to 6 | another state immediately once all their (asynchronous) side effects 7 | have completed. It is not nearly as feature-complete, actively 8 | maintained, or practical as a serious Python state machine library 9 | like [transitions](https://github.com/tyarkoni/transitions). This 10 | repository also is intended to act as some kind of approximation 11 | of my understanding of how to set up a Python project with some 12 | niceties like setup/automated testing/coverage reports. 13 | 14 | ## Project setup 15 | 16 | The state machine uses `asyncio` (and the Python 3.5 `async 17 | def`/`await` syntax) so the tests need the `pytest-asyncio` extension 18 | to `pytest`, which should be pulled in automatically if you run the 19 | bash thingy below. I'm also using `tox` to run `pytest` which can 20 | easily be adapted to automatically run a test suite on multiple Python 21 | versions, but it's not being used for this now because 3.5 is the only 22 | version where the code will work. Doing installation the way described 23 | below means that all the dependencies for the library and tests are 24 | installed into a virtualenv but still links in the current directory 25 | so you don't have to run `pip install` again. You _do_ need 26 | `eventlet` installed to run coverage (I think) because the `eventlet` 27 | concurrency model is the only one that worked for me. 28 | 29 | ## Running the test suite 30 | 31 | ```bash 32 | git clone git://github.com/csboling/async-fsm 33 | cd async-fsm 34 | pip install virtualenv 35 | virtualenv . 36 | pip install -r requirements.txt 37 | tox 38 | ``` 39 | 40 | ## Using the library 41 | 42 | You can inherit from StateMachine and define state and input enums if 43 | you want, but the easiest way to specify a state machine is to use a 44 | YAML/JSON/whatever file to load a table definition dictionary with the 45 | class method `from_table`. This is 46 | demonstrated with YAML syntax [here](async_fsm/tests/test_fsm.yaml). For 47 | each state listed under `table` you may list one or more inputs, then 48 | give a sequence of states the machine should transition to once all 49 | registered side effects are completed. 50 | 51 | Once you have a `StateMachine` instance (say `machine`) you can 52 | register side effects that you wish to fire when a state transition 53 | occurs as `asyncio` coroutines using the `machine.on` decorator: 54 | 55 | ``` python 56 | @machine.on('init', 'active') 57 | async def behave(): 58 | await asyncio.sleep(1) 59 | ``` 60 | 61 | and trigger state transitions using `machine.input` or 62 | `machine.input_sequence`. 63 | -------------------------------------------------------------------------------- /async_fsm/__init__.py: -------------------------------------------------------------------------------- 1 | from async_fsm.state_machine import StateMachine 2 | from async_fsm.exceptions import InvalidInput 3 | 4 | 5 | __all__ = [ 6 | StateMachine, 7 | InvalidInput, 8 | ] 9 | -------------------------------------------------------------------------------- /async_fsm/exceptions.py: -------------------------------------------------------------------------------- 1 | class InvalidInput(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /async_fsm/state_machine.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from collections import defaultdict 3 | from enum import Enum 4 | from functools import reduce 5 | from itertools import starmap 6 | 7 | from promise import Promise 8 | 9 | from async_fsm.exceptions import InvalidInput 10 | 11 | 12 | class Message: 13 | def __init__(self, type, data): 14 | self.type = type 15 | self.data = data 16 | 17 | 18 | class StateMachine: 19 | def __init__(self, State, initial): 20 | self.state = State(initial) 21 | self._transition_table = defaultdict(list) 22 | 23 | def on(self, old, new): 24 | '''Should only be used to decorate free functions. If you must use a 25 | self argument, pass it in as a member of the "data" dictionary when you 26 | call input(). 27 | ''' 28 | def decorate(f): 29 | self._transition_table[(old, new)].append(f) 30 | return f 31 | 32 | return decorate 33 | 34 | def input(self, signal, data=None) -> Promise: 35 | promise = getattr(self, self.state.name)(Message(signal, data)) 36 | if promise is None: 37 | raise InvalidInput( 38 | '{} is not a valid input for state {}'.format( 39 | signal, 40 | self.state 41 | ) 42 | ) 43 | return promise 44 | 45 | def transition(self, state, data=None) -> Promise: 46 | async def do_side_effects(): 47 | await asyncio.wait(map( 48 | lambda f: asyncio.ensure_future(f()), 49 | self._transition_table[(self.state.name, state.name)] 50 | )) 51 | self.state = state 52 | return Promise.promisify(asyncio.ensure_future(do_side_effects())) 53 | 54 | def state_sequence(self, states, data=None) -> Promise: 55 | return reduce( 56 | lambda promise, state: promise.then( 57 | lambda data: self.transition(state, data) 58 | ), 59 | states, 60 | Promise.resolve(data) 61 | ) 62 | 63 | def input_sequence(self, actions, data=None) -> Promise: 64 | return reduce( 65 | lambda prev, action: prev.then( 66 | lambda data: self.input(action, data) 67 | ), 68 | actions, 69 | Promise.resolve(data) 70 | ) 71 | 72 | @classmethod 73 | def from_table(cls, states, inputs, table): 74 | class Machine(cls): 75 | State = Enum('State', ' '.join(states)) 76 | Input = Enum('Input', ' '.join(inputs)) 77 | 78 | def __init__(self): 79 | super().__init__(self.State, 1) 80 | 81 | def attach_behavior(state, transitions): 82 | def behavior(self, msg): 83 | try: 84 | transition = transitions[msg.type.name] 85 | except KeyError: 86 | return None 87 | else: 88 | return self.state_sequence( 89 | getattr(self.State, state) for state in transition 90 | ) 91 | setattr(Machine, state, behavior) 92 | 93 | list(starmap(attach_behavior, table.items())) 94 | return Machine 95 | -------------------------------------------------------------------------------- /async_fsm/tests/test_fsm.yaml: -------------------------------------------------------------------------------- 1 | states: 2 | - idle 3 | - working 4 | - done 5 | inputs: 6 | - start 7 | - complete 8 | - refresh 9 | - cancel 10 | - reset 11 | table: 12 | idle: 13 | start: 14 | - working 15 | working: 16 | complete: 17 | - done 18 | refresh: 19 | - done 20 | - idle 21 | cancel: 22 | - idle 23 | done: 24 | reset: 25 | - idle 26 | -------------------------------------------------------------------------------- /async_fsm/tests/test_state_machine.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | from unittest import mock 4 | 5 | from promise import Promise 6 | 7 | import yaml 8 | import pytest 9 | 10 | from async_fsm import StateMachine, InvalidInput 11 | 12 | 13 | def __await__(self): 14 | result = yield from asyncio.get_event_loop().run_in_executor( 15 | None, 16 | self.get 17 | ) 18 | return result 19 | 20 | Promise.__await__ = __await__ 21 | 22 | LOCALROOT = os.path.abspath(os.path.dirname(__file__)) 23 | 24 | 25 | @pytest.fixture 26 | def machine(): 27 | with open(os.path.join(LOCALROOT, 'test_fsm.yaml')) as f: 28 | table = yaml.load(f) 29 | Machine = StateMachine.from_table(**table) 30 | 31 | return Machine() 32 | 33 | 34 | @pytest.fixture 35 | def busy(): 36 | busy = mock.create_autospec( 37 | lambda was, now: print('busywork: {} -> {}'.format(was, now)) 38 | ) 39 | return busy 40 | 41 | 42 | @pytest.fixture 43 | def long_busy(): 44 | long_busy = mock.create_autospec( 45 | lambda was, now: asyncio.sleep(1) 46 | ) 47 | return long_busy 48 | 49 | 50 | @pytest.fixture 51 | def behaviors(machine, busy, long_busy): 52 | class StateMachineClient: 53 | @machine.on('idle', 'working') 54 | async def do_start(): 55 | busy('idle', 'working') 56 | 57 | @machine.on('working', 'idle') 58 | async def do_cancel(): 59 | busy('working', 'idle') 60 | 61 | @machine.on('working', 'done') 62 | async def do_refresh(): 63 | await long_busy('working', 'done') 64 | 65 | @machine.on('done', 'idle') 66 | async def do_reset(): 67 | busy('done', 'idle') 68 | return StateMachineClient() 69 | 70 | 71 | class TestStateMachine: 72 | @pytest.mark.asyncio 73 | async def test_single_transition(self, machine, busy, behaviors): 74 | await machine.input( 75 | machine.Input.start, 76 | {'input': 'data'} 77 | ) 78 | busy.assert_called_once_with('idle', 'working') 79 | 80 | @pytest.mark.asyncio 81 | async def test_input_sequence(self, machine, busy, behaviors): 82 | await machine.input_sequence( 83 | [ 84 | machine.Input.start, 85 | machine.Input.cancel, 86 | ], 87 | {'input': 'data'} 88 | ) 89 | busy.assert_has_calls([ 90 | mock.call('idle', 'working'), 91 | mock.call('working', 'idle'), 92 | ]) 93 | 94 | @pytest.mark.asyncio 95 | async def test_transition_sequence(self, machine, busy, long_busy, behaviors): 96 | await machine.input( 97 | machine.Input.start, {'input': 'data'} 98 | ).then( 99 | lambda data: machine.input( 100 | machine.Input.refresh, data 101 | ) 102 | ) 103 | 104 | busy.assert_has_calls([ 105 | mock.call('idle', 'working'), 106 | mock.call('done', 'idle'), 107 | ]) 108 | long_busy.assert_has_calls([ 109 | mock.call('working', 'done'), 110 | ]) 111 | 112 | @pytest.mark.asyncio 113 | async def test_complex_sequence(self, machine, busy, long_busy, behaviors): 114 | await machine.input_sequence( 115 | [ 116 | machine.Input.start, 117 | machine.Input.cancel, 118 | machine.Input.start, 119 | machine.Input.refresh, 120 | machine.Input.start, 121 | machine.Input.complete, 122 | machine.Input.reset, 123 | ], 124 | {'input': 'data'} 125 | ) 126 | 127 | busy.assert_has_calls([ 128 | mock.call('idle', 'working'), 129 | mock.call('working', 'idle'), 130 | mock.call('idle', 'working'), 131 | mock.call('done', 'idle'), 132 | mock.call('idle', 'working'), 133 | mock.call('done', 'idle'), 134 | ]) 135 | long_busy.assert_has_calls([ 136 | mock.call('working', 'done'), 137 | mock.call('working', 'done'), 138 | ]) 139 | 140 | @pytest.mark.asyncio 141 | async def test_bad_input(self, machine, busy, long_busy, behaviors): 142 | with pytest.raises(InvalidInput): 143 | await machine.input(machine.Input.refresh, {}) 144 | 145 | @pytest.mark.asyncio 146 | async def test_mixed_signals(self, machine, busy, long_busy, behaviors): 147 | seq_one = machine.input_sequence( 148 | [ 149 | machine.Input.start, 150 | machine.Input.complete, 151 | ] 152 | ) 153 | seq_two = machine.input(machine.Input.start) 154 | 155 | await Promise.all([seq_two, seq_one]) 156 | busy.assert_any_call('idle', 'working') 157 | long_busy.assert_called_with('working', 'done') 158 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | eventlet 2 | pytest 3 | pytest-asyncio 4 | tox 5 | coverage 6 | -e git://github.com/csboling/promise.git#egg=promise 7 | -e . 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='async_fsm', 5 | packages=find_packages(), 6 | install_requires=[ 7 | 'promise', 8 | 'PyYAML', 9 | ], 10 | tests_require=[ 11 | 'tox', 12 | 'eventlet', 13 | 'pytest', 14 | 'pytest-asyncio', 15 | 'coverage', 16 | ], 17 | ) 18 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | skipsdist = True 3 | envlist = py35 4 | 5 | [testenv] 6 | usedevelop = True 7 | commands = 8 | py.test --cov-report term-missing --cov=async_fsm async_fsm 9 | deps = 10 | pytest 11 | pytest-cov 12 | eventlet 13 | -rrequirements.txt --------------------------------------------------------------------------------