├── .editorconfig ├── .github └── workflows │ └── check.yaml ├── .gitignore ├── AUTHORS.rst ├── CONTRIBUTING.rst ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── docs ├── Makefile ├── authors.rst ├── conf.py ├── contributing.rst ├── history.rst ├── index.rst ├── installation.rst ├── make.bat ├── readme.rst └── usage.rst ├── requirements_dev.txt ├── setup.cfg ├── setup.py ├── statechart ├── __init__.py ├── display.py ├── event.py ├── pseudostates.py ├── runtime.py ├── states.py └── transitions.py ├── tests ├── __init__.py ├── test_display.py ├── test_event.py ├── test_pseudostate.py ├── test_runtime.py ├── test_states.py └── test_transitions.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | name: Python CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | python-version: 12 | - "3.13" 13 | - "3.12" 14 | - "3.11" 15 | - "3.10" 16 | - "3.9" 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v4 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install the latest version of pip 26 | run: python -m pip install --upgrade pip 27 | - name: Install project dependencies 28 | run: pip install -r requirements_dev.txt 29 | - name: Run tests 30 | run: tox 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | 104 | # IDE settings 105 | .vscode/ 106 | .idea/ 107 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Leigh McKenzie 9 | 10 | Contributors 11 | ------------ 12 | 13 | None yet. Why not be the first? 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every little bit 8 | helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/leighmck/statechart/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 30 | wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | Statechart could always use more documentation, whether as part of the 42 | official Statechart docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at https://github.com/leighmck/statechart/issues. 49 | 50 | If you are proposing a feature: 51 | 52 | * Explain in detail how it would work. 53 | * Keep the scope as narrow as possible, to make it easier to implement. 54 | * Remember that this is a volunteer-driven project, and that contributions 55 | are welcome :) 56 | 57 | Get Started! 58 | ------------ 59 | 60 | Ready to contribute? Here's how to set up `statechart` for local development. 61 | 62 | 1. Fork the `statechart` repo on GitHub. 63 | 2. Clone your fork locally:: 64 | 65 | $ git clone git@github.com:your_name_here/statechart.git 66 | 67 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 68 | 69 | $ mkvirtualenv statechart 70 | $ cd statechart/ 71 | $ python setup.py develop 72 | 73 | 4. Create a branch for local development:: 74 | 75 | $ git checkout -b name-of-your-bugfix-or-feature 76 | 77 | Now you can make your changes locally. 78 | 79 | 5. When you're done making changes, check that your changes pass flake8 and the 80 | tests, including testing other Python versions with tox:: 81 | 82 | $ flake8 statechart tests 83 | $ python setup.py test or pytest 84 | $ tox 85 | 86 | To get flake8 and tox, just pip install them into your virtualenv. 87 | 88 | 6. Commit your changes and push your branch to GitHub:: 89 | 90 | $ git add . 91 | $ git commit -m "Your detailed description of your changes." 92 | $ git push origin name-of-your-bugfix-or-feature 93 | 94 | 7. Submit a pull request through the GitHub website. 95 | 96 | Pull Request Guidelines 97 | ----------------------- 98 | 99 | Before you submit a pull request, check that it meets these guidelines: 100 | 101 | 1. The pull request should include tests. 102 | 2. If the pull request adds functionality, the docs should be updated. Put 103 | your new functionality into a function with a docstring, and add the 104 | feature to the list in README.rst. 105 | 3. The pull request should work for Python 3.9, 3.10, 3.11, 3.12, 3.13 106 | and for PyPy. Check https://travis-ci.com/leighmck/statechart/pull_requests 107 | and make sure that the tests pass for all supported Python versions. 108 | 109 | Tips 110 | ---- 111 | 112 | To run a subset of tests:: 113 | 114 | $ pytest tests.test_statechart 115 | 116 | 117 | Deploying 118 | --------- 119 | 120 | A reminder for the maintainers on how to deploy. 121 | Make sure all your changes are committed (including an entry in HISTORY.rst). 122 | Then run:: 123 | 124 | $ bump2version patch # possible: major / minor / patch 125 | $ git push 126 | $ git push --tags 127 | 128 | Travis will then deploy to PyPI if tests pass. 129 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | History 3 | ======= 4 | 5 | 0.2.0 (2016-08-02) 6 | ------------------ 7 | 8 | * First release on PyPI. 9 | 10 | 0.2.1 (2016-08-07) 11 | ------------------ 12 | 13 | * Final state bug fixes. 14 | 15 | 0.2.2 (2016-08-08) 16 | ------------------ 17 | 18 | * Default transition bug fix. 19 | 20 | 0.2.3 (2016-08-10) 21 | ------------------ 22 | 23 | * Consume event dispatched by child state unless a final state activated. 24 | 25 | 0.2.4 (2016-08-21) 26 | ------------------ 27 | 28 | * Fix internal transition acting like local transition. 29 | 30 | 0.3.0 (2016-10-16) 31 | ------------------ 32 | 33 | * Implement display module to generate Plant UML code of a statechart. 34 | * Raise runtime exception if an action is defined on top level statechart. 35 | 36 | 0.3.1 (2016-10-16) 37 | ------------------ 38 | 39 | * Implement specific statechart deactivate function. 40 | 41 | 0.4.0 (2019-05-18) 42 | ------------------ 43 | 44 | * Add support for functional action and guard definitions. 45 | * Deprecate KwEvent, Internal Transitions, Actions and Guard 46 | * Add support for generating PlantUML diagrams. 47 | 48 | 0.4.2 (2019-08-15) 49 | ------------------ 50 | 51 | * Fix display of guard function names in PlantUML diagrams. 52 | * Allow any type of value to be used for event data. 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2016, Leigh McKenzie 4 | All rights reserved. 5 | 6 | Permission to use, copy, modify, and/or distribute this software for any 7 | purpose with or without fee is hereby granted, provided that the above 8 | copyright notice and this permission notice appear in all copies. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 13 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 15 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 16 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 17 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | 7 | recursive-include tests * 8 | recursive-exclude * __pycache__ 9 | recursive-exclude * *.py[co] 10 | 11 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-build clean-pyc clean-test coverage dist docs help install lint lint/flake8 lint/black 2 | .DEFAULT_GOAL := help 3 | 4 | define BROWSER_PYSCRIPT 5 | import os, webbrowser, sys 6 | 7 | from urllib.request import pathname2url 8 | 9 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 10 | endef 11 | export BROWSER_PYSCRIPT 12 | 13 | define PRINT_HELP_PYSCRIPT 14 | import re, sys 15 | 16 | for line in sys.stdin: 17 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 18 | if match: 19 | target, help = match.groups() 20 | print("%-20s %s" % (target, help)) 21 | endef 22 | export PRINT_HELP_PYSCRIPT 23 | 24 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 25 | 26 | help: 27 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 28 | 29 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 30 | 31 | clean-build: ## remove build artifacts 32 | rm -fr build/ 33 | rm -fr dist/ 34 | rm -fr .eggs/ 35 | find . -name '*.egg-info' -exec rm -fr {} + 36 | find . -name '*.egg' -exec rm -f {} + 37 | 38 | clean-pyc: ## remove Python file artifacts 39 | find . -name '*.pyc' -exec rm -f {} + 40 | find . -name '*.pyo' -exec rm -f {} + 41 | find . -name '*~' -exec rm -f {} + 42 | find . -name '__pycache__' -exec rm -fr {} + 43 | 44 | clean-test: ## remove test and coverage artifacts 45 | rm -fr .tox/ 46 | rm -f .coverage 47 | rm -fr htmlcov/ 48 | rm -fr .pytest_cache 49 | 50 | lint/flake8: ## check style with flake8 51 | flake8 statechart tests 52 | lint/black: ## check style with black 53 | black --check statechart tests 54 | 55 | lint: lint/flake8 lint/black ## check style 56 | 57 | test: ## run tests quickly with the default Python 58 | pytest 59 | 60 | test-all: ## run tests on every Python version with tox 61 | tox 62 | 63 | coverage: ## check code coverage quickly with the default Python 64 | coverage run --source statechart -m pytest 65 | coverage report -m 66 | coverage html 67 | $(BROWSER) htmlcov/index.html 68 | 69 | docs: ## generate Sphinx HTML documentation, including API docs 70 | rm -f docs/statechart.rst 71 | rm -f docs/modules.rst 72 | sphinx-apidoc -o docs/ statechart 73 | $(MAKE) -C docs clean 74 | $(MAKE) -C docs html 75 | $(BROWSER) docs/_build/html/index.html 76 | 77 | servedocs: docs ## compile the docs watching for changes 78 | watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . 79 | 80 | release: dist ## package and upload a release 81 | twine upload dist/* 82 | 83 | dist: clean ## builds source and wheel package 84 | python setup.py sdist 85 | python setup.py bdist_wheel 86 | ls -l dist 87 | 88 | install: clean ## install the package to the active Python's site-packages 89 | python setup.py install 90 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | Python Statechart 3 | ================= 4 | 5 | 6 | .. image:: https://img.shields.io/pypi/v/statechart.svg 7 | :target: https://pypi.python.org/pypi/statechart 8 | 9 | .. image:: https://github.com/leighmck/statechart/actions/workflows/check.yaml/badge.svg 10 | :target: https://github.com/leighmck/statechart/actions/workflows/check.yaml 11 | :alt: Python CI 12 | 13 | .. image:: https://readthedocs.org/projects/statechart/badge/?version=latest 14 | :target: https://statechart.readthedocs.io/en/latest/?version=latest 15 | :alt: Documentation Status 16 | 17 | Python UML statechart framework 18 | 19 | * Free software: ISC license 20 | * Documentation: https://statechart.readthedocs.org. 21 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = statechart 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # statechart documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Jul 9 22:26:36 2013. 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 16 | # directory, add these directories to sys.path here. If the directory is 17 | # relative to the documentation root, use os.path.abspath to make it 18 | # absolute, like shown here. 19 | # 20 | import os 21 | import sys 22 | sys.path.insert(0, os.path.abspath('..')) 23 | 24 | import statechart 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 ones. 34 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ['_templates'] 38 | 39 | # The suffix(es) of source filenames. 40 | # You can specify multiple suffix as a list of string: 41 | # 42 | # source_suffix = ['.rst', '.md'] 43 | source_suffix = '.rst' 44 | 45 | # The master toctree document. 46 | master_doc = 'index' 47 | 48 | # General information about the project. 49 | project = 'statechart' 50 | copyright = "2016, Leigh McKenzie" 51 | author = "Leigh McKenzie" 52 | 53 | # The version info for the project you're documenting, acts as replacement 54 | # for |version| and |release|, also used in various other places throughout 55 | # the built documents. 56 | # 57 | # The short X.Y version. 58 | version = statechart.__version__ 59 | # The full version, including alpha/beta/rc tags. 60 | release = statechart.__version__ 61 | 62 | # The language for content autogenerated by Sphinx. Refer to documentation 63 | # for a list of supported languages. 64 | # 65 | # This is also used if you do content translation via gettext catalogs. 66 | # Usually you set "language" from the command line for these cases. 67 | language = None 68 | 69 | # List of patterns, relative to source directory, that match files and 70 | # directories to ignore when looking for source files. 71 | # This patterns also effect to html_static_path and html_extra_path 72 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 73 | 74 | # The name of the Pygments (syntax highlighting) style to use. 75 | pygments_style = 'sphinx' 76 | 77 | # If true, `todo` and `todoList` produce output, else they produce nothing. 78 | todo_include_todos = False 79 | 80 | 81 | # -- Options for HTML output ------------------------------------------- 82 | 83 | # The theme to use for HTML and HTML Help pages. See the documentation for 84 | # a list of builtin themes. 85 | # 86 | html_theme = 'alabaster' 87 | 88 | # Theme options are theme-specific and customize the look and feel of a 89 | # theme further. For a list of options available for each theme, see the 90 | # documentation. 91 | # 92 | # html_theme_options = {} 93 | 94 | # Add any paths that contain custom static files (such as style sheets) here, 95 | # relative to this directory. They are copied after the builtin static files, 96 | # so a file named "default.css" will overwrite the builtin "default.css". 97 | html_static_path = ['_static'] 98 | 99 | 100 | # -- Options for HTMLHelp output --------------------------------------- 101 | 102 | # Output file base name for HTML help builder. 103 | htmlhelp_basename = 'statechartdoc' 104 | 105 | 106 | # -- Options for LaTeX output ------------------------------------------ 107 | 108 | latex_elements = { 109 | # The paper size ('letterpaper' or 'a4paper'). 110 | # 111 | # 'papersize': 'letterpaper', 112 | 113 | # The font size ('10pt', '11pt' or '12pt'). 114 | # 115 | # 'pointsize': '10pt', 116 | 117 | # Additional stuff for the LaTeX preamble. 118 | # 119 | # 'preamble': '', 120 | 121 | # Latex figure (float) alignment 122 | # 123 | # 'figure_align': 'htbp', 124 | } 125 | 126 | # Grouping the document tree into LaTeX files. List of tuples 127 | # (source start file, target name, title, author, documentclass 128 | # [howto, manual, or own class]). 129 | latex_documents = [ 130 | (master_doc, 'statechart.tex', 131 | 'statechart Documentation', 132 | 'Leigh McKenzie', 'manual'), 133 | ] 134 | 135 | 136 | # -- Options for manual page output ------------------------------------ 137 | 138 | # One entry per manual page. List of tuples 139 | # (source start file, name, description, authors, manual section). 140 | man_pages = [ 141 | (master_doc, 'statechart', 142 | 'statechart Documentation', 143 | [author], 1) 144 | ] 145 | 146 | 147 | # -- Options for Texinfo output ---------------------------------------- 148 | 149 | # Grouping the document tree into Texinfo files. List of tuples 150 | # (source start file, target name, title, author, 151 | # dir menu entry, description, category) 152 | texinfo_documents = [ 153 | (master_doc, 'statechart', 154 | u'Python Statechart Documentation', 155 | u'Leigh McKenzie', 156 | 'statechart', 157 | 'One line description of project.', 158 | 'Miscellaneous'), 159 | ] 160 | 161 | 162 | 163 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to statechart's documentation! 2 | ====================================== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | readme 9 | installation 10 | usage 11 | modules 12 | contributing 13 | authors 14 | history 15 | 16 | Indices and tables 17 | ================== 18 | * :ref:`genindex` 19 | * :ref:`modindex` 20 | * :ref:`search` 21 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | 8 | Stable release 9 | -------------- 10 | 11 | To install statechart, run this command in your terminal: 12 | 13 | .. code-block:: console 14 | 15 | $ pip install statechart 16 | 17 | This is the preferred method to install statechart, as it will always install the most recent stable release. 18 | 19 | If you don't have `pip`_ installed, this `Python installation guide`_ can guide 20 | you through the process. 21 | 22 | .. _pip: https://pip.pypa.io 23 | .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ 24 | 25 | 26 | From sources 27 | ------------ 28 | 29 | The sources for statechart can be downloaded from the `Github repo`_. 30 | 31 | You can either clone the public repository: 32 | 33 | .. code-block:: console 34 | 35 | $ git clone git://github.com/leighmck/statechart 36 | 37 | Or download the `tarball`_: 38 | 39 | .. code-block:: console 40 | 41 | $ curl -OJL https://github.com/leighmck/statechart/tarball/master 42 | 43 | Once you have a copy of the source, you can install it with: 44 | 45 | .. code-block:: console 46 | 47 | $ python setup.py install 48 | 49 | 50 | .. _Github repo: https://github.com/leighmck/statechart 51 | .. _tarball: https://github.com/leighmck/statechart/tarball/master 52 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=statechart 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | To use Python Statechart in a project:: 6 | 7 | import statechart 8 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | pip==23.1.2 2 | bump2version==1.0.1 3 | wheel==0.40.0 4 | watchdog==3.0.0 5 | flake8==5.0.4 6 | tox==4.5.1 7 | coverage==7.2.5 8 | Sphinx==5.3.0 9 | twine==4.0.2 10 | pytest==7.3.1 11 | black==23.3.0 12 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.5.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version='{current_version}' 8 | replace = version='{new_version}' 9 | 10 | [bumpversion:file:statechart/__init__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | 14 | [bdist_wheel] 15 | universal = 1 16 | 17 | [flake8] 18 | exclude = docs 19 | max-line-length = 120 20 | 21 | [tool:pytest] 22 | addopts = --ignore=setup.py 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """The setup script.""" 4 | 5 | from setuptools import setup, find_packages 6 | 7 | with open('README.rst') as readme_file: 8 | readme = readme_file.read() 9 | 10 | with open('HISTORY.rst') as history_file: 11 | history = history_file.read() 12 | 13 | requirements = [ ] 14 | 15 | test_requirements = ['pytest>=3', ] 16 | 17 | setup( 18 | author="Leigh McKenzie", 19 | author_email='maccarav0@gmail.com', 20 | python_requires='>=3.9', 21 | classifiers=[ 22 | 'Development Status :: 2 - Pre-Alpha', 23 | 'Intended Audience :: Developers', 24 | 'License :: OSI Approved :: ISC License (ISCL)', 25 | 'Natural Language :: English', 26 | 'Programming Language :: Python :: 3', 27 | 'Programming Language :: Python :: 3.9', 28 | 'Programming Language :: Python :: 3.10', 29 | 'Programming Language :: Python :: 3.11', 30 | 'Programming Language :: Python :: 3.12', 31 | 'Programming Language :: Python :: 3.13', 32 | ], 33 | description="Python Boilerplate contains all the boilerplate you need to create a Python package.", 34 | install_requires=requirements, 35 | license="ISC license", 36 | long_description=readme + '\n\n' + history, 37 | include_package_data=True, 38 | keywords='statechart', 39 | name='statechart', 40 | packages=find_packages(include=['statechart', 'statechart.*']), 41 | test_suite='tests', 42 | tests_require=test_requirements, 43 | url="https://github.com/leighmck/statechart", 44 | version="0.5.0", 45 | zip_safe=False, 46 | ) 47 | -------------------------------------------------------------------------------- /statechart/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2016, Leigh McKenzie 4 | # All rights reserved. 5 | # 6 | # Permission to use, copy, modify, and/or distribute this software for any 7 | # purpose with or without fee is hereby granted, provided that the above 8 | # copyright notice and this permission notice appear in all copies. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 13 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 15 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 16 | # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 17 | 18 | import logging 19 | 20 | from statechart.event import Event # NOQA 21 | from statechart.states import CompositeState, ConcurrentState, FinalState, State, Statechart # NOQA 22 | from statechart.pseudostates import ChoiceState, InitialState, ShallowHistoryState # NOQA 23 | from statechart.transitions import Transition # NOQA 24 | 25 | __author__ = 'Leigh McKenzie' 26 | __copyright__ = 'Copyright 2016, Leigh McKenzie' 27 | __email__ = 'maccarav0@gmail.com' 28 | __license__ = 'ISCL' 29 | __version__ = '0.5.0' 30 | 31 | # Set default logging handler to avoid "No handler found" warnings. 32 | try: 33 | from logging import NullHandler 34 | except ImportError: 35 | class NullHandler(logging.Handler): 36 | def emit(self, record): 37 | pass 38 | 39 | logging.getLogger(__name__).addHandler(NullHandler()) 40 | -------------------------------------------------------------------------------- /statechart/display.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2016, Leigh McKenzie 4 | # All rights reserved. 5 | # 6 | # Permission to use, copy, modify, and/or distribute this software for any 7 | # purpose with or without fee is hereby granted, provided that the above 8 | # copyright notice and this permission notice appear in all copies. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 13 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 15 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 16 | # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 17 | 18 | import uuid 19 | 20 | from statechart import (ChoiceState, ConcurrentState, CompositeState, FinalState, InitialState, 21 | ShallowHistoryState, __version__) 22 | 23 | 24 | class Display: 25 | """Generate code of statechart models for common graphing languages.""" 26 | 27 | def plantuml(self, statechart): 28 | """ 29 | Generate PlantUML code of a statechart model. 30 | 31 | Args: 32 | statechart (Statechart): Statechart model to generate Plant UML code. 33 | 34 | Returns: 35 | str: PlantUML description of the statechart. This string can be passed to a PlantUML 36 | renderer to generate statechart images. 37 | """ 38 | 39 | result = [ 40 | '@startuml', 41 | ] 42 | 43 | (states, transitions) = self.describe(state=statechart.initial_state, 44 | states=[], 45 | transitions=[]) 46 | 47 | result += self._puml_context(context=statechart, states=states) 48 | 49 | result += self._puml_transitions(transitions) 50 | 51 | result += [ 52 | 'right footer generated by statechart v%s' % __version__, 53 | '@enduml' 54 | ] 55 | 56 | return '\n'.join(result) 57 | 58 | def describe(self, state, states, transitions): 59 | """ 60 | Use a 'depth first search' to capture all states and transitions. 61 | 62 | Given a root state (typically the initial state of a statechart), explore a transition 63 | path as far as possible before backtracking to find another path. 64 | 65 | Args: 66 | statechart (Statechart): Statechart to describe. 67 | state (State): State node to branch out from. 68 | 69 | Returns: 70 | (tuple): tuple containing: 71 | states (List[State]): List of states within the statechart. 72 | transitions (List[Transition]): List of transitions within the statechart. 73 | """ 74 | state.uuid = ''.join(('node_', str(uuid.uuid4()))).replace('-', '_') 75 | states.append(state) 76 | 77 | for transition in state.transitions: 78 | transitions.append(transition) 79 | 80 | node = transition.end 81 | if node not in states: 82 | (states, transitions) = self.describe(state=node, 83 | states=states, 84 | transitions=transitions) 85 | 86 | if isinstance(node, CompositeState): 87 | (states, transitions) = self.describe(state=node.initial_state, 88 | states=states, 89 | transitions=transitions) 90 | elif isinstance(node, ConcurrentState): 91 | for region in node.regions: 92 | (states, transitions) = self.describe(state=region, 93 | states=states, 94 | transitions=transitions) 95 | (states, transitions) = self.describe(state=region.initial_state, 96 | states=states, 97 | transitions=transitions) 98 | 99 | return (states, transitions) 100 | 101 | def _gen_state_name(self, state): 102 | """ 103 | Generate state name. 104 | 105 | Generate empty name for a choice state, it is distinguished using a diamond shape instead. 106 | Generate 'H' for a Shallow History state. 107 | 108 | Extend to define state names for State subclasses. 109 | 110 | Args: 111 | state (Statae): Guard used to generate name. 112 | 113 | Returns: 114 | str: Generated state name. 115 | """ 116 | if isinstance(state, ChoiceState): 117 | return '' 118 | elif isinstance(state, ShallowHistoryState): 119 | return 'H' 120 | else: 121 | return state.name 122 | 123 | def _puml_concurrent(self, concurrent, states): 124 | """ 125 | Describe concurrent state in PlantUML syntax. 126 | 127 | Args: 128 | concurrent: Concurrent state to describe. 129 | 130 | Returns: 131 | List[str]: PlantUML description of the concurrent. 132 | """ 133 | result = [ 134 | 'state {uuid} as "{name}" {{'.format(uuid=concurrent.uuid, name=concurrent.name), 135 | ] 136 | 137 | for region in concurrent.regions: 138 | result += self._puml_context(region, states) 139 | 140 | if not region == concurrent.regions[-1]: 141 | result += [ 142 | '--' 143 | ] 144 | 145 | result += [ 146 | '}' 147 | ] 148 | 149 | return result 150 | 151 | def _puml_composite(self, composite, states): 152 | """ 153 | Describe composite state in PlantUML syntax. 154 | 155 | Args: 156 | composite: Composite state to describe. 157 | 158 | Returns: 159 | List[str]: PlantUML description of the composite. 160 | """ 161 | result = [ 162 | 'state {uuid} as "{name}" {{'.format(uuid=composite.uuid, name=composite.name), 163 | ] 164 | 165 | result += self._puml_context(composite, states) 166 | 167 | result += [ 168 | '}' 169 | ] 170 | 171 | return result 172 | 173 | def _puml_context(self, context, states): 174 | """ 175 | Describe context in PlantUML syntax. 176 | 177 | Args: 178 | context: Context to describe. 179 | 180 | Returns: 181 | List[str]: PlantUML description of the context. 182 | """ 183 | result = [] 184 | 185 | for state in states: 186 | if state.context is context: 187 | if isinstance(state, CompositeState): 188 | result += self._puml_composite(composite=state, states=states) 189 | elif isinstance(state, ConcurrentState): 190 | result += self._puml_concurrent(concurrent=state, states=states) 191 | else: 192 | result += self._puml_state(state) 193 | 194 | for transition in state.transitions: 195 | if isinstance(transition.end, FinalState): 196 | result += self._puml_transition(transition) 197 | 198 | result += [ 199 | '[*] --> {uuid}'.format(uuid=context.initial_state.transitions[0].end.uuid), 200 | ] 201 | 202 | return result 203 | 204 | def _puml_state(self, state): 205 | """ 206 | Describe state in PlantUML syntax. 207 | 208 | Args: 209 | state: State to describe. 210 | 211 | Returns: 212 | List[str]: PlantUML description of the state. 213 | """ 214 | result = [] 215 | 216 | if isinstance(state, InitialState) or isinstance(state, FinalState): 217 | pass 218 | elif isinstance(state, ChoiceState): 219 | result += [ 220 | 'state {uuid} <>'.format(uuid=state.uuid) 221 | ] 222 | else: 223 | result += [ 224 | 'state {uuid} as "{name}"'.format(uuid=state.uuid, name=self._gen_state_name(state)) 225 | ] 226 | 227 | return result 228 | 229 | def _puml_transition(self, transition): 230 | """ 231 | Describe transition in PlantUML syntax. 232 | 233 | Args: 234 | transition: Transition to describe. 235 | 236 | Returns: 237 | List[str]: PlantUML description of the transition. 238 | """ 239 | result = [] 240 | 241 | if isinstance(transition.start, InitialState): 242 | start = '[*]' 243 | end = transition.end.uuid 244 | elif isinstance(transition.end, FinalState): 245 | start = transition.start.uuid 246 | end = '[*]' 247 | else: 248 | start = transition.start.uuid 249 | end = transition.end.uuid 250 | 251 | result += [ 252 | '{start} --> {end}{div}{event}{guard}{action}'.format( 253 | start=start, 254 | end=end, 255 | div=' :' if transition.event or transition.action or transition.guard else '', 256 | event=' {}'.format(transition.event.name) if transition.event else '', 257 | guard=' [{}]'.format(transition.guard.__name__) if transition.guard else '', 258 | action='{}'.format(' / ' + transition.action.__name__ if transition.action else '') 259 | ) 260 | ] 261 | 262 | return result 263 | 264 | def _puml_transitions(self, transitions): 265 | """ 266 | Describe all transitions in PlantUML syntax. 267 | 268 | Args: 269 | transitions: Transitions to describe. 270 | 271 | Returns: 272 | List[str]: PlantUML description of the transitions. 273 | """ 274 | result = [] 275 | 276 | for transition in transitions: 277 | if isinstance(transition.start, InitialState): 278 | continue 279 | 280 | if isinstance(transition.end, FinalState): 281 | continue 282 | 283 | result += self._puml_transition(transition) 284 | 285 | return result 286 | -------------------------------------------------------------------------------- /statechart/event.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2016, Leigh McKenzie 4 | # All rights reserved. 5 | # 6 | # Permission to use, copy, modify, and/or distribute this software for any 7 | # purpose with or without fee is hereby granted, provided that the above 8 | # copyright notice and this permission notice appear in all copies. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 13 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 15 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 16 | # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 17 | 18 | import logging 19 | 20 | 21 | class Event: 22 | """ 23 | An event is a specification of a type of observable occurrence. The 24 | occurrence that generates an event instance is assumed to take place at an 25 | instant in time with no duration. 26 | 27 | Example: 28 | Create an instance of an event: 29 | my_event = Event(name='my event') 30 | 31 | Add the event trigger to a transition: 32 | Transition(start=a, end=b, event=my_event) 33 | 34 | Fire the event: 35 | statechart.dispatch(event=my_event) 36 | 37 | If the current state has an outgoing transition associated 38 | with the event, it may be fired if the guard condition allows. 39 | 40 | Args: 41 | name (str): An identifier for the event. 42 | data Optional[dict]: Optional data dict. 43 | """ 44 | 45 | def __init__(self, name, data=None): 46 | self._logger = logging.getLogger(self.__class__.__name__) 47 | self.name = name 48 | self.data = {} if data is None else data 49 | 50 | def __eq__(self, other): 51 | if isinstance(other, self.__class__): 52 | return self.__dict__ == other.__dict__ 53 | return NotImplemented 54 | 55 | def __ne__(self, other): 56 | if isinstance(other, self.__class__): 57 | return not self.__eq__(other) 58 | return NotImplemented 59 | 60 | def __hash__(self): 61 | return hash(tuple(sorted(self.__dict__.items()))) 62 | 63 | def __repr__(self): 64 | return 'Event(name="%s", data=%r)' % (self.name, self.data) 65 | -------------------------------------------------------------------------------- /statechart/pseudostates.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2016, Leigh McKenzie 4 | # All rights reserved. 5 | # 6 | # Permission to use, copy, modify, and/or distribute this software for any 7 | # purpose with or without fee is hereby granted, provided that the above 8 | # copyright notice and this permission notice appear in all copies. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 13 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 15 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 16 | # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 17 | 18 | from statechart import CompositeState, State, Statechart 19 | 20 | 21 | class PseudoState(State): 22 | """ 23 | A pseudostate is an abstraction that encompasses different types of 24 | transient states. They are used, typically, to connect multiple transitions 25 | into more complex state transitions paths. 26 | 27 | Args: 28 | name (str): An identifier for the model element. 29 | context (Context): The parent context that contains this state. 30 | """ 31 | 32 | def activate(self, metadata, event): 33 | """ 34 | Activate the state. 35 | 36 | Args: 37 | metadata (Metadata): Common statechart metadata. 38 | event (Event): Event which led to the transition into this state. 39 | """ 40 | self.active = True 41 | 42 | if self.context: 43 | if not self.context.active: 44 | raise RuntimeError('Parent state not activated') 45 | 46 | self.context.current_state = self 47 | 48 | 49 | class InitialState(PseudoState): 50 | """ 51 | A special kind of state signifying the source for a single transition to 52 | the default state of the composite state. 53 | 54 | Args: 55 | context (Context): The parent context that contains this state. 56 | """ 57 | 58 | def __init__(self, context): 59 | super().__init__(name='Initial', context=context) 60 | 61 | if isinstance(self.context, CompositeState) or isinstance(self.context, Statechart): 62 | if self.context.initial_state: 63 | raise RuntimeError('Initial state already present') 64 | else: 65 | self.context.initial_state = self 66 | else: 67 | raise RuntimeError('Parent not a composite state or statechart') 68 | 69 | def activate(self, metadata, event): 70 | """ 71 | Activate the state and dispatch transition to the default state of the 72 | composite state. 73 | 74 | Args: 75 | metadata (Metadata): Common statechart metadata. 76 | event (Event): Event which led to the transition into this state. 77 | """ 78 | super().activate(metadata=metadata, event=event) 79 | self.dispatch(metadata=metadata, event=None) 80 | 81 | def dispatch(self, metadata, event): 82 | """ 83 | Dispatch transition. 84 | 85 | Args: 86 | metadata (Metadata): Common statechart metadata. 87 | event (Event): Transition event trigger. 88 | 89 | Returns: 90 | True if the transition was executed, False if transition was not 91 | triggered for this event or if the guard condition failed. 92 | 93 | Raises: 94 | RuntimeError: If the state could not dispatch transition 95 | """ 96 | if super().dispatch(metadata=metadata, event=event): 97 | return True 98 | else: 99 | raise RuntimeError('Initial state must be able to dispatch transition') 100 | 101 | def add_transition(self, transition): 102 | """Add a transition from this state. 103 | 104 | An initial state must have a single transition. The transition must not need an 105 | event trigger or have a guard condition. 106 | 107 | Args: 108 | transition (Transition): Transition to add, must be an external transition. 109 | 110 | Raises: 111 | RuntimeError: If transition is invalid, or if transition already exists. 112 | """ 113 | if len(self.transitions) != 0: 114 | raise RuntimeError('There can only be a single transition from an initial state') 115 | elif transition.event is not None: 116 | raise RuntimeError('Transition from initial state must not require an event trigger') 117 | elif transition.guard is not None: 118 | raise RuntimeError('Transition from initial state cannot have a guard condition') 119 | else: 120 | super().add_transition(transition) 121 | 122 | 123 | class ShallowHistoryState(PseudoState): 124 | def __init__(self, context): 125 | """ 126 | Shallow history is a pseudo state representing the most recent 127 | substate of a submachine. 128 | 129 | A submachine can have at most one shallow history state. A transition 130 | with a history pseudo state as target is equivalent to a transition 131 | with the most recent substate as target. And very importantly, only 132 | one transition may originate from the history. 133 | 134 | Args: 135 | context (Context): The parent context that contains this state. 136 | """ 137 | super().__init__(name='Shallow history', context=context) 138 | 139 | self.state = None 140 | 141 | if isinstance(self.context, CompositeState): 142 | if self.context.history_state: 143 | raise RuntimeError('"History state already present') 144 | else: 145 | self.context.history_state = self 146 | else: 147 | raise RuntimeError('Parent not a composite state') 148 | 149 | def activate(self, metadata, event): 150 | """ 151 | Activate the state and dispatch transition to the default state of the 152 | composite state. 153 | 154 | Args: 155 | metadata (Metadata): Common statechart metadata. 156 | event (Event): Event which led to the transition into this state. 157 | """ 158 | super().activate(metadata=metadata, event=event) 159 | 160 | if len(self.transitions) > 1: 161 | raise RuntimeError('History state cannot have more than 1 transition') 162 | 163 | if self.state: 164 | # Setup transition to the history's target state 165 | metadata.transition.start = self 166 | metadata.transition.end = self.state 167 | 168 | self.state.activate(metadata=metadata, event=event) 169 | else: 170 | self.dispatch(metadata=metadata, event=None) 171 | 172 | 173 | class ChoiceState(PseudoState): 174 | """ 175 | The Choice pseudo-state is used to compose complex transitional path which, 176 | which, when reached, result in the dynamic evaluation of the guards of the 177 | triggers of its outgoing transitions. 178 | 179 | It enables splitting of transitions into multiple outgoing paths. 180 | 181 | Args: 182 | context (Context): The parent context that contains this state. 183 | 184 | Note: 185 | It must have at least one incoming and one outgoing Transition. 186 | 187 | If none of the guards evaluates to true, then the model is considered ill-formed. 188 | To avoid this, it is recommended to define one outgoing transition with a 189 | predefined "else" guard for every choice vertex. 190 | """ 191 | 192 | def __init__(self, context): 193 | super().__init__(name='Choice', context=context) 194 | 195 | def activate(self, metadata, event): 196 | """ 197 | Activate the state and dispatch transition to the default state of the 198 | composite state. 199 | 200 | Args: 201 | metadata (Metadata): Common statechart metadata. 202 | event (Event): Event which led to the transition into this state. 203 | """ 204 | super().activate(metadata=metadata, event=event) 205 | 206 | for transition in self.transitions: 207 | if transition.execute(metadata=metadata, event=None): 208 | break 209 | else: 210 | raise RuntimeError('No choice made due to guard conditions, ' 211 | 'add a transition with an "Else" guard') 212 | 213 | def add_transition(self, transition): 214 | """Add a transition from this state. 215 | 216 | Transitions are checked in the order they are defined. 217 | 218 | Args: 219 | transition (Transition): Transition to add. 220 | 221 | Raises: 222 | RuntimeError: If transition is invalid. 223 | """ 224 | if transition is None: 225 | raise RuntimeError('Cannot add null transition') 226 | 227 | self.transitions.append(transition) 228 | -------------------------------------------------------------------------------- /statechart/runtime.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2016, Leigh McKenzie 4 | # All rights reserved. 5 | # 6 | # Permission to use, copy, modify, and/or distribute this software for any 7 | # purpose with or without fee is hereby granted, provided that the above 8 | # copyright notice and this permission notice appear in all copies. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 13 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 15 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 16 | # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 17 | 18 | import logging 19 | 20 | 21 | class Metadata: 22 | """ 23 | Describes runtime specific data of the statechart. The main data is the 24 | currently active state. For every active state a StateRuntimeData object is 25 | created which stores specific data for the state. This object is allocated 26 | only when the state is active, otherwise it is deleted. 27 | """ 28 | 29 | def __init__(self): 30 | self._logger = logging.getLogger(self.__class__.__name__) 31 | self.event = None 32 | self.transition = None 33 | -------------------------------------------------------------------------------- /statechart/states.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2016, Leigh McKenzie 4 | # All rights reserved. 5 | # 6 | # Permission to use, copy, modify, and/or distribute this software for any 7 | # purpose with or without fee is hereby granted, provided that the above 8 | # copyright notice and this permission notice appear in all copies. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 13 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 15 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 16 | # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 17 | 18 | import logging 19 | 20 | from statechart.runtime import Metadata 21 | 22 | 23 | class State: 24 | """A State is a simple state that has no regions or submachine states. 25 | 26 | Args: 27 | name (str): State name used to identify this instance for logging. 28 | A unique name is recommended although not enforced. 29 | context (Context): The parent context that contains this state. 30 | 31 | Attributes: 32 | name (str): State name used to identify this instance. 33 | context (Context): State's parent context. 34 | 35 | Examples: 36 | * First create the parent context 37 | statechart = Statechart(name='statechart') 38 | 39 | * Then create the states 40 | a = State(name='a', context=statechart) 41 | b = State(name='b', context=statechart) 42 | 43 | * Finally create the transitions between states with any associated 44 | event triggers, actions or guard conditions. 45 | Transition(start=a, end=b) 46 | 47 | Note: 48 | Do not dispatch a synchronous event within the action (enter, do or 49 | exit) functions. If you need to dispatch an event, do so using the 50 | async_dispatch function of the statechart. 51 | 52 | Raises: 53 | RuntimeError: If the parent context is invalid. 54 | Only a state chart can have no parent context. 55 | """ 56 | 57 | def __init__(self, name, context): 58 | self._logger = logging.getLogger(self.__class__.__name__) 59 | 60 | self.name = name 61 | 62 | """Context can be null only for the statechart""" 63 | if context is None and (not isinstance(self, Statechart)): 64 | raise RuntimeError('Context cannot be null') 65 | 66 | self.context = context 67 | self.transitions = [] 68 | self.active = False 69 | 70 | def entry(self, event): 71 | """ 72 | An optional action that is executed whenever this state is 73 | entered, regardless of the transition taken to reach the state. If 74 | defined, entry actions are always executed to completion prior to any 75 | internal activity or transitions performed within the state. 76 | 77 | Args: 78 | event (Event): Event which led to the transition into this state. 79 | """ 80 | pass 81 | 82 | def do(self, event): 83 | """ 84 | An optional action that is executed whilst this state is active. 85 | The execution starts after this state is entered, and stops either by 86 | itself, or when the state is exited, whichever comes first. 87 | 88 | Starts an async task. When the task is finished, it may fire an event 89 | to trigger a state transition. If the task is still in progress when 90 | this state is deactivated it will be cancelled. 91 | 92 | Args: 93 | event (Event): Event which led to the transition into this state. 94 | """ 95 | pass 96 | 97 | def exit(self, event): 98 | """ 99 | An optional action that is executed upon deactivation of this state 100 | regardless of which transition was taken out of the state. If defined, 101 | exit actions are always executed to completion only after all 102 | internal activities and transition actions have completed execution. 103 | Initiates cancellation of the state do action if it is still running. 104 | 105 | Args: 106 | event (Event): Event which led to the transition into this state. 107 | """ 108 | pass 109 | 110 | def add_transition(self, transition): 111 | """Add a transition from this state. 112 | 113 | Transitions with guards are checked first. 114 | 115 | Args: 116 | transition (Transition): Transition to add, can be a normal or 117 | internal transition. 118 | 119 | Raises: 120 | RuntimeError: If transition is invalid. 121 | """ 122 | if transition is None: 123 | raise RuntimeError('Cannot add null transition') 124 | 125 | if transition.guard: 126 | self.transitions.insert(0, transition) 127 | else: 128 | self.transitions.append(transition) 129 | 130 | def activate(self, metadata, event): 131 | """ 132 | Activate the state. 133 | 134 | Args: 135 | metadata (Metadata): Common statechart metadata. 136 | event (Event): Event which led to the transition into this state. 137 | """ 138 | self._logger.info('Activate "%s"', self.name) 139 | 140 | self.active = True 141 | 142 | if self.context: 143 | if not self.context.active: 144 | raise RuntimeError('Parent state not activated') 145 | 146 | self.context.current_state = self 147 | 148 | if self.entry: 149 | self.entry(event=event) 150 | 151 | if self.do: 152 | self.do(event=event) 153 | 154 | def deactivate(self, metadata, event): 155 | """ 156 | Deactivate the state. 157 | 158 | Args: 159 | metadata (Metadata): Common statechart metadata. 160 | event (Event): Event which led to the transition out of this state. 161 | """ 162 | self._logger.info('Deactivate "%s"', self.name) 163 | 164 | self.exit(event=event) 165 | 166 | self.active = False 167 | 168 | def dispatch(self, metadata, event): 169 | """ 170 | Dispatch transition. 171 | 172 | Args: 173 | metadata (Metadata): Common statechart metadata. 174 | event (Event): Transition event trigger. 175 | 176 | Returns: 177 | True if transition executed, False if transition not allowed, 178 | due to mismatched event trigger or failed guard condition. 179 | """ 180 | self.handle_internal(event) 181 | 182 | status = False 183 | 184 | for transition in self.transitions: 185 | if transition.execute(metadata=metadata, event=event): 186 | status = True 187 | break 188 | 189 | return status 190 | 191 | def handle_internal(self, event): 192 | """ 193 | Handle an internal event. Override to provide specific behaviour for 194 | a state. 195 | 196 | Events are routed to all active states from the outside-in before 197 | processing transitions. 198 | 199 | This is a lightweight implementation of an internal transition without 200 | strict guard and action semantics. 201 | 202 | Filter internal transitions by checking the event name. 203 | 204 | event (Event): Incoming event to handle. 205 | """ 206 | pass 207 | 208 | def is_active(self, state_name): 209 | return self.active and self.name == state_name 210 | 211 | def __repr__(self): 212 | return '%s(name="%s", active="%r")' % (self.__class__.__name__, self.name, self.active) 213 | 214 | 215 | class Context(State): 216 | """ 217 | Domain of the state. Needed for setting up the hierarchy. This class 218 | needn't be instantiated directly. 219 | 220 | Args: 221 | name (str): An identifier for the model element. 222 | context (Context): The parent context that contains this state. 223 | """ 224 | 225 | def __init__(self, name, context): 226 | super().__init__(name=name, context=context) 227 | self.initial_state = None 228 | self.current_state = None 229 | self.finished = False 230 | 231 | def deactivate(self, metadata, event): 232 | """ 233 | Deactivate the state. 234 | 235 | Args: 236 | metadata (Metadata): Common statechart metadata. 237 | event (Event): Event which led to the transition out of this state. 238 | """ 239 | super().deactivate(metadata=metadata, event=event) 240 | self.current_state = None 241 | self.finished = False 242 | 243 | def is_active(self, state_name): 244 | if not self.active: 245 | return False 246 | elif self.name == state_name: 247 | return True 248 | else: 249 | return self.current_state.is_active(state_name) 250 | 251 | def __repr__(self): 252 | return '%s(name="%s", active="%s", current state=%r, finished="%s")' % ( 253 | self.__class__.__name__, self.name, self.active, self.current_state, self.finished) 254 | 255 | 256 | class FinalState(State): 257 | """ 258 | A special kind of state signifying that the enclosing composite state or 259 | the entire state machine is completed. 260 | 261 | A final state cannot have transitions or dispatch other transitions. 262 | 263 | Args: 264 | context (Context): The parent context that contains this state. 265 | 266 | Raises: 267 | RuntimeError: If the model is ill-formed by attempting to add a transition directly from 268 | the final state. 269 | """ 270 | 271 | def __init__(self, context): 272 | super().__init__(name='Final', context=context) 273 | 274 | def add_transition(self, transition): 275 | raise RuntimeError('Cannot add a transition from the final state') 276 | 277 | def activate(self, metadata, event): 278 | super().activate(metadata=metadata, event=event) 279 | self.context.finished = True 280 | 281 | def deactivate(self, metadata, event): 282 | super().deactivate(metadata=metadata, event=event) 283 | self.context.finished = False 284 | 285 | 286 | class ConcurrentState(State): 287 | """ 288 | A concurrent state is a state that contains composite state regions, 289 | activated concurrently. 290 | 291 | Args: 292 | name (str): An identifier for the model element. 293 | context (Context): The parent context that contains this state. 294 | """ 295 | 296 | def __init__(self, name, context): 297 | super().__init__(name, context) 298 | self.regions = [] 299 | 300 | def add_region(self, region): 301 | """ 302 | Add a new region to the concurrent state. 303 | 304 | Args: 305 | region (CompositeState): Region to add. 306 | """ 307 | if isinstance(region, CompositeState): 308 | self.regions.append(region) 309 | else: 310 | raise RuntimeError('A concurrent state can only add composite state regions') 311 | 312 | def activate(self, metadata, event): 313 | """ 314 | Activate the state. 315 | 316 | Args: 317 | metadata (Metadata): Common statechart metadata. 318 | event (Event): Event which led to the transition into this state. 319 | """ 320 | super().activate(metadata, event) 321 | for inactive in [region for region in self.regions if not region.active]: 322 | # Check if region is activated implicitly via incoming transition. 323 | inactive.activate(metadata=metadata, event=event) 324 | inactive.initial_state.activate(metadata=metadata, event=event) 325 | 326 | def deactivate(self, metadata, event): 327 | """ 328 | Deactivate child states within regions, then overall state. 329 | 330 | Args: 331 | metadata (Metadata): Common statechart metadata. 332 | event: Event which led to the transition out of this state. 333 | """ 334 | self._logger.info('Deactivate "%s"', self.name) 335 | 336 | for region in self.regions: 337 | if region.active: 338 | region.deactivate(metadata=metadata, event=event) 339 | 340 | super().deactivate(metadata=metadata, event=event) 341 | 342 | def dispatch(self, metadata, event): 343 | """ 344 | Dispatch transition. 345 | 346 | Args: 347 | metadata (Metadata): Common statechart metadata. 348 | event (Event): Transition event trigger. 349 | 350 | Returns: 351 | True if transition executed, False if transition not allowed, 352 | due to mismatched event trigger or failed guard condition. 353 | """ 354 | if not self.active: 355 | raise RuntimeError('Inactive composite state attempting to dispatch transition') 356 | 357 | self.handle_internal(event) 358 | 359 | dispatched = False 360 | 361 | """ Check if any of the child regions can handle the event """ 362 | for region in self.regions: 363 | if region.dispatch(metadata=metadata, event=event): 364 | dispatched = True 365 | 366 | if dispatched: 367 | return True 368 | 369 | """ Check if this state can handle the event by itself """ 370 | for transition in self.transitions: 371 | if transition.execute(metadata=metadata, event=event): 372 | dispatched = True 373 | break 374 | 375 | return dispatched 376 | 377 | @property 378 | def finished(self): 379 | """ 380 | Check if all regions within the concurrent state are finished. 381 | 382 | Returns: 383 | True if all regions are finished. 384 | """ 385 | return all(region.finished for region in self.regions) 386 | 387 | def is_active(self, state_name): 388 | if not self.active: 389 | return False 390 | elif self.name == state_name: 391 | return True 392 | else: 393 | return any(region.is_active(state_name) for region in self.regions) 394 | 395 | def __repr__(self): 396 | return '%s(name="%s", active="%s", regions=%r, finished="%s")' % ( 397 | self.__class__.__name__, self.name, self.active, self.regions, self.finished) 398 | 399 | 400 | class CompositeState(Context): 401 | """ 402 | A composite state is a state that contains other state vertices (states, 403 | pseudostates, etc.). 404 | 405 | Args: 406 | name (str): An identifier for the model element. 407 | context (Context): The parent context that contains this state. 408 | """ 409 | 410 | def __init__(self, name, context): 411 | super().__init__(name=name, context=context) 412 | self.history_state = None 413 | 414 | if isinstance(context, ConcurrentState): 415 | context.add_region(self) 416 | 417 | def activate(self, metadata, event): 418 | """ 419 | Activate the state. 420 | 421 | If the transition being activated leads to this state, activate 422 | the initial state. 423 | 424 | Args: 425 | metadata (Metadata): Common statechart metadata. 426 | event: Event which led to the transition into this state. 427 | """ 428 | super().activate(metadata=metadata, event=event) 429 | 430 | if metadata.transition and metadata.transition.end is self: 431 | self.initial_state.activate(metadata=metadata, event=event) 432 | 433 | def deactivate(self, metadata, event): 434 | """ 435 | Deactivate the state. 436 | 437 | If this state contains a history state, store the currently active 438 | state in history so it can be restored once the history state is 439 | activated. 440 | 441 | Args: 442 | metadata (Metadata): Common statechart metadata. 443 | event: Event which led to the transition out of this state. 444 | """ 445 | # If the composite state contains a history pseudostate, preserve the current active child 446 | # state in history, unless that state is a final state. 447 | if self.history_state and not (isinstance(self.current_state, FinalState)): 448 | self.history_state.state = self.current_state 449 | 450 | if self.current_state.active: 451 | self.current_state.deactivate(metadata=metadata, event=event) 452 | 453 | super().deactivate(metadata=metadata, event=event) 454 | 455 | def dispatch(self, metadata, event): 456 | """ 457 | Dispatch transition. 458 | 459 | Args: 460 | metadata (Metadata): Common statechart metadata. 461 | event (Event): Transition event trigger. 462 | 463 | Returns: 464 | True if transition executed, False if transition not allowed, 465 | due to mismatched event trigger or failed guard condition. 466 | """ 467 | if not self.active: 468 | raise RuntimeError('Inactive composite state attempting to dispatch transition') 469 | 470 | self.handle_internal(event) 471 | 472 | # See if the current child state can handle the event 473 | if self.current_state is None and self.initial_state: 474 | self.initial_state.activate(metadata=metadata, event=None) 475 | self.current_state.activate(metadata=metadata, event=event) 476 | 477 | dispatched = False 478 | 479 | if self.current_state and self.current_state.dispatch(metadata=metadata, event=event): 480 | dispatched = True 481 | 482 | if dispatched: 483 | # If the substate dispatched the event and this state is no longer active, return. 484 | if not self.active: 485 | return True 486 | 487 | # If the substate dispatched the event and reached a final state, continue to dispatch 488 | # any default transitions from this state. 489 | if isinstance(self.current_state, FinalState): 490 | event = None 491 | else: 492 | return True 493 | 494 | # Since none of the child states can handle the event, let this state 495 | # try handling the event. 496 | for transition in self.transitions: 497 | # If transition is local, deactivate current state if transition is allowed. 498 | if self._is_local_transition(transition) and transition.is_allowed(event=event): 499 | self.current_state.deactivate(metadata=metadata, event=event) 500 | 501 | if transition.execute(metadata=metadata, event=event): 502 | return True 503 | 504 | return False 505 | 506 | def _is_local_transition(self, transition): 507 | """ 508 | Check if a transition is local. 509 | 510 | The transition must meet the following conditions: 511 | - Not an internal transition. 512 | - Transition originates from this state, but doesn't leave/deactivate it. 513 | 514 | Returns: 515 | True if the transition is a local transition, otherwise false. 516 | """ 517 | if transition.start is transition.end or self in transition.deactivate: 518 | return False 519 | else: 520 | return True 521 | 522 | 523 | class Statechart(Context): 524 | """ 525 | The main entry point for using the statechart framework. Contains all 526 | necessary methods for delegating incoming events to the substates. 527 | 528 | Args: 529 | name (str): An identifier for the model element. 530 | """ 531 | 532 | def __init__(self, name): 533 | super().__init__(name=name, context=None) 534 | self.metadata = Metadata() 535 | 536 | def start(self): 537 | """ 538 | Initialises the Statechart in the metadata. Sets the start state. 539 | 540 | Ensure the statechart has at least an initial state. 541 | 542 | Raises: 543 | RuntimeError if the statechart had already been started. 544 | """ 545 | self._logger.info('Start "%s"', self.name) 546 | self.active = True 547 | self.initial_state.activate(metadata=self.metadata, event=None) 548 | 549 | def stop(self): 550 | """ 551 | Stops the statemachine by deactivating statechart and thus all it's child states. 552 | """ 553 | self._logger.info('Stop "%s"', self.name) 554 | self.deactivate(metadata=self.metadata, event=None) 555 | 556 | def deactivate(self, metadata, event): 557 | """ 558 | Deactivate the statechart. 559 | 560 | Args: 561 | metadata (Metadata): Common statechart metadata. 562 | event (Event): Event which led to the transition out of this state. 563 | """ 564 | self._logger.info('Deactivate "%s"', self.name) 565 | self.active = False 566 | self.current_state = None 567 | 568 | def dispatch(self, event): 569 | """ 570 | Calls the dispatch method on the current state. 571 | 572 | Args: 573 | event (Event): Transition event trigger. 574 | 575 | Returns: 576 | True if transition executed. 577 | """ 578 | self.handle_internal(event=event) 579 | 580 | return self.current_state.dispatch(metadata=self.metadata, event=event) 581 | 582 | def active_states(self): 583 | states = [] 584 | 585 | if self.active: 586 | states.append(self) 587 | node = self.current_state 588 | 589 | while node is not None: 590 | states.append(node) 591 | 592 | if isinstance(node, Context): 593 | node = node.current_state 594 | else: 595 | break 596 | 597 | return states 598 | 599 | def is_finished(self): 600 | return self.finished 601 | 602 | def add_transition(self, transition): 603 | raise RuntimeError('Cannot add transition to a statechart') 604 | 605 | def entry(self, event): 606 | raise RuntimeError('Cannot define an entry action for a statechart') 607 | 608 | def do(self, event): 609 | raise RuntimeError('Cannot define an do action for a statechart') 610 | 611 | def exit(self, event): 612 | raise RuntimeError('Cannot define an exit action for a statechart') 613 | -------------------------------------------------------------------------------- /statechart/transitions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2016, Leigh McKenzie 4 | # All rights reserved. 5 | # 6 | # Permission to use, copy, modify, and/or distribute this software for any 7 | # purpose with or without fee is hereby granted, provided that the above 8 | # copyright notice and this permission notice appear in all copies. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 13 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 15 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 16 | # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 17 | 18 | 19 | import logging 20 | from functools import partial 21 | 22 | from statechart import Event, Statechart 23 | 24 | 25 | class Transition: 26 | """ 27 | A transition is a directed relationship between a source state and a target 28 | state. It may be part of a compound transition, which takes the state 29 | machine from one state configuration to another, representing the complete 30 | response of the state machine to a particular event instance. 31 | 32 | Args: 33 | start (State): The originating state (or pseudostate) of the 34 | transition. 35 | end (State): The target state (or pseudostate) that is reached when the 36 | transition is executed. 37 | event (Event|str): The event or event name that fires the transition. 38 | guard (function): A boolean predicate that must be true for the 39 | transition to be fired. It is evaluated at the time the event is 40 | dispatched. 41 | action (function): An optional procedure to be performed when the 42 | transition fires. 43 | """ 44 | 45 | def __init__(self, start, end, event=None, guard=None, action=None): 46 | self._logger = logging.getLogger(self.__class__.__name__) 47 | self.start = start 48 | self.end = end 49 | self.event = event 50 | self.guard = guard 51 | self.action = action 52 | 53 | if isinstance(event, str): 54 | self.event = Event(event) 55 | 56 | if guard is not None and not callable(guard): 57 | raise ValueError('Guard must be callable') 58 | 59 | if action is not None and not callable(action): 60 | raise ValueError('Action must be callable') 61 | 62 | """ Used to store the states that will get activated """ 63 | self.activate = list() 64 | 65 | """ Used to store the states that will get de-activated """ 66 | self.deactivate = list() 67 | 68 | self._calculate_state_set(start=start, end=end) 69 | 70 | start.add_transition(self) 71 | 72 | def execute(self, metadata, event): 73 | """ 74 | Attempt to execute the transition. 75 | Evaluate if the transition is allowed by checking the guard condition. 76 | If the transition is allowed, deactivate source states, perform 77 | transition action and activate all target states. 78 | 79 | Args: 80 | metadata (Metadata): Common statechart metadata. 81 | event (Event): The event that fires the transition. 82 | 83 | Returns: 84 | True if the transition was executed. 85 | """ 86 | if not self.is_allowed(event=event): 87 | return False 88 | 89 | metadata.transition = self 90 | 91 | if event: 92 | self._logger.info('Transition from "%s" to "%s" due to event trigger "%s"', 93 | self.start.name, self.end.name, event.name) 94 | else: 95 | self._logger.info('Default transition from "%s" to "%s"', 96 | self.start.name, self.end.name) 97 | 98 | for state in self.deactivate: 99 | state.deactivate(metadata=metadata, event=event) 100 | 101 | if self.action: 102 | for func in [partial(self.action, event=event), 103 | self.action]: 104 | try: 105 | func() 106 | break 107 | except TypeError: 108 | pass 109 | else: 110 | raise RuntimeError('Unable to call action function') 111 | 112 | for state in self.activate: 113 | state.activate(metadata=metadata, event=event) 114 | 115 | metadata.transition = None 116 | 117 | return True 118 | 119 | def is_allowed(self, event): 120 | """ 121 | Check if the transition is allowed. 122 | 123 | Args: 124 | event (Event): The event that fires the transition. 125 | 126 | Returns: 127 | True if the transition is allowed. 128 | """ 129 | try: 130 | if (not self.event and not event) or (self.event.name == event.name): 131 | pass 132 | else: 133 | return False 134 | except AttributeError: 135 | return False 136 | 137 | if self.guard: 138 | for func in [partial(self.guard, event=event), 139 | self.guard]: 140 | try: 141 | return func() 142 | except TypeError: 143 | pass 144 | else: 145 | raise RuntimeError('Unable to call guard function') 146 | return True 147 | 148 | def _calculate_state_set(self, start, end): 149 | """ 150 | Calculate all the states which must be deactivated and then activated 151 | when triggering the transition. 152 | 153 | Args: 154 | start (State): The originating state (or pseudostate) of the transition. 155 | end (State): The target state (or pseudostate) that is reached when the transition is 156 | executed. 157 | """ 158 | start_states = list() 159 | end_states = list() 160 | 161 | """ Recursively get all the context start states """ 162 | s = start 163 | while s is not None: 164 | start_states.insert(0, s) 165 | context = s.context 166 | if context and not isinstance(context, Statechart): 167 | s = context 168 | else: 169 | s = None 170 | 171 | """ Recursively get all the context end states """ 172 | e = end 173 | while e is not None: 174 | end_states.insert(0, e) 175 | context = e.context 176 | if context and not isinstance(context, Statechart): 177 | e = context 178 | else: 179 | e = None 180 | 181 | """ Get the Least Common Ancestor (LCA) of the start and end states """ 182 | min_state_count = min(len(start_states), len(end_states)) 183 | lca = min_state_count - 1 184 | 185 | if start is not end: 186 | lca = 0 187 | while lca < min_state_count: 188 | if start_states[lca] is not end_states[lca]: 189 | break 190 | lca += 1 191 | 192 | """ Starting from the LCA get the states that will be deactivated """ 193 | i = lca 194 | while i < len(start_states): 195 | self.deactivate.insert(0, start_states[i]) 196 | i += 1 197 | 198 | """ Starting from the LCA get the states that will be activated """ 199 | i = lca 200 | while i < len(end_states): 201 | self.activate.append(end_states[i]) 202 | i += 1 203 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2016, Leigh McKenzie 4 | # All rights reserved. 5 | # 6 | # Permission to use, copy, modify, and/or distribute this software for any 7 | # purpose with or without fee is hereby granted, provided that the above 8 | # copyright notice and this permission notice appear in all copies. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 13 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 15 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 16 | # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 17 | -------------------------------------------------------------------------------- /tests/test_display.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2016, Leigh McKenzie 4 | # All rights reserved. 5 | # 6 | # Permission to use, copy, modify, and/or distribute this software for any 7 | # purpose with or without fee is hereby granted, provided that the above 8 | # copyright notice and this permission notice appear in all copies. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 13 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 15 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 16 | # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 17 | 18 | 19 | import pytest 20 | 21 | from statechart import (ConcurrentState, CompositeState, Event, FinalState, InitialState, 22 | State, Statechart, Transition) 23 | from statechart.display import Display 24 | 25 | 26 | @pytest.fixture 27 | def simple_statechart(): 28 | sc = Statechart('simple') 29 | sc.init = InitialState(sc) 30 | sc.state = State(name='state', context=sc) 31 | sc.final = FinalState(sc) 32 | 33 | Transition(start=sc.init, end=sc.state) 34 | Transition(start=sc.state, end=sc.final, event=Event('finish')) 35 | 36 | return sc 37 | 38 | 39 | @pytest.fixture 40 | def composite_statechart(): 41 | sc = Statechart('simple') 42 | sc.init = InitialState(sc) 43 | 44 | sc.composite = CompositeState(name='composite', context=sc) 45 | sc.composite.init = InitialState(sc.composite) 46 | sc.composite.state = State(name='state', context=sc.composite) 47 | sc.composite.final = FinalState(sc.composite) 48 | 49 | sc.final = FinalState(sc) 50 | 51 | Transition(start=sc.init, end=sc.composite) 52 | Transition(start=sc.composite.init, end=sc.composite.state) 53 | Transition(start=sc.composite.state, end=sc.composite.final, event=Event('finish')) 54 | 55 | Transition(start=sc.composite, end=sc.final, event=Event('finish')) 56 | 57 | return sc 58 | 59 | 60 | @pytest.fixture 61 | def concurrent_statechart(): 62 | def composite(name, context): 63 | composite = CompositeState(name=name, context=context) 64 | composite.init = InitialState(composite) 65 | composite.state = State(name='state', context=composite) 66 | composite.final = FinalState(composite) 67 | 68 | Transition(start=composite.init, end=composite.state) 69 | Transition(start=composite.state, end=composite.final, event=Event('finish')) 70 | 71 | return composite 72 | 73 | sc = Statechart('simple') 74 | sc.init = InitialState(sc) 75 | 76 | sc.concurrent = ConcurrentState(name='compound', context=sc) 77 | 78 | sc.concurrent.composite_a = composite(name='a', context=sc.concurrent) 79 | sc.concurrent.composite_b = composite(name='b', context=sc.concurrent) 80 | sc.concurrent.composite_c = composite(name='c', context=sc.concurrent) 81 | 82 | sc.final = FinalState(sc) 83 | 84 | Transition(start=sc.init, end=sc.concurrent) 85 | Transition(start=sc.concurrent, end=sc.final, event=Event('finish')) 86 | 87 | return sc 88 | 89 | 90 | class TestDescribe: 91 | def test_describe_simple_statechart(self, simple_statechart): 92 | sc = simple_statechart 93 | display = Display() 94 | 95 | (states, transitions) = display.describe(sc.initial_state, states=[], transitions=[]) 96 | 97 | assert set(states) == {sc.init, sc.state, sc.final} 98 | assert set(transitions) == set(sc.init.transitions + sc.state.transitions) 99 | 100 | def test_describe_composite_statechart(self, composite_statechart): 101 | sc = composite_statechart 102 | display = Display() 103 | 104 | (states, transitions) = display.describe(sc.initial_state, states=[], transitions=[]) 105 | 106 | assert set(states) == { 107 | sc.init, 108 | sc.composite, 109 | sc.composite.init, 110 | sc.composite.state, 111 | sc.composite.final, 112 | sc.final 113 | } 114 | 115 | assert set(transitions) == set( 116 | sc.init.transitions + 117 | sc.composite.transitions + 118 | sc.composite.init.transitions + 119 | sc.composite.state.transitions 120 | ) 121 | 122 | def test_describe_concurrent_statechart(self, concurrent_statechart): 123 | sc = concurrent_statechart 124 | display = Display() 125 | 126 | (states, transitions) = display.describe(sc.initial_state, states=[], transitions=[]) 127 | 128 | assert set(states) == { 129 | sc.init, 130 | sc.concurrent, 131 | sc.concurrent.composite_a, 132 | sc.concurrent.composite_a.init, 133 | sc.concurrent.composite_a.state, 134 | sc.concurrent.composite_a.final, 135 | sc.concurrent.composite_b, 136 | sc.concurrent.composite_b.init, 137 | sc.concurrent.composite_b.state, 138 | sc.concurrent.composite_b.final, 139 | sc.concurrent.composite_c, 140 | sc.concurrent.composite_c.init, 141 | sc.concurrent.composite_c.state, 142 | sc.concurrent.composite_c.final, 143 | sc.final 144 | } 145 | 146 | assert set(transitions) == set( 147 | sc.init.transitions + 148 | sc.concurrent.transitions + 149 | sc.concurrent.composite_a.transitions + 150 | sc.concurrent.composite_a.init.transitions + 151 | sc.concurrent.composite_a.state.transitions + 152 | sc.concurrent.composite_b.transitions + 153 | sc.concurrent.composite_b.init.transitions + 154 | sc.concurrent.composite_b.state.transitions + 155 | sc.concurrent.composite_c.transitions + 156 | sc.concurrent.composite_c.init.transitions + 157 | sc.concurrent.composite_c.state.transitions 158 | ) 159 | 160 | def test_plantuml_simple_statechart(self, simple_statechart): 161 | sc = simple_statechart 162 | display = Display() 163 | 164 | plantuml = display.plantuml(sc) 165 | 166 | # Manually verify the expected PlantUML code was generated. 167 | assert plantuml 168 | 169 | def test_plantuml_composite_statechart(self, composite_statechart): 170 | sc = composite_statechart 171 | display = Display() 172 | 173 | plantuml = display.plantuml(sc) 174 | 175 | # Manually verify the expected PlantUML code was generated. 176 | assert plantuml 177 | 178 | def test_plantuml_concurrent_statechart(self, concurrent_statechart): 179 | sc = concurrent_statechart 180 | display = Display() 181 | 182 | plantuml = display.plantuml(sc) 183 | 184 | # Manually verify the expected PlantUML code was generated. 185 | assert plantuml 186 | -------------------------------------------------------------------------------- /tests/test_event.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2016, Leigh McKenzie 4 | # All rights reserved. 5 | # 6 | # Permission to use, copy, modify, and/or distribute this software for any 7 | # purpose with or without fee is hereby granted, provided that the above 8 | # copyright notice and this permission notice appear in all copies. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 13 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 15 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 16 | # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 17 | 18 | import pytest 19 | from statechart import Event 20 | 21 | 22 | @pytest.fixture 23 | def event(): 24 | return Event(name='event', data={'a': 1}) 25 | 26 | 27 | class TestEvent: 28 | def test_create_event(self): 29 | Event(name='event') 30 | 31 | def test_events_equal(self, event): 32 | event = event 33 | assert event is event 34 | 35 | def test_compare_invalid_event(self, event): 36 | assert event is not None 37 | 38 | def test_diff_names(self, event): 39 | diff_event = Event(name='diff_event') 40 | assert diff_event is not event 41 | 42 | def test_diff_data(self, event): 43 | diff_event = Event(name='diff_event', data={'a': 2}) 44 | assert event != diff_event 45 | 46 | def test_event_data(self): 47 | name = 'ev' 48 | data = {'a': 1} 49 | ev = Event(name, data) 50 | 51 | assert ev.name == name 52 | assert ev.data == data 53 | -------------------------------------------------------------------------------- /tests/test_pseudostate.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2016, Leigh McKenzie 4 | # All rights reserved. 5 | # 6 | # Permission to use, copy, modify, and/or distribute this software for any 7 | # purpose with or without fee is hereby granted, provided that the above 8 | # copyright notice and this permission notice appear in all copies. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 13 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 15 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 16 | # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 17 | 18 | import pytest 19 | 20 | from statechart import (ChoiceState, CompositeState, Event, InitialState, ShallowHistoryState, 21 | State, Statechart, Transition) 22 | 23 | 24 | class TestInitialState: 25 | def test_create_initial_state(self): 26 | startchart = Statechart(name='statechart') 27 | InitialState(startchart) 28 | 29 | def test_activate_initial_state(self): 30 | startchart = Statechart(name='statechart') 31 | initial_state = InitialState(startchart) 32 | default_state = State(name='default', context=startchart) 33 | Transition(start=initial_state, end=default_state) 34 | startchart.start() 35 | 36 | initial_state.activate(metadata=startchart.metadata, event=None) 37 | assert startchart.is_active('default') 38 | 39 | def test_missing_transition_from_initial_state(self): 40 | startchart = Statechart(name='statechart') 41 | InitialState(startchart) 42 | 43 | with pytest.raises(RuntimeError): 44 | startchart.start() 45 | 46 | def test_multiple_transitions_from_initial_state(self): 47 | startchart = Statechart(name='statechart') 48 | initial_state = InitialState(startchart) 49 | default_state = State(name='default', context=startchart) 50 | 51 | Transition(start=initial_state, end=default_state) 52 | with pytest.raises(RuntimeError): 53 | Transition(start=initial_state, end=default_state) 54 | 55 | def test_transition_from_initial_state_with_event_trigger(self): 56 | startchart = Statechart(name='statechart') 57 | initial_state = InitialState(startchart) 58 | default_state = State(name='default', context=startchart) 59 | 60 | with pytest.raises(RuntimeError): 61 | Transition(start=initial_state, end=default_state, event=Event('event')) 62 | 63 | def test_transition_from_initial_state_with_guard_condition(self): 64 | startchart = Statechart(name='statechart') 65 | initial_state = InitialState(startchart) 66 | default_state = State(name='default', context=startchart) 67 | 68 | def my_guard(**kwargs): 69 | return False 70 | 71 | with pytest.raises(RuntimeError): 72 | Transition(start=initial_state, end=default_state, event=None, guard=my_guard) 73 | 74 | 75 | class TestShallowHistoryState: 76 | def test_create_shallow_history_state(self): 77 | startchart = Statechart(name='statechart') 78 | composite_state = CompositeState(name='composite', context=startchart) 79 | ShallowHistoryState(composite_state) 80 | 81 | def test_cannot_create_multiple_shallow_history_states(self): 82 | startchart = Statechart(name='statechart') 83 | composite_state = CompositeState(name='composite', context=startchart) 84 | 85 | ShallowHistoryState(composite_state) 86 | 87 | with pytest.raises(RuntimeError): 88 | ShallowHistoryState(composite_state) 89 | 90 | def test_activate_shallow_history_state(self): 91 | """ 92 | statechart: 93 | 94 | statechart_init 95 | | 96 | *** csa ********************** *** csb ************* 97 | * * * * 98 | * csa_init-csa_hist * * csb_init * 99 | * | * --J--> * | * 100 | * A --I--> B * <--K-- * C --L--> D * 101 | * * * * 102 | ****************************** ********************* 103 | """ 104 | 105 | # Top level states 106 | statechart = Statechart(name='statechart') 107 | csa_state = CompositeState(name='csa', context=statechart) 108 | csb_state = CompositeState(name='csb', context=statechart) 109 | 110 | # Child states 111 | # statechart 112 | statechart_init = InitialState(statechart) 113 | # csa 114 | csa_initial_state = InitialState(csa_state) 115 | csa_history_state = ShallowHistoryState(context=csa_state) 116 | csa_state_a = State(name='A', context=csa_state) 117 | csa_state_b = State(name='B', context=csa_state) 118 | # csb 119 | csb_init = InitialState(csb_state) 120 | csb_state_c = State(name='C', context=csb_state) 121 | csa_state_d = State(name='D', context=csb_state) 122 | 123 | # Events 124 | csa_a_to_b = Event(name='csa_a_to_b') 125 | csa_to_csb = Event(name='csa_to_csb') 126 | csb_to_csa = Event(name='csb_to_csa') 127 | csb_c_to_d = Event(name='csb_c_to_d') 128 | 129 | # Transitions between states & event triggers 130 | Transition(start=statechart_init, end=csa_state) 131 | Transition(start=csa_initial_state, end=csa_history_state) 132 | Transition(start=csa_history_state, end=csa_state_a) 133 | Transition(start=csa_state_a, end=csa_state_b, event=csa_a_to_b) 134 | Transition(start=csa_state, end=csb_state, event=csa_to_csb) 135 | Transition(start=csb_state, end=csa_state, event=csb_to_csa) 136 | Transition(start=csb_init, end=csb_state_c) 137 | Transition(start=csb_state_c, end=csa_state_d, event=csb_c_to_d) 138 | 139 | # Execute statechart 140 | statechart.start() 141 | statechart.dispatch(csa_a_to_b) 142 | 143 | # Assert we have reached CSA child state B, history should restore this state 144 | assert statechart.is_active(csa_state_b.name) 145 | 146 | statechart.dispatch(csa_to_csb) 147 | 148 | # Assert we have reached CSB child state C 149 | assert statechart.is_active(csb_state_c.name) 150 | 151 | statechart.dispatch(csb_to_csa) 152 | 153 | # Assert the history state has restored CSA child state B, 154 | assert statechart.is_active(csa_state_b.name) 155 | 156 | def test_activate_shallow_history_given_deep_history_scenario(self): 157 | """ 158 | statechart: 159 | 160 | statechart_init 161 | | 162 | *** csa ******************************************* *** csc ************* 163 | * * * * 164 | * csa_init--csa_hist * * csc_init * 165 | * | * --K--> * | * 166 | * A --I--> *** csb ************* * <--L-- * D --M--> E * 167 | * * * * * * 168 | * * csb_init * * ********************* 169 | * * | * * 170 | * * B --J--> C * * 171 | * * * * 172 | * ********************* * 173 | * * 174 | *************************************************** 175 | """ # noqa 176 | # Top level states 177 | statechart = Statechart(name='statechart') 178 | csa = CompositeState(name='csa', context=statechart) 179 | csb = CompositeState(name='csb', context=csa) 180 | csc = CompositeState(name='csc', context=statechart) 181 | 182 | # Child states 183 | # statechart 184 | statechart_init = InitialState(statechart) 185 | 186 | # csa 187 | csa_initial_state = InitialState(csa) 188 | csa_history_state = ShallowHistoryState(context=csa) 189 | csa_state_a = State(name='A', context=csa) 190 | # csb 191 | csb_init = InitialState(csb) 192 | csb_state_b = State(name='csb_state_b', context=csb) 193 | csb_state_c = State(name='csb_state_c', context=csb) 194 | # csc 195 | csc_initial_state = InitialState(csc) 196 | csc_state_d = State(name='csc_state_d', context=csc) 197 | csc_state_e = State(name='csc_state_e', context=csc) 198 | 199 | # Events 200 | csa_state_b_to_csb = Event(name='csa_state_b_to_csb') 201 | csb_state_b_to_csb_state_b = Event(name='csb_state_b_to_csb_state_b') 202 | csa_to_csc = Event(name='csa_to_csc') 203 | csc_to_csa = Event(name='csc_to_csa') 204 | csc_state_d_to_csc_state_d = Event(name='csc_state_d_to_csc_state_d') 205 | 206 | # Transitions between states & event triggers 207 | Transition(start=statechart_init, end=csa) 208 | Transition(start=csa_initial_state, end=csa_history_state) 209 | Transition(start=csa_history_state, end=csa_state_a) 210 | Transition(start=csa_state_a, end=csb, event=csa_state_b_to_csb) 211 | Transition(start=csb_init, end=csb_state_b) 212 | Transition(start=csb_state_b, end=csb_state_c, event=csb_state_b_to_csb_state_b) 213 | Transition(start=csa, end=csc, event=csa_to_csc) 214 | Transition(start=csc, end=csa, event=csc_to_csa) 215 | Transition(start=csc_initial_state, end=csc_state_d) 216 | Transition(start=csc_state_d, end=csc_state_e, event=csc_state_d_to_csc_state_d) 217 | 218 | # Execute statechart 219 | statechart.start() 220 | statechart.dispatch(csa_state_b_to_csb) 221 | 222 | assert statechart.is_active('csb_state_b') 223 | 224 | statechart.dispatch(csb_state_b_to_csb_state_b) 225 | 226 | # Assert we have reached state csb_state_c, history should restore csb_state_c's parent 227 | # state csb 228 | assert statechart.is_active('csb_state_c') 229 | 230 | statechart.dispatch(csa_to_csc) 231 | 232 | # Assert we have reached state csc_state_d 233 | assert statechart.is_active('csc_state_d') 234 | 235 | statechart.dispatch(csc_to_csa) 236 | 237 | # Assert the history state has restored state csb 238 | assert statechart.is_active('csb') 239 | 240 | def test_activate_multiple_shallow_history_states(self): 241 | """ 242 | statechart: 243 | 244 | statechart_init 245 | | 246 | *** csa ***************************************************** *** csc ************* 247 | * * * * 248 | * csa_init--csa_hist * * csc_init * 249 | * | * --K--> * | * 250 | * A --I--> *** csb *********************** * <--L-- * D --M--> E * 251 | * * * * * * 252 | * * csb_init--csb_hist * * ********************* 253 | * * | * * 254 | * * B --J--> C * * 255 | * * * * 256 | * ******************************* * 257 | * * 258 | ************************************************************* 259 | """ 260 | # Top level states 261 | statechart = Statechart(name='statechart') 262 | csa = CompositeState(name='csa', context=statechart) 263 | csb = CompositeState(name='csb', context=csa) 264 | csc = CompositeState(name='csc', context=statechart) 265 | 266 | # Child states 267 | # statechart 268 | statechart_init = InitialState(statechart) 269 | 270 | # csa 271 | csa_initial_state = InitialState(csa) 272 | csa_history_state = ShallowHistoryState(context=csa) 273 | csa_state_a = State(name='csa_state_a', context=csa) 274 | # csb 275 | csb_initial_state = InitialState(csb) 276 | csb_history_state = ShallowHistoryState(context=csb) 277 | csb_state_b = State(name='csb_state_b', context=csb) 278 | csb_state_c = State(name='csb_state_c', context=csb) 279 | # csc 280 | csc_initial_state = InitialState(csc) 281 | csc_state_d = State(name='csc_state_d', context=csc) 282 | csc_state_e = State(name='csc_state_e', context=csc) 283 | 284 | # Events 285 | csa_state_a_to_csb = Event(name='csa_state_a_to_csb') 286 | csb_state_b_to_csb_state_b = Event(name='csb_state_b_to_csb_state_b') 287 | csa_to_csc = Event(name='csa_to_csc') 288 | csc_to_sca = Event(name='csc_to_sca') 289 | csc_state_d_to_csc_state_e = Event(name='csc_state_d_to_csc_state_e') 290 | 291 | # Transitions between states & event triggers 292 | Transition(start=statechart_init, end=csa) 293 | Transition(start=csa_initial_state, end=csa_history_state) 294 | Transition(start=csa_history_state, end=csa_state_a) 295 | Transition(start=csa_state_a, end=csb, event=csa_state_a_to_csb) 296 | Transition(start=csb_initial_state, end=csb_history_state) 297 | Transition(start=csb_history_state, end=csb_state_b) 298 | Transition(start=csb_state_b, end=csb_state_c, event=csb_state_b_to_csb_state_b) 299 | Transition(start=csa, end=csc, event=csa_to_csc) 300 | Transition(start=csc, end=csa, event=csc_to_sca) 301 | Transition(start=csc_initial_state, end=csc_state_d) 302 | Transition(start=csc_state_d, end=csc_state_e, event=csc_state_d_to_csc_state_e) 303 | 304 | # Execute statechart 305 | statechart.start() 306 | statechart.dispatch(csa_state_a_to_csb) 307 | 308 | assert statechart.is_active('csb_state_b') 309 | 310 | statechart.dispatch(csb_state_b_to_csb_state_b) 311 | 312 | # Assert we have reached state csb_state_c, csb's history state should restore 313 | # this state 314 | assert statechart.is_active('csb_state_c') 315 | 316 | statechart.dispatch(csa_to_csc) 317 | 318 | # Assert we have reached state csc_state_d 319 | assert statechart.is_active('csc_state_d') 320 | 321 | statechart.dispatch(csc_to_sca) 322 | 323 | # Assert the history state has restored state csb_state_c 324 | assert statechart.is_active('csb_state_c') 325 | 326 | 327 | class TestChoiceState: 328 | def test_create_choice_state(self): 329 | startchart = Statechart(name='statechart') 330 | composite_state = CompositeState(name='composite', context=startchart) 331 | ShallowHistoryState(composite_state) 332 | 333 | @pytest.mark.parametrize('state_name, expected_state_name', 334 | [('a', 'a'), 335 | ('b', 'b')]) 336 | def test_choice_state_transitions(self, state_name, expected_state_name): 337 | def is_a(**kwargs): 338 | return state_name == 'a' 339 | 340 | statechart = Statechart(name='statechart') 341 | init = InitialState(statechart) 342 | 343 | state_a = State(name='a', context=statechart) 344 | state_b = State(name='b', context=statechart) 345 | 346 | choice = ChoiceState(context=statechart) 347 | 348 | Transition(start=init, end=choice) 349 | 350 | Transition(start=choice, end=state_a, event=None, guard=is_a) 351 | Transition(start=choice, end=state_b, event=None, guard=None) # else 352 | 353 | statechart.start() 354 | 355 | assert statechart.is_active(expected_state_name) 356 | -------------------------------------------------------------------------------- /tests/test_runtime.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2016, Leigh McKenzie 4 | # All rights reserved. 5 | # 6 | # Permission to use, copy, modify, and/or distribute this software for any 7 | # purpose with or without fee is hereby granted, provided that the above 8 | # copyright notice and this permission notice appear in all copies. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 13 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 15 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 16 | # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 17 | 18 | import pytest 19 | from statechart import InitialState, State, Statechart, Transition 20 | 21 | 22 | @pytest.fixture 23 | def state(): 24 | statechart = Statechart(name='statechart') 25 | initial_state = InitialState(statechart) 26 | next_state = State(name='next', context=statechart) 27 | Transition(initial_state, next_state) 28 | statechart.start() 29 | return next_state 30 | -------------------------------------------------------------------------------- /tests/test_states.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2016, Leigh McKenzie 4 | # All rights reserved. 5 | # 6 | # Permission to use, copy, modify, and/or distribute this software for any 7 | # purpose with or without fee is hereby granted, provided that the above 8 | # copyright notice and this permission notice appear in all copies. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 13 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 15 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 16 | # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 17 | 18 | import pytest 19 | 20 | from statechart import (CompositeState, ConcurrentState, Event, FinalState, 21 | InitialState, State, Statechart, Transition) 22 | 23 | 24 | class StateSpy(State): 25 | def __init__(self, name, context): 26 | State.__init__(self, name=name, context=context) 27 | self.dispatch_called = False 28 | self.dispatch_internal_called = False 29 | self.metadata = None 30 | self.event = None 31 | 32 | def dispatch(self, metadata, event): 33 | self.dispatch_called = True 34 | self.metadata = metadata 35 | self.event = event 36 | return True 37 | 38 | def handle_internal(self, event): 39 | self.dispatch_internal_called = True 40 | 41 | 42 | class TestStatechart: 43 | def test_create_statechart(self): 44 | Statechart(name='statechart') 45 | 46 | def test_simple_statechart_finished(self): 47 | statechart = Statechart(name='statechart') 48 | init = InitialState(statechart) 49 | default = State(name='default', context=statechart) 50 | final = FinalState(statechart) 51 | 52 | finish = Event('finish') 53 | 54 | Transition(start=init, end=default) 55 | Transition(start=default, end=final, event=finish) 56 | statechart.start() 57 | 58 | assert statechart.is_active('default') 59 | assert not statechart.finished 60 | 61 | statechart.dispatch(finish) 62 | 63 | assert statechart.finished 64 | 65 | def test_composite_statechart_finished(self): 66 | statechart = Statechart(name='statechart') 67 | init = InitialState(statechart) 68 | final = FinalState(statechart) 69 | 70 | composite = CompositeState(name='composite', context=statechart) 71 | composite_init = InitialState(composite) 72 | composite_default = State(name='composite_default', context=composite) 73 | composite_final = FinalState(composite) 74 | 75 | finish = Event('finish') 76 | 77 | Transition(start=init, end=composite) 78 | Transition(start=composite_init, end=composite_default) 79 | Transition(start=composite_default, end=composite_final, event=finish) 80 | Transition(start=composite, end=final) 81 | 82 | statechart.start() 83 | 84 | assert statechart.is_active('composite') 85 | assert statechart.is_active('composite_default') 86 | assert not statechart.finished 87 | 88 | statechart.dispatch(finish) 89 | 90 | assert statechart.finished 91 | 92 | def test_active_states(self): 93 | statechart = Statechart(name='a') 94 | statechart_init = InitialState(statechart) 95 | 96 | b = CompositeState(name='b', context=statechart) 97 | b_init = InitialState(b) 98 | 99 | c = CompositeState(name='c', context=b) 100 | c_init = InitialState(c) 101 | 102 | d = State(name='d', context=c) 103 | 104 | Transition(start=statechart_init, end=b) 105 | Transition(start=b_init, end=c) 106 | Transition(start=c_init, end=d) 107 | 108 | statechart.start() 109 | assert statechart.active_states() == [statechart, b, c, d] 110 | 111 | 112 | class TestState: 113 | def test_create_state(self): 114 | statechart = Statechart(name='statechart') 115 | State(name='anon', context=statechart) 116 | 117 | def test_create_state_without_parent(self): 118 | with pytest.raises(RuntimeError): 119 | State(name='anon', context=None) 120 | 121 | def test_add_transition(self): 122 | statechart = Statechart(name='statechart') 123 | initial_state = InitialState(statechart) 124 | default_state = State(name='default', context=statechart) 125 | 126 | default_transition = Transition(start=initial_state, end=default_state) 127 | 128 | assert default_transition in initial_state.transitions 129 | 130 | def test_light_switch(self): 131 | """ 132 | --Flick--> 133 | init ---> Off On 134 | <--Flick-- Entry: Light = ON 135 | Exit: Light = OFF 136 | Internal: 137 | Flick: Count++ 138 | """ 139 | 140 | class On(State): 141 | def __init__(self, name, context, data): 142 | State.__init__(self, name=name, context=context) 143 | self.data = data 144 | 145 | def entry(self, event): 146 | self.data['light'] = 'on' 147 | 148 | def exit(self, event): 149 | self.data['light'] = 'off' 150 | 151 | def handle_internal(self, event): 152 | if event.name == 'flick': 153 | self.data['on_count'] += 1 154 | 155 | sm = Statechart(name='sm') 156 | data = dict(light='off', on_count=0) 157 | sm.initial_state = InitialState(context=sm) 158 | off = State(name='off', context=sm) 159 | on = On(name='on', context=sm, data=data) 160 | 161 | Transition(start=sm.initial_state, end=off) 162 | Transition(start=off, end=on, event=Event('flick')) 163 | Transition(start=on, end=off, event=Event('flick')) 164 | 165 | sm.start() 166 | 167 | assert data['light'] == 'off' 168 | 169 | sm.dispatch(Event('flick')) 170 | assert data['light'] == 'on' 171 | 172 | assert data['on_count'] == 0 173 | 174 | sm.dispatch(Event('flick')) 175 | assert data['light'] == 'off' 176 | 177 | assert data['on_count'] == 1 178 | 179 | 180 | class TestFinalState: 181 | def test_add_transition(self): 182 | statechart = Statechart(name='statechart') 183 | final_state = FinalState(statechart) 184 | 185 | with pytest.raises(RuntimeError): 186 | Transition(start=final_state, end=statechart) 187 | 188 | def test_transition_from_finished_composite_state(self): 189 | statechart = Statechart(name='statechart') 190 | statechart_init = InitialState(statechart) 191 | 192 | composite_state = CompositeState(name='composite', context=statechart) 193 | comp_init = InitialState(composite_state) 194 | a = State(name='a', context=composite_state) 195 | comp_final = FinalState(composite_state) 196 | 197 | Transition(start=statechart_init, end=composite_state) 198 | Transition(start=comp_init, end=a) 199 | Transition(start=a, end=comp_final, event=Event('e')) 200 | 201 | b = State(name='b', context=statechart) 202 | Transition(start=composite_state, end=b) 203 | 204 | statechart.start() 205 | assert statechart.is_active('a') 206 | statechart.dispatch(Event('e')) 207 | assert statechart.is_active('b') 208 | 209 | def test_default_transition_from_finished_composite_state(self): 210 | statechart = Statechart(name='statechart') 211 | statechart_init = InitialState(statechart) 212 | 213 | composite_state = CompositeState(name='composite', context=statechart) 214 | comp_init = InitialState(composite_state) 215 | a = State(name='a', context=composite_state) 216 | comp_final = FinalState(composite_state) 217 | 218 | Transition(start=statechart_init, end=composite_state) 219 | Transition(start=comp_init, end=a) 220 | Transition(start=a, end=comp_final, event=Event('e')) 221 | 222 | b = State(name='b', context=statechart) 223 | c = State(name='c', context=statechart) 224 | d = State(name='d', context=statechart) 225 | 226 | Transition(start=composite_state, end=c, event=Event('f')) 227 | Transition(start=composite_state, end=b, event=Event('e')) 228 | Transition(start=composite_state, end=d) 229 | 230 | statechart.start() 231 | 232 | assert statechart.is_active('a') 233 | 234 | statechart.dispatch(Event('e')) 235 | 236 | assert statechart.is_active('d') 237 | 238 | def test_default_transition_isnt_executed_from_unfinished_composite_state(self): 239 | statechart = Statechart(name='statechart') 240 | statechart_init = InitialState(statechart) 241 | 242 | composite_state = CompositeState(name='composite', context=statechart) 243 | comp_init = InitialState(composite_state) 244 | a = State(name='a', context=composite_state) 245 | 246 | Transition(start=statechart_init, end=composite_state) 247 | Transition(start=comp_init, end=a) 248 | 249 | b = State(name='b', context=statechart) 250 | 251 | Transition(start=composite_state, end=b) 252 | 253 | statechart.start() 254 | 255 | assert statechart.is_active('a') 256 | 257 | statechart.dispatch(Event('e')) 258 | 259 | assert statechart.is_active('a') 260 | 261 | 262 | # TODO(lam) Add test with final states - state shouldn't dispatch default 263 | # event until all regions have finished. 264 | # TOOD(lam) Add test for transition directly into a concurrent, composite sub 265 | # state. 266 | class TestConcurrentState: 267 | def test_keyboard_example(self): 268 | """ 269 | Test classic concurrent state keyboard example with concurrent states 270 | for caps, num and scroll lock. 271 | 272 | init - - 273 | | 274 | v 275 | -- keyboard -------------------------------------- 276 | | | 277 | | init ---> --caps lock off -- | 278 | | --- | | <-- | 279 | | | -----------------| | | 280 | | caps lock pressed caps lock pressed | 281 | | | -- caps lock on -- | | 282 | | --> | | --- | 283 | | ------------------ | 284 | | | 285 | -------------------------------------------------- 286 | | | 287 | | init ---> --num lock off --- | 288 | | --- | | <-- | 289 | | | -----------------| | | 290 | | num lock pressed num lock pressed | 291 | | | -- num lock on --- | | 292 | | --> | | --- | 293 | | ------------------ | 294 | | | 295 | -------------------------------------------------- 296 | | | 297 | | init ---> -- scroll lock off -- | 298 | | --- | | <-- | 299 | | | ---------------------| | | 300 | | scroll lock pressed scroll lock pressed | 301 | | | -- scroll lock on ---| | | 302 | | --> | | --- | 303 | | ---------------------- | 304 | | | 305 | -------------------------------------------------- 306 | """ 307 | statechart = Statechart(name='statechart') 308 | 309 | start_state = InitialState(statechart) 310 | keyboard = ConcurrentState(name='keyboard', context=statechart) 311 | Transition(start=start_state, end=keyboard) 312 | 313 | caps_lock = CompositeState(name='caps_lock', context=keyboard) 314 | caps_lock_initial = InitialState(caps_lock) 315 | caps_lock_on = State(name='caps_lock_on', context=caps_lock) 316 | caps_lock_off = State(name='caps_lock_off', context=caps_lock) 317 | caps_lock_pressed = Event(name='caps_lock_pressed') 318 | Transition(start=caps_lock_initial, end=caps_lock_off) 319 | Transition(start=caps_lock_on, end=caps_lock_off, event=caps_lock_pressed) 320 | Transition(start=caps_lock_off, end=caps_lock_on, event=caps_lock_pressed) 321 | 322 | num_lock = CompositeState(name='num_lock', context=keyboard) 323 | num_lock_initial = InitialState(num_lock) 324 | num_lock_on = State(name='num_lock_on', context=num_lock) 325 | num_lock_off = State(name='num_lock_off', context=num_lock) 326 | num_lock_pressed = Event(name='num_lock_pressed') 327 | Transition(start=num_lock_initial, end=num_lock_off) 328 | Transition(start=num_lock_on, end=num_lock_off, event=num_lock_pressed) 329 | Transition(start=num_lock_off, end=num_lock_on, event=num_lock_pressed) 330 | 331 | scroll_lock = CompositeState(name='scroll_lock', context=keyboard) 332 | scroll_lock_initial = InitialState(scroll_lock) 333 | scroll_lock_on = State(name='scroll_lock_on', context=scroll_lock) 334 | scroll_lock_off = State(name='scroll_lock_off', context=scroll_lock) 335 | scroll_lock_pressed = Event(name='scroll_lock_pressed') 336 | Transition(start=scroll_lock_initial, end=scroll_lock_off) 337 | Transition(start=scroll_lock_on, end=scroll_lock_off, event=scroll_lock_pressed) 338 | Transition(start=scroll_lock_off, end=scroll_lock_on, event=scroll_lock_pressed) 339 | 340 | statechart.start() 341 | 342 | assert statechart.is_active('keyboard') 343 | assert statechart.is_active('caps_lock_off') 344 | assert statechart.is_active('num_lock_off') 345 | assert statechart.is_active('scroll_lock_off') 346 | 347 | statechart.dispatch(event=caps_lock_pressed) 348 | assert statechart.is_active('caps_lock_on') 349 | 350 | statechart.dispatch(event=num_lock_pressed) 351 | assert statechart.is_active('num_lock_on') 352 | 353 | statechart.dispatch(event=scroll_lock_pressed) 354 | assert statechart.is_active('scroll_lock_on') 355 | 356 | statechart.dispatch(event=caps_lock_pressed) 357 | assert statechart.is_active('caps_lock_off') 358 | 359 | statechart.dispatch(event=num_lock_pressed) 360 | assert statechart.is_active('num_lock_off') 361 | 362 | statechart.dispatch(event=scroll_lock_pressed) 363 | assert statechart.is_active('scroll_lock_off') 364 | 365 | 366 | class TestCompositeState: 367 | class Submachine(CompositeState): 368 | def __init__(self, name, context): 369 | CompositeState.__init__(self, name=name, context=context) 370 | 371 | init = InitialState(self) 372 | self.state_a = State(name='sub state a', context=self) 373 | self.state_b = State(name='sub state b', context=self) 374 | 375 | self.sub_a_to_b = Event('sub_ab') 376 | Transition(start=init, end=self.state_a) 377 | Transition(start=self.state_a, end=self.state_b, event=self.sub_a_to_b) 378 | 379 | def test_submachines(self): 380 | statechart = Statechart(name='statechart') 381 | 382 | init = InitialState(statechart) 383 | top_a = self.Submachine('top a', statechart) 384 | top_b = self.Submachine('top b', statechart) 385 | 386 | top_a_to_b = Event('top ab') 387 | Transition(start=init, end=top_a) 388 | Transition(start=top_a, end=top_b, event=top_a_to_b) 389 | 390 | statechart.start() 391 | 392 | assert statechart.is_active('top a') 393 | assert statechart.is_active('sub state a') 394 | 395 | statechart.dispatch(top_a.sub_a_to_b) 396 | 397 | assert statechart.is_active('top a') 398 | assert statechart.is_active('sub state b') 399 | 400 | statechart.dispatch(top_a_to_b) 401 | 402 | assert statechart.is_active('top b') 403 | assert statechart.is_active('sub state a') 404 | 405 | statechart.dispatch(top_a.sub_a_to_b) 406 | 407 | assert statechart.is_active('top b') 408 | assert statechart.is_active('sub state b') 409 | -------------------------------------------------------------------------------- /tests/test_transitions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2016, Leigh McKenzie 4 | # All rights reserved. 5 | # 6 | # Permission to use, copy, modify, and/or distribute this software for any 7 | # purpose with or without fee is hereby granted, provided that the above 8 | # copyright notice and this permission notice appear in all copies. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 13 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 15 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 16 | # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 17 | 18 | from functools import partial 19 | 20 | import pytest 21 | 22 | from statechart import (CompositeState, Event, State, InitialState, Statechart, Transition) 23 | 24 | 25 | @pytest.fixture 26 | def empty_statechart(): 27 | statechart = Statechart(name='statechart') 28 | return statechart 29 | 30 | 31 | class TestTransition: 32 | class StateSpy(CompositeState): 33 | def __init__(self, name, context): 34 | super().__init__(name=name, context=context) 35 | 36 | # Count state entries and exit 37 | self.entries = 0 38 | self.exits = 0 39 | 40 | init = InitialState(self) 41 | self.default = State(name='default', context=self) 42 | self.local = State(name='local', context=self) 43 | 44 | Transition(start=init, end=self.default) 45 | 46 | def entry(self, event): 47 | self.entries += 1 48 | 49 | def exit(self, event): 50 | self.exits += 1 51 | 52 | def test_create_transition(self, empty_statechart): 53 | initial_state = InitialState(empty_statechart) 54 | next_state = State(name='next', context=empty_statechart) 55 | transition = Transition(start=initial_state, end=next_state) 56 | 57 | # The transition should be added to the initial state's list of 58 | # outgoing transitions 59 | assert transition in initial_state.transitions 60 | 61 | # When executed, the transition should be setup to deactivate the 62 | # initial state and to activate the next state 63 | assert initial_state in transition.deactivate 64 | assert next_state in transition.activate 65 | 66 | def test_create_cyclic_transition(self, empty_statechart): 67 | next_state = State(name='next', context=empty_statechart) 68 | transition = Transition(start=next_state, end=next_state) 69 | 70 | # The transition should be added to the initial state's list of 71 | # outgoing transitions. 72 | assert transition in next_state.transitions 73 | 74 | # When executed, the transition should be setup to deactivate the 75 | # next state and to re-activate it. 76 | assert next_state in transition.deactivate 77 | assert next_state in transition.activate 78 | 79 | def test_external_transition(self, empty_statechart): 80 | init = InitialState(empty_statechart) 81 | state_spy = self.StateSpy(name='spy', context=empty_statechart) 82 | 83 | Transition(start=init, end=state_spy) 84 | Transition(start=state_spy, end=state_spy, event='extern') 85 | 86 | empty_statechart.start() 87 | 88 | assert empty_statechart.is_active('spy') 89 | assert state_spy.entries == 1 90 | assert state_spy.exits == 0 91 | 92 | empty_statechart.dispatch(Event('extern')) 93 | 94 | # After dispatching the external event from the state spy, the 95 | # state should be deactivated and activated again. 96 | assert empty_statechart.is_active('spy') 97 | assert state_spy.entries == 2 98 | assert state_spy.exits == 1 99 | 100 | def test_local_transition(self, empty_statechart): 101 | init = InitialState(empty_statechart) 102 | state_spy = self.StateSpy(name='spy', context=empty_statechart) 103 | 104 | Transition(start=init, end=state_spy) 105 | Transition(start=state_spy, end=state_spy.local, event=Event('local')) 106 | 107 | empty_statechart.start() 108 | 109 | assert empty_statechart.is_active('spy') 110 | assert empty_statechart.is_active('default') 111 | assert state_spy.entries == 1 112 | assert state_spy.exits == 0 113 | 114 | empty_statechart.dispatch(Event('local')) 115 | 116 | assert empty_statechart.is_active('spy') 117 | assert not empty_statechart.is_active('default') 118 | assert empty_statechart.is_active('local') 119 | assert state_spy.entries == 1 120 | assert state_spy.exits == 0 121 | 122 | def test_deep_local_transitions(self, empty_statechart): 123 | sc = empty_statechart 124 | init = InitialState(sc) 125 | 126 | top = CompositeState(name='top', context=sc) 127 | top_init = InitialState(top) 128 | middle_a = CompositeState(name='middle_a', context=top) 129 | middle_b = CompositeState(name='middle_b', context=top) 130 | 131 | middle_a_init = InitialState(middle_a) 132 | bottom_a1 = State(name='bottom_a1', context=middle_a) 133 | bottom_a2 = State(name='bottom_a2', context=middle_a) 134 | 135 | middle_b_init = InitialState(middle_b) 136 | bottom_b1 = State(name='bottom_b1', context=middle_b) 137 | bottom_b2 = State(name='bottom_b2', context=middle_b) 138 | 139 | # Setup default transitions 140 | Transition(start=init, end=top) 141 | Transition(start=top_init, end=middle_a) 142 | Transition(start=middle_a_init, end=bottom_a1) 143 | Transition(start=middle_b_init, end=bottom_b1) 144 | 145 | # Setup events to trigger transitions 146 | a_to_b = Event('a_to_b') 147 | a1_to_a2 = Event('a1_to_a2') 148 | b1_to_b2 = Event('b1_to_b2') 149 | top_to_middle_a = Event('top_to_middle_a') 150 | top_to_middle_b = Event('top_to_middle_b') 151 | middle_a_to_a1 = Event('middle_a_to_a1') 152 | middle_a_to_a2 = Event('middle_a_to_a2') 153 | middle_b_to_b1 = Event('middle_b_to_b1') 154 | middle_b_to_b2 = Event('middle_b_to_b2') 155 | 156 | # Setup external transitions 157 | Transition(start=middle_a, end=middle_b, event=a_to_b) 158 | Transition(start=bottom_a1, end=bottom_a2, event=a1_to_a2) 159 | Transition(start=bottom_a1, end=bottom_a2, event=b1_to_b2) 160 | 161 | # Setup local transitions 162 | Transition(start=top, end=middle_a, event=top_to_middle_a) 163 | Transition(start=top, end=middle_b, event=top_to_middle_b) 164 | 165 | Transition(start=middle_a, end=bottom_a1, event=middle_a_to_a1) 166 | Transition(start=middle_a, end=bottom_a2, event=middle_a_to_a2) 167 | 168 | Transition(start=middle_b, end=bottom_b1, event=middle_b_to_b1) 169 | Transition(start=middle_b, end=bottom_b2, event=middle_b_to_b2) 170 | 171 | sc.start() 172 | 173 | assert sc.is_active('top') 174 | assert sc.is_active('middle_a') 175 | assert sc.is_active('bottom_a1') 176 | 177 | sc.dispatch(middle_a_to_a2) 178 | 179 | assert sc.is_active('top') 180 | assert sc.is_active('middle_a') 181 | assert sc.is_active('bottom_a2') 182 | 183 | sc.dispatch(top_to_middle_b) 184 | 185 | assert sc.is_active('top') 186 | assert sc.is_active('middle_b') 187 | assert sc.is_active('bottom_b1') 188 | 189 | sc.dispatch(top_to_middle_a) 190 | 191 | assert sc.is_active('top') 192 | assert sc.is_active('middle_a') 193 | assert sc.is_active('bottom_a1') 194 | 195 | sc.dispatch(a_to_b) 196 | 197 | assert sc.is_active('top') 198 | assert sc.is_active('middle_b') 199 | assert sc.is_active('bottom_b1') 200 | 201 | sc.dispatch(middle_b_to_b2) 202 | 203 | assert sc.is_active('top') 204 | assert sc.is_active('middle_b') 205 | assert sc.is_active('bottom_b2') 206 | 207 | def test_transition_hierarchy(self, empty_statechart): 208 | sc = empty_statechart 209 | init = InitialState(sc) 210 | 211 | top = CompositeState(name='top', context=sc) 212 | top_init = InitialState(top) 213 | middle_a = CompositeState(name='middle_a', context=top) 214 | middle_b = CompositeState(name='middle_b', context=top) 215 | 216 | middle_a_init = InitialState(middle_a) 217 | bottom_a1 = State(name='bottom_a1', context=middle_a) 218 | bottom_a2 = State(name='bottom_a2', context=middle_a) 219 | 220 | middle_b_init = InitialState(middle_b) 221 | bottom_b1 = State(name='bottom_b1', context=middle_b) 222 | 223 | # Setup default transitions 224 | Transition(start=init, end=top) 225 | Transition(start=top_init, end=middle_a) 226 | Transition(start=middle_a_init, end=bottom_a1) 227 | Transition(start=middle_b_init, end=bottom_b1) 228 | 229 | # Setup event triggers 230 | across = Event('across') 231 | up = Event('up') 232 | 233 | # Setup external transitions 234 | Transition(start=bottom_a1, end=bottom_a2, event=across) 235 | Transition(start=bottom_a2, end=middle_b, event=up) 236 | Transition(start=middle_b, end=middle_a, event=across) 237 | 238 | sc.start() 239 | 240 | assert sc.is_active('top') 241 | assert sc.is_active('middle_a') 242 | assert sc.is_active('bottom_a1') 243 | 244 | sc.dispatch(across) 245 | 246 | assert sc.is_active('top') 247 | assert sc.is_active('middle_a') 248 | assert sc.is_active('bottom_a2') 249 | 250 | sc.dispatch(up) 251 | 252 | assert sc.is_active('top') 253 | assert sc.is_active('middle_b') 254 | assert sc.is_active('bottom_b1') 255 | 256 | sc.dispatch(across) 257 | 258 | assert sc.is_active('top') 259 | assert sc.is_active('middle_a') 260 | assert sc.is_active('bottom_a1') 261 | 262 | def test_transition_event_consumed(self, empty_statechart): 263 | sc = empty_statechart 264 | init = InitialState(sc) 265 | 266 | # a = State(name='a', context=sc) 267 | b = State(name='b', context=sc) 268 | 269 | cs = CompositeState(name='cs', context=sc) 270 | cs_init = InitialState(cs) 271 | cs_a = State(name='cs a', context=cs) 272 | cs_b = State(name='cs b', context=cs) 273 | 274 | Transition(start=init, end=cs) 275 | Transition(start=cs, end=cs_init) 276 | 277 | Transition(start=cs_init, end=cs_a) 278 | Transition(start=cs_a, end=cs_b, event='home') 279 | Transition(start=cs, end=b, event='home') 280 | 281 | sc.start() 282 | 283 | assert sc.is_active('cs a') 284 | 285 | sc.dispatch(Event('home')) 286 | 287 | assert sc.is_active('cs b') 288 | 289 | def test_transition_action_function(self, empty_statechart): 290 | self.state = False 291 | 292 | def set_state(state): 293 | self.state = bool(state) 294 | 295 | set_true = partial(set_state, True) 296 | 297 | sc = empty_statechart 298 | initial = InitialState(sc) 299 | default = State(name='default', context=sc) 300 | next = State(name='next', context=sc) 301 | 302 | Transition(start=initial, end=default) 303 | Transition(start=default, end=next, event='next', action=set_true) 304 | 305 | sc.start() 306 | sc.dispatch(Event('next')) 307 | 308 | assert self.state 309 | 310 | def test_transition_action_function_with_event(self, empty_statechart): 311 | self.state = False 312 | 313 | def set_state(event): 314 | self.state = event.data['state'] 315 | 316 | sc = empty_statechart 317 | initial = InitialState(sc) 318 | default = State(name='default', context=sc) 319 | next = State(name='next', context=sc) 320 | 321 | Transition(start=initial, end=default) 322 | Transition(start=default, end=next, event='next', action=set_state) 323 | 324 | sc.start() 325 | sc.dispatch(Event(name='next', data={'state': True})) 326 | 327 | assert self.state 328 | 329 | def test_transition_action_function_with_metadata(self, empty_statechart): 330 | sc = empty_statechart 331 | sc.metadata.state = True 332 | 333 | self.state = False 334 | 335 | def set_state(event): 336 | self.state = sc.metadata.state 337 | 338 | initial = InitialState(sc) 339 | default = State(name='default', context=sc) 340 | next = State(name='next', context=sc) 341 | 342 | Transition(start=initial, end=default) 343 | Transition(start=default, end=next, event='next', action=set_state) 344 | 345 | sc.start() 346 | sc.dispatch(Event('next')) 347 | 348 | assert self.state 349 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py39, py310, py311, py312, py313, flake8 3 | 4 | [travis] 5 | python = 6 | 3.9: py39 7 | 3.10: py310 8 | 3.11: py311 9 | 3.12: py312 10 | 3.13: py313 11 | 12 | [testenv:flake8] 13 | basepython = python 14 | deps = flake8 15 | commands = flake8 statechart tests 16 | 17 | [testenv] 18 | setenv = 19 | PYTHONPATH = {toxinidir} 20 | deps = 21 | -r{toxinidir}/requirements_dev.txt 22 | ; If you want to make tox run the tests with the same versions, create a 23 | ; requirements.txt with the pinned versions and uncomment the following line: 24 | ; -r{toxinidir}/requirements.txt 25 | commands = 26 | pytest --basetemp={envtmpdir} 27 | --------------------------------------------------------------------------------