├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .flake8 ├── .github └── workflows │ ├── pull_request.yaml │ └── release.yaml ├── .gitignore ├── .gitmodules ├── .vscode ├── extensions.json ├── settings.json └── tasks.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── examples ├── actions.py ├── basic.py ├── substates.py └── ui.py ├── poetry.lock ├── poetry.toml ├── pyproject.toml ├── tests ├── __init__.py ├── test_actions.py ├── test_algorithm.py ├── test_machine.py └── test_scxml.py ├── viz.py └── xstate ├── __init__.py ├── action.py ├── algorithm.py ├── event.py ├── interpreter.py ├── machine.py ├── scxml.py ├── state.py ├── state_node.py └── transition.py /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.177.0/containers/python-3/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Python version: 3, 3.9, 3.8, 3.7, 3.6 4 | ARG VARIANT="3.9" 5 | FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} 6 | 7 | # [Option] Install Node.js 8 | ARG INSTALL_NODE="true" 9 | ARG NODE_VERSION="lts/*" 10 | RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi 11 | 12 | # install poetry for vscode user 13 | ENV POETRY_VERSION=1.1.6 \ 14 | PATH=/home/vscode/.poetry/bin:$PATH 15 | RUN runuser -l vscode -c 'curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python -' 16 | 17 | 18 | # [Optional] If your pip requirements rarely change, uncomment this section to add them to the image. 19 | # RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements_dev.txt \ 20 | # && rm -rf /tmp/pip-tmp 21 | 22 | # [Optional] Uncomment this section to install additional OS packages. 23 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 24 | # && apt-get -y install --no-install-recommends 25 | 26 | # [Optional] Uncomment this line to install global node packages. 27 | # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.177.0/containers/python-3 3 | { 4 | "name": "XState Python", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | "context": "..", 8 | "args": { 9 | // Update 'VARIANT' to pick a Python version: 3, 3.6, 3.7, 3.8, 3.9 10 | "VARIANT": "3.7", 11 | // Options 12 | "INSTALL_NODE": "false", 13 | } 14 | }, 15 | 16 | // Set *default* container specific settings.json values on container create. 17 | "settings": { 18 | "terminal.integrated.shell.linux": "/bin/zsh", 19 | "python.languageServer": "Pylance", 20 | }, 21 | 22 | // Add the IDs of extensions you want installed when the container is created. 23 | "extensions": [ 24 | "ms-python.python", 25 | "ms-python.vscode-pylance" 26 | ], 27 | 28 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 29 | // "forwardPorts": [], 30 | 31 | // Use 'postCreateCommand' to run commands after the container is created. 32 | "postCreateCommand": "git submodule update --init && poetry install", 33 | 34 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 35 | "remoteUser": "vscode" 36 | } 37 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | exclude = 4 | .git, 5 | __pycache__, 6 | .mypy_cache, 7 | .pytest_cache, 8 | .venv 9 | max-complexity = 10 -------------------------------------------------------------------------------- /.github/workflows/pull_request.yaml: -------------------------------------------------------------------------------- 1 | name: Run tests and code quality 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | test: 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | python-version: [3.6, 3.7, 3.8, 3.9] 14 | poetry-version: [1.1.6] 15 | os: [ubuntu-latest] 16 | runs-on: ${{ matrix.os }} 17 | steps: 18 | - uses: actions/checkout@v2 19 | with: 20 | submodules: recursive 21 | - uses: actions/setup-python@v2 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Run image 25 | uses: abatilo/actions-poetry@v2.0.0 26 | with: 27 | poetry-version: ${{ matrix.poetry-version }} 28 | - name: Install dependencies 29 | run: poetry install 30 | - name: Run tests 31 | run: poetry run pytest --cov --cov-report=xml 32 | - name: Upload coverage to Codecov 33 | uses: codecov/codecov-action@v1 34 | code-quality: 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | python-version: [3.7] 39 | poetry-version: [1.1.6] 40 | os: [ubuntu-latest] 41 | runs-on: ${{ matrix.os }} 42 | steps: 43 | - uses: actions/checkout@v2 44 | with: 45 | submodules: recursive 46 | - uses: actions/setup-python@v2 47 | with: 48 | python-version: ${{ matrix.python-version }} 49 | - name: Run image 50 | uses: abatilo/actions-poetry@v2.0.0 51 | with: 52 | poetry-version: ${{ matrix.poetry-version }} 53 | - name: Install dependencies 54 | run: poetry install 55 | - name: Run black 56 | run: poetry run black . --check 57 | - name: Run isort 58 | run: poetry run isort . --check-only 59 | - name: Run flake8 60 | run: poetry run flake8 . -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | release: 4 | types: 5 | - created 6 | 7 | jobs: 8 | publish: 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | python-version: [3.7] 13 | poetry-version: [1.1.6] 14 | os: [ubuntu-latest] 15 | runs-on: ${{ matrix.os }} 16 | steps: 17 | - uses: actions/checkout@v2 18 | with: 19 | submodules: recursive 20 | - uses: actions/setup-python@v2 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Run image 24 | uses: abatilo/actions-poetry@v2.0.0 25 | with: 26 | poetry-version: ${{ matrix.poetry-version }} 27 | - name: Publish 28 | env: 29 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 30 | run: | 31 | poetry config pypi-token.pypi $PYPI_TOKEN 32 | poetry publish --build -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # output 132 | test.svg 133 | node_modules/ 134 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "test-framework"] 2 | path = test-framework 3 | url = https://gitlab.com/scion-scxml/test-framework.git 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "eamodio.gitlens" 4 | ] 5 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": ".venv/bin/python", 3 | "python.testing.pytestArgs": [ 4 | "tests" 5 | ], 6 | "python.testing.unittestEnabled": false, 7 | "python.testing.nosetestsEnabled": false, 8 | "python.testing.pytestEnabled": true, 9 | "python.formatting.provider": "black", 10 | "python.linting.flake8Enabled": true, 11 | "python.linting.enabled": true, 12 | "python.analysis.typeCheckingMode": "off" 13 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Run tests", 8 | "type": "shell", 9 | "command": "poetry run pytest --cov", 10 | "problemMatcher": [] 11 | }, 12 | { 13 | "label": "Format code", 14 | "type": "shell", 15 | "command": "poetry run black . && poetry run isort .", 16 | "problemMatcher": [] 17 | }, 18 | { 19 | "label": "Check code-quality", 20 | "type": "shell", 21 | "command": "poetry run black . --check && poetry run isort . --check-only && poetry run flake8", 22 | "problemMatcher": [] 23 | }, 24 | ] 25 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | ## Git Flow: 3 | 4 | 1. Checkout the latest master: 5 | 6 | ```bash 7 | git checkout master 8 | git pull 9 | ``` 10 | 11 | 2. Branch off of master: 12 | 13 | ```bash 14 | # example: jenny-tru/54 or jenny-tru/quick-fix-for-thing 15 | git checkout -b / 16 | ``` 17 | 18 | 3. Make your changes! 19 | 20 | 4. Run tests: 21 | 22 | ```bash 23 | pytest 24 | # To see print() output: 25 | pytest -s 26 | ``` 27 | 28 | 5. Commit your changes, through the VS Code UI or: 29 | 30 | ```bash 31 | git commit -a -m "Your commit message here" 32 | ``` 33 | 34 | 6. Push to origin, through the VS Code UI or: 35 | 36 | ```bash 37 | git push origin head 38 | ``` 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 David Khourshid 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # XState for Python 2 | 3 | XState for Python - work in progress! 4 | 5 | ## How to use 6 | 7 | ```python 8 | 9 | from xstate import Machine 10 | 11 | lights = Machine( 12 | { 13 | "id": "lights", 14 | "initial": "green", 15 | "states": { 16 | "green": {"on": {"TIMER": "yellow"},}, 17 | "yellow": {"on": {"TIMER": "red"}}, 18 | "red": {"on": {"TIMER": "green"}}, 19 | }, 20 | } 21 | ) 22 | 23 | state = lights.initial_state # state.value is green 24 | 25 | state = lights.transition(state, "TIMER") # state.value is yellow 26 | state = lights.transition(state, "TIMER") # state.value is red 27 | state = lights.transition(state, "TIMER") # state.value is green again 28 | ``` 29 | 30 | More advanced examples in [the "examples" folder](./examples) 31 | 32 | ## Developing 33 | 34 | You can set up your development environment in two different ways. 35 | 36 | ### Using [Remote Containers](https://code.visualstudio.com/docs/remote/containers) (recommended if you use VS Code) 37 | 38 | Prerequisites 39 | * VS Code IDE and [Remote Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.vscode-remote-extensionpack) 40 | 41 | Steps 42 | 43 | 1. [Open the folder in a container](https://code.visualstudio.com/docs/remote/containers#_quick-start-open-an-existing-folder-in-a-container). This will setup your environment with python (including python and pylance extensions), dependencies and download scxml tests. 44 | 1. Run `poetry run pytest --cov` to run the tests! 👩‍🔬 (or run the `Run tests` task via VS Code or using VS Code Test explorer where you can debug as well) 45 | 46 | ### Or installing the environment on your local drive 47 | 48 | Prerequisites 49 | * [`poetry`](https://python-poetry.org/) for package and dependency management 50 | 51 | Steps 52 | 1. Run `poetry install` to create a virtual environment 53 | 1. Make sure test files are present and up to date by running `git submodule update --init` 54 | 1. Run `poetry run pytest --cov` to run the tests! 👩‍🔬 (or run the `Run tests` task via VS Code or using VS Code Test explorer where you can debug as well) 55 | 56 | ## SCXML 57 | 58 | SCXML tests are ran from [the SCION Test Framework](./node_modules/@scion-scxml/test-framework/README.md) module. 59 | 60 | ## Related Projects 61 | 62 | - [finite-state-machine](https://github.com/alysivji/finite-state-machine) 63 | - [Sismic for Python](https://github.com/AlexandreDecan/sismic) 64 | - [Miros](https://github.com/aleph2c/miros) 65 | - [Automat](https://github.com/glyph/automat) 66 | -------------------------------------------------------------------------------- /examples/actions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import time 4 | 5 | from xstate import Machine 6 | 7 | # Trafic light example 8 | # green -> yellow -> red -> green .. 9 | timing = 2 10 | 11 | 12 | def enterGreen(): 13 | print("\tENTER_GREEN callback") 14 | 15 | 16 | def exitGreen(): 17 | print("\tEXIT_GREEN callback") 18 | 19 | 20 | # fmt: off 21 | lights = Machine( 22 | { 23 | "id": "lights", 24 | "initial": "green", 25 | "states": { 26 | "green": { 27 | "on": {"TIMER": "yellow"}, 28 | "entry": [{"type": "enterGreen"}], 29 | "exit": [{"type": "exitGreen"}], 30 | }, 31 | "yellow": { 32 | "on": {"TIMER": "red"}, 33 | "entry": [{"type": "enterYellow"}] 34 | }, 35 | "red": { 36 | "on": {"TIMER": "green"}, 37 | "entry": [lambda: print("\tINLINE callback")], 38 | }, 39 | }, 40 | }, 41 | actions={ 42 | # action implementations 43 | "enterGreen": enterGreen, 44 | "exitGreen": exitGreen, 45 | "enterYellow": lambda: print("\tENTER_YELLOW callback"), 46 | }, 47 | ) 48 | # fmt: on 49 | 50 | if __name__ == "__main__": 51 | state = lights.initial_state 52 | 53 | for i in range(10): 54 | # execute all the actions (before/exit states) 55 | for action in state.actions: 56 | action() 57 | print("VALUE: {}".format(state.value)) 58 | 59 | time.sleep(timing) 60 | state = lights.transition(state, "TIMER") 61 | -------------------------------------------------------------------------------- /examples/basic.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # add parent to the path to include local xstate module 4 | import sys 5 | 6 | sys.path.insert(0, "..") 7 | 8 | import time # noqa 9 | 10 | from xstate import Machine # noqa 11 | 12 | # Trafic light example 13 | # green -> yellow -> red -> green .. 14 | 15 | lights = Machine( 16 | { 17 | "id": "lights", 18 | "initial": "green", 19 | "states": { 20 | "green": { 21 | "on": {"TIMER": "yellow"}, 22 | }, 23 | "yellow": {"on": {"TIMER": "red"}}, 24 | "red": {"on": {"TIMER": "green"}}, 25 | }, 26 | } 27 | ) 28 | 29 | if __name__ == "__main__": 30 | state = lights.initial_state 31 | print(state.value) 32 | time.sleep(0.5) 33 | 34 | for i in range(10): 35 | state = lights.transition(state, "TIMER") 36 | print(state.value) 37 | time.sleep(0.5) 38 | 39 | state = lights.transition(state, "TIMER") 40 | print(state.value) 41 | time.sleep(0.5) 42 | 43 | state = lights.transition(state, "TIMER") 44 | print(state.value) 45 | time.sleep(0.5) 46 | -------------------------------------------------------------------------------- /examples/substates.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # add parent to the path to include local xstate module 4 | import sys 5 | 6 | sys.path.insert(0, "..") 7 | 8 | import time # noqa 9 | 10 | from xstate import Machine # noqa 11 | 12 | # Trafic light example with substate 13 | # green -> yellow -> red.walk -> red.wait -> red.stop -> green .. 14 | 15 | lights = Machine( 16 | { 17 | "id": "lights", 18 | "initial": "green", 19 | "states": { 20 | "green": { 21 | "entry": [{"type": "enterGreen"}], 22 | "on": {"TIMER": "yellow"}, 23 | }, 24 | "yellow": {"on": {"TIMER": "red"}}, 25 | "red": { # subFSM 26 | "initial": "walk", 27 | "states": { 28 | "walk": {"on": {"COUNTDOWN": "wait"}}, 29 | "wait": {"on": {"COUNTDOWN": "stop"}}, 30 | "stop": {"on": {"TIMEOUT": "timeout"}}, 31 | "timeout": {"type": "final"}, 32 | # type 'final' will make it to the onDone step of the superior FSM 33 | }, 34 | "onDone": "green", 35 | }, 36 | }, 37 | } 38 | ) 39 | 40 | if __name__ == "__main__": 41 | state = lights.initial_state 42 | print(state.value) 43 | time.sleep(0.5) 44 | 45 | for i in range(10): 46 | state = lights.transition(state, "TIMER") 47 | print(state.value) 48 | time.sleep(0.5) 49 | 50 | state = lights.transition(state, "TIMER") 51 | print(state.value) 52 | time.sleep(0.5) 53 | 54 | state = lights.transition(state, "COUNTDOWN") 55 | print(state.value) 56 | time.sleep(0.5) 57 | 58 | state = lights.transition(state, "COUNTDOWN") 59 | print(state.value) 60 | time.sleep(0.5) 61 | 62 | state = lights.transition(state, "TIMEOUT") 63 | print(state.value) 64 | time.sleep(0.5) 65 | -------------------------------------------------------------------------------- /examples/ui.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # add parent to the path to include local xstate module 4 | import sys 5 | 6 | sys.path.insert(0, "..") 7 | 8 | import tkinter # noqa 9 | 10 | from xstate import Machine # noqa 11 | 12 | 13 | class ApplicationBasic: 14 | def __init__(self): 15 | self.init_ui() 16 | self.init_FSM() 17 | 18 | def init_FSM(self): 19 | # Trafic light example 20 | # green -> yellow -> red -> green .. 21 | self.fsm = Machine( 22 | { 23 | "id": "lights", 24 | "initial": "green", 25 | "states": { 26 | "green": { 27 | "on": {"TIMER": "yellow"}, 28 | }, 29 | "yellow": {"on": {"TIMER": "red"}}, 30 | "red": {"on": {"TIMER": "green"}}, 31 | }, 32 | } 33 | ) 34 | 35 | self.state = self.fsm.initial_state 36 | self.update_label() 37 | 38 | def init_ui(self): 39 | self.fen = tkinter.Tk() 40 | self.label = tkinter.Label(self.fen, text="") 41 | self.label.pack() 42 | self.button = tkinter.Button(self.fen, text="TIMER", command=self.action) 43 | self.button.pack() 44 | 45 | def action(self): 46 | print("action") 47 | self.state = self.fsm.transition(self.state, "TIMER") 48 | self.update_label() 49 | 50 | def update_label(self): 51 | self.label["text"] = self.state.value 52 | 53 | def run(self): 54 | self.fen.mainloop() 55 | 56 | 57 | if __name__ == "__main__": 58 | app = ApplicationBasic() 59 | app.run() 60 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "appdirs" 3 | version = "1.4.4" 4 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 5 | category = "dev" 6 | optional = false 7 | python-versions = "*" 8 | 9 | [[package]] 10 | name = "atomicwrites" 11 | version = "1.4.0" 12 | description = "Atomic file writes." 13 | category = "dev" 14 | optional = false 15 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 16 | 17 | [[package]] 18 | name = "attrs" 19 | version = "21.2.0" 20 | description = "Classes Without Boilerplate" 21 | category = "dev" 22 | optional = false 23 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 24 | 25 | [package.extras] 26 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] 27 | docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] 28 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] 29 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] 30 | 31 | [[package]] 32 | name = "black" 33 | version = "21.5b1" 34 | description = "The uncompromising code formatter." 35 | category = "dev" 36 | optional = false 37 | python-versions = ">=3.6.2" 38 | 39 | [package.dependencies] 40 | appdirs = "*" 41 | click = ">=7.1.2" 42 | dataclasses = {version = ">=0.6", markers = "python_version < \"3.7\""} 43 | mypy-extensions = ">=0.4.3" 44 | pathspec = ">=0.8.1,<1" 45 | regex = ">=2020.1.8" 46 | toml = ">=0.10.1" 47 | typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\""} 48 | typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} 49 | 50 | [package.extras] 51 | colorama = ["colorama (>=0.4.3)"] 52 | d = ["aiohttp (>=3.6.0)", "aiohttp-cors"] 53 | python2 = ["typed-ast (>=1.4.2)"] 54 | 55 | [[package]] 56 | name = "click" 57 | version = "8.0.1" 58 | description = "Composable command line interface toolkit" 59 | category = "dev" 60 | optional = false 61 | python-versions = ">=3.6" 62 | 63 | [package.dependencies] 64 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 65 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 66 | 67 | [[package]] 68 | name = "colorama" 69 | version = "0.4.4" 70 | description = "Cross-platform colored terminal text." 71 | category = "dev" 72 | optional = false 73 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 74 | 75 | [[package]] 76 | name = "coverage" 77 | version = "5.5" 78 | description = "Code coverage measurement for Python" 79 | category = "dev" 80 | optional = false 81 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 82 | 83 | [package.dependencies] 84 | toml = {version = "*", optional = true, markers = "extra == \"toml\""} 85 | 86 | [package.extras] 87 | toml = ["toml"] 88 | 89 | [[package]] 90 | name = "dataclasses" 91 | version = "0.8" 92 | description = "A backport of the dataclasses module for Python 3.6" 93 | category = "dev" 94 | optional = false 95 | python-versions = ">=3.6, <3.7" 96 | 97 | [[package]] 98 | name = "flake8" 99 | version = "3.9.2" 100 | description = "the modular source code checker: pep8 pyflakes and co" 101 | category = "dev" 102 | optional = false 103 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 104 | 105 | [package.dependencies] 106 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 107 | mccabe = ">=0.6.0,<0.7.0" 108 | pycodestyle = ">=2.7.0,<2.8.0" 109 | pyflakes = ">=2.3.0,<2.4.0" 110 | 111 | [[package]] 112 | name = "importlib-metadata" 113 | version = "4.3.1" 114 | description = "Read metadata from Python packages" 115 | category = "dev" 116 | optional = false 117 | python-versions = ">=3.6" 118 | 119 | [package.dependencies] 120 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 121 | zipp = ">=0.5" 122 | 123 | [package.extras] 124 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 125 | testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] 126 | 127 | [[package]] 128 | name = "iniconfig" 129 | version = "1.1.1" 130 | description = "iniconfig: brain-dead simple config-ini parsing" 131 | category = "dev" 132 | optional = false 133 | python-versions = "*" 134 | 135 | [[package]] 136 | name = "isort" 137 | version = "5.8.0" 138 | description = "A Python utility / library to sort Python imports." 139 | category = "dev" 140 | optional = false 141 | python-versions = ">=3.6,<4.0" 142 | 143 | [package.extras] 144 | pipfile_deprecated_finder = ["pipreqs", "requirementslib"] 145 | requirements_deprecated_finder = ["pipreqs", "pip-api"] 146 | colors = ["colorama (>=0.4.3,<0.5.0)"] 147 | 148 | [[package]] 149 | name = "js2py" 150 | version = "0.71" 151 | description = "JavaScript to Python Translator & JavaScript interpreter written in 100% pure Python." 152 | category = "main" 153 | optional = false 154 | python-versions = "*" 155 | 156 | [package.dependencies] 157 | pyjsparser = ">=2.5.1" 158 | six = ">=1.10" 159 | tzlocal = ">=1.2" 160 | 161 | [[package]] 162 | name = "mccabe" 163 | version = "0.6.1" 164 | description = "McCabe checker, plugin for flake8" 165 | category = "dev" 166 | optional = false 167 | python-versions = "*" 168 | 169 | [[package]] 170 | name = "mypy" 171 | version = "0.812" 172 | description = "Optional static typing for Python" 173 | category = "dev" 174 | optional = false 175 | python-versions = ">=3.5" 176 | 177 | [package.dependencies] 178 | mypy-extensions = ">=0.4.3,<0.5.0" 179 | typed-ast = ">=1.4.0,<1.5.0" 180 | typing-extensions = ">=3.7.4" 181 | 182 | [package.extras] 183 | dmypy = ["psutil (>=4.0)"] 184 | 185 | [[package]] 186 | name = "mypy-extensions" 187 | version = "0.4.3" 188 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 189 | category = "dev" 190 | optional = false 191 | python-versions = "*" 192 | 193 | [[package]] 194 | name = "packaging" 195 | version = "20.9" 196 | description = "Core utilities for Python packages" 197 | category = "dev" 198 | optional = false 199 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 200 | 201 | [package.dependencies] 202 | pyparsing = ">=2.0.2" 203 | 204 | [[package]] 205 | name = "pathspec" 206 | version = "0.8.1" 207 | description = "Utility library for gitignore style pattern matching of file paths." 208 | category = "dev" 209 | optional = false 210 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 211 | 212 | [[package]] 213 | name = "pluggy" 214 | version = "0.13.1" 215 | description = "plugin and hook calling mechanisms for python" 216 | category = "dev" 217 | optional = false 218 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 219 | 220 | [package.dependencies] 221 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 222 | 223 | [package.extras] 224 | dev = ["pre-commit", "tox"] 225 | 226 | [[package]] 227 | name = "py" 228 | version = "1.10.0" 229 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 230 | category = "dev" 231 | optional = false 232 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 233 | 234 | [[package]] 235 | name = "pycodestyle" 236 | version = "2.7.0" 237 | description = "Python style guide checker" 238 | category = "dev" 239 | optional = false 240 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 241 | 242 | [[package]] 243 | name = "pyflakes" 244 | version = "2.3.1" 245 | description = "passive checker of Python programs" 246 | category = "dev" 247 | optional = false 248 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 249 | 250 | [[package]] 251 | name = "pyjsparser" 252 | version = "2.7.1" 253 | description = "Fast javascript parser (based on esprima.js)" 254 | category = "main" 255 | optional = false 256 | python-versions = "*" 257 | 258 | [[package]] 259 | name = "pyparsing" 260 | version = "2.4.7" 261 | description = "Python parsing module" 262 | category = "dev" 263 | optional = false 264 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 265 | 266 | [[package]] 267 | name = "pytest" 268 | version = "6.2.4" 269 | description = "pytest: simple powerful testing with Python" 270 | category = "dev" 271 | optional = false 272 | python-versions = ">=3.6" 273 | 274 | [package.dependencies] 275 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 276 | attrs = ">=19.2.0" 277 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 278 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 279 | iniconfig = "*" 280 | packaging = "*" 281 | pluggy = ">=0.12,<1.0.0a1" 282 | py = ">=1.8.2" 283 | toml = "*" 284 | 285 | [package.extras] 286 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 287 | 288 | [[package]] 289 | name = "pytest-cov" 290 | version = "2.12.0" 291 | description = "Pytest plugin for measuring coverage." 292 | category = "dev" 293 | optional = false 294 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 295 | 296 | [package.dependencies] 297 | coverage = {version = ">=5.2.1", extras = ["toml"]} 298 | pytest = ">=4.6" 299 | 300 | [package.extras] 301 | testing = ["fields", "hunter", "process-tests (==2.0.2)", "six", "pytest-xdist", "virtualenv"] 302 | 303 | [[package]] 304 | name = "pytz" 305 | version = "2021.1" 306 | description = "World timezone definitions, modern and historical" 307 | category = "main" 308 | optional = false 309 | python-versions = "*" 310 | 311 | [[package]] 312 | name = "regex" 313 | version = "2021.4.4" 314 | description = "Alternative regular expression module, to replace re." 315 | category = "dev" 316 | optional = false 317 | python-versions = "*" 318 | 319 | [[package]] 320 | name = "six" 321 | version = "1.16.0" 322 | description = "Python 2 and 3 compatibility utilities" 323 | category = "main" 324 | optional = false 325 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 326 | 327 | [[package]] 328 | name = "toml" 329 | version = "0.10.2" 330 | description = "Python Library for Tom's Obvious, Minimal Language" 331 | category = "dev" 332 | optional = false 333 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 334 | 335 | [[package]] 336 | name = "typed-ast" 337 | version = "1.4.3" 338 | description = "a fork of Python 2 and 3 ast modules with type comment support" 339 | category = "dev" 340 | optional = false 341 | python-versions = "*" 342 | 343 | [[package]] 344 | name = "typing-extensions" 345 | version = "3.10.0.0" 346 | description = "Backported and Experimental Type Hints for Python 3.5+" 347 | category = "dev" 348 | optional = false 349 | python-versions = "*" 350 | 351 | [[package]] 352 | name = "tzlocal" 353 | version = "2.1" 354 | description = "tzinfo object for the local timezone" 355 | category = "main" 356 | optional = false 357 | python-versions = "*" 358 | 359 | [package.dependencies] 360 | pytz = "*" 361 | 362 | [[package]] 363 | name = "zipp" 364 | version = "3.4.1" 365 | description = "Backport of pathlib-compatible object wrapper for zip files" 366 | category = "dev" 367 | optional = false 368 | python-versions = ">=3.6" 369 | 370 | [package.extras] 371 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 372 | testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] 373 | 374 | [metadata] 375 | lock-version = "1.1" 376 | python-versions = "^3.6.2" 377 | content-hash = "fb41e670a8523bc3b4e0f68dbaf2d3cc6cbdf975d05b44ec4854af257151815a" 378 | 379 | [metadata.files] 380 | appdirs = [ 381 | {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, 382 | {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, 383 | ] 384 | atomicwrites = [ 385 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 386 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 387 | ] 388 | attrs = [ 389 | {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, 390 | {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, 391 | ] 392 | black = [ 393 | {file = "black-21.5b1-py3-none-any.whl", hash = "sha256:8a60071a0043876a4ae96e6c69bd3a127dad2c1ca7c8083573eb82f92705d008"}, 394 | {file = "black-21.5b1.tar.gz", hash = "sha256:23695358dbcb3deafe7f0a3ad89feee5999a46be5fec21f4f1d108be0bcdb3b1"}, 395 | ] 396 | click = [ 397 | {file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"}, 398 | {file = "click-8.0.1.tar.gz", hash = "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a"}, 399 | ] 400 | colorama = [ 401 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 402 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 403 | ] 404 | coverage = [ 405 | {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"}, 406 | {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"}, 407 | {file = "coverage-5.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669"}, 408 | {file = "coverage-5.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90"}, 409 | {file = "coverage-5.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c"}, 410 | {file = "coverage-5.5-cp27-cp27m-win32.whl", hash = "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a"}, 411 | {file = "coverage-5.5-cp27-cp27m-win_amd64.whl", hash = "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82"}, 412 | {file = "coverage-5.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905"}, 413 | {file = "coverage-5.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083"}, 414 | {file = "coverage-5.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5"}, 415 | {file = "coverage-5.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81"}, 416 | {file = "coverage-5.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6"}, 417 | {file = "coverage-5.5-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0"}, 418 | {file = "coverage-5.5-cp310-cp310-win_amd64.whl", hash = "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae"}, 419 | {file = "coverage-5.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb"}, 420 | {file = "coverage-5.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160"}, 421 | {file = "coverage-5.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"}, 422 | {file = "coverage-5.5-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701"}, 423 | {file = "coverage-5.5-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793"}, 424 | {file = "coverage-5.5-cp35-cp35m-win32.whl", hash = "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e"}, 425 | {file = "coverage-5.5-cp35-cp35m-win_amd64.whl", hash = "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3"}, 426 | {file = "coverage-5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066"}, 427 | {file = "coverage-5.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a"}, 428 | {file = "coverage-5.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465"}, 429 | {file = "coverage-5.5-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb"}, 430 | {file = "coverage-5.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821"}, 431 | {file = "coverage-5.5-cp36-cp36m-win32.whl", hash = "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45"}, 432 | {file = "coverage-5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184"}, 433 | {file = "coverage-5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a"}, 434 | {file = "coverage-5.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53"}, 435 | {file = "coverage-5.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d"}, 436 | {file = "coverage-5.5-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638"}, 437 | {file = "coverage-5.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3"}, 438 | {file = "coverage-5.5-cp37-cp37m-win32.whl", hash = "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a"}, 439 | {file = "coverage-5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a"}, 440 | {file = "coverage-5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6"}, 441 | {file = "coverage-5.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2"}, 442 | {file = "coverage-5.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759"}, 443 | {file = "coverage-5.5-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873"}, 444 | {file = "coverage-5.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a"}, 445 | {file = "coverage-5.5-cp38-cp38-win32.whl", hash = "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6"}, 446 | {file = "coverage-5.5-cp38-cp38-win_amd64.whl", hash = "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502"}, 447 | {file = "coverage-5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b"}, 448 | {file = "coverage-5.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529"}, 449 | {file = "coverage-5.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b"}, 450 | {file = "coverage-5.5-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff"}, 451 | {file = "coverage-5.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b"}, 452 | {file = "coverage-5.5-cp39-cp39-win32.whl", hash = "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6"}, 453 | {file = "coverage-5.5-cp39-cp39-win_amd64.whl", hash = "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03"}, 454 | {file = "coverage-5.5-pp36-none-any.whl", hash = "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079"}, 455 | {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, 456 | {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, 457 | ] 458 | dataclasses = [ 459 | {file = "dataclasses-0.8-py3-none-any.whl", hash = "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf"}, 460 | {file = "dataclasses-0.8.tar.gz", hash = "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97"}, 461 | ] 462 | flake8 = [ 463 | {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, 464 | {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, 465 | ] 466 | importlib-metadata = [ 467 | {file = "importlib_metadata-4.3.1-py3-none-any.whl", hash = "sha256:c2e27fa8b6c8b34ebfcd4056ae2ca290e36250d1fbeceec85c1c67c711449fac"}, 468 | {file = "importlib_metadata-4.3.1.tar.gz", hash = "sha256:2d932ea08814f745863fd20172fe7de4794ad74567db78f2377343e24520a5b6"}, 469 | ] 470 | iniconfig = [ 471 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 472 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 473 | ] 474 | isort = [ 475 | {file = "isort-5.8.0-py3-none-any.whl", hash = "sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d"}, 476 | {file = "isort-5.8.0.tar.gz", hash = "sha256:0a943902919f65c5684ac4e0154b1ad4fac6dcaa5d9f3426b732f1c8b5419be6"}, 477 | ] 478 | js2py = [ 479 | {file = "Js2Py-0.71-py3-none-any.whl", hash = "sha256:7f3dfa30c4b8e3ac3edd64c361c950a8c2587a30a86854aeb8c1ffb6f4dd24b4"}, 480 | {file = "Js2Py-0.71.tar.gz", hash = "sha256:a41b1009dd1498ae7d436bfa5ac952a08ca92a4bb9e31dca6e8bb966b49f7fce"}, 481 | ] 482 | mccabe = [ 483 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 484 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 485 | ] 486 | mypy = [ 487 | {file = "mypy-0.812-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a26f8ec704e5a7423c8824d425086705e381b4f1dfdef6e3a1edab7ba174ec49"}, 488 | {file = "mypy-0.812-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:28fb5479c494b1bab244620685e2eb3c3f988d71fd5d64cc753195e8ed53df7c"}, 489 | {file = "mypy-0.812-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:9743c91088d396c1a5a3c9978354b61b0382b4e3c440ce83cf77994a43e8c521"}, 490 | {file = "mypy-0.812-cp35-cp35m-win_amd64.whl", hash = "sha256:d7da2e1d5f558c37d6e8c1246f1aec1e7349e4913d8fb3cb289a35de573fe2eb"}, 491 | {file = "mypy-0.812-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:4eec37370483331d13514c3f55f446fc5248d6373e7029a29ecb7b7494851e7a"}, 492 | {file = "mypy-0.812-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d65cc1df038ef55a99e617431f0553cd77763869eebdf9042403e16089fe746c"}, 493 | {file = "mypy-0.812-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:61a3d5b97955422964be6b3baf05ff2ce7f26f52c85dd88db11d5e03e146a3a6"}, 494 | {file = "mypy-0.812-cp36-cp36m-win_amd64.whl", hash = "sha256:25adde9b862f8f9aac9d2d11971f226bd4c8fbaa89fb76bdadb267ef22d10064"}, 495 | {file = "mypy-0.812-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:552a815579aa1e995f39fd05dde6cd378e191b063f031f2acfe73ce9fb7f9e56"}, 496 | {file = "mypy-0.812-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:499c798053cdebcaa916eef8cd733e5584b5909f789de856b482cd7d069bdad8"}, 497 | {file = "mypy-0.812-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:5873888fff1c7cf5b71efbe80e0e73153fe9212fafdf8e44adfe4c20ec9f82d7"}, 498 | {file = "mypy-0.812-cp37-cp37m-win_amd64.whl", hash = "sha256:9f94aac67a2045ec719ffe6111df543bac7874cee01f41928f6969756e030564"}, 499 | {file = "mypy-0.812-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d23e0ea196702d918b60c8288561e722bf437d82cb7ef2edcd98cfa38905d506"}, 500 | {file = "mypy-0.812-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:674e822aa665b9fd75130c6c5f5ed9564a38c6cea6a6432ce47eafb68ee578c5"}, 501 | {file = "mypy-0.812-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:abf7e0c3cf117c44d9285cc6128856106183938c68fd4944763003decdcfeb66"}, 502 | {file = "mypy-0.812-cp38-cp38-win_amd64.whl", hash = "sha256:0d0a87c0e7e3a9becdfbe936c981d32e5ee0ccda3e0f07e1ef2c3d1a817cf73e"}, 503 | {file = "mypy-0.812-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7ce3175801d0ae5fdfa79b4f0cfed08807af4d075b402b7e294e6aa72af9aa2a"}, 504 | {file = "mypy-0.812-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:b09669bcda124e83708f34a94606e01b614fa71931d356c1f1a5297ba11f110a"}, 505 | {file = "mypy-0.812-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:33f159443db0829d16f0a8d83d94df3109bb6dd801975fe86bacb9bf71628e97"}, 506 | {file = "mypy-0.812-cp39-cp39-win_amd64.whl", hash = "sha256:3f2aca7f68580dc2508289c729bd49ee929a436208d2b2b6aab15745a70a57df"}, 507 | {file = "mypy-0.812-py3-none-any.whl", hash = "sha256:2f9b3407c58347a452fc0736861593e105139b905cca7d097e413453a1d650b4"}, 508 | {file = "mypy-0.812.tar.gz", hash = "sha256:cd07039aa5df222037005b08fbbfd69b3ab0b0bd7a07d7906de75ae52c4e3119"}, 509 | ] 510 | mypy-extensions = [ 511 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 512 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 513 | ] 514 | packaging = [ 515 | {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, 516 | {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, 517 | ] 518 | pathspec = [ 519 | {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, 520 | {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, 521 | ] 522 | pluggy = [ 523 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, 524 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, 525 | ] 526 | py = [ 527 | {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, 528 | {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, 529 | ] 530 | pycodestyle = [ 531 | {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, 532 | {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, 533 | ] 534 | pyflakes = [ 535 | {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, 536 | {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, 537 | ] 538 | pyjsparser = [ 539 | {file = "pyjsparser-2.7.1-py2-none-any.whl", hash = "sha256:2b12842df98d83f65934e0772fa4a5d8b123b3bc79f1af1789172ac70265dd21"}, 540 | {file = "pyjsparser-2.7.1.tar.gz", hash = "sha256:be60da6b778cc5a5296a69d8e7d614f1f870faf94e1b1b6ac591f2ad5d729579"}, 541 | ] 542 | pyparsing = [ 543 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, 544 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, 545 | ] 546 | pytest = [ 547 | {file = "pytest-6.2.4-py3-none-any.whl", hash = "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"}, 548 | {file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"}, 549 | ] 550 | pytest-cov = [ 551 | {file = "pytest-cov-2.12.0.tar.gz", hash = "sha256:8535764137fecce504a49c2b742288e3d34bc09eed298ad65963616cc98fd45e"}, 552 | {file = "pytest_cov-2.12.0-py2.py3-none-any.whl", hash = "sha256:95d4933dcbbacfa377bb60b29801daa30d90c33981ab2a79e9ab4452c165066e"}, 553 | ] 554 | pytz = [ 555 | {file = "pytz-2021.1-py2.py3-none-any.whl", hash = "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"}, 556 | {file = "pytz-2021.1.tar.gz", hash = "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da"}, 557 | ] 558 | regex = [ 559 | {file = "regex-2021.4.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:619d71c59a78b84d7f18891fe914446d07edd48dc8328c8e149cbe0929b4e000"}, 560 | {file = "regex-2021.4.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:47bf5bf60cf04d72bf6055ae5927a0bd9016096bf3d742fa50d9bf9f45aa0711"}, 561 | {file = "regex-2021.4.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:281d2fd05555079448537fe108d79eb031b403dac622621c78944c235f3fcf11"}, 562 | {file = "regex-2021.4.4-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:bd28bc2e3a772acbb07787c6308e00d9626ff89e3bfcdebe87fa5afbfdedf968"}, 563 | {file = "regex-2021.4.4-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7c2a1af393fcc09e898beba5dd59196edaa3116191cc7257f9224beaed3e1aa0"}, 564 | {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c38c71df845e2aabb7fb0b920d11a1b5ac8526005e533a8920aea97efb8ec6a4"}, 565 | {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:96fcd1888ab4d03adfc9303a7b3c0bd78c5412b2bfbe76db5b56d9eae004907a"}, 566 | {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:ade17eb5d643b7fead300a1641e9f45401c98eee23763e9ed66a43f92f20b4a7"}, 567 | {file = "regex-2021.4.4-cp36-cp36m-win32.whl", hash = "sha256:e8e5b509d5c2ff12f8418006d5a90e9436766133b564db0abaec92fd27fcee29"}, 568 | {file = "regex-2021.4.4-cp36-cp36m-win_amd64.whl", hash = "sha256:11d773d75fa650cd36f68d7ca936e3c7afaae41b863b8c387a22aaa78d3c5c79"}, 569 | {file = "regex-2021.4.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d3029c340cfbb3ac0a71798100ccc13b97dddf373a4ae56b6a72cf70dfd53bc8"}, 570 | {file = "regex-2021.4.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:18c071c3eb09c30a264879f0d310d37fe5d3a3111662438889ae2eb6fc570c31"}, 571 | {file = "regex-2021.4.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:4c557a7b470908b1712fe27fb1ef20772b78079808c87d20a90d051660b1d69a"}, 572 | {file = "regex-2021.4.4-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:01afaf2ec48e196ba91b37451aa353cb7eda77efe518e481707e0515025f0cd5"}, 573 | {file = "regex-2021.4.4-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:3a9cd17e6e5c7eb328517969e0cb0c3d31fd329298dd0c04af99ebf42e904f82"}, 574 | {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:90f11ff637fe8798933fb29f5ae1148c978cccb0452005bf4c69e13db951e765"}, 575 | {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:919859aa909429fb5aa9cf8807f6045592c85ef56fdd30a9a3747e513db2536e"}, 576 | {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:339456e7d8c06dd36a22e451d58ef72cef293112b559010db3d054d5560ef439"}, 577 | {file = "regex-2021.4.4-cp37-cp37m-win32.whl", hash = "sha256:67bdb9702427ceddc6ef3dc382455e90f785af4c13d495f9626861763ee13f9d"}, 578 | {file = "regex-2021.4.4-cp37-cp37m-win_amd64.whl", hash = "sha256:32e65442138b7b76dd8173ffa2cf67356b7bc1768851dded39a7a13bf9223da3"}, 579 | {file = "regex-2021.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1e1c20e29358165242928c2de1482fb2cf4ea54a6a6dea2bd7a0e0d8ee321500"}, 580 | {file = "regex-2021.4.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:314d66636c494ed9c148a42731b3834496cc9a2c4251b1661e40936814542b14"}, 581 | {file = "regex-2021.4.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6d1b01031dedf2503631d0903cb563743f397ccaf6607a5e3b19a3d76fc10480"}, 582 | {file = "regex-2021.4.4-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:741a9647fcf2e45f3a1cf0e24f5e17febf3efe8d4ba1281dcc3aa0459ef424dc"}, 583 | {file = "regex-2021.4.4-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:4c46e22a0933dd783467cf32b3516299fb98cfebd895817d685130cc50cd1093"}, 584 | {file = "regex-2021.4.4-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:e512d8ef5ad7b898cdb2d8ee1cb09a8339e4f8be706d27eaa180c2f177248a10"}, 585 | {file = "regex-2021.4.4-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:980d7be47c84979d9136328d882f67ec5e50008681d94ecc8afa8a65ed1f4a6f"}, 586 | {file = "regex-2021.4.4-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:ce15b6d103daff8e9fee13cf7f0add05245a05d866e73926c358e871221eae87"}, 587 | {file = "regex-2021.4.4-cp38-cp38-win32.whl", hash = "sha256:a91aa8619b23b79bcbeb37abe286f2f408d2f2d6f29a17237afda55bb54e7aac"}, 588 | {file = "regex-2021.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:c0502c0fadef0d23b128605d69b58edb2c681c25d44574fc673b0e52dce71ee2"}, 589 | {file = "regex-2021.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:598585c9f0af8374c28edd609eb291b5726d7cbce16be6a8b95aa074d252ee17"}, 590 | {file = "regex-2021.4.4-cp39-cp39-manylinux1_i686.whl", hash = "sha256:ee54ff27bf0afaf4c3b3a62bcd016c12c3fdb4ec4f413391a90bd38bc3624605"}, 591 | {file = "regex-2021.4.4-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7d9884d86dd4dd489e981d94a65cd30d6f07203d90e98f6f657f05170f6324c9"}, 592 | {file = "regex-2021.4.4-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:bf5824bfac591ddb2c1f0a5f4ab72da28994548c708d2191e3b87dd207eb3ad7"}, 593 | {file = "regex-2021.4.4-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:563085e55b0d4fb8f746f6a335893bda5c2cef43b2f0258fe1020ab1dd874df8"}, 594 | {file = "regex-2021.4.4-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9c3db21af35e3b3c05764461b262d6f05bbca08a71a7849fd79d47ba7bc33ed"}, 595 | {file = "regex-2021.4.4-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:3916d08be28a1149fb97f7728fca1f7c15d309a9f9682d89d79db75d5e52091c"}, 596 | {file = "regex-2021.4.4-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:fd45ff9293d9274c5008a2054ecef86a9bfe819a67c7be1afb65e69b405b3042"}, 597 | {file = "regex-2021.4.4-cp39-cp39-win32.whl", hash = "sha256:fa4537fb4a98fe8fde99626e4681cc644bdcf2a795038533f9f711513a862ae6"}, 598 | {file = "regex-2021.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:97f29f57d5b84e73fbaf99ab3e26134e6687348e95ef6b48cfd2c06807005a07"}, 599 | {file = "regex-2021.4.4.tar.gz", hash = "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb"}, 600 | ] 601 | six = [ 602 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 603 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 604 | ] 605 | toml = [ 606 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 607 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 608 | ] 609 | typed-ast = [ 610 | {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"}, 611 | {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"}, 612 | {file = "typed_ast-1.4.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528"}, 613 | {file = "typed_ast-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428"}, 614 | {file = "typed_ast-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3"}, 615 | {file = "typed_ast-1.4.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f"}, 616 | {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341"}, 617 | {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace"}, 618 | {file = "typed_ast-1.4.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f"}, 619 | {file = "typed_ast-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363"}, 620 | {file = "typed_ast-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7"}, 621 | {file = "typed_ast-1.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266"}, 622 | {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e"}, 623 | {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04"}, 624 | {file = "typed_ast-1.4.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899"}, 625 | {file = "typed_ast-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c"}, 626 | {file = "typed_ast-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805"}, 627 | {file = "typed_ast-1.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a"}, 628 | {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff"}, 629 | {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41"}, 630 | {file = "typed_ast-1.4.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39"}, 631 | {file = "typed_ast-1.4.3-cp38-cp38-win32.whl", hash = "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927"}, 632 | {file = "typed_ast-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40"}, 633 | {file = "typed_ast-1.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3"}, 634 | {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4"}, 635 | {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0"}, 636 | {file = "typed_ast-1.4.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3"}, 637 | {file = "typed_ast-1.4.3-cp39-cp39-win32.whl", hash = "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808"}, 638 | {file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"}, 639 | {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"}, 640 | ] 641 | typing-extensions = [ 642 | {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, 643 | {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, 644 | {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, 645 | ] 646 | tzlocal = [ 647 | {file = "tzlocal-2.1-py2.py3-none-any.whl", hash = "sha256:e2cb6c6b5b604af38597403e9852872d7f534962ae2954c7f35efcb1ccacf4a4"}, 648 | {file = "tzlocal-2.1.tar.gz", hash = "sha256:643c97c5294aedc737780a49d9df30889321cbe1204eac2c2ec6134035a92e44"}, 649 | ] 650 | zipp = [ 651 | {file = "zipp-3.4.1-py3-none-any.whl", hash = "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098"}, 652 | {file = "zipp-3.4.1.tar.gz", hash = "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76"}, 653 | ] 654 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "xstate" 3 | version = "0.0.1" 4 | description = "XState for Python" 5 | readme = "README.md" 6 | authors = ["David Khourshid "] 7 | license = "MIT" 8 | keywords = ["Answer Set Programming", "wrapper", "clingo"] 9 | classifiers = [ 10 | "License :: OSI Approved :: MIT License", 11 | "Programming Language :: Python :: 3", 12 | "Programming Language :: Python :: 3.6", 13 | "Programming Language :: Python :: 3.7", 14 | "Programming Language :: Python :: 3.8", 15 | "Programming Language :: Python :: 3.9" 16 | ] 17 | packages = [ 18 | { include = "xstate" }, 19 | ] 20 | 21 | [tool.poetry.urls] 22 | docs = "https://github.com/davidkpiano/xstate-python" 23 | 24 | [tool.poetry.dependencies] 25 | python = "^3.6.2" 26 | Js2Py = "^0.71" 27 | 28 | [tool.poetry.dev-dependencies] 29 | pytest-cov = "^2.12.0" 30 | black = "^21.5b1" 31 | isort = "^5.8.0" 32 | flake8 = "^3.9.2" 33 | mypy = "^0.812" 34 | 35 | [tool.isort] 36 | profile = "black" 37 | 38 | [tool.pytest.ini_options] 39 | minversion = "6.0" 40 | addopts = "-ra -q" 41 | testpaths = [ 42 | "tests", 43 | ] 44 | 45 | [tool.coverage.run] 46 | source = ["xstate"] 47 | branch = true 48 | 49 | [tool.coverage.report] 50 | exclude_lines = [ 51 | "raise NotImplementedError" 52 | ] 53 | fail_under = 50 54 | show_missing = true 55 | 56 | [build-system] 57 | requires = ["poetry-core>=1.0.0"] 58 | build-backend = "poetry.core.masonry.api" 59 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statelyai/xstate-python/1de44461d4fd9aef06f0e4cee00b55731ffa624a/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_actions.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from xstate.machine import Machine 4 | 5 | 6 | def test_action(): 7 | entry_mock = Mock() 8 | exit_mock = Mock() 9 | 10 | machine = Machine( 11 | { 12 | "id": "machine", 13 | "initial": "on", 14 | "states": { 15 | "on": { 16 | "on": {"TOGGLE": "off"}, 17 | "entry": [{"type": "entry_action"}], 18 | "exit": [{"type": "exit_action"}], 19 | }, 20 | "off": {"on": {"TOGGLE": "on"}}, 21 | }, 22 | }, 23 | actions={ 24 | "entry_action": entry_mock, 25 | "exit_action": exit_mock, 26 | }, 27 | ) 28 | 29 | state = machine.initial_state 30 | assert state.value == "on" 31 | 32 | for action in state.actions: 33 | action() 34 | 35 | entry_mock.assert_called_with() 36 | assert entry_mock.call_count == 1 37 | assert exit_mock.call_count == 0 38 | 39 | # ------------------------ 40 | 41 | state = machine.transition(state, "TOGGLE") 42 | 43 | assert state.value == "off" 44 | 45 | for action in state.actions: 46 | action() 47 | 48 | exit_mock.assert_called_with() 49 | assert entry_mock.call_count == 1 50 | assert exit_mock.call_count == 1 51 | 52 | 53 | def test_entry_action_inline(): 54 | mock = Mock() 55 | 56 | machine = Machine( 57 | { 58 | "id": "machine", 59 | "initial": "on", 60 | "states": { 61 | "on": {"on": {"TOGGLE": "off"}, "entry": [lambda: mock()]}, 62 | "off": {"on": {"TOGGLE": "on"}}, 63 | }, 64 | } 65 | ) 66 | 67 | state = machine.initial_state 68 | assert state.value == "on" 69 | 70 | for action in state.actions: 71 | action() 72 | 73 | mock.assert_called_with() 74 | assert mock.call_count == 1 75 | 76 | 77 | def test_exit_action_inline(): 78 | mock = Mock() 79 | 80 | machine = Machine( 81 | { 82 | "id": "machine", 83 | "initial": "on", 84 | "states": { 85 | "on": {"on": {"TOGGLE": "off"}, "exit": [lambda: mock()]}, 86 | "off": {"on": {"TOGGLE": "on"}}, 87 | }, 88 | } 89 | ) 90 | 91 | state = machine.initial_state 92 | assert state.value == "on" 93 | 94 | for action in state.actions: 95 | action() 96 | 97 | assert mock.call_count == 0 98 | 99 | state = machine.transition(state, "TOGGLE") 100 | 101 | for action in state.actions: 102 | action() 103 | 104 | assert mock.call_count == 1 105 | -------------------------------------------------------------------------------- /tests/test_algorithm.py: -------------------------------------------------------------------------------- 1 | from xstate.algorithm import is_parallel_state 2 | from xstate.machine import Machine 3 | 4 | 5 | def test_is_parallel_state(): 6 | machine = Machine( 7 | {"id": "test", "initial": "foo", "states": {"foo": {"type": "parallel"}}} 8 | ) 9 | 10 | foo_state_node = machine._get_by_id("test.foo") 11 | 12 | assert is_parallel_state(foo_state_node) is True 13 | 14 | 15 | def test_is_not_parallel_state(): 16 | machine = Machine( 17 | {"id": "test", "initial": "foo", "states": {"foo": {"type": "atomic"}}} 18 | ) 19 | 20 | foo_state_node = machine._get_by_id("test.foo") 21 | 22 | assert is_parallel_state(foo_state_node) is False 23 | -------------------------------------------------------------------------------- /tests/test_machine.py: -------------------------------------------------------------------------------- 1 | from xstate.machine import Machine 2 | 3 | lights = Machine( 4 | { 5 | "id": "lights", 6 | "initial": "green", 7 | "states": { 8 | "green": {"on": {"TIMER": "yellow"}, "entry": [{"type": "enterGreen"}]}, 9 | "yellow": {"on": {"TIMER": "red"}}, 10 | "red": { 11 | "initial": "walk", 12 | "states": { 13 | "walk": {"on": {"COUNTDOWN": "wait"}}, 14 | "wait": {"on": {"COUNTDOWN": "stop"}}, 15 | "stop": {"on": {"TIMEOUT": "timeout"}}, 16 | "timeout": {"type": "final"}, 17 | }, 18 | "onDone": "green", 19 | }, 20 | }, 21 | } 22 | ) 23 | 24 | 25 | def test_machine(): 26 | yellow_state = lights.transition(lights.initial_state, "TIMER") 27 | 28 | assert yellow_state.value == "yellow" 29 | 30 | red_state = lights.transition(yellow_state, "TIMER") 31 | 32 | assert red_state.value == {"red": "walk"} 33 | 34 | 35 | def test_machine_initial_state(): 36 | 37 | assert lights.initial_state.value == "green" 38 | 39 | 40 | def test_final_state(): 41 | red_stop_state = lights.state_from({"red": "stop"}) 42 | 43 | red_timeout_state = lights.transition(red_stop_state, "TIMEOUT") 44 | 45 | assert red_timeout_state.value == "green" 46 | 47 | 48 | 49 | fan = Machine( 50 | { 51 | "id": "fan", 52 | "initial": "fanOff", 53 | "states": { 54 | "fanOff": { 55 | "on": { 56 | "POWER": "#fan.fanOn.hist", 57 | "HIGH_POWER": "fanOn.highPowerHist", 58 | }, 59 | }, 60 | "fanOn": { 61 | "initial": "first", 62 | "states": { 63 | "first": {"on": {"SWITCH": "second"}}, 64 | "second": {"on": {"SWITCH": "third"}}, 65 | "third": {}, 66 | "hist": {"type": "history", "history": "shallow"}, 67 | "highPowerHist": {"type": "history", "target": "third"}, 68 | }, 69 | "on": {"POWER": "fanOff"}, 70 | }, 71 | }, 72 | } 73 | ) 74 | 75 | 76 | def test_history_state(): 77 | on_state = fan.transition(fan.initial_state, "POWER") 78 | 79 | assert on_state.value == "fanOn.first" 80 | 81 | on_second_state = fan.transition(on_state, "SWITCH") 82 | 83 | assert on_second_state.value == "fanOn.second" 84 | 85 | off_state = fan.transition(on_second_state, "POWER") 86 | 87 | assert off_state.value == "fanOff" 88 | 89 | on_second_state = fan.transition(off_state, "POWER") 90 | 91 | assert on_second_state.value == "fanOn.second" 92 | 93 | 94 | 95 | 96 | def test_top_level_final(): 97 | final = Machine( 98 | { 99 | "id": "final", 100 | "initial": "start", 101 | "states": { 102 | "start": {"on": {"FINISH": "end"}}, 103 | "end": {"type": "final"}, 104 | }, 105 | } 106 | ) 107 | 108 | end_state = final.transition(final.initial_state, "FINISH") 109 | 110 | assert end_state.value == "end" 111 | -------------------------------------------------------------------------------- /tests/test_scxml.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pprint import PrettyPrinter 3 | from typing import Dict, List 4 | 5 | import pytest 6 | 7 | from xstate.scxml import scxml_to_machine 8 | 9 | pp = PrettyPrinter(indent=2) 10 | 11 | test_dir = "test-framework/test" 12 | 13 | test_groups: Dict[str, List[str]] = { 14 | "actionSend": [ 15 | "send1", 16 | "send2", 17 | "send3", 18 | "send4", 19 | "send4b", 20 | "send7", 21 | "send7b", 22 | "send8", 23 | "send8b", 24 | "send9", 25 | ], 26 | # "assign": ["assign_invalid", "assign_obj_literal"], 27 | "basic": ["basic0", "basic1", "basic2"], 28 | "cond-js": ["test0", "test1", "test2", "TestConditionalTransition"], 29 | "default-initial-state": ["initial1", "initial2"], 30 | "documentOrder": ["documentOrder0"], 31 | "hierarchy": ["hier0", "hier1", "hier2"], 32 | "hierarchy+documentOrder": ["test0", "test1"], 33 | "parallel": ["test0", "test1", "test2", "test3"], 34 | } 35 | 36 | test_files = [ 37 | ( 38 | f"{test_dir}/{test_group}/{test_name}.scxml", 39 | f"{test_dir}/{test_group}/{test_name}.json", 40 | ) 41 | for test_group, test_names in test_groups.items() 42 | for test_name in test_names 43 | ] 44 | 45 | 46 | @pytest.mark.parametrize("scxml_source,scxml_test_source", test_files) 47 | def test_scxml(scxml_source, scxml_test_source): 48 | machine = scxml_to_machine(scxml_source) 49 | 50 | try: 51 | state = machine.initial_state 52 | 53 | with open(scxml_test_source) as scxml_test_file: 54 | scxml_test = json.load(scxml_test_file) 55 | 56 | for event_test in scxml_test.get("events"): 57 | event_to_send = event_test.get("event") 58 | event_name = event_to_send.get("name") 59 | next_configuration = event_test.get("nextConfiguration") 60 | 61 | state = machine.transition(state, event_name) 62 | 63 | assert sorted( 64 | [sn.key for sn in state.configuration if sn.type == "atomic"] 65 | ) == sorted(next_configuration) 66 | except Exception: 67 | pp.pprint(machine.config) 68 | raise 69 | -------------------------------------------------------------------------------- /viz.py: -------------------------------------------------------------------------------- 1 | # https://plantweb.readthedocs.io/#python-api 2 | # https://plantuml.com/state-diagram 3 | from plantweb.render import render 4 | 5 | from xstate.machine import Machine 6 | 7 | # just a test 8 | simple_machine = Machine( 9 | { 10 | "id": "simple", 11 | "initial": "green", 12 | "states": { 13 | "green": {"on": {"JENNY_EVENT": "yellow"}}, 14 | "yellow": {"on": {"NEXT_EVENT": "red"}}, 15 | "red": {"on": {"NEXT_EVENT": "green"}}, 16 | }, 17 | } 18 | ) 19 | 20 | 21 | def state_node_to_viz(state_node): 22 | result = "" 23 | 24 | if not state_node.parent: 25 | result += """ 26 | @startuml 27 | 28 | skinparam state { 29 | ArrowColor Black 30 | ArrowThickness 2 31 | BorderThickness 5 32 | BorderColor Blue 33 | BackgroundColor White 34 | } 35 | """ 36 | 37 | if state_node.initial: 38 | initial_state = state_node.initial.target[0].id 39 | initial_string = f"[*] --> {initial_state}\n" 40 | result += initial_string 41 | 42 | transitions = state_node.transitions 43 | for t in transitions: 44 | t_string = f"{t.source.id} --> {t.target[0].id} : {t.event}\n" 45 | result += t_string 46 | 47 | children = state_node.states.values() 48 | for c in children: 49 | child_string = state_node_to_viz(c) 50 | result += child_string 51 | 52 | if not state_node.parent: 53 | result += "@enduml\n" 54 | 55 | return result 56 | 57 | 58 | if __name__ == "__main__": 59 | output = render( 60 | state_node_to_viz(simple_machine.root), 61 | engine="plantuml", 62 | format="svg", 63 | cacheopts={"use_cache": False}, 64 | )[0] 65 | 66 | file1 = open("test.svg", "w") 67 | file1.write(output.decode("utf-8")) 68 | file1.close() 69 | -------------------------------------------------------------------------------- /xstate/__init__.py: -------------------------------------------------------------------------------- 1 | from xstate.machine import Machine # noqa 2 | -------------------------------------------------------------------------------- /xstate/action.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Dict, Optional 2 | 3 | 4 | def not_implemented(): 5 | pass 6 | 7 | 8 | class Action: 9 | type: str 10 | exec: Callable[[], None] 11 | data: Dict[str, Any] 12 | 13 | def __init__( 14 | self, 15 | type: str, 16 | exec: Optional[Callable[[], None]] = not_implemented, 17 | data: Dict[str, Any] = {}, 18 | ): 19 | self.type = type 20 | self.exec = exec 21 | self.data = data 22 | 23 | def __repr__(self): 24 | return repr({"type": self.type}) 25 | -------------------------------------------------------------------------------- /xstate/algorithm.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Optional, Set, Tuple, Union 2 | 3 | from xstate.action import Action 4 | from xstate.event import Event 5 | from xstate.state_node import StateNode 6 | from xstate.transition import Transition 7 | 8 | HistoryValue = Dict[str, Set[StateNode]] 9 | 10 | 11 | def compute_entry_set( 12 | transitions: List[Transition], 13 | states_to_enter: Set[StateNode], 14 | states_for_default_entry: Set[StateNode], 15 | default_history_content: Dict, 16 | history_value: HistoryValue, 17 | ): 18 | for t in transitions: 19 | for s in t.target: 20 | add_descendent_states_to_enter( 21 | s, 22 | states_to_enter=states_to_enter, 23 | states_for_default_entry=states_for_default_entry, 24 | default_history_content=default_history_content, 25 | history_value=history_value, 26 | ) 27 | ancestor = get_transition_domain(t, history_value=history_value) 28 | for s in get_effective_target_states(t, history_value=history_value): 29 | add_ancestor_states_to_enter( 30 | s, 31 | ancestor=ancestor, 32 | states_to_enter=states_to_enter, 33 | states_for_default_entry=states_for_default_entry, 34 | default_history_content=default_history_content, 35 | history_value=history_value, 36 | ) 37 | 38 | 39 | def add_descendent_states_to_enter( # noqa C901 too complex. TODO: simplify function 40 | state: StateNode, 41 | states_to_enter: Set[StateNode], 42 | states_for_default_entry: Set[StateNode], 43 | default_history_content: Dict, 44 | history_value: HistoryValue, 45 | ): 46 | if is_history_state(state): 47 | if history_value.get(state.id): 48 | for s in history_value.get(state.id): 49 | add_descendent_states_to_enter( 50 | s, 51 | states_to_enter=states_to_enter, 52 | states_for_default_entry=states_for_default_entry, 53 | default_history_content=default_history_content, 54 | history_value=history_value, 55 | ) 56 | for s in history_value.get(state.id): 57 | add_ancestor_states_to_enter( 58 | s, 59 | ancestor=s.parent, 60 | states_to_enter=states_to_enter, 61 | states_for_default_entry=states_for_default_entry, 62 | default_history_content=default_history_content, 63 | history_value=history_value, 64 | ) 65 | else: 66 | default_history_content[state.parent.id] = state.transition.content 67 | # for s in state.transition.target: 68 | # add_descendent_states_to_enter( 69 | # s, 70 | # states_to_enter=states_to_enter, 71 | # states_for_default_entry=states_for_default_entry, 72 | # default_history_content=default_history_content, 73 | # history_value=history_value, 74 | # ) 75 | # for s in state.transition.target: 76 | # add_ancestor_states_to_enter( 77 | # s, 78 | # ancestor=s.parent, 79 | # states_to_enter=states_to_enter, 80 | # states_for_default_entry=states_for_default_entry, 81 | # default_history_content=default_history_content, 82 | # history_value=history_value, 83 | # ) 84 | else: 85 | states_to_enter.add(state) 86 | if is_compound_state(state): 87 | states_for_default_entry.add(state) 88 | for s in state.initial.target: 89 | add_descendent_states_to_enter( 90 | s, 91 | states_to_enter=states_to_enter, 92 | states_for_default_entry=states_for_default_entry, 93 | default_history_content=default_history_content, 94 | history_value=history_value, 95 | ) 96 | for s in state.initial.target: 97 | add_ancestor_states_to_enter( 98 | s, 99 | ancestor=s.parent, 100 | states_to_enter=states_to_enter, 101 | states_for_default_entry=states_for_default_entry, 102 | default_history_content=default_history_content, 103 | history_value=history_value, 104 | ) 105 | else: 106 | if is_parallel_state(state): 107 | for child in get_child_states(state): 108 | if not any([is_descendent(s, child) for s in states_to_enter]): 109 | add_descendent_states_to_enter( 110 | child, 111 | states_to_enter=states_to_enter, 112 | states_for_default_entry=states_for_default_entry, 113 | default_history_content=default_history_content, 114 | history_value=history_value, 115 | ) 116 | 117 | 118 | def is_history_state(state: StateNode) -> bool: 119 | return state.type == "history" 120 | 121 | 122 | def is_compound_state(state: StateNode) -> bool: 123 | return state.type == "compound" 124 | 125 | 126 | def is_atomic_state(state: StateNode) -> bool: 127 | return any( 128 | state.type == state_type for state_type in ["atomic", "final", "history"] 129 | ) 130 | 131 | 132 | def is_descendent(state: StateNode, state2: StateNode) -> bool: 133 | marker = state 134 | 135 | while marker.parent and marker.parent != state2: 136 | marker = marker.parent 137 | 138 | return marker.parent == state2 139 | 140 | 141 | def get_transition_domain( 142 | transition: Transition, history_value: HistoryValue 143 | ) -> StateNode: 144 | tstates = get_effective_target_states(transition, history_value=history_value) 145 | if not tstates: 146 | return None 147 | elif ( 148 | transition.type == "internal" 149 | and is_compound_state(transition.source) 150 | and all([is_descendent(s, state2=transition.source) for s in tstates]) 151 | ): 152 | return transition.source 153 | else: 154 | return find_lcca([transition.source] + list(tstates)) 155 | 156 | 157 | def find_lcca(state_list: List[StateNode]): 158 | for anc in get_proper_ancestors(state_list[0], state2=None): 159 | if all([is_descendent(s, state2=anc) for s in state_list[1:]]): 160 | return anc 161 | 162 | 163 | def get_effective_target_states( 164 | transition: Transition, history_value: HistoryValue 165 | ) -> Set[StateNode]: 166 | targets: Set[StateNode] = set() 167 | 168 | for s in transition.target: 169 | if is_history_state(s): 170 | if history_value.get(s.id): 171 | targets.update(history_value.get(s.id)) 172 | else: 173 | targets.update( 174 | get_effective_target_states( 175 | s.transition, history_value=history_value 176 | ) 177 | ) 178 | else: 179 | targets.add(s) 180 | 181 | return targets 182 | 183 | 184 | def add_ancestor_states_to_enter( 185 | state: StateNode, 186 | ancestor: StateNode, 187 | states_to_enter: Set[StateNode], 188 | states_for_default_entry: Set[StateNode], 189 | default_history_content: Dict, 190 | history_value: HistoryValue, 191 | ): 192 | for anc in get_proper_ancestors(state, state2=ancestor): 193 | states_to_enter.add(anc) 194 | if is_parallel_state(anc): 195 | for child in get_child_states(anc): 196 | if not any([is_descendent(s, state2=child) for s in states_to_enter]): 197 | add_descendent_states_to_enter( 198 | child, 199 | states_to_enter=states_to_enter, 200 | states_for_default_entry=states_for_default_entry, 201 | default_history_content=default_history_content, 202 | history_value=history_value, 203 | ) 204 | 205 | 206 | def get_proper_ancestors( 207 | state1: StateNode, state2: Optional[StateNode] 208 | ) -> List[StateNode]: 209 | ancestors: List[StateNode] = [] 210 | marker = state1.parent 211 | while marker and marker != state2: 212 | ancestors.append(marker) 213 | marker = marker.parent 214 | 215 | return ancestors 216 | 217 | 218 | def is_final_state(state_node: StateNode) -> bool: 219 | return state_node.type == "final" 220 | 221 | 222 | def is_parallel_state(state_node: StateNode) -> bool: 223 | # should return whether state_node.type is parallel 224 | if state_node.type == "parallel": 225 | return True 226 | else: 227 | return False 228 | 229 | 230 | def get_child_states(state_node: StateNode) -> List[StateNode]: 231 | return [state_node.states.get(key) for key in state_node.states.keys()] 232 | 233 | 234 | def is_in_final_state(state: StateNode, configuration: Set[StateNode]) -> bool: 235 | if is_compound_state(state): 236 | return any( 237 | [ 238 | is_final_state(s) and (s in configuration) 239 | for s in get_child_states(state) 240 | ] 241 | ) 242 | elif is_parallel_state(state): 243 | return all(is_in_final_state(s, configuration) for s in get_child_states(state)) 244 | else: 245 | return False 246 | 247 | 248 | def enter_states( 249 | enabled_transitions: List[Transition], 250 | configuration: Set[StateNode], 251 | states_to_invoke: Set[StateNode], 252 | history_value: HistoryValue, 253 | actions: List[Action], 254 | internal_queue: List[Event], 255 | ) -> Tuple[Set[StateNode], List[Action], List[Event]]: 256 | states_to_enter: Set[StateNode] = set() 257 | states_for_default_entry: Set[StateNode] = set() 258 | 259 | default_history_content = {} 260 | 261 | compute_entry_set( 262 | enabled_transitions, 263 | states_to_enter=states_to_enter, 264 | states_for_default_entry=states_for_default_entry, 265 | default_history_content=default_history_content, 266 | history_value=history_value, 267 | ) 268 | 269 | # TODO: sort 270 | for s in list(states_to_enter): 271 | configuration.add(s) 272 | states_to_invoke.add(s) 273 | 274 | # if binding == "late" and s.isFirstEntry: 275 | # initializeDataModel(datamodel.s,doc.s) 276 | # s.isFirstEntry = false 277 | 278 | # TODO: sort 279 | for action in s.entry: 280 | execute_content(action, actions=actions, internal_queue=internal_queue) 281 | if s in states_for_default_entry: 282 | # executeContent(s.initial.transition) 283 | continue 284 | if default_history_content.get(s.id, None) is not None: 285 | # executeContent(defaultHistoryContent[s.id]) 286 | continue 287 | if is_final_state(s): 288 | parent = s.parent 289 | grandparent = parent.parent 290 | internal_queue.append(Event(f"done.state.{parent.id}", s.donedata)) 291 | 292 | if grandparent and is_parallel_state(grandparent): 293 | if all( 294 | is_in_final_state(parent_state, configuration) 295 | for parent_state in get_child_states(grandparent) 296 | ): 297 | internal_queue.append(Event(f"done.state.{grandparent.id}")) 298 | 299 | return (configuration, actions, internal_queue) 300 | 301 | 302 | def exit_states( 303 | enabled_transitions: List[Transition], 304 | configuration: Set[StateNode], 305 | states_to_invoke: Set[StateNode], 306 | history_value: HistoryValue, 307 | actions: List[Action], 308 | internal_queue: List[Event], 309 | ): 310 | states_to_exit = compute_exit_set( 311 | enabled_transitions, configuration=configuration, history_value=history_value 312 | ) 313 | for s in states_to_exit: 314 | states_to_invoke.discard(s) 315 | # statesToExit = statesToExit.toList().sort(exitOrder) 316 | # for s in states_to_exit: 317 | # for h in s.history 318 | for s in states_to_exit: 319 | for action in s.exit: 320 | execute_content(action, actions=actions, internal_queue=internal_queue) 321 | # for inv in s.invoke: 322 | # cancelInvoke(inv) 323 | configuration.remove(s) 324 | 325 | return ( 326 | configuration, 327 | actions, 328 | ) 329 | 330 | 331 | def compute_exit_set( 332 | enabled_transitions: List[Transition], 333 | configuration: Set[StateNode], 334 | history_value: HistoryValue, 335 | ) -> Set[StateNode]: 336 | states_to_exit: Set[StateNode] = set() 337 | for t in enabled_transitions: 338 | if t.target: 339 | domain = get_transition_domain(t, history_value=history_value) 340 | for s in configuration: 341 | if is_descendent(s, state2=domain): 342 | states_to_exit.add(s) 343 | 344 | return states_to_exit 345 | 346 | 347 | def name_match(event: str, specific_event: str) -> bool: 348 | return event == specific_event 349 | 350 | 351 | def condition_match(transition: Transition) -> bool: 352 | return transition.cond() if transition.cond else True 353 | 354 | 355 | def select_transitions(event: Event, configuration: Set[StateNode]): 356 | enabled_transitions: Set[Transition] = set() 357 | atomic_states = [s for s in configuration if is_atomic_state(s)] 358 | for state_node in atomic_states: 359 | break_loop = False 360 | for s in [state_node] + get_proper_ancestors(state_node, None): 361 | if break_loop: 362 | break 363 | for t in sorted(s.transitions, key=lambda t: t.order): 364 | if t.event and name_match(t.event, event.name) and condition_match(t): 365 | enabled_transitions.add(t) 366 | break_loop = True 367 | 368 | enabled_transitions = remove_conflicting_transitions( 369 | enabled_transitions, configuration=configuration, history_value={} # TODO 370 | ) 371 | 372 | return enabled_transitions 373 | 374 | 375 | def select_eventless_transitions(configuration: Set[StateNode]): 376 | enabled_transitions: Set[Transition] = set() 377 | atomic_states = filter(is_atomic_state, configuration) 378 | 379 | loop = True 380 | for state in atomic_states: 381 | if not loop: 382 | break 383 | for s in [state] + get_proper_ancestors(state, None): 384 | for t in sorted(s.transitions, key=lambda t: t.order): 385 | if not t.event and condition_match(t): 386 | enabled_transitions.add(t) 387 | loop = False 388 | 389 | enabled_transitions = remove_conflicting_transitions( 390 | enabled_transitions=enabled_transitions, 391 | configuration=configuration, 392 | history_value={}, # TODO 393 | ) 394 | return enabled_transitions 395 | 396 | 397 | def remove_conflicting_transitions( 398 | enabled_transitions: Set[Transition], 399 | configuration: Set[StateNode], 400 | history_value: HistoryValue, 401 | ): 402 | enabled_transitions = sorted(enabled_transitions, key=lambda t: t.order) 403 | 404 | filtered_transitions: Set[Transition] = set() 405 | for t1 in enabled_transitions: 406 | t1_preempted = False 407 | transitions_to_remove: Set[Transition] = set() 408 | for t2 in filtered_transitions: 409 | t1_exit_set = compute_exit_set( 410 | enabled_transitions=[t1], 411 | configuration=configuration, 412 | history_value=history_value, 413 | ) 414 | t2_exit_set = compute_exit_set( 415 | enabled_transitions=[t2], 416 | configuration=configuration, 417 | history_value=history_value, 418 | ) 419 | intersection = [value for value in t1_exit_set if value in t2_exit_set] 420 | 421 | if intersection: 422 | if is_descendent(t1.source, t2.source): 423 | transitions_to_remove.add(t2) 424 | else: 425 | t1_preempted = True 426 | break 427 | if not t1_preempted: 428 | for t3 in transitions_to_remove: 429 | filtered_transitions.remove(t3) 430 | filtered_transitions.add(t1) 431 | 432 | return filtered_transitions 433 | 434 | 435 | def main_event_loop( 436 | configuration: Set[StateNode], event: Event 437 | ) -> Tuple[Set[StateNode], List[Action]]: 438 | states_to_invoke: Set[StateNode] = set() 439 | history_value = {} 440 | enabled_transitions = select_transitions(event=event, configuration=configuration) 441 | 442 | (configuration, actions, internal_queue) = microstep( 443 | enabled_transitions, 444 | configuration=configuration, 445 | states_to_invoke=states_to_invoke, 446 | history_value=history_value, 447 | ) 448 | 449 | (configuration, actions) = macrostep( 450 | configuration=configuration, actions=actions, internal_queue=internal_queue 451 | ) 452 | 453 | return (configuration, actions) 454 | 455 | 456 | def macrostep( 457 | configuration: Set[StateNode], actions: List[Action], internal_queue: List[Event] 458 | ) -> Tuple[Set[StateNode], List[Action]]: 459 | enabled_transitions = set() 460 | macrostep_done = False 461 | 462 | while not macrostep_done: 463 | enabled_transitions = select_eventless_transitions(configuration=configuration) 464 | 465 | if not enabled_transitions: 466 | if not internal_queue: 467 | macrostep_done = True 468 | else: 469 | internal_event = internal_queue.pop() 470 | enabled_transitions = select_transitions( 471 | event=internal_event, 472 | configuration=configuration, 473 | ) 474 | if enabled_transitions: 475 | (configuration, actions, internal_queue) = microstep( 476 | enabled_transitions=enabled_transitions, 477 | configuration=configuration, 478 | states_to_invoke=set(), # TODO 479 | history_value={}, # TODO 480 | ) 481 | 482 | return (configuration, actions) 483 | 484 | 485 | def execute_transition_content( 486 | enabled_transitions: List[Transition], 487 | actions: List[Action], 488 | internal_queue: List[Event], 489 | ): 490 | for transition in enabled_transitions: 491 | for action in transition.actions: 492 | execute_content(action, actions, internal_queue) 493 | 494 | 495 | def execute_content(action: Action, actions: List[Action], internal_queue: List[Event]): 496 | if action.type == "xstate:raise": 497 | internal_queue.append(Event(action.data.get("event"))) 498 | else: 499 | actions.append(action) 500 | 501 | 502 | def microstep( 503 | enabled_transitions: List[Transition], 504 | configuration: Set[StateNode], 505 | states_to_invoke: Set[StateNode], 506 | history_value: HistoryValue, 507 | ) -> Tuple[Set[StateNode], List[Action], List[Event]]: 508 | actions: List[Action] = [] 509 | internal_queue: List[Event] = [] 510 | 511 | exit_states( 512 | enabled_transitions, 513 | configuration=configuration, 514 | states_to_invoke=states_to_invoke, 515 | history_value=history_value, 516 | actions=actions, 517 | internal_queue=internal_queue, 518 | ) 519 | 520 | execute_transition_content( 521 | enabled_transitions, actions=actions, internal_queue=internal_queue 522 | ) 523 | 524 | enter_states( 525 | enabled_transitions, 526 | configuration=configuration, 527 | states_to_invoke=states_to_invoke, 528 | history_value=history_value, 529 | actions=actions, 530 | internal_queue=internal_queue, 531 | ) 532 | 533 | return (configuration, actions, internal_queue) 534 | 535 | 536 | # =================== 537 | 538 | 539 | def get_configuration_from_state( 540 | from_node: StateNode, 541 | state_value: Union[Dict, str], 542 | partial_configuration: Set[StateNode], 543 | ) -> Set[StateNode]: 544 | if isinstance(state_value, str): 545 | partial_configuration.add(from_node.states.get(state_value)) 546 | else: 547 | for key in state_value.keys(): 548 | node = from_node.states.get(key) 549 | partial_configuration.add(node) 550 | get_configuration_from_state( 551 | node, state_value.get(key), partial_configuration 552 | ) 553 | 554 | return partial_configuration 555 | 556 | 557 | def get_adj_list(configuration: Set[StateNode]) -> Dict[str, Set[StateNode]]: 558 | adj_list: Dict[str, Set[StateNode]] = {} 559 | 560 | for s in configuration: 561 | if not adj_list.get(s.id): 562 | adj_list[s.id] = set() 563 | 564 | if s.parent: 565 | if not adj_list.get(s.parent.id): 566 | adj_list[s.parent.id] = set() 567 | 568 | adj_list.get(s.parent.id).add(s) 569 | 570 | return adj_list 571 | 572 | 573 | def get_state_value(state_node: StateNode, configuration: Set[StateNode]): 574 | return get_value_from_adj(state_node, get_adj_list(configuration)) 575 | 576 | 577 | def get_value_from_adj(state_node: StateNode, adj_list: Dict[str, Set[StateNode]]): 578 | child_state_nodes = adj_list.get(state_node.id) 579 | 580 | if is_compound_state(state_node): 581 | child_state_node = list(child_state_nodes)[0] 582 | 583 | if child_state_node: 584 | if is_atomic_state(child_state_node): 585 | return child_state_node.key 586 | else: 587 | return {} 588 | 589 | state_value = {} 590 | 591 | for s in child_state_nodes: 592 | state_value[s.key] = get_value_from_adj(s, adj_list) 593 | 594 | return state_value 595 | -------------------------------------------------------------------------------- /xstate/event.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional 2 | 3 | 4 | class Event: 5 | name: str 6 | data: Dict 7 | 8 | def __init__(self, name: str, data: Optional[Dict] = None): 9 | self.name = name 10 | self.data = data 11 | 12 | def __repr__(self) -> str: 13 | return repr({"name": self.name, "data": self.data}) 14 | -------------------------------------------------------------------------------- /xstate/interpreter.py: -------------------------------------------------------------------------------- 1 | from xstate.machine import Machine 2 | from xstate.state import State 3 | 4 | 5 | class Interpreter: 6 | machine: Machine 7 | state: State 8 | 9 | def __init__(self, machine): 10 | self.machine = machine 11 | self.state = self.machine.initial_state 12 | 13 | def start(): 14 | pass 15 | -------------------------------------------------------------------------------- /xstate/machine.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | 3 | from xstate.algorithm import ( 4 | enter_states, 5 | get_configuration_from_state, 6 | macrostep, 7 | main_event_loop, 8 | ) 9 | from xstate.event import Event 10 | from xstate.state import State 11 | from xstate.state_node import StateNode 12 | 13 | 14 | class Machine: 15 | id: str 16 | root: StateNode 17 | _id_map: Dict[str, StateNode] 18 | config: object 19 | states: Dict[str, StateNode] 20 | actions: List[lambda: None] 21 | 22 | def __init__(self, config: object, actions={}): 23 | self.id = config["id"] 24 | self._id_map = {} 25 | self.root = StateNode( 26 | config, machine=self, key=config.get("id", "(machine)"), parent=None 27 | ) 28 | self.states = self.root.states 29 | self.config = config 30 | self.actions = actions 31 | 32 | def transition(self, state: State, event: str): 33 | configuration = get_configuration_from_state( 34 | from_node=self.root, state_value=state.value, partial_configuration=set() 35 | ) 36 | (configuration, _actions) = main_event_loop(configuration, Event(event)) 37 | 38 | actions, warnings = self._get_actions(_actions) 39 | for w in warnings: 40 | print(w) 41 | 42 | return State(configuration=configuration, context={}, actions=actions) 43 | 44 | def _get_actions(self, actions) -> List[lambda: None]: 45 | result = [] 46 | errors = [] 47 | for action in actions: 48 | if action.type in self.actions: 49 | result.append(self.actions[action.type]) 50 | elif callable(action.type): 51 | result.append(action.type) 52 | else: 53 | errors.append("No '{}' action".format(action.type)) 54 | return result, errors 55 | 56 | def state_from(self, state_value) -> State: 57 | configuration = self._get_configuration(state_value=state_value) 58 | 59 | return State(configuration=configuration, context=None) 60 | 61 | def _register(self, state_node: StateNode): 62 | state_node.machine = self 63 | self._id_map[state_node.id] = state_node 64 | 65 | def _get_by_id(self, id: str) -> StateNode: 66 | return self._id_map.get(id, None) 67 | 68 | def _get_configuration(self, state_value, parent=None) -> List[StateNode]: 69 | if parent is None: 70 | parent = self.root 71 | 72 | if isinstance(state_value, str): 73 | state_node = parent.states.get(state_value, None) 74 | 75 | if state_node is None: 76 | raise ValueError(f"State node {state_value} is missing") 77 | 78 | return [state_node] 79 | 80 | configuration = [] 81 | 82 | for key in state_value.keys(): 83 | state_node = parent.states.get(key) 84 | configuration.append(state_node) 85 | 86 | configuration += self._get_configuration( 87 | state_value.get(key), parent=state_node 88 | ) 89 | 90 | return configuration 91 | 92 | @property 93 | def initial_state(self) -> State: 94 | (configuration, _actions, internal_queue) = enter_states( 95 | [self.root.initial], 96 | configuration=set(), 97 | states_to_invoke=set(), 98 | history_value={}, 99 | actions=[], 100 | internal_queue=[], 101 | ) 102 | 103 | (configuration, _actions) = macrostep( 104 | configuration=configuration, actions=_actions, internal_queue=internal_queue 105 | ) 106 | 107 | actions, warnings = self._get_actions(_actions) 108 | for w in warnings: 109 | print(w) 110 | 111 | return State(configuration=configuration, context={}, actions=actions) 112 | -------------------------------------------------------------------------------- /xstate/scxml.py: -------------------------------------------------------------------------------- 1 | import xml.etree.ElementTree as ET 2 | from typing import Optional 3 | 4 | import js2py 5 | 6 | from xstate.machine import Machine 7 | 8 | ns = {"scxml": "http://www.w3.org/2005/07/scxml"} 9 | 10 | 11 | def convert_scxml(element: ET.Element, parent): 12 | state_els = element.findall("scxml:state", namespaces=ns) 13 | parallel_els = element.findall("scxml:parallel", namespaces=ns) 14 | all_state_els = state_els + parallel_els 15 | 16 | initial_state_key = element.attrib.get( 17 | "initial", 18 | convert_state(all_state_els[0], parent=element).get("key"), 19 | ) 20 | 21 | return { 22 | "id": "machine", 23 | "initial": initial_state_key, 24 | "states": accumulate_states(element, parent), 25 | } 26 | 27 | 28 | def accumulate_states(element: ET.Element, parent: ET.Element): 29 | state_els = element.findall("scxml:state", namespaces=ns) 30 | parallel_els = element.findall("scxml:parallel", namespaces=ns) 31 | all_state_els = state_els + parallel_els 32 | states = [convert_state(state_el, element) for state_el in all_state_els] 33 | 34 | states_dict = {} 35 | 36 | for state in states: 37 | states_dict[state.get("key")] = state 38 | 39 | return states_dict 40 | 41 | 42 | def convert_state(element: ET.Element, parent: ET.Element): 43 | id = element.attrib.get("id") 44 | transition_els = element.findall("scxml:transition", namespaces=ns) 45 | transitions = [convert_transition(el, element) for el in transition_els] 46 | 47 | state_els = element.findall("scxml:state", namespaces=ns) 48 | 49 | states = accumulate_states(element, parent) 50 | 51 | onexit_el = element.find("scxml:onexit", namespaces=ns) 52 | onexit = convert_onexit(onexit_el, parent=element) if onexit_el else None 53 | onentry_el = element.find("scxml:onentry", namespaces=ns) 54 | onentry = convert_onentry(onentry_el, parent=element) if onentry_el else None 55 | 56 | _, _, tag = element.tag.rpartition("}") 57 | 58 | initial_state_key = element.attrib.get( 59 | "initial", 60 | convert_state(state_els[0], parent=element).get("key") if state_els else None, 61 | ) 62 | 63 | result = { 64 | "type": "parallel" if tag == "parallel" else None, 65 | "id": f"{id}", 66 | "key": id, 67 | "exit": onexit, 68 | "entry": onentry, 69 | "states": states, 70 | "initial": initial_state_key, 71 | } 72 | 73 | if len(transitions) > 0: 74 | transitions_dict = {} 75 | 76 | for t in transitions: 77 | transitions_dict[t.get("event")] = transitions_dict.get(t.get("event"), []) 78 | transitions_dict[t.get("event")].append(t) 79 | 80 | result["on"] = transitions_dict 81 | 82 | return result 83 | 84 | 85 | def convert_transition(element: ET.Element, parent: ET.Element): 86 | event_type = element.attrib.get("event") 87 | event_target = element.attrib.get("target") 88 | event_cond_str = element.attrib.get("cond") 89 | 90 | event_cond = ( 91 | js2py.eval_js("function cond() { return %s }" % event_cond_str) 92 | if event_cond_str 93 | else None 94 | ) 95 | 96 | raise_els = element.findall("scxml:raise", namespaces=ns) 97 | 98 | actions = [convert_raise(raise_el, element) for raise_el in raise_els] 99 | 100 | return { 101 | "event": event_type, 102 | "target": ["#%s" % event_target], 103 | "actions": actions, 104 | "cond": event_cond, 105 | } 106 | 107 | 108 | def convert_raise(element: ET.Element, parent: ET.Element): 109 | return {"type": "xstate:raise", "event": element.attrib.get("event")} 110 | 111 | 112 | def convert_onexit(element: ET.Element, parent: ET.Element): 113 | raise_els = element.findall("scxml:raise", namespaces=ns) 114 | actions = [convert_raise(raise_el, element) for raise_el in raise_els] 115 | 116 | return actions 117 | 118 | 119 | def convert_onentry(element: ET.Element, parent: ET.Element): 120 | raise_els = element.findall("scxml:raise", namespaces=ns) 121 | actions = [convert_raise(raise_el, element) for raise_el in raise_els] 122 | 123 | return actions 124 | 125 | 126 | def convert(element: ET.Element, parent: Optional[ET.Element] = None): 127 | _, _, element_tag = element.tag.rpartition("}") # strip namespace 128 | result = elements.get(element_tag, lambda _: f"Invalid tag: {element_tag}") 129 | 130 | return result(element, parent) 131 | 132 | 133 | elements = {"scxml": convert_scxml, "state": convert_state} 134 | 135 | 136 | def scxml_to_machine(source: str) -> Machine: 137 | tree = ET.parse(source) 138 | root = tree.getroot() 139 | result = convert(root) 140 | machine = Machine(result) 141 | 142 | return machine 143 | -------------------------------------------------------------------------------- /xstate/state.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any, Dict, List, Set 2 | 3 | from xstate.algorithm import get_state_value 4 | 5 | if TYPE_CHECKING: 6 | from xstate.action import Action 7 | from xstate.state_node import StateNode 8 | 9 | 10 | class State: 11 | configuration: Set["StateNode"] 12 | value: str 13 | context: Dict[str, Any] 14 | actions: List["Action"] 15 | 16 | def __init__( 17 | self, 18 | configuration: Set["StateNode"], 19 | context: Dict[str, Any], 20 | actions: List["Action"] = [], 21 | ): 22 | root = next(iter(configuration)).machine.root 23 | self.configuration = configuration 24 | self.value = get_state_value(root, configuration) 25 | self.context = context 26 | self.actions = actions 27 | 28 | def __repr__(self): 29 | return repr( 30 | { 31 | "value": self.value, 32 | "context": self.context, 33 | "actions": self.actions, 34 | } 35 | ) 36 | -------------------------------------------------------------------------------- /xstate/state_node.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Dict, List, Optional, Union 2 | 3 | from xstate.action import Action 4 | from xstate.transition import Transition 5 | 6 | if TYPE_CHECKING: 7 | from xstate.machine import Machine 8 | 9 | 10 | class StateNode: 11 | on: Dict[str, List[Transition]] 12 | machine: "Machine" 13 | parent: Optional["StateNode"] 14 | initial: Optional[Transition] 15 | entry: List[Action] 16 | exit: List[Action] 17 | donedata: Optional[Dict] 18 | type: str # 'atomic' or 'compound' or 'parallel' or 'final' 19 | transitions: List[Transition] 20 | id: str 21 | key: str 22 | states: Dict[str, "StateNode"] 23 | 24 | def get_actions(self, action): 25 | if callable(action): 26 | return Action(action) 27 | else: 28 | return Action(action.get("type"), exec=None, data=action) 29 | 30 | def __init__( 31 | self, 32 | # { "type": "compound", "states": { ... } } 33 | config, 34 | machine: "Machine", 35 | key: str, 36 | parent: Union["StateNode", "Machine"] = None, 37 | ): 38 | self.config = config 39 | self.parent = parent 40 | self.id = ( 41 | config.get("id", parent.id + "." + key) 42 | if parent 43 | else config.get("id", machine.id + "." + key) 44 | ) 45 | self.entry = ( 46 | [self.get_actions(entry_action) for entry_action in config.get("entry")] 47 | if config.get("entry") 48 | else [] 49 | ) 50 | 51 | self.exit = ( 52 | [self.get_actions(exit_action) for exit_action in config.get("exit")] 53 | if config.get("exit") 54 | else [] 55 | ) 56 | 57 | self.key = key 58 | self.states = { 59 | k: StateNode(v, machine=machine, parent=self, key=k) 60 | for k, v in config.get("states", {}).items() 61 | } 62 | self.on = {} 63 | self.transitions = [] 64 | for k, v in config.get("on", {}).items(): 65 | self.on[k] = [] 66 | transition_configs = v if isinstance(v, list) else [v] 67 | 68 | for transition_config in transition_configs: 69 | transition = Transition( 70 | transition_config, 71 | source=self, 72 | event=k, 73 | order=len(self.transitions), 74 | ) 75 | self.on[k].append(transition) 76 | self.transitions.append(transition) 77 | 78 | self.type = config.get("type") 79 | 80 | if self.type is None: 81 | self.type = "atomic" if not self.states else "compound" 82 | 83 | if self.type == "final": 84 | self.donedata = config.get("data") 85 | 86 | if config.get("onDone"): 87 | done_event = f"done.state.{self.id}" 88 | done_transition = Transition( 89 | config.get("onDone"), 90 | source=self, 91 | event=done_event, 92 | order=len(self.transitions), 93 | ) 94 | self.on[done_event] = done_transition 95 | self.transitions.append(done_transition) 96 | 97 | machine._register(self) 98 | 99 | @property 100 | def initial(self): 101 | initial_key = self.config.get("initial") 102 | 103 | if not initial_key: 104 | if self.type == "compound": 105 | return Transition( 106 | next(iter(self.states.values())), source=self, event=None, order=-1 107 | ) 108 | else: 109 | return Transition( 110 | self.states.get(initial_key), source=self, event=None, order=-1 111 | ) 112 | 113 | def _get_relative(self, target: str) -> "StateNode": 114 | if target.startswith("#"): 115 | return self.machine._get_by_id(target[1:]) 116 | 117 | state_node = self.parent.states.get(target) 118 | 119 | if not state_node: 120 | raise ValueError( 121 | f"Relative state node '{target}' does not exist on state node '#{self.id}'" # noqa 122 | ) 123 | 124 | return state_node 125 | 126 | def __repr__(self) -> str: 127 | return "" % repr({"id": self.id}) 128 | -------------------------------------------------------------------------------- /xstate/transition.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any, Callable, List, NamedTuple, Optional, Union 2 | 3 | from xstate.action import Action 4 | from xstate.event import Event 5 | 6 | if TYPE_CHECKING: 7 | from xstate.state_node import StateNode 8 | 9 | CondFunction = Callable[[Any, Event], bool] 10 | 11 | 12 | class TransitionConfig(NamedTuple): 13 | target: List[str] 14 | 15 | 16 | class Transition: 17 | event: str 18 | source: "StateNode" 19 | config: Union[str, "StateNode", TransitionConfig] 20 | actions: List[Action] 21 | cond: Optional[CondFunction] 22 | order: int 23 | # "internal" or "external" 24 | type: str 25 | 26 | def __init__( 27 | self, 28 | config, 29 | source: "StateNode", 30 | event: str, 31 | order: int, 32 | cond: Optional[CondFunction] = None, 33 | ): 34 | self.event = event 35 | self.config = config 36 | self.source = source 37 | self.type = "external" 38 | self.cond = config.get("cond", None) if isinstance(config, dict) else None 39 | self.order = order 40 | 41 | self.actions = ( 42 | ( 43 | [ 44 | Action(type=action.get("type"), data=action) 45 | for action in config.get("actions", []) 46 | ] 47 | ) 48 | if isinstance(config, dict) 49 | else [] 50 | ) 51 | 52 | @property 53 | def target(self) -> List["StateNode"]: 54 | if isinstance(self.config, str): 55 | return [self.source._get_relative(self.config)] 56 | elif isinstance(self.config, dict): 57 | if isinstance(self.config["target"], str): 58 | return [self.source._get_relative(self.config["target"])] 59 | 60 | return [self.source._get_relative(v) for v in self.config["target"]] 61 | else: 62 | return [self.config] if self.config else [] 63 | 64 | def __repr__(self) -> str: 65 | return repr( 66 | { 67 | "event": self.event, 68 | "source": self.source.id, 69 | "target": [f"#{t.id}" for t in self.target], 70 | "cond": self.cond, 71 | "actions": self.actions, 72 | "type": self.type, 73 | "order": self.order, 74 | } 75 | ) 76 | --------------------------------------------------------------------------------