├── .gitignore ├── .pylintrc ├── .travis.yml ├── LICENSE ├── README.rst ├── conftest.py ├── docs ├── Makefile ├── _static │ └── img │ │ ├── complex_hsm.png │ │ └── oven_hsm.png ├── conf.py ├── examples.rst ├── index.rst ├── installing.rst ├── make.bat └── pysm_module.rst ├── examples ├── complex_hsm.py ├── oven.py ├── rpn_calculator.py └── simple_on_off.py ├── pysm ├── __init__.py ├── pysm.py └── version.py ├── setup.cfg ├── setup.py └── test ├── test_complex_hsm.py ├── test_oven.py ├── test_pysm.py ├── test_rpn.py ├── test_simple_on_off.py └── test_string_parsing.py /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | docs/_build/ 3 | *.pyc 4 | *.pyo 5 | .cache 6 | /dist/ 7 | /*.egg-info 8 | .eggs 9 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | 3 | disable=E1608,W1627,E1601,E1603,E1602,E1605,E1604,E1607,E1606,W1621,W1620,W1623,W1622,W1625,W1624,W1609,W1608,W1607,W1606,W1605,W1604,W1603,W1602,W1601,W1639,W1640,I0021,W1638,I0020,W1618,W1619,W1630,W1626,W1637,W1634,W1635,W1610,W1611,W1612,W1613,W1614,W1615,W1616,W1617,W1632,W1633,W0704,W1628,W1629,W1636,C0111,C0103,W0622,R0903,R0913,R0201,W0212,C0330,R0205,W0107 4 | 5 | 6 | [MISCELLANEOUS] 7 | 8 | # List of note tags to take in consideration, separated by a comma. 9 | # notes=FIXME,XXX,TODO 10 | notes= 11 | 12 | [REPORTS] 13 | output-format=parseable 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.5" 5 | - "3.6" 6 | # - "pypy" 7 | install: 8 | - pip install py>=1.5.0 9 | - pip install pytest>=3.1.2, pytest-cov>=2.2.1, mock>=2.0.0 10 | - pip install coveralls 11 | - pip install pylint 12 | - pip install pep8 13 | script: 14 | - if [[ "$TRAVIS_PYTHON_VERSION" != "2.6" ]]; then pylint --rcfile=.pylintrc pysm && pep8 pysm && python setup.py test ; fi 15 | sudo: false 16 | after_success: 17 | coveralls 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Piotr Gularski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | pysm - Python State Machine 2 | --------------------------- 3 | 4 | Versatile and flexible Python State Machine library. 5 | 6 | 7 | .. image:: https://travis-ci.org/pgularski/pysm.svg?branch=master 8 | :target: https://travis-ci.org/pgularski/pysm 9 | 10 | .. image:: https://coveralls.io/repos/github/pgularski/pysm/badge.svg?branch=master 11 | :target: https://coveralls.io/github/pgularski/pysm?branch=master 12 | 13 | .. image:: https://api.codacy.com/project/badge/Grade/6f18f01639c242a0b83280a52245539d 14 | :target: https://www.codacy.com/app/pgularski/pysm?utm_source=github.com&utm_medium=referral&utm_content=pgularski/pysm&utm_campaign=Badge_Grade 15 | 16 | .. image:: https://landscape.io/github/pgularski/pysm/master/landscape.svg?style=flat 17 | :target: https://landscape.io/github/pgularski/pysm/master 18 | :alt: Code Health 19 | 20 | .. image:: https://readthedocs.org/projects/pysm/badge/?version=latest 21 | :target: http://pysm.readthedocs.io/en/latest/?badge=latest 22 | :alt: Documentation Status 23 | 24 | 25 | Implement simple and complex state machines 26 | ------------------------------------------ 27 | 28 | It can do simple things like this: 29 | 30 | .. image:: https://cloud.githubusercontent.com/assets/3026621/15031178/bf5efb2a-124e-11e6-9748-0b5a5be60a30.png 31 | 32 | Or somewhat more complex like that: 33 | 34 | .. image:: https://cloud.githubusercontent.com/assets/3026621/15031148/ad955f06-124e-11e6-865e-c7e3340f14cb.png 35 | 36 | 37 | Python State Machine 38 | -------------------- 39 | 40 | `The State Pattern `_ 41 | solves many problems, untangles the code and saves one's sanity. 42 | Yet.., it's a bit rigid and doesn't scale. The goal of this library is to give 43 | you a close to the State Pattern simplicity with much more flexibility. And, 44 | if needed, the full state machine functionality, including `FSM 45 | `_, `HSM 46 | `_, `PDA 48 | `_ and other tasty things. 49 | 50 | 51 | Goals 52 | ----- 53 | 54 | * Provide a State Pattern-like behavior with more flexibility (see 55 | `Documentation `_ for 56 | examples) 57 | * Be explicit and don't add any magic code to objects that use pysm 58 | * Handle directly any kind of event or input (not only strings) - parsing 59 | strings is cool again! 60 | * Keep it simple, even for someone who's not very familiar with the FSM 61 | terminology 62 | 63 | 64 | Features 65 | -------- 66 | 67 | * Finite State Machine (FSM) 68 | * Hierarchical State Machine (HSM) with Internal/External/Local transitions 69 | * Pushdown Automaton (PDA) 70 | * Transition callbacks - action, before, after 71 | * State hooks - enter, exit, and other event handlers 72 | * Entry and exit actions are associated with states, not transitions 73 | * Events may be anything as long as they're hashable 74 | * States history and transition to previous states 75 | * Conditional transitions (if/elif/else-like logic) 76 | * Explicit behaviour (no method or attribute is added to the object containing a state machine) 77 | * No need to extend a class with State Machine class (composition over inheritance) 78 | * Fast (even with hundreds of transition rules) 79 | * Not too many pythonisms, so that it's easily portable to other languages (ie. `JavaScript `_). 80 | * Micropython support 81 | 82 | 83 | Installation 84 | ------------ 85 | 86 | Install pysm from `PyPI `_:: 87 | 88 | pip install pysm 89 | 90 | or clone the `Github pysm repository `_:: 91 | 92 | git clone https://github.com/pgularski/pysm 93 | cd pysm 94 | python setup.py install 95 | 96 | 97 | Documentation 98 | ------------- 99 | 100 | Read the docs for API documentation and examples - http://pysm.readthedocs.io/ 101 | 102 | See Unit Tests to see it working and extensively tested. 103 | 104 | Micropython support 105 | ------------------- 106 | The library works with pyboards!:: 107 | 108 | import upip 109 | upip.install('upysm') 110 | 111 | 112 | Links 113 | ----- 114 | * `Documentation `_ 115 | * `Installation `_ 116 | * `Github `_ 117 | * `Issues `_ 118 | * `Examples `_ 119 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | # This file makes pytest running just by running "pytest" 2 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help 18 | help: 19 | @echo "Please use \`make ' where is one of" 20 | @echo " html to make standalone HTML files" 21 | @echo " dirhtml to make HTML files named index.html in directories" 22 | @echo " singlehtml to make a single large HTML file" 23 | @echo " pickle to make pickle files" 24 | @echo " json to make JSON files" 25 | @echo " htmlhelp to make HTML files and a HTML help project" 26 | @echo " qthelp to make HTML files and a qthelp project" 27 | @echo " applehelp to make an Apple Help Book" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " epub3 to make an epub3" 31 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 32 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 33 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 34 | @echo " text to make text files" 35 | @echo " man to make manual pages" 36 | @echo " texinfo to make Texinfo files" 37 | @echo " info to make Texinfo files and run them through makeinfo" 38 | @echo " gettext to make PO message catalogs" 39 | @echo " changes to make an overview of all changed/added/deprecated items" 40 | @echo " xml to make Docutils-native XML files" 41 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 42 | @echo " linkcheck to check all external links for integrity" 43 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 44 | @echo " coverage to run coverage check of the documentation (if enabled)" 45 | @echo " dummy to check syntax errors of document sources" 46 | 47 | .PHONY: clean 48 | clean: 49 | rm -rf $(BUILDDIR)/* 50 | 51 | .PHONY: html 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | .PHONY: dirhtml 58 | dirhtml: 59 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 60 | @echo 61 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 62 | 63 | .PHONY: singlehtml 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | .PHONY: pickle 70 | pickle: 71 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 72 | @echo 73 | @echo "Build finished; now you can process the pickle files." 74 | 75 | .PHONY: json 76 | json: 77 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 78 | @echo 79 | @echo "Build finished; now you can process the JSON files." 80 | 81 | .PHONY: htmlhelp 82 | htmlhelp: 83 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 84 | @echo 85 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 86 | ".hhp project file in $(BUILDDIR)/htmlhelp." 87 | 88 | .PHONY: qthelp 89 | qthelp: 90 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 91 | @echo 92 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 93 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 94 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/pysm.qhcp" 95 | @echo "To view the help file:" 96 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/pysm.qhc" 97 | 98 | .PHONY: applehelp 99 | applehelp: 100 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 101 | @echo 102 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 103 | @echo "N.B. You won't be able to view it unless you put it in" \ 104 | "~/Library/Documentation/Help or install it in your application" \ 105 | "bundle." 106 | 107 | .PHONY: devhelp 108 | devhelp: 109 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 110 | @echo 111 | @echo "Build finished." 112 | @echo "To view the help file:" 113 | @echo "# mkdir -p $$HOME/.local/share/devhelp/pysm" 114 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/pysm" 115 | @echo "# devhelp" 116 | 117 | .PHONY: epub 118 | epub: 119 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 120 | @echo 121 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 122 | 123 | .PHONY: epub3 124 | epub3: 125 | $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 126 | @echo 127 | @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." 128 | 129 | .PHONY: latex 130 | latex: 131 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 132 | @echo 133 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 134 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 135 | "(use \`make latexpdf' here to do that automatically)." 136 | 137 | .PHONY: latexpdf 138 | latexpdf: 139 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 140 | @echo "Running LaTeX files through pdflatex..." 141 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 142 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 143 | 144 | .PHONY: latexpdfja 145 | latexpdfja: 146 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 147 | @echo "Running LaTeX files through platex and dvipdfmx..." 148 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 149 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 150 | 151 | .PHONY: text 152 | text: 153 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 154 | @echo 155 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 156 | 157 | .PHONY: man 158 | man: 159 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 160 | @echo 161 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 162 | 163 | .PHONY: texinfo 164 | texinfo: 165 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 166 | @echo 167 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 168 | @echo "Run \`make' in that directory to run these through makeinfo" \ 169 | "(use \`make info' here to do that automatically)." 170 | 171 | .PHONY: info 172 | info: 173 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 174 | @echo "Running Texinfo files through makeinfo..." 175 | make -C $(BUILDDIR)/texinfo info 176 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 177 | 178 | .PHONY: gettext 179 | gettext: 180 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 181 | @echo 182 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 183 | 184 | .PHONY: changes 185 | changes: 186 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 187 | @echo 188 | @echo "The overview file is in $(BUILDDIR)/changes." 189 | 190 | .PHONY: linkcheck 191 | linkcheck: 192 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 193 | @echo 194 | @echo "Link check complete; look for any errors in the above output " \ 195 | "or in $(BUILDDIR)/linkcheck/output.txt." 196 | 197 | .PHONY: doctest 198 | doctest: 199 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 200 | @echo "Testing of doctests in the sources finished, look at the " \ 201 | "results in $(BUILDDIR)/doctest/output.txt." 202 | 203 | .PHONY: coverage 204 | coverage: 205 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 206 | @echo "Testing of coverage in the sources finished, look at the " \ 207 | "results in $(BUILDDIR)/coverage/python.txt." 208 | 209 | .PHONY: xml 210 | xml: 211 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 212 | @echo 213 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 214 | 215 | .PHONY: pseudoxml 216 | pseudoxml: 217 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 218 | @echo 219 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 220 | 221 | .PHONY: dummy 222 | dummy: 223 | $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy 224 | @echo 225 | @echo "Build finished. Dummy builder generates no files." 226 | -------------------------------------------------------------------------------- /docs/_static/img/complex_hsm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pgularski/pysm/32cc06c893f4a5146c4f0542d03886909dda18a6/docs/_static/img/complex_hsm.png -------------------------------------------------------------------------------- /docs/_static/img/oven_hsm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pgularski/pysm/32cc06c893f4a5146c4f0542d03886909dda18a6/docs/_static/img/oven_hsm.png -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # pysm documentation build configuration file, created by 4 | # sphinx-quickstart on Mon Jun 6 21:47:12 2016. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | import os 20 | import sys 21 | sys.path.insert(0, os.path.abspath(os.pardir)) 22 | # sys.path.insert(0, os.path.abspath(os.pardir + os.path.sep + 'pysm')) 23 | 24 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True' 25 | 26 | # -- General configuration ------------------------------------------------ 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | # 30 | # needs_sphinx = '1.0' 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | 'sphinx.ext.autodoc', 37 | 'sphinx.ext.doctest', 38 | 'sphinx.ext.intersphinx', 39 | 'sphinx.ext.todo', 40 | 'sphinx.ext.coverage', 41 | 'sphinx.ext.mathjax', 42 | 'sphinx.ext.viewcode', 43 | 'sphinx.ext.extlinks', 44 | ] 45 | 46 | # Add any paths that contain templates here, relative to this directory. 47 | templates_path = ['_templates'] 48 | 49 | # The suffix(es) of source filenames. 50 | # You can specify multiple suffix as a list of string: 51 | # 52 | # source_suffix = ['.rst', '.md'] 53 | source_suffix = '.rst' 54 | 55 | # The encoding of source files. 56 | # 57 | # source_encoding = 'utf-8-sig' 58 | 59 | # The master toctree document. 60 | master_doc = 'index' 61 | 62 | # General information about the project. 63 | project = u'pysm' 64 | copyright = u'2016, Piotr Gularski' 65 | author = u'Piotr Gularski' 66 | 67 | # The version info for the project you're documenting, acts as replacement for 68 | # |version| and |release|, also used in various other places throughout the 69 | # built documents. 70 | # 71 | try: 72 | from pysm import __version__ 73 | # The short X.Y version. 74 | version = '.'.join(__version__.split('.')[:2]) 75 | # The full version, including alpha/beta/rc tags. 76 | release = __version__ 77 | except ImportError: 78 | version = release = 'dev' 79 | 80 | # The language for content autogenerated by Sphinx. Refer to documentation 81 | # for a list of supported languages. 82 | # 83 | # This is also used if you do content translation via gettext catalogs. 84 | # Usually you set "language" from the command line for these cases. 85 | language = None 86 | 87 | # There are two options for replacing |today|: either, you set today to some 88 | # non-false value, then it is used: 89 | # 90 | # today = '' 91 | # 92 | # Else, today_fmt is used as the format for a strftime call. 93 | # 94 | # today_fmt = '%B %d, %Y' 95 | 96 | # List of patterns, relative to source directory, that match files and 97 | # directories to ignore when looking for source files. 98 | # This patterns also effect to html_static_path and html_extra_path 99 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 100 | 101 | # The reST default role (used for this markup: `text`) to use for all 102 | # documents. 103 | # 104 | # default_role = None 105 | 106 | # If true, '()' will be appended to :func: etc. cross-reference text. 107 | # 108 | # add_function_parentheses = True 109 | 110 | # If true, the current module name will be prepended to all description 111 | # unit titles (such as .. function::). 112 | # 113 | # add_module_names = True 114 | 115 | # If true, sectionauthor and moduleauthor directives will be shown in the 116 | # output. They are ignored by default. 117 | # 118 | # show_authors = False 119 | 120 | # The name of the Pygments (syntax highlighting) style to use. 121 | pygments_style = 'sphinx' 122 | 123 | # A list of ignored prefixes for module index sorting. 124 | # modindex_common_prefix = [] 125 | 126 | # If true, keep warnings as "system message" paragraphs in the built documents. 127 | # keep_warnings = False 128 | 129 | # If true, `todo` and `todoList` produce output, else they produce nothing. 130 | todo_include_todos = False 131 | 132 | 133 | # -- Options for HTML output ---------------------------------------------- 134 | 135 | # The theme to use for HTML and HTML Help pages. See the documentation for 136 | # a list of builtin themes. 137 | # 138 | # html_theme = 'alabaster' 139 | html_theme = 'default' 140 | if not on_rtd: 141 | try: 142 | import sphinx_rtd_theme 143 | html_theme = 'sphinx_rtd_theme' 144 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 145 | except ImportError: 146 | pass 147 | 148 | # Theme options are theme-specific and customize the look and feel of a theme 149 | # further. For a list of options available for each theme, see the 150 | # documentation. 151 | # 152 | # html_theme_options = {} 153 | 154 | # Add any paths that contain custom themes here, relative to this directory. 155 | # html_theme_path = [] 156 | 157 | # The name for this set of Sphinx documents. 158 | # " v documentation" by default. 159 | # 160 | # html_title = u'pysm v0.3.0' 161 | 162 | # A shorter title for the navigation bar. Default is the same as html_title. 163 | # 164 | # html_short_title = None 165 | 166 | # The name of an image file (relative to this directory) to place at the top 167 | # of the sidebar. 168 | # 169 | # html_logo = None 170 | 171 | # The name of an image file (relative to this directory) to use as a favicon of 172 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 173 | # pixels large. 174 | # 175 | # html_favicon = None 176 | 177 | # Add any paths that contain custom static files (such as style sheets) here, 178 | # relative to this directory. They are copied after the builtin static files, 179 | # so a file named "default.css" will overwrite the builtin "default.css". 180 | html_static_path = ['_static'] 181 | 182 | # Add any extra paths that contain custom files (such as robots.txt or 183 | # .htaccess) here, relative to this directory. These files are copied 184 | # directly to the root of the documentation. 185 | # 186 | # html_extra_path = [] 187 | 188 | # If not None, a 'Last updated on:' timestamp is inserted at every page 189 | # bottom, using the given strftime format. 190 | # The empty string is equivalent to '%b %d, %Y'. 191 | # 192 | # html_last_updated_fmt = None 193 | 194 | # If true, SmartyPants will be used to convert quotes and dashes to 195 | # typographically correct entities. 196 | # 197 | # html_use_smartypants = True 198 | 199 | # Custom sidebar templates, maps document names to template names. 200 | # 201 | # html_sidebars = {} 202 | 203 | # Additional templates that should be rendered to pages, maps page names to 204 | # template names. 205 | # 206 | # html_additional_pages = {} 207 | 208 | # If false, no module index is generated. 209 | # 210 | # html_domain_indices = True 211 | 212 | # If false, no index is generated. 213 | # 214 | # html_use_index = True 215 | 216 | # If true, the index is split into individual pages for each letter. 217 | # 218 | # html_split_index = False 219 | 220 | # If true, links to the reST sources are added to the pages. 221 | # 222 | # html_show_sourcelink = True 223 | 224 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 225 | # 226 | # html_show_sphinx = True 227 | 228 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 229 | # 230 | # html_show_copyright = True 231 | 232 | # If true, an OpenSearch description file will be output, and all pages will 233 | # contain a tag referring to it. The value of this option must be the 234 | # base URL from which the finished HTML is served. 235 | # 236 | # html_use_opensearch = '' 237 | 238 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 239 | # html_file_suffix = None 240 | 241 | # Language to be used for generating the HTML full-text search index. 242 | # Sphinx supports the following languages: 243 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 244 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' 245 | # 246 | # html_search_language = 'en' 247 | 248 | # A dictionary with options for the search language support, empty by default. 249 | # 'ja' uses this config value. 250 | # 'zh' user can custom change `jieba` dictionary path. 251 | # 252 | # html_search_options = {'type': 'default'} 253 | 254 | # The name of a javascript file (relative to the configuration directory) that 255 | # implements a search results scorer. If empty, the default will be used. 256 | # 257 | # html_search_scorer = 'scorer.js' 258 | 259 | # Output file base name for HTML help builder. 260 | htmlhelp_basename = 'pysmdoc' 261 | 262 | # -- Options for LaTeX output --------------------------------------------- 263 | 264 | latex_elements = { 265 | # The paper size ('letterpaper' or 'a4paper'). 266 | # 267 | # 'papersize': 'letterpaper', 268 | 269 | # The font size ('10pt', '11pt' or '12pt'). 270 | # 271 | # 'pointsize': '10pt', 272 | 273 | # Additional stuff for the LaTeX preamble. 274 | # 275 | # 'preamble': '', 276 | 277 | # Latex figure (float) alignment 278 | # 279 | # 'figure_align': 'htbp', 280 | } 281 | 282 | # Grouping the document tree into LaTeX files. List of tuples 283 | # (source start file, target name, title, 284 | # author, documentclass [howto, manual, or own class]). 285 | latex_documents = [ 286 | (master_doc, 'pysm.tex', u'pysm Documentation', 287 | u'Piotr Gularski', 'manual'), 288 | ] 289 | 290 | # The name of an image file (relative to this directory) to place at the top of 291 | # the title page. 292 | # 293 | # latex_logo = None 294 | 295 | # For "manual" documents, if this is true, then toplevel headings are parts, 296 | # not chapters. 297 | # 298 | # latex_use_parts = False 299 | 300 | # If true, show page references after internal links. 301 | # 302 | # latex_show_pagerefs = False 303 | 304 | # If true, show URL addresses after external links. 305 | # 306 | # latex_show_urls = False 307 | 308 | # Documents to append as an appendix to all manuals. 309 | # 310 | # latex_appendices = [] 311 | 312 | # If false, no module index is generated. 313 | # 314 | # latex_domain_indices = True 315 | 316 | 317 | # -- Options for manual page output --------------------------------------- 318 | 319 | # One entry per manual page. List of tuples 320 | # (source start file, name, description, authors, manual section). 321 | man_pages = [ 322 | (master_doc, 'pysm', u'pysm Documentation', 323 | [author], 1) 324 | ] 325 | 326 | # If true, show URL addresses after external links. 327 | # 328 | # man_show_urls = False 329 | 330 | 331 | # -- Options for Texinfo output ------------------------------------------- 332 | 333 | # Grouping the document tree into Texinfo files. List of tuples 334 | # (source start file, target name, title, author, 335 | # dir menu entry, description, category) 336 | texinfo_documents = [ 337 | (master_doc, 'pysm', u'pysm Documentation', 338 | author, 'pysm', 'One line description of project.', 339 | 'Miscellaneous'), 340 | ] 341 | 342 | # Documents to append as an appendix to all manuals. 343 | # 344 | # texinfo_appendices = [] 345 | 346 | # If false, no module index is generated. 347 | # 348 | # texinfo_domain_indices = True 349 | 350 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 351 | # 352 | # texinfo_show_urls = 'footnote' 353 | 354 | # If true, do not generate a @detailmenu in the "Top" node's menu. 355 | # 356 | # texinfo_no_detailmenu = False 357 | 358 | 359 | # Example configuration for intersphinx: refer to the Python standard library. 360 | intersphinx_mapping = {'https://docs.python.org/': None} 361 | 362 | 363 | extlinks = { 364 | 'issue': ('https://github.com/pgularski/pysm/issues/%s', '#'), 365 | 'pull': ('https://github.com/pgularski/pysm/pull/%s', 'PR #'), 366 | } 367 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | .. |State| replace:: :class:`~pysm.pysm.State` 2 | 3 | Examples 4 | ======== 5 | 6 | .. contents:: 7 | :local: 8 | 9 | 10 | Simple state machine 11 | -------------------- 12 | 13 | This is a simple state machine with only two states - `on` and `off`. 14 | 15 | .. include:: ../examples/simple_on_off.py 16 | :literal: 17 | 18 | 19 | Complex hierarchical state machine 20 | ---------------------------------- 21 | 22 | A Hierarchical state machine similar to the one from Miro Samek's book [#f1]_, 23 | page 95. *It is a state machine that contains all possible state transition 24 | topologies up to four levels of state nesting* [#f2]_ 25 | 26 | .. image:: _static/img/complex_hsm.png 27 | 28 | .. include:: ../examples/complex_hsm.py 29 | :literal: 30 | 31 | 32 | Different ways to attach event handlers 33 | --------------------------------------- 34 | 35 | A state machine and states may be created in many ways. The code below mixes 36 | many styles to demonstrate it (In production code you'd rather keep your code 37 | style consistent). One way is to subclass the |State| class and attach event 38 | handlers to it. This resembles the State Pattern way of writing a state 39 | machine. But handlers may live anywhere, really, and you can attach them 40 | however you want. You're free to chose your own style of writing state machines 41 | with pysm. 42 | Also in this example a transition to a historical state is used. 43 | 44 | .. image:: _static/img/oven_hsm.png 45 | 46 | .. include:: ../examples/oven.py 47 | :literal: 48 | 49 | 50 | Reverse Polish notation calculator 51 | ---------------------------------- 52 | 53 | A state machine is used in the `Reverse Polish notation (RPN) 54 | `_ calculator as a 55 | parser. A single event name (`parse`) is used along with specific `inputs` (See 56 | :func:`pysm.pysm.StateMachine.add_transition`). 57 | 58 | This example also demonstrates how to use the stack of a state machine, so it 59 | behaves as a `Pushdown Automaton (PDA) 60 | `_ 61 | 62 | .. include:: ../examples/rpn_calculator.py 63 | :literal: 64 | 65 | 66 | ---- 67 | 68 | .. rubric:: Footnotes 69 | 70 | .. [#f1] `Miro Samek, Practical Statecharts in C/C++, CMP Books 2002. 71 | `_ 73 | .. [#f2] http://www.embedded.com/print/4008251 (visited on 07.06.2016) 74 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Python State Machine 2 | ==================== 3 | 4 | `Github `_ | 5 | `PyPI `_ 6 | 7 | 8 | `The State Pattern `_ 9 | solves many problems, untangles the code and saves one's sanity. 10 | Yet.., it's a bit rigid and doesn't scale. The goal of this library is to give 11 | you a close to the State Pattern simplicity with much more flexibility. And, 12 | if needed, the full state machine functionality, including `FSM 13 | `_, `HSM 14 | `_, `PDA 16 | `_ and other tasty things. 17 | 18 | Goals: 19 | - Provide a State Pattern-like behavior with more flexibility 20 | - Be explicit and don't add any code to objects 21 | - Handle directly any kind of event (not only strings) - parsing strings is 22 | cool again! 23 | - Keep it simple, even for someone who's not very familiar with the FSM 24 | terminology 25 | 26 | 27 | .. toctree:: 28 | :maxdepth: 2 29 | 30 | pysm_module 31 | installing 32 | quickstart 33 | examples 34 | user_guide 35 | cookbook 36 | -------------------------------------------------------------------------------- /docs/installing.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Install pysm from `PyPI `_:: 5 | 6 | pip install pysm 7 | 8 | or clone the `Github pysm repository `_:: 9 | 10 | git clone https://github.com/pgularski/pysm 11 | cd pysm 12 | python setup.py install 13 | 14 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. epub3 to make an epub3 31 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 32 | echo. text to make text files 33 | echo. man to make manual pages 34 | echo. texinfo to make Texinfo files 35 | echo. gettext to make PO message catalogs 36 | echo. changes to make an overview over all changed/added/deprecated items 37 | echo. xml to make Docutils-native XML files 38 | echo. pseudoxml to make pseudoxml-XML files for display purposes 39 | echo. linkcheck to check all external links for integrity 40 | echo. doctest to run all doctests embedded in the documentation if enabled 41 | echo. coverage to run coverage check of the documentation if enabled 42 | echo. dummy to check syntax errors of document sources 43 | goto end 44 | ) 45 | 46 | if "%1" == "clean" ( 47 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 48 | del /q /s %BUILDDIR%\* 49 | goto end 50 | ) 51 | 52 | 53 | REM Check if sphinx-build is available and fallback to Python version if any 54 | %SPHINXBUILD% 1>NUL 2>NUL 55 | if errorlevel 9009 goto sphinx_python 56 | goto sphinx_ok 57 | 58 | :sphinx_python 59 | 60 | set SPHINXBUILD=python -m sphinx.__init__ 61 | %SPHINXBUILD% 2> nul 62 | if errorlevel 9009 ( 63 | echo. 64 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 65 | echo.installed, then set the SPHINXBUILD environment variable to point 66 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 67 | echo.may add the Sphinx directory to PATH. 68 | echo. 69 | echo.If you don't have Sphinx installed, grab it from 70 | echo.http://sphinx-doc.org/ 71 | exit /b 1 72 | ) 73 | 74 | :sphinx_ok 75 | 76 | 77 | if "%1" == "html" ( 78 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 79 | if errorlevel 1 exit /b 1 80 | echo. 81 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 82 | goto end 83 | ) 84 | 85 | if "%1" == "dirhtml" ( 86 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 87 | if errorlevel 1 exit /b 1 88 | echo. 89 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 90 | goto end 91 | ) 92 | 93 | if "%1" == "singlehtml" ( 94 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 95 | if errorlevel 1 exit /b 1 96 | echo. 97 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 98 | goto end 99 | ) 100 | 101 | if "%1" == "pickle" ( 102 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 103 | if errorlevel 1 exit /b 1 104 | echo. 105 | echo.Build finished; now you can process the pickle files. 106 | goto end 107 | ) 108 | 109 | if "%1" == "json" ( 110 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 111 | if errorlevel 1 exit /b 1 112 | echo. 113 | echo.Build finished; now you can process the JSON files. 114 | goto end 115 | ) 116 | 117 | if "%1" == "htmlhelp" ( 118 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 119 | if errorlevel 1 exit /b 1 120 | echo. 121 | echo.Build finished; now you can run HTML Help Workshop with the ^ 122 | .hhp project file in %BUILDDIR%/htmlhelp. 123 | goto end 124 | ) 125 | 126 | if "%1" == "qthelp" ( 127 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 128 | if errorlevel 1 exit /b 1 129 | echo. 130 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 131 | .qhcp project file in %BUILDDIR%/qthelp, like this: 132 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\pysm.qhcp 133 | echo.To view the help file: 134 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\pysm.ghc 135 | goto end 136 | ) 137 | 138 | if "%1" == "devhelp" ( 139 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 140 | if errorlevel 1 exit /b 1 141 | echo. 142 | echo.Build finished. 143 | goto end 144 | ) 145 | 146 | if "%1" == "epub" ( 147 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 148 | if errorlevel 1 exit /b 1 149 | echo. 150 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 151 | goto end 152 | ) 153 | 154 | if "%1" == "epub3" ( 155 | %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 156 | if errorlevel 1 exit /b 1 157 | echo. 158 | echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. 159 | goto end 160 | ) 161 | 162 | if "%1" == "latex" ( 163 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 164 | if errorlevel 1 exit /b 1 165 | echo. 166 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 167 | goto end 168 | ) 169 | 170 | if "%1" == "latexpdf" ( 171 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 172 | cd %BUILDDIR%/latex 173 | make all-pdf 174 | cd %~dp0 175 | echo. 176 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 177 | goto end 178 | ) 179 | 180 | if "%1" == "latexpdfja" ( 181 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 182 | cd %BUILDDIR%/latex 183 | make all-pdf-ja 184 | cd %~dp0 185 | echo. 186 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 187 | goto end 188 | ) 189 | 190 | if "%1" == "text" ( 191 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 192 | if errorlevel 1 exit /b 1 193 | echo. 194 | echo.Build finished. The text files are in %BUILDDIR%/text. 195 | goto end 196 | ) 197 | 198 | if "%1" == "man" ( 199 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 200 | if errorlevel 1 exit /b 1 201 | echo. 202 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 203 | goto end 204 | ) 205 | 206 | if "%1" == "texinfo" ( 207 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 208 | if errorlevel 1 exit /b 1 209 | echo. 210 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 211 | goto end 212 | ) 213 | 214 | if "%1" == "gettext" ( 215 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 216 | if errorlevel 1 exit /b 1 217 | echo. 218 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 219 | goto end 220 | ) 221 | 222 | if "%1" == "changes" ( 223 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 224 | if errorlevel 1 exit /b 1 225 | echo. 226 | echo.The overview file is in %BUILDDIR%/changes. 227 | goto end 228 | ) 229 | 230 | if "%1" == "linkcheck" ( 231 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 232 | if errorlevel 1 exit /b 1 233 | echo. 234 | echo.Link check complete; look for any errors in the above output ^ 235 | or in %BUILDDIR%/linkcheck/output.txt. 236 | goto end 237 | ) 238 | 239 | if "%1" == "doctest" ( 240 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 241 | if errorlevel 1 exit /b 1 242 | echo. 243 | echo.Testing of doctests in the sources finished, look at the ^ 244 | results in %BUILDDIR%/doctest/output.txt. 245 | goto end 246 | ) 247 | 248 | if "%1" == "coverage" ( 249 | %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage 250 | if errorlevel 1 exit /b 1 251 | echo. 252 | echo.Testing of coverage in the sources finished, look at the ^ 253 | results in %BUILDDIR%/coverage/python.txt. 254 | goto end 255 | ) 256 | 257 | if "%1" == "xml" ( 258 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 259 | if errorlevel 1 exit /b 1 260 | echo. 261 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 262 | goto end 263 | ) 264 | 265 | if "%1" == "pseudoxml" ( 266 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 267 | if errorlevel 1 exit /b 1 268 | echo. 269 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 270 | goto end 271 | ) 272 | 273 | if "%1" == "dummy" ( 274 | %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy 275 | if errorlevel 1 exit /b 1 276 | echo. 277 | echo.Build finished. Dummy builder generates no files. 278 | goto end 279 | ) 280 | 281 | :end 282 | -------------------------------------------------------------------------------- /docs/pysm_module.rst: -------------------------------------------------------------------------------- 1 | Module documentation 2 | ==================== 3 | 4 | .. automodule:: pysm.pysm 5 | :members: 6 | :inherited-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /examples/complex_hsm.py: -------------------------------------------------------------------------------- 1 | ../test/test_complex_hsm.py -------------------------------------------------------------------------------- /examples/oven.py: -------------------------------------------------------------------------------- 1 | ../test/test_oven.py -------------------------------------------------------------------------------- /examples/rpn_calculator.py: -------------------------------------------------------------------------------- 1 | ../test/test_rpn.py -------------------------------------------------------------------------------- /examples/simple_on_off.py: -------------------------------------------------------------------------------- 1 | ../test/test_simple_on_off.py -------------------------------------------------------------------------------- /pysm/__init__.py: -------------------------------------------------------------------------------- 1 | from .version import __version__, __version_info__ 2 | from .pysm import (State, StateMachine, Event, StateMachineException, Stack, 3 | any_event, logger) 4 | -------------------------------------------------------------------------------- /pysm/pysm.py: -------------------------------------------------------------------------------- 1 | '''Python State Machine 2 | 3 | The goal of this library is to give you a close to the State Pattern 4 | simplicity with much more flexibility. And, if needed, the full state machine 5 | functionality, including `FSM 6 | `_, `HSM 7 | `_, `PDA 9 | `_ and other tasty things. 10 | 11 | Goals: 12 | - Provide a State Pattern-like behavior with more flexibility 13 | - Be explicit and don't add any code to objects 14 | - Handle directly any kind of event (not only strings) - parsing strings is 15 | cool again! 16 | - Keep it simple, even for someone who's not very familiar with the FSM 17 | terminology 18 | 19 | ---- 20 | 21 | .. |StateMachine| replace:: :class:`~.StateMachine` 22 | .. |State| replace:: :class:`~.State` 23 | .. |Hashable| replace:: :class:`~collections.Hashable` 24 | .. |Iterable| replace:: :class:`~collections.Iterable` 25 | .. |Callable| replace:: :class:`~collections.Callable` 26 | 27 | ''' 28 | import logging 29 | import sys 30 | from collections import defaultdict, deque 31 | 32 | 33 | # Required to make it Micropython compatible 34 | if str(type(defaultdict)).find('module') > 0: 35 | # pylint: disable=no-member 36 | defaultdict = defaultdict.defaultdict 37 | 38 | 39 | # Required to make it Micropython compatible 40 | def patch_deque(deque_module): 41 | class deque_maxlen(object): 42 | def __init__(self, iterable=None, maxlen=0): 43 | # pylint: disable=no-member 44 | if iterable is None: 45 | iterable = [] 46 | if maxlen in [None, 0]: 47 | maxlen = float('Inf') 48 | self.q = deque_module.deque(iterable, maxlen) 49 | self.maxlen = maxlen 50 | 51 | def pop(self): 52 | return self.q.pop() 53 | 54 | def append(self, item): 55 | if self.maxlen > 0 and len(self.q) >= self.maxlen: 56 | self.q.popleft() 57 | self.q.append(item) 58 | 59 | def __getattr__(self, name): 60 | return getattr(self.q, name) 61 | 62 | def __bool__(self): 63 | return len(self.q) > 0 64 | 65 | def __len__(self): 66 | return len(self.q) 67 | 68 | def __iter__(self): 69 | return iter(self.q) 70 | 71 | def __getitem__(self, key): 72 | return self.q[key] 73 | 74 | return deque_maxlen 75 | 76 | 77 | # Required to make it Micropython compatible 78 | try: 79 | # if this throws an error, it means we are on MicroPython 80 | test_deque = deque(maxlen=1) 81 | except TypeError: 82 | # TypeError: function doesn't take keyword arguments 83 | if hasattr(deque, 'deque'): 84 | deque = patch_deque(deque) 85 | else: 86 | class MockDequeModule(object): 87 | deque = deque 88 | deque = patch_deque(MockDequeModule) 89 | else: 90 | del test_deque 91 | 92 | 93 | logger = logging.getLogger(__name__) 94 | handler = logging.StreamHandler(sys.stdout) 95 | logger.addHandler(handler) 96 | logger.setLevel(logging.INFO) 97 | 98 | 99 | class AnyEvent(object): 100 | ''' 101 | hash(object()) doesn't work in MicroPython therefore the need for this 102 | class. 103 | ''' 104 | pass 105 | 106 | any_event = AnyEvent() 107 | 108 | 109 | def is_iterable(obj): 110 | try: 111 | iter(obj) 112 | except TypeError: 113 | return False 114 | return True 115 | 116 | 117 | class StateMachineException(Exception): 118 | '''All |StateMachine| exceptions are of this type. ''' 119 | pass 120 | 121 | 122 | class Event(object): 123 | r'''Triggers actions and transition in |StateMachine|. 124 | 125 | Events are also used to control the flow of data propagated to states 126 | within the states hierarchy. 127 | 128 | Event objects have the following attributes set after an event has been 129 | dispatched: 130 | 131 | **Attributes:** 132 | 133 | .. attribute:: state_machine 134 | 135 | A |StateMachine| instance that is handling the event (the one whose 136 | :func:`pysm.pysm.StateMachine.dispatch` method is called) 137 | 138 | .. attribute:: propagate 139 | 140 | An event is propagated from a current leaf state up in the states 141 | hierarchy until it encounters a handler that can handle the event. 142 | To propagate it further, it has to be set to `True` in a handler. 143 | 144 | :param name: Name of an event. It may be anything as long as it's hashable. 145 | :type name: |Hashable| 146 | :param input: Optional input. Anything hashable. 147 | :type input: |Hashable| 148 | :param \*\*cargo: Keyword arguments for an event, used to transport data to 149 | handlers. It's added to an event as a `cargo` property of type `dict`. 150 | For `enter` and `exit` events, the original event that triggered a 151 | transition is passed in cargo as `source_event` entry. 152 | 153 | .. note`: 154 | 155 | `enter` and `exit` events are never propagated, even if the `propagate` 156 | flag is set to `True` in a handler. 157 | 158 | **Example Usage:** 159 | 160 | .. code-block:: python 161 | 162 | state_machine.dispatch(Event('start')) 163 | state_machine.dispatch(Event('start', key='value')) 164 | state_machine.dispatch(Event('parse', input='#', entity=my_object)) 165 | state_machine.dispatch(Event('%')) 166 | state_machine.dispatch(Event(frozenset([1, 2]))) 167 | 168 | ''' 169 | def __init__(self, name, input=None, **cargo): 170 | self.name = name 171 | self.input = input 172 | self.propagate = True 173 | self.cargo = cargo 174 | # This must be always the root machine 175 | self.state_machine = None 176 | 177 | def __repr__(self): 178 | return ''.format( 179 | self.name, self.input, self.cargo, hex(id(self))) 180 | 181 | 182 | class State(object): 183 | '''Represents a state in a state machine. 184 | 185 | `enter` and `exit` handlers are called whenever a state is entered or 186 | exited respectively. These action names are reserved only for this purpose. 187 | 188 | It is encouraged to extend this class to encapsulate a state behavior, 189 | similarly to the State Pattern. 190 | 191 | Once it's extended, the preferred way of adding an event handlers is 192 | through the :func:`register_handlers` hook. Usually, 193 | there's no need to create the :func:`__init__` in a subclass. 194 | 195 | :param name: Human readable state name 196 | :type name: str 197 | 198 | **Example Usage:** 199 | 200 | .. code-block:: python 201 | 202 | # Extending State to encapsulate state-related behavior. Similar to the 203 | # State Pattern. 204 | class Running(State): 205 | def on_enter(self, state, event): 206 | print('Running state entered') 207 | 208 | def on_jump(self, state, event): 209 | print('Jumping') 210 | 211 | def on_dollar(self, state, event): 212 | print('Dollar found!') 213 | 214 | def register_handlers(self): 215 | self.handlers = { 216 | 'enter': self.on_enter, 217 | 'jump': self.on_jump, 218 | '$': self.on_dollar 219 | } 220 | 221 | .. code-block:: python 222 | 223 | # Different way of attaching handlers. A handler may be any function as 224 | # long as it takes `state` and `event` args. 225 | def another_handler(state, event): 226 | print('Another handler') 227 | 228 | running = State('running') 229 | running.handlers = { 230 | 'another_event': another_handler 231 | } 232 | 233 | ''' 234 | def __init__(self, name): 235 | self.parent = None 236 | self.name = name 237 | # self.id = 1 238 | self.handlers = {} 239 | self.initial = False 240 | self.register_handlers() 241 | 242 | def __repr__(self): 243 | return ''.format(self.name, hex(id(self))) 244 | 245 | def register_handlers(self): 246 | '''Hook method to register event handlers. 247 | 248 | It is used to easily extend |State| class. The hook is called from 249 | within the base :func:`.State.__init__`. Usually, the 250 | :func:`__init__` doesn't have to be created in a subclass. 251 | 252 | Event handlers are kept in a `dict`, with events' names as keys, 253 | therefore registered events may be of any hashable type. 254 | 255 | Handlers take two arguments: 256 | 257 | - **state**: The current state that is handling an event. The same 258 | handler function may be attached to many states, therefore it 259 | is helpful to get the handling state's instance. 260 | - **event**: An event that triggered the handler call. If it is an 261 | `enter` or `exit` event, then the source event (the one that 262 | triggered the transition) is passed in `event`'s cargo 263 | property as `cargo.source_event`. 264 | 265 | **Example Usage:** 266 | 267 | .. code-block:: python 268 | 269 | class On(State): 270 | def handle_my_event(self, state, event): 271 | print('Handling an event') 272 | 273 | def register_handlers(self): 274 | self.handlers = { 275 | 'my_event': self.handle_my_event, 276 | '&': self.handle_my_event, 277 | frozenset([1, 2]): self.handle_my_event 278 | } 279 | 280 | ''' 281 | pass 282 | 283 | def is_substate(self, state): 284 | '''Check whether the `state` is a substate of `self`. 285 | 286 | Also `self` is considered a substate of `self`. 287 | 288 | :param state: State to verify 289 | :type state: |State| 290 | :returns: `True` if `state` is a substate of `self`, `False` otherwise 291 | :rtype: bool 292 | 293 | ''' 294 | if state is self: 295 | return True 296 | parent = self.parent 297 | while parent: 298 | if parent is state: 299 | return True 300 | parent = parent.parent 301 | return False 302 | 303 | def _on(self, event): 304 | if event.name in self.handlers: 305 | event.propagate = False 306 | self.handlers[event.name](self, event) 307 | # Never propagate exit/enter events, even if propagate is set to True 308 | if (self.parent and event.propagate and 309 | event.name not in ('exit', 'enter')): 310 | self.parent._on(event) 311 | 312 | def _nop(self, state, event): 313 | del state # Unused (silence pylint) 314 | del event # Unused (silence pylint) 315 | return True 316 | 317 | 318 | class TransitionsContainer(object): 319 | def __init__(self, machine): 320 | self._machine = machine 321 | self._transitions = defaultdict(list) 322 | 323 | def add(self, key, transition): 324 | self._transitions[key].append(transition) 325 | 326 | def get(self, event): 327 | key = (self._machine.state, event.name, event.input) 328 | return self._get_transition_matching_condition(key, event) 329 | 330 | def _get_transition_matching_condition(self, key, event): 331 | from_state = self._machine.leaf_state 332 | for transition in self._transitions[key]: 333 | if transition['condition'](from_state, event) is True: 334 | return transition 335 | key = (self._machine.state, any_event, event.input) 336 | for transition in self._transitions[key]: 337 | if transition['condition'](from_state, event) is True: 338 | return transition 339 | return None 340 | 341 | 342 | class Stack(object): 343 | def __init__(self, maxlen=None): 344 | self.deque = deque(maxlen=maxlen) 345 | 346 | def pop(self): 347 | return self.deque.pop() 348 | 349 | def push(self, value): 350 | self.deque.append(value) 351 | 352 | def peek(self): 353 | return self.deque[-1] 354 | 355 | def __repr__(self): 356 | return str(list(self.deque)) 357 | 358 | 359 | class StateMachine(State): 360 | '''State machine controls actions and transitions. 361 | 362 | To provide the State Pattern-like behavior, the formal state machine rules 363 | may be slightly broken, and instead of creating an `internal transition 364 | `_ 365 | for every action that doesn't require a state change, event handlers may be 366 | added to states. These are handled first when an event occurs. After that 367 | the actual transition is called, calling `enter`/`exit` actions and other 368 | transition actions. Nevertheless, internal transitions are also supported. 369 | 370 | So the order of calls on an event is as follows: 371 | 372 | 1. State's event handler 373 | 2. `condition` callback 374 | 3. `before` callback 375 | 4. `exit` handlers 376 | 5. `action` callback 377 | 6. `enter` handlers 378 | 7. `after` callback 379 | 380 | If there's no handler in states or transition for an event, it is silently 381 | ignored. 382 | 383 | If using nested state machines, all events should be sent to the root state 384 | machine. 385 | 386 | **Attributes:** 387 | 388 | .. attribute:: state 389 | 390 | Current, local state (instance of |State|) in a state machine. 391 | 392 | .. attribute:: stack 393 | 394 | Stack that can be used if the `Pushdown Automaton (PDA) 395 | `_ functionality 396 | is needed. 397 | 398 | .. attribute:: state_stack 399 | 400 | Stack of previous local states in a state machine. With every 401 | transition, a previous state (instance of |State|) is pushed to the 402 | `state_stack`. Only :attr:`.StateMachine.STACK_SIZE` (32 403 | by default) are stored and old values are removed from the stack. 404 | 405 | .. attribute:: leaf_state_stack 406 | 407 | Stack of previous leaf states in a state machine. With every 408 | transition, a previous leaf state (instance of |State|) is pushed 409 | to the `leaf_state_stack`. Only 410 | :attr:`.StateMachine.STACK_SIZE` (32 by default) are 411 | stored and old values are removed from the stack. 412 | 413 | **leaf_state** 414 | See the :attr:`~.StateMachine.leaf_state` property. 415 | 416 | **root_machine** 417 | See the :attr:`~.StateMachine.root_machine` property. 418 | 419 | :param name: Human readable state machine name 420 | :type name: str 421 | 422 | .. note :: 423 | 424 | |StateMachine| extends |State| and therefore it is possible to always 425 | use a |StateMachine| instance instead of the |State|. This wouldn't 426 | be a good practice though, as the |State| class is designed to be as 427 | small as possible memory-wise and thus it's more memory efficient. It 428 | is valid to replace a |State| with a |StateMachine| later on if there's 429 | a need to extend a state with internal states. 430 | 431 | .. note:: 432 | 433 | For the sake of speed thread safety isn't guaranteed. 434 | 435 | **Example Usage:** 436 | 437 | .. code-block:: python 438 | 439 | state_machine = StateMachine('root_machine') 440 | state_on = State('On') 441 | state_off = State('Off') 442 | state_machine.add_state('Off', initial=True) 443 | state_machine.add_state('On') 444 | state_machine.add_transition(state_on, state_off, events=['off']) 445 | state_machine.add_transition(state_off, state_on, events=['on']) 446 | state_machine.initialize() 447 | state_machine.dispatch(Event('on')) 448 | 449 | ''' 450 | STACK_SIZE = 32 451 | 452 | def __init__(self, name): 453 | super(StateMachine, self).__init__(name) 454 | self.states = set() 455 | self.state = None 456 | self._transitions = TransitionsContainer(self) 457 | self.state_stack = Stack(maxlen=StateMachine.STACK_SIZE) 458 | self.leaf_state_stack = Stack(maxlen=StateMachine.STACK_SIZE) 459 | self.stack = Stack(maxlen=StateMachine.STACK_SIZE) 460 | self._leaf_state = None 461 | 462 | def add_state(self, state, initial=False): 463 | '''Add a state to a state machine. 464 | 465 | If states are added, one (and only one) of them has to be declared as 466 | `initial`. 467 | 468 | :param state: State to be added. It may be an another |StateMachine| 469 | :type state: |State| 470 | :param initial: Declare a state as initial 471 | :type initial: bool 472 | 473 | ''' 474 | Validator(self).validate_add_state(state, initial) 475 | state.initial = initial 476 | state.parent = self 477 | self.states.add(state) 478 | 479 | def add_states(self, *states): 480 | '''Add `states` to the |StateMachine|. 481 | 482 | To set the initial state use 483 | :func:`set_initial_state`. 484 | 485 | :param states: A list of states to be added 486 | :type states: |State| 487 | 488 | ''' 489 | for state in states: 490 | self.add_state(state) 491 | 492 | def set_initial_state(self, state): 493 | '''Set an initial state in a state machine. 494 | 495 | :param state: Set this state as initial in a state machine 496 | :type state: |State| 497 | 498 | ''' 499 | Validator(self).validate_set_initial(state) 500 | state.initial = True 501 | 502 | @property 503 | def initial_state(self): 504 | '''Get the initial state in a state machine. 505 | 506 | :returns: Initial state in a state machine 507 | :rtype: |State| 508 | 509 | ''' 510 | for state in self.states: 511 | if state.initial: 512 | return state 513 | return None 514 | 515 | @property 516 | def root_machine(self): 517 | '''Get the root state machine in a states hierarchy. 518 | 519 | :returns: Root state in the states hierarchy 520 | :rtype: |StateMachine| 521 | 522 | ''' 523 | machine = self 524 | while machine.parent: 525 | machine = machine.parent 526 | return machine 527 | 528 | def add_transition( 529 | self, from_state, to_state, events, input=None, action=None, 530 | condition=None, before=None, after=None): 531 | '''Add a transition to a state machine. 532 | 533 | All callbacks take two arguments - `state` and `event`. See parameters 534 | description for details. 535 | 536 | It is possible to create conditional if/elif/else-like logic for 537 | transitions. To do so, add many same transition rules with different 538 | condition callbacks. First met condition will trigger a transition, if 539 | no condition is met, no transition is performed. 540 | 541 | :param from_state: Source state 542 | :type from_state: |State| 543 | :param to_state: Target state. If `None`, then it's an `internal 544 | transition `_ 546 | :type to_state: |State|, `None` 547 | :param events: List of events that trigger the transition 548 | :type events: |Iterable| of |Hashable| 549 | :param input: List of inputs that trigger the transition. A transition 550 | event may be associated with a specific input. i.e.: An event may 551 | be ``parse`` and an input associated with it may be ``$``. May be 552 | `None` (default), then every matched event name triggers a 553 | transition. 554 | :type input: `None`, |Iterable| of |Hashable| 555 | :param action: Action callback that is called during the transition 556 | after all states have been left but before the new one is entered. 557 | 558 | `action` callback takes two arguments: 559 | 560 | - state: Leaf state before transition 561 | - event: Event that triggered the transition 562 | 563 | :type action: |Callable| 564 | :param condition: Condition callback - if returns `True` transition may 565 | be initiated. 566 | 567 | `condition` callback takes two arguments: 568 | 569 | - state: Leaf state before transition 570 | - event: Event that triggered the transition 571 | 572 | :type condition: |Callable| 573 | :param before: Action callback that is called right before the 574 | transition. 575 | 576 | `before` callback takes two arguments: 577 | 578 | - state: Leaf state before transition 579 | - event: Event that triggered the transition 580 | 581 | :type before: |Callable| 582 | :param after: Action callback that is called just after the transition 583 | 584 | `after` callback takes two arguments: 585 | 586 | - state: Leaf state after transition 587 | - event: Event that triggered the transition 588 | 589 | :type after: |Callable| 590 | 591 | ''' 592 | # Rather than adding some if statements later on, let's just declare a 593 | # neutral items that will do nothing if called. It simplifies the logic 594 | # a lot. 595 | if input is None: 596 | input = tuple([None]) 597 | if action is None: 598 | action = self._nop 599 | if before is None: 600 | before = self._nop 601 | if after is None: 602 | after = self._nop 603 | if condition is None: 604 | condition = self._nop 605 | 606 | Validator(self).validate_add_transition( 607 | from_state, to_state, events, input) 608 | 609 | for input_value in input: 610 | for event in events: 611 | key = (from_state, event, input_value) 612 | transition = { 613 | 'from_state': from_state, 614 | 'to_state': to_state, 615 | 'action': action, 616 | 'condition': condition, 617 | 'before': before, 618 | 'after': after, 619 | } 620 | self._transitions.add(key, transition) 621 | 622 | def _get_transition(self, event): 623 | machine = self.leaf_state.parent 624 | while machine: 625 | transition = machine._transitions.get(event) 626 | if transition: 627 | return transition 628 | machine = machine.parent 629 | return None 630 | 631 | @property 632 | def leaf_state(self): 633 | '''Get the current leaf state. 634 | 635 | The :attr:`~.StateMachine.state` property gives the current, 636 | local state in a state machine. The `leaf_state` goes to the bottom in 637 | a hierarchy of states. In most cases, this is the property that should 638 | be used to get the current state in a state machine, even in a flat 639 | FSM, to keep the consistency in the code and to avoid confusion. 640 | 641 | :returns: Leaf state in a hierarchical state machine 642 | :rtype: |State| 643 | 644 | ''' 645 | return self.root_machine._leaf_state 646 | # return self._get_leaf_state(self) 647 | 648 | def _get_leaf_state(self, state): 649 | while hasattr(state, 'state') and state.state is not None: 650 | state = state.state 651 | return state 652 | 653 | def initialize(self): 654 | '''Initialize states in the state machine. 655 | 656 | After a state machine has been created and all states are added to it, 657 | :func:`initialize` has to be called. 658 | 659 | If using nested state machines (HSM), 660 | :func:`initialize` has to be called on a root 661 | state machine in the hierarchy. 662 | 663 | ''' 664 | machines = deque() 665 | machines.append(self) 666 | while machines: 667 | machine = machines.popleft() 668 | Validator(self).validate_initial_state(machine) 669 | machine.state = machine.initial_state 670 | for child_state in machine.states: 671 | if isinstance(child_state, StateMachine): 672 | machines.append(child_state) 673 | 674 | self._leaf_state = self._get_leaf_state(self) 675 | 676 | def dispatch(self, event): 677 | '''Dispatch an event to a state machine. 678 | 679 | If using nested state machines (HSM), it has to be called on a root 680 | state machine in the hierarchy. 681 | 682 | :param event: Event to be dispatched 683 | :type event: :class:`.Event` 684 | 685 | ''' 686 | event.state_machine = self 687 | leaf_state_before = self.leaf_state 688 | leaf_state_before._on(event) 689 | transition = self._get_transition(event) 690 | if transition is None: 691 | return 692 | to_state = transition['to_state'] 693 | from_state = transition['from_state'] 694 | 695 | transition['before'](leaf_state_before, event) 696 | top_state = self._exit_states(event, from_state, to_state) 697 | transition['action'](leaf_state_before, event) 698 | self._enter_states(event, top_state, to_state) 699 | transition['after'](self.leaf_state, event) 700 | 701 | def _exit_states(self, event, from_state, to_state): 702 | if to_state is None: 703 | return None 704 | state = self.leaf_state 705 | self.leaf_state_stack.push(state) 706 | while (state.parent and 707 | not (from_state.is_substate(state) and 708 | to_state.is_substate(state)) or 709 | (state == from_state == to_state)): 710 | logger.debug('exiting %s', state.name) 711 | exit_event = Event('exit', propagate=False, source_event=event) 712 | exit_event.state_machine = self 713 | self.root_machine._leaf_state = state 714 | state._on(exit_event) 715 | state.parent.state_stack.push(state) 716 | state.parent.state = state.parent.initial_state 717 | state = state.parent 718 | return state 719 | 720 | def _enter_states(self, event, top_state, to_state): 721 | if to_state is None: 722 | return 723 | path = [] 724 | state = self._get_leaf_state(to_state) 725 | 726 | while state.parent and state != top_state: 727 | path.append(state) 728 | state = state.parent 729 | for state in reversed(path): 730 | logger.debug('entering %s', state.name) 731 | enter_event = Event('enter', propagate=False, source_event=event) 732 | enter_event.state_machine = self 733 | self.root_machine._leaf_state = state 734 | state._on(enter_event) 735 | state.parent.state = state 736 | 737 | def set_previous_leaf_state(self, event=None): 738 | '''Transition to a previous leaf state. This makes a dynamic transition 739 | to a historical state. The current `leaf_state` is saved on the stack 740 | of historical leaf states when calling this method. 741 | 742 | :param event: (Optional) event that is passed to states involved in the 743 | transition 744 | :type event: :class:`.Event` 745 | 746 | ''' 747 | if event is not None: 748 | event.state_machine = self 749 | from_state = self.leaf_state 750 | try: 751 | to_state = self.leaf_state_stack.peek() 752 | except IndexError: 753 | return 754 | top_state = self._exit_states(event, from_state, to_state) 755 | self._enter_states(event, top_state, to_state) 756 | 757 | def revert_to_previous_leaf_state(self, event=None): 758 | '''Similar to :func:`set_previous_leaf_state` 759 | but the current leaf_state is not saved on the stack of states. It 760 | allows to perform transitions further in the history of states. 761 | 762 | ''' 763 | self.set_previous_leaf_state(event) 764 | try: 765 | self.leaf_state_stack.pop() 766 | self.leaf_state_stack.pop() 767 | except IndexError: 768 | return 769 | 770 | 771 | class Validator(object): 772 | def __init__(self, state_machine): 773 | self.state_machine = state_machine 774 | self.template = 'Machine "{0}" error: {1}'.format( 775 | self.state_machine.name, '{0}') 776 | 777 | def _raise(self, msg): 778 | raise StateMachineException(self.template.format(msg)) 779 | 780 | def validate_add_state(self, state, initial): 781 | if not isinstance(state, State): 782 | msg = 'Unable to add state of type {0}'.format(type(state)) 783 | self._raise(msg) 784 | self._validate_state_already_added(state) 785 | if initial is True: 786 | self.validate_set_initial(state) 787 | 788 | def _validate_state_already_added(self, state): 789 | root_machine = self.state_machine.root_machine 790 | machines = deque() 791 | machines.append(root_machine) 792 | while machines: 793 | machine = machines.popleft() 794 | if state in machine.states and machine is not self.state_machine: 795 | msg = ('Machine "{0}" error: State "{1}" is already added ' 796 | 'to machine "{2}"'.format( 797 | self.state_machine.name, state.name, machine.name)) 798 | self._raise(msg) 799 | for child_state in machine.states: 800 | if isinstance(child_state, StateMachine): 801 | machines.append(child_state) 802 | 803 | def validate_set_initial(self, state): 804 | for added_state in self.state_machine.states: 805 | if added_state.initial is True and added_state is not state: 806 | msg = ('Unable to set initial state to "{0}". ' 807 | 'Initial state is already set to "{1}"' 808 | .format(state.name, added_state.name)) 809 | self._raise(msg) 810 | 811 | def validate_add_transition(self, from_state, to_state, events, input): 812 | self._validate_from_state(from_state) 813 | self._validate_to_state(to_state) 814 | self._validate_events(events) 815 | self._validate_input(input) 816 | 817 | def _validate_from_state(self, from_state): 818 | if from_state not in self.state_machine.states: 819 | msg = 'Unable to add transition from unknown state "{0}"'.format( 820 | from_state.name) 821 | self._raise(msg) 822 | 823 | def _validate_to_state(self, to_state): 824 | root_machine = self.state_machine.root_machine 825 | # pylint: disable=no-else-return 826 | if to_state is None: 827 | return 828 | elif to_state is root_machine: 829 | return 830 | elif not to_state.is_substate(root_machine): 831 | msg = 'Unable to add transition to unknown state "{0}"'.format( 832 | to_state.name) 833 | self._raise(msg) 834 | 835 | def _validate_events(self, events): 836 | if not is_iterable(events): 837 | msg = ('Unable to add transition, events is not iterable: {0}' 838 | .format(events)) 839 | self._raise(msg) 840 | 841 | def _validate_input(self, input): 842 | if not is_iterable(input): 843 | msg = ('Unable to add transition, input is not iterable: {0}' 844 | .format(input)) 845 | self._raise(msg) 846 | 847 | def validate_initial_state(self, machine): 848 | if machine.states and not machine.initial_state: 849 | msg = 'Machine "{0}" has no initial state'.format(machine.name) 850 | self._raise(msg) 851 | -------------------------------------------------------------------------------- /pysm/version.py: -------------------------------------------------------------------------------- 1 | __version_info__ = ('0', '3', '11') 2 | __version__ = '.'.join(__version_info__) 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | addopts = --cov=pysm --cov-report term-missing 3 | 4 | [aliases] 5 | test=pytest 6 | 7 | [bdist_wheel] 8 | universal = 1 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import os 3 | from setuptools import setup, find_packages, Command 4 | 5 | HERE = os.path.abspath(os.path.dirname(__file__)) 6 | 7 | # Get __version__ 8 | exec(open('pysm/version.py').read()) 9 | 10 | 11 | def read(*parts): 12 | # Build an absolute path from *parts* and and return the contents of the 13 | # resulting file. Assume UTF-8 encoding. 14 | with codecs.open(os.path.join(HERE, *parts), "rb", "utf-8") as f: 15 | return f.read() 16 | 17 | 18 | setup( 19 | name='pysm', 20 | version=__version__, 21 | url='https://github.com/pgularski/pysm', 22 | description='Versatile and flexible Python State Machine library', 23 | author='Piotr Gularski', 24 | author_email='piotr.gularski@gmail.com', 25 | license='MIT', 26 | long_description=read('README.rst'), 27 | long_description_content_type="text/x-rst", 28 | packages=find_packages(), 29 | zip_safe=False, 30 | classifiers=[ 31 | 'Development Status :: 3 - Alpha', 32 | 'License :: OSI Approved :: MIT License', 33 | 'Programming Language :: Python', 34 | 'Programming Language :: Python :: 2', 35 | 'Programming Language :: Python :: 2.7', 36 | 'Programming Language :: Python :: 3', 37 | 'Programming Language :: Python :: 3.4', 38 | 'Programming Language :: Python :: 3.5', 39 | 'Programming Language :: Python :: 3.6', 40 | 'Programming Language :: Python :: 3.7', 41 | 'Programming Language :: Python :: Implementation :: MicroPython', 42 | 'Environment :: Console', 43 | 'Intended Audience :: Developers', 44 | 'Intended Audience :: Education', 45 | 'Intended Audience :: Information Technology', 46 | 'Intended Audience :: Telecommunications Industry', 47 | 'Natural Language :: English', 48 | 'Topic :: Software Development', 49 | 'Topic :: Software Development :: Libraries', 50 | ], 51 | setup_requires=['pytest-runner'], 52 | tests_require=['pytest', 'pytest-cov', 'mock'], 53 | keywords='finite state machine automaton fsm hsm pda', 54 | ) 55 | -------------------------------------------------------------------------------- /test/test_complex_hsm.py: -------------------------------------------------------------------------------- 1 | from pysm import State, StateMachine, Event 2 | 3 | foo = True 4 | 5 | def on_enter(state, event): 6 | print('enter state {0}'.format(state.name)) 7 | 8 | def on_exit(state, event): 9 | print('exit state {0}'.format(state.name)) 10 | 11 | def set_foo(state, event): 12 | global foo 13 | print('set foo') 14 | foo = True 15 | 16 | def unset_foo(state, event): 17 | global foo 18 | print('unset foo') 19 | foo = False 20 | 21 | def action_i(state, event): 22 | print('action_i') 23 | 24 | def action_j(state, event): 25 | print('action_j') 26 | 27 | def action_k(state, event): 28 | print('action_k') 29 | 30 | def action_l(state, event): 31 | print('action_l') 32 | 33 | def action_m(state, event): 34 | print('action_m') 35 | 36 | def action_n(state, event): 37 | print('action_n') 38 | 39 | def is_foo(state, event): 40 | return foo is True 41 | 42 | def is_not_foo(state, event): 43 | return foo is False 44 | 45 | 46 | m = StateMachine('m') 47 | s0 = StateMachine('s0') 48 | s1 = StateMachine('s1') 49 | s2 = StateMachine('s2') 50 | s11 = State('s11') 51 | s21 = StateMachine('s21') 52 | s211 = State('s211') 53 | 54 | m.add_state(s0, initial=True) 55 | s0.add_state(s1, initial=True) 56 | s0.add_state(s2) 57 | s1.add_state(s11, initial=True) 58 | s2.add_state(s21, initial=True) 59 | s21.add_state(s211, initial=True) 60 | 61 | # Internal transitions 62 | m.add_transition(s0, None, events='i', action=action_i) 63 | s0.add_transition(s1, None, events='j', action=action_j) 64 | s0.add_transition(s2, None, events='k', action=action_k) 65 | s1.add_transition(s11, None, events='h', condition=is_foo, action=unset_foo) 66 | s1.add_transition(s11, None, events='n', action=action_n) 67 | s21.add_transition(s211, None, events='m', action=action_m) 68 | s2.add_transition(s21, None, events='l', condition=is_foo, action=action_l) 69 | 70 | # External transition 71 | m.add_transition(s0, s211, events='e') 72 | s0.add_transition(s1, s0, events='d') 73 | s0.add_transition(s1, s11, events='b') 74 | s0.add_transition(s1, s1, events='a') 75 | s0.add_transition(s1, s211, events='f') 76 | s0.add_transition(s1, s2, events='c') 77 | s0.add_transition(s2, s11, events='f') 78 | s0.add_transition(s2, s1, events='c') 79 | s1.add_transition(s11, s211, events='g') 80 | s21.add_transition(s211, s0, events='g') 81 | s21.add_transition(s211, s21, events='d') 82 | s2.add_transition(s21, s211, events='b') 83 | s2.add_transition(s21, s21, events='h', condition=is_not_foo, action=set_foo) 84 | 85 | # Attach enter/exit handlers 86 | states = [m, s0, s1, s11, s2, s21, s211] 87 | for state in states: 88 | state.handlers = {'enter': on_enter, 'exit': on_exit} 89 | 90 | m.initialize() 91 | 92 | 93 | def test(): 94 | assert m.leaf_state == s11 95 | m.dispatch(Event('a')) 96 | assert m.leaf_state == s11 97 | # This transition toggles state between s11 and s211 98 | m.dispatch(Event('c')) 99 | assert m.leaf_state == s211 100 | m.dispatch(Event('b')) 101 | assert m.leaf_state == s211 102 | m.dispatch(Event('i')) 103 | assert m.leaf_state == s211 104 | m.dispatch(Event('c')) 105 | assert m.leaf_state == s11 106 | assert foo is True 107 | m.dispatch(Event('h')) 108 | assert foo is False 109 | assert m.leaf_state == s11 110 | # Do nothing if foo is False 111 | m.dispatch(Event('h')) 112 | assert m.leaf_state == s11 113 | # This transition toggles state between s11 and s211 114 | m.dispatch(Event('c')) 115 | assert m.leaf_state == s211 116 | assert foo is False 117 | m.dispatch(Event('h')) 118 | assert foo is True 119 | assert m.leaf_state == s211 120 | m.dispatch(Event('h')) 121 | assert m.leaf_state == s211 122 | 123 | 124 | if __name__ == '__main__': 125 | test() 126 | -------------------------------------------------------------------------------- /test/test_oven.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | from pysm import StateMachine, State, Event 4 | 5 | 6 | # It's possible to encapsulate all state related behaviour in a state class. 7 | class HeatingState(StateMachine): 8 | def on_enter(self, state, event): 9 | oven = event.cargo['source_event'].cargo['oven'] 10 | if not oven.timer.is_alive(): 11 | oven.start_timer() 12 | print('Heating on') 13 | 14 | def on_exit(self, state, event): 15 | print('Heating off') 16 | 17 | def register_handlers(self): 18 | self.handlers = { 19 | 'enter': self.on_enter, 20 | 'exit': self.on_exit, 21 | } 22 | 23 | 24 | class Oven(object): 25 | TIMEOUT = 0.1 26 | 27 | def __init__(self): 28 | self.sm = self._get_state_machine() 29 | self.timer = threading.Timer(Oven.TIMEOUT, self.on_timeout) 30 | 31 | def _get_state_machine(self): 32 | oven = StateMachine('Oven') 33 | door_closed = StateMachine('Door closed') 34 | door_open = State('Door open') 35 | heating = HeatingState('Heating') 36 | toasting = State('Toasting') 37 | baking = State('Baking') 38 | off = State('Off') 39 | 40 | oven.add_state(door_closed, initial=True) 41 | oven.add_state(door_open) 42 | door_closed.add_state(off, initial=True) 43 | door_closed.add_state(heating) 44 | heating.add_state(baking, initial=True) 45 | heating.add_state(toasting) 46 | 47 | oven.add_transition(door_closed, toasting, events=['toast']) 48 | oven.add_transition(door_closed, baking, events=['bake']) 49 | oven.add_transition(door_closed, off, events=['off', 'timeout']) 50 | oven.add_transition(door_closed, door_open, events=['open']) 51 | 52 | # This time, a state behaviour is handled by Oven's methods. 53 | door_open.handlers = { 54 | 'enter': self.on_open_enter, 55 | 'exit': self.on_open_exit, 56 | 'close': self.on_door_close 57 | } 58 | 59 | oven.initialize() 60 | return oven 61 | 62 | @property 63 | def state(self): 64 | return self.sm.leaf_state.name 65 | 66 | def light_on(self): 67 | print('Light on') 68 | 69 | def light_off(self): 70 | print('Light off') 71 | 72 | def start_timer(self): 73 | self.timer.start() 74 | 75 | def bake(self): 76 | self.sm.dispatch(Event('bake', oven=self)) 77 | 78 | def toast(self): 79 | self.sm.dispatch(Event('toast', oven=self)) 80 | 81 | def open_door(self): 82 | self.sm.dispatch(Event('open', oven=self)) 83 | 84 | def close_door(self): 85 | self.sm.dispatch(Event('close', oven=self)) 86 | 87 | def on_timeout(self): 88 | print('Timeout...') 89 | self.sm.dispatch(Event('timeout', oven=self)) 90 | self.timer = threading.Timer(Oven.TIMEOUT, self.on_timeout) 91 | 92 | def on_open_enter(self, state, event): 93 | print('Opening door') 94 | self.light_on() 95 | 96 | def on_open_exit(self, state, event): 97 | print('Closing door') 98 | self.light_off() 99 | 100 | def on_door_close(self, state, event): 101 | # Transition to a history state 102 | self.sm.set_previous_leaf_state(event) 103 | 104 | 105 | def test_oven(): 106 | oven = Oven() 107 | print(oven.state) 108 | assert oven.state == 'Off' 109 | oven.bake() 110 | print(oven.state) 111 | assert oven.state == 'Baking' 112 | oven.open_door() 113 | print(oven.state) 114 | assert oven.state == 'Door open' 115 | oven.close_door() 116 | print(oven.state) 117 | assert oven.state == 'Baking' 118 | time.sleep(0.2) 119 | print(oven.state) 120 | assert oven.state == 'Off' 121 | 122 | 123 | if __name__ == '__main__': 124 | test_oven() 125 | -------------------------------------------------------------------------------- /test/test_pysm.py: -------------------------------------------------------------------------------- 1 | import mock 2 | import pytest 3 | import pysm 4 | import logging 5 | from pysm import (Event, State, StateMachine, StateMachineException, Stack, 6 | any_event, logger) 7 | _e = Event 8 | 9 | # logger.setLevel(logging.DEBUG) 10 | 11 | 12 | def test_new_sm(): 13 | run_call_mock = mock.Mock() 14 | stop_call_mock = mock.Mock() 15 | idling_mock = mock.Mock() 16 | running_mock = mock.Mock() 17 | action_mock = mock.Mock() 18 | 19 | class Idling(State): 20 | # @event('run') 21 | def run(self, state, event): 22 | run_call_mock(self, event.input, event.cargo) 23 | 24 | def do(self, state, event): 25 | entity = event.cargo['entity'] 26 | entity.do() 27 | 28 | def on_enter(self, state, event): 29 | idling_mock(self, 'on_enter') 30 | 31 | def on_exit(self, state, event): 32 | idling_mock(self, 'on_exit') 33 | 34 | def register_handlers(self): 35 | self.handlers = { 36 | 'run': self.run, 37 | 'do': self.do, 38 | 'enter': self.on_enter, 39 | 'exit': self.on_exit, 40 | } 41 | 42 | def stop(state, event): 43 | stop_call_mock('stopping...', event.cargo) 44 | 45 | def do(state, event): 46 | entity = event.cargo['entity'] 47 | entity.do() 48 | 49 | def enter(state, event): 50 | running_mock('running, enter') 51 | 52 | def exit(state, event): 53 | running_mock('running, exit') 54 | 55 | def update(state, event): 56 | print('update', event) 57 | 58 | def do_on_transition(state, event): 59 | action_mock('action on transition') 60 | 61 | class Entity(object): 62 | def do(self): 63 | print(self, self.do) 64 | 65 | idling = Idling('idling') 66 | running = State('running') 67 | running.handlers = { 68 | 'stop': stop, 69 | 'do': do, 70 | 'update': update, 71 | 'enter': enter, 72 | 'exit': exit, 73 | } 74 | 75 | entity = Entity() 76 | 77 | sm = StateMachine('sm') 78 | sm.add_state(idling, initial=True) 79 | sm.add_state(running) 80 | sm.add_transition(idling, running, events=['run'], action=do_on_transition) 81 | sm.add_transition(running, idling, events=['stop']) 82 | sm.initialize() 83 | assert sm.state == idling 84 | sm.dispatch(_e('run')) 85 | assert sm.state == running 86 | assert run_call_mock.call_count == 1 87 | assert run_call_mock.call_args[0] == (idling, None, {}) 88 | assert idling_mock.call_count == 1 89 | assert idling_mock.call_args[0] == (idling, 'on_exit') 90 | assert running_mock.call_count == 1 91 | assert running_mock.call_args[0] == ('running, enter',) 92 | assert action_mock.call_count == 1 93 | assert action_mock.call_args[0] == ('action on transition',) 94 | 95 | # Nothing should happen - running state has no 'run' handler 96 | sm.dispatch(_e('run')) 97 | assert sm.state == running 98 | assert run_call_mock.call_count == 1 99 | assert run_call_mock.call_args[0] == (idling, None, {}) 100 | 101 | sm.dispatch(_e('stop')) 102 | assert sm.state == idling 103 | assert idling_mock.call_count == 2 104 | assert idling_mock.call_args[0] == (idling, 'on_enter') 105 | assert running_mock.call_count == 2 106 | assert running_mock.call_args[0] == ('running, exit',) 107 | assert stop_call_mock.call_count == 1 108 | assert stop_call_mock.call_args[0] == ('stopping...', {}) 109 | 110 | # Unknown events must be ignored 111 | sm.dispatch(_e('blah')) 112 | sm.dispatch(_e('blah blah')) 113 | assert sm.state == idling 114 | 115 | 116 | def test_conditions(): 117 | class Bool(object): 118 | def __init__(self): 119 | self.value = True 120 | 121 | def get(self, state, event): 122 | return self.value 123 | 124 | bool_a = Bool() 125 | bool_b = Bool() 126 | 127 | def run(event): 128 | print('runninng...') 129 | 130 | idling = State('idling') 131 | running = State('running') 132 | stopped = State('stopped') 133 | broken = State('broken') 134 | 135 | sm = StateMachine('sm') 136 | sm.add_state(idling, initial=True) 137 | sm.add_state(running) 138 | sm.add_state(stopped) 139 | sm.add_state(broken) 140 | sm.add_transition(idling, running, events=['run'], condition=bool_a.get) 141 | sm.add_transition(idling, stopped, events=['run'], condition=bool_b.get) 142 | sm.add_transition(running, idling, events=['idle']) 143 | sm.add_transition(stopped, idling, events=['idle']) 144 | sm.add_transition(broken, idling, events=['idle']) 145 | sm.initialize() 146 | 147 | # Expect no change 148 | bool_a.value = False 149 | bool_b.value = False 150 | assert sm.state == idling 151 | sm.dispatch(_e('run')) 152 | assert sm.state == idling 153 | sm.dispatch(_e('idle')) 154 | assert sm.state == idling 155 | 156 | # Expect first transition 157 | bool_a.value = True 158 | bool_b.value = False 159 | assert sm.state == idling 160 | sm.dispatch(_e('run')) 161 | assert sm.state == running 162 | sm.dispatch(_e('idle')) 163 | assert sm.state == idling 164 | 165 | # Expect first transition 166 | bool_a.value = True 167 | bool_b.value = True 168 | assert sm.state == idling 169 | sm.dispatch(_e('run')) 170 | assert sm.state == running 171 | sm.dispatch(_e('idle')) 172 | assert sm.state == idling 173 | 174 | # Expect second transition 175 | bool_a.value = False 176 | bool_b.value = True 177 | assert sm.state == idling 178 | sm.dispatch(_e('run')) 179 | assert sm.state == stopped 180 | sm.dispatch(_e('idle')) 181 | assert sm.state == idling 182 | 183 | sm.add_transition(idling, broken, events=['run']) 184 | # Expect transition to state without condition 185 | bool_a.value = False 186 | bool_b.value = False 187 | assert sm.state == idling 188 | sm.dispatch(_e('run')) 189 | assert sm.state == broken 190 | sm.dispatch(_e('idle')) 191 | assert sm.state == idling 192 | 193 | 194 | def test_internal_transition(): 195 | class Foo(object): 196 | def __init__(self): 197 | self.value = False 198 | foo = Foo() 199 | 200 | def on_enter(state, event): 201 | foo.value = True 202 | 203 | def on_exit(state, event): 204 | foo.value = True 205 | 206 | idling = State('idling') 207 | idling.handlers = { 208 | 'enter': on_enter, 209 | 'exit': on_exit, 210 | } 211 | 212 | sm = StateMachine('sm') 213 | sm.add_state(idling, initial=True) 214 | sm.add_transition(idling, None, events=['internal_transition']) 215 | sm.add_transition(idling, idling, events=['external_transition']) 216 | sm.initialize() 217 | sm.dispatch(_e('internal_transition')) 218 | assert foo.value is False 219 | sm.dispatch(_e('external_transition')) 220 | assert foo.value is True 221 | 222 | 223 | def test_hsm_init(): 224 | sm = StateMachine('sm') 225 | s0 = StateMachine('s0') 226 | s1 = StateMachine('s1') 227 | s2 = StateMachine('s2') 228 | s11 = State('s11') 229 | s12 = State('s12') 230 | sm.add_state(s0, initial=True) 231 | s0.add_state(s1, initial=True) 232 | s1.add_state(s11, initial=True) 233 | sm.initialize() 234 | assert sm.state == s0 235 | assert s0.state == s1 236 | assert s1.state == s11 237 | assert sm.leaf_state == s11 238 | 239 | 240 | def test_hsm_get_transition(): 241 | sm = StateMachine('sm') 242 | s0 = StateMachine('s0') 243 | s1 = StateMachine('s1') 244 | s2 = StateMachine('s2') 245 | s0.add_state(s1) 246 | s0.add_state(s2) 247 | s0.add_transition(s1, s2, events='a') 248 | s11 = State('s11') 249 | s12 = State('s12') 250 | sm.add_state(s0, initial=True) 251 | s0.add_state(s1, initial=True) 252 | s1.add_state(s11, initial=True) 253 | sm.initialize() 254 | transition = sm._get_transition(_e('a')) 255 | assert s1 == transition['from_state'] 256 | assert s2 == transition['to_state'] 257 | 258 | 259 | def test_hsm_simple_hsm_transition(): 260 | sm = StateMachine('sm') 261 | s0 = StateMachine('s0') 262 | s1 = StateMachine('s1') 263 | s2 = StateMachine('s2') 264 | s0.add_state(s1) 265 | s0.add_state(s2) 266 | s0.add_transition(s1, s2, events='a') 267 | s0.add_transition(s2, s1, events='a') 268 | s11 = State('s11') 269 | s12 = State('s12') 270 | sm.add_state(s0, initial=True) 271 | s0.add_state(s1, initial=True) 272 | s0.add_state(s2) 273 | s1.add_state(s11, initial=True) 274 | sm.initialize() 275 | assert sm.state == s0 276 | assert s0.state == s1 277 | assert s1.state == s11 278 | assert sm.leaf_state == s11 279 | 280 | sm.dispatch(_e('a')) 281 | assert sm.state == s0 282 | assert s0.state == s2 283 | assert sm.leaf_state == s2 284 | 285 | sm.dispatch(_e('a')) 286 | assert sm.state == s0 287 | assert s0.state == s1 288 | assert s1.state == s11 289 | assert sm.leaf_state == s11 290 | 291 | 292 | def test_enter_exit_on_transitions(): 293 | test_list = [] 294 | 295 | m = StateMachine('m') 296 | # exit = m.add_state('exit', terminal=True) 297 | s0 = StateMachine('s0') 298 | s1 = StateMachine('s1') 299 | s2 = StateMachine('s2') 300 | 301 | def on_enter(state, event): 302 | assert state == m.leaf_state 303 | test_list.append(('enter', state)) 304 | 305 | def on_exit(state, event): 306 | assert state == m.leaf_state 307 | test_list.append(('exit', state)) 308 | 309 | s11 = State('s11') 310 | s21 = StateMachine('s21') 311 | s211 = State('s211') 312 | s212 = State('s212') 313 | 314 | m.add_state(s0, initial=True) 315 | s0.add_state(s1, initial=True) 316 | s0.add_state(s2) 317 | s1.add_state(s11, initial=True) 318 | s2.add_state(s21, initial=True) 319 | s21.add_state(s211, initial=True) 320 | s21.add_state(s212) 321 | 322 | states = [m, s0, s1, s11, s2, s21, s211, s212] 323 | for state in states: 324 | state.handlers = {'enter': on_enter, 'exit': on_exit} 325 | 326 | s0.add_transition(s1, s1, events='a') 327 | s0.add_transition(s1, s11, events='b') 328 | s2.add_transition(s21, s211, events='b') 329 | s0.add_transition(s1, s2, events='c') 330 | s0.add_transition(s2, s1, events='c') 331 | s0.add_transition(s1, s0, events='d') 332 | s21.add_transition(s211, s21, events='d') 333 | m.add_transition(s0, s211, events='e') 334 | m.add_transition(s0, s212, events='z') 335 | s0.add_transition(s2, s11, events='f') 336 | s0.add_transition(s1, s211, events='f') 337 | s1.add_transition(s11, s211, events='g') 338 | s21.add_transition(s211, s0, events='g') 339 | 340 | m.initialize() 341 | 342 | test_list[:] = [] 343 | m.dispatch(_e('a')) 344 | assert test_list == [('exit', s11), ('exit', s1), ('enter', s1), ('enter', s11)] 345 | 346 | test_list[:] = [] 347 | m.dispatch(_e('b')) 348 | assert test_list == [('exit', s11), ('enter', s11)] 349 | m.dispatch(_e('c')) 350 | test_list[:] = [] 351 | m.dispatch(_e('b')) 352 | assert test_list == [('exit', s211), ('enter', s211)] 353 | m.dispatch(_e('c')) 354 | 355 | test_list[:] = [] 356 | m.dispatch(_e('c')) 357 | assert test_list == [('exit', s11), ('exit', s1), ('enter', s2), ('enter', s21), ('enter', s211)] 358 | test_list[:] = [] 359 | m.dispatch(_e('c')) 360 | assert test_list == [('exit', s211), ('exit', s21), ('exit', s2), ('enter', s1), ('enter', s11)] 361 | 362 | test_list[:] = [] 363 | m.dispatch(_e('d')) 364 | assert test_list == [('exit', s11), ('exit', s1), ('enter', s1), ('enter', s11)] 365 | m.dispatch(_e('c')) 366 | test_list[:] = [] 367 | m.dispatch(_e('d')) 368 | assert test_list == [('exit', s211), ('enter', s211)] 369 | m.dispatch(_e('c')) 370 | 371 | test_list[:] = [] 372 | m.dispatch(_e('e')) 373 | assert test_list == [('exit', s11), ('exit', s1), ('enter', s2), ('enter', s21), ('enter', s211)] 374 | test_list[:] = [] 375 | m.dispatch(_e('e')) 376 | assert test_list == [('exit', s211), ('exit', s21), ('exit', s2), ('enter', s2), ('enter', s21), ('enter', s211)] 377 | 378 | test_list[:] = [] 379 | m.dispatch(_e('f')) 380 | assert test_list == [('exit', s211), ('exit', s21), ('exit', s2), ('enter', s1), ('enter', s11)] 381 | test_list[:] = [] 382 | m.dispatch(_e('f')) 383 | assert test_list == [('exit', s11), ('exit', s1), ('enter', s2), ('enter', s21), ('enter', s211)] 384 | 385 | test_list[:] = [] 386 | m.dispatch(_e('g')) 387 | assert test_list == [('exit', s211), ('exit', s21), ('exit', s2), ('enter', s1), ('enter', s11)] 388 | test_list[:] = [] 389 | m.dispatch(_e('g')) 390 | assert test_list == [('exit', s11), ('exit', s1), ('enter', s2), ('enter', s21), ('enter', s211)] 391 | 392 | test_list[:] = [] 393 | m.dispatch(_e('z')) 394 | assert test_list == [('exit', s211), ('exit', s21), ('exit', s2), ('enter', s2), ('enter', s21), ('enter', s212)] 395 | assert m.leaf_state == s212 396 | 397 | test_list[:] = [] 398 | m.dispatch(_e('c')) 399 | assert test_list == [('exit', s212), ('exit', s21), ('exit', s2), ('enter', s1), ('enter', s11)] 400 | assert m.leaf_state == s11 401 | 402 | test_list[:] = [] 403 | m.dispatch(_e('g')) 404 | assert m.leaf_state == s211 405 | assert test_list == [('exit', s11), ('exit', s1), ('enter', s2), ('enter', s21), ('enter', s211)] 406 | assert m.leaf_state == s211 407 | 408 | 409 | def test_internal_vs_external_transitions(): 410 | test_list = [] 411 | 412 | class Foo(object): 413 | value = True 414 | 415 | def on_enter(state, event): 416 | test_list.append(('enter', state)) 417 | 418 | def on_exit(state, event): 419 | test_list.append(('exit', state)) 420 | 421 | def set_foo(state, event): 422 | Foo.value = True 423 | test_list.append('set_foo') 424 | 425 | def unset_foo(state, event): 426 | Foo.value = False 427 | test_list.append('unset_foo') 428 | 429 | def action_i(state, event): 430 | test_list.append('action_i') 431 | return True 432 | 433 | def action_j(state, event): 434 | test_list.append('action_j') 435 | return True 436 | 437 | def action_k(state, event): 438 | test_list.append('action_k') 439 | return True 440 | 441 | def action_l(state, event): 442 | test_list.append('action_l') 443 | 444 | def action_m(state, event): 445 | test_list.append('action_m') 446 | 447 | def action_n(state, event): 448 | test_list.append('action_n') 449 | return True 450 | 451 | m = StateMachine('m') 452 | # exit = m.add_state('exit', terminal=True) 453 | s0 = StateMachine('s0') 454 | s1 = StateMachine('s1') 455 | s2 = StateMachine('s2') 456 | 457 | s11 = State('s11') 458 | s21 = StateMachine('s21') 459 | s211 = State('s211') 460 | s212 = State('s212') 461 | 462 | m.add_state(s0, initial=True) 463 | s0.add_state(s1, initial=True) 464 | s0.add_state(s2) 465 | s1.add_state(s11, initial=True) 466 | s2.add_state(s21, initial=True) 467 | s21.add_state(s211, initial=True) 468 | s21.add_state(s212) 469 | 470 | states = [m, s0, s1, s11, s2, s21, s211, s212] 471 | for state in states: 472 | state.handlers = {'enter': on_enter, 'exit': on_exit} 473 | 474 | s0.add_transition(s1, s1, events='a') 475 | s0.add_transition(s1, s11, events='b') 476 | s2.add_transition(s21, s211, events='b') 477 | s0.add_transition(s1, s2, events='c') 478 | s0.add_transition(s2, s1, events='c') 479 | s0.add_transition(s1, s0, events='d') 480 | s21.add_transition(s211, s21, events='d') 481 | m.add_transition(s0, s211, events='e') 482 | m.add_transition(s0, s212, events='z') 483 | s0.add_transition(s2, s11, events='f') 484 | s0.add_transition(s1, s211, events='f') 485 | s1.add_transition(s11, s211, events='g') 486 | s21.add_transition(s211, s0, events='g') 487 | 488 | m.initialize() 489 | 490 | # Internal transitions 491 | m.add_transition(s0, None, events='i', action=action_i) 492 | s0.add_transition(s1, None, events='j', action=action_j) 493 | s0.add_transition(s2, None, events='k', action=action_k) 494 | s1.add_transition(s11, None, events='n', action=action_n) 495 | s1.add_transition(s11, None, events='h', 496 | condition=lambda s, e: Foo.value is True, action=unset_foo) 497 | s2.add_transition(s21, None, events='l', 498 | condition=lambda s, e: Foo.value is True, action=action_l) 499 | s21.add_transition(s211, None, events='m', action=action_m) 500 | # External transition 501 | s2.add_transition(s21, s21, events='h', 502 | condition=lambda s, e: Foo.value is False, action=set_foo) 503 | 504 | m.initialize() 505 | 506 | test_list[:] = [] 507 | m.dispatch(_e('i')) 508 | assert test_list == ['action_i'] 509 | assert m.leaf_state == s11 510 | 511 | test_list[:] = [] 512 | m.dispatch(_e('j')) 513 | assert test_list == ['action_j'] 514 | assert m.leaf_state == s11 515 | 516 | test_list[:] = [] 517 | m.dispatch(_e('n')) 518 | assert test_list == ['action_n'] 519 | assert m.leaf_state == s11 520 | 521 | # This transition toggles state between s11 and s211 522 | m.dispatch(_e('c')) 523 | assert m.leaf_state == s211 524 | 525 | test_list[:] = [] 526 | m.dispatch(_e('i')) 527 | assert test_list == ['action_i'] 528 | assert m.leaf_state == s211 529 | 530 | test_list[:] = [] 531 | m.dispatch(_e('k')) 532 | assert test_list == ['action_k'] 533 | assert m.leaf_state == s211 534 | 535 | test_list[:] = [] 536 | m.dispatch(_e('m')) 537 | assert test_list == ['action_m'] 538 | assert m.leaf_state == s211 539 | 540 | test_list[:] = [] 541 | m.dispatch(_e('n')) 542 | assert test_list == [] 543 | assert m.leaf_state == s211 544 | 545 | # This transition toggles state between s11 and s211 546 | m.dispatch(_e('c')) 547 | assert m.leaf_state == s11 548 | 549 | test_list[:] = [] 550 | assert Foo.value is True 551 | m.dispatch(_e('h')) 552 | assert Foo.value is False 553 | assert test_list == ['unset_foo'] 554 | assert m.leaf_state == s11 555 | 556 | test_list[:] = [] 557 | m.dispatch(_e('h')) 558 | assert test_list == [] # Do nothing if foo is False 559 | assert m.leaf_state == s11 560 | 561 | # This transition toggles state between s11 and s211 562 | m.dispatch(_e('c')) 563 | assert m.leaf_state == s211 564 | 565 | test_list[:] = [] 566 | assert Foo.value is False 567 | m.dispatch(_e('h')) 568 | assert test_list == [('exit', s211), ('exit', s21), 'set_foo', ('enter', s21), ('enter', s211)] 569 | assert Foo.value is True 570 | assert m.leaf_state == s211 571 | 572 | test_list[:] = [] 573 | m.dispatch(_e('h')) 574 | assert test_list == [] 575 | assert m.leaf_state == s211 576 | 577 | 578 | def test_add_transition_unknown_state(): 579 | sm = StateMachine('sm') 580 | s1 = State('s1') 581 | s2 = State('s2') # This state isn't added to sm 582 | s3 = StateMachine('s3') 583 | s31 = State('s31') 584 | s32 = State('s32') # This state isn't added to s3 585 | 586 | sm.add_state(s1) 587 | sm.add_state(s3) 588 | s3.add_state(s31) 589 | 590 | with pytest.raises(StateMachineException) as exc: 591 | sm.add_transition(s1, s2, events='a') 592 | expected = ( 593 | 'Machine "sm" error: Unable to add transition to unknown state "s2"') 594 | assert expected in str(exc.value) 595 | 596 | with pytest.raises(StateMachineException) as exc: 597 | sm.add_transition(s2, s1, events='a') 598 | expected = ( 599 | 'Machine "sm" error: Unable to add transition from unknown state "s2"') 600 | assert expected in str(exc.value) 601 | 602 | with pytest.raises(StateMachineException) as exc: 603 | sm.add_transition(s1, s32, events='a') 604 | expected = ( 605 | 'Machine "sm" error: Unable to add transition to unknown state "s32"') 606 | assert expected in str(exc.value) 607 | 608 | with pytest.raises(StateMachineException) as exc: 609 | sm.add_transition(s32, s1, events='a') 610 | expected = ( 611 | 'Machine "sm" error: Unable to add transition from unknown state "s32"') 612 | assert expected in str(exc.value) 613 | 614 | 615 | def test_events_not_iterable(): 616 | sm = StateMachine('sm') 617 | s1 = State('s1') 618 | sm.add_state(s1) 619 | 620 | with pytest.raises(StateMachineException) as exc: 621 | sm.add_transition(s1, None, events=1) 622 | expected = ( 623 | 'Machine "sm" error: Unable to add transition, ' 624 | 'events is not iterable: 1') 625 | assert expected in str(exc.value) 626 | 627 | 628 | def test_input_not_iterable(): 629 | sm = StateMachine('sm') 630 | s1 = State('s1') 631 | sm.add_state(s1) 632 | 633 | with pytest.raises(StateMachineException) as exc: 634 | sm.add_transition(s1, None, events=[1], input=2) 635 | expected = ( 636 | 'Machine "sm" error: Unable to add transition, ' 637 | 'input is not iterable: 2') 638 | assert expected in str(exc.value) 639 | 640 | 641 | def test_add_not_a_state_instance(): 642 | class NotState(object): 643 | pass 644 | 645 | sm = StateMachine('sm') 646 | s1 = NotState() 647 | 648 | with pytest.raises(StateMachineException) as exc: 649 | sm.add_state(s1) 650 | expected = ('Machine "sm" error: Unable to add state of type') 651 | assert expected in str(exc.value) 652 | 653 | 654 | def test_no_initial_state(): 655 | sm = StateMachine('sm') 656 | s1 = State('s1') 657 | s2 = State('s2') 658 | try: 659 | sm.initialize() 660 | except StateMachineException as exc: 661 | assert not exc 662 | 663 | sm.add_state(s1) 664 | sm.add_state(s2) 665 | with pytest.raises(StateMachineException) as exc: 666 | sm.initialize() 667 | expected = ('Machine "sm" error: Machine "sm" has no initial state') 668 | assert expected in str(exc.value) 669 | 670 | 671 | def test_many_initial_states(): 672 | sm = StateMachine('sm') 673 | s1 = State('s1') 674 | s2 = State('s2') 675 | 676 | sm.add_state(s1, initial=True) 677 | with pytest.raises(StateMachineException) as exc: 678 | sm.add_state(s2, initial=True) 679 | expected = ('Machine "sm" error: Unable to set initial state to "s2". ' 680 | 'Initial state is already set to "s1"') 681 | assert expected in str(exc.value) 682 | 683 | 684 | def test_add_state_that_is_already_added_anywhere_in_the_hsm(): 685 | sm = StateMachine('sm') 686 | s1 = State('s1') 687 | s2 = State('s2') 688 | s3 = StateMachine('s3') 689 | s31 = State('s31') 690 | s32 = State('s32') 691 | 692 | sm.add_state(s1) 693 | sm.add_state(s2) 694 | sm.add_state(s3) 695 | s3.add_state(s31) 696 | s3.add_state(s32) 697 | 698 | with pytest.raises(StateMachineException) as exc: 699 | s3.add_state(s2) 700 | expected = ('Machine "s3" error: State "s2" is already added ' 701 | 'to machine "sm"') 702 | assert expected in str(exc.value) 703 | 704 | 705 | def test_state_stack(): 706 | sm = StateMachine('sm') 707 | s1 = State('s1') 708 | s2 = State('s2') 709 | s3 = StateMachine('s3') 710 | s31 = State('s31') 711 | s32 = State('s32') 712 | 713 | sm.add_state(s1, initial=True) 714 | sm.add_state(s2) 715 | sm.add_state(s3) 716 | s3.add_state(s31, initial=True) 717 | s3.add_state(s32) 718 | 719 | sm.add_transition(s1, s2, events=['s1->s2']) 720 | sm.add_transition(s2, s1, events=['s2->s1']) 721 | sm.add_transition(s1, s3, events=['a']) 722 | s3.add_transition(s31, s32, events=['b']) 723 | sm.initialize() 724 | 725 | assert sm.state == s1 726 | assert sm.leaf_state == s1 727 | 728 | sm.dispatch(_e('s1->s2')) 729 | assert sm.state == s2 730 | assert sm.leaf_state == s2 731 | assert list(sm.state_stack.deque) == [s1] 732 | 733 | sm.dispatch(_e('s2->s1')) 734 | assert sm.state == s1 735 | assert sm.leaf_state == s1 736 | assert list(sm.state_stack.deque) == [s1, s2] 737 | 738 | sm.dispatch(_e('a')) 739 | assert sm.state == s3 740 | assert sm.leaf_state == s31 741 | assert s3.leaf_state == s31 742 | assert list(sm.state_stack.deque) == [s1, s2, s1] 743 | assert list(s3.state_stack.deque) == [] 744 | 745 | sm.dispatch(_e('b')) 746 | assert sm.state == s3 747 | assert sm.leaf_state == s32 748 | assert s3.state == s32 749 | assert s3.leaf_state == s32 750 | assert list(sm.state_stack.deque) == [s1, s2, s1] 751 | assert list(s3.state_stack.deque) == [s31] 752 | 753 | # # Brute force rollback of the previous state 754 | # # In fact, it's illegal to break the state machine integrity like this. 755 | # s3.state = s3.state_stack.pop() 756 | # sm.state = sm.state_stack.pop() 757 | # assert sm.state == s1 758 | # assert sm.leaf_state == s1 759 | # assert s3.state == s31 760 | # assert s3.leaf_state == s31 761 | # assert list(sm.state_stack.deque) == [s1, s2] 762 | # assert list(s3.state_stack.deque) == [] 763 | 764 | 765 | def test_state_stack_high_tree(): 766 | sm = StateMachine('sm') 767 | s0 = StateMachine('s0') 768 | s01 = StateMachine('s01') 769 | s011 = StateMachine('s011') 770 | s0111 = State('s0111') 771 | s0112 = State('s0112') 772 | s0113 = StateMachine('s0113') 773 | s01131 = State('s01131') 774 | 775 | sm.add_state(s0, initial=True) 776 | s0.add_state(s01, initial=True) 777 | s01.add_state(s011, initial=True) 778 | s011.add_state(s0111, initial=True) 779 | s011.add_state(s0112) 780 | s011.add_state(s0113) 781 | s0113.add_state(s01131, initial=True) 782 | 783 | s011.add_transition(s0111, s0113, events=['a']) 784 | sm.initialize() 785 | 786 | assert sm.leaf_state == s0111 787 | sm.dispatch(_e('a')) 788 | assert list(sm.state_stack.deque) == [] 789 | assert list(s0.state_stack.deque) == [] 790 | assert list(s01.state_stack.deque) == [] 791 | assert list(s011.state_stack.deque) == [s0111] 792 | assert list(s011.leaf_state_stack.deque) == [] 793 | assert list(s0113.state_stack.deque) == [] 794 | assert list(sm.leaf_state_stack.deque) == [s0111] 795 | 796 | 797 | def test_stack(): 798 | stack = Stack() 799 | stack.push(1) 800 | stack.push(2) 801 | stack.push(3) 802 | assert repr(stack) == '[1, 2, 3]' 803 | assert stack.peek() == 3 804 | assert repr(stack) == '[1, 2, 3]' 805 | assert stack.pop() == 3 806 | assert repr(stack) == '[1, 2]' 807 | assert stack.pop() == 2 808 | assert repr(stack) == '[1]' 809 | assert stack.pop() == 1 810 | assert repr(stack) == '[]' 811 | with pytest.raises(IndexError) as exc: 812 | stack.pop() 813 | expected = ('pop from an empty deque') 814 | assert expected in str(exc.value) 815 | 816 | tiny_stack = Stack(maxlen=2) 817 | tiny_stack.push(1) 818 | assert repr(tiny_stack) == '[1]' 819 | tiny_stack.push(2) 820 | assert repr(tiny_stack) == '[1, 2]' 821 | tiny_stack.push(3) 822 | assert repr(tiny_stack) == '[2, 3]' 823 | 824 | 825 | def test_transition_from_and_to_machine_itself(): 826 | sm = StateMachine('sm') 827 | s1 = State('s1') 828 | s2 = State('s2') 829 | sm.add_state(s1, initial=True) 830 | sm.add_state(s2) 831 | 832 | with pytest.raises(StateMachineException) as exc: 833 | sm.add_transition(sm, s1, events=['sm->s1']) 834 | expected = ( 835 | 'Machine "sm" error: Unable to add transition from unknown state "sm"') 836 | assert expected in str(exc.value) 837 | sm.add_transition(s1, sm, events=['s1->sm']) 838 | sm.initialize() 839 | 840 | assert sm.state == s1 841 | sm.dispatch(_e('sm->s1')) 842 | assert sm.state == s1 843 | assert list(sm.state_stack.deque) == [] 844 | sm.dispatch(_e('s1->sm')) 845 | assert sm.state == s1 846 | assert list(sm.state_stack.deque) == [s1] 847 | sm.dispatch(_e('sm->s1')) 848 | assert sm.state == s1 849 | assert list(sm.state_stack.deque) == [s1] 850 | sm.dispatch(_e('s1->sm')) 851 | assert sm.state == s1 852 | assert list(sm.state_stack.deque) == [s1, s1] 853 | 854 | 855 | def test_add_transition_event_with_input(): 856 | sm = StateMachine('sm') 857 | s1 = State('s1') 858 | s2 = State('s2') 859 | s3 = State('s3') 860 | sm.add_state(s1, initial=True) 861 | sm.add_state(s2) 862 | sm.add_state(s3) 863 | 864 | sm.add_transition(s1, s2, events=['a'], input=['go_to_s2']) 865 | sm.add_transition(s1, s3, events=['a'], input=['go_to_s3']) 866 | sm.add_transition(s2, s1, events=['a']) 867 | sm.add_transition(s3, s1, events=['a']) 868 | sm.initialize() 869 | 870 | assert sm.state == s1 871 | sm.dispatch(_e('a', input='go_to_s2')) 872 | assert sm.state == s2 873 | sm.dispatch(_e('a')) 874 | assert sm.state == s1 875 | sm.dispatch(_e('a', input='go_to_s3')) 876 | assert sm.state == s3 877 | sm.dispatch(_e('a')) 878 | assert sm.state == s1 879 | 880 | 881 | def test_event_repr(): 882 | data = {'data_key': 'data_value'} 883 | event = _e('test_event', input='test_input', key='value', data=data) 884 | expected_1 = (" 0 1419 | deque = patch_deque(MockMicropythonDequeModule(deque)) 1420 | assert repr(deque).find('collections.deque') < 0 1421 | assert repr(deque).find('deque_maxlen') > 0 1422 | stack = Stack() 1423 | stack.deque = deque() 1424 | assert repr(stack) == '[]' 1425 | stack.push(1) 1426 | assert repr(stack) == '[1]' 1427 | stack.pop() 1428 | assert repr(stack) == '[]' 1429 | stack.push('Mary') 1430 | stack.push('had') 1431 | stack.push('a') 1432 | stack.push('little') 1433 | stack.push('lamb') 1434 | assert repr(stack) == "['Mary', 'had', 'a', 'little', 'lamb']" 1435 | assert stack.pop() == 'lamb' 1436 | assert stack.pop() == 'little' 1437 | assert stack.pop() == 'a' 1438 | assert stack.pop() == 'had' 1439 | assert stack.pop() == 'Mary' 1440 | 1441 | with pytest.raises(IndexError) as exc: 1442 | stack.pop() 1443 | 1444 | with pytest.raises(IndexError) as exc: 1445 | stack.peek() 1446 | expected = 'deque index out of range' 1447 | assert expected in str(exc.value) 1448 | 1449 | assert repr(stack) == '[]' 1450 | 1451 | stack.push('Mary') 1452 | assert repr(stack) == "['Mary']" 1453 | 1454 | assert stack.peek() == "Mary" 1455 | assert repr(stack) == "['Mary']" 1456 | 1457 | finally: 1458 | deque = old_deque 1459 | 1460 | 1461 | def test_micropython_deque_maxlen_exceeded(): 1462 | from collections import deque 1463 | from pysm.pysm import patch_deque 1464 | from pysm import Stack 1465 | 1466 | class MockMicropythonDequeModule(object): 1467 | def __init__(self, deque): 1468 | self.deque = deque 1469 | 1470 | try: 1471 | old_deque = deque 1472 | assert repr(deque).find('collections.deque') > 0 1473 | deque = patch_deque(MockMicropythonDequeModule(deque)) 1474 | assert repr(deque).find('collections.deque') < 0 1475 | assert repr(deque).find('deque_maxlen') > 0 1476 | stack = Stack() 1477 | stack.deque = deque(maxlen=2) 1478 | assert repr(stack) == '[]' 1479 | assert bool(stack.deque) is False 1480 | stack.push(1) 1481 | assert repr(stack) == '[1]' 1482 | assert bool(stack.deque) is True 1483 | stack.pop() 1484 | assert repr(stack) == '[]' 1485 | assert bool(stack.deque) is False 1486 | stack.push('Mary') 1487 | stack.push('had') 1488 | assert repr(stack) == "['Mary', 'had']" 1489 | stack.push('a') 1490 | assert repr(stack) == "['had', 'a']" 1491 | stack.push('little') 1492 | assert repr(stack) == "['a', 'little']" 1493 | stack.push('lamb') 1494 | assert repr(stack) == "['little', 'lamb']" 1495 | assert stack.pop() == 'lamb' 1496 | assert stack.pop() == 'little' 1497 | 1498 | with pytest.raises(IndexError) as exc: 1499 | stack.pop() 1500 | 1501 | with pytest.raises(IndexError) as exc: 1502 | stack.peek() 1503 | expected = 'deque index out of range' 1504 | assert expected in str(exc.value) 1505 | 1506 | assert repr(stack) == '[]' 1507 | 1508 | stack.push('Mary') 1509 | assert repr(stack) == "['Mary']" 1510 | 1511 | assert stack.peek() == "Mary" 1512 | assert repr(stack) == "['Mary']" 1513 | 1514 | finally: 1515 | deque = old_deque 1516 | 1517 | 1518 | 1519 | def test_leaf_state_from_action_method(): 1520 | class TestSM(object): 1521 | def __init__(self): 1522 | self.state_1 = None 1523 | self.state_2 = None 1524 | self.sm = self._get_state_machine() 1525 | 1526 | def _get_state_machine(self): 1527 | state_machine = StateMachine('Test') 1528 | state_1 = State('One') 1529 | state_2 = State('Two') 1530 | 1531 | self.state_1 = state_1 1532 | self.state_2 = state_2 1533 | 1534 | state_machine.add_state(state_1, initial=True) 1535 | state_machine.add_state(state_2) 1536 | state_machine.add_transition(state_1, state_2, events=['change_state']) 1537 | state_machine.add_transition(state_2, state_1, events=['change_state']) 1538 | state_1.handlers = { 1539 | 'enter': self.entry_func, 1540 | 'exit': self.exit_func 1541 | } 1542 | state_2.handlers = { 1543 | 'enter': self.entry_func, 1544 | 'exit': self.exit_func 1545 | } 1546 | 1547 | state_machine.initialize() 1548 | return state_machine 1549 | 1550 | def entry_func(self, state, event): 1551 | if state is self.state_1: 1552 | assert self.sm.leaf_state == self.state_1 1553 | else: 1554 | assert self.sm.leaf_state == self.state_2 1555 | 1556 | def exit_func(self, state, event): 1557 | if state is self.state_1: 1558 | assert self.sm.leaf_state == self.state_1 1559 | else: 1560 | assert self.sm.leaf_state == self.state_2 1561 | 1562 | def test(self): 1563 | assert self.sm.leaf_state == self.state_1 1564 | self.sm.dispatch(Event('change_state')) 1565 | assert self.sm.leaf_state == self.state_2 1566 | self.sm.dispatch(Event('change_state')) 1567 | assert self.sm.leaf_state == self.state_1 1568 | self.sm.dispatch(Event('change_state')) 1569 | assert self.sm.leaf_state == self.state_2 1570 | 1571 | TEST_SM = TestSM() 1572 | TEST_SM.test() 1573 | -------------------------------------------------------------------------------- /test/test_rpn.py: -------------------------------------------------------------------------------- 1 | import string as py_string 2 | from pysm import StateMachine, Event, State 3 | 4 | 5 | class Calculator(object): 6 | def __init__(self): 7 | self.sm = self.get_state_machine() 8 | self.result = None 9 | 10 | def get_state_machine(self): 11 | sm = StateMachine('sm') 12 | initial = State('Initial') 13 | number = State('BuildingNumber') 14 | sm.add_state(initial, initial=True) 15 | sm.add_state(number) 16 | sm.add_transition(initial, number, 17 | events=['parse'], input=py_string.digits, 18 | action=self.start_building_number) 19 | sm.add_transition(number, None, 20 | events=['parse'], input=py_string.digits, 21 | action=self.build_number) 22 | sm.add_transition(number, initial, 23 | events=['parse'], input=py_string.whitespace) 24 | sm.add_transition(initial, None, 25 | events=['parse'], input='+-*/', 26 | action=self.do_operation) 27 | sm.add_transition(initial, None, 28 | events=['parse'], input='=', 29 | action=self.do_equal) 30 | sm.initialize() 31 | return sm 32 | 33 | def parse(self, string): 34 | for char in string: 35 | self.sm.dispatch(Event('parse', input=char)) 36 | 37 | def calculate(self, string): 38 | self.parse(string) 39 | return self.result 40 | 41 | def start_building_number(self, state, event): 42 | digit = event.input 43 | self.sm.stack.push(int(digit)) 44 | return True 45 | 46 | def build_number(self, state, event): 47 | digit = event.input 48 | number = str(self.sm.stack.pop()) 49 | number += digit 50 | self.sm.stack.push(int(number)) 51 | return True 52 | 53 | def do_operation(self, state, event): 54 | operation = event.input 55 | y = self.sm.stack.pop() 56 | x = self.sm.stack.pop() 57 | # eval is evil 58 | result = eval('float({0}) {1} float({2})'.format(x, operation, y)) 59 | self.sm.stack.push(result) 60 | return True 61 | 62 | def do_equal(self, state, event): 63 | operation = event.input 64 | number = self.sm.stack.pop() 65 | self.result = number 66 | return True 67 | 68 | 69 | def test_calc_callbacks(): 70 | calc = Calculator() 71 | assert calc.calculate(' 167 3 2 2 * * * 1 - =') == 2003 72 | assert calc.calculate(' 167 3 2 2 * * * 1 - 2 / =') == 1001.5 73 | assert calc.calculate(' 3 5 6 + * =') == 33 74 | assert calc.calculate(' 3 4 + =') == 7 75 | assert calc.calculate('2 4 / 5 6 - * =') == -0.5 76 | 77 | if __name__ == '__main__': 78 | test_calc_callbacks() 79 | -------------------------------------------------------------------------------- /test/test_simple_on_off.py: -------------------------------------------------------------------------------- 1 | from pysm import State, StateMachine, Event 2 | 3 | on = State('on') 4 | off = State('off') 5 | 6 | sm = StateMachine('sm') 7 | sm.add_state(on, initial=True) 8 | sm.add_state(off) 9 | 10 | sm.add_transition(on, off, events=['off']) 11 | sm.add_transition(off, on, events=['on']) 12 | 13 | sm.initialize() 14 | 15 | def test(): 16 | assert sm.state == on 17 | sm.dispatch(Event('off')) 18 | assert sm.state == off 19 | sm.dispatch(Event('on')) 20 | assert sm.state == on 21 | 22 | 23 | if __name__ == '__main__': 24 | test() 25 | -------------------------------------------------------------------------------- /test/test_string_parsing.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # Test based on the code from: 4 | # http://code.activestate.com/recipes/\ 5 | # 578344-simple-finite-state-machine-class-v2/ 6 | 7 | import string 8 | 9 | from pysm import State, StateMachine, Event 10 | 11 | 12 | def char(event): 13 | return event.cargo['char'] 14 | 15 | 16 | # Simple storage object for each token 17 | class token(object): 18 | def __init__(self, type): 19 | self.tokenType = type 20 | self.tokenText = "" 21 | 22 | def addCharacter(self, char): 23 | self.tokenText += char 24 | 25 | def __repr__(self): 26 | return "{0}<{1}>".format(self.tokenType, self.tokenText) 27 | 28 | 29 | # Token list object - demonstrating the definition of state machine callbacks 30 | class tokenList(object): 31 | def __init__(self): 32 | self.tokenList = [] 33 | self.currentToken = None 34 | 35 | def StartToken(self, state, event): 36 | value = event.name 37 | self.currentToken = token(state.name) 38 | self.currentToken.addCharacter(value) 39 | 40 | def addCharacter(self, state, event): 41 | value = event.name 42 | self.currentToken.addCharacter(value) 43 | 44 | def EndToken(self, state, event): 45 | value = event.name 46 | self.tokenList.append(self.currentToken) 47 | self.currentToken = None 48 | 49 | 50 | class Parser(object): 51 | def __init__(self, t): 52 | self.t = t 53 | self.sm = None 54 | self.init_sm() 55 | 56 | def init_sm(self): 57 | start = State('Start') 58 | identifier = State('Identifier') 59 | operator = State('Operator') 60 | number = State('Number') 61 | start_quote = State('StartQuote') 62 | string_st = State('String') 63 | end_quote = State('EndQuote') 64 | 65 | sm = StateMachine(self) 66 | sm.add_states( 67 | start, identifier, operator, number, start_quote, string_st, 68 | end_quote) 69 | sm.set_initial_state(start) 70 | 71 | # # This works also after a minor tweak in callback functions 72 | # transitions = [ 73 | # (start, start, lambda e: char(e).isspace()), 74 | # (start, identifier, 75 | # lambda e: char(e).isalpha(), self.t.StartToken), 76 | # (start, operator, 77 | # lambda e: char(e) in "=+*/-()", self.t.StartToken), 78 | # (start, number, lambda e: char(e).isdigit(), self.t.StartToken), 79 | # (start, start_quote, lambda e: char(e) == "\'"), 80 | # (start_quote, string, 81 | # lambda e: char(e) != "\'", self.t.StartToken), 82 | # (identifier, identifier, 83 | # lambda e: char(e).isalnum(), self.t.addCharacter ), 84 | # (identifier, start, 85 | # lambda e: not char(e).isalnum(), self.t.EndToken ), 86 | # (operator, start, lambda e: True, self.t.EndToken), 87 | # (number, number, 88 | # lambda e: char(e).isdigit() or char(e) == ".", 89 | # self.t.addCharacter), 90 | # (number, start, 91 | # lambda e: not char(e).isdigit() and char(e) != ".", 92 | # self.t.EndToken ), 93 | # (string, string, lambda e: char(e) != "\'", self.t.addCharacter), 94 | # (string, end_quote, lambda e: char(e) == "\'", self.t.EndToken ), 95 | # (end_quote, start, lambda e: True) 96 | # ] 97 | 98 | # for transition in transitions: 99 | # from_state = transition[0] 100 | # to_state = transition[1] 101 | # condition = transition[2] 102 | # action = transition[3] if len(transition) == 4 else None 103 | # events = ['on_char'] 104 | 105 | # sm.add_transition(from_state, to_state, events=events, 106 | # condition=condition, action=action) 107 | 108 | alnum = string.ascii_letters + string.digits 109 | not_alnum = ''.join(set(string.printable) 110 | - set(string.ascii_letters + string.digits)) 111 | not_quote = ''.join(set(string.printable) - set(["'"])) 112 | not_digit_or_dot = ''.join(set(string.printable) 113 | - set(string.digits) - set(['.'])) 114 | digits_and_dot = string.digits + '.' 115 | 116 | at = sm.add_transition 117 | at(start, start, events=string.whitespace) 118 | at(start, identifier, 119 | events=string.ascii_letters, after=self.t.StartToken) 120 | at(start, operator, events='=+*/-()', after=self.t.StartToken) 121 | at(start, number, events=string.digits, after=self.t.StartToken) 122 | at(start, start_quote, events="'", after=self.t.StartToken) 123 | at(identifier, identifier, events=alnum, action=self.t.addCharacter) 124 | at(identifier, start, events=not_alnum, action=self.t.EndToken) 125 | at(start_quote, string_st, events=not_quote, after=self.t.StartToken) 126 | at(operator, start, events=string.printable, action=self.t.EndToken) 127 | at(number, number, events=digits_and_dot, action=self.t.addCharacter) 128 | at(number, start, events=not_digit_or_dot, action=self.t.EndToken) 129 | at(string_st, string_st, events=not_quote, action=self.t.addCharacter) 130 | at(string_st, end_quote, events="'", action=self.t.EndToken) 131 | at(end_quote, start, events=string.printable) 132 | 133 | sm.initialize() 134 | self.sm = sm 135 | 136 | def on_char(self, char): 137 | self.sm.dispatch(Event(char)) 138 | 139 | def parse(self, text): 140 | for char in text: 141 | self.on_char(char) 142 | 143 | 144 | def test(): 145 | text = " x123 = MyString + 123.65 - 'hello' * value " 146 | t = tokenList() 147 | parser = Parser(t) 148 | parser.parse(text) 149 | expected = ('[Identifier, Operator<=>, Identifier, ' 150 | 'Operator<+>, Number<123.65>, Operator<->, String, ' 151 | 'Operator<*>, Identifier]') 152 | assert str(t.tokenList) == expected 153 | 154 | if __name__ == '__main__': 155 | test() 156 | --------------------------------------------------------------------------------