├── .coveragerc ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .pydoctor.cfg ├── .readthedocs.yaml ├── LICENSE ├── README.md ├── SECURITY.md ├── benchmark └── test_transitions.py ├── docs ├── Makefile ├── _static │ ├── garage_door.machineFactory.dot.png │ └── mystate.machine.MyMachine._machine.dot.png ├── api │ └── index.rst ├── compare.rst ├── conf.py ├── examples │ ├── automat_card.py │ ├── automat_example.py │ ├── coffee_expanded.py │ ├── dont_get_state.py │ ├── feedback_debugging.py │ ├── feedback_errors.py │ ├── feedback_order.py │ ├── garage_door.py │ ├── garage_door_security.py │ ├── io_coffee_example.py │ ├── lightswitch.py │ ├── serialize_machine.py │ ├── turnstile_example.py │ └── turnstile_typified.py ├── index.rst ├── requirements.in ├── requirements.txt ├── tutorial.rst └── visualize.rst ├── mypy.ini ├── pyproject.toml ├── src └── automat │ ├── __init__.py │ ├── _core.py │ ├── _discover.py │ ├── _introspection.py │ ├── _methodical.py │ ├── _runtimeproto.py │ ├── _test │ ├── __init__.py │ ├── test_core.py │ ├── test_discover.py │ ├── test_methodical.py │ ├── test_trace.py │ ├── test_type_based.py │ └── test_visualize.py │ ├── _typed.py │ ├── _visualize.py │ └── py.typed ├── tox.ini └── typical_example_happy.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | precision = 2 3 | ignore_errors = True 4 | exclude_lines = 5 | pragma: no cover 6 | if TYPE_CHECKING 7 | \s*\.\.\.$ 8 | raise NotImplementedError 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - trunk 7 | 8 | pull_request: 9 | branches: 10 | - trunk 11 | 12 | jobs: 13 | build: 14 | name: python/${{ matrix.python }} tox/${{ matrix.TOX_ENV }} 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python: ["3.9", "3.10", "3.11", "3.12", "3.13"] 20 | TOX_ENV: ["extras", "noextras", "mypy"] 21 | include: 22 | - python: "3.13" 23 | TOX_ENV: "lint" 24 | 25 | steps: 26 | - uses: actions/checkout@v3 27 | - name: Set up Python 28 | uses: actions/setup-python@v3 29 | with: 30 | python-version: ${{ matrix.python }} 31 | - name: Install graphviz 32 | run: | 33 | sudo apt-get install -y graphviz 34 | dot -V 35 | if: ${{ matrix.TOX_ENV == 'extras' }} 36 | - name: Tox Run 37 | run: | 38 | pip install tox; 39 | TOX_ENV="py$(echo ${{ matrix.python }} | sed -e 's/\.//g')-${{ matrix.TOX_ENV }}"; 40 | echo "Starting: ${TOX_ENV} ${PUSH_DOCS}" 41 | if [[ -n "${TOX_ENV}" ]]; then 42 | tox -e "$TOX_ENV"; 43 | if [[ "${{ matrix.TOX_ENV }}" != "mypy" && "${{ matrix.TOX_ENV }}" != "lint" ]]; then 44 | tox -e coverage-report; 45 | fi; 46 | fi; 47 | - name: Upload coverage report 48 | if: ${{ matrix.TOX_ENV != 'mypy' }} 49 | uses: codecov/codecov-action@v4.5.0 50 | with: 51 | token: ${{ secrets.CODECOV_TOKEN }} 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .tox/ 2 | .coverage.* 3 | .eggs/ 4 | *.egg-info/ 5 | *.py[co] 6 | build/ 7 | dist/ 8 | docs/_build/ 9 | coverage.xml 10 | 11 | -------------------------------------------------------------------------------- /.pydoctor.cfg: -------------------------------------------------------------------------------- 1 | [tool:pydoctor] 2 | quiet=1 3 | warnings-as-errors=true 4 | project-name=Automat 5 | project-url=../index.html 6 | docformat=epytext 7 | theme=readthedocs 8 | intersphinx= 9 | https://graphviz.readthedocs.io/en/stable/objects.inv 10 | https://docs.python.org/3/objects.inv 11 | https://cryptography.io/en/latest/objects.inv 12 | https://pyopenssl.readthedocs.io/en/stable/objects.inv 13 | https://hyperlink.readthedocs.io/en/stable/objects.inv 14 | https://twisted.org/constantly/docs/objects.inv 15 | https://twisted.org/incremental/docs/objects.inv 16 | https://python-hyper.org/projects/hyper-h2/en/stable/objects.inv 17 | https://priority.readthedocs.io/en/stable/objects.inv 18 | https://zopeinterface.readthedocs.io/en/latest/objects.inv 19 | https://automat.readthedocs.io/en/latest/objects.inv 20 | https://docs.twisted.org/en/stable/objects.inv 21 | project-base-dir=automat 22 | html-output=docs/_build/api 23 | html-viewsource-base=https://github.com/glyph/automat/tree/trunk 24 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file for Sphinx projects 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the OS, Python version and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.12" 12 | # You can also specify other tool versions: 13 | # nodejs: "20" 14 | # rust: "1.70" 15 | # golang: "1.20" 16 | 17 | # Build documentation in the "docs/" directory with Sphinx 18 | sphinx: 19 | configuration: docs/conf.py 20 | # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs 21 | # builder: "dirhtml" 22 | # Fail on all warnings to avoid broken references 23 | # fail_on_warning: true 24 | 25 | # Optionally build your docs in additional formats such as PDF and ePub 26 | # formats: 27 | # - pdf 28 | # - epub 29 | 30 | # Optional but recommended, declare the Python requirements required 31 | # to build your documentation 32 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 33 | python: 34 | install: 35 | - requirements: docs/requirements.txt 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 2 | Rackspace 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining 5 | a copy of this software and associated documentation files (the 6 | "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, 8 | distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so, subject to 10 | the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 19 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 20 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 21 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Automat # 2 | 3 | [![Documentation Status](https://readthedocs.org/projects/automat/badge/?version=latest)](http://automat.readthedocs.io/en/latest/) 4 | [![Build Status](https://github.com/glyph/automat/actions/workflows/ci.yml/badge.svg?branch=trunk)](https://github.com/glyph/automat/actions/workflows/ci.yml?query=branch%3Atrunk) 5 | [![Coverage Status](http://codecov.io/github/glyph/automat/coverage.svg?branch=trunk)](http://codecov.io/github/glyph/automat?branch=trunk) 6 | 7 | ## Self-service finite-state machines for the programmer on the go. ## 8 | 9 | Automat is a library for concise, idiomatic Python expression of finite-state 10 | automata (particularly deterministic finite-state transducers). 11 | 12 | Read more here, or on [Read the Docs](https://automat.readthedocs.io/), or watch the following videos for an overview and presentation 13 | 14 | ### Why use state machines? ### 15 | 16 | Sometimes you have to create an object whose behavior varies with its state, 17 | but still wishes to present a consistent interface to its callers. 18 | 19 | For example, let's say you're writing the software for a coffee machine. It 20 | has a lid that can be opened or closed, a chamber for water, a chamber for 21 | coffee beans, and a button for "brew". 22 | 23 | There are a number of possible states for the coffee machine. It might or 24 | might not have water. It might or might not have beans. The lid might be open 25 | or closed. The "brew" button should only actually attempt to brew coffee in 26 | one of these configurations, and the "open lid" button should only work if the 27 | coffee is not, in fact, brewing. 28 | 29 | With diligence and attention to detail, you can implement this correctly using 30 | a collection of attributes on an object; `hasWater`, `hasBeans`, `isLidOpen` 31 | and so on. However, you have to keep all these attributes consistent. As the 32 | coffee maker becomes more complex - perhaps you add an additional chamber for 33 | flavorings so you can make hazelnut coffee, for example - you have to keep 34 | adding more and more checks and more and more reasoning about which 35 | combinations of states are allowed. 36 | 37 | Rather than adding tedious `if` checks to every single method to make sure that 38 | each of these flags are exactly what you expect, you can use a state machine to 39 | ensure that if your code runs at all, it will be run with all the required 40 | values initialized, because they have to be called in the order you declare 41 | them. 42 | 43 | You can read about state machines and their advantages for Python programmers 44 | in more detail [in this excellent article by Jean-Paul 45 | Calderone](https://web.archive.org/web/20160507053658/https://clusterhq.com/2013/12/05/what-is-a-state-machine/). 46 | 47 | ### What makes Automat different? ### 48 | 49 | There are 50 | [dozens of libraries on PyPI implementing state machines](https://pypi.org/search/?q=finite+state+machine). 51 | So it behooves me to say why yet another one would be a good idea. 52 | 53 | Automat is designed around this principle: while organizing your code around 54 | state machines is a good idea, your callers don't, and shouldn't have to, care 55 | that you've done so. In Python, the "input" to a stateful system is a method 56 | call; the "output" may be a method call, if you need to invoke a side effect, 57 | or a return value, if you are just performing a computation in memory. Most 58 | other state-machine libraries require you to explicitly create an input object, 59 | provide that object to a generic "input" method, and then receive results, 60 | sometimes in terms of that library's interfaces and sometimes in terms of 61 | classes you define yourself. 62 | 63 | For example, a snippet of the coffee-machine example above might be implemented 64 | as follows in naive Python: 65 | 66 | ```python 67 | class CoffeeMachine(object): 68 | def brewButton(self) -> None: 69 | if self.hasWater and self.hasBeans and not self.isLidOpen: 70 | self.heatTheHeatingElement() 71 | # ... 72 | ``` 73 | 74 | With Automat, you'd begin with a `typing.Protocol` that describes all of your 75 | inputs: 76 | 77 | ```python 78 | from typing import Protocol 79 | 80 | class CoffeeBrewer(Protocol): 81 | def brewButton(self) -> None: 82 | "The user pressed the 'brew' button." 83 | def putInBeans(self) -> None: 84 | "The user put in some beans." 85 | ``` 86 | 87 | We'll then need a concrete class to contain the shared core of state shared 88 | among the different states: 89 | 90 | ```python 91 | from dataclasses import dataclass 92 | 93 | @dataclass 94 | class BrewerCore: 95 | heatingElement: HeatingElement 96 | ``` 97 | 98 | Next, we need to describe our state machine, including all of our states. For 99 | simplicity's sake let's say that the only two states are `noBeans` and 100 | `haveBeans`: 101 | 102 | ```python 103 | from automat import TypeMachineBuilder 104 | 105 | builder = TypeMachineBuilder(CoffeeBrewer, BrewerCore) 106 | noBeans = builder.state("noBeans") 107 | haveBeans = builder.state("haveBeans") 108 | ``` 109 | 110 | Next we can describe a simple transition; when we put in beans, we move to the 111 | `haveBeans` state, with no other behavior. 112 | 113 | ```python 114 | # When we don't have beans, upon putting in beans, we will then have beans 115 | noBeans.upon(CoffeeBrewer.putInBeans).to(haveBeans).returns(None) 116 | ``` 117 | 118 | And then another transition that we describe with a decorator, one that *does* 119 | have some behavior, that needs to heat up the heating element to brew the 120 | coffee: 121 | 122 | ```python 123 | @haveBeans.upon(CoffeeBrewer.brewButton).to(noBeans) 124 | def heatUp(inputs: CoffeeBrewer, core: BrewerCore) -> None: 125 | """ 126 | When we have beans, upon pressing the brew button, we will then not have 127 | beans any more (as they have been entered into the brewing chamber) and 128 | our output will be heating the heating element. 129 | """ 130 | print("Brewing the coffee...") 131 | core.heatingElement.turnOn() 132 | ``` 133 | 134 | Then we finalize the state machine by building it, which gives us a callable 135 | that takes a `BrewerCore` and returns a synthetic `CoffeeBrewer` 136 | 137 | ```python 138 | newCoffeeMachine = builder.build() 139 | ``` 140 | 141 | ```python 142 | >>> coffee = newCoffeeMachine(BrewerCore(HeatingElement())) 143 | >>> machine.putInBeans() 144 | >>> machine.brewButton() 145 | Brewing the coffee... 146 | ``` 147 | 148 | All of the *inputs* are provided by calling them like methods, all of the 149 | *output behaviors* are automatically invoked when they are produced according 150 | to the outputs specified to `upon` and all of the states are simply opaque 151 | tokens. 152 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | ## Security contact information 2 | 3 | To report a security vulnerability, please use the 4 | [Tidelift security contact](https://tidelift.com/security). 5 | Tidelift will coordinate the fix and disclosure. 6 | 7 | ## Security supported versions 8 | 9 | Automat is a [CalVer](https://calver.org) project that issues time-based 10 | releases. This means its version format is `YEAR.MONTH.PATCH`. 11 | 12 | Users are expected to upgrade in a timely manner when new releases of automat 13 | are issued; within about 3 months, so that we do not need to maintain an 14 | arbitrarily large legacy support window for old versions. This means that a 15 | version is “current” until 3 months after the last day of the `YEAR.MONTH` of 16 | the *next* released version. This means at least one version is always 17 | “current”, regardless of how long ago it was released. 18 | 19 | The simple rule is this: **upgrade within 3 months of a release, and your 20 | current version will always be security-supported**. 21 | 22 | Automat releases are also largely intended to be compatible, following 23 | [Twisted's compatibility 24 | policy](https://docs.twisted.org/en/stable/development/compatibility-policy.html) 25 | of R+2 for any removals. 26 | 27 | Thus, “security support” is a function of breaking changes and time. If a 28 | vulnerability is discovered, all versions that were *current on that date* will 29 | receive a security update. A “security update” is a release with no removals 30 | from its previous version, and thus will be installable without breaking 31 | compatibility. 32 | 33 | Some examples may be helpful to understand the nuances here. 34 | 35 | Let's say it's August 9, 2027. A vulnerability, V1, is discovered, that 36 | affects many versions of automat. The previous two versions of Automat were 37 | 2025.5.0 and 2026.1.0. Because it is more than 3 months after january 2026, 38 | only 2026.1.0 is current. Thus, a security update of 2026.1.1 will be issued. 39 | 40 | Alternately, let's say it's December 5th, 2029. Another vulnerability, V2, is 41 | discovered. It's been an active year for automat: there were lots of 42 | deprecations in 2028, and there has been a removal (a breaking change) in every 43 | release in 2029, of which there has been one every month. This means that 44 | `2029.9.0`, `2029.10.0`, and `2029.11.0` will all be receiving `.1` security 45 | updates, with no changes besides the security patch. 46 | 47 | Once again, just upgrade within 3 months of a release, and you will have no 48 | issues. 49 | -------------------------------------------------------------------------------- /benchmark/test_transitions.py: -------------------------------------------------------------------------------- 1 | # https://github.com/glyph/automat/issues/60 2 | 3 | import automat 4 | 5 | 6 | class Simple(object): 7 | """ 8 | """ 9 | _m = automat.MethodicalMachine() 10 | 11 | @_m.input() 12 | def one(self, data): 13 | "some input data" 14 | 15 | @_m.state(initial=True) 16 | def waiting(self): 17 | "patiently" 18 | 19 | @_m.output() 20 | def boom(self, data): 21 | pass 22 | 23 | waiting.upon( 24 | one, 25 | enter=waiting, 26 | outputs=[boom], 27 | ) 28 | 29 | 30 | def simple_one(machine, data): 31 | machine.one(data) 32 | 33 | 34 | def test_simple_machine_transitions(benchmark): 35 | benchmark(simple_one, Simple(), 0) 36 | -------------------------------------------------------------------------------- /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 = automat 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) -------------------------------------------------------------------------------- /docs/_static/garage_door.machineFactory.dot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glyph/automat/ef18293c905dd9fde902eb20c718a0a465a9d33b/docs/_static/garage_door.machineFactory.dot.png -------------------------------------------------------------------------------- /docs/_static/mystate.machine.MyMachine._machine.dot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glyph/automat/ef18293c905dd9fde902eb20c718a0a465a9d33b/docs/_static/mystate.machine.MyMachine._machine.dot.png -------------------------------------------------------------------------------- /docs/api/index.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | This will be overwritten by the pydoctor build. 5 | -------------------------------------------------------------------------------- /docs/compare.rst: -------------------------------------------------------------------------------- 1 | What makes Automat different? 2 | ============================= 3 | There are `dozens of libraries on PyPI implementing state machines 4 | `_. 5 | So it behooves me to say why yet another one would be a good idea. 6 | 7 | Automat is designed around the following principle: 8 | while organizing your code around state machines is a good idea, 9 | your callers don't, and shouldn't have to, care that you've done so. 10 | 11 | In Python, the "input" to a stateful system is a method call; 12 | the "output" may be a method call, if you need to invoke a side effect, 13 | or a return value, if you are just performing a computation in memory. 14 | Most other state-machine libraries require you to explicitly create an input object, 15 | provide that object to a generic "input" method, and then receive results, 16 | sometimes in terms of that library's interfaces and sometimes in terms of 17 | classes you define yourself. 18 | 19 | Therefore, from the outside, an Automat state machine looks like a Plain Old 20 | Python Object (POPO). It has methods, and the methods have type annotations, 21 | and you can call them and get their documented return values. 22 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # automat documentation build configuration file, created by 5 | # sphinx-quickstart on Thu Sep 14 19:11:24 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | import os 21 | import sys 22 | 23 | docs_dir = os.path.dirname(os.path.abspath(__file__)) 24 | automat_dir = os.path.dirname(docs_dir) 25 | sys.path.insert(0, automat_dir) 26 | 27 | 28 | # -- General configuration ------------------------------------------------ 29 | 30 | # If your documentation needs a minimal Sphinx version, state it here. 31 | # 32 | # needs_sphinx = '1.0' 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be 35 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 36 | # ones. 37 | extensions = [ 38 | "sphinx.ext.intersphinx", 39 | "pydoctor.sphinx_ext.build_apidocs", 40 | "sphinx.ext.autosectionlabel", 41 | ] 42 | import pathlib 43 | import subprocess 44 | _project_root = pathlib.Path(__file__).parent.parent 45 | _source_root = _project_root / "src" 46 | 47 | _git_reference = subprocess.run( 48 | ["git", "rev-parse", "--abbrev-ref", "HEAD"], 49 | text=True, 50 | encoding="utf8", 51 | capture_output=True, 52 | check=True, 53 | ).stdout 54 | 55 | 56 | # Try to find URL fragment for the GitHub source page based on current 57 | # branch or tag. 58 | 59 | if _git_reference == "HEAD": 60 | # It looks like the branch has no name. 61 | # Fallback to commit ID. 62 | _git_reference = subprocess.getoutput("git rev-parse HEAD") 63 | 64 | if os.environ.get("READTHEDOCS", "") == "True": 65 | rtd_version = os.environ.get("READTHEDOCS_VERSION", "") 66 | if "." in rtd_version: 67 | # It looks like we have a tag build. 68 | _git_reference = rtd_version 69 | 70 | pydoctor_args = [ 71 | # pydoctor should not fail the sphinx build, we have another tox environment for that. 72 | "--intersphinx=https://docs.twisted.org/en/twisted-22.1.0/api/objects.inv", 73 | "--intersphinx=https://docs.python.org/3/objects.inv", 74 | "--intersphinx=https://graphviz.readthedocs.io/en/stable/objects.inv", 75 | "--intersphinx=https://zopeinterface.readthedocs.io/en/latest/objects.inv", 76 | # TODO: not sure why I have to specify these all twice. 77 | f"--config={_project_root}/.pydoctor.cfg", 78 | f"--html-viewsource-base=https://github.com/glyph/automat/tree/{_git_reference}/src", 79 | f"--project-base-dir={_source_root}", 80 | "--html-output={outdir}/api", 81 | "--privacy=HIDDEN:automat.test.*", 82 | "--privacy=HIDDEN:automat.test", 83 | "--privacy=HIDDEN:**.__post_init__", 84 | str(_source_root / "automat"), 85 | ] 86 | pydoctor_url_path = "/en/{rtd_version}/api/" 87 | intersphinx_mapping = { 88 | "py3": ("https://docs.python.org/3", None), 89 | "zopeinterface": ("https://zopeinterface.readthedocs.io/en/latest", None), 90 | "twisted": ("https://docs.twisted.org/en/twisted-22.1.0/api", None), 91 | } 92 | 93 | # Add any paths that contain templates here, relative to this directory. 94 | templates_path = ["_templates"] 95 | 96 | # The suffix(es) of source filenames. 97 | # You can specify multiple suffix as a list of string: 98 | # 99 | # source_suffix = ['.rst', '.md'] 100 | source_suffix = ".rst" 101 | 102 | # The master toctree document. 103 | master_doc = "index" 104 | 105 | # General information about the project. 106 | project = "automat" 107 | copyright = "2017, Glyph" 108 | author = "Glyph" 109 | 110 | # The version info for the project you're documenting, acts as replacement for 111 | # |version| and |release|, also used in various other places throughout the 112 | # built documents. 113 | 114 | 115 | def _get_release() -> str: 116 | import importlib.metadata 117 | 118 | try: 119 | return importlib.metadata.version(project) 120 | except importlib.metadata.PackageNotFoundError: 121 | raise Exception("You must install Automat to build the documentation.") 122 | 123 | 124 | # The full version, including alpha/beta/rc tags. 125 | release = _get_release() 126 | 127 | # The short X.Y version. 128 | version = ".".join(release.split(".")[:2]) 129 | 130 | # The language for content autogenerated by Sphinx. Refer to documentation 131 | # for a list of supported languages. 132 | # 133 | # This is also used if you do content translation via gettext catalogs. 134 | # Usually you set "language" from the command line for these cases. 135 | language = "en" 136 | 137 | # List of patterns, relative to source directory, that match files and 138 | # directories to ignore when looking for source files. 139 | # This patterns also effect to html_static_path and html_extra_path 140 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 141 | 142 | # The name of the Pygments (syntax highlighting) style to use. 143 | pygments_style = "sphinx" 144 | 145 | # If true, `todo` and `todoList` produce output, else they produce nothing. 146 | todo_include_todos = False 147 | 148 | 149 | # -- Options for HTML output ---------------------------------------------- 150 | 151 | # The theme to use for HTML and HTML Help pages. See the documentation for 152 | # a list of builtin themes. 153 | # 154 | html_theme = "sphinx_rtd_theme" 155 | 156 | # Theme options are theme-specific and customize the look and feel of a theme 157 | # further. For a list of options available for each theme, see the 158 | # documentation. 159 | # 160 | # html_theme_options = {} 161 | 162 | # Add any paths that contain custom static files (such as style sheets) here, 163 | # relative to this directory. They are copied after the builtin static files, 164 | # so a file named "default.css" will overwrite the builtin "default.css". 165 | html_static_path = ["_static"] 166 | 167 | # Custom sidebar templates, must be a dictionary that maps document names 168 | # to template names. 169 | # 170 | # This is required for the alabaster theme 171 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 172 | html_sidebars = { 173 | "**": [ 174 | "about.html", 175 | "navigation.html", 176 | "relations.html", # needs 'show_related': True theme option to display 177 | "searchbox.html", 178 | "donate.html", 179 | ] 180 | } 181 | 182 | 183 | # -- Options for HTMLHelp output ------------------------------------------ 184 | 185 | # Output file base name for HTML help builder. 186 | htmlhelp_basename = "automatdoc" 187 | 188 | 189 | # -- Options for LaTeX output --------------------------------------------- 190 | 191 | latex_elements: dict[str, str] = { 192 | # The paper size ('letterpaper' or 'a4paper'). 193 | # 194 | # 'papersize': 'letterpaper', 195 | # The font size ('10pt', '11pt' or '12pt'). 196 | # 197 | # 'pointsize': '10pt', 198 | # Additional stuff for the LaTeX preamble. 199 | # 200 | # 'preamble': '', 201 | # Latex figure (float) alignment 202 | # 203 | # 'figure_align': 'htbp', 204 | } 205 | 206 | # Grouping the document tree into LaTeX files. List of tuples 207 | # (source start file, target name, title, 208 | # author, documentclass [howto, manual, or own class]). 209 | latex_documents = [ 210 | (master_doc, "automat.tex", "automat Documentation", "Glyph", "manual"), 211 | ] 212 | 213 | 214 | # -- Options for manual page output --------------------------------------- 215 | 216 | # One entry per manual page. List of tuples 217 | # (source start file, name, description, authors, manual section). 218 | man_pages = [(master_doc, "automat", "automat Documentation", [author], 1)] 219 | 220 | 221 | # -- Options for Texinfo output ------------------------------------------- 222 | 223 | # Grouping the document tree into Texinfo files. List of tuples 224 | # (source start file, target name, title, author, 225 | # dir menu entry, description, category) 226 | texinfo_documents = [ 227 | ( 228 | master_doc, 229 | "automat", 230 | "automat Documentation", 231 | author, 232 | "automat", 233 | "One line description of project.", 234 | "Miscellaneous", 235 | ), 236 | ] 237 | -------------------------------------------------------------------------------- /docs/examples/automat_card.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Protocol 3 | 4 | from automat import TypeMachineBuilder 5 | 6 | 7 | @dataclass 8 | class PaymentBackend: 9 | accounts: dict[str, int] = field(default_factory=dict) 10 | 11 | def checkBalance(self, accountID: str) -> int: 12 | "how many AutoBux™ do you have" 13 | return self.accounts[accountID] 14 | 15 | def deduct(self, accountID: str, amount: int) -> None: 16 | "deduct some amount of money from the given account" 17 | balance = self.accounts[accountID] 18 | newBalance = balance - amount 19 | if newBalance < 0: 20 | raise ValueError("not enough money") 21 | self.accounts[accountID] = newBalance 22 | 23 | 24 | @dataclass 25 | class Food: 26 | name: str 27 | price: int 28 | doorNumber: int 29 | 30 | 31 | class Doors: 32 | def openDoor(self, number: int) -> None: 33 | print(f"opening door {number}") 34 | 35 | 36 | class Automat(Protocol): 37 | def swipeCard(self, accountID: str) -> None: 38 | "Swipe a payment card with the given account ID." 39 | 40 | def selectFood(self, doorNumber: int) -> None: 41 | "Select a food." 42 | 43 | def _dispenseFood(self, doorNumber: int) -> None: 44 | "Open a door and dispense the food." 45 | 46 | 47 | @dataclass 48 | class AutomatCore: 49 | payments: PaymentBackend 50 | foods: dict[int, Food] # mapping door-number to food 51 | doors: Doors 52 | 53 | 54 | @dataclass 55 | class PaymentDetails: 56 | accountID: str 57 | 58 | 59 | def rememberAccount( 60 | inputs: Automat, core: AutomatCore, accountID: str 61 | ) -> PaymentDetails: 62 | print(f"remembering {accountID=}") 63 | return PaymentDetails(accountID) 64 | 65 | 66 | # define machine 67 | builder = TypeMachineBuilder(Automat, AutomatCore) 68 | 69 | idle = builder.state("idle") 70 | choosing = builder.state("choosing", rememberAccount) 71 | 72 | idle.upon(Automat.swipeCard).to(choosing).returns(None) 73 | # end define 74 | 75 | 76 | @choosing.upon(Automat.selectFood).loop() 77 | def selected( 78 | inputs: Automat, core: AutomatCore, details: PaymentDetails, doorNumber: int 79 | ) -> None: 80 | food = core.foods[doorNumber] 81 | try: 82 | core.payments.deduct(details.accountID, core.foods[doorNumber].price) 83 | except ValueError as ve: 84 | print(ve) 85 | else: 86 | inputs._dispenseFood(doorNumber) 87 | 88 | 89 | @choosing.upon(Automat._dispenseFood).to(idle) 90 | def doOpen( 91 | inputs: Automat, core: AutomatCore, details: PaymentDetails, doorNumber: int 92 | ) -> None: 93 | core.doors.openDoor(doorNumber) 94 | 95 | 96 | machineFactory = builder.build() 97 | 98 | if __name__ == "__main__": 99 | machine = machineFactory( 100 | AutomatCore( 101 | PaymentBackend({"alice": 100}), 102 | { 103 | 1: Food("burger", 5, 1), 104 | 2: Food("fries", 3, 2), 105 | 3: Food("pheasant under glass", 200, 3), 106 | }, 107 | Doors(), 108 | ) 109 | ) 110 | machine.swipeCard("alice") 111 | print("too expensive") 112 | machine.selectFood(3) 113 | print("just right") 114 | machine.selectFood(1) 115 | print("oops") 116 | machine.selectFood(2) 117 | -------------------------------------------------------------------------------- /docs/examples/automat_example.py: -------------------------------------------------------------------------------- 1 | 2 | from automat import MethodicalMachine 3 | 4 | class Door(object): 5 | def unlock(self): 6 | print("Opening the door so you can get your food.") 7 | 8 | def lock(self): 9 | print("Locking the door so you can't steal the food.") 10 | 11 | class Light(object): 12 | def on(self): 13 | print("Need some food over here.") 14 | 15 | def off(self): 16 | print("We're good on food for now.") 17 | 18 | class FoodSlot(object): 19 | """ 20 | Automats were a popular kind of business in the 1950s and 60s; a sort of 21 | restaurant-sized vending machine that served cooked food out of a 22 | coin-operated dispenser. 23 | 24 | This class represents the logic associated with a single food slot. 25 | """ 26 | 27 | machine = MethodicalMachine() 28 | 29 | def __init__(self, door, light): 30 | self._door = door 31 | self._light = light 32 | self.start() 33 | 34 | @machine.state(initial=True) 35 | def initial(self): 36 | """ 37 | The initial state when we are constructed. 38 | 39 | Note that applications never see this state, because the constructor 40 | provides an input to transition out of it immediately. 41 | """ 42 | 43 | @machine.state() 44 | def empty(self): 45 | """ 46 | The machine is empty (and the light asking for food is on). 47 | """ 48 | 49 | @machine.input() 50 | def start(self): 51 | """ 52 | A private input, for transitioning to the initial blank state to 53 | 'empty', making sure the door and light are properly configured. 54 | """ 55 | 56 | @machine.state() 57 | def ready(self): 58 | """ 59 | We've got some food and we're ready to serve it. 60 | """ 61 | 62 | @machine.state() 63 | def serving(self): 64 | """ 65 | The door is open, we're serving food. 66 | """ 67 | 68 | @machine.input() 69 | def coin(self): 70 | """ 71 | A coin (of the appropriate denomination) was inserted. 72 | """ 73 | 74 | @machine.input() 75 | def food(self): 76 | """ 77 | Food was prepared and inserted into the back of the machine. 78 | """ 79 | 80 | @machine.output() 81 | def turnOnFoodLight(self): 82 | """ 83 | Turn on the 'we need food' light. 84 | """ 85 | self._light.on() 86 | 87 | @machine.output() 88 | def turnOffFoodLight(self): 89 | """ 90 | Turn off the 'we need food' light. 91 | """ 92 | self._light.off() 93 | 94 | @machine.output() 95 | def lockDoor(self): 96 | """ 97 | Lock the door, we don't need food. 98 | """ 99 | self._door.lock() 100 | 101 | @machine.output() 102 | def unlockDoor(self): 103 | """ 104 | Unock the door, it's chow time!. 105 | """ 106 | self._door.unlock() 107 | 108 | @machine.input() 109 | def closeDoor(self): 110 | """ 111 | The door was closed. 112 | """ 113 | 114 | initial.upon(start, enter=empty, outputs=[lockDoor, turnOnFoodLight]) 115 | empty.upon(food, enter=ready, outputs=[turnOffFoodLight]) 116 | ready.upon(coin, enter=serving, outputs=[unlockDoor]) 117 | serving.upon(closeDoor, enter=empty, outputs=[lockDoor, 118 | turnOnFoodLight]) 119 | 120 | 121 | 122 | slot = FoodSlot(Door(), Light()) 123 | 124 | if __name__ == '__main__': 125 | import sys 126 | sys.stdout.writelines(FoodSlot.machine.asDigraph()) 127 | # raw_input("Hit enter to make some food and put it in the slot: ") 128 | # slot.food() 129 | # raw_input("Hit enter to insert a coin: ") 130 | # slot.coin() 131 | # raw_input("Hit enter to retrieve the food and close the door: ") 132 | # slot.closeDoor() 133 | # raw_input("Hit enter to make some more food: ") 134 | # slot.food() 135 | 136 | -------------------------------------------------------------------------------- /docs/examples/coffee_expanded.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Callable, Protocol 5 | 6 | from automat import TypeMachineBuilder 7 | 8 | 9 | @dataclass 10 | class Beans: 11 | description: str 12 | 13 | 14 | @dataclass 15 | class Water: 16 | "It's Water" 17 | 18 | 19 | @dataclass 20 | class Carafe: 21 | "It's a carafe" 22 | full: bool = False 23 | 24 | 25 | @dataclass 26 | class Ready: 27 | beans: Beans 28 | water: Water 29 | carafe: Carafe 30 | 31 | def brew(self) -> Mixture: 32 | print(f"brewing {self.beans} with {self.water} in {self.carafe}") 33 | return Mixture(self.beans, self.water) 34 | 35 | 36 | @dataclass 37 | class Mixture: 38 | beans: Beans 39 | water: Water 40 | 41 | 42 | class Brewer(Protocol): 43 | def brew_button(self) -> None: 44 | "The user pressed the 'brew' button." 45 | 46 | def wait_a_while(self) -> Mixture: 47 | "Allow some time to pass." 48 | 49 | def put_in_beans(self, beans: Beans) -> None: 50 | "The user put in some beans." 51 | 52 | def put_in_water(self, water: Water) -> None: 53 | "The user put in some water." 54 | 55 | def put_in_carafe(self, carafe: Carafe) -> None: 56 | "The user put the mug" 57 | 58 | 59 | class _BrewerInternals(Brewer, Protocol): 60 | def _ready(self, beans: Beans, water: Water, carafe: Carafe) -> None: 61 | "We are ready with all of our inputs." 62 | 63 | 64 | @dataclass 65 | class Light: 66 | on: bool = False 67 | 68 | 69 | @dataclass 70 | class BrewCore: 71 | "state for the brew process" 72 | ready_light: Light 73 | brew_light: Light 74 | beans: Beans | None = None 75 | water: Water | None = None 76 | carafe: Carafe | None = None 77 | brewing: Mixture | None = None 78 | 79 | 80 | def _coffee_machine() -> TypeMachineBuilder[_BrewerInternals, BrewCore]: 81 | """ 82 | Best practice: these functions are all fed in to the builder, they don't 83 | need to call each other, so they don't need to be defined globally. Use a 84 | function scope to avoid littering a module with states and such. 85 | """ 86 | builder = TypeMachineBuilder(_BrewerInternals, BrewCore) 87 | # reveal_type(builder) 88 | 89 | not_ready = builder.state("not_ready") 90 | 91 | def ready_factory( 92 | brewer: _BrewerInternals, 93 | core: BrewCore, 94 | beans: Beans, 95 | water: Water, 96 | carafe: Carafe, 97 | ) -> Ready: 98 | return Ready(beans, water, carafe) 99 | 100 | def mixture_factory(brewer: _BrewerInternals, core: BrewCore) -> Mixture: 101 | # We already do have a 'ready' but it's State-Specific Data which makes 102 | # it really annoying to relay on to the *next* state without passing it 103 | # through the state core. requiring the factory to take SSD inherently 104 | # means that it could only work with transitions away from a single 105 | # state, which would not be helpful, although that *is* what we want 106 | # here. 107 | 108 | assert core.beans is not None 109 | assert core.water is not None 110 | assert core.carafe is not None 111 | 112 | return Mixture(core.beans, core.water) 113 | 114 | ready = builder.state("ready", ready_factory) 115 | brewing = builder.state("brewing", mixture_factory) 116 | 117 | def ready_check(brewer: _BrewerInternals, core: BrewCore) -> None: 118 | if ( 119 | core.beans is not None 120 | and core.water is not None 121 | and core.carafe is not None 122 | and core.carafe.full is not None 123 | ): 124 | brewer._ready(core.beans, core.water, core.carafe) 125 | 126 | @not_ready.upon(Brewer.put_in_beans).loop() 127 | def put_beans(brewer: _BrewerInternals, core: BrewCore, beans: Beans) -> None: 128 | core.beans = beans 129 | ready_check(brewer, core) 130 | 131 | @not_ready.upon(Brewer.put_in_water).loop() 132 | def put_water(brewer: _BrewerInternals, core: BrewCore, water: Water) -> None: 133 | core.water = water 134 | ready_check(brewer, core) 135 | 136 | @not_ready.upon(Brewer.put_in_carafe).loop() 137 | def put_carafe(brewer: _BrewerInternals, core: BrewCore, carafe: Carafe) -> None: 138 | core.carafe = carafe 139 | ready_check(brewer, core) 140 | 141 | @not_ready.upon(_BrewerInternals._ready).to(ready) 142 | def get_ready( 143 | brewer: _BrewerInternals, 144 | core: BrewCore, 145 | beans: Beans, 146 | water: Water, 147 | carafe: Carafe, 148 | ) -> None: 149 | print("ready output") 150 | 151 | @ready.upon(Brewer.brew_button).to(brewing) 152 | def brew(brewer: _BrewerInternals, core: BrewCore, ready: Ready) -> None: 153 | core.brew_light.on = True 154 | print("BREW CALLED") 155 | core.brewing = ready.brew() 156 | 157 | @brewing.upon(_BrewerInternals.wait_a_while).to(not_ready) 158 | def brewed(brewer: _BrewerInternals, core: BrewCore, mixture: Mixture) -> Mixture: 159 | core.brew_light.on = False 160 | return mixture 161 | 162 | return builder 163 | 164 | 165 | CoffeeMachine: Callable[[BrewCore], Brewer] = _coffee_machine().build() 166 | 167 | if __name__ == "__main__": 168 | machine = CoffeeMachine(core := BrewCore(Light(), Light())) 169 | machine.put_in_beans(Beans("light roast")) 170 | machine.put_in_water(Water()) 171 | machine.put_in_carafe(Carafe()) 172 | machine.brew_button() 173 | brewed = machine.wait_a_while() 174 | print(brewed) 175 | -------------------------------------------------------------------------------- /docs/examples/dont_get_state.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Protocol 3 | 4 | from automat import TypeMachineBuilder 5 | 6 | 7 | class Transport: 8 | def send(self, arg: bytes) -> None: 9 | print(f"sent: {arg!r}") 10 | 11 | 12 | # begin salient 13 | class Connector(Protocol): 14 | def sendMessage(self) -> None: 15 | "send a message" 16 | 17 | 18 | @dataclass 19 | class Core: 20 | _transport: Transport 21 | 22 | 23 | builder = TypeMachineBuilder(Connector, Core) 24 | disconnected = builder.state("disconnected") 25 | connected = builder.state("connector") 26 | 27 | 28 | @connected.upon(Connector.sendMessage).loop() 29 | def actuallySend(connector: Connector, core: Core) -> None: 30 | core._transport.send(b"message") 31 | 32 | 33 | @disconnected.upon(Connector.sendMessage).loop() 34 | def failSend(connector: Connector, core: Core): 35 | print("not connected") 36 | # end salient 37 | 38 | 39 | machineFactory = builder.build() 40 | machine = machineFactory(Core(Transport())) 41 | machine.sendMessage() 42 | -------------------------------------------------------------------------------- /docs/examples/feedback_debugging.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import typing 3 | 4 | from automat import TypeMachineBuilder 5 | 6 | 7 | class Inputs(typing.Protocol): 8 | def behavior1(self) -> None: ... 9 | def behavior2(self) -> None: ... 10 | class Nothing: ... 11 | 12 | 13 | builder = TypeMachineBuilder(Inputs, Nothing) 14 | start = builder.state("start") 15 | 16 | 17 | @start.upon(Inputs.behavior1).loop() 18 | def one(inputs: Inputs, core: Nothing) -> None: 19 | print("starting behavior 1") 20 | inputs.behavior2() 21 | print("ending behavior 1") 22 | 23 | 24 | @start.upon(Inputs.behavior2).loop() 25 | def two(inputs: Inputs, core: Nothing) -> None: 26 | print("behavior 2") 27 | 28 | 29 | machineFactory = builder.build() 30 | machineFactory(Nothing()).behavior1() 31 | -------------------------------------------------------------------------------- /docs/examples/feedback_errors.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import typing 3 | 4 | from automat import TypeMachineBuilder 5 | 6 | #begin 7 | class Inputs(typing.Protocol): 8 | def compute(self) -> int: ... 9 | def behavior(self) -> None: ... 10 | class Nothing: ... 11 | 12 | 13 | builder = TypeMachineBuilder(Inputs, Nothing) 14 | start = builder.state("start") 15 | 16 | 17 | @start.upon(Inputs.compute).loop() 18 | def three(inputs: Inputs, core: Nothing) -> int: 19 | return 3 20 | 21 | 22 | @start.upon(Inputs.behavior).loop() 23 | def behave(inputs: Inputs, core: Nothing) -> None: 24 | print("computed:", inputs.compute()) 25 | #end 26 | 27 | machineFactory = builder.build() 28 | machineFactory(Nothing()).behavior() 29 | -------------------------------------------------------------------------------- /docs/examples/feedback_order.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import typing 3 | 4 | from automat import TypeMachineBuilder 5 | 6 | 7 | class Inputs(typing.Protocol): 8 | def compute(self) -> int: ... 9 | def behavior(self) -> None: ... 10 | class Nothing: ... 11 | 12 | 13 | builder = TypeMachineBuilder(Inputs, Nothing) 14 | start = builder.state("start") 15 | 16 | 17 | @start.upon(Inputs.compute).loop() 18 | def three(inputs: Inputs, core: Nothing) -> int: 19 | return 3 20 | 21 | 22 | # begin computations 23 | computations = [] 24 | 25 | 26 | @start.upon(Inputs.behavior).loop() 27 | def behave(inputs: Inputs, core: Nothing) -> None: 28 | computations.append(inputs.compute) 29 | 30 | 31 | machineFactory = builder.build() 32 | machineFactory(Nothing()).behavior() 33 | print(computations[0]()) 34 | # end computations 35 | -------------------------------------------------------------------------------- /docs/examples/garage_door.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import typing 3 | from enum import Enum, auto 4 | 5 | from automat import NoTransition, TypeMachineBuilder 6 | 7 | 8 | class Direction(Enum): 9 | up = auto() 10 | stopped = auto() 11 | down = auto() 12 | 13 | 14 | @dataclasses.dataclass 15 | class Motor: 16 | direction: Direction = Direction.stopped 17 | 18 | def up(self) -> None: 19 | assert self.direction is Direction.stopped 20 | self.direction = Direction.up 21 | print("motor running up") 22 | 23 | def stop(self) -> None: 24 | self.direction = Direction.stopped 25 | print("motor stopped") 26 | 27 | def down(self) -> None: 28 | assert self.direction is Direction.stopped 29 | self.direction = Direction.down 30 | print("motor running down") 31 | 32 | 33 | @dataclasses.dataclass 34 | class Alarm: 35 | def beep(self) -> None: 36 | "Sound an alarm so that the user can hear." 37 | print("beep beep beep") 38 | 39 | 40 | # protocol definition 41 | class GarageController(typing.Protocol): 42 | def pushButton(self) -> None: 43 | "Push the button to open or close the door" 44 | 45 | def openSensor(self) -> None: 46 | "The 'open' sensor activated; the door is fully open." 47 | 48 | def closeSensor(self) -> None: 49 | "The 'close' sensor activated; the door is fully closed." 50 | 51 | 52 | # end protocol definition 53 | # core definition 54 | @dataclasses.dataclass 55 | class DoorDevices: 56 | motor: Motor 57 | alarm: Alarm 58 | 59 | 60 | "end core definition" 61 | 62 | # end core definition 63 | 64 | # start building 65 | builder = TypeMachineBuilder(GarageController, DoorDevices) 66 | # build states 67 | closed = builder.state("closed") 68 | opening = builder.state("opening") 69 | opened = builder.state("opened") 70 | closing = builder.state("closing") 71 | # end states 72 | 73 | 74 | # build methods 75 | @closed.upon(GarageController.pushButton).to(opening) 76 | def startOpening(controller: GarageController, devices: DoorDevices) -> None: 77 | devices.motor.up() 78 | 79 | 80 | @opening.upon(GarageController.openSensor).to(opened) 81 | def finishedOpening(controller: GarageController, devices: DoorDevices): 82 | devices.motor.stop() 83 | 84 | 85 | @opened.upon(GarageController.pushButton).to(closing) 86 | def startClosing(controller: GarageController, devices: DoorDevices) -> None: 87 | devices.alarm.beep() 88 | devices.motor.down() 89 | 90 | 91 | @closing.upon(GarageController.closeSensor).to(closed) 92 | def finishedClosing(controller: GarageController, devices: DoorDevices): 93 | devices.motor.stop() 94 | # end methods 95 | 96 | 97 | # do build 98 | machineFactory = builder.build() 99 | # end building 100 | # story 101 | if __name__ == "__main__": 102 | # do instantiate 103 | machine = machineFactory(DoorDevices(Motor(), Alarm())) 104 | # end instantiate 105 | print("pushing button...") 106 | # do open 107 | machine.pushButton() 108 | # end open 109 | print("pushedW") 110 | try: 111 | machine.pushButton() 112 | except NoTransition: 113 | print("this is not implemented yet") 114 | print("triggering open sensor, pushing button again") 115 | # sensor and close 116 | machine.openSensor() 117 | machine.pushButton() 118 | # end close 119 | print("pushed") 120 | machine.closeSensor() 121 | 122 | # end story 123 | -------------------------------------------------------------------------------- /docs/examples/garage_door_security.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import typing 3 | from enum import Enum, auto 4 | 5 | from automat import NoTransition, TypeMachineBuilder 6 | 7 | 8 | class Direction(Enum): 9 | up = auto() 10 | stopped = auto() 11 | down = auto() 12 | 13 | 14 | @dataclasses.dataclass 15 | class Motor: 16 | direction: Direction = Direction.stopped 17 | 18 | def up(self) -> None: 19 | assert self.direction is Direction.stopped 20 | self.direction = Direction.up 21 | print("motor running up") 22 | 23 | def stop(self) -> None: 24 | self.direction = Direction.stopped 25 | print("motor stopped") 26 | 27 | def down(self) -> None: 28 | assert self.direction is Direction.stopped 29 | self.direction = Direction.down 30 | print("motor running down") 31 | 32 | 33 | @dataclasses.dataclass 34 | class Alarm: 35 | def beep(self) -> None: 36 | "Sound an alarm so that the user can hear." 37 | print("beep beep beep") 38 | 39 | 40 | # protocol definition 41 | class GarageController(typing.Protocol): 42 | def pushButton(self, remoteID: str) -> None: 43 | "Push the button to open or close the door" 44 | 45 | def openSensor(self) -> None: 46 | "The 'open' sensor activated; the door is fully open." 47 | 48 | def closeSensor(self) -> None: 49 | "The 'close' sensor activated; the door is fully closed." 50 | 51 | 52 | # end protocol definition 53 | # core definition 54 | @dataclasses.dataclass 55 | class DoorDevices: 56 | motor: Motor 57 | alarm: Alarm 58 | 59 | 60 | "end core definition" 61 | 62 | # end core definition 63 | 64 | # start building 65 | builder = TypeMachineBuilder(GarageController, DoorDevices) 66 | # build states 67 | closed = builder.state("closed") 68 | opening = builder.state("opening") 69 | opened = builder.state("opened") 70 | closing = builder.state("closing") 71 | # end states 72 | 73 | 74 | # build methods 75 | @closed.upon(GarageController.pushButton).to(opening) 76 | def startOpening(controller: GarageController, devices: DoorDevices, remoteID: str) -> None: 77 | print(f"opened by {remoteID}") 78 | devices.motor.up() 79 | 80 | 81 | @opening.upon(GarageController.openSensor).to(opened) 82 | def finishedOpening(controller: GarageController, devices: DoorDevices): 83 | devices.motor.stop() 84 | 85 | 86 | @opened.upon(GarageController.pushButton).to(closing) 87 | def startClosing(controller: GarageController, devices: DoorDevices, remoteID: str) -> None: 88 | print(f"closed by {remoteID}") 89 | devices.alarm.beep() 90 | devices.motor.down() 91 | 92 | 93 | @closing.upon(GarageController.closeSensor).to(closed) 94 | def finishedClosing(controller: GarageController, devices: DoorDevices): 95 | devices.motor.stop() 96 | # end methods 97 | 98 | 99 | # do build 100 | machineFactory = builder.build() 101 | # end building 102 | # story 103 | if __name__ == "__main__": 104 | # do instantiate 105 | machine = machineFactory(DoorDevices(Motor(), Alarm())) 106 | # end instantiate 107 | print("pushing button...") 108 | # do open 109 | machine.pushButton("alice") 110 | # end open 111 | print("pushed") 112 | try: 113 | machine.pushButton("bob") 114 | except NoTransition: 115 | print("this is not implemented yet") 116 | print("triggering open sensor, pushing button again") 117 | # sensor and close 118 | machine.openSensor() 119 | machine.pushButton("carol") 120 | # end close 121 | print("pushed") 122 | machine.closeSensor() 123 | 124 | # end story 125 | -------------------------------------------------------------------------------- /docs/examples/io_coffee_example.py: -------------------------------------------------------------------------------- 1 | from automat import MethodicalMachine 2 | 3 | 4 | class CoffeeBrewer(object): 5 | _machine = MethodicalMachine() 6 | 7 | @_machine.input() 8 | def brew_button(self): 9 | "The user pressed the 'brew' button." 10 | 11 | @_machine.output() 12 | def _heat_the_heating_element(self): 13 | "Heat up the heating element, which should cause coffee to happen." 14 | # self._heating_element.turn_on() 15 | 16 | @_machine.state() 17 | def have_beans(self): 18 | "In this state, you have some beans." 19 | 20 | @_machine.state(initial=True) 21 | def dont_have_beans(self): 22 | "In this state, you don't have any beans." 23 | 24 | @_machine.input() 25 | def put_in_beans(self, beans): 26 | "The user put in some beans." 27 | 28 | @_machine.output() 29 | def _save_beans(self, beans): 30 | "The beans are now in the machine; save them." 31 | self._beans = beans 32 | 33 | @_machine.output() 34 | def _describe_coffee(self): 35 | return "A cup of coffee made with {}.".format(self._beans) 36 | 37 | dont_have_beans.upon(put_in_beans, enter=have_beans, outputs=[_save_beans]) 38 | have_beans.upon( 39 | brew_button, 40 | enter=dont_have_beans, 41 | outputs=[_heat_the_heating_element, _describe_coffee], 42 | collector=lambda iterable: list(iterable)[-1], 43 | ) 44 | 45 | 46 | cb = CoffeeBrewer() 47 | cb.put_in_beans("real good beans") 48 | print(cb.brew_button()) 49 | -------------------------------------------------------------------------------- /docs/examples/lightswitch.py: -------------------------------------------------------------------------------- 1 | from operator import itemgetter 2 | 3 | from automat import MethodicalMachine 4 | 5 | 6 | class LightSwitch(object): 7 | machine = MethodicalMachine() 8 | 9 | @machine.state(serialized="on") 10 | def on_state(self): 11 | "the switch is on" 12 | 13 | @machine.state(serialized="off", initial=True) 14 | def off_state(self): 15 | "the switch is off" 16 | 17 | @machine.input() 18 | def flip(self): 19 | "flip the switch" 20 | 21 | on_state.upon(flip, enter=off_state, outputs=[]) 22 | off_state.upon(flip, enter=on_state, outputs=[]) 23 | 24 | @machine.input() 25 | def query_power(self): 26 | "return True if powered, False otherwise" 27 | 28 | @machine.output() 29 | def _is_powered(self): 30 | return True 31 | 32 | @machine.output() 33 | def _not_powered(self): 34 | return False 35 | 36 | on_state.upon( 37 | query_power, enter=on_state, outputs=[_is_powered], collector=itemgetter(0) 38 | ) 39 | off_state.upon( 40 | query_power, enter=off_state, outputs=[_not_powered], collector=itemgetter(0) 41 | ) 42 | 43 | @machine.serializer() 44 | def save(self, state): 45 | return {"is-it-on": state} 46 | 47 | @machine.unserializer() 48 | def _restore(self, blob): 49 | return blob["is-it-on"] 50 | 51 | @classmethod 52 | def from_blob(cls, blob): 53 | self = cls() 54 | self._restore(blob) 55 | return self 56 | 57 | 58 | if __name__ == "__main__": 59 | l = LightSwitch() 60 | print(l.query_power()) 61 | -------------------------------------------------------------------------------- /docs/examples/serialize_machine.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from dataclasses import dataclass 3 | from typing import Protocol, Self 4 | 5 | from automat import TypeMachineBuilder 6 | 7 | 8 | @dataclass 9 | class Core: 10 | value: int 11 | 12 | 13 | @dataclass 14 | class DataObj: 15 | datum: str 16 | 17 | @classmethod 18 | def create(cls, inputs: Inputs, core: Core, datum: str) -> Self: 19 | return cls(datum) 20 | 21 | 22 | # begin salient 23 | class Inputs(Protocol): 24 | def serialize(self) -> tuple[int, str | None]: ... 25 | def next(self) -> None: ... 26 | def data(self, datum: str) -> None: ... 27 | 28 | 29 | builder = TypeMachineBuilder(Inputs, Core) 30 | start = builder.state("start") 31 | nodata = builder.state("nodata") 32 | data = builder.state("data", DataObj.create) 33 | nodata.upon(Inputs.data).to(data).returns(None) 34 | start.upon(Inputs.next).to(nodata).returns(None) 35 | 36 | 37 | @nodata.upon(Inputs.serialize).loop() 38 | def serialize(inputs: Inputs, core: Core) -> tuple[int, None]: 39 | return (core.value, None) 40 | 41 | 42 | @data.upon(Inputs.serialize).loop() 43 | def serializeData(inputs: Inputs, core: Core, data: DataObj) -> tuple[int, str]: 44 | return (core.value, data.datum) 45 | # end salient 46 | 47 | 48 | # build and serialize 49 | machineFactory = builder.build() 50 | machine = machineFactory(Core(3)) 51 | machine.next() 52 | print(machine.serialize()) 53 | machine.data("hi") 54 | print(machine.serialize()) 55 | # end build 56 | 57 | 58 | def deserializeWithoutData(serialization: tuple[int, DataObj | None]) -> Inputs: 59 | coreValue, dataValue = serialization 60 | assert dataValue is None, "not handling data yet" 61 | return machineFactory(Core(coreValue), nodata) 62 | 63 | 64 | print(deserializeWithoutData((3, None))) 65 | 66 | 67 | def deserialize(serialization: tuple[int, str | None]) -> Inputs: 68 | coreValue, dataValue = serialization 69 | if dataValue is None: 70 | return machineFactory(Core(coreValue), nodata) 71 | else: 72 | return machineFactory( 73 | Core(coreValue), 74 | data, 75 | lambda inputs, core: DataObj(dataValue), 76 | ) 77 | 78 | 79 | print(deserialize((3, None)).serialize()) 80 | print(deserialize((4, "hello")).serialize()) 81 | -------------------------------------------------------------------------------- /docs/examples/turnstile_example.py: -------------------------------------------------------------------------------- 1 | from automat import MethodicalMachine 2 | 3 | 4 | class Lock(object): 5 | "A sample I/O device." 6 | 7 | def engage(self): 8 | print("Locked.") 9 | 10 | def disengage(self): 11 | print("Unlocked.") 12 | 13 | 14 | class Turnstile(object): 15 | machine = MethodicalMachine() 16 | 17 | def __init__(self, lock): 18 | self.lock = lock 19 | 20 | @machine.input() 21 | def arm_turned(self): 22 | "The arm was turned." 23 | 24 | @machine.input() 25 | def fare_paid(self): 26 | "The fare was paid." 27 | 28 | @machine.output() 29 | def _engage_lock(self): 30 | self.lock.engage() 31 | 32 | @machine.output() 33 | def _disengage_lock(self): 34 | self.lock.disengage() 35 | 36 | @machine.output() 37 | def _nope(self): 38 | print("**Clunk!** The turnstile doesn't move.") 39 | 40 | @machine.state(initial=True) 41 | def _locked(self): 42 | "The turnstile is locked." 43 | 44 | @machine.state() 45 | def _unlocked(self): 46 | "The turnstile is unlocked." 47 | 48 | _locked.upon(fare_paid, enter=_unlocked, outputs=[_disengage_lock]) 49 | _unlocked.upon(arm_turned, enter=_locked, outputs=[_engage_lock]) 50 | _locked.upon(arm_turned, enter=_locked, outputs=[_nope]) 51 | 52 | 53 | turner = Turnstile(Lock()) 54 | print("Paying fare 1.") 55 | turner.fare_paid() 56 | print("Walking through.") 57 | turner.arm_turned() 58 | print("Jumping.") 59 | turner.arm_turned() 60 | print("Paying fare 2.") 61 | turner.fare_paid() 62 | print("Walking through 2.") 63 | turner.arm_turned() 64 | print("Done.") 65 | -------------------------------------------------------------------------------- /docs/examples/turnstile_typified.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Protocol 2 | from automat import TypeMachineBuilder 3 | 4 | 5 | class Lock: 6 | "A sample I/O device." 7 | 8 | def engage(self) -> None: 9 | print("Locked.") 10 | 11 | def disengage(self) -> None: 12 | print("Unlocked.") 13 | 14 | 15 | class Turnstile(Protocol): 16 | def arm_turned(self) -> None: 17 | "The arm was turned." 18 | 19 | def fare_paid(self, coin: int) -> None: 20 | "The fare was paid." 21 | 22 | 23 | def buildMachine() -> Callable[[Lock], Turnstile]: 24 | builder = TypeMachineBuilder(Turnstile, Lock) 25 | locked = builder.state("Locked") 26 | unlocked = builder.state("Unlocked") 27 | 28 | @locked.upon(Turnstile.fare_paid).to(unlocked) 29 | def pay(self: Turnstile, lock: Lock, coin: int) -> None: 30 | lock.disengage() 31 | 32 | @locked.upon(Turnstile.arm_turned).loop() 33 | def block(self: Turnstile, lock: Lock) -> None: 34 | print("**Clunk!** The turnstile doesn't move.") 35 | 36 | @unlocked.upon(Turnstile.arm_turned).to(locked) 37 | def turn(self: Turnstile, lock: Lock) -> None: 38 | lock.engage() 39 | 40 | return builder.build() 41 | 42 | 43 | TurnstileImpl = buildMachine() 44 | turner = TurnstileImpl(Lock()) 45 | print("Paying fare 1.") 46 | turner.fare_paid(1) 47 | print("Walking through.") 48 | turner.arm_turned() 49 | print("Jumping.") 50 | turner.arm_turned() 51 | print("Paying fare 2.") 52 | turner.fare_paid(1) 53 | print("Walking through 2.") 54 | turner.arm_turned() 55 | print("Done.") 56 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ========================================================================= 2 | Automat: Self-service finite-state machines for the programmer on the go. 3 | ========================================================================= 4 | 5 | .. image:: https://upload.wikimedia.org/wikipedia/commons/d/db/Automat.jpg 6 | :width: 250 7 | :align: right 8 | 9 | Automat is a library for concise, idiomatic Python expression of finite-state 10 | automata (particularly `deterministic finite-state transducers 11 | `_). 12 | 13 | .. _Garage-Example: 14 | 15 | Why use state machines? 16 | ======================= 17 | 18 | Sometimes you have to create an object whose behavior varies with its state, 19 | but still wishes to present a consistent interface to its callers. 20 | 21 | For example, let's say we are writing the software for a garage door 22 | controller. The garage door is composed of 4 components: 23 | 24 | 1. A motor which can be run up or down, to raise or lower the door 25 | respectively. 26 | 2. A sensor that activates when the door is fully open. 27 | 3. A sensor that activates when the door is fully closed. 28 | 4. A button that tells the door to open or close. 29 | 30 | It's very important that the garage door does not get confused about its state, 31 | because we could burn out the motor if we attempt to close an already-closed 32 | door or open an already-open door. 33 | 34 | With diligence and attention to detail, you can implement this correctly using 35 | a collection of attributes on an object; ``isOpen``, ``isClosed``, 36 | ``motorRunningDirection``, and so on. 37 | 38 | However, you have to keep all these attributes consistent. As the software 39 | becomes more complex - perhaps you want to add a safety sensor that prevents 40 | the door from closing when someone is standing under it, for example - they all 41 | potentially need to be updated, and any invariants about their mutual 42 | interdependencies. 43 | 44 | Rather than adding tedious ``if`` checks to every method on your ``GarageDoor`` 45 | to make sure that all internal state is consistent, you can use a state machine 46 | to ensure that if your code runs at all, it will be run with all the required 47 | values initialized, because they have to be called in the order you declare 48 | them. 49 | 50 | You can read more about state machines and their advantages for Python programmers 51 | `in an excellent article by J.P. Calderone. `_ 52 | 53 | .. toctree:: 54 | :maxdepth: 2 55 | :caption: Contents: 56 | 57 | tutorial 58 | compare 59 | visualize 60 | api/index 61 | -------------------------------------------------------------------------------- /docs/requirements.in: -------------------------------------------------------------------------------- 1 | sphinx 2 | pydoctor 3 | sphinx_rtd_theme 4 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile --no-emit-index-url 6 | # 7 | alabaster==0.7.16 8 | # via sphinx 9 | appdirs==1.4.4 10 | # via pydoctor 11 | attrs==24.2.0 12 | # via 13 | # automat 14 | # pydoctor 15 | # twisted 16 | automat==22.10.0 17 | # via twisted 18 | babel==2.16.0 19 | # via sphinx 20 | cachecontrol[filecache]==0.14.0 21 | # via 22 | # cachecontrol 23 | # pydoctor 24 | certifi==2024.7.4 25 | # via requests 26 | charset-normalizer==3.3.2 27 | # via requests 28 | configargparse==1.7 29 | # via pydoctor 30 | constantly==23.10.4 31 | # via twisted 32 | docutils==0.20.1 33 | # via 34 | # pydoctor 35 | # sphinx 36 | # sphinx-rtd-theme 37 | filelock==3.15.4 38 | # via cachecontrol 39 | hyperlink==21.0.0 40 | # via twisted 41 | idna==3.7 42 | # via 43 | # hyperlink 44 | # requests 45 | imagesize==1.4.1 46 | # via sphinx 47 | incremental==24.7.2 48 | # via twisted 49 | jinja2==3.1.6 50 | # via sphinx 51 | lunr==0.6.2 52 | # via pydoctor 53 | markupsafe==2.1.5 54 | # via jinja2 55 | msgpack==1.0.8 56 | # via cachecontrol 57 | packaging==24.1 58 | # via sphinx 59 | pydoctor==24.3.3 60 | # via -r requirements.in 61 | pygments==2.18.0 62 | # via sphinx 63 | requests==2.32.3 64 | # via 65 | # cachecontrol 66 | # pydoctor 67 | # sphinx 68 | six==1.16.0 69 | # via automat 70 | snowballstemmer==2.2.0 71 | # via sphinx 72 | sphinx==7.4.7 73 | # via 74 | # -r requirements.in 75 | # sphinx-rtd-theme 76 | # sphinxcontrib-jquery 77 | sphinx-rtd-theme==2.0.0 78 | # via -r requirements.in 79 | sphinxcontrib-applehelp==2.0.0 80 | # via sphinx 81 | sphinxcontrib-devhelp==2.0.0 82 | # via sphinx 83 | sphinxcontrib-htmlhelp==2.1.0 84 | # via sphinx 85 | sphinxcontrib-jquery==4.1 86 | # via sphinx-rtd-theme 87 | sphinxcontrib-jsmath==1.0.1 88 | # via sphinx 89 | sphinxcontrib-qthelp==2.0.0 90 | # via sphinx 91 | sphinxcontrib-serializinghtml==2.0.0 92 | # via sphinx 93 | toml==0.10.2 94 | # via pydoctor 95 | twisted==24.7.0 96 | # via pydoctor 97 | typing-extensions==4.12.2 98 | # via twisted 99 | urllib3==2.2.2 100 | # via 101 | # pydoctor 102 | # requests 103 | zope-interface==7.0.1 104 | # via twisted 105 | 106 | # The following packages are considered to be unsafe in a requirements file: 107 | # setuptools 108 | -------------------------------------------------------------------------------- /docs/tutorial.rst: -------------------------------------------------------------------------------- 1 | ******** 2 | Tutorial 3 | ******** 4 | 5 | .. note:: 6 | 7 | Automat 24.8 is a *major* change to the public API - effectively a whole new 8 | library. For ease of migration, the code and API documentation still 9 | contains ``MethodicalMachine``, effectively the previous version of the 10 | library. However, for readability, the narrative documentation now *only* 11 | documents ``TypeMachineBuilder``. If you need documentation for that 12 | earlier version, you can find it as v22.10.0 on readthedocs. 13 | 14 | The Basics: a Garage Door Opener 15 | ================================ 16 | 17 | 18 | Describing the State Machine 19 | ---------------------------- 20 | 21 | Let's consider :ref:`the garage door example from the 22 | introduction`. 23 | 24 | Automat takes great care to present a state machine as a collection of regular 25 | methods. So we define what those methods *are* with a 26 | :py:class:`typing.Protocol` that describes them. 27 | 28 | .. literalinclude:: examples/garage_door.py 29 | :pyobject: GarageController 30 | 31 | This protocol tells us that only 3 things can happen to our controller from the 32 | outside world (its inputs): the user can push the button, the "door is all the 33 | way up" sensor can emit a signal, or the "door is all the way down" sensor can 34 | emit a signal. So those are our inputs. 35 | 36 | However, our state machine also needs to be able to *affect* things in the 37 | world (its outputs). As we are writing a program in Python, these come in the 38 | form of a Python object that can be shared between all the states that 39 | implement our controller, and for this purpose we define a simple shared-data 40 | class: 41 | 42 | .. literalinclude:: examples/garage_door.py 43 | :pyobject: DoorDevices 44 | 45 | Here we have a reference to a ``Motor`` that can open and close the door, and 46 | an ``Alarm`` that can beep to alert people that the door is closing. 47 | 48 | Next we need to combine those together, using a 49 | :py:class:`automat.TypeMachineBuilder`. 50 | 51 | .. literalinclude:: examples/garage_door.py 52 | :start-after: start building 53 | :end-before: build states 54 | 55 | Next we have to define our states. Let's start with four simple ones: 56 | 57 | 1. closed - the door is closed and idle 58 | 2. opening - the door is actively opening 59 | 3. opened - the door is open and idle 60 | 4. closing - the door is actively closing 61 | 62 | .. literalinclude:: examples/garage_door.py 63 | :start-after: build states 64 | :end-before: end states 65 | 66 | To describe the state machine, we define a series of transitions, using the 67 | method ``.upon()``: 68 | 69 | .. literalinclude:: examples/garage_door.py 70 | :start-after: build methods 71 | :end-before: end methods 72 | 73 | Building and using the state machine 74 | ------------------------------------ 75 | 76 | Now that we have described all the inputs, states, and output behaviors, it's 77 | time to actually build the state machine: 78 | 79 | .. literalinclude:: examples/garage_door.py 80 | :start-after: do build 81 | :end-before: end building 82 | 83 | The :py:meth:`automat.TypeMachineBuilder.build` method creates a callable that 84 | takes an instance of its state core (``DoorDevices``) and returns an object 85 | that conforms to its inputs protocol (``GarageController``). We can then take 86 | this ``machineFactory`` and call it, like so: 87 | 88 | .. literalinclude:: examples/garage_door.py 89 | :start-after: do instantiate 90 | :end-before: end instantiate 91 | 92 | Because we defined ``closed`` as our first state above, the machine begins in 93 | that state by default. So the first thing we'll do is to open the door: 94 | 95 | .. literalinclude:: examples/garage_door.py 96 | :start-after: do open 97 | :end-before: end open 98 | 99 | If we run this, we will then see some output, indicating that the motor is 100 | running: 101 | 102 | .. code-block:: 103 | 104 | motor running up 105 | 106 | If we press the button again, rather than silently double-starting the motor, 107 | we will get an error, since we haven't yet defined a state transition for this 108 | state yet. The traceback looks like this: 109 | 110 | .. code-block:: 111 | 112 | Traceback (most recent call last): 113 | File "", line 1, in 114 | machine.pushButton() 115 | File ".../automat/_typed.py", line 419, in implementation 116 | [outputs, tracer] = transitioner.transition(methodInput) 117 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 118 | File ".../automat/_core.py", line 196, in transition 119 | outState, outputSymbols = self._automaton.outputForInput( 120 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 121 | File ".../automat/_core.py", line 169, in outputForInput 122 | raise NoTransition(state=inState, symbol=inputSymbol) 123 | automat._core.NoTransition: no transition for pushButton in TypedState(name='opening') 124 | 125 | At first, this might seem like it's making more work for you. If you don't 126 | want to crash the code that calls your methods, you need to provide many more 127 | implementations of the same method for each different state. But, in this 128 | case, by causing this exception *before* running any of your code, Automat is 129 | protecting your internal state: although client code will get an exception, the 130 | *internal* state of your garage door controller will remain consistent. 131 | 132 | If you did not explicitly take a specific state into consideration while 133 | implementing some behavior, that behavior will never be invoked. Therefore, it 134 | cannot do something potentially harmful like double-starting the motor. 135 | 136 | If we trigger the open sensor so that the door completes its transition to the 137 | 'open' state, then push the button again, the buzzer will sound and the door 138 | will descend: 139 | 140 | .. literalinclude:: examples/garage_door.py 141 | :start-after: sensor and close 142 | :end-before: end close 143 | 144 | .. code-block:: 145 | 146 | motor stopped 147 | beep beep beep 148 | motor running down 149 | 150 | Try these exercises to get to to know Automat a little bit better: 151 | 152 | - When the button is pushed while the door is opening, the motor should stop, 153 | and if it's pressed again, the door should go in the reverse direction; for 154 | exmaple, if it's opening, it should pause and then close again, and if it's 155 | closing, it should pause and then open again. Make it do this rather than 156 | raise an exception. 157 | - Add a 'safety sensor' input, that refuses to close the door while it is 158 | tripped. 159 | 160 | Taking, Storing, and Returning Data 161 | ----------------------------------- 162 | 163 | Any method defined by the input protocol can take arguments and return values, 164 | just like any Python method. In order to facilitate this, all transition 165 | behavior methods must be able to accept any signature that their input can. 166 | 167 | To demonstrate this, let's add a feature to our door. Instead of a single 168 | button, let's add the ability to pair multiple remotes to open the door, so we 169 | can note which remote was used in a security log. For starters, we will need 170 | to modify our ``pushButton`` method to accept a ``remoteID`` argument, which we 171 | can print out. 172 | 173 | .. literalinclude:: examples/garage_door_security.py 174 | :pyobject: GarageController.pushButton 175 | 176 | If you're using ``mypy``, you will immediately see a type error when making 177 | this change, as all the calls to ``.upon(GarageController.pushButton)`` 178 | now complain something like this: 179 | 180 | .. code-block:: 181 | 182 | garage_door_security.py:75:2: error: Argument 1 to "__call__" of "TransitionRegistrar" 183 | has incompatible type "Callable[[GarageController, DoorDevices], None]"; 184 | expected "Callable[[GarageController, DoorDevices, str], None]" [arg-type] 185 | 186 | The ``TransitionRegistrar`` object is the result of calling ``.to(...)``, so 187 | what this is saying is that your function that is decorated with, say, 188 | ``@closed.upon(GarageController.pushButton).to(opening)``, takes your input 189 | protocol and your shared core object (as all transition behavior functions 190 | must), but does *not* take the ``str`` argument that ``pushButton`` takes. To 191 | fix it, we can add that parameter everywhere, and print it out, like so: 192 | 193 | .. literalinclude:: examples/garage_door_security.py 194 | :pyobject: startOpening 195 | 196 | Obviously, mypy will also complain that our test callers are missing the 197 | ``remoteID`` argument as well, so if we change them to pass along some value 198 | like so: 199 | 200 | .. literalinclude:: examples/garage_door.py 201 | :start-after: do open 202 | :end-before: end open 203 | 204 | Then we will see it in our output: 205 | 206 | .. code-block:: 207 | 208 | opened by alice 209 | 210 | Return values are treated in the same way as parameters. If your input 211 | protocol specifies a return type, then all behavior methods must also return 212 | that type. Your type checker will help ensure that these all line up for you 213 | as well. 214 | 215 | You can download the full examples here: 216 | 217 | - :download:`examples/garage_door.py` 218 | - :download:`examples/garage_door_security.py` 219 | 220 | More Advanced Usage: a Membership Card Automat Restaurant 221 | ========================================================= 222 | 223 | Setting Up the Example 224 | ---------------------- 225 | 226 | We will have to shift to a slightly more complex example to demonstrate 227 | Automat's more sophisticated features. Rather than opening the single door on 228 | our garage, let's implement the payment machine for an Automat - a food vending 229 | machine. 230 | 231 | Our automat operates on a membership system. You buy an AutoBux card, load it 232 | up, and then once you are at the machine, you swipe your card, make a 233 | selection, your account is debited, and your food is dispensed. 234 | 235 | State-specific Data 236 | ------------------- 237 | 238 | One of the coolest feature of Automat is not merely enforcing state 239 | transitions, but ensuring that the right data is always available in the right 240 | state. For our membership-card example, will start in an "idle" state, but 241 | when a customer swipes their card and starts to make their food selection, we 242 | have now entered the "choosing" state, it is crucial that *if we are in the 243 | choosing state, then we* **must** *know which customer's card we will charge*. 244 | 245 | We set up the state machine in much the same way as before: a state core: 246 | 247 | .. literalinclude:: examples/automat_card.py 248 | :pyobject: AutomatCore 249 | 250 | And an inputs protocol: 251 | 252 | .. literalinclude:: examples/automat_card.py 253 | :pyobject: Automat 254 | 255 | It may jump out at you that the ``_dispenseFood`` method is private. That's a 256 | bit unusual for a ``Protocol``, which is usually used to describe a 257 | publicly-facing API. Indeed, you might even want a *second* ``Protocol`` to 258 | hide this away from your public documentation. But for Automat, this is 259 | important because it's what lets us implement a *conditional state transition*, 260 | something commonly associated with state-specific data. 261 | 262 | We will get to that in a moment, but first, let's define that data. We'll 263 | begin with a function that, like transition behavior functions, takes our input 264 | protocol and core type. Its job will be to build our state-specific data for 265 | the "choosing" state, i.e. payment details. Entering this state requires an 266 | ``accountID`` as supplied by our ``swipeCard`` input, so we will require that 267 | as a parameter as well: 268 | 269 | .. literalinclude:: examples/automat_card.py 270 | :pyobject: rememberAccount 271 | 272 | Next, let's actually build the machine. We will use ``rememberAccount`` as the 273 | second parameter to ``TypeMachineBuilder.state()``, which defines ``choosing`` 274 | as a data state: 275 | 276 | .. literalinclude:: examples/automat_card.py 277 | :start-after: define machine 278 | :end-before: end define 279 | 280 | .. note:: 281 | 282 | Here, because swipeCard doesn't need any behavior and returns a static, 283 | immutable type (None), we define the transition with ``.returns(None)`` 284 | rather than giving it a behavior function. This is the same as using 285 | ``@idle.upon(Automat.swipeCard).to(choosing)`` as a decorator on an empty 286 | function, but a lot faster to type and to read. 287 | 288 | The fact that ``choosing`` is a data state adds two new requirements to its 289 | transitions:x 290 | 291 | 1. First, for every transition defined *to* the ``choosing`` state, the data 292 | factory function -- ``rememberAccount`` -- must be callable with whatever 293 | parameters defined in the input. If you want to make a lenient data factory 294 | that supports multiple signatures, you can always add ``*args: object, 295 | **kwargs: object`` to its signature, but any parameters it requires (in this 296 | case, ``accountID``) *must* be present in any input protocol methods that 297 | transition *to* ``choosing`` so that they can be passed along to the 298 | factory. 299 | 300 | 2. Second, for every transition defined *from* the ``choosing`` state, behavior 301 | functions will accept an additional parameter, of the same type returned by 302 | their state-specific data factory function. In other words, we will build a 303 | ``PaymentDetails`` object on every transition *to* ``choosing``, and then 304 | remember and pass that object to every behavior function as long as the 305 | machine remains in that state. 306 | 307 | Conditional State Transitions 308 | ----------------------------- 309 | 310 | Formally, in a deterministic finite-state automaton, an input in one state must 311 | result in the same transition to the same output state. When you define 312 | transitions statically, Automat adheres to this rule. However, in many 313 | real-world cases, which state you end up in after a particular event depends on 314 | things like the input data or internal state. In this example, if the user's 315 | AutoBux™ account balance is too low, then the food should not be dispensed; it 316 | should prompt the user to make another selection. 317 | 318 | Because it must be static, this means that the transition we will define from 319 | the ``choosing`` state upon ``selectFood`` will actually be a ``.loop()`` -- in 320 | other words, back to ``choosing`` -- rather than ``.to(idle)``. Within the 321 | behavior function of that transition, if we have determined that the user's 322 | card has been charged properly, we will call *back* into the ``Automat`` 323 | protocol via the ``_dispenseFood`` private input, like so: 324 | 325 | .. literalinclude:: examples/automat_card.py 326 | :pyobject: selected 327 | 328 | And since we want *that* input to transition us back to ``idle`` once the food 329 | has been dispensed, once again, we register a static transition, and this one's 330 | behavior is much simpler: 331 | 332 | .. literalinclude:: examples/automat_card.py 333 | :pyobject: doOpen 334 | 335 | You can download the full example here: 336 | 337 | - :download:`examples/garage_door_security.py` 338 | 339 | Reentrancy 340 | ---------- 341 | 342 | Observant readers may have noticed a slightly odd detail in the previous 343 | section. 344 | 345 | If our ``selected`` behavior function can cause a transition to another state 346 | before it's completed, but that other state's behaviors may require invariants 347 | that are maintained by previous behavior (i.e. ``selected`` itself) having 348 | completed, doesn't that create a paradox? How can we just invoke 349 | ``inputs._dispenseFood`` and have it work? 350 | 351 | In fact, you can't. This is an unresolvable paradox, and automat does a little 352 | trick to allow this convenient illusion, but it only works in some cases. 353 | 354 | Problems that lend themselves to state machines often involve setting up state 355 | to generate inputs back to the state machine in the future. For example, in 356 | the garage door example above, we implicitly registered sensors to call the 357 | ``openSensor`` and ``closeSensor`` methods. A more complete implementation in 358 | the behavior might need to set a timeout with an event loop, to automatically 359 | close the door after a certain amount of time. Being able to treat the state 360 | machines inputs as regular bound methods that can be used in callbacks is 361 | extremely convenient for this sort of thing. For those use cases, there are no 362 | particular limits on what can be called; once the behavior itself is finished 363 | and it's no longer on the stack, the object will behave exactly as its 364 | ``Protocol`` describes. 365 | 366 | One constraint is that any method you invoke in this way cannot return any 367 | value except None. This very simple machine, for example, that attempts to 368 | invoke a behavior that returns an integer: 369 | 370 | .. literalinclude:: examples/feedback_errors.py 371 | :start-after: #begin 372 | :end-before: #end 373 | 374 | will result in a traceback like so: 375 | 376 | .. code-block:: 377 | 378 | File "feedback_errors.py", line 24, in behave 379 | print("computed:", inputs.compute()) 380 | ^^^^^^^^^^^^^^^^ 381 | File ".../automat/_typed.py", line 406, in implementation 382 | raise RuntimeError( 383 | RuntimeError: attempting to reentrantly run Inputs.compute 384 | but it wants to return not None 385 | 386 | However, if instead of calling the method *immediately*, we save the method 387 | away to invoke later, it works fine once the current behavior function has 388 | completed: 389 | 390 | .. literalinclude:: examples/feedback_order.py 391 | :start-after: begin computations 392 | :end-before: end computations 393 | 394 | This simply prints ``3``, as expected. 395 | 396 | But why is there a constraint on return type? Surely a ``None``-returning 397 | method with side effects depends on its internal state just as much as 398 | something that returns a value? Running it re-entrantly before finishing the 399 | previous behavior would leave things in an invalid state, so how can it run at 400 | all? 401 | 402 | The magic that makes this work is that Automat automatically makes the 403 | invocation *not reentrant*, by re-ordering it for you. It can *re-order a 404 | second behavior that returns None to run at the end of your current behavior*, 405 | but it cannot steal a return value from the future, so it raises an exception 406 | to avoid confusion. 407 | 408 | But there is still the potentially confusing edge-case of re-ordering. A 409 | machine that contains these two behaviors: 410 | 411 | .. literalinclude:: examples/feedback_debugging.py 412 | :pyobject: one 413 | .. literalinclude:: examples/feedback_debugging.py 414 | :pyobject: two 415 | 416 | will, when ``.behavior1()`` is invoked on it, print like so: 417 | 418 | .. code-block:: 419 | 420 | starting behavior 1 421 | ending behavior 1 422 | behavior 2 423 | 424 | In general, this re-ordering *is* what you want idiomatically when working with 425 | a state machine, but it is important to know that it can happen. If you have 426 | code that you do want to invoke side effects in a precise order, put it in a 427 | function or into a method on your shared core. 428 | 429 | How do I get the current state of a state machine? 430 | ================================================== 431 | 432 | Don't do that. 433 | 434 | One major reason for having a state machine is that you want the callers of the 435 | state machine to just provide the appropriate input to the machine at the 436 | appropriate time, and *not have to check themselves* what state the machine is 437 | in. 438 | 439 | The *whole point* of Automat is to never, ever write code that looks like this, 440 | and places the burden on the caller: 441 | 442 | 443 | .. code-block:: python 444 | 445 | if connectionMachine.state == "CONNECTED": 446 | connectionMachine.sendMessage() 447 | else: 448 | print("not connected") 449 | 450 | Instead, just make your calling code do this: 451 | 452 | .. code-block:: python 453 | 454 | connectionMachine.sendMessage() 455 | 456 | and then change your state machine to look like this: 457 | 458 | .. literalinclude:: examples/dont_get_state.py 459 | :start-after: begin salient 460 | :end-before: end salient 461 | 462 | so that the responsibility for knowing which state the state machine is in 463 | remains within the state machine itself. 464 | 465 | 466 | If I can't get the state of the state machine, how can I save it to (a database, an API response, a file on disk...) 467 | ==================================================================================================================== 468 | 469 | On the serialization side, you can build inputs that return a type that every 470 | state can respond to. For example, here's a machine that maintains an ``int`` 471 | value in its core, and a ``str`` value in a piece of state-specific data. This 472 | really just works like implementing any other return value. 473 | 474 | .. literalinclude:: examples/serialize_machine.py 475 | :start-after: begin salient 476 | :end-before: end salient 477 | 478 | getting the data out then looks like this: 479 | 480 | .. literalinclude:: examples/serialize_machine.py 481 | :start-after: build and serialize 482 | :end-before: end build 483 | 484 | which produces: 485 | 486 | .. code-block:: 487 | 488 | (3, None) 489 | (3, DataObj(datum='hi')) 490 | 491 | Future versions of automat may include some utility functionaity here to reduce 492 | boilerplate, but no additional features are required to address this half of 493 | the problem. 494 | 495 | However, for *de*serialization, we do need the ability to start in a different 496 | initial state. For non-data states, it's simple enough; construct an 497 | appropriate shared core, and just pass the state that you want; in our case, 498 | ``nodata``: 499 | 500 | .. literalinclude:: examples/serialize_machine.py 501 | :pyobject: deserializeWithoutData 502 | 503 | Finally, all we need to deserialize a state with state-specific data is to pass 504 | a factory function which takes ``inputs, core`` as arguments, just like 505 | behavior and data-factory functions. Since we are skipping *directly* to the 506 | data state, we will skip the data factory declared on the state itself, and 507 | call this one: 508 | 509 | .. literalinclude:: examples/serialize_machine.py 510 | :pyobject: deserialize 511 | 512 | .. note:: 513 | 514 | In this specific deserialization context, since the object isn't even really 515 | constructed yet, the ``inputs`` argument is in a *totally* invalid state and 516 | cannot be invoked reentrantly at all; any method will raise an exception if 517 | called during the duration of this special deserialization data factory. 518 | You can only use it to save it away on your state-specific data for future 519 | invocations once the state machine instance is built. 520 | 521 | You can download the full example here: 522 | 523 | - :download:`examples/serialize_machine.py` 524 | 525 | And that's pretty much all you need to know in order to build type-safe state 526 | machines with Automat! 527 | -------------------------------------------------------------------------------- /docs/visualize.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | Visualizations 3 | ================ 4 | 5 | 6 | Installation 7 | ============ 8 | 9 | To create state machine graphs you must install `automat` with the graphing dependencies. 10 | 11 | 12 | .. code-block:: bash 13 | 14 | pip install automat[visualize] 15 | 16 | To generate images, you will also need to install `Graphviz 17 | `_ for your platform, such as with ``brew install 18 | graphviz`` on macOS or ``apt install graphviz`` on Ubuntu. 19 | 20 | 21 | Example 22 | ======= 23 | 24 | If we put the garage door example from the tutorial into a file called ``garage_door.py``, 25 | 26 | You can generate a state machine visualization by running: 27 | 28 | .. code-block:: bash 29 | 30 | $ automat-visualize garage_door 31 | garage_door.machineFactory ...discovered 32 | garage_door.machineFactory ...wrote image and dot into .automat_visualize 33 | 34 | The `dot` file and `png` will be saved in the default output directory, to the 35 | file ``.automat_visualize/garage_door.machineFactory.dot.png`` . 36 | 37 | 38 | .. image:: _static/garage_door.machineFactory.dot.png 39 | :alt: garage door state machine 40 | 41 | 42 | ``automat-visualize`` help 43 | ========================== 44 | 45 | .. code-block:: bash 46 | 47 | $ automat-visualize -h 48 | usage: /home/tom/Envs/tmp-72fe664d2dc5cbf/bin/automat-visualize 49 | [-h] [--quiet] [--dot-directory DOT_DIRECTORY] 50 | [--image-directory IMAGE_DIRECTORY] 51 | [--image-type {gv,vml,dot_json,imap_np,pov,tiff,pic,canon,jpg,ismap,sgi,webp,gd,json0,ps2,cmapx_np,plain-ext,wbmp,xdot_json,ps,cgimage,ico,gtk,pct,gif,json,fig,xlib,xdot1.2,tif,tk,xdot1.4,svgz,gd2,jpe,psd,xdot,bmp,jpeg,x11,cmapx,jp2,imap,png,tga,pict,plain,eps,vmlz,cmap,exr,svg,pdf,vrml,dot}] 52 | [--view] 53 | fqpn 54 | 55 | 56 | Visualize automat.MethodicalMachines as graphviz graphs. 57 | 58 | positional arguments: 59 | fqpn A Fully Qualified Path name representing where to find 60 | machines. 61 | 62 | optional arguments: 63 | -h, --help show this help message and exit 64 | --quiet, -q suppress output 65 | --dot-directory DOT_DIRECTORY, -d DOT_DIRECTORY 66 | Where to write out .dot files. 67 | --image-directory IMAGE_DIRECTORY, -i IMAGE_DIRECTORY 68 | Where to write out image files. 69 | --image-type {gv,vml,dot_json,imap_np,pov,tiff,pic,canon,jpg,ismap,sgi,webp,gd,json0,ps2,cmapx_np,plain-ext,wbmp,xdot_json,ps,cgimage,ico,gtk,pct,gif,json,fig,xlib,xdot1.2,tif,tk,xdot1.4,svgz,gd2,jpe,psd,xdot,bmp,jpeg,x11,cmapx,jp2,imap,png,tga,pict,plain,eps,vmlz,cmap,exr,svg,pdf,vrml,dot}, -t {gv,vml,dot_json,imap_np,pov,tiff,pic,canon,jpg,ismap,sgi,webp,gd,json0,ps2,cmapx_np,plain-ext,wbmp,xdot_json,ps,cgimage,ico,gtk,pct,gif,json,fig,xlib,xdot1.2,tif,tk,xdot1.4,svgz,gd2,jpe,psd,xdot,bmp,jpeg,x11,cmapx,jp2,imap,png,tga,pict,plain,eps,vmlz,cmap,exr,svg,pdf,vrml,dot} 70 | The image format. 71 | --view, -v View rendered graphs with default image viewer 72 | 73 | You must have the graphviz tool suite installed. Please visit 74 | http://www.graphviz.org for more information. 75 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | show_error_codes = True 3 | warn_unused_ignores = true 4 | no_implicit_optional = true 5 | strict_optional = true 6 | disallow_any_generics = true 7 | 8 | [mypy-graphviz.*] 9 | ignore_missing_imports = True 10 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools >= 35.0.2", 4 | "wheel >= 0.29.0", 5 | "setuptools-scm", 6 | "hatch-vcs", 7 | ] 8 | build-backend = "setuptools.build_meta" 9 | 10 | [project] 11 | name="Automat" 12 | dynamic = ["version"] 13 | authors=[ 14 | { name = "Glyph", email = "code@glyph.im" }, 15 | ] 16 | description="Self-service finite-state machines for the programmer on the go." 17 | readme="README.md" 18 | requires-python=">= 3.9" 19 | dependencies=[ 20 | 'typing_extensions; python_version<"3.10"', 21 | ] 22 | license={file="LICENSE"} 23 | keywords=[ 24 | "fsm", 25 | "state machine", 26 | "automata", 27 | ] 28 | classifiers=[ 29 | "Intended Audience :: Developers", 30 | "License :: OSI Approved :: MIT License", 31 | "Operating System :: OS Independent", 32 | "Programming Language :: Python", 33 | "Programming Language :: Python :: 3", 34 | "Programming Language :: Python :: 3.9", 35 | "Programming Language :: Python :: 3.10", 36 | "Programming Language :: Python :: 3.11", 37 | "Programming Language :: Python :: 3.12", 38 | "Programming Language :: Python :: 3.13", 39 | "Typing :: Typed", 40 | ] 41 | 42 | [project.urls] 43 | Documentation = "https://automat.readthedocs.io/" 44 | Source = "https://github.com/glyph/automat/" 45 | 46 | [project.optional-dependencies] 47 | visualize=[ 48 | "graphviz>0.5.1", 49 | "Twisted>=16.1.1", 50 | ] 51 | 52 | [project.scripts] 53 | automat-visualize = "automat._visualize:tool" 54 | 55 | [tool.setuptools.packages.find] 56 | where = ["src"] 57 | 58 | [tool.setuptools_scm] 59 | # No configuration required, but the section needs to exist? 60 | 61 | [tool.hatch] 62 | version.source = "vcs" 63 | 64 | -------------------------------------------------------------------------------- /src/automat/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- test-case-name: automat -*- 2 | """ 3 | State-machines. 4 | """ 5 | from ._typed import TypeMachineBuilder, pep614, AlreadyBuiltError, TypeMachine 6 | from ._core import NoTransition 7 | from ._methodical import MethodicalMachine 8 | 9 | __all__ = [ 10 | "TypeMachineBuilder", 11 | "TypeMachine", 12 | "NoTransition", 13 | "AlreadyBuiltError", 14 | "pep614", 15 | "MethodicalMachine", 16 | ] 17 | -------------------------------------------------------------------------------- /src/automat/_core.py: -------------------------------------------------------------------------------- 1 | # -*- test-case-name: automat._test.test_core -*- 2 | 3 | """ 4 | A core state-machine abstraction. 5 | 6 | Perhaps something that could be replaced with or integrated into machinist. 7 | """ 8 | from __future__ import annotations 9 | 10 | import sys 11 | from itertools import chain 12 | from typing import Callable, Generic, Optional, Sequence, TypeVar, Hashable 13 | 14 | if sys.version_info >= (3, 10): 15 | from typing import TypeAlias 16 | else: 17 | from typing_extensions import TypeAlias 18 | 19 | _NO_STATE = "" 20 | State = TypeVar("State", bound=Hashable) 21 | Input = TypeVar("Input", bound=Hashable) 22 | Output = TypeVar("Output", bound=Hashable) 23 | 24 | 25 | class NoTransition(Exception, Generic[State, Input]): 26 | """ 27 | A finite state machine in C{state} has no transition for C{symbol}. 28 | 29 | @ivar state: See C{state} init parameter. 30 | 31 | @ivar symbol: See C{symbol} init parameter. 32 | """ 33 | 34 | def __init__(self, state: State, symbol: Input): 35 | """ 36 | Construct a L{NoTransition}. 37 | 38 | @param state: the finite state machine's state at the time of the 39 | illegal transition. 40 | 41 | @param symbol: the input symbol for which no transition exists. 42 | """ 43 | self.state = state 44 | self.symbol = symbol 45 | super(Exception, self).__init__( 46 | "no transition for {} in {}".format(symbol, state) 47 | ) 48 | 49 | 50 | class Automaton(Generic[State, Input, Output]): 51 | """ 52 | A declaration of a finite state machine. 53 | 54 | Note that this is not the machine itself; it is immutable. 55 | """ 56 | 57 | def __init__(self, initial: State | None = None) -> None: 58 | """ 59 | Initialize the set of transitions and the initial state. 60 | """ 61 | if initial is None: 62 | initial = _NO_STATE # type:ignore[assignment] 63 | assert initial is not None 64 | self._initialState: State = initial 65 | self._transitions: set[tuple[State, Input, State, Sequence[Output]]] = set() 66 | self._unhandledTransition: Optional[tuple[State, Sequence[Output]]] = None 67 | 68 | @property 69 | def initialState(self) -> State: 70 | """ 71 | Return this automaton's initial state. 72 | """ 73 | return self._initialState 74 | 75 | @initialState.setter 76 | def initialState(self, state: State) -> None: 77 | """ 78 | Set this automaton's initial state. Raises a ValueError if 79 | this automaton already has an initial state. 80 | """ 81 | 82 | if self._initialState is not _NO_STATE: 83 | raise ValueError( 84 | "initial state already set to {}".format(self._initialState) 85 | ) 86 | 87 | self._initialState = state 88 | 89 | def addTransition( 90 | self, 91 | inState: State, 92 | inputSymbol: Input, 93 | outState: State, 94 | outputSymbols: tuple[Output, ...], 95 | ): 96 | """ 97 | Add the given transition to the outputSymbol. Raise ValueError if 98 | there is already a transition with the same inState and inputSymbol. 99 | """ 100 | # keeping self._transitions in a flat list makes addTransition 101 | # O(n^2), but state machines don't tend to have hundreds of 102 | # transitions. 103 | for anInState, anInputSymbol, anOutState, _ in self._transitions: 104 | if anInState == inState and anInputSymbol == inputSymbol: 105 | raise ValueError( 106 | "already have transition from {} to {} via {}".format( 107 | inState, anOutState, inputSymbol 108 | ) 109 | ) 110 | self._transitions.add((inState, inputSymbol, outState, tuple(outputSymbols))) 111 | 112 | def unhandledTransition( 113 | self, outState: State, outputSymbols: Sequence[Output] 114 | ) -> None: 115 | """ 116 | All unhandled transitions will be handled by transitioning to the given 117 | error state and error-handling output symbols. 118 | """ 119 | self._unhandledTransition = (outState, tuple(outputSymbols)) 120 | 121 | def allTransitions(self) -> frozenset[tuple[State, Input, State, Sequence[Output]]]: 122 | """ 123 | All transitions. 124 | """ 125 | return frozenset(self._transitions) 126 | 127 | def inputAlphabet(self) -> set[Input]: 128 | """ 129 | The full set of symbols acceptable to this automaton. 130 | """ 131 | return { 132 | inputSymbol 133 | for (inState, inputSymbol, outState, outputSymbol) in self._transitions 134 | } 135 | 136 | def outputAlphabet(self) -> set[Output]: 137 | """ 138 | The full set of symbols which can be produced by this automaton. 139 | """ 140 | return set( 141 | chain.from_iterable( 142 | outputSymbols 143 | for (inState, inputSymbol, outState, outputSymbols) in self._transitions 144 | ) 145 | ) 146 | 147 | def states(self) -> frozenset[State]: 148 | """ 149 | All valid states; "Q" in the mathematical description of a state 150 | machine. 151 | """ 152 | return frozenset( 153 | chain.from_iterable( 154 | (inState, outState) 155 | for (inState, inputSymbol, outState, outputSymbol) in self._transitions 156 | ) 157 | ) 158 | 159 | def outputForInput( 160 | self, inState: State, inputSymbol: Input 161 | ) -> tuple[State, Sequence[Output]]: 162 | """ 163 | A 2-tuple of (outState, outputSymbols) for inputSymbol. 164 | """ 165 | for anInState, anInputSymbol, outState, outputSymbols in self._transitions: 166 | if (inState, inputSymbol) == (anInState, anInputSymbol): 167 | return (outState, list(outputSymbols)) 168 | if self._unhandledTransition is None: 169 | raise NoTransition(state=inState, symbol=inputSymbol) 170 | return self._unhandledTransition 171 | 172 | 173 | OutputTracer = Callable[[Output], None] 174 | Tracer: TypeAlias = "Callable[[State, Input, State], OutputTracer[Output] | None]" 175 | 176 | 177 | class Transitioner(Generic[State, Input, Output]): 178 | """ 179 | The combination of a current state and an L{Automaton}. 180 | """ 181 | 182 | def __init__(self, automaton: Automaton[State, Input, Output], initialState: State): 183 | self._automaton: Automaton[State, Input, Output] = automaton 184 | self._state: State = initialState 185 | self._tracer: Tracer[State, Input, Output] | None = None 186 | 187 | def setTrace(self, tracer: Tracer[State, Input, Output] | None) -> None: 188 | self._tracer = tracer 189 | 190 | def transition( 191 | self, inputSymbol: Input 192 | ) -> tuple[Sequence[Output], OutputTracer[Output] | None]: 193 | """ 194 | Transition between states, returning any outputs. 195 | """ 196 | outState, outputSymbols = self._automaton.outputForInput( 197 | self._state, inputSymbol 198 | ) 199 | outTracer = None 200 | if self._tracer: 201 | outTracer = self._tracer(self._state, inputSymbol, outState) 202 | self._state = outState 203 | return (outputSymbols, outTracer) 204 | -------------------------------------------------------------------------------- /src/automat/_discover.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import collections 4 | import inspect 5 | from typing import Any, Iterator 6 | 7 | from twisted.python.modules import PythonAttribute, PythonModule, getModule 8 | 9 | from automat import MethodicalMachine 10 | 11 | from ._typed import TypeMachine, InputProtocol, Core 12 | 13 | 14 | def isOriginalLocation(attr: PythonAttribute | PythonModule) -> bool: 15 | """ 16 | Attempt to discover if this appearance of a PythonAttribute 17 | representing a class refers to the module where that class was 18 | defined. 19 | """ 20 | sourceModule = inspect.getmodule(attr.load()) 21 | if sourceModule is None: 22 | return False 23 | 24 | currentModule = attr 25 | while not isinstance(currentModule, PythonModule): 26 | currentModule = currentModule.onObject 27 | 28 | return currentModule.name == sourceModule.__name__ 29 | 30 | 31 | def findMachinesViaWrapper( 32 | within: PythonModule | PythonAttribute, 33 | ) -> Iterator[tuple[str, MethodicalMachine | TypeMachine[InputProtocol, Core]]]: 34 | """ 35 | Recursively yield L{MethodicalMachine}s and their FQPNs within a 36 | L{PythonModule} or a L{twisted.python.modules.PythonAttribute} 37 | wrapper object. 38 | 39 | Note that L{PythonModule}s may refer to packages, as well. 40 | 41 | The discovery heuristic considers L{MethodicalMachine} instances 42 | that are module-level attributes or class-level attributes 43 | accessible from module scope. Machines inside nested classes will 44 | be discovered, but those returned from functions or methods will not be. 45 | 46 | @type within: L{PythonModule} or L{twisted.python.modules.PythonAttribute} 47 | @param within: Where to start the search. 48 | 49 | @return: a generator which yields FQPN, L{MethodicalMachine} pairs. 50 | """ 51 | queue = collections.deque([within]) 52 | visited: set[ 53 | PythonModule 54 | | PythonAttribute 55 | | MethodicalMachine 56 | | TypeMachine[InputProtocol, Core] 57 | | type[Any] 58 | ] = set() 59 | 60 | while queue: 61 | attr = queue.pop() 62 | value = attr.load() 63 | if ( 64 | isinstance(value, MethodicalMachine) or isinstance(value, TypeMachine) 65 | ) and value not in visited: 66 | visited.add(value) 67 | yield attr.name, value 68 | elif ( 69 | inspect.isclass(value) and isOriginalLocation(attr) and value not in visited 70 | ): 71 | visited.add(value) 72 | queue.extendleft(attr.iterAttributes()) 73 | elif isinstance(attr, PythonModule) and value not in visited: 74 | visited.add(value) 75 | queue.extendleft(attr.iterAttributes()) 76 | queue.extendleft(attr.iterModules()) 77 | 78 | 79 | class InvalidFQPN(Exception): 80 | """ 81 | The given FQPN was not a dot-separated list of Python objects. 82 | """ 83 | 84 | 85 | class NoModule(InvalidFQPN): 86 | """ 87 | A prefix of the FQPN was not an importable module or package. 88 | """ 89 | 90 | 91 | class NoObject(InvalidFQPN): 92 | """ 93 | A suffix of the FQPN was not an accessible object 94 | """ 95 | 96 | 97 | def wrapFQPN(fqpn: str) -> PythonModule | PythonAttribute: 98 | """ 99 | Given an FQPN, retrieve the object via the global Python module 100 | namespace and wrap it with a L{PythonModule} or a 101 | L{twisted.python.modules.PythonAttribute}. 102 | """ 103 | # largely cribbed from t.p.reflect.namedAny 104 | 105 | if not fqpn: 106 | raise InvalidFQPN("FQPN was empty") 107 | 108 | components = collections.deque(fqpn.split(".")) 109 | 110 | if "" in components: 111 | raise InvalidFQPN( 112 | "name must be a string giving a '.'-separated list of Python " 113 | "identifiers, not %r" % (fqpn,) 114 | ) 115 | 116 | component = components.popleft() 117 | try: 118 | module = getModule(component) 119 | except KeyError: 120 | raise NoModule(component) 121 | 122 | # find the bottom-most module 123 | while components: 124 | component = components.popleft() 125 | try: 126 | module = module[component] 127 | except KeyError: 128 | components.appendleft(component) 129 | break 130 | else: 131 | module.load() 132 | else: 133 | return module 134 | 135 | # find the bottom-most attribute 136 | attribute = module 137 | for component in components: 138 | try: 139 | attribute = next( 140 | child 141 | for child in attribute.iterAttributes() 142 | if child.name.rsplit(".", 1)[-1] == component 143 | ) 144 | except StopIteration: 145 | raise NoObject("{}.{}".format(attribute.name, component)) 146 | 147 | return attribute 148 | 149 | 150 | def findMachines( 151 | fqpn: str, 152 | ) -> Iterator[tuple[str, MethodicalMachine | TypeMachine[InputProtocol, Core]]]: 153 | """ 154 | Recursively yield L{MethodicalMachine}s and their FQPNs in and under the a 155 | Python object specified by an FQPN. 156 | 157 | The discovery heuristic considers L{MethodicalMachine} instances that are 158 | module-level attributes or class-level attributes accessible from module 159 | scope. Machines inside nested classes will be discovered, but those 160 | returned from functions or methods will not be. 161 | 162 | @param fqpn: a fully-qualified Python identifier (i.e. the dotted 163 | identifier of an object defined at module or class scope, including the 164 | package and modele names); where to start the search. 165 | 166 | @return: a generator which yields (C{FQPN}, L{MethodicalMachine}) pairs. 167 | """ 168 | return findMachinesViaWrapper(wrapFQPN(fqpn)) 169 | -------------------------------------------------------------------------------- /src/automat/_introspection.py: -------------------------------------------------------------------------------- 1 | """ 2 | Python introspection helpers. 3 | """ 4 | 5 | from types import CodeType as code, FunctionType as function 6 | 7 | 8 | def copycode(template, changes): 9 | if hasattr(code, "replace"): 10 | return template.replace(**{"co_" + k: v for k, v in changes.items()}) 11 | names = [ 12 | "argcount", 13 | "nlocals", 14 | "stacksize", 15 | "flags", 16 | "code", 17 | "consts", 18 | "names", 19 | "varnames", 20 | "filename", 21 | "name", 22 | "firstlineno", 23 | "lnotab", 24 | "freevars", 25 | "cellvars", 26 | ] 27 | if hasattr(code, "co_kwonlyargcount"): 28 | names.insert(1, "kwonlyargcount") 29 | if hasattr(code, "co_posonlyargcount"): 30 | # PEP 570 added "positional only arguments" 31 | names.insert(1, "posonlyargcount") 32 | values = [changes.get(name, getattr(template, "co_" + name)) for name in names] 33 | return code(*values) 34 | 35 | 36 | def copyfunction(template, funcchanges, codechanges): 37 | names = [ 38 | "globals", 39 | "name", 40 | "defaults", 41 | "closure", 42 | ] 43 | values = [ 44 | funcchanges.get(name, getattr(template, "__" + name + "__")) for name in names 45 | ] 46 | return function(copycode(template.__code__, codechanges), *values) 47 | 48 | 49 | def preserveName(f): 50 | """ 51 | Preserve the name of the given function on the decorated function. 52 | """ 53 | 54 | def decorator(decorated): 55 | return copyfunction(decorated, dict(name=f.__name__), dict(name=f.__name__)) 56 | 57 | return decorator 58 | -------------------------------------------------------------------------------- /src/automat/_methodical.py: -------------------------------------------------------------------------------- 1 | # -*- test-case-name: automat._test.test_methodical -*- 2 | from __future__ import annotations 3 | 4 | import collections 5 | import sys 6 | from dataclasses import dataclass, field 7 | from functools import wraps 8 | from inspect import getfullargspec as getArgsSpec 9 | from itertools import count 10 | from typing import Any, Callable, Hashable, Iterable, TypeVar 11 | 12 | if sys.version_info < (3, 10): 13 | from typing_extensions import TypeAlias 14 | else: 15 | from typing import TypeAlias 16 | 17 | from ._core import Automaton, OutputTracer, Tracer, Transitioner 18 | from ._introspection import preserveName 19 | 20 | ArgSpec = collections.namedtuple( 21 | "ArgSpec", 22 | [ 23 | "args", 24 | "varargs", 25 | "varkw", 26 | "defaults", 27 | "kwonlyargs", 28 | "kwonlydefaults", 29 | "annotations", 30 | ], 31 | ) 32 | 33 | 34 | def _getArgSpec(func): 35 | """ 36 | Normalize inspect.ArgSpec across python versions 37 | and convert mutable attributes to immutable types. 38 | 39 | :param Callable func: A function. 40 | :return: The function's ArgSpec. 41 | :rtype: ArgSpec 42 | """ 43 | spec = getArgsSpec(func) 44 | return ArgSpec( 45 | args=tuple(spec.args), 46 | varargs=spec.varargs, 47 | varkw=spec.varkw, 48 | defaults=spec.defaults if spec.defaults else (), 49 | kwonlyargs=tuple(spec.kwonlyargs), 50 | kwonlydefaults=( 51 | tuple(spec.kwonlydefaults.items()) if spec.kwonlydefaults else () 52 | ), 53 | annotations=tuple(spec.annotations.items()), 54 | ) 55 | 56 | 57 | def _getArgNames(spec): 58 | """ 59 | Get the name of all arguments defined in a function signature. 60 | 61 | The name of * and ** arguments is normalized to "*args" and "**kwargs". 62 | 63 | Return type annotations are omitted, since we don't constrain input methods 64 | to have the same return type as output methods, nor output methods to have 65 | the same output type. 66 | 67 | :param ArgSpec spec: A function to interrogate for a signature. 68 | :return: The set of all argument names in `func`s signature. 69 | :rtype: Set[str] 70 | """ 71 | return set( 72 | spec.args 73 | + spec.kwonlyargs 74 | + (("*args",) if spec.varargs else ()) 75 | + (("**kwargs",) if spec.varkw else ()) 76 | + tuple(a for a in spec.annotations if a[0] != "return") 77 | ) 78 | 79 | 80 | def _keywords_only(f): 81 | """ 82 | Decorate a function so all its arguments must be passed by keyword. 83 | 84 | A useful utility for decorators that take arguments so that they don't 85 | accidentally get passed the thing they're decorating as their first 86 | argument. 87 | 88 | Only works for methods right now. 89 | """ 90 | 91 | @wraps(f) 92 | def g(self, **kw): 93 | return f(self, **kw) 94 | 95 | return g 96 | 97 | 98 | @dataclass(frozen=True) 99 | class MethodicalState(object): 100 | """ 101 | A state for a L{MethodicalMachine}. 102 | """ 103 | 104 | machine: MethodicalMachine = field(repr=False) 105 | method: Callable[..., Any] = field() 106 | serialized: bool = field(repr=False) 107 | 108 | def upon( 109 | self, 110 | input: MethodicalInput, 111 | enter: MethodicalState | None = None, 112 | outputs: Iterable[MethodicalOutput] | None = None, 113 | collector: Callable[[Iterable[T]], object] = list, 114 | ) -> None: 115 | """ 116 | Declare a state transition within the L{MethodicalMachine} associated 117 | with this L{MethodicalState}: upon the receipt of the `input`, enter 118 | the `state`, emitting each output in `outputs`. 119 | 120 | @param input: The input triggering a state transition. 121 | 122 | @param enter: The resulting state. 123 | 124 | @param outputs: The outputs to be triggered as a result of the declared 125 | state transition. 126 | 127 | @param collector: The function to be used when collecting output return 128 | values. 129 | 130 | @raises TypeError: if any of the `outputs` signatures do not match the 131 | `inputs` signature. 132 | 133 | @raises ValueError: if the state transition from `self` via `input` has 134 | already been defined. 135 | """ 136 | if enter is None: 137 | enter = self 138 | if outputs is None: 139 | outputs = [] 140 | inputArgs = _getArgNames(input.argSpec) 141 | for output in outputs: 142 | outputArgs = _getArgNames(output.argSpec) 143 | if not outputArgs.issubset(inputArgs): 144 | raise TypeError( 145 | "method {input} signature {inputSignature} " 146 | "does not match output {output} " 147 | "signature {outputSignature}".format( 148 | input=input.method.__name__, 149 | output=output.method.__name__, 150 | inputSignature=getArgsSpec(input.method), 151 | outputSignature=getArgsSpec(output.method), 152 | ) 153 | ) 154 | self.machine._oneTransition(self, input, enter, outputs, collector) 155 | 156 | def _name(self) -> str: 157 | return self.method.__name__ 158 | 159 | 160 | def _transitionerFromInstance( 161 | oself: object, 162 | symbol: str, 163 | automaton: Automaton[MethodicalState, MethodicalInput, MethodicalOutput], 164 | ) -> Transitioner[MethodicalState, MethodicalInput, MethodicalOutput]: 165 | """ 166 | Get a L{Transitioner} 167 | """ 168 | transitioner = getattr(oself, symbol, None) 169 | if transitioner is None: 170 | transitioner = Transitioner( 171 | automaton, 172 | automaton.initialState, 173 | ) 174 | setattr(oself, symbol, transitioner) 175 | return transitioner 176 | 177 | 178 | def _empty(): 179 | pass 180 | 181 | 182 | def _docstring(): 183 | """docstring""" 184 | 185 | 186 | def assertNoCode(f: Callable[..., Any]) -> None: 187 | # The function body must be empty, i.e. "pass" or "return None", which 188 | # both yield the same bytecode: LOAD_CONST (None), RETURN_VALUE. We also 189 | # accept functions with only a docstring, which yields slightly different 190 | # bytecode, because the "None" is put in a different constant slot. 191 | 192 | # Unfortunately, this does not catch function bodies that return a 193 | # constant value, e.g. "return 1", because their code is identical to a 194 | # "return None". They differ in the contents of their constant table, but 195 | # checking that would require us to parse the bytecode, find the index 196 | # being returned, then making sure the table has a None at that index. 197 | 198 | if f.__code__.co_code not in (_empty.__code__.co_code, _docstring.__code__.co_code): 199 | raise ValueError("function body must be empty") 200 | 201 | 202 | def _filterArgs(args, kwargs, inputSpec, outputSpec): 203 | """ 204 | Filter out arguments that were passed to input that output won't accept. 205 | 206 | :param tuple args: The *args that input received. 207 | :param dict kwargs: The **kwargs that input received. 208 | :param ArgSpec inputSpec: The input's arg spec. 209 | :param ArgSpec outputSpec: The output's arg spec. 210 | :return: The args and kwargs that output will accept. 211 | :rtype: Tuple[tuple, dict] 212 | """ 213 | named_args = tuple(zip(inputSpec.args[1:], args)) 214 | if outputSpec.varargs: 215 | # Only return all args if the output accepts *args. 216 | return_args = args 217 | else: 218 | # Filter out arguments that don't appear 219 | # in the output's method signature. 220 | return_args = [v for n, v in named_args if n in outputSpec.args] 221 | 222 | # Get any of input's default arguments that were not passed. 223 | passed_arg_names = tuple(kwargs) 224 | for name, value in named_args: 225 | passed_arg_names += (name, value) 226 | defaults = zip(inputSpec.args[::-1], inputSpec.defaults[::-1]) 227 | full_kwargs = {n: v for n, v in defaults if n not in passed_arg_names} 228 | full_kwargs.update(kwargs) 229 | 230 | if outputSpec.varkw: 231 | # Only pass all kwargs if the output method accepts **kwargs. 232 | return_kwargs = full_kwargs 233 | else: 234 | # Filter out names that the output method does not accept. 235 | all_accepted_names = outputSpec.args[1:] + outputSpec.kwonlyargs 236 | return_kwargs = { 237 | n: v for n, v in full_kwargs.items() if n in all_accepted_names 238 | } 239 | 240 | return return_args, return_kwargs 241 | 242 | 243 | T = TypeVar("T") 244 | R = TypeVar("R") 245 | 246 | 247 | @dataclass(eq=False) 248 | class MethodicalInput(object): 249 | """ 250 | An input for a L{MethodicalMachine}. 251 | """ 252 | 253 | automaton: Automaton[MethodicalState, MethodicalInput, MethodicalOutput] = field( 254 | repr=False 255 | ) 256 | method: Callable[..., Any] = field() 257 | symbol: str = field(repr=False) 258 | collectors: dict[MethodicalState, Callable[[Iterable[T]], R]] = field( 259 | default_factory=dict, repr=False 260 | ) 261 | 262 | argSpec: ArgSpec = field(init=False, repr=False) 263 | 264 | def __post_init__(self) -> None: 265 | self.argSpec = _getArgSpec(self.method) 266 | assertNoCode(self.method) 267 | 268 | def __get__(self, oself: object, type: None = None) -> object: 269 | """ 270 | Return a function that takes no arguments and returns values returned 271 | by output functions produced by the given L{MethodicalInput} in 272 | C{oself}'s current state. 273 | """ 274 | transitioner = _transitionerFromInstance(oself, self.symbol, self.automaton) 275 | 276 | @preserveName(self.method) 277 | @wraps(self.method) 278 | def doInput(*args: object, **kwargs: object) -> object: 279 | self.method(oself, *args, **kwargs) 280 | previousState = transitioner._state 281 | (outputs, outTracer) = transitioner.transition(self) 282 | collector = self.collectors[previousState] 283 | values = [] 284 | for output in outputs: 285 | if outTracer is not None: 286 | outTracer(output) 287 | a, k = _filterArgs(args, kwargs, self.argSpec, output.argSpec) 288 | value = output(oself, *a, **k) 289 | values.append(value) 290 | return collector(values) 291 | 292 | return doInput 293 | 294 | def _name(self) -> str: 295 | return self.method.__name__ 296 | 297 | 298 | @dataclass(frozen=True) 299 | class MethodicalOutput(object): 300 | """ 301 | An output for a L{MethodicalMachine}. 302 | """ 303 | 304 | machine: MethodicalMachine = field(repr=False) 305 | method: Callable[..., Any] 306 | argSpec: ArgSpec = field(init=False, repr=False, compare=False) 307 | 308 | def __post_init__(self) -> None: 309 | self.__dict__["argSpec"] = _getArgSpec(self.method) 310 | 311 | def __get__(self, oself, type=None): 312 | """ 313 | Outputs are private, so raise an exception when we attempt to get one. 314 | """ 315 | raise AttributeError( 316 | "{cls}.{method} is a state-machine output method; " 317 | "to produce this output, call an input method instead.".format( 318 | cls=type.__name__, method=self.method.__name__ 319 | ) 320 | ) 321 | 322 | def __call__(self, oself, *args, **kwargs): 323 | """ 324 | Call the underlying method. 325 | """ 326 | return self.method(oself, *args, **kwargs) 327 | 328 | def _name(self) -> str: 329 | return self.method.__name__ 330 | 331 | 332 | StringOutputTracer = Callable[[str], None] 333 | StringTracer: TypeAlias = "Callable[[str, str, str], StringOutputTracer | None]" 334 | 335 | 336 | def wrapTracer( 337 | wrapped: StringTracer | None, 338 | ) -> Tracer[MethodicalState, MethodicalInput, MethodicalOutput] | None: 339 | if wrapped is None: 340 | return None 341 | 342 | def tracer( 343 | state: MethodicalState, 344 | input: MethodicalInput, 345 | output: MethodicalState, 346 | ) -> OutputTracer[MethodicalOutput] | None: 347 | result = wrapped(state._name(), input._name(), output._name()) 348 | if result is not None: 349 | return lambda out: result(out._name()) 350 | return None 351 | 352 | return tracer 353 | 354 | 355 | @dataclass(eq=False) 356 | class MethodicalTracer(object): 357 | automaton: Automaton[MethodicalState, MethodicalInput, MethodicalOutput] = field( 358 | repr=False 359 | ) 360 | symbol: str = field(repr=False) 361 | 362 | def __get__( 363 | self, oself: object, type: object = None 364 | ) -> Callable[[StringTracer], None]: 365 | transitioner = _transitionerFromInstance(oself, self.symbol, self.automaton) 366 | 367 | def setTrace(tracer: StringTracer | None) -> None: 368 | transitioner.setTrace(wrapTracer(tracer)) 369 | 370 | return setTrace 371 | 372 | 373 | counter = count() 374 | 375 | 376 | def gensym(): 377 | """ 378 | Create a unique Python identifier. 379 | """ 380 | return "_symbol_" + str(next(counter)) 381 | 382 | 383 | class MethodicalMachine(object): 384 | """ 385 | A L{MethodicalMachine} is an interface to an L{Automaton} that uses methods 386 | on a class. 387 | """ 388 | 389 | def __init__(self): 390 | self._automaton = Automaton() 391 | self._reducers = {} 392 | self._symbol = gensym() 393 | 394 | def __get__(self, oself, type=None): 395 | """ 396 | L{MethodicalMachine} is an implementation detail for setting up 397 | class-level state; applications should never need to access it on an 398 | instance. 399 | """ 400 | if oself is not None: 401 | raise AttributeError("MethodicalMachine is an implementation detail.") 402 | return self 403 | 404 | @_keywords_only 405 | def state( 406 | self, initial: bool = False, terminal: bool = False, serialized: Hashable = None 407 | ): 408 | """ 409 | Declare a state, possibly an initial state or a terminal state. 410 | 411 | This is a decorator for methods, but it will modify the method so as 412 | not to be callable any more. 413 | 414 | @param initial: is this state the initial state? Only one state on 415 | this L{automat.MethodicalMachine} may be an initial state; more 416 | than one is an error. 417 | 418 | @param terminal: Is this state a terminal state? i.e. a state that the 419 | machine can end up in? (This is purely informational at this 420 | point.) 421 | 422 | @param serialized: a serializable value to be used to represent this 423 | state to external systems. This value should be hashable; L{str} 424 | is a good type to use. 425 | """ 426 | 427 | def decorator(stateMethod): 428 | state = MethodicalState( 429 | machine=self, method=stateMethod, serialized=serialized 430 | ) 431 | if initial: 432 | self._automaton.initialState = state 433 | return state 434 | 435 | return decorator 436 | 437 | @_keywords_only 438 | def input(self): 439 | """ 440 | Declare an input. 441 | 442 | This is a decorator for methods. 443 | """ 444 | 445 | def decorator(inputMethod): 446 | return MethodicalInput( 447 | automaton=self._automaton, method=inputMethod, symbol=self._symbol 448 | ) 449 | 450 | return decorator 451 | 452 | @_keywords_only 453 | def output(self): 454 | """ 455 | Declare an output. 456 | 457 | This is a decorator for methods. 458 | 459 | This method will be called when the state machine transitions to this 460 | state as specified in the decorated `output` method. 461 | """ 462 | 463 | def decorator(outputMethod): 464 | return MethodicalOutput(machine=self, method=outputMethod) 465 | 466 | return decorator 467 | 468 | def _oneTransition(self, startState, inputToken, endState, outputTokens, collector): 469 | """ 470 | See L{MethodicalState.upon}. 471 | """ 472 | # FIXME: tests for all of this (some of it is wrong) 473 | # if not isinstance(startState, MethodicalState): 474 | # raise NotImplementedError("start state {} isn't a state" 475 | # .format(startState)) 476 | # if not isinstance(inputToken, MethodicalInput): 477 | # raise NotImplementedError("start state {} isn't an input" 478 | # .format(inputToken)) 479 | # if not isinstance(endState, MethodicalState): 480 | # raise NotImplementedError("end state {} isn't a state" 481 | # .format(startState)) 482 | # for output in outputTokens: 483 | # if not isinstance(endState, MethodicalState): 484 | # raise NotImplementedError("output state {} isn't a state" 485 | # .format(endState)) 486 | self._automaton.addTransition( 487 | startState, inputToken, endState, tuple(outputTokens) 488 | ) 489 | inputToken.collectors[startState] = collector 490 | 491 | @_keywords_only 492 | def serializer(self): 493 | """ """ 494 | 495 | def decorator(decoratee): 496 | @wraps(decoratee) 497 | def serialize(oself): 498 | transitioner = _transitionerFromInstance( 499 | oself, self._symbol, self._automaton 500 | ) 501 | return decoratee(oself, transitioner._state.serialized) 502 | 503 | return serialize 504 | 505 | return decorator 506 | 507 | @_keywords_only 508 | def unserializer(self): 509 | """ """ 510 | 511 | def decorator(decoratee): 512 | @wraps(decoratee) 513 | def unserialize(oself, *args, **kwargs): 514 | state = decoratee(oself, *args, **kwargs) 515 | mapping = {} 516 | for eachState in self._automaton.states(): 517 | mapping[eachState.serialized] = eachState 518 | transitioner = _transitionerFromInstance( 519 | oself, self._symbol, self._automaton 520 | ) 521 | transitioner._state = mapping[state] 522 | return None # it's on purpose 523 | 524 | return unserialize 525 | 526 | return decorator 527 | 528 | @property 529 | def _setTrace(self) -> MethodicalTracer: 530 | return MethodicalTracer(self._automaton, self._symbol) 531 | 532 | def asDigraph(self): 533 | """ 534 | Generate a L{graphviz.Digraph} that represents this machine's 535 | states and transitions. 536 | 537 | @return: L{graphviz.Digraph} object; for more information, please 538 | see the documentation for 539 | U{graphviz} 540 | 541 | """ 542 | from ._visualize import makeDigraph 543 | 544 | return makeDigraph( 545 | self._automaton, 546 | stateAsString=lambda state: state.method.__name__, 547 | inputAsString=lambda input: input.method.__name__, 548 | outputAsString=lambda output: output.method.__name__, 549 | ) 550 | -------------------------------------------------------------------------------- /src/automat/_runtimeproto.py: -------------------------------------------------------------------------------- 1 | """ 2 | Workaround for U{the lack of TypeForm 3 | }. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import sys 9 | 10 | from typing import TYPE_CHECKING, Callable, Protocol, TypeVar 11 | 12 | from inspect import signature, Signature 13 | 14 | T = TypeVar("T") 15 | 16 | ProtocolAtRuntime = Callable[[], T] 17 | 18 | 19 | def runtime_name(x: ProtocolAtRuntime[T]) -> str: 20 | return x.__name__ 21 | 22 | 23 | from inspect import getmembers, isfunction 24 | 25 | emptyProtocolMethods: frozenset[str] 26 | if not TYPE_CHECKING: 27 | emptyProtocolMethods = frozenset( 28 | name 29 | for name, each in getmembers(type("Example", tuple([Protocol]), {}), isfunction) 30 | ) 31 | 32 | 33 | def actuallyDefinedProtocolMethods(protocol: object) -> frozenset[str]: 34 | """ 35 | Attempt to ignore implementation details, and get all the methods that the 36 | protocol actually defines. 37 | 38 | that includes locally defined methods and also those defined in inherited 39 | superclasses. 40 | """ 41 | return ( 42 | frozenset(name for name, each in getmembers(protocol, isfunction)) 43 | - emptyProtocolMethods 44 | ) 45 | 46 | 47 | def _fixAnnotation(method: Callable[..., object], it: object, ann: str) -> None: 48 | annotation = getattr(it, ann) 49 | if isinstance(annotation, str): 50 | setattr(it, ann, eval(annotation, method.__globals__)) 51 | 52 | 53 | def _liveSignature(method: Callable[..., object]) -> Signature: 54 | """ 55 | Get a signature with evaluated annotations. 56 | """ 57 | # TODO: could this be replaced with get_type_hints? 58 | result = signature(method) 59 | for param in result.parameters.values(): 60 | _fixAnnotation(method, param, "_annotation") 61 | _fixAnnotation(method, result, "_return_annotation") 62 | return result 63 | -------------------------------------------------------------------------------- /src/automat/_test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glyph/automat/ef18293c905dd9fde902eb20c718a0a465a9d33b/src/automat/_test/__init__.py -------------------------------------------------------------------------------- /src/automat/_test/test_core.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from .._core import Automaton, NoTransition, Transitioner 4 | 5 | 6 | class CoreTests(TestCase): 7 | """ 8 | Tests for Automat's (currently private, implementation detail) core. 9 | """ 10 | 11 | def test_NoTransition(self): 12 | """ 13 | A L{NoTransition} exception describes the state and input symbol 14 | that caused it. 15 | """ 16 | # NoTransition requires two arguments 17 | with self.assertRaises(TypeError): 18 | NoTransition() 19 | 20 | state = "current-state" 21 | symbol = "transitionless-symbol" 22 | noTransitionException = NoTransition(state=state, symbol=symbol) 23 | 24 | self.assertIs(noTransitionException.symbol, symbol) 25 | 26 | self.assertIn(state, str(noTransitionException)) 27 | self.assertIn(symbol, str(noTransitionException)) 28 | 29 | def test_unhandledTransition(self) -> None: 30 | """ 31 | Automaton.unhandledTransition sets the outputs and end-state to be used 32 | for all unhandled transitions. 33 | """ 34 | a: Automaton[str, str, str] = Automaton("start") 35 | a.addTransition("oops-state", "check", "start", tuple(["checked"])) 36 | a.unhandledTransition("oops-state", ["oops-out"]) 37 | t = Transitioner(a, "start") 38 | self.assertEqual(t.transition("check"), (tuple(["oops-out"]), None)) 39 | self.assertEqual(t.transition("check"), (["checked"], None)) 40 | self.assertEqual(t.transition("check"), (tuple(["oops-out"]), None)) 41 | 42 | def test_noOutputForInput(self): 43 | """ 44 | L{Automaton.outputForInput} raises L{NoTransition} if no 45 | transition for that input is defined. 46 | """ 47 | a = Automaton() 48 | self.assertRaises(NoTransition, a.outputForInput, "no-state", "no-symbol") 49 | 50 | def test_oneTransition(self): 51 | """ 52 | L{Automaton.addTransition} adds its input symbol to 53 | L{Automaton.inputAlphabet}, all its outputs to 54 | L{Automaton.outputAlphabet}, and causes L{Automaton.outputForInput} to 55 | start returning the new state and output symbols. 56 | """ 57 | a = Automaton() 58 | a.addTransition("beginning", "begin", "ending", ["end"]) 59 | self.assertEqual(a.inputAlphabet(), {"begin"}) 60 | self.assertEqual(a.outputAlphabet(), {"end"}) 61 | self.assertEqual(a.outputForInput("beginning", "begin"), ("ending", ["end"])) 62 | self.assertEqual(a.states(), {"beginning", "ending"}) 63 | 64 | def test_oneTransition_nonIterableOutputs(self): 65 | """ 66 | L{Automaton.addTransition} raises a TypeError when given outputs 67 | that aren't iterable and doesn't add any transitions. 68 | """ 69 | a = Automaton() 70 | nonIterableOutputs = 1 71 | self.assertRaises( 72 | TypeError, 73 | a.addTransition, 74 | "fromState", 75 | "viaSymbol", 76 | "toState", 77 | nonIterableOutputs, 78 | ) 79 | self.assertFalse(a.inputAlphabet()) 80 | self.assertFalse(a.outputAlphabet()) 81 | self.assertFalse(a.states()) 82 | self.assertFalse(a.allTransitions()) 83 | 84 | def test_initialState(self): 85 | """ 86 | L{Automaton.initialState} is a descriptor that sets the initial 87 | state if it's not yet set, and raises L{ValueError} if it is. 88 | 89 | """ 90 | a = Automaton() 91 | a.initialState = "a state" 92 | self.assertEqual(a.initialState, "a state") 93 | with self.assertRaises(ValueError): 94 | a.initialState = "another state" 95 | 96 | 97 | # FIXME: addTransition for transition that's been added before 98 | -------------------------------------------------------------------------------- /src/automat/_test/test_methodical.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the public interface of Automat. 3 | """ 4 | 5 | from functools import reduce 6 | from unittest import TestCase 7 | 8 | from automat._methodical import ArgSpec, _getArgNames, _getArgSpec, _filterArgs 9 | from .. import MethodicalMachine, NoTransition 10 | from .. import _methodical 11 | 12 | 13 | class MethodicalTests(TestCase): 14 | """ 15 | Tests for L{MethodicalMachine}. 16 | """ 17 | 18 | def test_oneTransition(self): 19 | """ 20 | L{MethodicalMachine} provides a way for you to declare a state machine 21 | with inputs, outputs, and states as methods. When you have declared an 22 | input, an output, and a state, calling the input method in that state 23 | will produce the specified output. 24 | """ 25 | 26 | class Machination(object): 27 | machine = MethodicalMachine() 28 | 29 | @machine.input() 30 | def anInput(self): 31 | "an input" 32 | 33 | @machine.output() 34 | def anOutput(self): 35 | "an output" 36 | return "an-output-value" 37 | 38 | @machine.output() 39 | def anotherOutput(self): 40 | "another output" 41 | return "another-output-value" 42 | 43 | @machine.state(initial=True) 44 | def anState(self): 45 | "a state" 46 | 47 | @machine.state() 48 | def anotherState(self): 49 | "another state" 50 | 51 | anState.upon(anInput, enter=anotherState, outputs=[anOutput]) 52 | anotherState.upon(anInput, enter=anotherState, outputs=[anotherOutput]) 53 | 54 | m = Machination() 55 | self.assertEqual(m.anInput(), ["an-output-value"]) 56 | self.assertEqual(m.anInput(), ["another-output-value"]) 57 | 58 | def test_machineItselfIsPrivate(self): 59 | """ 60 | L{MethodicalMachine} is an implementation detail. If you attempt to 61 | access it on an instance of your class, you will get an exception. 62 | However, since tools may need to access it for the purposes of, for 63 | example, visualization, you may access it on the class itself. 64 | """ 65 | expectedMachine = MethodicalMachine() 66 | 67 | class Machination(object): 68 | machine = expectedMachine 69 | 70 | machination = Machination() 71 | with self.assertRaises(AttributeError) as cm: 72 | machination.machine 73 | self.assertIn( 74 | "MethodicalMachine is an implementation detail", str(cm.exception) 75 | ) 76 | self.assertIs(Machination.machine, expectedMachine) 77 | 78 | def test_outputsArePrivate(self): 79 | """ 80 | One of the benefits of using a state machine is that your output method 81 | implementations don't need to take invalid state transitions into 82 | account - the methods simply won't be called. This property would be 83 | broken if client code called output methods directly, so output methods 84 | are not directly visible under their names. 85 | """ 86 | 87 | class Machination(object): 88 | machine = MethodicalMachine() 89 | counter = 0 90 | 91 | @machine.input() 92 | def anInput(self): 93 | "an input" 94 | 95 | @machine.output() 96 | def anOutput(self): 97 | self.counter += 1 98 | 99 | @machine.state(initial=True) 100 | def state(self): 101 | "a machine state" 102 | 103 | state.upon(anInput, enter=state, outputs=[anOutput]) 104 | 105 | mach1 = Machination() 106 | mach1.anInput() 107 | self.assertEqual(mach1.counter, 1) 108 | mach2 = Machination() 109 | with self.assertRaises(AttributeError) as cm: 110 | mach2.anOutput 111 | self.assertEqual(mach2.counter, 0) 112 | 113 | self.assertIn( 114 | "Machination.anOutput is a state-machine output method; to " 115 | "produce this output, call an input method instead.", 116 | str(cm.exception), 117 | ) 118 | 119 | def test_multipleMachines(self): 120 | """ 121 | Two machines may co-exist happily on the same instance; they don't 122 | interfere with each other. 123 | """ 124 | 125 | class MultiMach(object): 126 | a = MethodicalMachine() 127 | b = MethodicalMachine() 128 | 129 | @a.input() 130 | def inputA(self): 131 | "input A" 132 | 133 | @b.input() 134 | def inputB(self): 135 | "input B" 136 | 137 | @a.state(initial=True) 138 | def initialA(self): 139 | "initial A" 140 | 141 | @b.state(initial=True) 142 | def initialB(self): 143 | "initial B" 144 | 145 | @a.output() 146 | def outputA(self): 147 | return "A" 148 | 149 | @b.output() 150 | def outputB(self): 151 | return "B" 152 | 153 | initialA.upon(inputA, initialA, [outputA]) 154 | initialB.upon(inputB, initialB, [outputB]) 155 | 156 | mm = MultiMach() 157 | self.assertEqual(mm.inputA(), ["A"]) 158 | self.assertEqual(mm.inputB(), ["B"]) 159 | 160 | def test_collectOutputs(self): 161 | """ 162 | Outputs can be combined with the "collector" argument to "upon". 163 | """ 164 | import operator 165 | 166 | class Machine(object): 167 | m = MethodicalMachine() 168 | 169 | @m.input() 170 | def input(self): 171 | "an input" 172 | 173 | @m.output() 174 | def outputA(self): 175 | return "A" 176 | 177 | @m.output() 178 | def outputB(self): 179 | return "B" 180 | 181 | @m.state(initial=True) 182 | def state(self): 183 | "a state" 184 | 185 | state.upon( 186 | input, 187 | state, 188 | [outputA, outputB], 189 | collector=lambda x: reduce(operator.add, x), 190 | ) 191 | 192 | m = Machine() 193 | self.assertEqual(m.input(), "AB") 194 | 195 | def test_methodName(self): 196 | """ 197 | Input methods preserve their declared names. 198 | """ 199 | 200 | class Mech(object): 201 | m = MethodicalMachine() 202 | 203 | @m.input() 204 | def declaredInputName(self): 205 | "an input" 206 | 207 | @m.state(initial=True) 208 | def aState(self): 209 | "state" 210 | 211 | m = Mech() 212 | with self.assertRaises(TypeError) as cm: 213 | m.declaredInputName("too", "many", "arguments") 214 | self.assertIn("declaredInputName", str(cm.exception)) 215 | 216 | def test_inputWithArguments(self): 217 | """ 218 | If an input takes an argument, it will pass that along to its output. 219 | """ 220 | 221 | class Mechanism(object): 222 | m = MethodicalMachine() 223 | 224 | @m.input() 225 | def input(self, x, y=1): 226 | "an input" 227 | 228 | @m.state(initial=True) 229 | def state(self): 230 | "a state" 231 | 232 | @m.output() 233 | def output(self, x, y=1): 234 | self._x = x 235 | return x + y 236 | 237 | state.upon(input, state, [output]) 238 | 239 | m = Mechanism() 240 | self.assertEqual(m.input(3), [4]) 241 | self.assertEqual(m._x, 3) 242 | 243 | def test_outputWithSubsetOfArguments(self): 244 | """ 245 | Inputs pass arguments that output will accept. 246 | """ 247 | 248 | class Mechanism(object): 249 | m = MethodicalMachine() 250 | 251 | @m.input() 252 | def input(self, x, y=1): 253 | "an input" 254 | 255 | @m.state(initial=True) 256 | def state(self): 257 | "a state" 258 | 259 | @m.output() 260 | def outputX(self, x): 261 | self._x = x 262 | return x 263 | 264 | @m.output() 265 | def outputY(self, y): 266 | self._y = y 267 | return y 268 | 269 | @m.output() 270 | def outputNoArgs(self): 271 | return None 272 | 273 | state.upon(input, state, [outputX, outputY, outputNoArgs]) 274 | 275 | m = Mechanism() 276 | 277 | # Pass x as positional argument. 278 | self.assertEqual(m.input(3), [3, 1, None]) 279 | self.assertEqual(m._x, 3) 280 | self.assertEqual(m._y, 1) 281 | 282 | # Pass x as key word argument. 283 | self.assertEqual(m.input(x=4), [4, 1, None]) 284 | self.assertEqual(m._x, 4) 285 | self.assertEqual(m._y, 1) 286 | 287 | # Pass y as positional argument. 288 | self.assertEqual(m.input(6, 3), [6, 3, None]) 289 | self.assertEqual(m._x, 6) 290 | self.assertEqual(m._y, 3) 291 | 292 | # Pass y as key word argument. 293 | self.assertEqual(m.input(5, y=2), [5, 2, None]) 294 | self.assertEqual(m._x, 5) 295 | self.assertEqual(m._y, 2) 296 | 297 | def test_inputFunctionsMustBeEmpty(self): 298 | """ 299 | The wrapped input function must have an empty body. 300 | """ 301 | # input functions are executed to assert that the signature matches, 302 | # but their body must be empty 303 | 304 | _methodical._empty() # chase coverage 305 | _methodical._docstring() 306 | 307 | class Mechanism(object): 308 | m = MethodicalMachine() 309 | with self.assertRaises(ValueError) as cm: 310 | 311 | @m.input() 312 | def input(self): 313 | "an input" 314 | list() # pragma: no cover 315 | 316 | self.assertEqual(str(cm.exception), "function body must be empty") 317 | 318 | # all three of these cases should be valid. Functions/methods with 319 | # docstrings produce slightly different bytecode than ones without. 320 | 321 | class MechanismWithDocstring(object): 322 | m = MethodicalMachine() 323 | 324 | @m.input() 325 | def input(self): 326 | "an input" 327 | 328 | @m.state(initial=True) 329 | def start(self): 330 | "starting state" 331 | 332 | start.upon(input, enter=start, outputs=[]) 333 | 334 | MechanismWithDocstring().input() 335 | 336 | class MechanismWithPass(object): 337 | m = MethodicalMachine() 338 | 339 | @m.input() 340 | def input(self): 341 | pass 342 | 343 | @m.state(initial=True) 344 | def start(self): 345 | "starting state" 346 | 347 | start.upon(input, enter=start, outputs=[]) 348 | 349 | MechanismWithPass().input() 350 | 351 | class MechanismWithDocstringAndPass(object): 352 | m = MethodicalMachine() 353 | 354 | @m.input() 355 | def input(self): 356 | "an input" 357 | pass 358 | 359 | @m.state(initial=True) 360 | def start(self): 361 | "starting state" 362 | 363 | start.upon(input, enter=start, outputs=[]) 364 | 365 | MechanismWithDocstringAndPass().input() 366 | 367 | class MechanismReturnsNone(object): 368 | m = MethodicalMachine() 369 | 370 | @m.input() 371 | def input(self): 372 | return None 373 | 374 | @m.state(initial=True) 375 | def start(self): 376 | "starting state" 377 | 378 | start.upon(input, enter=start, outputs=[]) 379 | 380 | MechanismReturnsNone().input() 381 | 382 | class MechanismWithDocstringAndReturnsNone(object): 383 | m = MethodicalMachine() 384 | 385 | @m.input() 386 | def input(self): 387 | "an input" 388 | return None 389 | 390 | @m.state(initial=True) 391 | def start(self): 392 | "starting state" 393 | 394 | start.upon(input, enter=start, outputs=[]) 395 | 396 | MechanismWithDocstringAndReturnsNone().input() 397 | 398 | def test_inputOutputMismatch(self): 399 | """ 400 | All the argument lists of the outputs for a given input must match; if 401 | one does not the call to C{upon} will raise a C{TypeError}. 402 | """ 403 | 404 | class Mechanism(object): 405 | m = MethodicalMachine() 406 | 407 | @m.input() 408 | def nameOfInput(self, a): 409 | "an input" 410 | 411 | @m.output() 412 | def outputThatMatches(self, a): 413 | "an output that matches" 414 | 415 | @m.output() 416 | def outputThatDoesntMatch(self, b): 417 | "an output that doesn't match" 418 | 419 | @m.state() 420 | def state(self): 421 | "a state" 422 | 423 | with self.assertRaises(TypeError) as cm: 424 | state.upon( 425 | nameOfInput, state, [outputThatMatches, outputThatDoesntMatch] 426 | ) 427 | self.assertIn("nameOfInput", str(cm.exception)) 428 | self.assertIn("outputThatDoesntMatch", str(cm.exception)) 429 | 430 | def test_stateLoop(self): 431 | """ 432 | It is possible to write a self-loop by omitting "enter" 433 | """ 434 | 435 | class Mechanism(object): 436 | m = MethodicalMachine() 437 | 438 | @m.input() 439 | def input(self): 440 | "an input" 441 | 442 | @m.input() 443 | def say_hi(self): 444 | "an input" 445 | 446 | @m.output() 447 | def _start_say_hi(self): 448 | return "hi" 449 | 450 | @m.state(initial=True) 451 | def start(self): 452 | "a state" 453 | 454 | def said_hi(self): 455 | "a state with no inputs" 456 | 457 | start.upon(input, outputs=[]) 458 | start.upon(say_hi, outputs=[_start_say_hi]) 459 | 460 | a_mechanism = Mechanism() 461 | [a_greeting] = a_mechanism.say_hi() 462 | self.assertEqual(a_greeting, "hi") 463 | 464 | def test_defaultOutputs(self): 465 | """ 466 | It is possible to write a transition with no outputs 467 | """ 468 | 469 | class Mechanism(object): 470 | m = MethodicalMachine() 471 | 472 | @m.input() 473 | def finish(self): 474 | "final transition" 475 | 476 | @m.state(initial=True) 477 | def start(self): 478 | "a start state" 479 | 480 | @m.state() 481 | def finished(self): 482 | "a final state" 483 | 484 | start.upon(finish, enter=finished) 485 | 486 | Mechanism().finish() 487 | 488 | def test_getArgNames(self): 489 | """ 490 | Type annotations should be included in the set of 491 | """ 492 | spec = ArgSpec( 493 | args=("a", "b"), 494 | varargs=None, 495 | varkw=None, 496 | defaults=None, 497 | kwonlyargs=(), 498 | kwonlydefaults=None, 499 | annotations=(("a", int), ("b", str)), 500 | ) 501 | self.assertEqual( 502 | _getArgNames(spec), 503 | {"a", "b", ("a", int), ("b", str)}, 504 | ) 505 | 506 | def test_filterArgs(self): 507 | """ 508 | filterArgs() should not filter the `args` parameter 509 | if outputSpec accepts `*args`. 510 | """ 511 | inputSpec = _getArgSpec(lambda *args, **kwargs: None) 512 | outputSpec = _getArgSpec(lambda *args, **kwargs: None) 513 | argsIn = () 514 | argsOut, _ = _filterArgs(argsIn, {}, inputSpec, outputSpec) 515 | self.assertIs(argsIn, argsOut) 516 | 517 | def test_multipleInitialStatesFailure(self): 518 | """ 519 | A L{MethodicalMachine} can only have one initial state. 520 | """ 521 | 522 | class WillFail(object): 523 | m = MethodicalMachine() 524 | 525 | @m.state(initial=True) 526 | def firstInitialState(self): 527 | "The first initial state -- this is OK." 528 | 529 | with self.assertRaises(ValueError): 530 | 531 | @m.state(initial=True) 532 | def secondInitialState(self): 533 | "The second initial state -- results in a ValueError." 534 | 535 | def test_multipleTransitionsFailure(self): 536 | """ 537 | A L{MethodicalMachine} can only have one transition per start/event 538 | pair. 539 | """ 540 | 541 | class WillFail(object): 542 | m = MethodicalMachine() 543 | 544 | @m.state(initial=True) 545 | def start(self): 546 | "We start here." 547 | 548 | @m.state() 549 | def end(self): 550 | "Rainbows end." 551 | 552 | @m.input() 553 | def event(self): 554 | "An event." 555 | 556 | start.upon(event, enter=end, outputs=[]) 557 | with self.assertRaises(ValueError): 558 | start.upon(event, enter=end, outputs=[]) 559 | 560 | def test_badTransitionForCurrentState(self): 561 | """ 562 | Calling any input method that lacks a transition for the machine's 563 | current state raises an informative L{NoTransition}. 564 | """ 565 | 566 | class OnlyOnePath(object): 567 | m = MethodicalMachine() 568 | 569 | @m.state(initial=True) 570 | def start(self): 571 | "Start state." 572 | 573 | @m.state() 574 | def end(self): 575 | "End state." 576 | 577 | @m.input() 578 | def advance(self): 579 | "Move from start to end." 580 | 581 | @m.input() 582 | def deadEnd(self): 583 | "A transition from nowhere to nowhere." 584 | 585 | start.upon(advance, end, []) 586 | 587 | machine = OnlyOnePath() 588 | with self.assertRaises(NoTransition) as cm: 589 | machine.deadEnd() 590 | self.assertIn("deadEnd", str(cm.exception)) 591 | self.assertIn("start", str(cm.exception)) 592 | machine.advance() 593 | with self.assertRaises(NoTransition) as cm: 594 | machine.deadEnd() 595 | self.assertIn("deadEnd", str(cm.exception)) 596 | self.assertIn("end", str(cm.exception)) 597 | 598 | def test_saveState(self): 599 | """ 600 | L{MethodicalMachine.serializer} is a decorator that modifies its 601 | decoratee's signature to take a "state" object as its first argument, 602 | which is the "serialized" argument to the L{MethodicalMachine.state} 603 | decorator. 604 | """ 605 | 606 | class Mechanism(object): 607 | m = MethodicalMachine() 608 | 609 | def __init__(self): 610 | self.value = 1 611 | 612 | @m.state(serialized="first-state", initial=True) 613 | def first(self): 614 | "First state." 615 | 616 | @m.state(serialized="second-state") 617 | def second(self): 618 | "Second state." 619 | 620 | @m.serializer() 621 | def save(self, state): 622 | return { 623 | "machine-state": state, 624 | "some-value": self.value, 625 | } 626 | 627 | self.assertEqual( 628 | Mechanism().save(), 629 | { 630 | "machine-state": "first-state", 631 | "some-value": 1, 632 | }, 633 | ) 634 | 635 | def test_restoreState(self): 636 | """ 637 | L{MethodicalMachine.unserializer} decorates a function that becomes a 638 | machine-state unserializer; its return value is mapped to the 639 | C{serialized} parameter to C{state}, and the L{MethodicalMachine} 640 | associated with that instance's state is updated to that state. 641 | """ 642 | 643 | class Mechanism(object): 644 | m = MethodicalMachine() 645 | 646 | def __init__(self): 647 | self.value = 1 648 | self.ranOutput = False 649 | 650 | @m.state(serialized="first-state", initial=True) 651 | def first(self): 652 | "First state." 653 | 654 | @m.state(serialized="second-state") 655 | def second(self): 656 | "Second state." 657 | 658 | @m.input() 659 | def input(self): 660 | "an input" 661 | 662 | @m.output() 663 | def output(self): 664 | self.value = 2 665 | self.ranOutput = True 666 | return 1 667 | 668 | @m.output() 669 | def output2(self): 670 | return 2 671 | 672 | first.upon(input, second, [output], collector=lambda x: list(x)[0]) 673 | second.upon(input, second, [output2], collector=lambda x: list(x)[0]) 674 | 675 | @m.serializer() 676 | def save(self, state): 677 | return { 678 | "machine-state": state, 679 | "some-value": self.value, 680 | } 681 | 682 | @m.unserializer() 683 | def _restore(self, blob): 684 | self.value = blob["some-value"] 685 | return blob["machine-state"] 686 | 687 | @classmethod 688 | def fromBlob(cls, blob): 689 | self = cls() 690 | self._restore(blob) 691 | return self 692 | 693 | m1 = Mechanism() 694 | m1.input() 695 | blob = m1.save() 696 | m2 = Mechanism.fromBlob(blob) 697 | self.assertEqual(m2.ranOutput, False) 698 | self.assertEqual(m2.input(), 2) 699 | self.assertEqual( 700 | m2.save(), 701 | { 702 | "machine-state": "second-state", 703 | "some-value": 2, 704 | }, 705 | ) 706 | 707 | def test_allowBasicTypeAnnotations(self): 708 | """ 709 | L{MethodicalMachine} can operate with type annotations on inputs and outputs. 710 | """ 711 | 712 | class Mechanism(object): 713 | m = MethodicalMachine() 714 | 715 | @m.input() 716 | def an_input(self, arg: int): 717 | "An input" 718 | 719 | @m.output() 720 | def an_output(self, arg: int) -> int: 721 | return arg + 1 722 | 723 | @m.state(initial=True) 724 | def state(self): 725 | "A state" 726 | 727 | state.upon(an_input, enter=state, outputs=[an_output]) 728 | 729 | mechanism = Mechanism() 730 | assert mechanism.an_input(2) == [3] 731 | 732 | 733 | # FIXME: error for wrong types on any call to _oneTransition 734 | # FIXME: better public API for .upon; maybe a context manager? 735 | # FIXME: when transitions are defined, validate that we can always get to 736 | # terminal? do we care about this? 737 | # FIXME: implementation (and use-case/example) for passing args from in to out 738 | 739 | # FIXME: possibly these need some kind of support from core 740 | # FIXME: wildcard state (in all states, when input X, emit Y and go to Z) 741 | # FIXME: wildcard input (in state X, when any input, emit Y and go to Z) 742 | # FIXME: combined wildcards (in any state for any input, emit Y go to Z) 743 | -------------------------------------------------------------------------------- /src/automat/_test/test_trace.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from .._methodical import MethodicalMachine 3 | 4 | 5 | class SampleObject(object): 6 | mm = MethodicalMachine() 7 | 8 | @mm.state(initial=True) 9 | def begin(self): 10 | "initial state" 11 | 12 | @mm.state() 13 | def middle(self): 14 | "middle state" 15 | 16 | @mm.state() 17 | def end(self): 18 | "end state" 19 | 20 | @mm.input() 21 | def go1(self): 22 | "sample input" 23 | 24 | @mm.input() 25 | def go2(self): 26 | "sample input" 27 | 28 | @mm.input() 29 | def back(self): 30 | "sample input" 31 | 32 | @mm.output() 33 | def out(self): 34 | "sample output" 35 | 36 | setTrace = mm._setTrace 37 | 38 | begin.upon(go1, middle, [out]) 39 | middle.upon(go2, end, [out]) 40 | end.upon(back, middle, []) 41 | middle.upon(back, begin, []) 42 | 43 | 44 | class TraceTests(TestCase): 45 | def test_only_inputs(self): 46 | traces = [] 47 | 48 | def tracer(old_state, input, new_state): 49 | traces.append((old_state, input, new_state)) 50 | return None # "I only care about inputs, not outputs" 51 | 52 | s = SampleObject() 53 | s.setTrace(tracer) 54 | 55 | s.go1() 56 | self.assertEqual( 57 | traces, 58 | [ 59 | ("begin", "go1", "middle"), 60 | ], 61 | ) 62 | 63 | s.go2() 64 | self.assertEqual( 65 | traces, 66 | [ 67 | ("begin", "go1", "middle"), 68 | ("middle", "go2", "end"), 69 | ], 70 | ) 71 | s.setTrace(None) 72 | s.back() 73 | self.assertEqual( 74 | traces, 75 | [ 76 | ("begin", "go1", "middle"), 77 | ("middle", "go2", "end"), 78 | ], 79 | ) 80 | s.go2() 81 | self.assertEqual( 82 | traces, 83 | [ 84 | ("begin", "go1", "middle"), 85 | ("middle", "go2", "end"), 86 | ], 87 | ) 88 | 89 | def test_inputs_and_outputs(self): 90 | traces = [] 91 | 92 | def tracer(old_state, input, new_state): 93 | traces.append((old_state, input, new_state, None)) 94 | 95 | def trace_outputs(output): 96 | traces.append((old_state, input, new_state, output)) 97 | 98 | return trace_outputs # "I care about outputs too" 99 | 100 | s = SampleObject() 101 | s.setTrace(tracer) 102 | 103 | s.go1() 104 | self.assertEqual( 105 | traces, 106 | [ 107 | ("begin", "go1", "middle", None), 108 | ("begin", "go1", "middle", "out"), 109 | ], 110 | ) 111 | 112 | s.go2() 113 | self.assertEqual( 114 | traces, 115 | [ 116 | ("begin", "go1", "middle", None), 117 | ("begin", "go1", "middle", "out"), 118 | ("middle", "go2", "end", None), 119 | ("middle", "go2", "end", "out"), 120 | ], 121 | ) 122 | s.setTrace(None) 123 | s.back() 124 | self.assertEqual( 125 | traces, 126 | [ 127 | ("begin", "go1", "middle", None), 128 | ("begin", "go1", "middle", "out"), 129 | ("middle", "go2", "end", None), 130 | ("middle", "go2", "end", "out"), 131 | ], 132 | ) 133 | s.go2() 134 | self.assertEqual( 135 | traces, 136 | [ 137 | ("begin", "go1", "middle", None), 138 | ("begin", "go1", "middle", "out"), 139 | ("middle", "go2", "end", None), 140 | ("middle", "go2", "end", "out"), 141 | ], 142 | ) 143 | -------------------------------------------------------------------------------- /src/automat/_test/test_type_based.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Callable, Generic, List, Protocol, TypeVar 5 | from unittest import TestCase, skipIf 6 | 7 | from .. import AlreadyBuiltError, NoTransition, TypeMachineBuilder, pep614 8 | 9 | try: 10 | from zope.interface import Interface, implementer # type:ignore[import-untyped] 11 | except ImportError: 12 | hasInterface = False 13 | else: 14 | hasInterface = True 15 | 16 | class ISomething(Interface): 17 | def something() -> int: ... # type:ignore[misc,empty-body] 18 | 19 | 20 | T = TypeVar("T") 21 | 22 | 23 | class ProtocolForTesting(Protocol): 24 | 25 | def change(self) -> None: 26 | "Switch to the other state." 27 | 28 | def value(self) -> int: 29 | "Give a value specific to the given state." 30 | 31 | 32 | class ArgTaker(Protocol): 33 | def takeSomeArgs(self, arg1: int = 0, arg2: str = "") -> None: ... 34 | def value(self) -> int: ... 35 | 36 | 37 | class NoOpCore: 38 | "Just an object, you know?" 39 | 40 | 41 | @dataclass 42 | class Gen(Generic[T]): 43 | t: T 44 | 45 | 46 | def buildTestBuilder() -> tuple[ 47 | TypeMachineBuilder[ProtocolForTesting, NoOpCore], 48 | Callable[[NoOpCore], ProtocolForTesting], 49 | ]: 50 | builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) 51 | first = builder.state("first") 52 | second = builder.state("second") 53 | 54 | first.upon(ProtocolForTesting.change).to(second).returns(None) 55 | second.upon(ProtocolForTesting.change).to(first).returns(None) 56 | 57 | @pep614(first.upon(ProtocolForTesting.value).loop()) 58 | def firstValue(machine: ProtocolForTesting, core: NoOpCore) -> int: 59 | return 3 60 | 61 | @pep614(second.upon(ProtocolForTesting.value).loop()) 62 | def secondValue(machine: ProtocolForTesting, core: NoOpCore) -> int: 63 | return 4 64 | 65 | return builder, builder.build() 66 | 67 | 68 | builder, machineFactory = buildTestBuilder() 69 | 70 | 71 | def needsSomething(proto: ProtocolForTesting, core: NoOpCore, value: str) -> int: 72 | "we need data to build this state" 73 | return 3 # pragma: no cover 74 | 75 | 76 | def needsNothing(proto: ArgTaker, core: NoOpCore) -> str: 77 | return "state-specific data" # pragma: no cover 78 | 79 | 80 | class SimpleProtocol(Protocol): 81 | def method(self) -> None: 82 | "A method" 83 | 84 | 85 | class Counter(Protocol): 86 | def start(self) -> None: 87 | "enter the counting state" 88 | 89 | def increment(self) -> None: 90 | "increment the counter" 91 | 92 | def stop(self) -> int: 93 | "stop" 94 | 95 | 96 | @dataclass 97 | class Count: 98 | value: int = 0 99 | 100 | 101 | class TypeMachineTests(TestCase): 102 | 103 | def test_oneTransition(self) -> None: 104 | 105 | machine = machineFactory(NoOpCore()) 106 | 107 | self.assertEqual(machine.value(), 3) 108 | machine.change() 109 | self.assertEqual(machine.value(), 4) 110 | self.assertEqual(machine.value(), 4) 111 | machine.change() 112 | self.assertEqual(machine.value(), 3) 113 | 114 | def test_stateSpecificData(self) -> None: 115 | 116 | builder = TypeMachineBuilder(Counter, NoOpCore) 117 | initial = builder.state("initial") 118 | counting = builder.state("counting", lambda machine, core: Count()) 119 | initial.upon(Counter.start).to(counting).returns(None) 120 | 121 | @pep614(counting.upon(Counter.increment).loop()) 122 | def incf(counter: Counter, core: NoOpCore, count: Count) -> None: 123 | count.value += 1 124 | 125 | @pep614(counting.upon(Counter.stop).to(initial)) 126 | def finish(counter: Counter, core: NoOpCore, count: Count) -> int: 127 | return count.value 128 | 129 | machineFactory = builder.build() 130 | machine = machineFactory(NoOpCore()) 131 | machine.start() 132 | machine.increment() 133 | machine.increment() 134 | self.assertEqual(machine.stop(), 2) 135 | machine.start() 136 | machine.increment() 137 | self.assertEqual(machine.stop(), 1) 138 | 139 | def test_stateSpecificDataWithoutData(self) -> None: 140 | """ 141 | To facilitate common implementations of transition behavior methods, 142 | sometimes you want to implement a transition within a data state 143 | without taking a data parameter. To do this, pass the 'nodata=True' 144 | parameter to 'upon'. 145 | """ 146 | builder = TypeMachineBuilder(Counter, NoOpCore) 147 | initial = builder.state("initial") 148 | counting = builder.state("counting", lambda machine, core: Count()) 149 | startCalls = [] 150 | 151 | @pep614(initial.upon(Counter.start).to(counting)) 152 | @pep614(counting.upon(Counter.start, nodata=True).loop()) 153 | def start(counter: Counter, core: NoOpCore) -> None: 154 | startCalls.append("started!") 155 | 156 | @pep614(counting.upon(Counter.increment).loop()) 157 | def incf(counter: Counter, core: NoOpCore, count: Count) -> None: 158 | count.value += 1 159 | 160 | @pep614(counting.upon(Counter.stop).to(initial)) 161 | def finish(counter: Counter, core: NoOpCore, count: Count) -> int: 162 | return count.value 163 | 164 | machineFactory = builder.build() 165 | machine = machineFactory(NoOpCore()) 166 | machine.start() 167 | self.assertEqual(len(startCalls), 1) 168 | machine.start() 169 | self.assertEqual(len(startCalls), 2) 170 | machine.increment() 171 | self.assertEqual(machine.stop(), 1) 172 | 173 | def test_incompleteTransitionDefinition(self) -> None: 174 | builder = TypeMachineBuilder(SimpleProtocol, NoOpCore) 175 | sample = builder.state("sample") 176 | sample.upon(SimpleProtocol.method).loop() # oops, no '.returns(None)' 177 | with self.assertRaises(ValueError) as raised: 178 | builder.build() 179 | self.assertIn( 180 | "incomplete transition from sample to sample upon SimpleProtocol.method", 181 | str(raised.exception), 182 | ) 183 | 184 | def test_dataToData(self) -> None: 185 | builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) 186 | 187 | @dataclass 188 | class Data1: 189 | value: int 190 | 191 | @dataclass 192 | class Data2: 193 | stuff: List[str] 194 | 195 | initial = builder.state("initial") 196 | counting = builder.state("counting", lambda proto, core: Data1(1)) 197 | appending = builder.state("appending", lambda proto, core: Data2([])) 198 | 199 | initial.upon(ProtocolForTesting.change).to(counting).returns(None) 200 | 201 | @pep614(counting.upon(ProtocolForTesting.value).loop()) 202 | def countup(p: ProtocolForTesting, c: NoOpCore, d: Data1) -> int: 203 | d.value *= 2 204 | return d.value 205 | 206 | counting.upon(ProtocolForTesting.change).to(appending).returns(None) 207 | 208 | @pep614(appending.upon(ProtocolForTesting.value).loop()) 209 | def appendup(p: ProtocolForTesting, c: NoOpCore, d: Data2) -> int: 210 | d.stuff.extend("abc") 211 | return len(d.stuff) 212 | 213 | machineFactory = builder.build() 214 | machine = machineFactory(NoOpCore()) 215 | machine.change() 216 | self.assertEqual(machine.value(), 2) 217 | self.assertEqual(machine.value(), 4) 218 | machine.change() 219 | self.assertEqual(machine.value(), 3) 220 | self.assertEqual(machine.value(), 6) 221 | 222 | def test_dataFactoryArgs(self) -> None: 223 | """ 224 | Any data factory that takes arguments will constrain the allowed 225 | signature of all protocol methods that transition into that state. 226 | """ 227 | builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) 228 | initial = builder.state("initial") 229 | data = builder.state("data", needsSomething) 230 | data2 = builder.state("data2", needsSomething) 231 | # toState = initial.to(data) 232 | 233 | # 'assertions' in the form of expected type errors: 234 | # (no data -> data) 235 | uponNoData = initial.upon(ProtocolForTesting.change) 236 | uponNoData.to(data) # type:ignore[arg-type] 237 | 238 | # (data -> data) 239 | uponData = data.upon(ProtocolForTesting.change) 240 | uponData.to(data2) # type:ignore[arg-type] 241 | 242 | def test_dataFactoryNoArgs(self) -> None: 243 | """ 244 | Inverse of C{test_dataFactoryArgs} where the data factory specifically 245 | does I{not} take arguments, but the input specified does. 246 | """ 247 | builder = TypeMachineBuilder(ArgTaker, NoOpCore) 248 | initial = builder.state("initial") 249 | data = builder.state("data", needsNothing) 250 | ( 251 | initial.upon(ArgTaker.takeSomeArgs) 252 | .to(data) # type:ignore[arg-type] 253 | .returns(None) 254 | ) 255 | 256 | def test_invalidTransition(self) -> None: 257 | """ 258 | Invalid transitions raise a NoTransition exception. 259 | """ 260 | builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) 261 | builder.state("initial") 262 | factory = builder.build() 263 | machine = factory(NoOpCore()) 264 | with self.assertRaises(NoTransition): 265 | machine.change() 266 | 267 | def test_reentrancy(self) -> None: 268 | """ 269 | During the execution of a transition behavior implementation function, 270 | you may invoke other methods on your state machine. However, the 271 | execution of the behavior of those methods will be deferred until the 272 | current behavior method is done executing. In order to implement that 273 | deferral, we restrict the set of methods that can be invoked to those 274 | that return None. 275 | 276 | @note: it may be possible to implement deferral via Awaitables or 277 | Deferreds later, but we are starting simple. 278 | """ 279 | 280 | class SomeMethods(Protocol): 281 | def start(self) -> None: 282 | "Start the machine." 283 | 284 | def later(self) -> None: 285 | "Do some deferrable work." 286 | 287 | builder = TypeMachineBuilder(SomeMethods, NoOpCore) 288 | 289 | initial = builder.state("initial") 290 | second = builder.state("second") 291 | 292 | order = [] 293 | 294 | @pep614(initial.upon(SomeMethods.start).to(second)) 295 | def startup(methods: SomeMethods, core: NoOpCore) -> None: 296 | order.append("startup") 297 | methods.later() 298 | order.append("startup done") 299 | 300 | @pep614(second.upon(SomeMethods.later).loop()) 301 | def later(methods: SomeMethods, core: NoOpCore) -> None: 302 | order.append("later") 303 | 304 | machineFactory = builder.build() 305 | machine = machineFactory(NoOpCore()) 306 | machine.start() 307 | self.assertEqual(order, ["startup", "startup done", "later"]) 308 | 309 | def test_reentrancyNotNoneError(self) -> None: 310 | class SomeMethods(Protocol): 311 | def start(self) -> None: 312 | "Start the machine." 313 | 314 | def later(self) -> int: 315 | "Do some deferrable work." 316 | 317 | builder = TypeMachineBuilder(SomeMethods, NoOpCore) 318 | 319 | initial = builder.state("initial") 320 | second = builder.state("second") 321 | 322 | order = [] 323 | 324 | @pep614(initial.upon(SomeMethods.start).to(second)) 325 | def startup(methods: SomeMethods, core: NoOpCore) -> None: 326 | order.append("startup") 327 | methods.later() 328 | order.append("startup done") # pragma: no cover 329 | 330 | @pep614(second.upon(SomeMethods.later).loop()) 331 | def later(methods: SomeMethods, core: NoOpCore) -> int: 332 | order.append("later") 333 | return 3 334 | 335 | machineFactory = builder.build() 336 | machine = machineFactory(NoOpCore()) 337 | with self.assertRaises(RuntimeError): 338 | machine.start() 339 | self.assertEqual(order, ["startup"]) 340 | # We do actually do the state transition, which happens *before* the 341 | # output is generated; TODO: maybe we should have exception handling 342 | # that transitions into an error state that requires explicit recovery? 343 | self.assertEqual(machine.later(), 3) 344 | self.assertEqual(order, ["startup", "later"]) 345 | 346 | def test_buildLock(self) -> None: 347 | """ 348 | ``.build()`` locks the builder so it can no longer be modified. 349 | """ 350 | builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) 351 | state = builder.state("test-state") 352 | state2 = builder.state("state2") 353 | state3 = builder.state("state3") 354 | upon = state.upon(ProtocolForTesting.change) 355 | to = upon.to(state2) 356 | to2 = upon.to(state3) 357 | to.returns(None) 358 | with self.assertRaises(ValueError) as ve: 359 | to2.returns(None) 360 | with self.assertRaises(AlreadyBuiltError): 361 | to.returns(None) 362 | builder.build() 363 | with self.assertRaises(AlreadyBuiltError): 364 | builder.state("hello") 365 | with self.assertRaises(AlreadyBuiltError): 366 | builder.build() 367 | 368 | def test_methodMembership(self) -> None: 369 | """ 370 | Input methods must be members of their protocol. 371 | """ 372 | builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) 373 | state = builder.state("test-state") 374 | 375 | def stateful(proto: ProtocolForTesting, core: NoOpCore) -> int: 376 | return 4 # pragma: no cover 377 | 378 | state2 = builder.state("state2", stateful) 379 | 380 | def change(self: ProtocolForTesting) -> None: ... 381 | 382 | def rogue(self: ProtocolForTesting) -> int: 383 | return 3 # pragma: no cover 384 | 385 | with self.assertRaises(ValueError): 386 | state.upon(change) 387 | with self.assertRaises(ValueError) as ve: 388 | state2.upon(change) 389 | with self.assertRaises(ValueError): 390 | state.upon(rogue) 391 | 392 | def test_startInAlternateState(self) -> None: 393 | """ 394 | The state machine can be started in an alternate state. 395 | """ 396 | builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) 397 | one = builder.state("one") 398 | two = builder.state("two") 399 | 400 | @dataclass 401 | class Three: 402 | proto: ProtocolForTesting 403 | core: NoOpCore 404 | value: int = 0 405 | 406 | three = builder.state("three", Three) 407 | one.upon(ProtocolForTesting.change).to(two).returns(None) 408 | one.upon(ProtocolForTesting.value).loop().returns(1) 409 | two.upon(ProtocolForTesting.change).to(three).returns(None) 410 | two.upon(ProtocolForTesting.value).loop().returns(2) 411 | 412 | @pep614(three.upon(ProtocolForTesting.value).loop()) 413 | def threevalue(proto: ProtocolForTesting, core: NoOpCore, three: Three) -> int: 414 | return 3 + three.value 415 | 416 | onetwothree = builder.build() 417 | 418 | # confirm positive behavior first, particularly the value of the three 419 | # state's change 420 | normal = onetwothree(NoOpCore()) 421 | self.assertEqual(normal.value(), 1) 422 | normal.change() 423 | self.assertEqual(normal.value(), 2) 424 | normal.change() 425 | self.assertEqual(normal.value(), 3) 426 | 427 | # now try deserializing it in each state 428 | self.assertEqual(onetwothree(NoOpCore()).value(), 1) 429 | self.assertEqual(onetwothree(NoOpCore(), two).value(), 2) 430 | self.assertEqual( 431 | onetwothree( 432 | NoOpCore(), three, lambda proto, core: Three(proto, core, 4) 433 | ).value(), 434 | 7, 435 | ) 436 | 437 | def test_genericData(self) -> None: 438 | """ 439 | Test to cover get_origin in generic assertion. 440 | """ 441 | builder = TypeMachineBuilder(ArgTaker, NoOpCore) 442 | one = builder.state("one") 443 | 444 | def dat( 445 | proto: ArgTaker, core: NoOpCore, arg1: int = 0, arg2: str = "" 446 | ) -> Gen[int]: 447 | return Gen(arg1) 448 | 449 | two = builder.state("two", dat) 450 | one.upon(ArgTaker.takeSomeArgs).to(two).returns(None) 451 | 452 | @pep614(two.upon(ArgTaker.value).loop()) 453 | def val(proto: ArgTaker, core: NoOpCore, data: Gen[int]) -> int: 454 | return data.t 455 | 456 | b = builder.build() 457 | m = b(NoOpCore()) 458 | m.takeSomeArgs(3) 459 | self.assertEqual(m.value(), 3) 460 | 461 | @skipIf(not hasInterface, "zope.interface not installed") 462 | def test_interfaceData(self) -> None: 463 | """ 464 | Test to cover providedBy assertion. 465 | """ 466 | builder = TypeMachineBuilder(ArgTaker, NoOpCore) 467 | one = builder.state("one") 468 | 469 | @implementer(ISomething) 470 | @dataclass 471 | class Something: 472 | val: int 473 | 474 | def something(self) -> int: 475 | return self.val 476 | 477 | def dat( 478 | proto: ArgTaker, core: NoOpCore, arg1: int = 0, arg2: str = "" 479 | ) -> ISomething: 480 | return Something(arg1) # type:ignore[return-value] 481 | 482 | two = builder.state("two", dat) 483 | one.upon(ArgTaker.takeSomeArgs).to(two).returns(None) 484 | 485 | @pep614(two.upon(ArgTaker.value).loop()) 486 | def val(proto: ArgTaker, core: NoOpCore, data: ISomething) -> int: 487 | return data.something() # type:ignore[misc] 488 | 489 | b = builder.build() 490 | m = b(NoOpCore()) 491 | m.takeSomeArgs(3) 492 | self.assertEqual(m.value(), 3) 493 | 494 | def test_noMethodsInAltStateDataFactory(self) -> None: 495 | """ 496 | When the state machine is received by a data factory during 497 | construction, it is in an invalid state. It may be invoked after 498 | construction is complete. 499 | """ 500 | builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) 501 | 502 | @dataclass 503 | class Data: 504 | value: int 505 | proto: ProtocolForTesting 506 | 507 | start = builder.state("start") 508 | data = builder.state("data", lambda proto, core: Data(3, proto)) 509 | 510 | @pep614(data.upon(ProtocolForTesting.value).loop()) 511 | def getval(proto: ProtocolForTesting, core: NoOpCore, data: Data) -> int: 512 | return data.value 513 | 514 | @pep614(start.upon(ProtocolForTesting.value).loop()) 515 | def minusone(proto: ProtocolForTesting, core: NoOpCore) -> int: 516 | return -1 517 | 518 | factory = builder.build() 519 | self.assertEqual(factory(NoOpCore()).value(), -1) 520 | 521 | def touchproto(proto: ProtocolForTesting, core: NoOpCore) -> Data: 522 | return Data(proto.value(), proto) 523 | 524 | catchdata = [] 525 | 526 | def notouchproto(proto: ProtocolForTesting, core: NoOpCore) -> Data: 527 | catchdata.append(new := Data(4, proto)) 528 | return new 529 | 530 | with self.assertRaises(NoTransition): 531 | factory(NoOpCore(), data, touchproto) 532 | machine = factory(NoOpCore(), data, notouchproto) 533 | self.assertIs(machine, catchdata[0].proto) 534 | self.assertEqual(machine.value(), 4) 535 | -------------------------------------------------------------------------------- /src/automat/_test/test_visualize.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import os 5 | import subprocess 6 | from dataclasses import dataclass 7 | from typing import Protocol 8 | from unittest import TestCase, skipIf 9 | 10 | from automat import TypeMachineBuilder, pep614 11 | 12 | from .._methodical import MethodicalMachine 13 | from .._typed import TypeMachine 14 | from .test_discover import isTwistedInstalled 15 | 16 | 17 | def isGraphvizModuleInstalled(): 18 | """ 19 | Is the graphviz Python module installed? 20 | """ 21 | try: 22 | __import__("graphviz") 23 | except ImportError: 24 | return False 25 | else: 26 | return True 27 | 28 | 29 | def isGraphvizInstalled(): 30 | """ 31 | Are the graphviz tools installed? 32 | """ 33 | r, w = os.pipe() 34 | os.close(w) 35 | try: 36 | return not subprocess.call("dot", stdin=r, shell=True) 37 | finally: 38 | os.close(r) 39 | 40 | 41 | def sampleMachine(): 42 | """ 43 | Create a sample L{MethodicalMachine} with some sample states. 44 | """ 45 | mm = MethodicalMachine() 46 | 47 | class SampleObject(object): 48 | @mm.state(initial=True) 49 | def begin(self): 50 | "initial state" 51 | 52 | @mm.state() 53 | def end(self): 54 | "end state" 55 | 56 | @mm.input() 57 | def go(self): 58 | "sample input" 59 | 60 | @mm.output() 61 | def out(self): 62 | "sample output" 63 | 64 | begin.upon(go, end, [out]) 65 | 66 | so = SampleObject() 67 | so.go() 68 | return mm 69 | 70 | 71 | class Sample(Protocol): 72 | def go(self) -> None: ... 73 | class Core: ... 74 | 75 | 76 | def sampleTypeMachine() -> TypeMachine[Sample, Core]: 77 | """ 78 | Create a sample L{TypeMachine} with some sample states. 79 | """ 80 | builder = TypeMachineBuilder(Sample, Core) 81 | begin = builder.state("begin") 82 | 83 | def buildit(proto: Sample, core: Core) -> int: 84 | return 3 # pragma: no cover 85 | 86 | data = builder.state("data", buildit) 87 | end = builder.state("end") 88 | begin.upon(Sample.go).to(data).returns(None) 89 | data.upon(Sample.go).to(end).returns(None) 90 | 91 | @pep614(end.upon(Sample.go).to(begin)) 92 | def out(sample: Sample, core: Core) -> None: ... 93 | 94 | return builder.build() 95 | 96 | 97 | @skipIf(not isGraphvizModuleInstalled(), "Graphviz module is not installed.") 98 | @skipIf(not isTwistedInstalled(), "Twisted is not installed.") 99 | class ElementMakerTests(TestCase): 100 | """ 101 | L{elementMaker} generates HTML representing the specified element. 102 | """ 103 | 104 | def setUp(self): 105 | from .._visualize import elementMaker 106 | 107 | self.elementMaker = elementMaker 108 | 109 | def test_sortsAttrs(self): 110 | """ 111 | L{elementMaker} orders HTML attributes lexicographically. 112 | """ 113 | expected = r'
' 114 | self.assertEqual(expected, self.elementMaker("div", b="2", a="1", c="3")) 115 | 116 | def test_quotesAttrs(self): 117 | """ 118 | L{elementMaker} quotes HTML attributes according to DOT's quoting rule. 119 | 120 | See U{http://www.graphviz.org/doc/info/lang.html}, footnote 1. 121 | """ 122 | expected = r'
' 123 | self.assertEqual( 124 | expected, self.elementMaker("div", b='a " quote', a=1, c="a string") 125 | ) 126 | 127 | def test_noAttrs(self): 128 | """ 129 | L{elementMaker} should render an element with no attributes. 130 | """ 131 | expected = r"
" 132 | self.assertEqual(expected, self.elementMaker("div")) 133 | 134 | 135 | @dataclass 136 | class HTMLElement(object): 137 | """Holds an HTML element, as created by elementMaker.""" 138 | 139 | name: str 140 | children: list[HTMLElement] 141 | attributes: dict[str, str] 142 | 143 | 144 | def findElements(element, predicate): 145 | """ 146 | Recursively collect all elements in an L{HTMLElement} tree that 147 | match the optional predicate. 148 | """ 149 | if predicate(element): 150 | return [element] 151 | elif isLeaf(element): 152 | return [] 153 | 154 | return [ 155 | result 156 | for child in element.children 157 | for result in findElements(child, predicate) 158 | ] 159 | 160 | 161 | def isLeaf(element): 162 | """ 163 | This HTML element is actually leaf node. 164 | """ 165 | return not isinstance(element, HTMLElement) 166 | 167 | 168 | @skipIf(not isGraphvizModuleInstalled(), "Graphviz module is not installed.") 169 | @skipIf(not isTwistedInstalled(), "Twisted is not installed.") 170 | class TableMakerTests(TestCase): 171 | """ 172 | Tests that ensure L{tableMaker} generates HTML tables usable as 173 | labels in DOT graphs. 174 | 175 | For more information, read the "HTML-Like Labels" section of 176 | U{http://www.graphviz.org/doc/info/shapes.html}. 177 | """ 178 | 179 | def fakeElementMaker(self, name, *children, **attributes): 180 | return HTMLElement(name=name, children=children, attributes=attributes) 181 | 182 | def setUp(self): 183 | from .._visualize import tableMaker 184 | 185 | self.inputLabel = "input label" 186 | self.port = "the port" 187 | self.tableMaker = functools.partial(tableMaker, _E=self.fakeElementMaker) 188 | 189 | def test_inputLabelRow(self): 190 | """ 191 | The table returned by L{tableMaker} always contains the input 192 | symbol label in its first row, and that row contains one cell 193 | with a port attribute set to the provided port. 194 | """ 195 | 196 | def hasPort(element): 197 | return not isLeaf(element) and element.attributes.get("port") == self.port 198 | 199 | for outputLabels in ([], ["an output label"]): 200 | table = self.tableMaker(self.inputLabel, outputLabels, port=self.port) 201 | self.assertGreater(len(table.children), 0) 202 | inputLabelRow = table.children[0] 203 | 204 | portCandidates = findElements(table, hasPort) 205 | 206 | self.assertEqual(len(portCandidates), 1) 207 | self.assertEqual(portCandidates[0].name, "td") 208 | self.assertEqual(findElements(inputLabelRow, isLeaf), [self.inputLabel]) 209 | 210 | def test_noOutputLabels(self): 211 | """ 212 | L{tableMaker} does not add a colspan attribute to the input 213 | label's cell or a second row if there no output labels. 214 | """ 215 | table = self.tableMaker("input label", (), port=self.port) 216 | self.assertEqual(len(table.children), 1) 217 | (inputLabelRow,) = table.children 218 | self.assertNotIn("colspan", inputLabelRow.attributes) 219 | 220 | def test_withOutputLabels(self): 221 | """ 222 | L{tableMaker} adds a colspan attribute to the input label's cell 223 | equal to the number of output labels and a second row that 224 | contains the output labels. 225 | """ 226 | table = self.tableMaker( 227 | self.inputLabel, ("output label 1", "output label 2"), port=self.port 228 | ) 229 | 230 | self.assertEqual(len(table.children), 2) 231 | inputRow, outputRow = table.children 232 | 233 | def hasCorrectColspan(element): 234 | return ( 235 | not isLeaf(element) 236 | and element.name == "td" 237 | and element.attributes.get("colspan") == "2" 238 | ) 239 | 240 | self.assertEqual(len(findElements(inputRow, hasCorrectColspan)), 1) 241 | self.assertEqual( 242 | findElements(outputRow, isLeaf), ["output label 1", "output label 2"] 243 | ) 244 | 245 | 246 | @skipIf(not isGraphvizModuleInstalled(), "Graphviz module is not installed.") 247 | @skipIf(not isGraphvizInstalled(), "Graphviz tools are not installed.") 248 | @skipIf(not isTwistedInstalled(), "Twisted is not installed.") 249 | class IntegrationTests(TestCase): 250 | """ 251 | Tests which make sure Graphviz can understand the output produced by 252 | Automat. 253 | """ 254 | 255 | def test_validGraphviz(self) -> None: 256 | """ 257 | C{graphviz} emits valid graphviz data. 258 | """ 259 | digraph = sampleMachine().asDigraph() 260 | text = "".join(digraph).encode("utf-8") 261 | p = subprocess.Popen("dot", stdin=subprocess.PIPE, stdout=subprocess.PIPE) 262 | out, err = p.communicate(text) 263 | self.assertEqual(p.returncode, 0) 264 | 265 | 266 | @skipIf(not isGraphvizModuleInstalled(), "Graphviz module is not installed.") 267 | @skipIf(not isTwistedInstalled(), "Twisted is not installed.") 268 | class SpotChecks(TestCase): 269 | """ 270 | Tests to make sure that the output contains salient features of the machine 271 | being generated. 272 | """ 273 | 274 | def test_containsMachineFeatures(self): 275 | """ 276 | The output of L{graphviz.Digraph} should contain the names of the 277 | states, inputs, outputs in the state machine. 278 | """ 279 | gvout = "".join(sampleMachine().asDigraph()) 280 | self.assertIn("begin", gvout) 281 | self.assertIn("end", gvout) 282 | self.assertIn("go", gvout) 283 | self.assertIn("out", gvout) 284 | 285 | def test_containsTypeMachineFeatures(self): 286 | """ 287 | The output of L{graphviz.Digraph} should contain the names of the states, 288 | inputs, outputs in the state machine. 289 | """ 290 | gvout = "".join(sampleTypeMachine().asDigraph()) 291 | self.assertIn("begin", gvout) 292 | self.assertIn("end", gvout) 293 | self.assertIn("go", gvout) 294 | self.assertIn("data:buildit", gvout) 295 | self.assertIn("out", gvout) 296 | 297 | 298 | class RecordsDigraphActions(object): 299 | """ 300 | Records calls made to L{FakeDigraph}. 301 | """ 302 | 303 | def __init__(self): 304 | self.reset() 305 | 306 | def reset(self): 307 | self.renderCalls = [] 308 | self.saveCalls = [] 309 | 310 | 311 | class FakeDigraph(object): 312 | """ 313 | A fake L{graphviz.Digraph}. Instantiate it with a 314 | L{RecordsDigraphActions}. 315 | """ 316 | 317 | def __init__(self, recorder): 318 | self._recorder = recorder 319 | 320 | def render(self, **kwargs): 321 | self._recorder.renderCalls.append(kwargs) 322 | 323 | def save(self, **kwargs): 324 | self._recorder.saveCalls.append(kwargs) 325 | 326 | 327 | class FakeMethodicalMachine(object): 328 | """ 329 | A fake L{MethodicalMachine}. Instantiate it with a L{FakeDigraph} 330 | """ 331 | 332 | def __init__(self, digraph): 333 | self._digraph = digraph 334 | 335 | def asDigraph(self): 336 | return self._digraph 337 | 338 | 339 | @skipIf(not isGraphvizModuleInstalled(), "Graphviz module is not installed.") 340 | @skipIf(not isGraphvizInstalled(), "Graphviz tools are not installed.") 341 | @skipIf(not isTwistedInstalled(), "Twisted is not installed.") 342 | class VisualizeToolTests(TestCase): 343 | def setUp(self): 344 | self.digraphRecorder = RecordsDigraphActions() 345 | self.fakeDigraph = FakeDigraph(self.digraphRecorder) 346 | 347 | self.fakeProgname = "tool-test" 348 | self.fakeSysPath = ["ignored"] 349 | self.collectedOutput = [] 350 | self.fakeFQPN = "fake.fqpn" 351 | 352 | def collectPrints(self, *args): 353 | self.collectedOutput.append(" ".join(args)) 354 | 355 | def fakeFindMachines(self, fqpn): 356 | yield fqpn, FakeMethodicalMachine(self.fakeDigraph) 357 | 358 | def tool( 359 | self, progname=None, argv=None, syspath=None, findMachines=None, print=None 360 | ): 361 | from .._visualize import tool 362 | 363 | return tool( 364 | _progname=progname or self.fakeProgname, 365 | _argv=argv or [self.fakeFQPN], 366 | _syspath=syspath or self.fakeSysPath, 367 | _findMachines=findMachines or self.fakeFindMachines, 368 | _print=print or self.collectPrints, 369 | ) 370 | 371 | def test_checksCurrentDirectory(self): 372 | """ 373 | L{tool} adds '' to sys.path to ensure 374 | L{automat._discover.findMachines} searches the current 375 | directory. 376 | """ 377 | self.tool(argv=[self.fakeFQPN]) 378 | self.assertEqual(self.fakeSysPath[0], "") 379 | 380 | def test_quietHidesOutput(self): 381 | """ 382 | Passing -q/--quiet hides all output. 383 | """ 384 | self.tool(argv=[self.fakeFQPN, "--quiet"]) 385 | self.assertFalse(self.collectedOutput) 386 | self.tool(argv=[self.fakeFQPN, "-q"]) 387 | self.assertFalse(self.collectedOutput) 388 | 389 | def test_onlySaveDot(self): 390 | """ 391 | Passing an empty string for --image-directory/-i disables 392 | rendering images. 393 | """ 394 | for arg in ("--image-directory", "-i"): 395 | self.digraphRecorder.reset() 396 | self.collectedOutput = [] 397 | 398 | self.tool(argv=[self.fakeFQPN, arg, ""]) 399 | self.assertFalse(any("image" in line for line in self.collectedOutput)) 400 | 401 | self.assertEqual(len(self.digraphRecorder.saveCalls), 1) 402 | (call,) = self.digraphRecorder.saveCalls 403 | self.assertEqual("{}.dot".format(self.fakeFQPN), call["filename"]) 404 | 405 | self.assertFalse(self.digraphRecorder.renderCalls) 406 | 407 | def test_saveOnlyImage(self): 408 | """ 409 | Passing an empty string for --dot-directory/-d disables saving dot 410 | files. 411 | """ 412 | for arg in ("--dot-directory", "-d"): 413 | self.digraphRecorder.reset() 414 | self.collectedOutput = [] 415 | self.tool(argv=[self.fakeFQPN, arg, ""]) 416 | 417 | self.assertFalse(any("dot" in line for line in self.collectedOutput)) 418 | 419 | self.assertEqual(len(self.digraphRecorder.renderCalls), 1) 420 | (call,) = self.digraphRecorder.renderCalls 421 | self.assertEqual("{}.dot".format(self.fakeFQPN), call["filename"]) 422 | self.assertTrue(call["cleanup"]) 423 | 424 | self.assertFalse(self.digraphRecorder.saveCalls) 425 | 426 | def test_saveDotAndImagesInDifferentDirectories(self): 427 | """ 428 | Passing different directories to --image-directory and --dot-directory 429 | writes images and dot files to those directories. 430 | """ 431 | imageDirectory = "image" 432 | dotDirectory = "dot" 433 | self.tool( 434 | argv=[ 435 | self.fakeFQPN, 436 | "--image-directory", 437 | imageDirectory, 438 | "--dot-directory", 439 | dotDirectory, 440 | ] 441 | ) 442 | 443 | self.assertTrue(any("image" in line for line in self.collectedOutput)) 444 | self.assertTrue(any("dot" in line for line in self.collectedOutput)) 445 | 446 | self.assertEqual(len(self.digraphRecorder.renderCalls), 1) 447 | (renderCall,) = self.digraphRecorder.renderCalls 448 | self.assertEqual(renderCall["directory"], imageDirectory) 449 | self.assertTrue(renderCall["cleanup"]) 450 | 451 | self.assertEqual(len(self.digraphRecorder.saveCalls), 1) 452 | (saveCall,) = self.digraphRecorder.saveCalls 453 | self.assertEqual(saveCall["directory"], dotDirectory) 454 | 455 | def test_saveDotAndImagesInSameDirectory(self): 456 | """ 457 | Passing the same directory to --image-directory and --dot-directory 458 | writes images and dot files to that one directory. 459 | """ 460 | directory = "imagesAndDot" 461 | self.tool( 462 | argv=[ 463 | self.fakeFQPN, 464 | "--image-directory", 465 | directory, 466 | "--dot-directory", 467 | directory, 468 | ] 469 | ) 470 | 471 | self.assertTrue(any("image and dot" in line for line in self.collectedOutput)) 472 | 473 | self.assertEqual(len(self.digraphRecorder.renderCalls), 1) 474 | (renderCall,) = self.digraphRecorder.renderCalls 475 | self.assertEqual(renderCall["directory"], directory) 476 | self.assertFalse(renderCall["cleanup"]) 477 | 478 | self.assertFalse(len(self.digraphRecorder.saveCalls)) 479 | -------------------------------------------------------------------------------- /src/automat/_visualize.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import sys 5 | from functools import wraps 6 | from typing import Callable, Iterator 7 | 8 | import graphviz 9 | 10 | from ._core import Automaton, Input, Output, State 11 | from ._discover import findMachines 12 | from ._methodical import MethodicalMachine 13 | from ._typed import TypeMachine, InputProtocol, Core 14 | 15 | 16 | def _gvquote(s: str) -> str: 17 | return '"{}"'.format(s.replace('"', r"\"")) 18 | 19 | 20 | def _gvhtml(s: str) -> str: 21 | return "<{}>".format(s) 22 | 23 | 24 | def elementMaker(name: str, *children: str, **attrs: str) -> str: 25 | """ 26 | Construct a string from the HTML element description. 27 | """ 28 | formattedAttrs = " ".join( 29 | "{}={}".format(key, _gvquote(str(value))) 30 | for key, value in sorted(attrs.items()) 31 | ) 32 | formattedChildren = "".join(children) 33 | return "<{name} {attrs}>{children}".format( 34 | name=name, attrs=formattedAttrs, children=formattedChildren 35 | ) 36 | 37 | 38 | def tableMaker( 39 | inputLabel: str, 40 | outputLabels: list[str], 41 | port: str, 42 | _E: Callable[..., str] = elementMaker, 43 | ) -> str: 44 | """ 45 | Construct an HTML table to label a state transition. 46 | """ 47 | colspan = {} 48 | if outputLabels: 49 | colspan["colspan"] = str(len(outputLabels)) 50 | 51 | inputLabelCell = _E( 52 | "td", 53 | _E("font", inputLabel, face="menlo-italic"), 54 | color="purple", 55 | port=port, 56 | **colspan, 57 | ) 58 | 59 | pointSize = {"point-size": "9"} 60 | outputLabelCells = [ 61 | _E("td", _E("font", outputLabel, **pointSize), color="pink") 62 | for outputLabel in outputLabels 63 | ] 64 | 65 | rows = [_E("tr", inputLabelCell)] 66 | 67 | if outputLabels: 68 | rows.append(_E("tr", *outputLabelCells)) 69 | 70 | return _E("table", *rows) 71 | 72 | 73 | def escapify(x: Callable[[State], str]) -> Callable[[State], str]: 74 | @wraps(x) 75 | def impl(t: State) -> str: 76 | return x(t).replace("<", "<").replace(">", ">") 77 | 78 | return impl 79 | 80 | 81 | def makeDigraph( 82 | automaton: Automaton[State, Input, Output], 83 | inputAsString: Callable[[Input], str] = repr, 84 | outputAsString: Callable[[Output], str] = repr, 85 | stateAsString: Callable[[State], str] = repr, 86 | ) -> graphviz.Digraph: 87 | """ 88 | Produce a L{graphviz.Digraph} object from an automaton. 89 | """ 90 | 91 | inputAsString = escapify(inputAsString) 92 | outputAsString = escapify(outputAsString) 93 | stateAsString = escapify(stateAsString) 94 | 95 | digraph = graphviz.Digraph( 96 | graph_attr={"pack": "true", "dpi": "100"}, 97 | node_attr={"fontname": "Menlo"}, 98 | edge_attr={"fontname": "Menlo"}, 99 | ) 100 | 101 | for state in automaton.states(): 102 | if state is automaton.initialState: 103 | stateShape = "bold" 104 | fontName = "Menlo-Bold" 105 | else: 106 | stateShape = "" 107 | fontName = "Menlo" 108 | digraph.node( 109 | stateAsString(state), 110 | fontame=fontName, 111 | shape="ellipse", 112 | style=stateShape, 113 | color="blue", 114 | ) 115 | for n, eachTransition in enumerate(automaton.allTransitions()): 116 | inState, inputSymbol, outState, outputSymbols = eachTransition 117 | thisTransition = "t{}".format(n) 118 | inputLabel = inputAsString(inputSymbol) 119 | 120 | port = "tableport" 121 | table = tableMaker( 122 | inputLabel, 123 | [outputAsString(outputSymbol) for outputSymbol in outputSymbols], 124 | port=port, 125 | ) 126 | 127 | digraph.node(thisTransition, label=_gvhtml(table), margin="0.2", shape="none") 128 | 129 | digraph.edge( 130 | stateAsString(inState), 131 | "{}:{}:w".format(thisTransition, port), 132 | arrowhead="none", 133 | ) 134 | digraph.edge("{}:{}:e".format(thisTransition, port), stateAsString(outState)) 135 | 136 | return digraph 137 | 138 | 139 | def tool( 140 | _progname: str = sys.argv[0], 141 | _argv: list[str] = sys.argv[1:], 142 | _syspath: list[str] = sys.path, 143 | _findMachines: Callable[ 144 | [str], 145 | Iterator[tuple[str, MethodicalMachine | TypeMachine[InputProtocol, Core]]], 146 | ] = findMachines, 147 | _print: Callable[..., None] = print, 148 | ) -> None: 149 | """ 150 | Entry point for command line utility. 151 | """ 152 | 153 | DESCRIPTION = """ 154 | Visualize automat.MethodicalMachines as graphviz graphs. 155 | """ 156 | EPILOG = """ 157 | You must have the graphviz tool suite installed. Please visit 158 | http://www.graphviz.org for more information. 159 | """ 160 | if _syspath[0]: 161 | _syspath.insert(0, "") 162 | argumentParser = argparse.ArgumentParser( 163 | prog=_progname, description=DESCRIPTION, epilog=EPILOG 164 | ) 165 | argumentParser.add_argument( 166 | "fqpn", 167 | help="A Fully Qualified Path name" " representing where to find machines.", 168 | ) 169 | argumentParser.add_argument( 170 | "--quiet", "-q", help="suppress output", default=False, action="store_true" 171 | ) 172 | argumentParser.add_argument( 173 | "--dot-directory", 174 | "-d", 175 | help="Where to write out .dot files.", 176 | default=".automat_visualize", 177 | ) 178 | argumentParser.add_argument( 179 | "--image-directory", 180 | "-i", 181 | help="Where to write out image files.", 182 | default=".automat_visualize", 183 | ) 184 | argumentParser.add_argument( 185 | "--image-type", 186 | "-t", 187 | help="The image format.", 188 | choices=graphviz.FORMATS, 189 | default="png", 190 | ) 191 | argumentParser.add_argument( 192 | "--view", 193 | "-v", 194 | help="View rendered graphs with" " default image viewer", 195 | default=False, 196 | action="store_true", 197 | ) 198 | args = argumentParser.parse_args(_argv) 199 | 200 | explicitlySaveDot = args.dot_directory and ( 201 | not args.image_directory or args.image_directory != args.dot_directory 202 | ) 203 | if args.quiet: 204 | 205 | def _print(*args): 206 | pass 207 | 208 | for fqpn, machine in _findMachines(args.fqpn): 209 | _print(fqpn, "...discovered") 210 | 211 | digraph = machine.asDigraph() 212 | 213 | if explicitlySaveDot: 214 | digraph.save(filename="{}.dot".format(fqpn), directory=args.dot_directory) 215 | _print(fqpn, "...wrote dot into", args.dot_directory) 216 | 217 | if args.image_directory: 218 | deleteDot = not args.dot_directory or explicitlySaveDot 219 | digraph.format = args.image_type 220 | digraph.render( 221 | filename="{}.dot".format(fqpn), 222 | directory=args.image_directory, 223 | view=args.view, 224 | cleanup=deleteDot, 225 | ) 226 | if deleteDot: 227 | msg = "...wrote image into" 228 | else: 229 | msg = "...wrote image and dot into" 230 | _print(fqpn, msg, args.image_directory) 231 | -------------------------------------------------------------------------------- /src/automat/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glyph/automat/ef18293c905dd9fde902eb20c718a0a465a9d33b/src/automat/py.typed -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = lint,{pypy3,py38,py310,py311,py312}-mypy,coverage-clean,{pypy3,py38,py310,py311,py312}-{extras,noextras},coverage-report,docs 3 | 4 | isolated_build = true 5 | 6 | [testenv] 7 | deps = 8 | extras: graphviz>=0.4.9 9 | extras: Twisted>=16.2.0 10 | 11 | mypy: mypy 12 | mypy: graphviz>=0.4.9 13 | mypy: Twisted>=16.2.0 14 | 15 | coverage 16 | pytest 17 | 18 | commands = 19 | {extras,noextras}: coverage run --parallel --source src -m pytest -s -rfEsx src/automat/_test 20 | mypy: mypy {posargs:src/automat} 21 | 22 | depends = 23 | coverage-clean 24 | 25 | [testenv:coverage-clean] 26 | deps = coverage 27 | skip_install = true 28 | commands = coverage erase 29 | depends = 30 | 31 | [testenv:coverage-report] 32 | deps = coverage 33 | skip_install = true 34 | commands = 35 | coverage combine 36 | coverage xml 37 | coverage report -m 38 | depends = 39 | {pypy3,py38,py310,py311}-{extras,noextras} 40 | 41 | [testenv:benchmark] 42 | deps = pytest-benchmark 43 | commands = pytest --benchmark-only benchmark/ 44 | 45 | [testenv:lint] 46 | deps = black 47 | commands = black --check src 48 | 49 | [testenv:pypy3-benchmark] 50 | deps = {[testenv:benchmark]deps} 51 | commands = {[testenv:benchmark]commands} 52 | 53 | [testenv:docs] 54 | usedevelop = True 55 | changedir = docs 56 | deps = 57 | -r docs/requirements.txt 58 | commands = 59 | python -m sphinx -M html . _build 60 | basepython = python3.12 61 | -------------------------------------------------------------------------------- /typical_example_happy.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from itertools import count 5 | from typing import Callable, List, Protocol 6 | 7 | from automat import TypeMachineBuilder 8 | 9 | 10 | # scaffolding; no state machines yet 11 | 12 | 13 | @dataclass 14 | class Request: 15 | id: int = field(default_factory=count(1).__next__) 16 | 17 | 18 | @dataclass 19 | class RequestGetter: 20 | cb: Callable[[Request], None] | None = None 21 | 22 | def startGettingRequests(self, cb: Callable[[Request], None]) -> None: 23 | self.cb = cb 24 | 25 | 26 | @dataclass(repr=False) 27 | class Task: 28 | performer: TaskPerformer 29 | request: Request 30 | done: Callable[[Task, bool], None] 31 | active: bool = True 32 | number: int = field(default_factory=count(1000).__next__) 33 | 34 | def __repr__(self) -> str: 35 | return f"" 36 | 37 | def complete(self, success: bool) -> None: 38 | # Also a state machine, maybe? 39 | print("complete", success) 40 | self.performer.activeTasks.remove(self) 41 | self.active = False 42 | self.done(self, success) 43 | 44 | def stop(self) -> None: 45 | self.complete(False) 46 | 47 | 48 | @dataclass 49 | class TaskPerformer: 50 | activeTasks: List[Task] = field(default_factory=list) 51 | taskLimit: int = 3 52 | 53 | def performTask(self, r: Request, done: Callable[[Task, bool], None]) -> Task: 54 | self.activeTasks.append(it := Task(self, r, done)) 55 | return it 56 | 57 | 58 | class ConnectionCoordinator(Protocol): 59 | def start(self) -> None: 60 | "kick off the whole process" 61 | 62 | def requestReceived(self, r: Request) -> None: 63 | "a task was received" 64 | 65 | def taskComplete(self, task: Task, success: bool) -> None: 66 | "task complete" 67 | 68 | def atCapacity(self) -> None: 69 | "we're at capacity stop handling requests" 70 | 71 | def headroom(self) -> None: 72 | "one of the tasks completed" 73 | 74 | def cleanup(self) -> None: 75 | "clean everything up" 76 | 77 | 78 | @dataclass 79 | class ConnectionState: 80 | getter: RequestGetter 81 | performer: TaskPerformer 82 | allDone: Callable[[Task], None] 83 | queue: List[Request] = field(default_factory=list) 84 | 85 | 86 | def buildMachine() -> Callable[[ConnectionState], ConnectionCoordinator]: 87 | 88 | builder = TypeMachineBuilder(ConnectionCoordinator, ConnectionState) 89 | Initial = builder.state("Initial") 90 | Requested = builder.state("Requested") 91 | AtCapacity = builder.state("AtCapacity") 92 | CleaningUp = builder.state("CleaningUp") 93 | 94 | Requested.upon(ConnectionCoordinator.atCapacity).to(AtCapacity).returns(None) 95 | Requested.upon(ConnectionCoordinator.headroom).loop().returns(None) 96 | CleaningUp.upon(ConnectionCoordinator.headroom).loop().returns(None) 97 | CleaningUp.upon(ConnectionCoordinator.cleanup).loop().returns(None) 98 | 99 | @Initial.upon(ConnectionCoordinator.start).to(Requested) 100 | def startup(coord: ConnectionCoordinator, core: ConnectionState) -> None: 101 | core.getter.startGettingRequests(coord.requestReceived) 102 | 103 | @AtCapacity.upon(ConnectionCoordinator.requestReceived).loop() 104 | def requestReceived( 105 | coord: ConnectionCoordinator, core: ConnectionState, r: Request 106 | ) -> None: 107 | print("buffering request", r) 108 | core.queue.append(r) 109 | 110 | @AtCapacity.upon(ConnectionCoordinator.headroom).to(Requested) 111 | def headroom(coord: ConnectionCoordinator, core: ConnectionState) -> None: 112 | "nothing to do, just transition to Requested state" 113 | unhandledRequest = core.queue.pop() 114 | print("dequeueing", unhandledRequest) 115 | coord.requestReceived(unhandledRequest) 116 | 117 | @Requested.upon(ConnectionCoordinator.requestReceived).loop() 118 | def requestedRequest( 119 | coord: ConnectionCoordinator, core: ConnectionState, r: Request 120 | ) -> None: 121 | print("immediately handling request", r) 122 | core.performer.performTask(r, coord.taskComplete) 123 | if len(core.performer.activeTasks) >= core.performer.taskLimit: 124 | coord.atCapacity() 125 | 126 | 127 | @Initial.upon(ConnectionCoordinator.taskComplete).loop() 128 | @Requested.upon(ConnectionCoordinator.taskComplete).loop() 129 | @AtCapacity.upon(ConnectionCoordinator.taskComplete).loop() 130 | @CleaningUp.upon(ConnectionCoordinator.taskComplete).loop() 131 | def taskComplete( 132 | c: ConnectionCoordinator, s: ConnectionState, task: Task, success: bool 133 | ) -> None: 134 | if success: 135 | c.cleanup() 136 | s.allDone(task) 137 | else: 138 | c.headroom() 139 | 140 | @Requested.upon(ConnectionCoordinator.cleanup).to(CleaningUp) 141 | @AtCapacity.upon(ConnectionCoordinator.cleanup).to(CleaningUp) 142 | def cleanup(coord: ConnectionCoordinator, core: ConnectionState): 143 | # We *don't* want to recurse in here; stopping tasks will cause 144 | # taskComplete! 145 | while core.performer.activeTasks: 146 | core.performer.activeTasks[-1].stop() 147 | 148 | return builder.build() 149 | 150 | 151 | ConnectionMachine = buildMachine() 152 | 153 | 154 | def begin( 155 | r: RequestGetter, t: TaskPerformer, done: Callable[[Task], None] 156 | ) -> ConnectionCoordinator: 157 | machine = ConnectionMachine(ConnectionState(r, t, done)) 158 | machine.start() 159 | return machine 160 | 161 | 162 | def story() -> None: 163 | 164 | rget = RequestGetter() 165 | tper = TaskPerformer() 166 | 167 | def yay(t: Task) -> None: 168 | print("yay") 169 | 170 | m = begin(rget, tper, yay) 171 | cb = rget.cb 172 | assert cb is not None 173 | cb(Request()) 174 | cb(Request()) 175 | cb(Request()) 176 | cb(Request()) 177 | cb(Request()) 178 | cb(Request()) 179 | cb(Request()) 180 | print([each for each in tper.activeTasks]) 181 | sc: ConnectionState = m.__automat_core__ # type:ignore 182 | print(sc.queue) 183 | tper.activeTasks[0].complete(False) 184 | tper.activeTasks[0].complete(False) 185 | print([each for each in tper.activeTasks]) 186 | print(sc.queue) 187 | tper.activeTasks[0].complete(True) 188 | print([each for each in tper.activeTasks]) 189 | 190 | 191 | if __name__ == "__main__": 192 | story() 193 | --------------------------------------------------------------------------------