├── .coveragerc ├── .gitignore ├── .gitreview ├── .pre-commit-config.yaml ├── .stestr.conf ├── .zuul.yaml ├── CONTRIBUTING.rst ├── HACKING.rst ├── LICENSE ├── README.rst ├── automaton ├── __init__.py ├── _utils.py ├── converters │ ├── __init__.py │ └── pydot.py ├── exceptions.py ├── machines.py ├── runners.py └── tests │ ├── __init__.py │ └── test_fsm.py ├── bindep.txt ├── doc ├── requirements.txt └── source │ ├── conf.py │ ├── contributor │ └── index.rst │ ├── index.rst │ ├── install │ └── index.rst │ ├── reference │ └── index.rst │ └── user │ ├── examples.rst │ ├── features.rst │ ├── history.rst │ └── index.rst ├── releasenotes ├── notes │ ├── .placeholder │ └── drop-python-2-7-73d3113c69d724d6.yaml └── source │ ├── 2023.1.rst │ ├── 2023.2.rst │ ├── _static │ └── .placeholder │ ├── _templates │ └── .placeholder │ ├── conf.py │ ├── index.rst │ ├── ocata.rst │ ├── pike.rst │ ├── queens.rst │ ├── rocky.rst │ ├── stein.rst │ ├── train.rst │ ├── unreleased.rst │ ├── ussuri.rst │ ├── victoria.rst │ ├── wallaby.rst │ ├── xena.rst │ ├── yoga.rst │ └── zed.rst ├── requirements.txt ├── setup.cfg ├── setup.py ├── test-requirements.txt └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = automaton 4 | omit = automaton/tests/* 5 | 6 | [report] 7 | ignore_errors = True 8 | precision = 2 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .stestr/ 38 | .tox/ 39 | .coverage 40 | cover 41 | .cache 42 | coverage.xml 43 | 44 | # Translations 45 | *.mo 46 | *.pot 47 | 48 | # Django stuff: 49 | *.log 50 | 51 | # Sphinx documentation 52 | doc/build/ 53 | 54 | # pbr generates these 55 | AUTHORS 56 | ChangeLog 57 | 58 | # release note artifacts (reno) 59 | RELEASENOTES.rst 60 | releasenotes/notes/reno.cache 61 | 62 | # PyBuilder 63 | target/ 64 | -------------------------------------------------------------------------------- /.gitreview: -------------------------------------------------------------------------------- 1 | [gerrit] 2 | host=review.opendev.org 3 | port=29418 4 | project=openstack/automaton.git 5 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: trailing-whitespace 6 | # Replaces or checks mixed line ending 7 | - id: mixed-line-ending 8 | args: ['--fix', 'lf'] 9 | exclude: '.*\.(svg)$' 10 | # Forbid files which have a UTF-8 byte-order marker 11 | - id: check-byte-order-marker 12 | # Checks that non-binary executables have a proper shebang 13 | - id: check-executables-have-shebangs 14 | # Check for files that contain merge conflict strings. 15 | - id: check-merge-conflict 16 | # Check for debugger imports and py37+ breakpoint() 17 | # calls in python source 18 | - id: debug-statements 19 | - id: check-yaml 20 | files: .*\.(yaml|yml)$ 21 | - repo: https://opendev.org/openstack/hacking 22 | rev: 6.1.0 23 | hooks: 24 | - id: hacking 25 | additional_dependencies: [] 26 | - repo: https://github.com/asottile/pyupgrade 27 | rev: v3.18.0 28 | hooks: 29 | - id: pyupgrade 30 | args: [--py3-only] 31 | -------------------------------------------------------------------------------- /.stestr.conf: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | test_path=./automaton/tests 3 | top_dir=./ 4 | 5 | -------------------------------------------------------------------------------- /.zuul.yaml: -------------------------------------------------------------------------------- 1 | - project: 2 | templates: 3 | - check-requirements 4 | - lib-forward-testing-python3 5 | - openstack-python3-jobs 6 | - periodic-stable-jobs 7 | - publish-openstack-docs-pti 8 | - release-notes-jobs-python3 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | If you would like to contribute to the development of OpenStack, you must 2 | follow the steps in this page: 3 | 4 | https://docs.openstack.org/infra/manual/developers.html 5 | 6 | If you already have a good understanding of how the system works and your 7 | OpenStack accounts are set up, you can skip to the development workflow 8 | section of this documentation to learn how changes to OpenStack should be 9 | submitted for review via the Gerrit tool: 10 | 11 | https://docs.openstack.org/infra/manual/developers.html#development-workflow 12 | 13 | The code is hosted at: 14 | 15 | https://opendev.org/openstack/automaton. 16 | 17 | Pull requests submitted through GitHub will be ignored. 18 | 19 | Bugs should be filed on Launchpad, not GitHub: 20 | 21 | https://bugs.launchpad.net/automaton 22 | 23 | The mailing list is (prefix subjects with "[Oslo][Automaton]"): 24 | 25 | https://lists.openstack.org/cgi-bin/mailman/listinfo/openstack-discuss 26 | 27 | Questions and discussions take place in #openstack-oslo on 28 | irc.OFTC.net. 29 | -------------------------------------------------------------------------------- /HACKING.rst: -------------------------------------------------------------------------------- 1 | Automaton Style Commandments 2 | ============================ 3 | 4 | Read the OpenStack Style Commandments https://docs.openstack.org/hacking/latest/ 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Automaton 3 | ========= 4 | 5 | .. image:: https://img.shields.io/pypi/v/automaton.svg 6 | :target: https://pypi.org/project/automaton/ 7 | :alt: Latest Version 8 | 9 | .. image:: https://img.shields.io/pypi/dm/automaton.svg 10 | :target: https://pypi.org/project/automaton/ 11 | :alt: Downloads 12 | 13 | Friendly state machines for python. The goal of this library is to provide 14 | well documented state machine classes and associated utilities. The state 15 | machine pattern (or the implemented variation there-of) is a commonly 16 | used pattern and has a multitude of various usages. Some of the usages 17 | for this library include providing state & transition validation and 18 | running/scheduling/analyzing the execution of tasks. 19 | 20 | * Free software: Apache license 21 | * Documentation: https://docs.openstack.org/automaton/latest/ 22 | * Source: https://opendev.org/openstack/automaton 23 | * Bugs: https://bugs.launchpad.net/automaton 24 | -------------------------------------------------------------------------------- /automaton/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openstack/automaton/d85ecfadf9236ee6eff3b0bbde104a7f519ea463/automaton/__init__.py -------------------------------------------------------------------------------- /automaton/_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Yahoo! Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | import inspect 16 | 17 | 18 | def get_callback_name(cb): 19 | """Tries to get a callbacks fully-qualified name. 20 | 21 | If no name can be produced ``repr(cb)`` is called and returned. 22 | """ 23 | segments = [] 24 | try: 25 | segments.append(cb.__qualname__) 26 | except AttributeError: 27 | try: 28 | segments.append(cb.__name__) 29 | if inspect.ismethod(cb): 30 | try: 31 | # This attribute doesn't exist on py3.x or newer, so 32 | # we optionally ignore it... (on those versions of 33 | # python `__qualname__` should have been found anyway). 34 | segments.insert(0, cb.im_class.__name__) 35 | except AttributeError: 36 | pass 37 | except AttributeError: 38 | pass 39 | if not segments: 40 | return repr(cb) 41 | else: 42 | try: 43 | # When running under sphinx it appears this can be none? 44 | if cb.__module__: 45 | segments.insert(0, cb.__module__) 46 | except AttributeError: 47 | pass 48 | return ".".join(segments) 49 | -------------------------------------------------------------------------------- /automaton/converters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openstack/automaton/d85ecfadf9236ee6eff3b0bbde104a7f519ea463/automaton/converters/__init__.py -------------------------------------------------------------------------------- /automaton/converters/pydot.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Yahoo! Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | try: 16 | import pydot 17 | PYDOT_AVAILABLE = True 18 | except ImportError: 19 | PYDOT_AVAILABLE = False 20 | 21 | 22 | def convert(machine, graph_name, 23 | graph_attrs=None, node_attrs_cb=None, edge_attrs_cb=None, 24 | add_start_state=True, name_translations=None): 25 | """Translates the state machine into a pydot graph. 26 | 27 | :param machine: state machine to convert 28 | :type machine: FiniteMachine 29 | :param graph_name: name of the graph to be created 30 | :type graph_name: string 31 | :param graph_attrs: any initial graph attributes to set 32 | (see http://www.graphviz.org/doc/info/attrs.html for 33 | what these can be) 34 | :type graph_attrs: dict 35 | :param node_attrs_cb: a callback that takes one argument ``state`` 36 | and is expected to return a dict of node attributes 37 | (see http://www.graphviz.org/doc/info/attrs.html for 38 | what these can be) 39 | :type node_attrs_cb: callback 40 | :param edge_attrs_cb: a callback that takes three arguments ``start_state, 41 | event, end_state`` and is expected to return a dict 42 | of edge attributes (see 43 | http://www.graphviz.org/doc/info/attrs.html for 44 | what these can be) 45 | :type edge_attrs_cb: callback 46 | :param add_start_state: when enabled this creates a *private* start state 47 | with the name ``__start__`` that will be a point 48 | node that will have a dotted edge to the 49 | ``default_start_state`` that your machine may have 50 | defined (if your machine has no actively defined 51 | ``default_start_state`` then this does nothing, 52 | even if enabled) 53 | :type add_start_state: bool 54 | :param name_translations: a dict that provides alternative ``state`` 55 | string names for each state 56 | :type name_translations: dict 57 | """ 58 | if not PYDOT_AVAILABLE: 59 | raise RuntimeError("pydot (or pydot2 or equivalent) is required" 60 | " to convert a state machine into a pydot" 61 | " graph") 62 | if not name_translations: 63 | name_translations = {} 64 | graph_kwargs = { 65 | 'rankdir': 'LR', 66 | 'nodesep': '0.25', 67 | 'overlap': 'false', 68 | 'ranksep': '0.5', 69 | 'size': "11x8.5", 70 | 'splines': 'true', 71 | 'ordering': 'in', 72 | } 73 | if graph_attrs is not None: 74 | graph_kwargs.update(graph_attrs) 75 | graph_kwargs['graph_name'] = graph_name 76 | g = pydot.Dot(**graph_kwargs) 77 | node_attrs = { 78 | 'fontsize': '11', 79 | } 80 | nodes = {} 81 | for (start_state, event, end_state) in machine: 82 | if start_state not in nodes: 83 | start_node_attrs = node_attrs.copy() 84 | if node_attrs_cb is not None: 85 | start_node_attrs.update(node_attrs_cb(start_state)) 86 | pretty_start_state = name_translations.get(start_state, 87 | start_state) 88 | nodes[start_state] = pydot.Node(pretty_start_state, 89 | **start_node_attrs) 90 | g.add_node(nodes[start_state]) 91 | if end_state not in nodes: 92 | end_node_attrs = node_attrs.copy() 93 | if node_attrs_cb is not None: 94 | end_node_attrs.update(node_attrs_cb(end_state)) 95 | pretty_end_state = name_translations.get(end_state, end_state) 96 | nodes[end_state] = pydot.Node(pretty_end_state, **end_node_attrs) 97 | g.add_node(nodes[end_state]) 98 | edge_attrs = {} 99 | if edge_attrs_cb is not None: 100 | edge_attrs.update(edge_attrs_cb(start_state, event, end_state)) 101 | g.add_edge(pydot.Edge(nodes[start_state], nodes[end_state], 102 | **edge_attrs)) 103 | if add_start_state and machine.default_start_state: 104 | start = pydot.Node("__start__", shape="point", width="0.1", 105 | xlabel='start', fontcolor='green', **node_attrs) 106 | g.add_node(start) 107 | g.add_edge(pydot.Edge(start, nodes[machine.default_start_state], 108 | style='dotted')) 109 | return g 110 | -------------------------------------------------------------------------------- /automaton/exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2014 Yahoo! Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | 16 | class AutomatonException(Exception): 17 | """Base class for *most* exceptions emitted from this library.""" 18 | 19 | 20 | class InvalidState(AutomatonException): 21 | """Raised when a invalid state transition is attempted while executing.""" 22 | 23 | 24 | class NotInitialized(AutomatonException): 25 | """Error raised when an action is attempted on a not inited machine.""" 26 | 27 | 28 | class NotFound(AutomatonException): 29 | """Raised when some entry in some object doesn't exist.""" 30 | 31 | 32 | class Duplicate(AutomatonException): 33 | """Raised when a duplicate entry is found.""" 34 | 35 | 36 | class FrozenMachine(AutomatonException): 37 | """Exception raised when a frozen machine is modified.""" 38 | 39 | def __init__(self): 40 | super().__init__("Frozen machine can't be modified") 41 | -------------------------------------------------------------------------------- /automaton/machines.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2014 Yahoo! Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | import collections 16 | 17 | import prettytable 18 | 19 | from automaton import _utils as utils 20 | from automaton import exceptions as excp 21 | 22 | 23 | class State: 24 | """Container that defines needed components of a single state. 25 | 26 | Usage of this and the :meth:`~.FiniteMachine.build` make creating finite 27 | state machines that much easier. 28 | 29 | :ivar name: The name of the state. 30 | :ivar is_terminal: Whether this state is terminal (or not). 31 | :ivar next_states: Dictionary of 'event' -> 'next state name' (or none). 32 | :ivar on_enter: callback that will be called when the state is entered. 33 | :ivar on_exit: callback that will be called when the state is exited. 34 | """ 35 | 36 | def __init__(self, name, 37 | is_terminal=False, next_states=None, 38 | on_enter=None, on_exit=None): 39 | self.name = name 40 | self.is_terminal = bool(is_terminal) 41 | self.next_states = next_states 42 | self.on_enter = on_enter 43 | self.on_exit = on_exit 44 | 45 | 46 | def _convert_to_states(state_space): 47 | # NOTE(harlowja): if provided dicts, convert them... 48 | for state in state_space: 49 | if isinstance(state, dict): 50 | state = State(**state) 51 | yield state 52 | 53 | 54 | def _orderedkeys(data, sort=True): 55 | if sort: 56 | return sorted(data) 57 | else: 58 | return list(data) 59 | 60 | 61 | class _Jump: 62 | """A FSM transition tracks this data while jumping.""" 63 | def __init__(self, name, on_enter, on_exit): 64 | self.name = name 65 | self.on_enter = on_enter 66 | self.on_exit = on_exit 67 | 68 | 69 | class FiniteMachine: 70 | """A finite state machine. 71 | 72 | This state machine can be used to automatically run a given set of 73 | transitions and states in response to events (either from callbacks or from 74 | generator/iterator send() values, see PEP 342). On each triggered event, a 75 | ``on_enter`` and ``on_exit`` callback can also be provided which will be 76 | called to perform some type of action on leaving a prior state and before 77 | entering a new state. 78 | 79 | NOTE(harlowja): reactions will *only* be called when the generator/iterator 80 | from :py:meth:`~automaton.runners.Runner.run_iter` does *not* send 81 | back a new event (they will always be called if the 82 | :py:meth:`~automaton.runners.Runner.run` method is used). This allows 83 | for two unique ways (these ways can also be intermixed) to use this state 84 | machine when using :py:meth:`~automaton.runners.Runner.run`; one 85 | where *external* event trigger the next state transition and one 86 | where *internal* reaction callbacks trigger the next state 87 | transition. The other way to use this 88 | state machine is to skip using :py:meth:`~automaton.runners.Runner.run` 89 | or :py:meth:`~automaton.runners.Runner.run_iter` 90 | completely and use the :meth:`~.FiniteMachine.process_event` method 91 | explicitly and trigger the events via 92 | some *external* functionality/triggers... 93 | """ 94 | 95 | #: The result of processing an event (cause and effect...) 96 | Effect = collections.namedtuple('Effect', 'reaction,terminal') 97 | 98 | @classmethod 99 | def _effect_builder(cls, new_state, event): 100 | return cls.Effect(new_state['reactions'].get(event), 101 | new_state["terminal"]) 102 | 103 | def __init__(self): 104 | self._transitions = {} 105 | self._states = collections.OrderedDict() 106 | self._default_start_state = None 107 | self._current = None 108 | self.frozen = False 109 | 110 | @property 111 | def default_start_state(self): 112 | """Sets the *default* start state that the machine should use. 113 | 114 | NOTE(harlowja): this will be used by ``initialize`` but only if that 115 | function is not given its own ``start_state`` that overrides this 116 | default. 117 | """ 118 | return self._default_start_state 119 | 120 | @default_start_state.setter 121 | def default_start_state(self, state): 122 | if self.frozen: 123 | raise excp.FrozenMachine() 124 | if state not in self._states: 125 | raise excp.NotFound("Can not set the default start state to" 126 | " undefined state '%s'" % (state)) 127 | self._default_start_state = state 128 | 129 | @classmethod 130 | def build(cls, state_space): 131 | """Builds a machine from a state space listing. 132 | 133 | Each element of this list must be an instance 134 | of :py:class:`.State` or a ``dict`` with equivalent keys that 135 | can be used to construct a :py:class:`.State` instance. 136 | """ 137 | state_space = list(_convert_to_states(state_space)) 138 | m = cls() 139 | for state in state_space: 140 | m.add_state(state.name, 141 | terminal=state.is_terminal, 142 | on_enter=state.on_enter, 143 | on_exit=state.on_exit) 144 | for state in state_space: 145 | if state.next_states: 146 | for event, next_state in state.next_states.items(): 147 | if isinstance(next_state, State): 148 | next_state = next_state.name 149 | m.add_transition(state.name, next_state, event) 150 | return m 151 | 152 | @property 153 | def current_state(self): 154 | """The current state the machine is in (or none if not initialized).""" 155 | if self._current is not None: 156 | return self._current.name 157 | return None 158 | 159 | @property 160 | def terminated(self): 161 | """Returns whether the state machine is in a terminal state.""" 162 | if self._current is None: 163 | return False 164 | return self._states[self._current.name]['terminal'] 165 | 166 | def add_state(self, state, terminal=False, on_enter=None, on_exit=None): 167 | """Adds a given state to the state machine. 168 | 169 | The ``on_enter`` and ``on_exit`` callbacks, if provided will be 170 | expected to take two positional parameters, these being the state 171 | being exited (for ``on_exit``) or the state being entered (for 172 | ``on_enter``) and a second parameter which is the event that is 173 | being processed that caused the state transition. 174 | """ 175 | if self.frozen: 176 | raise excp.FrozenMachine() 177 | if state in self._states: 178 | raise excp.Duplicate("State '%s' already defined" % state) 179 | if on_enter is not None: 180 | if not callable(on_enter): 181 | raise ValueError("On enter callback must be callable") 182 | if on_exit is not None: 183 | if not callable(on_exit): 184 | raise ValueError("On exit callback must be callable") 185 | self._states[state] = { 186 | 'terminal': bool(terminal), 187 | 'reactions': {}, 188 | 'on_enter': on_enter, 189 | 'on_exit': on_exit, 190 | } 191 | self._transitions[state] = collections.OrderedDict() 192 | 193 | def is_actionable_event(self, event): 194 | """Check whether the event is actionable in the current state.""" 195 | current = self._current 196 | if current is None: 197 | return False 198 | if event not in self._transitions[current.name]: 199 | return False 200 | return True 201 | 202 | def add_reaction(self, state, event, reaction, *args, **kwargs): 203 | """Adds a reaction that may get triggered by the given event & state. 204 | 205 | Reaction callbacks may (depending on how the state machine is ran) be 206 | used after an event is processed (and a transition occurs) to cause the 207 | machine to react to the newly arrived at stable state. 208 | 209 | These callbacks are expected to accept three default positional 210 | parameters (although more can be passed in via *args and **kwargs, 211 | these will automatically get provided to the callback when it is 212 | activated *ontop* of the three default). The three default parameters 213 | are the last stable state, the new stable state and the event that 214 | caused the transition to this new stable state to be arrived at. 215 | 216 | The expected result of a callback is expected to be a new event that 217 | the callback wants the state machine to react to. This new event 218 | may (depending on how the state machine is ran) get processed (and 219 | this process typically repeats) until the state machine reaches a 220 | terminal state. 221 | """ 222 | if self.frozen: 223 | raise excp.FrozenMachine() 224 | if state not in self._states: 225 | raise excp.NotFound("Can not add a reaction to event '%s' for an" 226 | " undefined state '%s'" % (event, state)) 227 | if not callable(reaction): 228 | raise ValueError("Reaction callback must be callable") 229 | if event not in self._states[state]['reactions']: 230 | self._states[state]['reactions'][event] = (reaction, args, kwargs) 231 | else: 232 | raise excp.Duplicate("State '%s' reaction to event '%s'" 233 | " already defined" % (state, event)) 234 | 235 | def add_transition(self, start, end, event, replace=False): 236 | """Adds an allowed transition from start -> end for the given event. 237 | 238 | :param start: starting state 239 | :param end: ending state 240 | :param event: event that causes start state to 241 | transition to end state 242 | :param replace: replace existing event instead of raising a 243 | :py:class:`~automaton.exceptions.Duplicate` exception 244 | when the transition already exists. 245 | """ 246 | if self.frozen: 247 | raise excp.FrozenMachine() 248 | if start not in self._states: 249 | raise excp.NotFound("Can not add a transition on event '%s' that" 250 | " starts in a undefined state '%s'" 251 | % (event, start)) 252 | if end not in self._states: 253 | raise excp.NotFound("Can not add a transition on event '%s' that" 254 | " ends in a undefined state '%s'" 255 | % (event, end)) 256 | if self._states[start]['terminal']: 257 | raise excp.InvalidState("Can not add a transition on event '%s'" 258 | " that starts in the terminal state '%s'" 259 | % (event, start)) 260 | if event in self._transitions[start] and not replace: 261 | target = self._transitions[start][event] 262 | if target.name != end: 263 | raise excp.Duplicate("Cannot add transition from" 264 | " '%(start_state)s' to '%(end_state)s'" 265 | " on event '%(event)s' because a" 266 | " transition from '%(start_state)s'" 267 | " to '%(existing_end_state)s' on" 268 | " event '%(event)s' already exists." 269 | % {'existing_end_state': target.name, 270 | 'end_state': end, 'event': event, 271 | 'start_state': start}) 272 | else: 273 | target = _Jump(end, self._states[end]['on_enter'], 274 | self._states[start]['on_exit']) 275 | self._transitions[start][event] = target 276 | 277 | def _pre_process_event(self, event): 278 | current = self._current 279 | if current is None: 280 | raise excp.NotInitialized("Can not process event '%s'; the state" 281 | " machine hasn't been initialized" 282 | % event) 283 | if self._states[current.name]['terminal']: 284 | raise excp.InvalidState("Can not transition from terminal" 285 | " state '%s' on event '%s'" 286 | % (current.name, event)) 287 | if event not in self._transitions[current.name]: 288 | raise excp.NotFound("Can not transition from state '%s' on" 289 | " event '%s' (no defined transition)" 290 | % (current.name, event)) 291 | 292 | def _post_process_event(self, event, result): 293 | return result 294 | 295 | def process_event(self, event): 296 | """Trigger a state change in response to the provided event. 297 | 298 | :returns: Effect this is either a :py:class:`.FiniteMachine.Effect` or 299 | an ``Effect`` from a subclass of :py:class:`.FiniteMachine`. 300 | See the appropriate named tuple for a description of the 301 | actual items in the tuple. For 302 | example, :py:class:`.FiniteMachine.Effect`'s 303 | first item is ``reaction``: one could invoke this reaction's 304 | callback to react to the new stable state. 305 | :rtype: namedtuple 306 | """ 307 | self._pre_process_event(event) 308 | current = self._current 309 | replacement = self._transitions[current.name][event] 310 | if current.on_exit is not None: 311 | current.on_exit(current.name, event) 312 | if replacement.on_enter is not None: 313 | replacement.on_enter(replacement.name, event) 314 | self._current = replacement 315 | result = self._effect_builder(self._states[replacement.name], event) 316 | return self._post_process_event(event, result) 317 | 318 | def initialize(self, start_state=None): 319 | """Sets up the state machine (sets current state to start state...). 320 | 321 | :param start_state: explicit start state to use to initialize the 322 | state machine to. If ``None`` is provided then 323 | the machine's default start state will be used 324 | instead. 325 | """ 326 | if start_state is None: 327 | start_state = self._default_start_state 328 | if start_state not in self._states: 329 | raise excp.NotFound("Can not start from a undefined" 330 | " state '%s'" % (start_state)) 331 | if self._states[start_state]['terminal']: 332 | raise excp.InvalidState("Can not start from a terminal" 333 | " state '%s'" % (start_state)) 334 | # No on enter will be called, since we are priming the state machine 335 | # and have not really transitioned from anything to get here, we will 336 | # though allow on_exit to be called on the event that causes this 337 | # to be moved from... 338 | self._current = _Jump(start_state, None, 339 | self._states[start_state]['on_exit']) 340 | 341 | def copy(self, shallow=False, unfreeze=False): 342 | """Copies the current state machine. 343 | 344 | NOTE(harlowja): the copy will be left in an *uninitialized* state. 345 | 346 | NOTE(harlowja): when a shallow copy is requested the copy will share 347 | the same transition table and state table as the 348 | source; this can be advantageous if you have a machine 349 | and transitions + states that is defined somewhere 350 | and want to use copies to run with (the copies have 351 | the current state that is different between machines). 352 | """ 353 | c = type(self)() 354 | c._default_start_state = self._default_start_state 355 | if unfreeze and self.frozen: 356 | c.frozen = False 357 | else: 358 | c.frozen = self.frozen 359 | if not shallow: 360 | for state, data in self._states.items(): 361 | copied_data = data.copy() 362 | copied_data['reactions'] = copied_data['reactions'].copy() 363 | c._states[state] = copied_data 364 | for state, data in self._transitions.items(): 365 | c._transitions[state] = data.copy() 366 | else: 367 | c._transitions = self._transitions 368 | c._states = self._states 369 | return c 370 | 371 | def __contains__(self, state): 372 | """Returns if this state exists in the machines known states.""" 373 | return state in self._states 374 | 375 | def freeze(self): 376 | """Freezes & stops addition of states, transitions, reactions...""" 377 | self.frozen = True 378 | 379 | @property 380 | def states(self): 381 | """Returns the state names.""" 382 | return list(self._states) 383 | 384 | @property 385 | def events(self): 386 | """Returns how many events exist.""" 387 | c = 0 388 | for state in self._states: 389 | c += len(self._transitions[state]) 390 | return c 391 | 392 | def __iter__(self): 393 | """Iterates over (start, event, end) transition tuples.""" 394 | for state in self._states: 395 | for event, target in self._transitions[state].items(): 396 | yield (state, event, target.name) 397 | 398 | def pformat(self, sort=True, empty='.'): 399 | """Pretty formats the state + transition table into a string. 400 | 401 | NOTE(harlowja): the sort parameter can be provided to sort the states 402 | and transitions by sort order; with it being provided as false the rows 403 | will be iterated in addition order instead. 404 | """ 405 | tbl = prettytable.PrettyTable(["Start", "Event", "End", 406 | "On Enter", "On Exit"]) 407 | for state in _orderedkeys(self._states, sort=sort): 408 | prefix_markings = [] 409 | if self.current_state == state: 410 | prefix_markings.append("@") 411 | postfix_markings = [] 412 | if self.default_start_state == state: 413 | postfix_markings.append("^") 414 | if self._states[state]['terminal']: 415 | postfix_markings.append("$") 416 | pretty_state = "{}{}".format("".join(prefix_markings), state) 417 | if postfix_markings: 418 | pretty_state += "[%s]" % "".join(postfix_markings) 419 | if self._transitions[state]: 420 | for event in _orderedkeys(self._transitions[state], 421 | sort=sort): 422 | target = self._transitions[state][event] 423 | row = [pretty_state, event, target.name] 424 | if target.on_enter is not None: 425 | row.append(utils.get_callback_name(target.on_enter)) 426 | else: 427 | row.append(empty) 428 | if target.on_exit is not None: 429 | row.append(utils.get_callback_name(target.on_exit)) 430 | else: 431 | row.append(empty) 432 | tbl.add_row(row) 433 | else: 434 | on_enter = self._states[state]['on_enter'] 435 | if on_enter is not None: 436 | on_enter = utils.get_callback_name(on_enter) 437 | else: 438 | on_enter = empty 439 | on_exit = self._states[state]['on_exit'] 440 | if on_exit is not None: 441 | on_exit = utils.get_callback_name(on_exit) 442 | else: 443 | on_exit = empty 444 | tbl.add_row([pretty_state, empty, empty, on_enter, on_exit]) 445 | return tbl.get_string() 446 | 447 | 448 | class HierarchicalFiniteMachine(FiniteMachine): 449 | """A fsm that understands how to run in a hierarchical mode.""" 450 | 451 | #: The result of processing an event (cause and effect...) 452 | Effect = collections.namedtuple('Effect', 453 | 'reaction,terminal,machine') 454 | 455 | def __init__(self): 456 | super().__init__() 457 | self._nested_machines = {} 458 | 459 | @classmethod 460 | def _effect_builder(cls, new_state, event): 461 | return cls.Effect(new_state['reactions'].get(event), 462 | new_state["terminal"], new_state.get('machine')) 463 | 464 | def add_state(self, state, 465 | terminal=False, on_enter=None, on_exit=None, machine=None): 466 | """Adds a given state to the state machine. 467 | 468 | :param machine: the nested state machine that will be transitioned 469 | into when this state is entered 470 | :type machine: :py:class:`.FiniteMachine` 471 | 472 | Further arguments are interpreted as 473 | for :py:meth:`.FiniteMachine.add_state`. 474 | """ 475 | if machine is not None and not isinstance(machine, FiniteMachine): 476 | raise ValueError( 477 | "Nested state machines must themselves be state machines") 478 | super().add_state( 479 | state, terminal=terminal, on_enter=on_enter, on_exit=on_exit) 480 | if machine is not None: 481 | self._states[state]['machine'] = machine 482 | self._nested_machines[state] = machine 483 | 484 | def copy(self, shallow=False, unfreeze=False): 485 | c = super().copy(shallow=shallow, 486 | unfreeze=unfreeze) 487 | if shallow: 488 | c._nested_machines = self._nested_machines 489 | else: 490 | c._nested_machines = self._nested_machines.copy() 491 | return c 492 | 493 | def initialize(self, start_state=None, 494 | nested_start_state_fetcher=None): 495 | """Sets up the state machine (sets current state to start state...). 496 | 497 | :param start_state: explicit start state to use to initialize the 498 | state machine to. If ``None`` is provided then the 499 | machine's default start state will be used 500 | instead. 501 | :param nested_start_state_fetcher: A callback that can return start 502 | states for any nested machines 503 | **only**. If not ``None`` then it 504 | will be provided a single argument, 505 | the machine to provide a starting 506 | state for and it is expected to 507 | return a starting state (or 508 | ``None``) for each machine called 509 | with. Do note that this callback 510 | will also be passed to other nested 511 | state machines as well, so it will 512 | also be used to initialize any state 513 | machines they contain (recursively). 514 | """ 515 | super().initialize( 516 | start_state=start_state) 517 | for data in self._states.values(): 518 | if 'machine' in data: 519 | nested_machine = data['machine'] 520 | nested_start_state = None 521 | if nested_start_state_fetcher is not None: 522 | nested_start_state = nested_start_state_fetcher( 523 | nested_machine) 524 | if isinstance(nested_machine, HierarchicalFiniteMachine): 525 | nested_machine.initialize( 526 | start_state=nested_start_state, 527 | nested_start_state_fetcher=nested_start_state_fetcher) 528 | else: 529 | nested_machine.initialize(start_state=nested_start_state) 530 | 531 | @property 532 | def nested_machines(self): 533 | """Dictionary of **all** nested state machines this machine may use.""" 534 | return self._nested_machines 535 | -------------------------------------------------------------------------------- /automaton/runners.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Yahoo! Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | import abc 16 | 17 | from automaton import exceptions as excp 18 | from automaton import machines 19 | 20 | 21 | _JUMPER_NOT_FOUND_TPL = ("Unable to progress since no reaction (or" 22 | " sent event) has been made available in" 23 | " new state '%s' (moved to from state '%s'" 24 | " in response to event '%s')") 25 | 26 | 27 | class Runner(metaclass=abc.ABCMeta): 28 | """Machine runner used to run a state machine. 29 | 30 | Only **one** runner per machine should be active at the same time (aka 31 | there should not be multiple runners using the same machine instance at 32 | the same time). 33 | """ 34 | def __init__(self, machine): 35 | self._machine = machine 36 | 37 | @abc.abstractmethod 38 | def run(self, event, initialize=True): 39 | """Runs the state machine, using reactions only.""" 40 | 41 | @abc.abstractmethod 42 | def run_iter(self, event, initialize=True): 43 | """Returns a iterator/generator that will run the state machine. 44 | 45 | NOTE(harlowja): only one runner iterator/generator should be active for 46 | a machine, if this is not observed then it is possible for 47 | initialization and other local state to be corrupted and cause issues 48 | when running... 49 | """ 50 | 51 | 52 | class FiniteRunner(Runner): 53 | """Finite machine runner used to run a finite machine. 54 | 55 | Only **one** runner per machine should be active at the same time (aka 56 | there should not be multiple runners using the same machine instance at 57 | the same time). 58 | """ 59 | 60 | def __init__(self, machine): 61 | """Create a runner for the given machine.""" 62 | if not isinstance(machine, (machines.FiniteMachine,)): 63 | raise TypeError("FiniteRunner only works with FiniteMachine(s)") 64 | super().__init__(machine) 65 | 66 | def run(self, event, initialize=True): 67 | for transition in self.run_iter(event, initialize=initialize): 68 | pass 69 | 70 | def run_iter(self, event, initialize=True): 71 | if initialize: 72 | self._machine.initialize() 73 | while True: 74 | old_state = self._machine.current_state 75 | reaction, terminal = self._machine.process_event(event) 76 | new_state = self._machine.current_state 77 | try: 78 | sent_event = yield (old_state, new_state) 79 | except GeneratorExit: 80 | break 81 | if terminal: 82 | break 83 | if reaction is None and sent_event is None: 84 | raise excp.NotFound(_JUMPER_NOT_FOUND_TPL % (new_state, 85 | old_state, 86 | event)) 87 | elif sent_event is not None: 88 | event = sent_event 89 | else: 90 | cb, args, kwargs = reaction 91 | event = cb(old_state, new_state, event, *args, **kwargs) 92 | 93 | 94 | class HierarchicalRunner(Runner): 95 | """Hierarchical machine runner used to run a hierarchical machine. 96 | 97 | Only **one** runner per machine should be active at the same time (aka 98 | there should not be multiple runners using the same machine instance at 99 | the same time). 100 | """ 101 | 102 | def __init__(self, machine): 103 | """Create a runner for the given machine.""" 104 | if not isinstance(machine, (machines.HierarchicalFiniteMachine,)): 105 | raise TypeError("HierarchicalRunner only works with" 106 | " HierarchicalFiniteMachine(s)") 107 | super().__init__(machine) 108 | 109 | def run(self, event, initialize=True): 110 | for transition in self.run_iter(event, initialize=initialize): 111 | pass 112 | 113 | @staticmethod 114 | def _process_event(machines, event): 115 | """Matches a event to the machine hierarchy. 116 | 117 | If the lowest level machine does not handle the event, then the 118 | parent machine is referred to and so on, until there is only one 119 | machine left which *must* handle the event. 120 | 121 | The machine whose ``process_event`` does not throw invalid state or 122 | not found exceptions is expected to be the machine that should 123 | continue handling events... 124 | """ 125 | while True: 126 | machine = machines[-1] 127 | try: 128 | result = machine.process_event(event) 129 | except (excp.InvalidState, excp.NotFound): 130 | if len(machines) == 1: 131 | raise 132 | else: 133 | current = machine._current 134 | if current is not None and current.on_exit is not None: 135 | current.on_exit(current.name, event) 136 | machine._current = None 137 | machines.pop() 138 | else: 139 | return result 140 | 141 | def run_iter(self, event, initialize=True): 142 | """Returns a iterator/generator that will run the state machine. 143 | 144 | This will keep a stack (hierarchy) of machines active and jumps through 145 | them as needed (depending on which machine handles which event) during 146 | the running lifecycle. 147 | 148 | NOTE(harlowja): only one runner iterator/generator should be active for 149 | a machine hierarchy, if this is not observed then it is possible for 150 | initialization and other local state to be corrupted and causes issues 151 | when running... 152 | """ 153 | machines = [self._machine] 154 | if initialize: 155 | machines[-1].initialize() 156 | while True: 157 | old_state = machines[-1].current_state 158 | effect = self._process_event(machines, event) 159 | new_state = machines[-1].current_state 160 | try: 161 | machine = effect.machine 162 | except AttributeError: 163 | pass 164 | else: 165 | if machine is not None and machine is not machines[-1]: 166 | machine.initialize() 167 | machines.append(machine) 168 | try: 169 | sent_event = yield (old_state, new_state) 170 | except GeneratorExit: 171 | break 172 | if len(machines) == 1 and effect.terminal: 173 | # Only allow the top level machine to actually terminate the 174 | # execution, the rest of the nested machines must not handle 175 | # events if they wish to have the root machine terminate... 176 | break 177 | if effect.reaction is None and sent_event is None: 178 | raise excp.NotFound(_JUMPER_NOT_FOUND_TPL % (new_state, 179 | old_state, 180 | event)) 181 | elif sent_event is not None: 182 | event = sent_event 183 | else: 184 | cb, args, kwargs = effect.reaction 185 | event = cb(old_state, new_state, event, *args, **kwargs) 186 | -------------------------------------------------------------------------------- /automaton/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openstack/automaton/d85ecfadf9236ee6eff3b0bbde104a7f519ea463/automaton/tests/__init__.py -------------------------------------------------------------------------------- /automaton/tests/test_fsm.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2014 Yahoo! Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | import collections 16 | import functools 17 | import random 18 | 19 | from automaton import exceptions as excp 20 | from automaton import machines 21 | from automaton import runners 22 | 23 | from testtools import testcase 24 | 25 | 26 | class FSMTest(testcase.TestCase): 27 | 28 | @staticmethod 29 | def _create_fsm(start_state, add_start=True, add_states=None): 30 | m = machines.FiniteMachine() 31 | if add_start: 32 | m.add_state(start_state) 33 | m.default_start_state = start_state 34 | if add_states: 35 | for s in add_states: 36 | if s in m: 37 | continue 38 | m.add_state(s) 39 | return m 40 | 41 | def setUp(self): 42 | super().setUp() 43 | # NOTE(harlowja): this state machine will never stop if run() is used. 44 | self.jumper = self._create_fsm("down", add_states=['up', 'down']) 45 | self.jumper.add_transition('down', 'up', 'jump') 46 | self.jumper.add_transition('up', 'down', 'fall') 47 | self.jumper.add_reaction('up', 'jump', lambda *args: 'fall') 48 | self.jumper.add_reaction('down', 'fall', lambda *args: 'jump') 49 | 50 | def test_build(self): 51 | space = [] 52 | for a in 'abc': 53 | space.append(machines.State(a)) 54 | m = machines.FiniteMachine.build(space) 55 | for a in 'abc': 56 | self.assertIn(a, m) 57 | 58 | def test_build_transitions(self): 59 | space = [ 60 | machines.State('down', is_terminal=False, 61 | next_states={'jump': 'up'}), 62 | machines.State('up', is_terminal=False, 63 | next_states={'fall': 'down'}), 64 | ] 65 | m = machines.FiniteMachine.build(space) 66 | m.default_start_state = 'down' 67 | expected = [('down', 'jump', 'up'), ('up', 'fall', 'down')] 68 | self.assertEqual(expected, list(m)) 69 | 70 | def test_build_transitions_with_callbacks(self): 71 | entered = collections.defaultdict(list) 72 | exitted = collections.defaultdict(list) 73 | 74 | def on_enter(state, event): 75 | entered[state].append(event) 76 | 77 | def on_exit(state, event): 78 | exitted[state].append(event) 79 | 80 | space = [ 81 | machines.State('down', is_terminal=False, 82 | next_states={'jump': 'up'}, 83 | on_enter=on_enter, on_exit=on_exit), 84 | machines.State('up', is_terminal=False, 85 | next_states={'fall': 'down'}, 86 | on_enter=on_enter, on_exit=on_exit), 87 | ] 88 | m = machines.FiniteMachine.build(space) 89 | m.default_start_state = 'down' 90 | expected = [('down', 'jump', 'up'), ('up', 'fall', 'down')] 91 | self.assertEqual(expected, list(m)) 92 | 93 | m.initialize() 94 | m.process_event('jump') 95 | 96 | self.assertEqual({'down': ['jump']}, dict(exitted)) 97 | self.assertEqual({'up': ['jump']}, dict(entered)) 98 | 99 | m.process_event('fall') 100 | 101 | self.assertEqual({'down': ['jump'], 'up': ['fall']}, dict(exitted)) 102 | self.assertEqual({'up': ['jump'], 'down': ['fall']}, dict(entered)) 103 | 104 | def test_build_transitions_dct(self): 105 | space = [ 106 | { 107 | 'name': 'down', 'is_terminal': False, 108 | 'next_states': {'jump': 'up'}, 109 | }, 110 | { 111 | 'name': 'up', 'is_terminal': False, 112 | 'next_states': {'fall': 'down'}, 113 | }, 114 | ] 115 | m = machines.FiniteMachine.build(space) 116 | m.default_start_state = 'down' 117 | expected = [('down', 'jump', 'up'), ('up', 'fall', 'down')] 118 | self.assertEqual(expected, list(m)) 119 | 120 | def test_build_terminal(self): 121 | space = [ 122 | machines.State('down', is_terminal=False, 123 | next_states={'jump': 'fell_over'}), 124 | machines.State('fell_over', is_terminal=True), 125 | ] 126 | m = machines.FiniteMachine.build(space) 127 | m.default_start_state = 'down' 128 | m.initialize() 129 | m.process_event('jump') 130 | self.assertTrue(m.terminated) 131 | 132 | def test_actionable(self): 133 | self.jumper.initialize() 134 | self.assertTrue(self.jumper.is_actionable_event('jump')) 135 | self.assertFalse(self.jumper.is_actionable_event('fall')) 136 | 137 | def test_bad_start_state(self): 138 | m = self._create_fsm('unknown', add_start=False) 139 | r = runners.FiniteRunner(m) 140 | self.assertRaises(excp.NotFound, r.run, 'unknown') 141 | 142 | def test_contains(self): 143 | m = self._create_fsm('unknown', add_start=False) 144 | self.assertNotIn('unknown', m) 145 | m.add_state('unknown') 146 | self.assertIn('unknown', m) 147 | 148 | def test_no_add_transition_terminal(self): 149 | m = self._create_fsm('up') 150 | m.add_state('down', terminal=True) 151 | self.assertRaises(excp.InvalidState, 152 | m.add_transition, 'down', 'up', 'jump') 153 | 154 | def test_duplicate_state(self): 155 | m = self._create_fsm('unknown') 156 | self.assertRaises(excp.Duplicate, m.add_state, 'unknown') 157 | 158 | def test_duplicate_transition(self): 159 | m = self.jumper 160 | m.add_state('side_ways') 161 | self.assertRaises(excp.Duplicate, 162 | m.add_transition, 'up', 'side_ways', 'fall') 163 | 164 | def test_duplicate_transition_replace(self): 165 | m = self.jumper 166 | m.add_state('side_ways') 167 | m.add_transition('up', 'side_ways', 'fall', replace=True) 168 | 169 | def test_duplicate_transition_same_transition(self): 170 | m = self.jumper 171 | m.add_transition('up', 'down', 'fall') 172 | 173 | def test_duplicate_reaction(self): 174 | self.assertRaises( 175 | # Currently duplicate reactions are not allowed... 176 | excp.Duplicate, 177 | self.jumper.add_reaction, 'down', 'fall', lambda *args: 'skate') 178 | 179 | def test_bad_transition(self): 180 | m = self._create_fsm('unknown') 181 | m.add_state('fire') 182 | self.assertRaises(excp.NotFound, m.add_transition, 183 | 'unknown', 'something', 'boom') 184 | self.assertRaises(excp.NotFound, m.add_transition, 185 | 'something', 'unknown', 'boom') 186 | 187 | def test_bad_reaction(self): 188 | m = self._create_fsm('unknown') 189 | self.assertRaises(excp.NotFound, m.add_reaction, 'something', 'boom', 190 | lambda *args: 'cough') 191 | 192 | def test_run(self): 193 | m = self._create_fsm('down', add_states=['up', 'down']) 194 | m.add_state('broken', terminal=True) 195 | m.add_transition('down', 'up', 'jump') 196 | m.add_transition('up', 'broken', 'hit-wall') 197 | m.add_reaction('up', 'jump', lambda *args: 'hit-wall') 198 | self.assertEqual(['broken', 'down', 'up'], sorted(m.states)) 199 | self.assertEqual(2, m.events) 200 | m.initialize() 201 | self.assertEqual('down', m.current_state) 202 | self.assertFalse(m.terminated) 203 | r = runners.FiniteRunner(m) 204 | r.run('jump') 205 | self.assertTrue(m.terminated) 206 | self.assertEqual('broken', m.current_state) 207 | self.assertRaises(excp.InvalidState, r.run, 208 | 'jump', initialize=False) 209 | 210 | def test_on_enter_on_exit(self): 211 | enter_transitions = [] 212 | exit_transitions = [] 213 | 214 | def on_exit(state, event): 215 | exit_transitions.append((state, event)) 216 | 217 | def on_enter(state, event): 218 | enter_transitions.append((state, event)) 219 | 220 | m = self._create_fsm('start', add_start=False) 221 | m.add_state('start', on_exit=on_exit) 222 | m.add_state('down', on_enter=on_enter, on_exit=on_exit) 223 | m.add_state('up', on_enter=on_enter, on_exit=on_exit) 224 | m.add_transition('start', 'down', 'beat') 225 | m.add_transition('down', 'up', 'jump') 226 | m.add_transition('up', 'down', 'fall') 227 | 228 | m.initialize('start') 229 | m.process_event('beat') 230 | m.process_event('jump') 231 | m.process_event('fall') 232 | self.assertEqual([('down', 'beat'), 233 | ('up', 'jump'), ('down', 'fall')], enter_transitions) 234 | self.assertEqual([('start', 'beat'), ('down', 'jump'), ('up', 'fall')], 235 | exit_transitions) 236 | 237 | def test_run_iter(self): 238 | up_downs = [] 239 | runner = runners.FiniteRunner(self.jumper) 240 | for (old_state, new_state) in runner.run_iter('jump'): 241 | up_downs.append((old_state, new_state)) 242 | if len(up_downs) >= 3: 243 | break 244 | self.assertEqual([('down', 'up'), ('up', 'down'), ('down', 'up')], 245 | up_downs) 246 | self.assertFalse(self.jumper.terminated) 247 | self.assertEqual('up', self.jumper.current_state) 248 | self.jumper.process_event('fall') 249 | self.assertEqual('down', self.jumper.current_state) 250 | 251 | def test_run_send(self): 252 | up_downs = [] 253 | runner = runners.FiniteRunner(self.jumper) 254 | it = runner.run_iter('jump') 255 | while True: 256 | up_downs.append(it.send(None)) 257 | if len(up_downs) >= 3: 258 | it.close() 259 | break 260 | self.assertEqual('up', self.jumper.current_state) 261 | self.assertFalse(self.jumper.terminated) 262 | self.assertEqual([('down', 'up'), ('up', 'down'), ('down', 'up')], 263 | up_downs) 264 | self.assertRaises(StopIteration, next, it) 265 | 266 | def test_run_send_fail(self): 267 | up_downs = [] 268 | runner = runners.FiniteRunner(self.jumper) 269 | it = runner.run_iter('jump') 270 | up_downs.append(next(it)) 271 | self.assertRaises(excp.NotFound, it.send, 'fail') 272 | it.close() 273 | self.assertEqual([('down', 'up')], up_downs) 274 | 275 | def test_not_initialized(self): 276 | self.assertRaises(excp.NotInitialized, 277 | self.jumper.process_event, 'jump') 278 | 279 | def test_copy_states(self): 280 | c = self._create_fsm('down', add_start=False) 281 | self.assertEqual(0, len(c.states)) 282 | d = c.copy() 283 | c.add_state('up') 284 | c.add_state('down') 285 | self.assertEqual(2, len(c.states)) 286 | self.assertEqual(0, len(d.states)) 287 | 288 | def test_copy_reactions(self): 289 | c = self._create_fsm('down', add_start=False) 290 | d = c.copy() 291 | 292 | c.add_state('down') 293 | c.add_state('up') 294 | c.add_reaction('down', 'jump', lambda *args: 'up') 295 | c.add_transition('down', 'up', 'jump') 296 | 297 | self.assertEqual(1, c.events) 298 | self.assertEqual(0, d.events) 299 | self.assertNotIn('down', d) 300 | self.assertNotIn('up', d) 301 | self.assertEqual([], list(d)) 302 | self.assertEqual([('down', 'jump', 'up')], list(c)) 303 | 304 | def test_copy_initialized(self): 305 | j = self.jumper.copy() 306 | self.assertIsNone(j.current_state) 307 | r = runners.FiniteRunner(self.jumper) 308 | 309 | for i, transition in enumerate(r.run_iter('jump')): 310 | if i == 4: 311 | break 312 | 313 | self.assertIsNone(j.current_state) 314 | self.assertIsNotNone(self.jumper.current_state) 315 | 316 | def test_iter(self): 317 | transitions = list(self.jumper) 318 | self.assertEqual(2, len(transitions)) 319 | self.assertIn(('up', 'fall', 'down'), transitions) 320 | self.assertIn(('down', 'jump', 'up'), transitions) 321 | 322 | def test_freeze(self): 323 | self.jumper.freeze() 324 | self.assertRaises(excp.FrozenMachine, self.jumper.add_state, 'test') 325 | self.assertRaises(excp.FrozenMachine, 326 | self.jumper.add_transition, 'test', 'test', 'test') 327 | self.assertRaises(excp.FrozenMachine, 328 | self.jumper.add_reaction, 329 | 'test', 'test', lambda *args: 'test') 330 | 331 | def test_freeze_copy_unfreeze(self): 332 | self.jumper.freeze() 333 | self.assertTrue(self.jumper.frozen) 334 | cp = self.jumper.copy(unfreeze=True) 335 | self.assertTrue(self.jumper.frozen) 336 | self.assertFalse(cp.frozen) 337 | 338 | def test_invalid_callbacks(self): 339 | m = self._create_fsm('working', add_states=['working', 'broken']) 340 | self.assertRaises(ValueError, m.add_state, 'b', on_enter=2) 341 | self.assertRaises(ValueError, m.add_state, 'b', on_exit=2) 342 | 343 | 344 | class HFSMTest(FSMTest): 345 | 346 | @staticmethod 347 | def _create_fsm(start_state, 348 | add_start=True, hierarchical=False, add_states=None): 349 | if hierarchical: 350 | m = machines.HierarchicalFiniteMachine() 351 | else: 352 | m = machines.FiniteMachine() 353 | if add_start: 354 | m.add_state(start_state) 355 | m.default_start_state = start_state 356 | if add_states: 357 | for s in add_states: 358 | if s not in m: 359 | m.add_state(s) 360 | return m 361 | 362 | def _make_phone_call(self, talk_time=1.0): 363 | 364 | def phone_reaction(old_state, new_state, event, chat_iter): 365 | try: 366 | next(chat_iter) 367 | except StopIteration: 368 | return 'finish' 369 | else: 370 | # Talk until the iterator expires... 371 | return 'chat' 372 | 373 | talker = self._create_fsm("talk") 374 | talker.add_transition("talk", "talk", "pickup") 375 | talker.add_transition("talk", "talk", "chat") 376 | talker.add_reaction("talk", "pickup", lambda *args: 'chat') 377 | chat_iter = iter(list(range(0, 10))) 378 | talker.add_reaction("talk", "chat", phone_reaction, chat_iter) 379 | 380 | handler = self._create_fsm('begin', hierarchical=True) 381 | handler.add_state("phone", machine=talker) 382 | handler.add_state('hangup', terminal=True) 383 | handler.add_transition("begin", "phone", "call") 384 | handler.add_reaction("phone", 'call', lambda *args: 'pickup') 385 | handler.add_transition("phone", "hangup", "finish") 386 | 387 | return handler 388 | 389 | def _make_phone_dialer(self): 390 | dialer = self._create_fsm("idle", hierarchical=True) 391 | digits = self._create_fsm("idle") 392 | 393 | dialer.add_state("pickup", machine=digits) 394 | dialer.add_transition("idle", "pickup", "dial") 395 | dialer.add_reaction("pickup", "dial", lambda *args: 'press') 396 | dialer.add_state("hangup", terminal=True) 397 | 398 | def react_to_press(last_state, new_state, event, number_calling): 399 | if len(number_calling) >= 10: 400 | return 'call' 401 | else: 402 | return 'press' 403 | 404 | digit_maker = functools.partial(random.randint, 0, 9) 405 | number_calling = [] 406 | digits.add_state( 407 | "accumulate", 408 | on_enter=lambda *args: number_calling.append(digit_maker())) 409 | digits.add_transition("idle", "accumulate", "press") 410 | digits.add_transition("accumulate", "accumulate", "press") 411 | digits.add_reaction("accumulate", "press", 412 | react_to_press, number_calling) 413 | digits.add_state("dial", terminal=True) 414 | digits.add_transition("accumulate", "dial", "call") 415 | digits.add_reaction("dial", "call", lambda *args: 'ringing') 416 | dialer.add_state("talk") 417 | dialer.add_transition("pickup", "talk", "ringing") 418 | dialer.add_reaction("talk", "ringing", lambda *args: 'hangup') 419 | dialer.add_transition("talk", "hangup", 'hangup') 420 | return dialer, number_calling 421 | 422 | def test_nested_machines(self): 423 | dialer, _number_calling = self._make_phone_dialer() 424 | self.assertEqual(1, len(dialer.nested_machines)) 425 | 426 | def test_nested_machine_initializers(self): 427 | dialer, _number_calling = self._make_phone_dialer() 428 | queried_for = [] 429 | 430 | def init_with(nested_machine): 431 | queried_for.append(nested_machine) 432 | return None 433 | 434 | dialer.initialize(nested_start_state_fetcher=init_with) 435 | self.assertEqual(1, len(queried_for)) 436 | 437 | def test_phone_dialer_iter(self): 438 | dialer, number_calling = self._make_phone_dialer() 439 | self.assertEqual(0, len(number_calling)) 440 | r = runners.HierarchicalRunner(dialer) 441 | transitions = list(r.run_iter('dial')) 442 | self.assertEqual(('talk', 'hangup'), transitions[-1]) 443 | self.assertEqual(len(number_calling), 444 | sum(1 if new_state == 'accumulate' else 0 445 | for (old_state, new_state) in transitions)) 446 | self.assertEqual(10, len(number_calling)) 447 | 448 | def test_phone_call(self): 449 | handler = self._make_phone_call() 450 | r = runners.HierarchicalRunner(handler) 451 | r.run('call') 452 | self.assertTrue(handler.terminated) 453 | 454 | def test_phone_call_iter(self): 455 | handler = self._make_phone_call() 456 | r = runners.HierarchicalRunner(handler) 457 | transitions = list(r.run_iter('call')) 458 | self.assertEqual(('talk', 'hangup'), transitions[-1]) 459 | self.assertEqual(("begin", 'phone'), transitions[0]) 460 | talk_talk = 0 461 | for transition in transitions: 462 | if transition == ("talk", "talk"): 463 | talk_talk += 1 464 | self.assertGreater(talk_talk, 0) 465 | -------------------------------------------------------------------------------- /bindep.txt: -------------------------------------------------------------------------------- 1 | # This is a cross-platform list tracking distribution packages needed for install and tests; 2 | # see https://docs.openstack.org/infra/bindep/ for additional information. 3 | 4 | graphviz [!platform:gentoo] 5 | media-gfx/graphviz [platform:gentoo] 6 | 7 | -------------------------------------------------------------------------------- /doc/requirements.txt: -------------------------------------------------------------------------------- 1 | # For generating sphinx documentation 2 | doc8>=0.6.0 # Apache-2.0 3 | sphinx>=2.0.0 # BSD 4 | openstackdocstheme>=2.2.1 # Apache-2.0 5 | reno>=3.1.0 # Apache-2.0 6 | -------------------------------------------------------------------------------- /doc/source/conf.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 Red Hat, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 12 | # implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | import os 17 | import sys 18 | 19 | sys.path.insert(0, os.path.abspath('../..')) 20 | # -- General configuration ---------------------------------------------------- 21 | 22 | # Add any Sphinx extension module names here, as strings. They can be 23 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 24 | extensions = [ 25 | 'sphinx.ext.autodoc', 26 | 'sphinx.ext.doctest', 27 | 'sphinx.ext.inheritance_diagram', 28 | 'sphinx.ext.viewcode', 29 | 'openstackdocstheme', 30 | ] 31 | 32 | # openstackdocstheme options 33 | openstackdocs_repo_name = 'openstack/automaton' 34 | openstackdocs_auto_name = False 35 | openstackdocs_bug_project = 'automaton' 36 | openstackdocs_bug_tag = '' 37 | 38 | # autodoc generation is a bit aggressive and a nuisance when doing heavy 39 | # text edit cycles. 40 | # execute "export SPHINX_DEBUG=1" in your terminal to disable 41 | 42 | # The suffix of source filenames. 43 | source_suffix = '.rst' 44 | 45 | # The master toctree document. 46 | master_doc = 'index' 47 | 48 | # General information about the project. 49 | project = 'automaton' 50 | copyright = '2013, OpenStack Foundation' 51 | 52 | # If true, '()' will be appended to :func: etc. cross-reference text. 53 | add_function_parentheses = True 54 | 55 | # If true, the current module name will be prepended to all description 56 | # unit titles (such as .. function::). 57 | add_module_names = True 58 | 59 | # The name of the Pygments (syntax highlighting) style to use. 60 | pygments_style = 'native' 61 | 62 | # -- Options for HTML output -------------------------------------------------- 63 | 64 | # The theme to use for HTML and HTML Help pages. Major themes that come with 65 | # Sphinx are currently 'default' and 'sphinxdoc'. 66 | # html_theme_path = ["."] 67 | # html_theme = '_theme' 68 | # html_static_path = ['static'] 69 | html_theme = 'openstackdocs' 70 | 71 | # Output file base name for HTML help builder. 72 | htmlhelp_basename = '%sdoc' % project 73 | 74 | # Grouping the document tree into LaTeX files. List of tuples 75 | # (source start file, target name, title, author, documentclass 76 | # [howto/manual]). 77 | latex_documents = [ 78 | ('index', 79 | '%s.tex' % project, 80 | '%s Documentation' % project, 81 | 'OpenStack Foundation', 'manual'), 82 | ] 83 | -------------------------------------------------------------------------------- /doc/source/contributor/index.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | .. include:: ../../../CONTRIBUTING.rst 6 | -------------------------------------------------------------------------------- /doc/source/index.rst: -------------------------------------------------------------------------------- 1 | ===================================== 2 | Welcome to automaton's documentation! 3 | ===================================== 4 | 5 | Friendly state machines for python. 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | 10 | user/index 11 | reference/index 12 | install/index 13 | contributor/index 14 | 15 | .. rubric:: Indices and tables 16 | 17 | * :ref:`genindex` 18 | * :ref:`modindex` 19 | * :ref:`search` 20 | 21 | -------------------------------------------------------------------------------- /doc/source/install/index.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | At the command line:: 6 | 7 | $ pip install automaton 8 | 9 | Or, if you have virtualenvwrapper installed:: 10 | 11 | $ mkvirtualenv automaton 12 | $ pip install automaton 13 | -------------------------------------------------------------------------------- /doc/source/reference/index.rst: -------------------------------------------------------------------------------- 1 | === 2 | API 3 | === 4 | 5 | -------- 6 | Machines 7 | -------- 8 | 9 | .. autoclass:: automaton.machines.State 10 | :members: 11 | 12 | .. autoclass:: automaton.machines.FiniteMachine 13 | :members: 14 | :special-members: __iter__, __contains__ 15 | 16 | .. autoclass:: automaton.machines.HierarchicalFiniteMachine 17 | :noindex: 18 | :members: 19 | 20 | ------- 21 | Runners 22 | ------- 23 | 24 | .. autoclass:: automaton.runners.Runner 25 | :members: 26 | 27 | .. autoclass:: automaton.runners.FiniteRunner 28 | :members: 29 | 30 | .. autoclass:: automaton.runners.HierarchicalRunner 31 | :members: 32 | 33 | ---------- 34 | Converters 35 | ---------- 36 | 37 | .. automodule:: automaton.converters.pydot 38 | :members: 39 | 40 | ---------- 41 | Exceptions 42 | ---------- 43 | 44 | .. automodule:: automaton.exceptions 45 | :members: 46 | 47 | Hierarchy 48 | --------- 49 | 50 | .. inheritance-diagram:: 51 | automaton.exceptions 52 | :parts: 1 53 | -------------------------------------------------------------------------------- /doc/source/user/examples.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Examples 3 | ======== 4 | 5 | ------------------------- 6 | Creating a simple machine 7 | ------------------------- 8 | 9 | .. testcode:: 10 | 11 | from automaton import machines 12 | m = machines.FiniteMachine() 13 | m.add_state('up') 14 | m.add_state('down') 15 | m.add_transition('down', 'up', 'jump') 16 | m.add_transition('up', 'down', 'fall') 17 | m.default_start_state = 'down' 18 | print(m.pformat()) 19 | 20 | **Expected output:** 21 | 22 | .. testoutput:: 23 | 24 | +---------+-------+------+----------+---------+ 25 | | Start | Event | End | On Enter | On Exit | 26 | +---------+-------+------+----------+---------+ 27 | | down[^] | jump | up | . | . | 28 | | up | fall | down | . | . | 29 | +---------+-------+------+----------+---------+ 30 | 31 | ------------------------------ 32 | Transitioning a simple machine 33 | ------------------------------ 34 | 35 | .. testcode:: 36 | 37 | m.initialize() 38 | m.process_event('jump') 39 | print(m.pformat()) 40 | print(m.current_state) 41 | print(m.terminated) 42 | m.process_event('fall') 43 | print(m.pformat()) 44 | print(m.current_state) 45 | print(m.terminated) 46 | 47 | **Expected output:** 48 | 49 | .. testoutput:: 50 | 51 | +---------+-------+------+----------+---------+ 52 | | Start | Event | End | On Enter | On Exit | 53 | +---------+-------+------+----------+---------+ 54 | | down[^] | jump | up | . | . | 55 | | @up | fall | down | . | . | 56 | +---------+-------+------+----------+---------+ 57 | up 58 | False 59 | +----------+-------+------+----------+---------+ 60 | | Start | Event | End | On Enter | On Exit | 61 | +----------+-------+------+----------+---------+ 62 | | @down[^] | jump | up | . | . | 63 | | up | fall | down | . | . | 64 | +----------+-------+------+----------+---------+ 65 | down 66 | False 67 | 68 | 69 | ------------------------------------- 70 | Running a complex dog-barking machine 71 | ------------------------------------- 72 | 73 | .. testcode:: 74 | 75 | from automaton import machines 76 | from automaton import runners 77 | 78 | 79 | # These reaction functions will get triggered when the registered state 80 | # and event occur, it is expected to provide a new event that reacts to the 81 | # new stable state (so that the state-machine can transition to a new 82 | # stable state, and repeat, until the machine ends up in a terminal 83 | # state, whereby it will stop...) 84 | 85 | def react_to_squirrel(old_state, new_state, event_that_triggered): 86 | return "gets petted" 87 | 88 | 89 | def react_to_wagging(old_state, new_state, event_that_triggered): 90 | return "gets petted" 91 | 92 | 93 | m = machines.FiniteMachine() 94 | 95 | m.add_state("sits") 96 | m.add_state("lies down", terminal=True) 97 | m.add_state("barks") 98 | m.add_state("wags tail") 99 | 100 | m.default_start_state = 'sits' 101 | 102 | m.add_transition("sits", "barks", "squirrel!") 103 | m.add_transition("barks", "wags tail", "gets petted") 104 | m.add_transition("wags tail", "lies down", "gets petted") 105 | 106 | m.add_reaction("barks", "squirrel!", react_to_squirrel) 107 | m.add_reaction('wags tail', "gets petted", react_to_wagging) 108 | 109 | print(m.pformat()) 110 | r = runners.FiniteRunner(m) 111 | for (old_state, new_state) in r.run_iter("squirrel!"): 112 | print("Leaving '%s'" % old_state) 113 | print("Entered '%s'" % new_state) 114 | 115 | **Expected output:** 116 | 117 | .. testoutput:: 118 | 119 | +--------------+-------------+-----------+----------+---------+ 120 | | Start | Event | End | On Enter | On Exit | 121 | +--------------+-------------+-----------+----------+---------+ 122 | | barks | gets petted | wags tail | . | . | 123 | | lies down[$] | . | . | . | . | 124 | | sits[^] | squirrel! | barks | . | . | 125 | | wags tail | gets petted | lies down | . | . | 126 | +--------------+-------------+-----------+----------+---------+ 127 | Leaving 'sits' 128 | Entered 'barks' 129 | Leaving 'barks' 130 | Entered 'wags tail' 131 | Leaving 'wags tail' 132 | Entered 'lies down' 133 | 134 | 135 | ------------------------------------ 136 | Creating a complex CD-player machine 137 | ------------------------------------ 138 | 139 | .. testcode:: 140 | 141 | from automaton import machines 142 | 143 | 144 | def print_on_enter(new_state, triggered_event): 145 | print("Entered '%s' due to '%s'" % (new_state, triggered_event)) 146 | 147 | 148 | def print_on_exit(old_state, triggered_event): 149 | print("Exiting '%s' due to '%s'" % (old_state, triggered_event)) 150 | 151 | 152 | m = machines.FiniteMachine() 153 | 154 | m.add_state('stopped', on_enter=print_on_enter, on_exit=print_on_exit) 155 | m.add_state('opened', on_enter=print_on_enter, on_exit=print_on_exit) 156 | m.add_state('closed', on_enter=print_on_enter, on_exit=print_on_exit) 157 | m.add_state('playing', on_enter=print_on_enter, on_exit=print_on_exit) 158 | m.add_state('paused', on_enter=print_on_enter, on_exit=print_on_exit) 159 | 160 | m.add_transition('stopped', 'playing', 'play') 161 | m.add_transition('stopped', 'opened', 'open_close') 162 | m.add_transition('stopped', 'stopped', 'stop') 163 | 164 | m.add_transition('opened', 'closed', 'open_close') 165 | 166 | m.add_transition('closed', 'opened', 'open_close') 167 | m.add_transition('closed', 'stopped', 'cd_detected') 168 | 169 | m.add_transition('playing', 'stopped', 'stop') 170 | m.add_transition('playing', 'paused', 'pause') 171 | m.add_transition('playing', 'opened', 'open_close') 172 | 173 | m.add_transition('paused', 'playing', 'play') 174 | m.add_transition('paused', 'stopped', 'stop') 175 | m.add_transition('paused', 'opened', 'open_close') 176 | 177 | m.default_start_state = 'closed' 178 | 179 | m.initialize() 180 | print(m.pformat()) 181 | 182 | for event in ['cd_detected', 'play', 'pause', 'play', 'stop', 183 | 'open_close', 'open_close']: 184 | m.process_event(event) 185 | print(m.pformat()) 186 | print("=============") 187 | print("Current state => %s" % m.current_state) 188 | print("=============") 189 | 190 | 191 | 192 | **Expected output:** 193 | 194 | .. testoutput:: 195 | 196 | +------------+-------------+---------+----------------+---------------+ 197 | | Start | Event | End | On Enter | On Exit | 198 | +------------+-------------+---------+----------------+---------------+ 199 | | @closed[^] | cd_detected | stopped | print_on_enter | print_on_exit | 200 | | @closed[^] | open_close | opened | print_on_enter | print_on_exit | 201 | | opened | open_close | closed | print_on_enter | print_on_exit | 202 | | paused | open_close | opened | print_on_enter | print_on_exit | 203 | | paused | play | playing | print_on_enter | print_on_exit | 204 | | paused | stop | stopped | print_on_enter | print_on_exit | 205 | | playing | open_close | opened | print_on_enter | print_on_exit | 206 | | playing | pause | paused | print_on_enter | print_on_exit | 207 | | playing | stop | stopped | print_on_enter | print_on_exit | 208 | | stopped | open_close | opened | print_on_enter | print_on_exit | 209 | | stopped | play | playing | print_on_enter | print_on_exit | 210 | | stopped | stop | stopped | print_on_enter | print_on_exit | 211 | +------------+-------------+---------+----------------+---------------+ 212 | Exiting 'closed' due to 'cd_detected' 213 | Entered 'stopped' due to 'cd_detected' 214 | +-----------+-------------+---------+----------------+---------------+ 215 | | Start | Event | End | On Enter | On Exit | 216 | +-----------+-------------+---------+----------------+---------------+ 217 | | closed[^] | cd_detected | stopped | print_on_enter | print_on_exit | 218 | | closed[^] | open_close | opened | print_on_enter | print_on_exit | 219 | | opened | open_close | closed | print_on_enter | print_on_exit | 220 | | paused | open_close | opened | print_on_enter | print_on_exit | 221 | | paused | play | playing | print_on_enter | print_on_exit | 222 | | paused | stop | stopped | print_on_enter | print_on_exit | 223 | | playing | open_close | opened | print_on_enter | print_on_exit | 224 | | playing | pause | paused | print_on_enter | print_on_exit | 225 | | playing | stop | stopped | print_on_enter | print_on_exit | 226 | | @stopped | open_close | opened | print_on_enter | print_on_exit | 227 | | @stopped | play | playing | print_on_enter | print_on_exit | 228 | | @stopped | stop | stopped | print_on_enter | print_on_exit | 229 | +-----------+-------------+---------+----------------+---------------+ 230 | ============= 231 | Current state => stopped 232 | ============= 233 | Exiting 'stopped' due to 'play' 234 | Entered 'playing' due to 'play' 235 | +-----------+-------------+---------+----------------+---------------+ 236 | | Start | Event | End | On Enter | On Exit | 237 | +-----------+-------------+---------+----------------+---------------+ 238 | | closed[^] | cd_detected | stopped | print_on_enter | print_on_exit | 239 | | closed[^] | open_close | opened | print_on_enter | print_on_exit | 240 | | opened | open_close | closed | print_on_enter | print_on_exit | 241 | | paused | open_close | opened | print_on_enter | print_on_exit | 242 | | paused | play | playing | print_on_enter | print_on_exit | 243 | | paused | stop | stopped | print_on_enter | print_on_exit | 244 | | @playing | open_close | opened | print_on_enter | print_on_exit | 245 | | @playing | pause | paused | print_on_enter | print_on_exit | 246 | | @playing | stop | stopped | print_on_enter | print_on_exit | 247 | | stopped | open_close | opened | print_on_enter | print_on_exit | 248 | | stopped | play | playing | print_on_enter | print_on_exit | 249 | | stopped | stop | stopped | print_on_enter | print_on_exit | 250 | +-----------+-------------+---------+----------------+---------------+ 251 | ============= 252 | Current state => playing 253 | ============= 254 | Exiting 'playing' due to 'pause' 255 | Entered 'paused' due to 'pause' 256 | +-----------+-------------+---------+----------------+---------------+ 257 | | Start | Event | End | On Enter | On Exit | 258 | +-----------+-------------+---------+----------------+---------------+ 259 | | closed[^] | cd_detected | stopped | print_on_enter | print_on_exit | 260 | | closed[^] | open_close | opened | print_on_enter | print_on_exit | 261 | | opened | open_close | closed | print_on_enter | print_on_exit | 262 | | @paused | open_close | opened | print_on_enter | print_on_exit | 263 | | @paused | play | playing | print_on_enter | print_on_exit | 264 | | @paused | stop | stopped | print_on_enter | print_on_exit | 265 | | playing | open_close | opened | print_on_enter | print_on_exit | 266 | | playing | pause | paused | print_on_enter | print_on_exit | 267 | | playing | stop | stopped | print_on_enter | print_on_exit | 268 | | stopped | open_close | opened | print_on_enter | print_on_exit | 269 | | stopped | play | playing | print_on_enter | print_on_exit | 270 | | stopped | stop | stopped | print_on_enter | print_on_exit | 271 | +-----------+-------------+---------+----------------+---------------+ 272 | ============= 273 | Current state => paused 274 | ============= 275 | Exiting 'paused' due to 'play' 276 | Entered 'playing' due to 'play' 277 | +-----------+-------------+---------+----------------+---------------+ 278 | | Start | Event | End | On Enter | On Exit | 279 | +-----------+-------------+---------+----------------+---------------+ 280 | | closed[^] | cd_detected | stopped | print_on_enter | print_on_exit | 281 | | closed[^] | open_close | opened | print_on_enter | print_on_exit | 282 | | opened | open_close | closed | print_on_enter | print_on_exit | 283 | | paused | open_close | opened | print_on_enter | print_on_exit | 284 | | paused | play | playing | print_on_enter | print_on_exit | 285 | | paused | stop | stopped | print_on_enter | print_on_exit | 286 | | @playing | open_close | opened | print_on_enter | print_on_exit | 287 | | @playing | pause | paused | print_on_enter | print_on_exit | 288 | | @playing | stop | stopped | print_on_enter | print_on_exit | 289 | | stopped | open_close | opened | print_on_enter | print_on_exit | 290 | | stopped | play | playing | print_on_enter | print_on_exit | 291 | | stopped | stop | stopped | print_on_enter | print_on_exit | 292 | +-----------+-------------+---------+----------------+---------------+ 293 | ============= 294 | Current state => playing 295 | ============= 296 | Exiting 'playing' due to 'stop' 297 | Entered 'stopped' due to 'stop' 298 | +-----------+-------------+---------+----------------+---------------+ 299 | | Start | Event | End | On Enter | On Exit | 300 | +-----------+-------------+---------+----------------+---------------+ 301 | | closed[^] | cd_detected | stopped | print_on_enter | print_on_exit | 302 | | closed[^] | open_close | opened | print_on_enter | print_on_exit | 303 | | opened | open_close | closed | print_on_enter | print_on_exit | 304 | | paused | open_close | opened | print_on_enter | print_on_exit | 305 | | paused | play | playing | print_on_enter | print_on_exit | 306 | | paused | stop | stopped | print_on_enter | print_on_exit | 307 | | playing | open_close | opened | print_on_enter | print_on_exit | 308 | | playing | pause | paused | print_on_enter | print_on_exit | 309 | | playing | stop | stopped | print_on_enter | print_on_exit | 310 | | @stopped | open_close | opened | print_on_enter | print_on_exit | 311 | | @stopped | play | playing | print_on_enter | print_on_exit | 312 | | @stopped | stop | stopped | print_on_enter | print_on_exit | 313 | +-----------+-------------+---------+----------------+---------------+ 314 | ============= 315 | Current state => stopped 316 | ============= 317 | Exiting 'stopped' due to 'open_close' 318 | Entered 'opened' due to 'open_close' 319 | +-----------+-------------+---------+----------------+---------------+ 320 | | Start | Event | End | On Enter | On Exit | 321 | +-----------+-------------+---------+----------------+---------------+ 322 | | closed[^] | cd_detected | stopped | print_on_enter | print_on_exit | 323 | | closed[^] | open_close | opened | print_on_enter | print_on_exit | 324 | | @opened | open_close | closed | print_on_enter | print_on_exit | 325 | | paused | open_close | opened | print_on_enter | print_on_exit | 326 | | paused | play | playing | print_on_enter | print_on_exit | 327 | | paused | stop | stopped | print_on_enter | print_on_exit | 328 | | playing | open_close | opened | print_on_enter | print_on_exit | 329 | | playing | pause | paused | print_on_enter | print_on_exit | 330 | | playing | stop | stopped | print_on_enter | print_on_exit | 331 | | stopped | open_close | opened | print_on_enter | print_on_exit | 332 | | stopped | play | playing | print_on_enter | print_on_exit | 333 | | stopped | stop | stopped | print_on_enter | print_on_exit | 334 | +-----------+-------------+---------+----------------+---------------+ 335 | ============= 336 | Current state => opened 337 | ============= 338 | Exiting 'opened' due to 'open_close' 339 | Entered 'closed' due to 'open_close' 340 | +------------+-------------+---------+----------------+---------------+ 341 | | Start | Event | End | On Enter | On Exit | 342 | +------------+-------------+---------+----------------+---------------+ 343 | | @closed[^] | cd_detected | stopped | print_on_enter | print_on_exit | 344 | | @closed[^] | open_close | opened | print_on_enter | print_on_exit | 345 | | opened | open_close | closed | print_on_enter | print_on_exit | 346 | | paused | open_close | opened | print_on_enter | print_on_exit | 347 | | paused | play | playing | print_on_enter | print_on_exit | 348 | | paused | stop | stopped | print_on_enter | print_on_exit | 349 | | playing | open_close | opened | print_on_enter | print_on_exit | 350 | | playing | pause | paused | print_on_enter | print_on_exit | 351 | | playing | stop | stopped | print_on_enter | print_on_exit | 352 | | stopped | open_close | opened | print_on_enter | print_on_exit | 353 | | stopped | play | playing | print_on_enter | print_on_exit | 354 | | stopped | stop | stopped | print_on_enter | print_on_exit | 355 | +------------+-------------+---------+----------------+---------------+ 356 | ============= 357 | Current state => closed 358 | ============= 359 | 360 | ---------------------------------------------------------- 361 | Creating a complex CD-player machine (using a state-space) 362 | ---------------------------------------------------------- 363 | 364 | This example is equivalent to the prior one but creates a machine in 365 | a more declarative manner. Instead of calling ``add_state`` 366 | and ``add_transition`` a explicit and declarative format can be used. For 367 | example to create the same machine: 368 | 369 | .. testcode:: 370 | 371 | from automaton import machines 372 | 373 | 374 | def print_on_enter(new_state, triggered_event): 375 | print("Entered '%s' due to '%s'" % (new_state, triggered_event)) 376 | 377 | 378 | def print_on_exit(old_state, triggered_event): 379 | print("Exiting '%s' due to '%s'" % (old_state, triggered_event)) 380 | 381 | # This will contain all the states and transitions that our machine will 382 | # allow, the format is relatively simple and designed to be easy to use. 383 | state_space = [ 384 | { 385 | 'name': 'stopped', 386 | 'next_states': { 387 | # On event 'play' transition to the 'playing' state. 388 | 'play': 'playing', 389 | 'open_close': 'opened', 390 | 'stop': 'stopped', 391 | }, 392 | 'on_enter': print_on_enter, 393 | 'on_exit': print_on_exit, 394 | }, 395 | { 396 | 'name': 'opened', 397 | 'next_states': { 398 | 'open_close': 'closed', 399 | }, 400 | 'on_enter': print_on_enter, 401 | 'on_exit': print_on_exit, 402 | }, 403 | { 404 | 'name': 'closed', 405 | 'next_states': { 406 | 'open_close': 'opened', 407 | 'cd_detected': 'stopped', 408 | }, 409 | 'on_enter': print_on_enter, 410 | 'on_exit': print_on_exit, 411 | }, 412 | { 413 | 'name': 'playing', 414 | 'next_states': { 415 | 'stop': 'stopped', 416 | 'pause': 'paused', 417 | 'open_close': 'opened', 418 | }, 419 | 'on_enter': print_on_enter, 420 | 'on_exit': print_on_exit, 421 | }, 422 | { 423 | 'name': 'paused', 424 | 'next_states': { 425 | 'play': 'playing', 426 | 'stop': 'stopped', 427 | 'open_close': 'opened', 428 | }, 429 | 'on_enter': print_on_enter, 430 | 'on_exit': print_on_exit, 431 | }, 432 | ] 433 | 434 | m = machines.FiniteMachine.build(state_space) 435 | m.default_start_state = 'closed' 436 | print(m.pformat()) 437 | 438 | **Expected output:** 439 | 440 | .. testoutput:: 441 | 442 | +-----------+-------------+---------+----------------+---------------+ 443 | | Start | Event | End | On Enter | On Exit | 444 | +-----------+-------------+---------+----------------+---------------+ 445 | | closed[^] | cd_detected | stopped | print_on_enter | print_on_exit | 446 | | closed[^] | open_close | opened | print_on_enter | print_on_exit | 447 | | opened | open_close | closed | print_on_enter | print_on_exit | 448 | | paused | open_close | opened | print_on_enter | print_on_exit | 449 | | paused | play | playing | print_on_enter | print_on_exit | 450 | | paused | stop | stopped | print_on_enter | print_on_exit | 451 | | playing | open_close | opened | print_on_enter | print_on_exit | 452 | | playing | pause | paused | print_on_enter | print_on_exit | 453 | | playing | stop | stopped | print_on_enter | print_on_exit | 454 | | stopped | open_close | opened | print_on_enter | print_on_exit | 455 | | stopped | play | playing | print_on_enter | print_on_exit | 456 | | stopped | stop | stopped | print_on_enter | print_on_exit | 457 | +-----------+-------------+---------+----------------+---------------+ 458 | 459 | .. note:: 460 | 461 | As can be seen the two tables from this example and the prior one are 462 | exactly the same. 463 | -------------------------------------------------------------------------------- /doc/source/user/features.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Features 3 | ======== 4 | 5 | Machines 6 | -------- 7 | 8 | * A :py:class:`.automaton.machines.FiniteMachine` state machine. 9 | * A :py:class:`.automaton.machines.HierarchicalFiniteMachine` hierarchical 10 | state machine. 11 | 12 | Runners 13 | ------- 14 | 15 | * A :py:class:`.automaton.runners.FiniteRunner` state machine runner. 16 | * A :py:class:`.automaton.runners.HierarchicalRunner` hierarchical state 17 | machine runner. 18 | -------------------------------------------------------------------------------- /doc/source/user/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../../ChangeLog 2 | 3 | -------------------------------------------------------------------------------- /doc/source/user/index.rst: -------------------------------------------------------------------------------- 1 | ==================== 2 | automaton User Guide 3 | ==================== 4 | 5 | .. toctree:: 6 | :maxdepth: 2 7 | 8 | features 9 | examples 10 | history 11 | -------------------------------------------------------------------------------- /releasenotes/notes/.placeholder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openstack/automaton/d85ecfadf9236ee6eff3b0bbde104a7f519ea463/releasenotes/notes/.placeholder -------------------------------------------------------------------------------- /releasenotes/notes/drop-python-2-7-73d3113c69d724d6.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | upgrade: 3 | - | 4 | Python 2.7 support has been dropped. The minimum version of Python now 5 | supported by automaton is Python 3.6. 6 | -------------------------------------------------------------------------------- /releasenotes/source/2023.1.rst: -------------------------------------------------------------------------------- 1 | =========================== 2 | 2023.1 Series Release Notes 3 | =========================== 4 | 5 | .. release-notes:: 6 | :branch: unmaintained/2023.1 7 | -------------------------------------------------------------------------------- /releasenotes/source/2023.2.rst: -------------------------------------------------------------------------------- 1 | =========================== 2 | 2023.2 Series Release Notes 3 | =========================== 4 | 5 | .. release-notes:: 6 | :branch: stable/2023.2 7 | -------------------------------------------------------------------------------- /releasenotes/source/_static/.placeholder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openstack/automaton/d85ecfadf9236ee6eff3b0bbde104a7f519ea463/releasenotes/source/_static/.placeholder -------------------------------------------------------------------------------- /releasenotes/source/_templates/.placeholder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openstack/automaton/d85ecfadf9236ee6eff3b0bbde104a7f519ea463/releasenotes/source/_templates/.placeholder -------------------------------------------------------------------------------- /releasenotes/source/conf.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 10 | # implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | # automaton Release Notes documentation build configuration file, created by 15 | # sphinx-quickstart on Tue Nov 3 17:40:50 2015. 16 | # 17 | # This file is execfile()d with the current directory set to its 18 | # containing dir. 19 | # 20 | # Note that not all possible configuration values are present in this 21 | # autogenerated file. 22 | # 23 | # All configuration values have a default; values that are commented out 24 | # serve to show the default. 25 | 26 | # If extensions (or modules to document with autodoc) are in another directory, 27 | # add these directories to sys.path here. If the directory is relative to the 28 | # documentation root, use os.path.abspath to make it absolute, like shown here. 29 | # sys.path.insert(0, os.path.abspath('.')) 30 | 31 | # -- General configuration ------------------------------------------------ 32 | 33 | # If your documentation needs a minimal Sphinx version, state it here. 34 | # needs_sphinx = '1.0' 35 | 36 | # Add any Sphinx extension module names here, as strings. They can be 37 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 38 | # ones. 39 | extensions = [ 40 | 'openstackdocstheme', 41 | 'reno.sphinxext', 42 | ] 43 | 44 | # Add any paths that contain templates here, relative to this directory. 45 | templates_path = ['_templates'] 46 | 47 | # The suffix of source filenames. 48 | source_suffix = '.rst' 49 | 50 | # The encoding of source files. 51 | # source_encoding = 'utf-8-sig' 52 | 53 | # The master toctree document. 54 | master_doc = 'index' 55 | 56 | # General information about the project. 57 | project = 'automaton Release Notes' 58 | copyright = '2016, automaton Developers' 59 | 60 | 61 | # Release notes do not need a version number in the title, they 62 | # cover multiple releases. 63 | release = '' 64 | # The short X.Y version. 65 | version = '' 66 | 67 | # The language for content autogenerated by Sphinx. Refer to documentation 68 | # for a list of supported languages. 69 | # language = None 70 | 71 | # There are two options for replacing |today|: either, you set today to some 72 | # non-false value, then it is used: 73 | # today = '' 74 | # Else, today_fmt is used as the format for a strftime call. 75 | # today_fmt = '%B %d, %Y' 76 | 77 | # List of patterns, relative to source directory, that match files and 78 | # directories to ignore when looking for source files. 79 | exclude_patterns = [] 80 | 81 | # The reST default role (used for this markup: `text`) to use for all 82 | # documents. 83 | # default_role = None 84 | 85 | # If true, '()' will be appended to :func: etc. cross-reference text. 86 | # add_function_parentheses = True 87 | 88 | # If true, the current module name will be prepended to all description 89 | # unit titles (such as .. function::). 90 | # add_module_names = True 91 | 92 | # If true, sectionauthor and moduleauthor directives will be shown in the 93 | # output. They are ignored by default. 94 | # show_authors = False 95 | 96 | # The name of the Pygments (syntax highlighting) style to use. 97 | pygments_style = 'native' 98 | 99 | # A list of ignored prefixes for module index sorting. 100 | # modindex_common_prefix = [] 101 | 102 | # If true, keep warnings as "system message" paragraphs in the built documents. 103 | # keep_warnings = False 104 | 105 | 106 | # -- Options for HTML output ---------------------------------------------- 107 | 108 | # The theme to use for HTML and HTML Help pages. See the documentation for 109 | # a list of builtin themes. 110 | html_theme = 'openstackdocs' 111 | 112 | # Theme options are theme-specific and customize the look and feel of a theme 113 | # further. For a list of options available for each theme, see the 114 | # documentation. 115 | # html_theme_options = {} 116 | 117 | # Add any paths that contain custom themes here, relative to this directory. 118 | # html_theme_path = [] 119 | 120 | # The name for this set of Sphinx documents. If None, it defaults to 121 | # " v documentation". 122 | # html_title = None 123 | 124 | # A shorter title for the navigation bar. Default is the same as html_title. 125 | # html_short_title = None 126 | 127 | # The name of an image file (relative to this directory) to place at the top 128 | # of the sidebar. 129 | # html_logo = None 130 | 131 | # The name of an image file (within the static path) to use as favicon of the 132 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 133 | # pixels large. 134 | # html_favicon = None 135 | 136 | # Add any paths that contain custom static files (such as style sheets) here, 137 | # relative to this directory. They are copied after the builtin static files, 138 | # so a file named "default.css" will overwrite the builtin "default.css". 139 | html_static_path = ['_static'] 140 | 141 | # Add any extra paths that contain custom files (such as robots.txt or 142 | # .htaccess) here, relative to this directory. These files are copied 143 | # directly to the root of the documentation. 144 | # html_extra_path = [] 145 | 146 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 147 | # using the given strftime format. 148 | # html_last_updated_fmt = '%b %d, %Y' 149 | 150 | # If true, SmartyPants will be used to convert quotes and dashes to 151 | # typographically correct entities. 152 | # html_use_smartypants = True 153 | 154 | # Custom sidebar templates, maps document names to template names. 155 | # html_sidebars = {} 156 | 157 | # Additional templates that should be rendered to pages, maps page names to 158 | # template names. 159 | # html_additional_pages = {} 160 | 161 | # If false, no module index is generated. 162 | # html_domain_indices = True 163 | 164 | # If false, no index is generated. 165 | # html_use_index = True 166 | 167 | # If true, the index is split into individual pages for each letter. 168 | # html_split_index = False 169 | 170 | # If true, links to the reST sources are added to the pages. 171 | # html_show_sourcelink = True 172 | 173 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 174 | # html_show_sphinx = True 175 | 176 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 177 | # html_show_copyright = True 178 | 179 | # If true, an OpenSearch description file will be output, and all pages will 180 | # contain a tag referring to it. The value of this option must be the 181 | # base URL from which the finished HTML is served. 182 | # html_use_opensearch = '' 183 | 184 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 185 | # html_file_suffix = None 186 | 187 | # Output file base name for HTML help builder. 188 | htmlhelp_basename = 'automatonReleaseNotesdoc' 189 | 190 | 191 | # -- Options for LaTeX output --------------------------------------------- 192 | 193 | latex_elements = { 194 | # The paper size ('letterpaper' or 'a4paper'). 195 | # 'papersize': 'letterpaper', 196 | 197 | # The font size ('10pt', '11pt' or '12pt'). 198 | # 'pointsize': '10pt', 199 | 200 | # Additional stuff for the LaTeX preamble. 201 | # 'preamble': '', 202 | } 203 | 204 | # Grouping the document tree into LaTeX files. List of tuples 205 | # (source start file, target name, title, 206 | # author, documentclass [howto, manual, or own class]). 207 | latex_documents = [ 208 | ('index', 'automatonReleaseNotes.tex', 209 | 'automaton Release Notes Documentation', 210 | 'automaton Developers', 'manual'), 211 | ] 212 | 213 | # The name of an image file (relative to this directory) to place at the top of 214 | # the title page. 215 | # latex_logo = None 216 | 217 | # For "manual" documents, if this is true, then toplevel headings are parts, 218 | # not chapters. 219 | # latex_use_parts = False 220 | 221 | # If true, show page references after internal links. 222 | # latex_show_pagerefs = False 223 | 224 | # If true, show URL addresses after external links. 225 | # latex_show_urls = False 226 | 227 | # Documents to append as an appendix to all manuals. 228 | # latex_appendices = [] 229 | 230 | # If false, no module index is generated. 231 | # latex_domain_indices = True 232 | 233 | 234 | # -- Options for manual page output --------------------------------------- 235 | 236 | # One entry per manual page. List of tuples 237 | # (source start file, name, description, authors, manual section). 238 | man_pages = [ 239 | ('index', 'automatonreleasenotes', 240 | 'automaton Release Notes Documentation', 241 | ['automaton Developers'], 1) 242 | ] 243 | 244 | # If true, show URL addresses after external links. 245 | # man_show_urls = False 246 | 247 | 248 | # -- Options for Texinfo output ------------------------------------------- 249 | 250 | # Grouping the document tree into Texinfo files. List of tuples 251 | # (source start file, target name, title, author, 252 | # dir menu entry, description, category) 253 | texinfo_documents = [ 254 | ('index', 'automatonReleaseNotes', 255 | 'automaton Release Notes Documentation', 256 | 'automaton Developers', 'automatonReleaseNotes', 257 | 'An OpenStack library for parsing configuration options from the command' 258 | ' line and configuration files.', 259 | 'Miscellaneous'), 260 | ] 261 | 262 | # Documents to append as an appendix to all manuals. 263 | # texinfo_appendices = [] 264 | 265 | # If false, no module index is generated. 266 | # texinfo_domain_indices = True 267 | 268 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 269 | # texinfo_show_urls = 'footnote' 270 | 271 | # If true, do not generate a @detailmenu in the "Top" node's menu. 272 | # texinfo_no_detailmenu = False 273 | 274 | # -- Options for Internationalization output ------------------------------ 275 | locale_dirs = ['locale/'] 276 | 277 | # openstackdocstheme options 278 | openstackdocs_repo_name = 'openstack/automaton' 279 | openstackdocs_auto_name = False 280 | openstackdocs_bug_project = 'automaton' 281 | openstackdocs_bug_tag = '' 282 | -------------------------------------------------------------------------------- /releasenotes/source/index.rst: -------------------------------------------------------------------------------- 1 | =========================== 2 | automaton Release Notes 3 | =========================== 4 | 5 | .. toctree:: 6 | :maxdepth: 1 7 | 8 | unreleased 9 | 2023.2 10 | 2023.1 11 | zed 12 | yoga 13 | xena 14 | wallaby 15 | victoria 16 | ussuri 17 | train 18 | stein 19 | rocky 20 | queens 21 | pike 22 | ocata 23 | -------------------------------------------------------------------------------- /releasenotes/source/ocata.rst: -------------------------------------------------------------------------------- 1 | =================================== 2 | Ocata Series Release Notes 3 | =================================== 4 | 5 | .. release-notes:: 6 | :branch: origin/stable/ocata 7 | -------------------------------------------------------------------------------- /releasenotes/source/pike.rst: -------------------------------------------------------------------------------- 1 | =================================== 2 | Pike Series Release Notes 3 | =================================== 4 | 5 | .. release-notes:: 6 | :branch: stable/pike 7 | -------------------------------------------------------------------------------- /releasenotes/source/queens.rst: -------------------------------------------------------------------------------- 1 | =================================== 2 | Queens Series Release Notes 3 | =================================== 4 | 5 | .. release-notes:: 6 | :branch: stable/queens 7 | -------------------------------------------------------------------------------- /releasenotes/source/rocky.rst: -------------------------------------------------------------------------------- 1 | =================================== 2 | Rocky Series Release Notes 3 | =================================== 4 | 5 | .. release-notes:: 6 | :branch: stable/rocky 7 | -------------------------------------------------------------------------------- /releasenotes/source/stein.rst: -------------------------------------------------------------------------------- 1 | =================================== 2 | Stein Series Release Notes 3 | =================================== 4 | 5 | .. release-notes:: 6 | :branch: stable/stein 7 | -------------------------------------------------------------------------------- /releasenotes/source/train.rst: -------------------------------------------------------------------------------- 1 | ========================== 2 | Train Series Release Notes 3 | ========================== 4 | 5 | .. release-notes:: 6 | :branch: stable/train 7 | -------------------------------------------------------------------------------- /releasenotes/source/unreleased.rst: -------------------------------------------------------------------------------- 1 | ========================== 2 | Unreleased Release Notes 3 | ========================== 4 | 5 | .. release-notes:: 6 | -------------------------------------------------------------------------------- /releasenotes/source/ussuri.rst: -------------------------------------------------------------------------------- 1 | =========================== 2 | Ussuri Series Release Notes 3 | =========================== 4 | 5 | .. release-notes:: 6 | :branch: stable/ussuri 7 | -------------------------------------------------------------------------------- /releasenotes/source/victoria.rst: -------------------------------------------------------------------------------- 1 | ============================= 2 | Victoria Series Release Notes 3 | ============================= 4 | 5 | .. release-notes:: 6 | :branch: unmaintained/victoria 7 | -------------------------------------------------------------------------------- /releasenotes/source/wallaby.rst: -------------------------------------------------------------------------------- 1 | ============================ 2 | Wallaby Series Release Notes 3 | ============================ 4 | 5 | .. release-notes:: 6 | :branch: unmaintained/wallaby 7 | -------------------------------------------------------------------------------- /releasenotes/source/xena.rst: -------------------------------------------------------------------------------- 1 | ========================= 2 | Xena Series Release Notes 3 | ========================= 4 | 5 | .. release-notes:: 6 | :branch: unmaintained/xena 7 | -------------------------------------------------------------------------------- /releasenotes/source/yoga.rst: -------------------------------------------------------------------------------- 1 | ========================= 2 | Yoga Series Release Notes 3 | ========================= 4 | 5 | .. release-notes:: 6 | :branch: unmaintained/yoga 7 | -------------------------------------------------------------------------------- /releasenotes/source/zed.rst: -------------------------------------------------------------------------------- 1 | ======================== 2 | Zed Series Release Notes 3 | ======================== 4 | 5 | .. release-notes:: 6 | :branch: unmaintained/zed 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # See: https://bugs.launchpad.net/pbr/+bug/1384919 for why this is here... 2 | pbr>=2.0.0 # Apache-2.0 3 | 4 | # For pretty formatting machines/state tables... 5 | PrettyTable>=0.7.2 # BSD 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = automaton 3 | summary = Friendly state machines for python. 4 | author = OpenStack 5 | author_email = openstack-discuss@lists.openstack.org 6 | home_page = https://docs.openstack.org/automaton/latest/ 7 | description_file = 8 | README.rst 9 | python_requires = >=3.8 10 | classifier = 11 | Intended Audience :: Developers 12 | License :: OSI Approved :: Apache Software License 13 | Operating System :: POSIX 14 | Programming Language :: Python :: 3 15 | Programming Language :: Python :: 3.8 16 | Programming Language :: Python :: 3.9 17 | Programming Language :: Python :: 3.10 18 | Programming Language :: Python :: 3.11 19 | Programming Language :: Python :: 3.12 20 | Programming Language :: Python :: 3 :: Only 21 | Programming Language :: Python :: Implementation :: CPython 22 | Topic :: Software Development :: Libraries 23 | 24 | [files] 25 | packages = 26 | automaton 27 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Hewlett-Packard Development Company, L.P. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 12 | # implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | import setuptools 17 | 18 | setuptools.setup( 19 | setup_requires=['pbr>=2.0.0'], 20 | pbr=True) 21 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | coverage>=4.0 # Apache-2.0 2 | oslotest>=3.2.0 # Apache-2.0 3 | stestr>=2.0.0 # Apache-2.0 4 | testtools>=2.2.0 # MIT 5 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 3.1.1 3 | envlist = py3,docs,pep8 4 | ignore_basepython_conflict = true 5 | 6 | [testenv] 7 | basepython = python3 8 | setenv = 9 | OS_STDOUT_CAPTURE=1 10 | OS_STDERR_CAPTURE=1 11 | OS_TEST_TIMEOUT=60 12 | PYTHONDONTWRITEBYTECODE=1 13 | deps = 14 | -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} 15 | -r{toxinidir}/test-requirements.txt 16 | -r{toxinidir}/requirements.txt 17 | commands = stestr run --slowest {posargs} 18 | 19 | [testenv:pep8] 20 | skip_install = true 21 | deps = 22 | pre-commit>=2.6.0 # MIT 23 | commands = pre-commit run -a 24 | 25 | [testenv:venv] 26 | commands = {posargs} 27 | 28 | [testenv:cover] 29 | setenv = 30 | {[testenv]setenv} 31 | PYTHON=coverage run --source automaton --parallel-mode 32 | commands = 33 | coverage erase 34 | stestr run {posargs} 35 | coverage combine 36 | coverage html -d cover 37 | coverage xml -o cover/coverage.xml 38 | coverage report 39 | 40 | [testenv:docs] 41 | deps = 42 | -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} 43 | -r{toxinidir}/doc/requirements.txt 44 | commands = 45 | doc8 --ignore-path "doc/source/history.rst" doc/source 46 | sphinx-build -a -E -W -b html doc/source doc/build/html 47 | 48 | [testenv:releasenotes] 49 | deps = {[testenv:docs]deps} 50 | commands = 51 | sphinx-build -a -E -W -b html releasenotes/source releasenotes/build/html 52 | 53 | [testenv:bindep] 54 | # Do not install any requirements. We want this to be fast and work even if 55 | # system dependencies are missing, since it's used to tell you what system 56 | # dependencies are missing! This also means that bindep must be installed 57 | # separately, outside of the requirements files, and develop mode disabled 58 | # explicitly to avoid unnecessarily installing the checked-out repo too (this 59 | # further relies on "tox.skipsdist = True" above). 60 | deps = bindep 61 | commands = bindep test 62 | usedevelop = False 63 | 64 | [flake8] 65 | show-source = True 66 | exclude=.venv,.git,.tox,dist,doc,*lib/python*,*egg,build 67 | --------------------------------------------------------------------------------