├── .github └── workflows │ └── python-app.yml ├── .gitignore ├── .readthedocs.yml ├── AUTHORS ├── LICENSE ├── README.md ├── docs ├── Makefile ├── api │ ├── constants.rst │ ├── exceptions.rst │ ├── fsm.rst │ └── module.rst ├── conf.py ├── index.rst ├── make.bat ├── requirements.txt └── usage │ ├── advanced.rst │ ├── installation.rst │ └── tutorial.rst ├── justfile ├── pyproject.toml ├── setup.cfg ├── src └── definite │ ├── __init__.py │ ├── constants.py │ ├── exceptions.py │ └── fsm.py └── tests ├── __init__.py ├── test_fsm.py ├── test_module.py └── test_readme.py /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python application 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Python 3.10 23 | uses: actions/setup-python@v3 24 | with: 25 | python-version: "3.10" 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install poetry 30 | poetry install 31 | - name: Test with pytest 32 | run: | 33 | poetry run pytest . 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | Thumbs.db 3 | 4 | .vscode 5 | 6 | *.pyc 7 | __pycache__ 8 | .pytest_cache 9 | 10 | dist 11 | 12 | poetry.lock 13 | 14 | docs/_build 15 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | # Build documentation with MkDocs 13 | #mkdocs: 14 | # configuration: mkdocs.yml 15 | 16 | # Optionally build your docs in additional formats such as PDF and ePub 17 | formats: all 18 | 19 | # Optionally set the version of Python and requirements required to build your docs 20 | python: 21 | version: 3.7 22 | install: 23 | - requirements: docs/requirements.txt 24 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Primary authors: 2 | 3 | * Daniel Lindsley (@toastdriven) 4 | 5 | Maintainers: 6 | 7 | * 8 | 9 | Contributors: 10 | 11 | * Frank Wiles (@frankwiles) 12 | * V David Zvenyach (@vdavez) 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Daniel Lindsley 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, 7 | this list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | 3. Neither the name of the copyright holder nor the names of its contributors 14 | may be used to endorse or promote products derived from this software without 15 | specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 18 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 19 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 20 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 21 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 22 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 23 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 24 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 25 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 26 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 27 | POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Documentation Status](https://readthedocs.org/projects/definite/badge/?version=latest)](https://definite.readthedocs.io/en/latest/?badge=latest) 2 | 3 | # `definite` 4 | 5 | Simple finite state machines. 6 | 7 | Perfect for representing workflows. 8 | 9 | 10 | ## Quickstart 11 | 12 | ```python 13 | from definite import FSM 14 | 15 | # You define all the valid states, as well as what their allowed 16 | # transitions are. 17 | class Workflow(FSM): 18 | allowed_transitions = { 19 | "draft": ["awaiting_review", "rejected"], 20 | "awaiting_review": ["draft", "reviewed", "rejected"], 21 | "reviewed": ["published", "rejected"], 22 | "published": None, 23 | "rejected": ["draft"], 24 | } 25 | default_state = "draft" 26 | 27 | # Right away, you can use the states/transitions as-is to enforce changes. 28 | workflow = Workflow() 29 | workflow.current_state() # "draft" 30 | 31 | workflow.transition_to("awaiting_review") 32 | workflow.transition_to("reviewed") 33 | 34 | workflow.is_allowed("published") # True 35 | 36 | # Invalid/disallowed transitions will throw an exception. 37 | workflow.current_state() # "reviewed" 38 | # ...which can only go to "published" or "rejected", but... 39 | workflow.transition_to("awaiting_review") 40 | # Traceback (most recent call last): 41 | # ... 42 | # workflow.TransitionNotAllowed: "reviewed" cannot transition to "awaiting_review" 43 | 44 | 45 | # Additionally, you can set up extra code to fire on given state changes. 46 | class Workflow(FSM): 47 | # Same transitions & default state. 48 | allowed_transitions = { 49 | "draft": ["awaiting_review", "rejected"], 50 | "awaiting_review": ["draft", "reviewed", "rejected"], 51 | "reviewed": ["published", "rejected"], 52 | "published": None, 53 | "rejected": ["draft"], 54 | } 55 | default_state = "draft" 56 | 57 | # Define a `handle_` method on the class. 58 | def handle_awaiting_review(self, new_state): 59 | spell_check_results = check_spelling(self.obj.content) 60 | msg = ( 61 | f"{self.obj.title} ready for review. " 62 | f"{len(spell_check_results)} spelling errors." 63 | ) 64 | send_email(to=editor_email, message=msg) 65 | 66 | def handle_published(self, new_state): 67 | self.obj.pub_date = datetime.datetime.utcnow() 68 | self.obj.save() 69 | 70 | # You can also setup code that fires on **ANY** valid transition with the 71 | # special `handle_any` method. 72 | def handle_any(self, new_state): 73 | self.obj.state = new_state 74 | self.obj.save() 75 | 76 | 77 | # We can pull in any Python object, like a database-backed model, that we 78 | # want to associate with our FSM. 79 | from news.models import NewsPost 80 | news_post = NewsPost.objects.create( 81 | title="Hello world!", 82 | content="This iz our frist post!", 83 | state="draft", 84 | ) 85 | 86 | # We start mostly the same, but this time pass an `obj` kwarg! 87 | workflow = Workflow(obj=news_post) 88 | 89 | # If you wanted to be explicit, you could also pass along the `initial_state`: 90 | workflow = Workflow( 91 | obj=news_post, 92 | initial_state=news_post.state 93 | ) 94 | 95 | workflow.current_state() # "draft" 96 | 97 | # But when we trigger this change... 98 | workflow.transition_to("awaiting_review") 99 | # ...it triggers the spell check & the email we defined above, as well as 100 | # hitting the `handle_any` method & updating the `state` field in the DB. 101 | news_post.refresh_from_db() 102 | news_post.state # "awaiting_review" ! 103 | ``` 104 | 105 | 106 | ## Installation 107 | 108 | `pip install definite` 109 | 110 | 111 | ## Requirements 112 | 113 | * Python 3.6+ 114 | 115 | 116 | ## Testing 117 | 118 | `$ pytest .` 119 | 120 | 121 | ## License 122 | 123 | New BSD 124 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/api/constants.rst: -------------------------------------------------------------------------------- 1 | ``definite.constants`` 2 | ====================== 3 | 4 | .. py:data:: definite.constants.UNKNOWN_STATE 5 | :type: class 6 | 7 | A sentinel value, used by the ``FSM`` class to detect when no 8 | ``default_state`` has been provided by the subclass. 9 | 10 | It has no meaningful attributes/method, is never instantiated, & is 11 | strictly used for identity purposes. 12 | 13 | 14 | .. py:data:: definite.constants.ANY 15 | :type: str 16 | :value: ``"any"`` 17 | 18 | A constant to represent any/all state names. 19 | -------------------------------------------------------------------------------- /docs/api/exceptions.rst: -------------------------------------------------------------------------------- 1 | ``definite.exceptions`` 2 | ======================= 3 | 4 | .. automodule:: definite.exceptions 5 | :members: 6 | :undoc-members: 7 | -------------------------------------------------------------------------------- /docs/api/fsm.rst: -------------------------------------------------------------------------------- 1 | ``definite.fsm`` 2 | ================ 3 | 4 | .. automodule:: definite.fsm 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/api/module.rst: -------------------------------------------------------------------------------- 1 | ``definite`` 2 | ============ 3 | 4 | .. automodule:: definite 5 | :members: get_version 6 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = "definite" 21 | copyright = "2022, Daniel Lindsley" 22 | author = "Daniel Lindsley" 23 | 24 | # The full version, including alpha/beta/rc tags 25 | release = "1.0.1-dev" 26 | 27 | 28 | # -- General configuration --------------------------------------------------- 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon"] 34 | 35 | # Add any paths that contain templates here, relative to this directory. 36 | templates_path = ["_templates"] 37 | 38 | # List of patterns, relative to source directory, that match files and 39 | # directories to ignore when looking for source files. 40 | # This pattern also affects html_static_path and html_extra_path. 41 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 42 | 43 | 44 | # -- Options for HTML output ------------------------------------------------- 45 | 46 | # The theme to use for HTML and HTML Help pages. See the documentation for 47 | # a list of builtin themes. 48 | # 49 | html_theme = "alabaster" 50 | 51 | # Add any paths that contain custom static files (such as style sheets) here, 52 | # relative to this directory. They are copied after the builtin static files, 53 | # so a file named "default.css" will overwrite the builtin "default.css". 54 | html_static_path = ["_static"] 55 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. definite documentation master file, created by 2 | sphinx-quickstart on Wed Jun 15 00:21:56 2022. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to definite's documentation! 7 | ====================================== 8 | 9 | Simple finite state machines. 10 | 11 | Perfect for representing workflows. 12 | 13 | .. toctree:: 14 | :maxdepth: 2 15 | :caption: Guides: 16 | 17 | usage/tutorial 18 | usage/installation 19 | usage/advanced 20 | 21 | 22 | .. toctree:: 23 | :maxdepth: 1 24 | :caption: API Reference: 25 | 26 | api/module 27 | api/constants 28 | api/exceptions 29 | api/fsm 30 | 31 | 32 | Indices and tables 33 | ================== 34 | 35 | * :ref:`genindex` 36 | * :ref:`modindex` 37 | * :ref:`search` 38 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinxcontrib-napoleon 3 | -------------------------------------------------------------------------------- /docs/usage/advanced.rst: -------------------------------------------------------------------------------- 1 | Advanced Usage 2 | ============== 3 | 4 | Here are some advanced ways you can use ``definite``. 5 | 6 | 7 | Use External JSON Files 8 | ----------------------- 9 | 10 | ``definite`` has built-in support for loading states/transitions directly 11 | from JSON files. This allows you to share the states with other code, or allow 12 | non-technical users to edit them. 13 | 14 | The JSON looks almost identical to the required attributes for a ``FSM`` 15 | subclass. 16 | 17 | .. code-block:: javascript 18 | 19 | { 20 | allowed_transitions={ 21 | "created": ["waiting"], 22 | "waiting": ["in_progress", "done"], 23 | "in_progress": ["done"], 24 | "done": null 25 | }, 26 | default_state="created" 27 | } 28 | 29 | However, it can be loaded & the classes created at runtime. 30 | 31 | .. code-block:: python 32 | 33 | >>> import json 34 | >>> from definite import FSM 35 | 36 | # First, we load the JSON up. 37 | >>> state_data = json.loads("/path/to/above/file.json") 38 | 39 | # Then we can dynamically create the class based on the JSON. 40 | >>> JobFlow = FSM.from_json("JobFlow", state_data) 41 | 42 | # And then instantiate it & use it. 43 | >>> job_1 = JobFlow() 44 | >>> job_1.current_state() 45 | "created" 46 | >>> job_1.all_states() 47 | ["created", "done", "in_progress", "waiting"] 48 | 49 | 50 | Certain Transition Logic 51 | ------------------------ 52 | 53 | When asked to perform a transition, the ``FSM`` class does things in a certain 54 | order: 55 | 56 | #. Check for validity/allowed-ness new state 57 | #. If present, call the ``handle_any`` handler with the new state 58 | #. If present, call the ``handle_`` handler with the new state 59 | #. Finally update the current state to the new state 60 | 61 | Because of this order, you can take special actions *only* for transitions 62 | between two certain states. 63 | 64 | For example, the ``Workflow`` example from the :doc:`./tutorial` can reach 65 | the ``rejected`` state from a variety of other states (``draft``, 66 | ``awaiting_review``, ``reviewed``). 67 | 68 | If you're feeling malicious, you could send the writer a scathing email 69 | only when the editor-in-chief rejects their story (the transition from 70 | ``reviewed`` to ``rejected``). 71 | 72 | .. code-block:: python 73 | 74 | from .email import send_mail, FROM_EMAIL 75 | 76 | # This is the same as the tutorial code. 77 | class Workflow(FSM): 78 | allowed_transitions = { 79 | "draft": ["awaiting_review", "rejected"], 80 | "awaiting_review": ["draft", "reviewed", "rejected"], 81 | "reviewed": ["published", "rejected"], 82 | "published": None, 83 | "rejected": ["draft"], 84 | } 85 | default_state = "draft" 86 | 87 | # But here, we look for the `rejected` state. 88 | def handle_rejected(self, state_name): 89 | # The `state_name` here is "rejected". 90 | # But `self.current_state()` will tell you what the "old" state was! 91 | prev_state = self.current_state() 92 | 93 | # So if it was previously reviewed by the staff editors, it went 94 | # to the chief for publishing, but got rejected! 95 | if prev_state == "reviewed": 96 | # TIME TO BURN. 97 | msg = ( 98 | f"The editors let '{self.obj.title}' through, but the Chief" 99 | "tossed it in the trash! Write better content!" 100 | ) 101 | send_mail( 102 | FROM_EMAIL, 103 | self.obj.author.email, 104 | "The Chief rejected you!", 105 | msg 106 | ) 107 | 108 | Obviously, this is mean-spirited & would promote an unhealthy work environment. 109 | Don't do this per-se, but the utility to control behavior down to certain 110 | transitions has a lot of potential. 111 | 112 | 113 | Auto-Create State Constants 114 | --------------------------- 115 | 116 | ``definite`` automatically does a fair amount of checking of state names for 117 | validity. However, some programmers may prefer having constants for use instead 118 | of the simple strings shown throughout these docs. 119 | 120 | Because ``FSM`` is designed to be subclassed, you could override/extend the 121 | built-in behavior to automatically create constants for use. 122 | 123 | .. code-block:: python 124 | 125 | from definite import FSM 126 | 127 | 128 | class AutoConstantsFSM(FSM): 129 | # We'll latch onto the `setup` method, which is called when the class 130 | # is instantiated. 131 | def setup(self): 132 | # Make sure you call `super()` first. 133 | super().setup() 134 | 135 | # Then we can automatically create the constants on the class. 136 | for state_name in self.allowed_transitions.keys(): 137 | setattr(self, state_name.upper(), state_name) 138 | 139 | Then you simply inherit from your new subclass instead of ``FSM``. 140 | 141 | .. code-block:: python 142 | 143 | class JobFlow(AutoConstantsFSM): 144 | allowed_transitions = { 145 | "created": ["waiting"], 146 | "waiting": ["in_progress", "done"], 147 | "in_progress": ["done"], 148 | "done": None, 149 | } 150 | default_state = "created" 151 | 152 | Now all-caps versions of your states will be present on your instances. 153 | 154 | .. code-block:: python 155 | 156 | >>> job_1 = JobFlow() 157 | >>> job_1.transition_to(job_1.WAITING) 158 | 159 | -------------------------------------------------------------------------------- /docs/usage/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | You must be running Python 3.6 or greater. 5 | 6 | `definite` only requires the Python built-in standard library & has no 7 | other dependencies. 8 | 9 | 10 | Standard Installation 11 | --------------------- 12 | 13 | For everyday usage, simply run:: 14 | 15 | $ pip install definite 16 | 17 | It's recommended that you use a `virtualenv` (or `poetry`, or `pipx`, or 18 | whatever) to isolate your install from the system Python. 19 | 20 | 21 | Development Installation 22 | ------------------------ 23 | 24 | If you'd like to work on `definite`'s code, run tests or generate the docs 25 | locally, the setup is a touch more complex. Here's the recommended approach. 26 | 27 | :: 28 | 29 | $ git clone git@github.com:toastdriven/definite.git 30 | $ cd definite 31 | $ poetry install 32 | $ poetry shell 33 | 34 | # Now you can run tests. 35 | $ pytest . 36 | 37 | # Or build the docs. 38 | $ cd docs 39 | $ make html && open _build/html/index.html -------------------------------------------------------------------------------- /docs/usage/tutorial.rst: -------------------------------------------------------------------------------- 1 | Tutorial 2 | ======== 3 | 4 | `definite` lets you create small finite state machines. These are great for 5 | tracking state, as well as triggering transition behavior when the states 6 | change. 7 | 8 | 9 | The Most Basic Example 10 | ---------------------- 11 | 12 | To start with, you'll need to import the ``FSM`` class from ``definite``, then 13 | subclass it. 14 | 15 | We'll define the tiniest fully-functional subclass we can, allowing just a 16 | ``start`` & ``end`` state. Beyond the subclassing ``FSM``, the only 17 | requirements are defining the ``allowed_transitions`` and ``default_state`` 18 | attributes on the subclass. 19 | 20 | .. code-block:: python 21 | 22 | from definite import FSM 23 | 24 | class Tiny(FSM): 25 | allowed_transitions = { 26 | "start": ["end"], 27 | "end": None, 28 | } 29 | default_state = "start" 30 | 31 | .. note:: 32 | You may be wondering why ``default_state`` is also required. A natural 33 | assumption would be that the first mentioned state in 34 | ``allowed_transitions`` would be the default. 35 | 36 | Only relatively recently in Python's history did dictionary ordering become 37 | guaranteed (`documented feature in 3.7`_). And even now, not all third-party 38 | tooling respects this. 39 | 40 | This could be solved in a variety of clever ways (special syntax, changing 41 | from a ``dict`` to a two-``tuple`` structure, using 42 | ``collections.OrderedDict``, etc.). 43 | 44 | But in the end, manually specifying it this way is clear, easy-to-read, & 45 | unsurprising/doesn't require special knowledge. Hence, having to specify it. 46 | 47 | Now you can instantiate the new ``Tiny`` class. Each instance starts in the 48 | provided default state of ``start``. 49 | 50 | .. code-block:: python 51 | 52 | >>> tiny = Tiny() 53 | >>> tiny.current_state() 54 | "start" 55 | 56 | There are also a variety of methods for inspecting what the ``FSM`` can do, 57 | such as checking what states are available, if a given state is a 58 | recognized/valid state, and if a given state can be transitioned to. 59 | 60 | .. code-block:: python 61 | 62 | >>> tiny.all_states() 63 | ["end", "start"] 64 | >>> tiny.is_valid("start") 65 | True 66 | >>> tiny.is_valid("nopenopenope") 67 | False 68 | >>> tiny.is_allowed("end") 69 | True 70 | 71 | From there, we can trigger a state change by tell it to transition to the 72 | ``end`` state. 73 | 74 | .. code-block:: python 75 | 76 | >>> tiny.transition_to("end") 77 | >>> tiny.current_state() 78 | "end" 79 | 80 | Just two states isn't very useful, so let's move on to a more complex example. 81 | 82 | 83 | More States/Transitions 84 | ----------------------- 85 | 86 | Enforcing workflows are a great use of finite state machines, so let's model 87 | a possible workflow for a small publisher. We'll assume there are writers, 88 | editors, and an editor-in-chief. 89 | 90 | * Writers can only create drafts, then submit them for review. 91 | * Editors perform the review, then decide whether to send it back for changes 92 | (back to draft), to mark it as reviewed for the editor-in-chief to look at, 93 | or to reject the story. 94 | * Editor-in-chief looks at reviewed stories, then either publishes them or can 95 | reject them. 96 | 97 | We can model all the states a news story could be in this: 98 | 99 | .. code-block:: python 100 | 101 | from definite import FSM 102 | 103 | class Workflow(FSM): 104 | allowed_transitions = { 105 | "draft": ["awaiting_review", "rejected"], 106 | "awaiting_review": ["draft", "reviewed", "rejected"], 107 | "reviewed": ["published", "rejected"], 108 | "published": None, 109 | "rejected": ["draft"], 110 | } 111 | default_state = "draft" 112 | 113 | Once this is defined, we can immediately start using it to guide what states 114 | our business logic. 115 | 116 | .. code-block:: python 117 | 118 | >>> workflow = Workflow() 119 | >>> workflow.current_state() # "draft" 120 | 121 | >>> workflow.transition_to("awaiting_review") 122 | >>> workflow.transition_to("reviewed") 123 | 124 | >>> workflow.is_allowed("published") # True 125 | 126 | # Invalid/disallowed transitions will throw an exception. 127 | >>> workflow.current_state() # "reviewed" 128 | # ...which can only go to "published" or "rejected", but... 129 | >>> workflow.transition_to("awaiting_review") 130 | # Traceback (most recent call last): 131 | # ... 132 | # workflow.TransitionNotAllowed: "reviewed" cannot transition to "awaiting_review" 133 | 134 | 135 | Adding Transition Behavior 136 | -------------------------- 137 | 138 | You can build your own logic around the FSMs anywhere, but ``definite`` also 139 | supports adding your own transition logic directly to the state machine, keeping 140 | the state & behavior together. 141 | 142 | For instance, let's say our previous example should send emails when a story 143 | is either waiting on review, or when it's available for the editor-in-chief to 144 | publish. 145 | 146 | We can expand on transition behavior by adding "handlers" to specific states. 147 | In ``definite``, any method prefixed with ``handle_`` followed by the desired 148 | state name will **automatically** be called when changing to that state. 149 | 150 | So, to send emails when ``awaiting_review`` & ``reviewed`` are met, we'd 151 | implement the following handlers. 152 | 153 | .. code-block:: python 154 | 155 | from email.message import EmailMessage 156 | import smtplib 157 | 158 | from definite import FSM 159 | 160 | 161 | FROM_EMAIL = "no-reply@example.com" 162 | EDITORS_EMAIL = "ed-staff@example.com" 163 | CHIEF_EMAIL = "chief@example.com" 164 | 165 | 166 | # To keep with the standard library theme, here's a very basic email-sending 167 | # function. Probably not very production-friendly. 168 | def send_mail(from_addr, to_addr, subject, message): 169 | msg = EmailMessage() 170 | msg["From"] = from_addr 171 | msg["To"] = to_addr 172 | msg["Subject"] = subject 173 | msg.set_content(message) 174 | 175 | smtp = smtplib.SMTP('localhost') 176 | smtp.send_message(msg) 177 | smtp.quit() 178 | 179 | 180 | # Here's our already-written FSM... 181 | class Workflow(FSM): 182 | allowed_transitions = { 183 | "draft": ["awaiting_review", "rejected"], 184 | "awaiting_review": ["draft", "reviewed", "rejected"], 185 | "reviewed": ["published", "rejected"], 186 | "published": None, 187 | "rejected": ["draft"], 188 | } 189 | default_state = "draft" 190 | 191 | # ...but here we add our new handlers! 192 | def handle_awaiting_review(self, state_name): 193 | msg = "There's a story awaiting review. Go log in & check it out!" 194 | send_mail( 195 | FROM_EMAIL, 196 | EDITORS_EMAIL, 197 | "Story ready for review!", 198 | msg 199 | ) 200 | 201 | def handle_reviewed(self, state_name): 202 | msg = "There's a story ready for publishing. Please have a look!" 203 | send_mail( 204 | FROM_EMAIL, 205 | CHIEF_EMAIL, 206 | "Story ready for publishing!", 207 | msg 208 | ) 209 | 210 | These handlers (``handle_awaiting_review`` & ``handle_reviewed``) will 211 | automatically be called upon transition. For example: 212 | 213 | .. code-block:: python 214 | 215 | >>> workflow = Workflow() 216 | # Some work happens, then... 217 | 218 | >>> workflow.transition_to("awaiting_review") 219 | # During this transition, the ``handle_awaiting_review`` method gets called 220 | # and the email is sent to the editors! 221 | 222 | >>> workflow.transition_to("reviewed") 223 | # And similarly with the editor-in-chief! 224 | 225 | 226 | Using/Affecting External Objects 227 | -------------------------------- 228 | 229 | We've got better encapsulization, but there are a couple shortcomings of the 230 | last example. 231 | 232 | #. The emails don't include any information about *which* story got changed. 233 | #. Because they're stored in the database, we're presumably having to manually 234 | manage the status of each story. 235 | 236 | To improve on this, we'll introduce two more concepts: the ability for a FSM 237 | to be specific to an external object, and the special ``handle_any`` transition 238 | handler. 239 | 240 | .. note:: 241 | For brevity, we're going to omit that same email code in all the future 242 | examples. Assume it's still defined, or that you've put it in its own 243 | module & are importing it. 244 | 245 | First, external objects. By passing **ANY** Python object in during 246 | initialization, you can enable the FSM to use it during transition handlers. 247 | We'll make a couple small tweaks to our existing handlers. 248 | 249 | .. code-block:: python 250 | 251 | class Workflow(FSM): 252 | allowed_transitions = { 253 | "draft": ["awaiting_review", "rejected"], 254 | "awaiting_review": ["draft", "reviewed", "rejected"], 255 | "reviewed": ["published", "rejected"], 256 | "published": None, 257 | "rejected": ["draft"], 258 | } 259 | default_state = "draft" 260 | 261 | def handle_awaiting_review(self, state_name): 262 | # Note that we're now using a format string & `self.obj` here! 263 | msg = f"'{self.obj.title}' is awaiting review. Go log in & check it out!" 264 | send_mail( 265 | FROM_EMAIL, 266 | EDITORS_EMAIL, 267 | "Story ready for review!", 268 | msg 269 | ) 270 | 271 | def handle_reviewed(self, state_name): 272 | # Note that we're now using a format string & `self.obj` here! 273 | msg = f"'{self.obj.title}' is ready for publishing. Please have a look!" 274 | send_mail( 275 | FROM_EMAIL, 276 | CHIEF_EMAIL, 277 | "Story ready for publishing!", 278 | msg 279 | ) 280 | 281 | Then, when we go to use the workflow, we pass the news story to the constructor. 282 | The FSM will save a reference to it & exposes it as ``self.obj`` to the 283 | handlers. 284 | 285 | .. note:: 286 | For convenience, we'll use a Django model here. But there's nothing 287 | stopping you from using whatever else, like SQLAlchemy's ORM, a Redis 288 | key/value, flat files, even built-in Python objects like ``dict``! 289 | 290 | .. code-block:: python 291 | 292 | >>> from news.models import NewsStory 293 | 294 | >>> story = NewsStory.objects.create( 295 | ... title="Hello, world!", 296 | ... content="This is our very first story!", 297 | ... author=some_writer, 298 | ... state="draft", 299 | ... ) 300 | 301 | # We pass it in here via the `obj=...` kwarg! 302 | >>> workflow = Workflow(obj=story) 303 | 304 | # Now when we make the transition to the new state, the editors will get 305 | # a customized email telling them the title of the story that's ready for 306 | # review! 307 | >>> workflow.transition_to("awaiting_review") 308 | 309 | Another improvement we can make is to persist the ``Workflow`` state in the 310 | database. So if a different server loads the story, the correct state will be 311 | preserved there. We'll implement this using the ``handle_any`` method. 312 | 313 | The special ``handle_any`` method fires on **ANY/ALL** state changes, making it 314 | easy to add behavior that should happen with any change of state. 315 | 316 | .. code-block:: python 317 | 318 | class Workflow(FSM): 319 | allowed_transitions = { 320 | "draft": ["awaiting_review", "rejected"], 321 | "awaiting_review": ["draft", "reviewed", "rejected"], 322 | "reviewed": ["published", "rejected"], 323 | "published": None, 324 | "rejected": ["draft"], 325 | } 326 | default_state = "draft" 327 | 328 | # Here's the new code! 329 | def handle_any(self, state_name): 330 | # The `state` field on the model isn't special, just a plain old 331 | # `CharField`. But we can push all the FSM's changes right onto it. 332 | self.obj.state = self.current_state() 333 | self.obj.save() 334 | 335 | def handle_awaiting_review(self, state_name): 336 | # Same as before... 337 | 338 | def handle_reviewed(self, state_name): 339 | # Same as before... 340 | 341 | Now when we work with the story, we're also persisting the state to the DB. 342 | 343 | .. code-block:: python 344 | 345 | # Same as before. 346 | >>> from news.models import NewsStory 347 | >>> story = NewsStory.objects.create( 348 | ... title="Hello, world!", 349 | ... content="This is our very first story!", 350 | ... author=some_writer, 351 | ... state="draft", 352 | ... ) 353 | >>> workflow = Workflow(obj=story) 354 | 355 | # First, show that nothing has changed yet & no handlers have fired. 356 | >>> story.state 357 | "draft" 358 | >>> workflow.current_state() 359 | "draft" 360 | 361 | # But now, when we trigger the transition, both the `handle_any` & the 362 | # `handle_awaiting_review` will fire! 363 | >>> workflow.transition_to("awaiting_review") 364 | # Email sent! 365 | 366 | # Proof that `handle_any` fired! 367 | >>> story.state 368 | "awaiting_review" 369 | 370 | This is great for generic things like adding logging, persisting to long-term 371 | storage, or performing integrity checks. 372 | 373 | The final change to make is that we can pass the story's current state to 374 | the ``Workflow`` when creating it, making it so that no matter what server loads 375 | the story, the FSM is always in the matching state. 376 | 377 | .. code-block:: python 378 | 379 | >>> from news.models import NewsStory 380 | # We'll assume there's already some stories in the database. 381 | >>> story = NewsStory.objects.get(title="Hello, world!") 382 | 383 | # Here, we pass in the `initial_state` from the model, to synchronize the 384 | # FSM to the correct state. 385 | >>> workflow = Workflow(obj=story, initial_state=obj.state) 386 | 387 | # Note that there's nothing special/magical about the `state` field name 388 | # on the model. Hence the explicit use of `initial_state=...`. 389 | 390 | 391 | Conclusion 392 | ---------- 393 | 394 | We've learned how to define simple finite state machines, ones with complex 395 | state interactions, how to use the everyday parts of the API, and how to build 396 | in behaviors! 397 | 398 | However, there's more that can be done with ``definite``: 399 | 400 | * You can store your states/transitions in external JSON files 401 | * You can implement logic that only happens on certain transitions 402 | * You can auto-create constants for your states 403 | 404 | You can find examples of these within the :doc:`./advanced` guide. 405 | 406 | Alternatively, you can dive into the API references, such as the 407 | :doc:`../api/fsm` reference. 408 | 409 | Enjoy! 410 | 411 | 412 | .. _`documented feature in 3.7`: https://docs.python.org/3/library/stdtypes.html?highlight=preserve#mapping-types-dict 413 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | # https://just.systems/ 2 | 3 | set dotenv-load := false 4 | 5 | shell: 6 | poetry shell 7 | 8 | test: 9 | pytest . 10 | 11 | build-docs: 12 | cd docs && make html && open _build/html/index.html && cd .. 13 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "definite" 3 | version = "1.0.1-dev" 4 | description = "Simple finite state machines." 5 | authors = ["Daniel Lindsley "] 6 | license = "BSD-3-Clause" 7 | readme = "README.md" 8 | repository = "https://github.com/toastdriven/definite" 9 | packages = [ 10 | { include = "definite", from = "src" }, 11 | ] 12 | 13 | [tool.black] 14 | line-length = 88 15 | target-version = ['py39'] 16 | include = '\.pyi?$' 17 | 18 | [tool.poetry.dependencies] 19 | python = "^3.6" 20 | 21 | [tool.poetry.dev-dependencies] 22 | pytest = "^6.2.4" 23 | Sphinx = "^5.0.1" 24 | 25 | [build-system] 26 | requires = [ 27 | "poetry-core>=1.0.0", 28 | "setuptools>=42", 29 | "wheel", 30 | ] 31 | build-backend = "poetry.core.masonry.api" 32 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = definite 3 | version = 1.0.1-dev 4 | author = Daniel Lindsley 5 | author_email = daniel@toastdriven.com 6 | description = Simple finite state machines. 7 | long_description = file: README.md 8 | long_description_content_type = text/markdown 9 | url = https://github.com/toastdriven/definite 10 | project_urls = 11 | Bug Tracker = https://github.com/toastdriven/definite/issues 12 | classifiers = 13 | Programming Language :: Python :: 3 14 | License :: OSI Approved :: BSD-3-Clause 15 | Operating System :: OS Independent 16 | 17 | [options] 18 | package_dir = 19 | = src 20 | packages = find: 21 | python_requires = >=3.6 22 | 23 | [options.packages.find] 24 | where = src 25 | 26 | [wheel] 27 | universal = 1 28 | -------------------------------------------------------------------------------- /src/definite/__init__.py: -------------------------------------------------------------------------------- 1 | from .fsm import FSM 2 | 3 | __author__ = "Daniel Lindsley" 4 | __license__ = "New BSD" 5 | __version__ = (1, 0, 1, "dev") 6 | 7 | 8 | def get_version(full=False): 9 | """ 10 | Returns the version information. 11 | 12 | Args: 13 | full (bool): Switches between short semver & the long/full version, 14 | including release information. Default is `False` (short). 15 | 16 | Returns: 17 | str: The version string 18 | """ 19 | short_version = ".".join([str(bit) for bit in __version__[:3]]) 20 | 21 | if not full: 22 | return short_version 23 | 24 | remainder = "-".join([str(bit) for bit in __version__[3:]]) 25 | return f"{short_version}-{remainder}" 26 | 27 | 28 | __all__ = ["FSM", "get_version", "__author__", "__license__", "__version__"] 29 | -------------------------------------------------------------------------------- /src/definite/constants.py: -------------------------------------------------------------------------------- 1 | # A sentinel for when a subclass has not defined a default state. 2 | class UNKNOWN_STATE(object): 3 | pass 4 | 5 | 6 | # A constant to represent any/all state names. 7 | ANY = "any" 8 | -------------------------------------------------------------------------------- /src/definite/exceptions.py: -------------------------------------------------------------------------------- 1 | class DefinitelyAnError(Exception): 2 | """ 3 | A base exception for the others to inherit from. 4 | """ 5 | 6 | pass 7 | 8 | 9 | class InvalidState(DefinitelyAnError): 10 | """ 11 | Raised when an unknown state is attempted to be transitioned to. 12 | """ 13 | 14 | pass 15 | 16 | 17 | class TransitionNotAllowed(DefinitelyAnError): 18 | """ 19 | Raised when the provide state is not allowed to be transitioned to. 20 | """ 21 | 22 | pass 23 | 24 | 25 | class InvalidHandler(DefinitelyAnError): 26 | """ 27 | Raised when a `handle_*` attribute is present, but can not be called. 28 | """ 29 | 30 | pass 31 | 32 | 33 | class InvalidDefaultState(DefinitelyAnError): 34 | """ 35 | Raised when no default state has been specified on a FSM subclass. 36 | """ 37 | 38 | pass 39 | 40 | 41 | class NoStatesDefined(DefinitelyAnError): 42 | """ 43 | Raised when no states/transitions have been specified on a FSM subclass. 44 | """ 45 | 46 | pass 47 | -------------------------------------------------------------------------------- /src/definite/fsm.py: -------------------------------------------------------------------------------- 1 | from . import constants 2 | from . import exceptions # import InvalidState, TransitionNotAllowed 3 | 4 | 5 | class FSM(object): 6 | """ 7 | The base finite state machine class. 8 | 9 | In normal usage, you should subclass this class. In doing so, you must 10 | define your own `allowed_transitions` and `default_state` attributes on the 11 | subclass. 12 | 13 | Args: 14 | obj (Any): (Optional) An object to perform actions on as part of any 15 | transition handlers. For example, triggering a save on that 16 | object, updating that object's internal state, sending 17 | notifications about it, etc. Default is `None`. 18 | initial_state (str): The initial state the instance should start in. 19 | Default is `None` (use the `default_state`). 20 | """ 21 | 22 | allowed_transitions = {} 23 | default_state = constants.UNKNOWN_STATE 24 | 25 | # For convenience, add the exceptions onto ourself, for easy raising & 26 | # catching. 27 | InvalidState = exceptions.InvalidState 28 | TransitionNotAllowed = exceptions.TransitionNotAllowed 29 | 30 | def __init__(self, obj=None, initial_state=None): 31 | self._state_names = [] 32 | self.obj = obj 33 | self._current_state = self.default_state 34 | 35 | # Trigger all the setup/caching. 36 | self.setup() 37 | 38 | if initial_state is not None: 39 | if self.is_valid(initial_state): 40 | self._current_state = initial_state 41 | 42 | def setup(self): 43 | """ 44 | A mostly-internal method for setting up required data. 45 | 46 | Can be used to re-trigger this build if you're doing something 47 | advanced/spicy. 48 | 49 | No arguments or return values. 50 | """ 51 | if not len(self.allowed_transitions): 52 | raise exceptions.NoStatesDefined( 53 | f"'allowed_transitions' not defined on {self}" 54 | ) 55 | 56 | if self.default_state == constants.UNKNOWN_STATE: 57 | raise exceptions.InvalidDefaultState( 58 | f"'default_state' not defined on {self}" 59 | ) 60 | 61 | self._state_names = self.allowed_transitions.keys() 62 | 63 | # Check that all states are present as keys. 64 | for current_name, transitions in self.allowed_transitions.items(): 65 | if transitions is None: 66 | continue 67 | 68 | for desired_name in transitions: 69 | if desired_name not in self._state_names: 70 | msg = ( 71 | f"'{current_name}' contains '{desired_name}' as a " 72 | f"transition, which is not present as a top-level state." 73 | ) 74 | raise self.InvalidState(msg) 75 | 76 | @classmethod 77 | def from_json(cls, name, json_data): 78 | """ 79 | Construct a new (sub)class, based on JSON data. 80 | 81 | Useful for storing states/transitions externally & loading them at 82 | runtime. 83 | 84 | Args: 85 | name (str): The desired (sub)class name. 86 | json_data (dict): A dictionary containing an `allowed_transitions` 87 | dictionary and a `default_state` key/value. 88 | 89 | Returns: 90 | FSM: A newly built subclass of FSM. 91 | """ 92 | new_cls = type( 93 | name, 94 | (cls,), 95 | { 96 | "allowed_transitions": json_data.get("allowed_transitions", {}), 97 | "default_state": json_data.get("default_state", {}), 98 | }, 99 | ) 100 | return new_cls 101 | 102 | def current_state(self): 103 | """ 104 | What state the FSM is currently in. 105 | 106 | Returns: 107 | str: The current state. 108 | """ 109 | return self._current_state 110 | 111 | def all_states(self): 112 | """ 113 | All the valid state names for the FSM. 114 | 115 | Returns: 116 | list: All the state names. 117 | """ 118 | return sorted(self._state_names) 119 | 120 | def is_valid(self, state_name): 121 | """ 122 | Identifies if the provided state name is a recognized/valid name. 123 | 124 | Args: 125 | state_name (str): The name to check. 126 | 127 | Returns: 128 | bool: If it's valid, returns True. Otherwise, returns False. 129 | """ 130 | return state_name in self._state_names 131 | 132 | def is_allowed(self, state_name): 133 | """ 134 | From the current state, identifies if transitioning to the provided 135 | state name is allowed. 136 | 137 | Args: 138 | state_name (str): The state to transition to. 139 | 140 | Returns: 141 | bool: If the transition would be allowed, returns True. Otherwise, 142 | returns False. 143 | """ 144 | current_state = self.current_state() 145 | available_transitions = self.allowed_transitions.get(current_state, None) 146 | 147 | if available_transitions is None: 148 | available_transitions = [] 149 | 150 | return state_name in available_transitions 151 | 152 | def _call_handler(self, handler_name, state_name): 153 | handle_specific = getattr(self, handler_name, None) 154 | 155 | if handle_specific is not None: 156 | if not callable(handle_specific): 157 | raise exceptions.InvalidHandler( 158 | f"The '{handler_name}' attribute is not callable" 159 | ) 160 | 161 | return handle_specific(state_name) 162 | 163 | def transition_to(self, state_name): 164 | """ 165 | Triggers a state transition to the provided state name. 166 | 167 | If the special `handle_any` method is defined on the FSM subclass, it 168 | will *always* be called, regardless of what state name is provided. 169 | 170 | If a `handle_` method is defined on the FSM subclass, it 171 | will be called. 172 | 173 | Args: 174 | state_name (str): The state to transition to. 175 | 176 | Returns: 177 | None 178 | 179 | Raises: 180 | InvalidState: If the state name is invalid. 181 | TransitionNotAllowed: If the transition isn't allowed from the 182 | current state. 183 | InvalidHandler: If a handler is present on the FSM, but is not 184 | callable. 185 | """ 186 | if not self.is_valid(state_name): 187 | raise self.InvalidState(f"'{state_name}' is not a recognized state.") 188 | 189 | if not self.is_allowed(state_name): 190 | raise self.TransitionNotAllowed( 191 | f"'{self.current_state()}' cannot transition to '{state_name}'" 192 | ) 193 | 194 | # The state transition is allowed. 195 | # Check & run for the `handle_any` method first, ... 196 | handle_any = "handle_any" 197 | self._call_handler(handle_any, state_name) 198 | 199 | # ...then the specific `handle_` method. 200 | handler_name = f"handle_{state_name}" 201 | self._call_handler(handler_name, state_name) 202 | 203 | # Finally, we update our internal state. 204 | self._current_state = state_name 205 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toastdriven/definite/09277bce521321a94f9a81821adc28fc218f9a8e/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_fsm.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from definite import exceptions 6 | from definite.fsm import FSM 7 | 8 | 9 | class BasicFlow(FSM): 10 | allowed_transitions = { 11 | "created": ["waiting"], 12 | "waiting": ["in_progress", "done"], 13 | "in_progress": ["waiting", "done"], 14 | "done": None, 15 | } 16 | default_state = "created" 17 | 18 | 19 | class ComplexFlow(FSM): 20 | allowed_transitions = { 21 | "created": ["waiting"], 22 | "waiting": ["in_progress", "done"], 23 | "in_progress": ["waiting", "done"], 24 | "done": None, 25 | } 26 | default_state = "created" 27 | 28 | def handle_any(self, desired_state): 29 | pass 30 | 31 | def handle_in_progress(self, desired_state): 32 | pass 33 | 34 | 35 | def test_empty_init(): 36 | # If you use it without subclassing. 37 | with pytest.raises(exceptions.NoStatesDefined): 38 | fsm = FSM() 39 | 40 | # If you subclass but don't provide a default state. 41 | class JustTransitions(FSM): 42 | allowed_transitions = { 43 | "start": ["end"], 44 | "end": None, 45 | } 46 | 47 | with pytest.raises(exceptions.InvalidDefaultState): 48 | fsm = JustTransitions() 49 | 50 | 51 | def test_init_and_setup(): 52 | basic = BasicFlow() 53 | assert basic.allowed_transitions == { 54 | "created": ["waiting"], 55 | "waiting": ["in_progress", "done"], 56 | "in_progress": ["waiting", "done"], 57 | "done": None, 58 | } 59 | assert basic.default_state == "created" 60 | assert len(basic._state_names) > 0 61 | 62 | 63 | def test_setup_untransitionable_state(): 64 | class Broken(FSM): 65 | allowed_transitions = { 66 | "start": ["nopenopenope"], 67 | "end": None, 68 | } 69 | default_state = "start" 70 | 71 | with pytest.raises(Broken.InvalidState) as excinfo: 72 | broke = Broken() 73 | 74 | assert "'start' contains 'nopenopenope'" in str(excinfo.value) 75 | 76 | 77 | def test_initial_state(): 78 | basic = BasicFlow(initial_state="waiting") 79 | assert basic._current_state == "waiting" 80 | 81 | 82 | def test_current_state(): 83 | basic = BasicFlow() 84 | assert basic.current_state() == "created" 85 | 86 | 87 | def test_all_states(): 88 | basic = BasicFlow() 89 | assert basic.all_states() == ["created", "done", "in_progress", "waiting"] 90 | 91 | 92 | def test_is_valid(): 93 | basic = BasicFlow() 94 | assert basic.is_valid("created") 95 | assert basic.is_valid("waiting") 96 | assert basic.is_valid("in_progress") 97 | assert basic.is_valid("done") 98 | 99 | # And invalid state names. 100 | assert basic.is_valid("nopenopenope") == False 101 | 102 | 103 | def test__call_handler(): 104 | class WithHandler(BasicFlow): 105 | def handle_waiting(self, state_name): 106 | # We'll observe this with `mock`. 107 | pass 108 | 109 | fsm = WithHandler() 110 | 111 | with mock.patch.object(fsm, "handle_waiting") as mock_handler: 112 | fsm._call_handler("handle_waiting", "waiting") 113 | # It should find & call the correct handler method. 114 | mock_handler.assert_called_once_with("waiting") 115 | 116 | 117 | def test_transition_to_basic(): 118 | fsm = BasicFlow() 119 | assert fsm.current_state() == "created" 120 | 121 | fsm.transition_to("waiting") 122 | assert fsm.current_state() == "waiting" 123 | 124 | fsm.transition_to("in_progress") 125 | assert fsm.current_state() == "in_progress" 126 | 127 | fsm.transition_to("waiting") 128 | assert fsm.current_state() == "waiting" 129 | 130 | fsm.transition_to("done") 131 | assert fsm.current_state() == "done" 132 | 133 | 134 | def test_transition_to_not_allowed(): 135 | fsm = BasicFlow() 136 | 137 | with pytest.raises(fsm.TransitionNotAllowed): 138 | fsm.transition_to("done") 139 | 140 | 141 | def test_transition_to_invalid(): 142 | fsm = BasicFlow() 143 | 144 | with pytest.raises(fsm.InvalidState): 145 | fsm.transition_to("nopenopenope") 146 | 147 | 148 | def test_transition_to_complex(): 149 | job = { 150 | "id": "CCEE9690-6626-4827-AC1A-73A911278067", 151 | } 152 | 153 | fsm = ComplexFlow(obj=job) 154 | assert fsm.current_state() == "created" 155 | 156 | with mock.patch.object(fsm, "handle_any") as mock_any: 157 | with mock.patch.object(fsm, "handle_in_progress") as mock_in_progress: 158 | fsm.transition_to("waiting") 159 | assert fsm.current_state() == "waiting" 160 | mock_any.assert_called_once_with("waiting") 161 | mock_in_progress.assert_not_called() 162 | 163 | with mock.patch.object(fsm, "handle_any") as mock_any: 164 | with mock.patch.object(fsm, "handle_in_progress") as mock_in_progress: 165 | fsm.transition_to("in_progress") 166 | assert fsm.current_state() == "in_progress" 167 | mock_any.assert_called_once_with("in_progress") 168 | mock_in_progress.assert_called_once_with("in_progress") 169 | 170 | with mock.patch.object(fsm, "handle_any") as mock_any: 171 | with mock.patch.object(fsm, "handle_in_progress") as mock_in_progress: 172 | fsm.transition_to("waiting") 173 | assert fsm.current_state() == "waiting" 174 | mock_any.assert_called_once_with("waiting") 175 | mock_in_progress.assert_not_called() 176 | 177 | with mock.patch.object(fsm, "handle_any") as mock_any: 178 | with mock.patch.object(fsm, "handle_in_progress") as mock_in_progress: 179 | fsm.transition_to("done") 180 | assert fsm.current_state() == "done" 181 | mock_any.assert_called_once_with("done") 182 | mock_in_progress.assert_not_called() 183 | 184 | 185 | def test_from_json(): 186 | # You can have external JSON definitions (or just a Python `dict`). 187 | json_definition = { 188 | "allowed_transitions": { 189 | "start": ["end"], 190 | "end": None, 191 | }, 192 | "default_state": "start", 193 | } 194 | 195 | # Now we've got a new class. 196 | Whee = FSM.from_json("Whee", json_definition) 197 | 198 | whee = Whee() 199 | assert whee.current_state() == "start" 200 | 201 | with pytest.raises(Whee.InvalidState): 202 | whee.transition_to("nope") 203 | 204 | whee.transition_to("end") 205 | assert whee.current_state() == "end" 206 | -------------------------------------------------------------------------------- /tests/test_module.py: -------------------------------------------------------------------------------- 1 | import definite 2 | 3 | 4 | def test_get_version_short(): 5 | # `mock.patch` wasn't cooperating here, so uh... yolo? 6 | old_version = definite.__version__ 7 | definite.__version__ = (1, 2, 3, "alpha") 8 | 9 | try: 10 | assert definite.get_version() == "1.2.3" 11 | finally: 12 | definite.__version__ = old_version 13 | 14 | 15 | def test_get_version_full(): 16 | # `mock.patch` wasn't cooperating here, so uh... yolo? 17 | old_version = definite.__version__ 18 | definite.__version__ = (1, 2, 3, "alpha", "omega") 19 | 20 | try: 21 | assert definite.get_version(full=True) == "1.2.3-alpha-omega" 22 | finally: 23 | definite.__version__ = old_version 24 | -------------------------------------------------------------------------------- /tests/test_readme.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from unittest import mock 3 | 4 | import pytest 5 | 6 | from definite import FSM 7 | 8 | 9 | class BasicWorkflow(FSM): 10 | allowed_transitions = { 11 | "draft": ["awaiting_review", "rejected"], 12 | "awaiting_review": ["draft", "reviewed", "rejected"], 13 | "reviewed": ["published", "rejected"], 14 | "published": None, 15 | "rejected": ["draft"], 16 | } 17 | default_state = "draft" 18 | 19 | 20 | class ComplexWorkflow(FSM): 21 | allowed_transitions = { 22 | "draft": ["awaiting_review", "rejected"], 23 | "awaiting_review": ["draft", "reviewed", "rejected"], 24 | "reviewed": ["published", "rejected"], 25 | "published": None, 26 | "rejected": ["draft"], 27 | } 28 | default_state = "draft" 29 | 30 | # Define a `handle_` method on the class. 31 | def handle_awaiting_review(self, new_state): 32 | # spell_check_results = check_spelling(self.obj.content) 33 | # msg = ( 34 | # f"{self.obj.title} ready for review. " 35 | # f"{len(spell_check_results)} spelling errors." 36 | # ) 37 | # send_email(to=editor_email, message=msg) 38 | pass 39 | 40 | def handle_published(self, new_state): 41 | self.obj.pub_date = datetime.datetime.utcnow() 42 | self.obj.save() 43 | 44 | def handle_any(self, new_state): 45 | self.obj.state = new_state 46 | self.obj.save() 47 | 48 | 49 | class FakeNewsPost(object): 50 | pub_date = None 51 | 52 | def __init__(self, **kwargs): 53 | for k, v in kwargs.items(): 54 | setattr(self, k, v) 55 | 56 | def save(self): 57 | # This would persist to a DB. 58 | pass 59 | 60 | 61 | def test_basic(): 62 | workflow = BasicWorkflow() 63 | assert workflow.current_state() == "draft" 64 | 65 | workflow.transition_to("awaiting_review") 66 | assert workflow.current_state() == "awaiting_review" 67 | workflow.transition_to("reviewed") 68 | assert workflow.current_state() == "reviewed" 69 | 70 | assert workflow.is_allowed("published") 71 | 72 | workflow.current_state() # "reviewed" 73 | 74 | with pytest.raises(workflow.TransitionNotAllowed) as excinfo: 75 | workflow.transition_to("awaiting_review") 76 | 77 | assert "'reviewed' cannot transition to 'awaiting_review'" in str(excinfo.value) 78 | 79 | 80 | def test_complex(): 81 | workflow = ComplexWorkflow() 82 | assert workflow.current_state() == "draft" 83 | 84 | with mock.patch.object(workflow, "handle_any") as mock_handle_any: 85 | with mock.patch.object(workflow, "handle_awaiting_review") as mock_handle_ar: 86 | workflow.transition_to("awaiting_review") 87 | mock_handle_any.assert_called_once_with("awaiting_review") 88 | mock_handle_ar.assert_called_once_with("awaiting_review") 89 | 90 | with mock.patch.object(workflow, "handle_any") as mock_handle_any: 91 | workflow.transition_to("reviewed") 92 | mock_handle_any.assert_called_once_with("reviewed") 93 | assert workflow.current_state() == "reviewed" 94 | 95 | with mock.patch.object(workflow, "handle_any") as mock_handle_any: 96 | with mock.patch.object(workflow, "handle_published") as mock_handle_published: 97 | workflow.transition_to("published") 98 | mock_handle_any.assert_called_once_with("published") 99 | mock_handle_published.assert_called_once_with("published") 100 | 101 | assert workflow.current_state() == "published" 102 | 103 | with pytest.raises(workflow.TransitionNotAllowed) as excinfo: 104 | workflow.transition_to("draft") 105 | 106 | 107 | def test_handlers(): 108 | news_post = FakeNewsPost( 109 | title="Hello world!", 110 | content="This iz our frist post!", 111 | state="draft", 112 | ) 113 | 114 | workflow = ComplexWorkflow(obj=news_post) 115 | assert workflow.current_state() == "draft" 116 | 117 | workflow.transition_to("awaiting_review") 118 | # It should've updated the state on the object! 119 | assert news_post.state == "awaiting_review" 120 | assert news_post.pub_date is None 121 | 122 | workflow.transition_to("reviewed") 123 | assert news_post.state == "reviewed" 124 | assert news_post.pub_date is None 125 | 126 | workflow.transition_to("published") 127 | # Both `handle_any` *AND* `handle_published` should've been called. 128 | assert news_post.state == "published" 129 | now = datetime.datetime.utcnow() 130 | assert news_post.pub_date.year == now.year 131 | 132 | assert workflow.current_state() == "published" 133 | --------------------------------------------------------------------------------