diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml new file mode 100644 index 0000000..62b6649 --- /dev/null +++ b/.github/workflows/publish-docs.yml @@ -0,0 +1,45 @@ +--- +name: publish-docs + +on: + push: + branches: + - main + +jobs: + docs: + runs-on: ubuntu-latest + steps: + - name: checkout-code@scm + uses: actions/checkout@main + - name: checkout-ghpages@scm + uses: actions/checkout@main + with: + ref: gh-pages + path: docs/build/html + + - name: setup@python + uses: actions/setup-python@main + with: + python-version: '3.10' + + - name: setup@poetry + run: pip install poetry + + - name: setup@venv + run: poetry install + + - name: docs@sphinx + run: | + cd docs + poetry run make html + + - name: publish@scm + run: | + cd docs/build/html + touch .nojekyll + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git add . + git commit -m ":construction_worker: publish documentation" --allow-empty + git push origin gh-pages diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml new file mode 100644 index 0000000..e5cf7da --- /dev/null +++ b/.github/workflows/test-suite.yml @@ -0,0 +1,34 @@ +--- +name: run-test-suite + +on: [push] + +jobs: + test-suite: + runs-on: ubuntu-latest + steps: + - name: checkout@scm + uses: actions/checkout@main + + - name: setup@python + uses: actions/setup-python@main + with: + python-version: '3.10' + + - name: setup@poetry + run: pip install poetry + + - name: setup@venv + run: poetry install + + - name: lint@black + run: poetry run black --check triotp --target-version py310 + + - name: test@pytest + run: poetry run pytest --cov triotp --cov-report xml + + - name: coverage@coveralls + run: poetry run coveralls + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ca48b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,141 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# VSCode settings +.vscode/ \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..9272c14 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,19 @@ +Copyright 2021, David Delassus + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..53e6640 --- /dev/null +++ b/README.rst @@ -0,0 +1,70 @@ +TriOTP, the OTP framework for Python Trio +========================================= + +See documentation_ for more informations. + +.. _documentation: https://linkdd.github.io/triotp + +.. image:: https://img.shields.io/pypi/l/triotp.svg?style=flat-square + :target: https://pypi.python.org/pypi/triotp/ + :alt: License + +.. image:: https://img.shields.io/pypi/status/triotp.svg?style=flat-square + :target: https://pypi.python.org/pypi/triotp/ + :alt: Development Status + +.. image:: https://img.shields.io/pypi/v/triotp.svg?style=flat-square + :target: https://pypi.python.org/pypi/triotp/ + :alt: Latest release + +.. image:: https://img.shields.io/pypi/pyversions/triotp.svg?style=flat-square + :target: https://pypi.python.org/pypi/triotp/ + :alt: Supported Python versions + +.. image:: https://img.shields.io/pypi/implementation/triotp.svg?style=flat-square + :target: https://pypi.python.org/pypi/triotp/ + :alt: Supported Python implementations + +.. image:: https://img.shields.io/pypi/wheel/triotp.svg?style=flat-square + :target: https://pypi.python.org/pypi/triotp + :alt: Download format + +.. image:: https://github.com/linkdd/triotp/actions/workflows/test-suite.yml/badge.svg + :target: https://github.com/linkdd/triotp + :alt: Build status + +.. image:: https://coveralls.io/repos/github/linkdd/triotp/badge.svg?style=flat-square + :target: https://coveralls.io/r/linkdd/triotp + :alt: Code test coverage + +.. image:: https://img.shields.io/pypi/dm/triotp.svg?style=flat-square + :target: https://pypi.python.org/pypi/triotp/ + :alt: Downloads + +Introduction +------------ + +This project is a simplified implementation of the Erlang_/Elixir_ OTP_ +framework. + +.. _erlang: https://erlang.org +.. _elixir: https://elixir-lang.org/ +.. _otp: https://en.wikipedia.org/wiki/Open_Telecom_Platform + +It is built on top of the Trio_ async library and provides: + + - **applications:** the root of a supervision tree + - **supervisors:** automatic restart of children tasks + - **mailboxes:** message-passing between tasks + - **gen_servers:** generic server task + +.. _trio: https://trio.readthedocs.io + +Why ? +----- + +Since I started writing Erlang/Elixir code, I've always wanted to use its +concepts in other languages. + +I made this library for fun and most importantly: to see if it was possible. +As it turns out, it is! \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..061f32f --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst new file mode 100644 index 0000000..23aead6 --- /dev/null +++ b/docs/source/api/index.rst @@ -0,0 +1,7 @@ +API Reference +============= + +.. toctree:: + :maxdepth: 4 + + triotp diff --git a/docs/source/api/triotp.rst b/docs/source/api/triotp.rst new file mode 100644 index 0000000..e03d010 --- /dev/null +++ b/docs/source/api/triotp.rst @@ -0,0 +1,77 @@ +triotp package +============== + +Module contents +--------------- + +.. automodule:: triotp + :members: + :undoc-members: + :show-inheritance: + +Submodules +---------- + +triotp.node module +~~~~~~~~~~~~~~~~~~ + +.. automodule:: triotp.node + :members: + :undoc-members: + :show-inheritance: + +triotp.application module +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. automodule:: triotp.application + :members: + :undoc-members: + :show-inheritance: + +triotp.supervisor module +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. automodule:: triotp.supervisor + :members: + :undoc-members: + :show-inheritance: + +triotp.mailbox module +~~~~~~~~~~~~~~~~~~~~~ + +.. automodule:: triotp.mailbox + :members: + :undoc-members: + :show-inheritance: + +triotp.gen\_server module +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. automodule:: triotp.gen_server + :members: + :undoc-members: + :show-inheritance: + +triotp.dynamic_supervisor module +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. automodule:: triotp.dynamic_supervisor + :members: + :undoc-members: + :show-inheritance: + +triotp.logging module +~~~~~~~~~~~~~~~~~~~~~ + +.. automodule:: triotp.logging + :members: + :undoc-members: + :show-inheritance: + +triotp.helpers module +~~~~~~~~~~~~~~~~~~~~~ + +.. automodule:: triotp.helpers + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..f65156d --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,65 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- +from pathlib import Path +import toml + +pyproject_path = Path.cwd().parent.parent / 'pyproject.toml' + +with open(pyproject_path) as f: + pyproject = toml.load(f) + +project = pyproject['tool']['poetry']['name'] +author = pyproject['tool']['poetry']['authors'][0] +copyright = f'2021, {author}' + +# The full version, including alpha/beta/rc tags +release = pyproject['tool']['poetry']['version'] + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc' +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +html_baseurl = 'https://linkdd.github.io/triotp/' diff --git a/docs/source/guides/index.rst b/docs/source/guides/index.rst new file mode 100644 index 0000000..790b676 --- /dev/null +++ b/docs/source/guides/index.rst @@ -0,0 +1,10 @@ +Tutorials +========= + +.. toctree:: + :maxdepth: 1 + :caption: Contents: + + message-passing + simple-app + webserver diff --git a/docs/source/guides/message-passing.rst b/docs/source/guides/message-passing.rst new file mode 100644 index 0000000..5e13510 --- /dev/null +++ b/docs/source/guides/message-passing.rst @@ -0,0 +1,99 @@ +Message-Passing between tasks with mailboxes +============================================ + +**trio** provides a mechanism to exchange messages between asynchronous tasks. + +The **triotp** mailbox is an abstraction of that mechanism. This abstraction can +be used without the rest of **triotp**. + +In this tutorial, we'll see how it works. + +Initializing the mailbox registry +--------------------------------- + +This step is done automatically when a node starts, but it is required if you +want to use it standalone: + +.. code-block:: python + + from triotp import mailbox + import trio + + + async def main(): + mailbox._init() + + + trio.run(main) + + +Creating a mailbox +------------------ + +There is 2 ways of creating a mailbox: + +.. code-block:: python + + async def your_task(): + mid = mailbox.create() + # do stuff + await mailbox.destroy(mid) + +Or: + +.. code-block:: python + + async def your_task(): + async with mailbox.open() as mid: + # do stuff + +The `mid` variable holds a unique reference to the mailbox. But it can be hard to +pass this reference between tasks. Therefore, you can register a name referencing +this identifier: + +.. code-block:: python + + async def your_task(): + mid = mailbox.create() + mailbox.register(mid, 'foo') + # do stuff + await mailbox.destroy() + +Or: + +.. code-block:: python + + async def your_task(): + async with mailbox.open(name='foo'): + # do stuff + +Sending and receiving messages +------------------------------ + +In this example, we create a mailbox, wait for a single message, then close it: + +.. code-block:: python + + from triotp import mailbox + import trio + + + async def consumer(task_status=trio.TASK_STATUS_IGNORED): + async with mailbox.open('foo') as mid: + task_status.started(None) + + message = await mailbox.receive(mid) + print(message) + + + async def producer(): + await mailbox.send('foo', 'Hello World!') + + + async def main(): + async with trio.open_nursery() as nursery: + await nursery.start(consumer) + nursery.start_soon(producer) + + + trio.run(main) diff --git a/docs/source/guides/simple-app.rst b/docs/source/guides/simple-app.rst new file mode 100644 index 0000000..96a6025 --- /dev/null +++ b/docs/source/guides/simple-app.rst @@ -0,0 +1,310 @@ +A simple application +==================== + +In this tutorial, we will create a single application with 2 processes: + + - a generic server which echo messages + - a client to interact with the generic server + +First, let's create our folder structure: + +.. code-block:: shell + + $ mkdir simple_triotp + $ touch simple_triotp/__init__.py + +The Generic Server +------------------ + +A generic server is an abstract of a process with a mailbox receiving messages +and sending responses back to the caller (server/client architecture). + +Let's create a file `simple_triotp/echo_server.py`: + +.. code-block:: python + + from triotp.helpers import current_module + from triotp import gen_server + + + __module__ = current_module() + + + async def start(): + await gen_server.start(__module__, init_arg=None, name=__name__) + + + async def echo(message): + return await gen_server.call(__name__, ('echo', message)) + + + async def stop(): + await gen_server.cast(__name__, 'stop') + + # gen_server callbacks + + async def init(_init_arg): + return 'nostate' + + + async def terminate(reason, state): + print('exited with reason', reason, 'and state', state) + + + async def handle_call(message, _caller, state): + match message: + case ('echo', message): + return (gen_server.Reply(message), state) + + case _: + exc = NotImplementedError('unkown command') + return (gen_server.Reply(exc), state) + + + async def handle_cast(message, state): + match message: + case 'stop': + return (gen_server.Stop(), state) + + case _: + exc = NotImplementedError('unknown command') + return (gen_server.Stop(exc), state) + +Let's look at it step by step: + +.. code-block:: python + + async def start(): + await gen_server.start(__module__, init_arg=None, name=__name__) + +This function will start our generic server: + + - the first argument is the module which defines our callbacks: + - `init`: to create the state of the server + - `terminate`: called whenever the generic server stops + - `handle_call`: called whenever a call to the generic server is made + - `handle_cast`: called whenever a cast to the generic server is made + - `handle_info`: called whenever a message is sent directly to the generic server's mailbox + - the second argument is the value passed to the `init` callback + - the third argument is the name to use to send message to the generic server's mailbox + +.. code-block:: python + + async def echo(message): + return await gen_server.call(__name__, ('echo', message)) + +The `gen_server.call()` function sends a message to the generic server's mailbox, +and then wait for a response. The request is handled by the `handle_call` callback. + +**NB:** If the response is an exception, it will be raised as soon as it is +received, delegating the error handling to the caller. + +.. code-block:: python + + async def stop(): + await gen_server.cast(__name__, 'stop') + +The `gen_server.cast()` function sends a message to the generic server's mailbox, +but it does not wait for a response and returns immediately. The request will be +handled by the `handle_cast` callback. + +You can also send messages directly to the generic server's maiblox: + +.. code-block:: python + + async def notify(): + await mailbox.send(__name__, 'notify') + +The message will then be handled by the `handle_info` callback. +This is useful to allow a generic server to send messages to itself. + +.. code-block:: python + + async def init(_init_arg): + return 'nostate' + +This callback creates and return the state for the generic server. This state +will be passed to every other callback. It can be anything like: + + - a data structure + - a database connection + - a state machine + - ... + +.. code-block:: python + + async def terminate(reason, state): + print('exited with reason', reason, 'and state', state) + +This callback is called when the generic server is stopped. The `reason` is either +`None` or the exception that triggered the generic server to stop. + +.. code-block:: python + + async def handle_call(message, _caller, state): + match message: + case ('echo', message): + return (gen_server.Reply(message), state) + + case _: + exc = NotImplementedError('unkown command') + return (gen_server.Reply(exc), state) + +This callback is called to handle requests made with `gen_server.call()`, it +must always return a tuple whose second element is the new state (for later calls +to any callback function). + +The first argument can be either: + + - `gen_server.NoReply()`: implying a call to `gen_server.reply()` will be made + in the future to send the response back to the caller + - `gen_server.Reply()`: to send a response back to the caller + - `gen_server.Stop(reason=None)`: to exit the generic server. The caller will + then raise a `GenServerExited` exception + +.. code-block:: python + + async def handle_cast(message, state): + match message: + case 'stop': + return (gen_server.Stop(), state) + + case _: + exc = NotImplementedError('unknown command') + return (gen_server.Stop(exc), state) + +This callback is called to handle requests made with `gen_server.call()`, it +must always return a tuple whose second element is the new state (for later calls +to any callback function). + +The first argument can be either: + + - `gen_server.NoReply()`: no reply will be sent to the caller + - `gen_server.Stop(reason=None)`: to exit the generic server + +**NB:** the `handle_info` callback works exactly the same. + +The client process +------------------ + +This task will only send some messages to the generic server, and finally stop it. + +Let's create a `simple_triotp/echo_client.py` file: + +.. code-block:: python + + from . import echo_server + + + async def run(): + response = await echo_server.echo('hello') + assert response == 'hello' + + response = await echo_server.echo('world') + assert response == 'world' + + await echo_server.stop() + +The supervisor +-------------- + +A supervisor handles automatic restart of its children whenever they exit +prematurely, or after a crash. + +It is useful to restart a generic server handling connections to a database, +after a temporary network failure. + +In this case, the supervisor will have 2 children, the generic server and the +client: + +.. code-block:: python + + from triotp import supervisor + from . import echo_server, echo_client + + + async def start(): + children = [ + supervisor.child_spec( + id='server', + task=echo_server.start, + args=[], + restart=supervisor.restart_strategy.TRANSIENT, + ), + supervisor.child_spec( + id='client', + task=echo_client.run, + args=[], + restart=supervisor.restart_strategy.TRANSIENT, + ), + ] + opts = supervisor.options( + max_restarts=3, + max_seconds=5, + ) + await supervisor.start(children, opts) + +There are 3 supported restart strategy: + + - `PERMANENT`: the task should always be restarted + - `TRANSIENT`: the task should be restarted only if it crashed + - `TEMPORARY`: the task should never be restarted + +If a child restart more than `max_restarts` within a `max_seconds` period, the +supervisor will also crash (maybe a parent supervisor will try to restart it). + +The application +--------------- + +An application is the root of a supervision tree. + +We'll use this to start our supervisor, let's create a file +`simple_triotp/echo_app.py`: + +.. code-block:: python + + from triotp.helpers import current_module + from triotp import application + from . import echo_supervisor + + + __module__ = current_module() + + + def spec(): + return application.app_spec( + module=__module__, + start_arg=None, + permanent=False, + ) + + + async def start(_start_arg): + await echo_supervisor.start() + +Starting the node +----------------- + +Finally, we need to create our entrypoint, this can be done in the file +`simple_triotp/main.py`: + +.. code-block:: python + + from triotp import node + from . import echo_app + + + def main(): + node.run(apps=[ + echo_app.spec(), + ]) + + + if __name__ == '__main__': + main() + +Now, you can run the whole program with: + +.. code-block:: shell + + $ python -m simple_triotp.main diff --git a/docs/source/guides/webserver.rst b/docs/source/guides/webserver.rst new file mode 100644 index 0000000..aa1e946 --- /dev/null +++ b/docs/source/guides/webserver.rst @@ -0,0 +1,49 @@ +Running a webserver +=================== + +In this tutorial, you'll learn how to run an ASGI or WSGI web application as part +of your supervision tree. + +Before starting, you'll need [hypercorn](https://pgjones.gitlab.io/hypercorn/): + +.. code-block:: shell + + $ pip install hypercorn + +It is a production-ready HTTP webserver able to run on top of **AsyncIO** or +**trio**. + +Then in a supervisor: + +.. code-block:: python + + from triotp import supervisor + from myproject.asgi import app as asgi_app + from myproject.wsgi import app as wsgi_app + + from hypercorn.middleware import TrioWSGIMiddleware + from hypercorn.config import Config + from hypercorn.trio import serve + + + async def start(): + config_a = Config() + config_a.bind = ["localhost:8000"] + + config_b = Config() + config_b.bind = ["localhost:8001"] + + children = [ + supervisor.child_spec( + id='endpoint-a', + task=serve, + args=[asgi_app, config_a], + ), + supervisor.child_spec( + id='endpoint-b', + task=serve, + args=[TrioWSGIMiddleware(wsgi_app), config_b], + ), + ] + opts = supervisor.options() + await supervisor.start(children, opts) diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..aab7a29 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,18 @@ +.. include:: ../../README.rst + +Getting Started +=============== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + guides/index + api/index + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..002e476 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1140 @@ +[[package]] +name = "alabaster" +version = "0.7.12" +description = "A configurable sidebar-enabled Sphinx theme" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "async-generator" +version = "1.10" +description = "Async generators and context managers for Python 3.5+" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "atomicwrites" +version = "1.4.0" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "21.2.0" +description = "Classes Without Boilerplate" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.extras] +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"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] + +[[package]] +name = "babel" +version = "2.9.1" +description = "Internationalization utilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +pytz = ">=2015.7" + +[[package]] +name = "black" +version = "21.11b1" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +click = ">=7.1.2" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.9.0,<1" +platformdirs = ">=2" +regex = ">=2021.4.4" +tomli = ">=0.2.6,<2.0.0" +typing-extensions = [ + {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}, + {version = "!=3.10.0.1", markers = "python_version >= \"3.10\""}, +] + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +python2 = ["typed-ast (>=1.4.3)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "certifi" +version = "2021.10.8" +description = "Python package for providing Mozilla's CA Bundle." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "cffi" +version = "1.15.0" +description = "Foreign Function Interface for Python calling C code." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "charset-normalizer" +version = "2.0.7" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "dev" +optional = false +python-versions = ">=3.5.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + +[[package]] +name = "click" +version = "8.0.3" +description = "Composable command line interface toolkit" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.4" +description = "Cross-platform colored terminal text." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "coverage" +version = "6.1.2" +description = "Code coverage measurement for Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "coveralls" +version = "3.3.1" +description = "Show coverage stats online via coveralls.io" +category = "dev" +optional = false +python-versions = ">= 3.5" + +[package.dependencies] +coverage = ">=4.1,<6.0.0 || >6.1,<6.1.1 || >6.1.1,<7.0" +docopt = ">=0.6.1" +requests = ">=1.0.0" + +[package.extras] +yaml = ["PyYAML (>=3.10)"] + +[[package]] +name = "docopt" +version = "0.6.2" +description = "Pythonic argument parser, that will make you smile" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "docutils" +version = "0.17.1" +description = "Docutils -- Python Documentation Utilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "idna" +version = "3.3" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "imagesize" +version = "1.3.0" +description = "Getting image size from png/jpeg/jpeg2000/gif file" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "jinja2" +version = "3.0.3" +description = "A very fast and expressive template engine." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "logbook" +version = "1.5.3" +description = "A logging replacement for Python" +category = "main" +optional = false +python-versions = "*" + +[package.extras] +all = ["redis", "brotli", "pytest (>4.0)", "execnet (>=1.0.9)", "cython", "pyzmq", "pytest-cov (>=2.6)", "sqlalchemy", "jinja2"] +compression = ["brotli"] +dev = ["pytest-cov (>=2.6)", "pytest (>4.0)", "cython"] +execnet = ["execnet (>=1.0.9)"] +jinja = ["jinja2"] +redis = ["redis"] +sqlalchemy = ["sqlalchemy"] +test = ["pytest-cov (>=2.6)", "pytest (>4.0)"] +zmq = ["pyzmq"] + +[[package]] +name = "markupsafe" +version = "2.0.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "mypy" +version = "0.910" +description = "Optional static typing for Python" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +mypy-extensions = ">=0.4.3,<0.5.0" +toml = "*" +typing-extensions = ">=3.7.4" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +python2 = ["typed-ast (>=1.4.0,<1.5.0)"] + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "outcome" +version = "1.1.0" +description = "Capture the outcome of Python function calls." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +attrs = ">=19.2.0" + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "pathspec" +version = "0.9.0" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[[package]] +name = "platformdirs" +version = "2.4.0" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] +test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] + +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pygments" +version = "2.10.0" +description = "Pygments is a syntax highlighting package written in Python." +category = "dev" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "pyparsing" +version = "3.0.6" +description = "Python parsing module" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + +[[package]] +name = "pytest" +version = "6.2.5" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +py = ">=1.8.2" +toml = "*" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "3.0.0" +description = "Pytest plugin for measuring coverage." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] + +[[package]] +name = "pytest-trio" +version = "0.7.0" +description = "Pytest plugin for trio" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +async_generator = ">=1.9" +outcome = "*" +pytest = ">=3.6" +trio = ">=0.15.0" + +[[package]] +name = "pytz" +version = "2021.3" +description = "World timezone definitions, modern and historical" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "regex" +version = "2021.11.10" +description = "Alternative regular expression module, to replace re." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "requests" +version = "2.26.0" +description = "Python HTTP for Humans." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} +idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] + +[[package]] +name = "sniffio" +version = "1.2.0" +description = "Sniff out which async library your code is running under" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "snowballstemmer" +version = "2.2.0" +description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "sphinx" +version = "4.3.0" +description = "Python documentation generator" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +alabaster = ">=0.7,<0.8" +babel = ">=1.3" +colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""} +docutils = ">=0.14,<0.18" +imagesize = "*" +Jinja2 = ">=2.3" +packaging = "*" +Pygments = ">=2.0" +requests = ">=2.5.0" +snowballstemmer = ">=1.1" +sphinxcontrib-applehelp = "*" +sphinxcontrib-devhelp = "*" +sphinxcontrib-htmlhelp = ">=2.0.0" +sphinxcontrib-jsmath = "*" +sphinxcontrib-qthelp = "*" +sphinxcontrib-serializinghtml = ">=1.1.5" + +[package.extras] +docs = ["sphinxcontrib-websupport"] +lint = ["flake8 (>=3.5.0)", "isort", "mypy (>=0.900)", "docutils-stubs", "types-typed-ast", "types-pkg-resources", "types-requests"] +test = ["pytest", "pytest-cov", "html5lib", "cython", "typed-ast"] + +[[package]] +name = "sphinx-rtd-theme" +version = "1.0.0" +description = "Read the Docs theme for Sphinx" +category = "dev" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" + +[package.dependencies] +docutils = "<0.18" +sphinx = ">=1.6" + +[package.extras] +dev = ["transifex-client", "sphinxcontrib-httpdomain", "bump2version"] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "1.0.2" +description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "1.0.2" +description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.0.0" +description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest", "html5lib"] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +description = "A sphinx extension which renders display math in HTML via JavaScript" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +test = ["pytest", "flake8", "mypy"] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "1.0.3" +description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "1.1.5" +description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest"] + +[[package]] +name = "tenacity" +version = "8.0.1" +description = "Retry code until it succeeds" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +doc = ["reno", "sphinx", "tornado (>=4.5)"] + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "tomli" +version = "1.2.2" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "trio" +version = "0.19.0" +description = "A friendly Python library for async concurrency and I/O" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +async-generator = ">=1.9" +attrs = ">=19.2.0" +cffi = {version = ">=1.14", markers = "os_name == \"nt\" and implementation_name != \"pypy\""} +idna = "*" +outcome = "*" +sniffio = "*" +sortedcontainers = "*" + +[[package]] +name = "trio-util" +version = "0.7.0" +description = "Utility library for the Trio async/await framework" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +async-generator = "*" +trio = ">=0.11.0" + +[[package]] +name = "typing-extensions" +version = "4.0.0" +description = "Backported and Experimental Type Hints for Python 3.6+" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "urllib3" +version = "1.26.7" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +brotli = ["brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[metadata] +lock-version = "1.1" +python-versions = "^3.10" +content-hash = "d8337a624847a407b1b300276563cfc932700a63875ce8b50118ae8d563a3ba8" + +[metadata.files] +alabaster = [ + {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, + {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, +] +async-generator = [ + {file = "async_generator-1.10-py3-none-any.whl", hash = "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b"}, + {file = "async_generator-1.10.tar.gz", hash = "sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"}, +] +atomicwrites = [ + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, +] +attrs = [ + {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, + {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, +] +babel = [ + {file = "Babel-2.9.1-py2.py3-none-any.whl", hash = "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9"}, + {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"}, +] +black = [ + {file = "black-21.11b1-py3-none-any.whl", hash = "sha256:802c6c30b637b28645b7fde282ed2569c0cd777dbe493a41b6a03c1d903f99ac"}, + {file = "black-21.11b1.tar.gz", hash = "sha256:a042adbb18b3262faad5aff4e834ff186bb893f95ba3a8013f09de1e5569def2"}, +] +certifi = [ + {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, + {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, +] +cffi = [ + {file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"}, + {file = "cffi-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0"}, + {file = "cffi-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14"}, + {file = "cffi-1.15.0-cp27-cp27m-win32.whl", hash = "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474"}, + {file = "cffi-1.15.0-cp27-cp27m-win_amd64.whl", hash = "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6"}, + {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27"}, + {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023"}, + {file = "cffi-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2"}, + {file = "cffi-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382"}, + {file = "cffi-1.15.0-cp310-cp310-win32.whl", hash = "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55"}, + {file = "cffi-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0"}, + {file = "cffi-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605"}, + {file = "cffi-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e"}, + {file = "cffi-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc"}, + {file = "cffi-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7"}, + {file = "cffi-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66"}, + {file = "cffi-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029"}, + {file = "cffi-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6"}, + {file = "cffi-1.15.0-cp38-cp38-win32.whl", hash = "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c"}, + {file = "cffi-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443"}, + {file = "cffi-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a"}, + {file = "cffi-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8"}, + {file = "cffi-1.15.0-cp39-cp39-win32.whl", hash = "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a"}, + {file = "cffi-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139"}, + {file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"}, +] +charset-normalizer = [ + {file = "charset-normalizer-2.0.7.tar.gz", hash = "sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0"}, + {file = "charset_normalizer-2.0.7-py3-none-any.whl", hash = "sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b"}, +] +click = [ + {file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"}, + {file = "click-8.0.3.tar.gz", hash = "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"}, +] +colorama = [ + {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, + {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, +] +coverage = [ + {file = "coverage-6.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:675adb3b3380967806b3cbb9c5b00ceb29b1c472692100a338730c1d3e59c8b9"}, + {file = "coverage-6.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95a58336aa111af54baa451c33266a8774780242cab3704b7698d5e514840758"}, + {file = "coverage-6.1.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d0a595a781f8e186580ff8e3352dd4953b1944289bec7705377c80c7e36c4d6c"}, + {file = "coverage-6.1.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d3c5f49ce6af61154060640ad3b3281dbc46e2e0ef2fe78414d7f8a324f0b649"}, + {file = "coverage-6.1.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:310c40bed6b626fd1f463e5a83dba19a61c4eb74e1ac0d07d454ebbdf9047e9d"}, + {file = "coverage-6.1.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a4d48e42e17d3de212f9af44f81ab73b9378a4b2b8413fd708d0d9023f2bbde4"}, + {file = "coverage-6.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ffa545230ca2ad921ad066bf8fd627e7be43716b6e0fcf8e32af1b8188ccb0ab"}, + {file = "coverage-6.1.2-cp310-cp310-win32.whl", hash = "sha256:cd2d11a59afa5001ff28073ceca24ae4c506da4355aba30d1e7dd2bd0d2206dc"}, + {file = "coverage-6.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:96129e41405887a53a9cc564f960d7f853cc63d178f3a182fdd302e4cab2745b"}, + {file = "coverage-6.1.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1de9c6f5039ee2b1860b7bad2c7bc3651fbeb9368e4c4d93e98a76358cdcb052"}, + {file = "coverage-6.1.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:80cb70264e9a1d04b519cdba3cd0dc42847bf8e982a4d55c769b9b0ee7cdce1e"}, + {file = "coverage-6.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:ba6125d4e55c0b8e913dad27b22722eac7abdcb1f3eab1bd090eee9105660266"}, + {file = "coverage-6.1.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8492d37acdc07a6eac6489f6c1954026f2260a85a4c2bb1e343fe3d35f5ee21a"}, + {file = "coverage-6.1.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:66af99c7f7b64d050d37e795baadf515b4561124f25aae6e1baa482438ecc388"}, + {file = "coverage-6.1.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ebcc03e1acef4ff44f37f3c61df478d6e469a573aa688e5a162f85d7e4c3860d"}, + {file = "coverage-6.1.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98d44a8136eebbf544ad91fef5bd2b20ef0c9b459c65a833c923d9aa4546b204"}, + {file = "coverage-6.1.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:c18725f3cffe96732ef96f3de1939d81215fd6d7d64900dcc4acfe514ea4fcbf"}, + {file = "coverage-6.1.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:c8e9c4bcaaaa932be581b3d8b88b677489975f845f7714efc8cce77568b6711c"}, + {file = "coverage-6.1.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:06d009e8a29483cbc0520665bc46035ffe9ae0e7484a49f9782c2a716e37d0a0"}, + {file = "coverage-6.1.2-cp36-cp36m-win32.whl", hash = "sha256:e5432d9c329b11c27be45ee5f62cf20a33065d482c8dec1941d6670622a6fb8f"}, + {file = "coverage-6.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:82fdcb64bf08aa5db881db061d96db102c77397a570fbc112e21c48a4d9cb31b"}, + {file = "coverage-6.1.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:94f558f8555e79c48c422045f252ef41eb43becdd945e9c775b45ebfc0cbd78f"}, + {file = "coverage-6.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:046647b96969fda1ae0605f61288635209dd69dcd27ba3ec0bf5148bc157f954"}, + {file = "coverage-6.1.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:cc799916b618ec9fd00135e576424165691fec4f70d7dc12cfaef09268a2478c"}, + {file = "coverage-6.1.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62646d98cf0381ffda301a816d6ac6c35fc97aa81b09c4c52d66a15c4bef9d7c"}, + {file = "coverage-6.1.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:27a3df08a855522dfef8b8635f58bab81341b2fb5f447819bc252da3aa4cf44c"}, + {file = "coverage-6.1.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:610c0ba11da8de3a753dc4b1f71894f9f9debfdde6559599f303286e70aeb0c2"}, + {file = "coverage-6.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:35b246ae3a2c042dc8f410c94bcb9754b18179cdb81ff9477a9089dbc9ecc186"}, + {file = "coverage-6.1.2-cp37-cp37m-win32.whl", hash = "sha256:0cde7d9fe2fb55ff68ebe7fb319ef188e9b88e0a3d1c9c5db7dd829cd93d2193"}, + {file = "coverage-6.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:958ac66272ff20e63d818627216e3d7412fdf68a2d25787b89a5c6f1eb7fdd93"}, + {file = "coverage-6.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a300b39c3d5905686c75a369d2a66e68fd01472ea42e16b38c948bd02b29e5bd"}, + {file = "coverage-6.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d3855d5d26292539861f5ced2ed042fc2aa33a12f80e487053aed3bcb6ced13"}, + {file = "coverage-6.1.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:586d38dfc7da4a87f5816b203ff06dd7c1bb5b16211ccaa0e9788a8da2b93696"}, + {file = "coverage-6.1.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a34fccb45f7b2d890183a263578d60a392a1a218fdc12f5bce1477a6a68d4373"}, + {file = "coverage-6.1.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bc1ee1318f703bc6c971da700d74466e9b86e0c443eb85983fb2a1bd20447263"}, + {file = "coverage-6.1.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3f546f48d5d80a90a266769aa613bc0719cb3e9c2ef3529d53f463996dd15a9d"}, + {file = "coverage-6.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd92ece726055e80d4e3f01fff3b91f54b18c9c357c48fcf6119e87e2461a091"}, + {file = "coverage-6.1.2-cp38-cp38-win32.whl", hash = "sha256:24ed38ec86754c4d5a706fbd5b52b057c3df87901a8610d7e5642a08ec07087e"}, + {file = "coverage-6.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:97ef6e9119bd39d60ef7b9cd5deea2b34869c9f0b9777450a7e3759c1ab09b9b"}, + {file = "coverage-6.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6e5a8c947a2a89c56655ecbb789458a3a8e3b0cbf4c04250331df8f647b3de59"}, + {file = "coverage-6.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a39590d1e6acf6a3c435c5d233f72f5d43b585f5be834cff1f21fec4afda225"}, + {file = "coverage-6.1.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9d2c2e3ce7b8cc932a2f918186964bd44de8c84e2f9ef72dc616f5bb8be22e71"}, + {file = "coverage-6.1.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3348865798c077c695cae00da0924136bb5cc501f236cfd6b6d9f7a3c94e0ec4"}, + {file = "coverage-6.1.2-cp39-cp39-win32.whl", hash = "sha256:fae3fe111670e51f1ebbc475823899524e3459ea2db2cb88279bbfb2a0b8a3de"}, + {file = "coverage-6.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:af45eea024c0e3a25462fade161afab4f0d9d9e0d5a5d53e86149f74f0a35ecc"}, + {file = "coverage-6.1.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:eab14fdd410500dae50fd14ccc332e65543e7b39f6fc076fe90603a0e5d2f929"}, + {file = "coverage-6.1.2.tar.gz", hash = "sha256:d9a635114b88c0ab462e0355472d00a180a5fbfd8511e7f18e4ac32652e7d972"}, +] +coveralls = [ + {file = "coveralls-3.3.1-py2.py3-none-any.whl", hash = "sha256:f42015f31d386b351d4226389b387ae173207058832fbf5c8ec4b40e27b16026"}, + {file = "coveralls-3.3.1.tar.gz", hash = "sha256:b32a8bb5d2df585207c119d6c01567b81fba690c9c10a753bfe27a335bfc43ea"}, +] +docopt = [ + {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, +] +docutils = [ + {file = "docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61"}, + {file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"}, +] +idna = [ + {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, + {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, +] +imagesize = [ + {file = "imagesize-1.3.0-py2.py3-none-any.whl", hash = "sha256:1db2f82529e53c3e929e8926a1fa9235aa82d0bd0c580359c67ec31b2fddaa8c"}, + {file = "imagesize-1.3.0.tar.gz", hash = "sha256:cd1750d452385ca327479d45b64d9c7729ecf0b3969a58148298c77092261f9d"}, +] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] +jinja2 = [ + {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"}, + {file = "Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"}, +] +logbook = [ + {file = "Logbook-1.5.3-cp27-cp27m-win32.whl", hash = "sha256:56ee54c11df3377314cedcd6507638f015b4b88c0238c2e01b5eb44fd3a6ad1b"}, + {file = "Logbook-1.5.3-cp27-cp27m-win_amd64.whl", hash = "sha256:2dc85f1510533fddb481e97677bb7bca913560862734c0b3b289bfed04f78c92"}, + {file = "Logbook-1.5.3-cp35-cp35m-win32.whl", hash = "sha256:94e2e11ff3c2304b0d09a36c6208e5ae756eb948b210e5cbd63cd8d27f911542"}, + {file = "Logbook-1.5.3-cp35-cp35m-win_amd64.whl", hash = "sha256:97fee1bd9605f76335b169430ed65e15e457a844b2121bd1d90a08cf7e30aba0"}, + {file = "Logbook-1.5.3-cp36-cp36m-win32.whl", hash = "sha256:7c533eb728b3d220b1b5414ba4635292d149d79f74f6973b4aa744c850ca944a"}, + {file = "Logbook-1.5.3-cp36-cp36m-win_amd64.whl", hash = "sha256:e18f7422214b1cf0240c56f884fd9c9b4ff9d0da2eabca9abccba56df7222f66"}, + {file = "Logbook-1.5.3-cp37-cp37m-win32.whl", hash = "sha256:8f76a2e7b1f72595f753228732f81ce342caf03babc3fed6bbdcf366f2f20f18"}, + {file = "Logbook-1.5.3-cp37-cp37m-win_amd64.whl", hash = "sha256:0cf2cdbfb65a03b5987d19109dacad13417809dcf697f66e1a7084fb21744ea9"}, + {file = "Logbook-1.5.3.tar.gz", hash = "sha256:66f454ada0f56eae43066f604a222b09893f98c1adc18df169710761b8f32fe8"}, +] +markupsafe = [ + {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, + {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, +] +mypy = [ + {file = "mypy-0.910-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a155d80ea6cee511a3694b108c4494a39f42de11ee4e61e72bc424c490e46457"}, + {file = "mypy-0.910-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b94e4b785e304a04ea0828759172a15add27088520dc7e49ceade7834275bedb"}, + {file = "mypy-0.910-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:088cd9c7904b4ad80bec811053272986611b84221835e079be5bcad029e79dd9"}, + {file = "mypy-0.910-cp35-cp35m-win_amd64.whl", hash = "sha256:adaeee09bfde366d2c13fe6093a7df5df83c9a2ba98638c7d76b010694db760e"}, + {file = "mypy-0.910-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ecd2c3fe726758037234c93df7e98deb257fd15c24c9180dacf1ef829da5f921"}, + {file = "mypy-0.910-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d9dd839eb0dc1bbe866a288ba3c1afc33a202015d2ad83b31e875b5905a079b6"}, + {file = "mypy-0.910-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:3e382b29f8e0ccf19a2df2b29a167591245df90c0b5a2542249873b5c1d78212"}, + {file = "mypy-0.910-cp36-cp36m-win_amd64.whl", hash = "sha256:53fd2eb27a8ee2892614370896956af2ff61254c275aaee4c230ae771cadd885"}, + {file = "mypy-0.910-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b6fb13123aeef4a3abbcfd7e71773ff3ff1526a7d3dc538f3929a49b42be03f0"}, + {file = "mypy-0.910-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e4dab234478e3bd3ce83bac4193b2ecd9cf94e720ddd95ce69840273bf44f6de"}, + {file = "mypy-0.910-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:7df1ead20c81371ccd6091fa3e2878559b5c4d4caadaf1a484cf88d93ca06703"}, + {file = "mypy-0.910-cp37-cp37m-win_amd64.whl", hash = "sha256:0aadfb2d3935988ec3815952e44058a3100499f5be5b28c34ac9d79f002a4a9a"}, + {file = "mypy-0.910-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ec4e0cd079db280b6bdabdc807047ff3e199f334050db5cbb91ba3e959a67504"}, + {file = "mypy-0.910-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:119bed3832d961f3a880787bf621634ba042cb8dc850a7429f643508eeac97b9"}, + {file = "mypy-0.910-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:866c41f28cee548475f146aa4d39a51cf3b6a84246969f3759cb3e9c742fc072"}, + {file = "mypy-0.910-cp38-cp38-win_amd64.whl", hash = "sha256:ceb6e0a6e27fb364fb3853389607cf7eb3a126ad335790fa1e14ed02fba50811"}, + {file = "mypy-0.910-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a85e280d4d217150ce8cb1a6dddffd14e753a4e0c3cf90baabb32cefa41b59e"}, + {file = "mypy-0.910-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42c266ced41b65ed40a282c575705325fa7991af370036d3f134518336636f5b"}, + {file = "mypy-0.910-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:3c4b8ca36877fc75339253721f69603a9c7fdb5d4d5a95a1a1b899d8b86a4de2"}, + {file = "mypy-0.910-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:c0df2d30ed496a08de5daed2a9ea807d07c21ae0ab23acf541ab88c24b26ab97"}, + {file = "mypy-0.910-cp39-cp39-win_amd64.whl", hash = "sha256:c6c2602dffb74867498f86e6129fd52a2770c48b7cd3ece77ada4fa38f94eba8"}, + {file = "mypy-0.910-py3-none-any.whl", hash = "sha256:ef565033fa5a958e62796867b1df10c40263ea9ded87164d67572834e57a174d"}, + {file = "mypy-0.910.tar.gz", hash = "sha256:704098302473cb31a218f1775a873b376b30b4c18229421e9e9dc8916fd16150"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] +outcome = [ + {file = "outcome-1.1.0-py2.py3-none-any.whl", hash = "sha256:c7dd9375cfd3c12db9801d080a3b63d4b0a261aa996c4c13152380587288d958"}, + {file = "outcome-1.1.0.tar.gz", hash = "sha256:e862f01d4e626e63e8f92c38d1f8d5546d3f9cce989263c521b2e7990d186967"}, +] +packaging = [ + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, +] +pathspec = [ + {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, + {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, +] +platformdirs = [ + {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"}, + {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"}, +] +pluggy = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] +py = [ + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +] +pycparser = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] +pygments = [ + {file = "Pygments-2.10.0-py3-none-any.whl", hash = "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380"}, + {file = "Pygments-2.10.0.tar.gz", hash = "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"}, +] +pyparsing = [ + {file = "pyparsing-3.0.6-py3-none-any.whl", hash = "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4"}, + {file = "pyparsing-3.0.6.tar.gz", hash = "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"}, +] +pytest = [ + {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, + {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, +] +pytest-cov = [ + {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, + {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, +] +pytest-trio = [ + {file = "pytest-trio-0.7.0.tar.gz", hash = "sha256:c01b741819aec2c419555f28944e132d3c711dae1e673d63260809bf92c30c31"}, +] +pytz = [ + {file = "pytz-2021.3-py2.py3-none-any.whl", hash = "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c"}, + {file = "pytz-2021.3.tar.gz", hash = "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326"}, +] +regex = [ + {file = "regex-2021.11.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9345b6f7ee578bad8e475129ed40123d265464c4cfead6c261fd60fc9de00bcf"}, + {file = "regex-2021.11.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:416c5f1a188c91e3eb41e9c8787288e707f7d2ebe66e0a6563af280d9b68478f"}, + {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0538c43565ee6e703d3a7c3bdfe4037a5209250e8502c98f20fea6f5fdf2965"}, + {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ee1227cf08b6716c85504aebc49ac827eb88fcc6e51564f010f11a406c0a667"}, + {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6650f16365f1924d6014d2ea770bde8555b4a39dc9576abb95e3cd1ff0263b36"}, + {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30ab804ea73972049b7a2a5c62d97687d69b5a60a67adca07eb73a0ddbc9e29f"}, + {file = "regex-2021.11.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:68a067c11463de2a37157930d8b153005085e42bcb7ad9ca562d77ba7d1404e0"}, + {file = "regex-2021.11.10-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:162abfd74e88001d20cb73ceaffbfe601469923e875caf9118333b1a4aaafdc4"}, + {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b9ed0b1e5e0759d6b7f8e2f143894b2a7f3edd313f38cf44e1e15d360e11749b"}, + {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:473e67837f786404570eae33c3b64a4b9635ae9f00145250851a1292f484c063"}, + {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2fee3ed82a011184807d2127f1733b4f6b2ff6ec7151d83ef3477f3b96a13d03"}, + {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:d5fd67df77bab0d3f4ea1d7afca9ef15c2ee35dfb348c7b57ffb9782a6e4db6e"}, + {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5d408a642a5484b9b4d11dea15a489ea0928c7e410c7525cd892f4d04f2f617b"}, + {file = "regex-2021.11.10-cp310-cp310-win32.whl", hash = "sha256:98ba568e8ae26beb726aeea2273053c717641933836568c2a0278a84987b2a1a"}, + {file = "regex-2021.11.10-cp310-cp310-win_amd64.whl", hash = "sha256:780b48456a0f0ba4d390e8b5f7c661fdd218934388cde1a974010a965e200e12"}, + {file = "regex-2021.11.10-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:dba70f30fd81f8ce6d32ddeef37d91c8948e5d5a4c63242d16a2b2df8143aafc"}, + {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1f54b9b4b6c53369f40028d2dd07a8c374583417ee6ec0ea304e710a20f80a0"}, + {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fbb9dc00e39f3e6c0ef48edee202f9520dafb233e8b51b06b8428cfcb92abd30"}, + {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:666abff54e474d28ff42756d94544cdfd42e2ee97065857413b72e8a2d6a6345"}, + {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5537f71b6d646f7f5f340562ec4c77b6e1c915f8baae822ea0b7e46c1f09b733"}, + {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed2e07c6a26ed4bea91b897ee2b0835c21716d9a469a96c3e878dc5f8c55bb23"}, + {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ca5f18a75e1256ce07494e245cdb146f5a9267d3c702ebf9b65c7f8bd843431e"}, + {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:74cbeac0451f27d4f50e6e8a8f3a52ca074b5e2da9f7b505c4201a57a8ed6286"}, + {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:3598893bde43091ee5ca0a6ad20f08a0435e93a69255eeb5f81b85e81e329264"}, + {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:50a7ddf3d131dc5633dccdb51417e2d1910d25cbcf842115a3a5893509140a3a"}, + {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:61600a7ca4bcf78a96a68a27c2ae9389763b5b94b63943d5158f2a377e09d29a"}, + {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:563d5f9354e15e048465061509403f68424fef37d5add3064038c2511c8f5e00"}, + {file = "regex-2021.11.10-cp36-cp36m-win32.whl", hash = "sha256:93a5051fcf5fad72de73b96f07d30bc29665697fb8ecdfbc474f3452c78adcf4"}, + {file = "regex-2021.11.10-cp36-cp36m-win_amd64.whl", hash = "sha256:b483c9d00a565633c87abd0aaf27eb5016de23fed952e054ecc19ce32f6a9e7e"}, + {file = "regex-2021.11.10-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fff55f3ce50a3ff63ec8e2a8d3dd924f1941b250b0aac3d3d42b687eeff07a8e"}, + {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e32d2a2b02ccbef10145df9135751abea1f9f076e67a4e261b05f24b94219e36"}, + {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53db2c6be8a2710b359bfd3d3aa17ba38f8aa72a82309a12ae99d3c0c3dcd74d"}, + {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2207ae4f64ad3af399e2d30dde66f0b36ae5c3129b52885f1bffc2f05ec505c8"}, + {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5ca078bb666c4a9d1287a379fe617a6dccd18c3e8a7e6c7e1eb8974330c626a"}, + {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd33eb9bdcfbabab3459c9ee651d94c842bc8a05fabc95edf4ee0c15a072495e"}, + {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:05b7d6d7e64efe309972adab77fc2af8907bb93217ec60aa9fe12a0dad35874f"}, + {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:42b50fa6666b0d50c30a990527127334d6b96dd969011e843e726a64011485da"}, + {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:6e1d2cc79e8dae442b3fa4a26c5794428b98f81389af90623ffcc650ce9f6732"}, + {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:0416f7399e918c4b0e074a0f66e5191077ee2ca32a0f99d4c187a62beb47aa05"}, + {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:ce298e3d0c65bd03fa65ffcc6db0e2b578e8f626d468db64fdf8457731052942"}, + {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dc07f021ee80510f3cd3af2cad5b6a3b3a10b057521d9e6aaeb621730d320c5a"}, + {file = "regex-2021.11.10-cp37-cp37m-win32.whl", hash = "sha256:e71255ba42567d34a13c03968736c5d39bb4a97ce98188fafb27ce981115beec"}, + {file = "regex-2021.11.10-cp37-cp37m-win_amd64.whl", hash = "sha256:07856afef5ffcc052e7eccf3213317fbb94e4a5cd8177a2caa69c980657b3cb4"}, + {file = "regex-2021.11.10-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ba05430e819e58544e840a68b03b28b6d328aff2e41579037e8bab7653b37d83"}, + {file = "regex-2021.11.10-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7f301b11b9d214f83ddaf689181051e7f48905568b0c7017c04c06dfd065e244"}, + {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aaa4e0705ef2b73dd8e36eeb4c868f80f8393f5f4d855e94025ce7ad8525f50"}, + {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:788aef3549f1924d5c38263104dae7395bf020a42776d5ec5ea2b0d3d85d6646"}, + {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f8af619e3be812a2059b212064ea7a640aff0568d972cd1b9e920837469eb3cb"}, + {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85bfa6a5413be0ee6c5c4a663668a2cad2cbecdee367630d097d7823041bdeec"}, + {file = "regex-2021.11.10-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f23222527b307970e383433daec128d769ff778d9b29343fb3496472dc20dabe"}, + {file = "regex-2021.11.10-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:da1a90c1ddb7531b1d5ff1e171b4ee61f6345119be7351104b67ff413843fe94"}, + {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f5be7805e53dafe94d295399cfbe5227f39995a997f4fd8539bf3cbdc8f47ca8"}, + {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a955b747d620a50408b7fdf948e04359d6e762ff8a85f5775d907ceced715129"}, + {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:139a23d1f5d30db2cc6c7fd9c6d6497872a672db22c4ae1910be22d4f4b2068a"}, + {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:ca49e1ab99593438b204e00f3970e7a5f70d045267051dfa6b5f4304fcfa1dbf"}, + {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:96fc32c16ea6d60d3ca7f63397bff5c75c5a562f7db6dec7d412f7c4d2e78ec0"}, + {file = "regex-2021.11.10-cp38-cp38-win32.whl", hash = "sha256:0617383e2fe465732af4509e61648b77cbe3aee68b6ac8c0b6fe934db90be5cc"}, + {file = "regex-2021.11.10-cp38-cp38-win_amd64.whl", hash = "sha256:a3feefd5e95871872673b08636f96b61ebef62971eab044f5124fb4dea39919d"}, + {file = "regex-2021.11.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f7f325be2804246a75a4f45c72d4ce80d2443ab815063cdf70ee8fb2ca59ee1b"}, + {file = "regex-2021.11.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:537ca6a3586931b16a85ac38c08cc48f10fc870a5b25e51794c74df843e9966d"}, + {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eef2afb0fd1747f33f1ee3e209bce1ed582d1896b240ccc5e2697e3275f037c7"}, + {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:432bd15d40ed835a51617521d60d0125867f7b88acf653e4ed994a1f8e4995dc"}, + {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b43c2b8a330a490daaef5a47ab114935002b13b3f9dc5da56d5322ff218eeadb"}, + {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:962b9a917dd7ceacbe5cd424556914cb0d636001e393b43dc886ba31d2a1e449"}, + {file = "regex-2021.11.10-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fa8c626d6441e2d04b6ee703ef2d1e17608ad44c7cb75258c09dd42bacdfc64b"}, + {file = "regex-2021.11.10-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3c5fb32cc6077abad3bbf0323067636d93307c9fa93e072771cf9a64d1c0f3ef"}, + {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cd410a1cbb2d297c67d8521759ab2ee3f1d66206d2e4328502a487589a2cb21b"}, + {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e6096b0688e6e14af6a1b10eaad86b4ff17935c49aa774eac7c95a57a4e8c296"}, + {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:529801a0d58809b60b3531ee804d3e3be4b412c94b5d267daa3de7fadef00f49"}, + {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0f594b96fe2e0821d026365f72ac7b4f0b487487fb3d4aaf10dd9d97d88a9737"}, + {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2409b5c9cef7054dde93a9803156b411b677affc84fca69e908b1cb2c540025d"}, + {file = "regex-2021.11.10-cp39-cp39-win32.whl", hash = "sha256:3b5df18db1fccd66de15aa59c41e4f853b5df7550723d26aa6cb7f40e5d9da5a"}, + {file = "regex-2021.11.10-cp39-cp39-win_amd64.whl", hash = "sha256:83ee89483672b11f8952b158640d0c0ff02dc43d9cb1b70c1564b49abe92ce29"}, + {file = "regex-2021.11.10.tar.gz", hash = "sha256:f341ee2df0999bfdf7a95e448075effe0db212a59387de1a70690e4acb03d4c6"}, +] +requests = [ + {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"}, + {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"}, +] +sniffio = [ + {file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"}, + {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"}, +] +snowballstemmer = [ + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, +] +sortedcontainers = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] +sphinx = [ + {file = "Sphinx-4.3.0-py3-none-any.whl", hash = "sha256:7e2b30da5f39170efcd95c6270f07669d623c276521fee27ad6c380f49d2bf5b"}, + {file = "Sphinx-4.3.0.tar.gz", hash = "sha256:6d051ab6e0d06cba786c4656b0fe67ba259fe058410f49e95bee6e49c4052cbf"}, +] +sphinx-rtd-theme = [ + {file = "sphinx_rtd_theme-1.0.0-py2.py3-none-any.whl", hash = "sha256:4d35a56f4508cfee4c4fb604373ede6feae2a306731d533f409ef5c3496fdbd8"}, + {file = "sphinx_rtd_theme-1.0.0.tar.gz", hash = "sha256:eec6d497e4c2195fa0e8b2016b337532b8a699a68bcb22a512870e16925c6a5c"}, +] +sphinxcontrib-applehelp = [ + {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"}, + {file = "sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a"}, +] +sphinxcontrib-devhelp = [ + {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, + {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, +] +sphinxcontrib-htmlhelp = [ + {file = "sphinxcontrib-htmlhelp-2.0.0.tar.gz", hash = "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2"}, + {file = "sphinxcontrib_htmlhelp-2.0.0-py2.py3-none-any.whl", hash = "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07"}, +] +sphinxcontrib-jsmath = [ + {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, + {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, +] +sphinxcontrib-qthelp = [ + {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, + {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, +] +sphinxcontrib-serializinghtml = [ + {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, + {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, +] +tenacity = [ + {file = "tenacity-8.0.1-py3-none-any.whl", hash = "sha256:f78f4ea81b0fabc06728c11dc2a8c01277bfc5181b321a4770471902e3eb844a"}, + {file = "tenacity-8.0.1.tar.gz", hash = "sha256:43242a20e3e73291a28bcbcacfd6e000b02d3857a9a9fff56b297a27afdc932f"}, +] +toml = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] +tomli = [ + {file = "tomli-1.2.2-py3-none-any.whl", hash = "sha256:f04066f68f5554911363063a30b108d2b5a5b1a010aa8b6132af78489fe3aade"}, + {file = "tomli-1.2.2.tar.gz", hash = "sha256:c6ce0015eb38820eaf32b5db832dbc26deb3dd427bd5f6556cf0acac2c214fee"}, +] +trio = [ + {file = "trio-0.19.0-py3-none-any.whl", hash = "sha256:c27c231e66336183c484fbfe080fa6cc954149366c15dc21db8b7290081ec7b8"}, + {file = "trio-0.19.0.tar.gz", hash = "sha256:895e318e5ec5e8cea9f60b473b6edb95b215e82d99556a03eb2d20c5e027efe1"}, +] +trio-util = [ + {file = "trio_util-0.7.0-py3-none-any.whl", hash = "sha256:a02b3daaf4996d7363f3fdfdc64157ef13d3d3725dff0dcaa0b47f4a6b5af4af"}, + {file = "trio_util-0.7.0.tar.gz", hash = "sha256:8dc5014dd00e6a24d0f048f8b723f2804c61ddc08ace9555db10fb7e1fee704a"}, +] +typing-extensions = [ + {file = "typing_extensions-4.0.0-py3-none-any.whl", hash = "sha256:829704698b22e13ec9eaf959122315eabb370b0884400e9818334d8b677023d9"}, + {file = "typing_extensions-4.0.0.tar.gz", hash = "sha256:2cdf80e4e04866a9b3689a51869016d36db0814d84b8d8a568d22781d45d27ed"}, +] +urllib3 = [ + {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"}, + {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6962fae --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +[tool.poetry] +name = "triotp" +version = "0.1.0" +license = "MIT" + +description = "The OTP framework for Python Trio" +keywords = ["trio", "async", "otp", "triotp"] +authors = ["David Delassus "] + +homepage = "https://github.com/linkdd/triotp" +repository = "https://github.com/linkdd/triotp" + +readme = "README.rst" +include = [ + "LICENSE.txt" +] + +classifiers = [ + "Development Status :: 4 - Beta", + "Framework :: Trio", + "Intended Audience :: Developers", + "Topic :: Software Development" +] + +[tool.poetry.dependencies] +python = "^3.10" +trio = "^0.19.0" +tenacity = "^8.0.1" +Logbook = "^1.5.3" +trio-util = "^0.7.0" + +[tool.poetry.dev-dependencies] +pytest = "^6.2.5" +pytest-trio = "^0.7.0" +Sphinx = "^4.3.0" +sphinx-rtd-theme = "^1.0.0" +pytest-cov = "^3.0.0" +black = "^21.11b1" +mypy = "^0.910" +coveralls = "^3.3.1" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..5f4a13a --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +trio_mode = true diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1dc2def --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,19 @@ +import pytest + +from triotp import mailbox, application +import trio + +import logbook + + +@pytest.fixture +def log_handler(): + handler = logbook.TestHandler(level=logbook.DEBUG) + + with handler.applicationbound(): + yield handler + + +@pytest.fixture +def mailbox_env(): + mailbox._init() diff --git a/tests/test_application/__init__.py b/tests/test_application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_application/conftest.py b/tests/test_application/conftest.py new file mode 100644 index 0000000..0eb37b9 --- /dev/null +++ b/tests/test_application/conftest.py @@ -0,0 +1,14 @@ +import pytest + +import trio + + +class SampleData: + def __init__(self): + self.count = 0 + self.stop = trio.Event() + + +@pytest.fixture +def test_data(): + return SampleData() diff --git a/tests/test_application/sample/__init__.py b/tests/test_application/sample/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_application/sample/app_a.py b/tests/test_application/sample/app_a.py new file mode 100644 index 0000000..e4b40be --- /dev/null +++ b/tests/test_application/sample/app_a.py @@ -0,0 +1,2 @@ +async def start(test_data): + test_data.count += 1 diff --git a/tests/test_application/sample/app_b.py b/tests/test_application/sample/app_b.py new file mode 100644 index 0000000..c6808a7 --- /dev/null +++ b/tests/test_application/sample/app_b.py @@ -0,0 +1,3 @@ +async def start(test_data): + test_data.count += 1 + raise RuntimeError('pytest') diff --git a/tests/test_application/sample/app_c.py b/tests/test_application/sample/app_c.py new file mode 100644 index 0000000..c1404d5 --- /dev/null +++ b/tests/test_application/sample/app_c.py @@ -0,0 +1,3 @@ +async def start(test_data): + test_data.count += 1 + await test_data.stop.wait() diff --git a/tests/test_application/test_restart.py b/tests/test_application/test_restart.py new file mode 100644 index 0000000..a5852d0 --- /dev/null +++ b/tests/test_application/test_restart.py @@ -0,0 +1,65 @@ +import pytest + +from triotp import application, supervisor +import trio + +from .sample import app_a, app_b + + + + +@pytest.mark.parametrize("max_restarts", [1, 3, 5]) +async def test_app_automatic_restart_permanent(test_data, max_restarts, log_handler): + async with trio.open_nursery() as nursery: + application._init(nursery) + + await application.start( + application.app_spec( + module=app_a, + start_arg=test_data, + permanent=True, + opts=supervisor.options( + max_restarts=max_restarts, + ), + ) + ) + + assert test_data.count == (max_restarts + 1) + assert log_handler.has_errors + + +@pytest.mark.parametrize("max_restarts", [1, 3, 5]) +async def test_app_automatic_restart_crash(test_data, max_restarts, log_handler): + with pytest.raises(RuntimeError): + async with trio.open_nursery() as nursery: + application._init(nursery) + + await application.start( + application.app_spec( + module=app_b, + start_arg=test_data, + permanent=False, + opts=supervisor.options( + max_restarts=max_restarts, + ), + ) + ) + + assert test_data.count == (max_restarts + 1) + assert log_handler.has_errors + + +async def test_app_no_automatic_restart(test_data, log_handler): + async with trio.open_nursery() as nursery: + application._init(nursery) + + await application.start( + application.app_spec( + module=app_a, + start_arg=test_data, + permanent=False, + ) + ) + + assert test_data.count == 1 + assert not log_handler.has_errors diff --git a/tests/test_application/test_stop.py b/tests/test_application/test_stop.py new file mode 100644 index 0000000..56cd510 --- /dev/null +++ b/tests/test_application/test_stop.py @@ -0,0 +1,24 @@ +import pytest + +from triotp import application, supervisor +import trio + +from .sample import app_c + + +async def test_app_stop(test_data, log_handler): + async with trio.open_nursery() as nursery: + application._init(nursery) + + await application.start( + application.app_spec( + module=app_c, + start_arg=test_data, + permanent=True, + ) + ) + + await trio.sleep(0.01) + await application.stop(app_c.__name__) + + assert test_data.count == 1 diff --git a/tests/test_dynamic_supervisor.py b/tests/test_dynamic_supervisor.py new file mode 100644 index 0000000..3e9c484 --- /dev/null +++ b/tests/test_dynamic_supervisor.py @@ -0,0 +1,113 @@ +import pytest + +from triotp import supervisor, dynamic_supervisor +import trio + + +class SampleData: + def __init__(self): + self.exec_count = 0 + + +async def sample_task(test_data): + test_data.exec_count += 1 + + +async def sample_task_error(test_data): + test_data.exec_count += 1 + raise RuntimeError('pytest') + + +@pytest.mark.parametrize("max_restarts", [1, 3, 5]) +async def test_automatic_restart_permanent(max_restarts, log_handler, mailbox_env): + test_data = SampleData() + + async with trio.open_nursery() as nursery: + children = [ + supervisor.child_spec( + id='sample_task', + task=sample_task, + args=[test_data], + restart=supervisor.restart_strategy.PERMANENT, + ), + ] + opts = supervisor.options( + max_restarts=max_restarts, + max_seconds=5, + ) + mid = await nursery.start(dynamic_supervisor.start, opts) + + for child_spec in children: + await dynamic_supervisor.start_child(mid, child_spec) + + await trio.sleep(0.5) + nursery.cancel_scope.cancel() + + assert test_data.exec_count == (max_restarts + 1) + assert log_handler.has_errors + + +@pytest.mark.parametrize("max_restarts", [1, 3, 5]) +@pytest.mark.parametrize("strategy", [ + supervisor.restart_strategy.PERMANENT, + supervisor.restart_strategy.TRANSIENT, +]) +async def test_automatic_restart_crash(max_restarts, strategy, log_handler, mailbox_env): + test_data = SampleData() + + with pytest.raises(RuntimeError): + async with trio.open_nursery() as nursery: + children = [ + supervisor.child_spec( + id='sample_task', + task=sample_task_error, + args=[test_data], + restart=strategy, + ), + ] + opts = supervisor.options( + max_restarts=max_restarts, + max_seconds=5, + ) + mid = await nursery.start(dynamic_supervisor.start, opts) + + for child_spec in children: + await dynamic_supervisor.start_child(mid, child_spec) + + await trio.sleep(0.5) + nursery.cancel_scope.cancel() + + assert test_data.exec_count == (max_restarts + 1) + assert log_handler.has_errors + + +@pytest.mark.parametrize("strategy", [ + supervisor.restart_strategy.TEMPORARY, + supervisor.restart_strategy.TRANSIENT, +]) +async def test_no_restart(strategy, log_handler, mailbox_env): + test_data = SampleData() + + async with trio.open_nursery() as nursery: + children = [ + supervisor.child_spec( + id='sample_task', + task=sample_task, + args=[test_data], + restart=strategy, + ), + ] + opts = supervisor.options( + max_restarts=3, + max_seconds=5, + ) + mid = await nursery.start(dynamic_supervisor.start, opts) + + for child_spec in children: + await dynamic_supervisor.start_child(mid, child_spec) + + await trio.sleep(0.5) + nursery.cancel_scope.cancel() + + assert test_data.exec_count == 1 + assert not log_handler.has_errors diff --git a/tests/test_gen_server/__init__.py b/tests/test_gen_server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_gen_server/conftest.py b/tests/test_gen_server/conftest.py new file mode 100644 index 0000000..33a91f4 --- /dev/null +++ b/tests/test_gen_server/conftest.py @@ -0,0 +1,34 @@ +import pytest + +from . import sample_kvstore +import trio + + +class GenServerTestState: + def __init__(self): + self.ready = trio.Event() + self.stopped = trio.Event() + self.info = trio.Event() + self.casted = trio.Event() + + self.data = {} + self.did_raise = None + self.terminated_with = None + + self.info_val = None + self.unknown_info = [] + + +@pytest.fixture +async def test_state(mailbox_env): + test_state = GenServerTestState() + + async with trio.open_nursery() as nursery: + nursery.start_soon(sample_kvstore.start, test_state) + + with trio.fail_after(0.1): + await test_state.ready.wait() + + yield test_state + + nursery.cancel_scope.cancel() diff --git a/tests/test_gen_server/sample_kvstore.py b/tests/test_gen_server/sample_kvstore.py new file mode 100644 index 0000000..ea12ea7 --- /dev/null +++ b/tests/test_gen_server/sample_kvstore.py @@ -0,0 +1,182 @@ +from triotp.helpers import current_module +from triotp import gen_server, mailbox +import trio + + +__module__ = current_module() + + +async def start(test_state): + try: + await gen_server.start(__module__, test_state, name=__name__) + + except Exception as err: + test_state.did_raise = err + + finally: + test_state.stopped.set() + + +class api: + """ + Normal KVStore API + """ + + @staticmethod + async def get(key): + return await gen_server.call(__name__, ('api_get', key)) + + + @staticmethod + async def set(key, val): + return await gen_server.call(__name__, ('api_set', key, val)) + + + @staticmethod + async def clear(): + return await gen_server.call(__name__, 'api_clear') + + +class special_call: + """ + Special edge cases for gen_server.call + """ + + @staticmethod + async def delayed(nursery): + return await gen_server.call(__name__, ('special_call_delayed', nursery)) + + + @staticmethod + async def timedout(timeout): + return await gen_server.call(__name__, 'special_call_timedout', timeout=timeout) + + + @staticmethod + async def stopped(): + return await gen_server.call(__name__, 'special_call_stopped') + + + @staticmethod + async def failure(): + return await gen_server.call(__name__, 'special_call_failure') + + +class special_cast: + """ + Special edge cases for gen_server.cast + """ + + @staticmethod + async def normal(): + await gen_server.cast(__name__, 'special_cast_normal') + + + @staticmethod + async def stop(): + await gen_server.cast(__name__, 'special_cast_stop') + + + @staticmethod + async def fail(): + await gen_server.cast(__name__, 'special_cast_fail') + + +class special_info: + """ + Special edge cases for direct messages + """ + + async def matched(val): + await mailbox.send(__name__, ('special_info_matched', val)) + + + async def no_match(val): + await mailbox.send(__name__, ('special_info_no_match', val)) + + + async def stop(): + await mailbox.send(__name__, 'special_info_stop') + + + async def fail(): + await mailbox.send(__name__, 'special_info_fail') + + +# gen_server callbacks + +async def init(test_state): + test_state.ready.set() + return test_state + + +async def terminate(reason, test_state): + test_state.terminated_with = reason + + +async def handle_call(message, caller, test_state): + match message: + case ('api_get', key): + val = test_state.data.get(key) + return (gen_server.Reply(val), test_state) + + case ('api_set', key, val): + prev = test_state.data.get(key) + test_state.data[key] = val + return (gen_server.Reply(prev), test_state) + + case ('special_call_delayed', nursery): + async def slow_task(): + await trio.sleep(0) + await gen_server.reply(caller, 'done') + + nursery.start_soon(slow_task) + return (gen_server.NoReply(), test_state) + + case 'special_call_timedout': + return (gen_server.NoReply(), test_state) + + case 'special_call_stopped': + return (gen_server.Stop(), test_state) + + case 'special_call_failure': + exc = RuntimeError('pytest') + return (gen_server.Stop(exc), test_state) + + case _: + exc = NotImplementedError('wrong call') + return (gen_server.Reply(exc), test_state) + + +async def handle_cast(message, test_state): + match message: + case 'special_cast_normal': + test_state.casted.set() + return (gen_server.NoReply(), test_state) + + case 'special_cast_stop': + return (gen_server.Stop(), test_state) + + case _: + exc = NotImplementedError('wrong cast') + return (gen_server.Stop(exc), test_state) + + +async def handle_info(message, test_state): + match message: + case ('special_info_matched', val): + test_state.info_val = val + test_state.info.set() + return (gen_server.NoReply(), test_state) + + case 'special_info_stop': + return (gen_server.Stop(), test_state) + + case 'special_info_fail': + exc = RuntimeError('pytest') + return (gen_server.Stop(exc), test_state) + + case _: + test_state.unknown_info.append(message) + test_state.info.set() + return (gen_server.NoReply(), test_state) diff --git a/tests/test_gen_server/test_api.py b/tests/test_gen_server/test_api.py new file mode 100644 index 0000000..b45f810 --- /dev/null +++ b/tests/test_gen_server/test_api.py @@ -0,0 +1,23 @@ +import pytest + +from . import sample_kvstore as kvstore + + +async def test_kvstore_api(test_state): + val = await kvstore.api.get('foo') + assert val is None + + val = await kvstore.api.set('foo', 'bar') + assert val is None + + val = await kvstore.api.get('foo') + assert val == 'bar' + + val = await kvstore.api.set('foo', 'baz') + assert val == 'bar' + + val = await kvstore.api.get('foo') + assert val == 'baz' + + with pytest.raises(NotImplementedError): + await kvstore.api.clear() diff --git a/tests/test_gen_server/test_call.py b/tests/test_gen_server/test_call.py new file mode 100644 index 0000000..914655b --- /dev/null +++ b/tests/test_gen_server/test_call.py @@ -0,0 +1,41 @@ +import pytest + +from . import sample_kvstore as kvstore + +from triotp.gen_server import GenServerExited +import trio + + +async def test_kvstore_call_delayed(test_state): + async with trio.open_nursery() as nursery: + resp = await kvstore.special_call.delayed(nursery) + + assert resp == 'done' + + +async def test_kvstore_call_timeout(test_state): + with pytest.raises(trio.TooSlowError): + await kvstore.special_call.timedout(0.01) + + + +async def test_kvstore_call_stopped(test_state): + with pytest.raises(GenServerExited): + await kvstore.special_call.stopped() + + with trio.fail_after(0.1): + await test_state.stopped.wait() + + assert test_state.terminated_with is None + assert test_state.did_raise is None + + +async def test_kvstore_call_failure(test_state): + with pytest.raises(GenServerExited): + await kvstore.special_call.failure() + + with trio.fail_after(0.1): + await test_state.stopped.wait() + + assert isinstance(test_state.terminated_with, RuntimeError) + assert test_state.did_raise is test_state.terminated_with diff --git a/tests/test_gen_server/test_cast.py b/tests/test_gen_server/test_cast.py new file mode 100644 index 0000000..2d9d71b --- /dev/null +++ b/tests/test_gen_server/test_cast.py @@ -0,0 +1,29 @@ +from . import sample_kvstore as kvstore +import trio + + +async def test_kvstore_cast_normal(test_state): + await kvstore.special_cast.normal() + + with trio.fail_after(0.1): + await test_state.casted.wait() + + +async def tests_kvstore_cast_stop(test_state): + await kvstore.special_cast.stop() + + with trio.fail_after(0.1): + await test_state.stopped.wait() + + assert test_state.terminated_with is None + assert test_state.did_raise is None + + +async def test_kvstore_cast_fail(test_state): + await kvstore.special_cast.fail() + + with trio.fail_after(0.1): + await test_state.stopped.wait() + + assert isinstance(test_state.terminated_with, NotImplementedError) + assert test_state.did_raise is test_state.terminated_with diff --git a/tests/test_gen_server/test_info.py b/tests/test_gen_server/test_info.py new file mode 100644 index 0000000..b4f6831 --- /dev/null +++ b/tests/test_gen_server/test_info.py @@ -0,0 +1,41 @@ +from . import sample_kvstore as kvstore +import trio + +async def test_kvstore_info_stop(test_state): + await kvstore.special_info.stop() + + with trio.fail_after(0.1): + await test_state.stopped.wait() + + assert test_state.terminated_with is None + assert test_state.did_raise is None + + +async def test_kvstore_info_fail(test_state): + await kvstore.special_info.fail() + + with trio.fail_after(0.1): + await test_state.stopped.wait() + + assert isinstance(test_state.terminated_with, RuntimeError) + assert test_state.did_raise is test_state.terminated_with + + +async def test_kvstore_info_matched(test_state): + await kvstore.special_info.matched('foo') + + with trio.fail_after(0.1): + await test_state.info.wait() + + assert test_state.info_val == 'foo' + + +async def test_kvstore_info_no_match(test_state): + await kvstore.special_info.no_match('foo') + + with trio.fail_after(0.1): + await test_state.info.wait() + + assert test_state.info_val is None + assert len(test_state.unknown_info) == 1 + assert test_state.unknown_info[0] == ('special_info_no_match', 'foo') diff --git a/tests/test_helpers/__init__.py b/tests/test_helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_helpers/sample.py b/tests/test_helpers/sample.py new file mode 100644 index 0000000..377f477 --- /dev/null +++ b/tests/test_helpers/sample.py @@ -0,0 +1,8 @@ +from triotp.helpers import current_module + + +__module__ = current_module() + + +def get_module(): + return current_module() diff --git a/tests/test_helpers/test_sample.py b/tests/test_helpers/test_sample.py new file mode 100644 index 0000000..a5624ff --- /dev/null +++ b/tests/test_helpers/test_sample.py @@ -0,0 +1,6 @@ +from . import sample + + +def test_current_module(): + assert sample is sample.__module__ + assert sample is not sample.get_module() diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..98535ed --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,34 @@ +import pytest + +from triotp import logging +import logbook + + +def test_logenum(): + assert logging.LogLevel.DEBUG.to_logbook() == logbook.DEBUG + assert logging.LogLevel.INFO.to_logbook() == logbook.INFO + assert logging.LogLevel.WARNING.to_logbook() == logbook.WARNING + assert logging.LogLevel.ERROR.to_logbook() == logbook.ERROR + assert logging.LogLevel.CRITICAL.to_logbook() == logbook.CRITICAL + + with pytest.raises(LookupError): + logging.LogLevel.NONE.to_logbook() + + +def test_logger(log_handler): + logger = logging.getLogger('pytest') + + logger.debug('foo') + assert log_handler.has_debug('foo', channel='pytest') + + logger.info('foo') + assert log_handler.has_info('foo', channel='pytest') + + logger.warn('foo') + assert log_handler.has_warning('foo', channel='pytest') + + logger.error('foo') + assert log_handler.has_error('foo', channel='pytest') + + logger.critical('foo') + assert log_handler.has_critical('foo', channel='pytest') diff --git a/tests/test_mailbox.py b/tests/test_mailbox.py new file mode 100644 index 0000000..04284f6 --- /dev/null +++ b/tests/test_mailbox.py @@ -0,0 +1,126 @@ +import pytest + +from triotp import mailbox +import trio + + +class Producer: + def __init__(self, mbox): + self.mbox = mbox + + async def __call__(self, message): + await mailbox.send(self.mbox, message) + + +class Consumer: + def __init__(self, mbox, timeout=None, with_on_timeout=True): + self.mbox = mbox + self.timeout = timeout + self.with_on_timeout = with_on_timeout + + self.received_message = None + self.timed_out = False + + async def on_timeout(self): + self.timed_out = True + return None + + async def __call__(self, task_status=trio.TASK_STATUS_IGNORED): + async with mailbox.open(self.mbox) as mid: + task_status.started(mid) + + cb = self.on_timeout if self.with_on_timeout else None + self.received_message = await mailbox.receive( + mid, + timeout=self.timeout, + on_timeout=cb, + ) + + +async def test_receive_no_timeout(mailbox_env): + producer = Producer('pytest') + consumer = Consumer('pytest') + + async with trio.open_nursery() as nursery: + await nursery.start(consumer) + nursery.start_soon(producer, 'foo') + + + assert not consumer.timed_out + assert consumer.received_message == 'foo' + + +async def test_receive_on_timeout(mailbox_env): + consumer = Consumer('pytest', timeout=0.01) + + async with trio.open_nursery() as nursery: + await nursery.start(consumer) + + assert consumer.timed_out + assert consumer.received_message is None + + +async def test_receive_too_slow(mailbox_env): + consumer = Consumer('pytest', timeout=0.01, with_on_timeout=False) + + with pytest.raises(trio.TooSlowError): + async with trio.open_nursery() as nursery: + await nursery.start(consumer) + + assert not consumer.timed_out + assert consumer.received_message is None + + +async def test_no_mailbox(mailbox_env): + producer = Producer('pytest') + + with pytest.raises(mailbox.MailboxDoesNotExist): + await producer('foo') + + with pytest.raises(mailbox.MailboxDoesNotExist): + await mailbox.receive('pytest') + + +async def test_direct(mailbox_env): + consumer = Consumer(None) + + async with trio.open_nursery() as nursery: + mid = await nursery.start(consumer) + producer = Producer(mid) + nursery.start_soon(producer, 'foo') + + assert not consumer.timed_out + assert consumer.received_message == 'foo' + + +async def test_register(mailbox_env): + consumer = Consumer('pytest') + + with pytest.raises(mailbox.MailboxDoesNotExist): + mailbox.register('not-found', 'pytest') + + with pytest.raises(mailbox.NameAlreadyExist): + async with trio.open_nursery() as nursery: + await nursery.start(consumer) + await nursery.start(consumer) + + +async def test_unregister(mailbox_env): + consumer = Consumer('pytest') + producer = Producer('pytest') + + with pytest.raises(mailbox.MailboxDoesNotExist): + async with trio.open_nursery() as nursery: + await nursery.start(consumer) + + mailbox.unregister('pytest') + + with pytest.raises(mailbox.NameDoesNotExist): + mailbox.unregister('pytest') + + nursery.start_soon(producer, 'foo') + + +async def test_destroy_unknown(mailbox_env): + with pytest.raises(mailbox.MailboxDoesNotExist): + await mailbox.destroy('not-found') diff --git a/tests/test_node/__init__.py b/tests/test_node/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_node/conftest.py b/tests/test_node/conftest.py new file mode 100644 index 0000000..9ebbe94 --- /dev/null +++ b/tests/test_node/conftest.py @@ -0,0 +1,11 @@ +import pytest + + +class SampleData: + def __init__(self): + self.count = 0 + + +@pytest.fixture +def test_data(): + return SampleData() diff --git a/tests/test_node/sample_app.py b/tests/test_node/sample_app.py new file mode 100644 index 0000000..e4b40be --- /dev/null +++ b/tests/test_node/sample_app.py @@ -0,0 +1,2 @@ +async def start(test_data): + test_data.count += 1 diff --git a/tests/test_node/test_run.py b/tests/test_node/test_run.py new file mode 100644 index 0000000..1244c37 --- /dev/null +++ b/tests/test_node/test_run.py @@ -0,0 +1,15 @@ +from triotp import node, application + +from . import sample_app + + +def test_node_run(test_data): + node.run(apps=[ + application.app_spec( + module=sample_app, + start_arg=test_data, + permanent=False + ) + ]) + + assert test_data.count == 1 diff --git a/tests/test_supervisor.py b/tests/test_supervisor.py new file mode 100644 index 0000000..dc5379b --- /dev/null +++ b/tests/test_supervisor.py @@ -0,0 +1,95 @@ +import pytest + +from triotp import supervisor +import trio + + +class SampleData: + def __init__(self): + self.exec_count = 0 + + +async def sample_task(test_data): + test_data.exec_count += 1 + + +async def sample_task_error(test_data): + test_data.exec_count += 1 + raise RuntimeError('pytest') + + +@pytest.mark.parametrize("max_restarts", [1, 3, 5]) +async def test_automatic_restart_permanent(max_restarts, log_handler): + test_data = SampleData() + + async with trio.open_nursery() as nursery: + children = [ + supervisor.child_spec( + id='sample_task', + task=sample_task, + args=[test_data], + restart=supervisor.restart_strategy.PERMANENT, + ), + ] + opts = supervisor.options( + max_restarts=max_restarts, + max_seconds=5, + ) + await nursery.start(supervisor.start, children, opts) + + assert test_data.exec_count == (max_restarts + 1) + assert log_handler.has_errors + + +@pytest.mark.parametrize("max_restarts", [1, 3, 5]) +@pytest.mark.parametrize("strategy", [ + supervisor.restart_strategy.PERMANENT, + supervisor.restart_strategy.TRANSIENT, +]) +async def test_automatic_restart_crash(max_restarts, strategy, log_handler): + test_data = SampleData() + + with pytest.raises(RuntimeError): + async with trio.open_nursery() as nursery: + children = [ + supervisor.child_spec( + id='sample_task', + task=sample_task_error, + args=[test_data], + restart=strategy, + ), + ] + opts = supervisor.options( + max_restarts=max_restarts, + max_seconds=5, + ) + await nursery.start(supervisor.start, children, opts) + + assert test_data.exec_count == (max_restarts + 1) + assert log_handler.has_errors + + +@pytest.mark.parametrize("strategy", [ + supervisor.restart_strategy.TEMPORARY, + supervisor.restart_strategy.TRANSIENT, +]) +async def test_no_restart(strategy, log_handler): + test_data = SampleData() + + async with trio.open_nursery() as nursery: + children = [ + supervisor.child_spec( + id='sample_task', + task=sample_task, + args=[test_data], + restart=strategy, + ), + ] + opts = supervisor.options( + max_restarts=3, + max_seconds=5, + ) + await nursery.start(supervisor.start, children, opts) + + assert test_data.exec_count == 1 + assert not log_handler.has_errors diff --git a/triotp/__init__.py b/triotp/__init__.py new file mode 100644 index 0000000..2d0d261 --- /dev/null +++ b/triotp/__init__.py @@ -0,0 +1,27 @@ +""" +TriOTP is built on top of the Trio_ async library. Therefore, it is not directly +compatible with AsyncIO libraries. + +.. _trio: https://trio.readthedocs.io + +This library revolves around the folllwing concepts: + + - a Node represent a single asynchronous loop (`trio.run`) + - an application represent the root of a supervision tree + - a supervisor handles automatic restart of child processes + - a mailbox enables message passing between asynchronous tasks + +On top of this concepts, this library provides: + + - generic servers to handle requests from other tasks + - dynamic supervisors to schedule new tasks + +.. _zeromq: https://zeromq.org/languages/python/ + + **NB:** You don't get distributed computing out of the box like you would + with Erlang_/Elixir_, this library is single-threaded and works within a + Python application only. + +.. _erlang: https://erlang.org +.. _elixir: https://elixir-lang.org/ +""" diff --git a/triotp/application.py b/triotp/application.py new file mode 100644 index 0000000..4145113 --- /dev/null +++ b/triotp/application.py @@ -0,0 +1,101 @@ +""" +An application is a Python module defining an asynchronous function `start`. + +.. code-block:: python + :caption: Example + + async def start(_start_arg): + print('Hello world') + +Usually, the application will start a supervisor containing the child tasks to +run. +""" + +from triotp import supervisor + +from contextvars import ContextVar +from dataclasses import dataclass +import trio + +from typing import Optional, Any +from types import ModuleType + + +context_app_nursery = ContextVar("app_nursery") +context_app_registry = ContextVar("app_registry") + + +@dataclass +class app_spec: + """Describe an application""" + + module: ModuleType #: Application module + start_arg: Any #: Argument to pass to the module's start function + permanent: bool = ( + True #: If `False`, the application won't be restarted if it exits + ) + opts: Optional[ + supervisor.options + ] = None #: Options for the supervisor managing the application task + + +def _init(nursery: trio.Nursery) -> None: + context_app_nursery.set(nursery) + context_app_registry.set({}) + + +async def start(app: app_spec) -> None: + """ + Starts an application on the current node. If the application is already + started, it does nothing. + + **NB:** This function cannot be called outside a node. + + :param app: The application to start + """ + + nursery = context_app_nursery.get() + registry = context_app_registry.get() + + if app.module.__name__ not in registry: + local_nursery = await nursery.start(_app_scope, app) + registry[app.module.__name__] = local_nursery + + +async def stop(app_name: str) -> None: + """ + Stops an application. If the application was not running, it does nothing. + + **NB:** This function cannot be called outside a node. + + :param app_name: `__name__` of the application module + """ + + registry = context_app_registry.get() + + if app_name in registry: + local_nursery = registry.pop(app_name) + local_nursery.cancel_scope.cancel() + + +async def _app_scope(app: app_spec, task_status=trio.TASK_STATUS_IGNORED): + if app.permanent: + restart = supervisor.restart_strategy.PERMANENT + + else: + restart = supervisor.restart_strategy.TRANSIENT + + async with trio.open_nursery() as nursery: + task_status.started(nursery) + + children = [ + supervisor.child_spec( + id=app.module.__name__, + task=app.module.start, + args=[app.start_arg], + restart=restart, + ) + ] + opts = app.opts if app.opts is not None else supervisor.options() + + nursery.start_soon(supervisor.start, children, opts) diff --git a/triotp/dynamic_supervisor.py b/triotp/dynamic_supervisor.py new file mode 100644 index 0000000..3f75bab --- /dev/null +++ b/triotp/dynamic_supervisor.py @@ -0,0 +1,116 @@ +""" +A dynamic supervisor is almost identical to a normal supervisor. + +The only difference is that a dynamic supervisor creates a mailbox in order to +receive requests to start new children from other tasks. + +.. code-block:: python + :caption: Example + + # app.py + + from triotp import supervisor, dynamic_supervisor + import trio + + from . import worker + + + async def start(): + opts = supervisor.options() + children = [ + supervisor.child_spec( + id='worker_pool', + task=dynamic_supervisor.start, + args=[opts, 'worker-pool'], + ), + ] + + async with trio.open_nursery() as nursery: + await nursery.start_soon(supervisor.start, children, opts) + + await dynamic_supervisor.start_child( + 'worker-pool', + supervisor.child_spec( + id='worker-0', + task=worker.start, + args=[], + restart=supervisor.restart_strategy.TRANSIENT, + ), + ) +""" + +from triotp import supervisor, mailbox +import trio + +from typing import Optional, Union + + +async def start( + opts: supervisor.options, + name: Optional[str] = None, + task_status=trio.TASK_STATUS_IGNORED, +) -> None: + """ + Starts a new dynamic supervisor. + + This function creates a new mailbox to receive request for new children. + + :param opts: Supervisor options + :param name: Optional name to use to register the supervisor's mailbox + :param task_status: Used to notify the trio nursery that the supervisor is ready + :raises triotp.mailbox.NameAlreadyExist: If the `name` was already registered + + .. code-block:: python + :caption: Example + + from triotp import dynamic_supervisor, supervisor + import trio + + + async def example(): + opts = supervisor.options() + child_spec = # ... + + async with trio.open_nursery() as nursery: + mid = await nursery.start(dynamic_supervisor.start, opts) + await dynamic_supervisor.start_child(mid, child_spec) + """ + + async with mailbox.open(name) as mid: + task_status.started(mid) + + async with trio.open_nursery() as nursery: + await nursery.start(_child_listener, mid, opts, nursery) + + +async def start_child( + name_or_mid: Union[str, mailbox.MailboxID], + child_spec: supervisor.child_spec, +) -> None: + """ + Start a new task in the specified supervisor. + + :param name_or_mid: Dynamic supervisor's mailbox identifier + :param child_spec: Child specification to start + """ + + await mailbox.send(name_or_mid, child_spec) + + +async def _child_listener( + mid: mailbox.MailboxID, + opts: supervisor.options, + nursery: trio.Nursery, + task_status=trio.TASK_STATUS_IGNORED, +) -> None: + task_status.started(None) + + while True: + request = await mailbox.receive(mid) + + match request: + case supervisor.child_spec() as spec: + await nursery.start(supervisor._child_monitor, spec, opts) + + case _: + pass diff --git a/triotp/gen_server.py b/triotp/gen_server.py new file mode 100644 index 0000000..56b5016 --- /dev/null +++ b/triotp/gen_server.py @@ -0,0 +1,456 @@ +""" +A generic server is an abstraction of a server loop built on top of the mailbox +module. + +It is best used to build components that accept request from other components in +your application such as: + + - an in-memory key-value store + - a TCP server handler + - a finite state machine + +There are 3 ways of sending messages to a generic server: + + - **cast:** send a message + - **call:** send a message an wait for a response + - directly to the mailbox + +> **NB:** If a call returns an exception to the caller, the exception will be +> raised on the caller side. + +.. code-block:: python + :caption: Example + + from triotp.helpers import current_module + from triotp import gen_server, mailbox + + + __module__ = current_module() + + + async def start(): + await gen_server.start(__module__, name='kvstore') + + + async def get(key): + return await gen_server.call('kvstore', ('get', key)) + + + async def set(key, val): + return await gen_server.call('kvstore', ('set', key, val)) + + + async def stop(): + await gen_server.cast('kvstore', 'stop') + + + async def printstate(): + await mailbox.send('kvstore', 'printstate') + + # gen_server callbacks + + async def init(_init_arg): + state = {} + return state + + + # optional + async def terminate(reason, state): + if reason is not None: + print('An error occured:', reason) + + print('Exited with state:' state) + + + # if not defined, the gen_server will stop with a NotImplementedError when + # receiving a call + async def handle_call(message, _caller, state): + match message: + case ('get', key): + val = state.get(key, None) + return (gen_server.Reply(payload=val), state) + + case ('set', key, val): + prev = state.get(key, None) + state[key] = val + return (gen_server.Reply(payload=prev), state) + + case _: + exc = NotImplementedError('unknown request') + return (gen_server.Reply(payload=exc), state) + + + # if not defined, the gen_server will stop with a NotImplementedError when + # receiving a cast + async def handle_cast(message, state): + match message: + case 'stop': + return (gen_server.Stop(), state) + + case _: + print('unknown request') + return (gen_server.NoReply(), state) + + + # optional + async def handle_info(message, state): + match message: + case 'printstate': + print(state) + + case _: + pass + + return (gen_server.NoReply(), state) +""" + +from triotp import mailbox, logging + +from dataclasses import dataclass +import trio + +from typing import TypeVar, Union, Optional, Any +from types import ModuleType + + +State = TypeVar("State") + + +class GenServerExited(Exception): + """ + Raised when the generic server exited during a call. + """ + + +@dataclass +class _Loop: + yes: bool + + +@dataclass +class _Raise: + exc: BaseException + + +Continuation = Union[_Loop, _Raise] + + +@dataclass +class Reply: + """ + Return an instance of this class to send a reply to the caller. + """ + + payload: Any #: The response to send back + + +@dataclass +class NoReply: + """ + Return an instance of this class to not send a reply to the caller. + """ + + +@dataclass +class Stop: + """ + Return an instance of this class to stop the generic server. + """ + + reason: Optional[ + BaseException + ] = None #: Eventual exception that caused the gen_server to stop + + +@dataclass +class _CallMessage: + source: trio.MemorySendChannel + payload: Any + + +@dataclass +class _CastMessage: + payload: Any + + +async def start( + module: ModuleType, + init_arg: Optional[Any] = None, + name: Optional[str] = None, +) -> None: + """ + Starts the generic server loop. + + :param module: Module containing the generic server's callbacks + :param init_arg: Optional argument passed to the `init` callback + :param name: Optional name to use to register the generic server's mailbox + + :raises triotp.mailbox.NameAlreadyExist: If the `name` was already registered + :raises Exception: If the generic server terminated with a non-null reason + """ + + await _loop(module, init_arg, name) + + +async def call( + name_or_mid: Union[str, mailbox.MailboxID], + payload: Any, + timeout: Optional[float] = None, +) -> Any: + """ + Send a request to the generic server and wait for a response. + + This function creates a temporary bi-directional channel. The writer is + passed to the `handle_call` function and is used to send the response back + to the caller. + + :param name_or_mid: The generic server's mailbox identifier + :param payload: The message to send to the generic server + :param timeout: Optional timeout after which this function fails + :returns: The response from the generic server + :raises GenServerExited: If the generic server exited after handling the call + :raises Exception: If the response is an exception + + """ + + wchan, rchan = trio.open_memory_channel(0) + message = _CallMessage(source=wchan, payload=payload) + + await mailbox.send(name_or_mid, message) + + try: + if timeout is not None: + with trio.fail_after(timeout): + val = await rchan.receive() + + else: + val = await rchan.receive() + + if isinstance(val, Exception): + raise val + + return val + + finally: + await wchan.aclose() + await rchan.aclose() + + +async def cast( + name_or_mid: Union[str, mailbox.MailboxID], + payload: Any, +) -> None: + """ + Send a message to the generic server without expecting a response. + + :param name_or_mid: The generic server's mailbox identifier + :param payload: The message to send + """ + + message = _CastMessage(payload=payload) + await mailbox.send(name_or_mid, message) + + +async def reply(caller: trio.MemorySendChannel, response: Any) -> None: + """ + The `handle_call` callback can start a background task to handle a slow + request and return a `NoReply` instance. Use this function in the background + task to send the response to the caller at a later time. + + :param caller: The caller `SendChannel` to use to send the response + :param response: The response to send back to the caller + + .. code-block:: python + :caption: Example + + from triotp import gen_server, supervisor, dynamic_supervisor + import trio + + + async def slow_task(message, caller): + # do stuff with message + await gen_server.reply(caller, response) + + + async def handle_call(message, caller, state): + await dynamic_supervisor.start_child( + 'slow-task-pool', + supervisor.child_spec( + id='some-slow-task', + task=slow_task, + args=[message, caller], + restart=supervisor.restart_strategy.TEMPORARY, + ), + ) + + return (gen_server.NoReply(), state) + """ + + await caller.send(response) + + +async def _loop( + module: ModuleType, + init_arg: Optional[Any], + name: Optional[str], +) -> None: + async with mailbox.open(name) as mid: + try: + state = await _init(module, init_arg) + looping = True + + while looping: + message = await mailbox.receive(mid) + + match message: + case _CallMessage(source, payload): + continuation, state = await _handle_call( + module, payload, source, state + ) + + case _CastMessage(payload): + continuation, state = await _handle_cast(module, payload, state) + + case _: + continuation, state = await _handle_info(module, message, state) + + match continuation: + case _Loop(yes=False): + looping = False + + case _Loop(yes=True): + looping = True + + case _Raise(exc=err): + raise err + + except Exception as err: + await _terminate(module, err, state) + raise err from None + + else: + await _terminate(module, None, state) + + +async def _init(module: ModuleType, init_arg: Any) -> State: + return await module.init(init_arg) + + +async def _terminate( + module: ModuleType, + reason: Optional[BaseException], + state: State, +) -> None: + handler = getattr(module, "terminate", None) + if handler is not None: + await handler(reason, state) + + elif reason is not None: + logger = logging.getLogger(module.__name__) + logger.exception(reason) + + +async def _handle_call( + module: ModuleType, + message: Any, + source: trio.MemorySendChannel, + state: State, +) -> tuple[Continuation, State]: + handler = getattr(module, "handle_call", None) + if handler is None: + raise NotImplementedError(f"{module.__name__}.handle_call") + + result = await handler(message, source, state) + + match result: + case (Reply(payload), new_state): + state = new_state + await reply(source, payload) + continuation = _Loop(yes=True) + + case (NoReply(), new_state): + state = new_state + continuation = _Loop(yes=True) + + case (Stop(reason), new_state): + state = new_state + await reply(source, GenServerExited()) + + if reason is not None: + continuation = _Raise(reason) + + else: + continuation = _Loop(yes=False) + + case _: + raise TypeError( + f"{module.__name__}.handle_call did not return a valid value" + ) + + return continuation, state + + +async def _handle_cast( + module: ModuleType, + message: Any, + state: State, +) -> tuple[Continuation, State]: + handler = getattr(module, "handle_cast", None) + if handler is None: + raise NotImplementedError(f"{module.__name__}.handle_cast") + + result = await handler(message, state) + + match result: + case (NoReply(), new_state): + state = new_state + continuation = _Loop(yes=True) + + case (Stop(reason), new_state): + state = new_state + + if reason is not None: + continuation = _Raise(reason) + + else: + continuation = _Loop(yes=False) + + case _: + raise TypeError( + f"{module.__name__}.handle_cast did not return a valid value" + ) + + return continuation, state + + +async def _handle_info( + module: ModuleType, + message: Any, + state: State, +) -> tuple[Continuation, State]: + handler = getattr(module, "handle_info", None) + if handler is None: + return _Loop(yes=True), state + + result = await handler(message, state) + + match result: + case (NoReply(), new_state): + state = new_state + continuation = _Loop(yes=True) + + case (Stop(reason), new_state): + state = new_state + + if reason is not None: + continuation = _Raise(reason) + + else: + continuation = _Loop(yes=False) + + case _: + raise TypeError( + f"{module.__name__}.handle_info did not return a valid value" + ) + + return continuation, state diff --git a/triotp/helpers.py b/triotp/helpers.py new file mode 100644 index 0000000..db109a9 --- /dev/null +++ b/triotp/helpers.py @@ -0,0 +1,40 @@ +import inspect +import sys + +from types import ModuleType + + +def current_module() -> ModuleType: + """ + This function should be called at the root of a module. + + :returns: The current module (similar to `__name__` for the current module name) + + .. code-block:: python + :caption: Example + + from triotp.helpers import current_module + + __module__ = current_module() # THIS WORKS + + + def get_module(): + return current_module() # THIS WON'T WORK + """ + + stack_frame = inspect.currentframe() + + while stack_frame: + if stack_frame.f_code.co_name == "": + if stack_frame.f_code.co_filename != "": + caller_module = inspect.getmodule(stack_frame) + + else: + caller_module = sys.modules["__main__"] + + if caller_module is not None: + return caller_module + + break + + stack_frame = stack_frame.f_back diff --git a/triotp/logging.py b/triotp/logging.py new file mode 100644 index 0000000..8a1562a --- /dev/null +++ b/triotp/logging.py @@ -0,0 +1,42 @@ +""" +TriOTP logging system relies on the Logbook_ library. Each node has its own log +handler. + +.. _logbook: https://logbook.readthedocs.io/ +""" + +from enum import Enum, auto +import logbook + + +class LogLevel(Enum): + """ + TriOTP node's logging level + """ + + NONE = auto() #: Logging is disabled + DEBUG = auto() + INFO = auto() + WARNING = auto() + ERROR = auto() + CRITICAL = auto() + + def to_logbook(self) -> int: + """ + Convert this enum to a Logbook log level. + + :returns: Logbook log level + """ + + return logbook.lookup_level(self.name) + + +def getLogger(name: str) -> logbook.Logger: + """ + Get a logger by name. + + :param name: Name of the logger + :returns: Logbook Logger instance + """ + + return logbook.Logger(name) diff --git a/triotp/mailbox.py b/triotp/mailbox.py new file mode 100644 index 0000000..3d7b095 --- /dev/null +++ b/triotp/mailbox.py @@ -0,0 +1,270 @@ +""" +In Erlang_/Elixir_, each process have a PID that can be used to receive message +from other processes. + +.. _erlang: https://erlang.org +.. _elixir: https://elixir-lang.org/ + +With trio, there is no such thing as a process. There is only asynchronous tasks +started within a nursery. + +This module provides an encapsulation of trio's memory channels_ which allows +tasks to communicate with each other. + +.. _channels: https://trio.readthedocs.io/en/stable/reference-core.html#using-channels-to-pass-values-between-tasks + +.. code-block:: python + :caption: Example + + from triotp import mailbox + + + async def task_a(task_status=trio.TASK_STATUS_IGNORED): + async with mailbox.open(name='task_a') as mid: + task_status.started(None) + + msg = await mailbox.receive(mid) + print(msg) + + + async def task_b(): + await mailbox.send('task_a', 'hello world') + + + async def main(): + async with trio.open_nursery() as nursery: + await nursery.start(task_a) + nursery.start_soon(task_b) +""" + +from contextlib import asynccontextmanager +from contextvars import ContextVar +from uuid import uuid4 +import trio + +from collections.abc import Callable, Awaitable +from typing import TypeVar, Union, Optional, Any, AsyncContextManager + + +MailboxID = TypeVar("MailboxID", bound=str) #: Mailbox identifier (UUID4) + + +context_mailbox_registry = ContextVar("mailbox_registry") +context_name_registry = ContextVar("name_registry") + + +class MailboxDoesNotExist(RuntimeError): + """ + Error thrown when the mailbox identifier was not found. + """ + + def __init__(self, mid: MailboxID): + super().__init__(f"mailbox {mid} does not exist") + + +class NameAlreadyExist(RuntimeError): + """ + Error thrown when trying to register a mailbox to an already registered + name. + """ + + def __init__(self, name: str): + super().__init__(f"mailbox {name} already registered") + + +class NameDoesNotExist(RuntimeError): + """ + Error thrown when trying to unregister a non-existing name. + """ + + def __init__(self, name: str): + super().__init__(f"mailbox {name} does not exist") + + +def _init() -> None: + context_mailbox_registry.set({}) + context_name_registry.set({}) + + +def create() -> MailboxID: + """ + Create a new mailbox. + + :returns: The mailbox unique identifier + """ + + mid = str(uuid4()) + + mailbox_registry = context_mailbox_registry.get() + mailbox_registry[mid] = trio.open_memory_channel(0) + + return mid + + +async def destroy(mid: MailboxID) -> None: + """ + Close and destroy a mailbox. + + :param mid: The mailbox identifier + :raises MailboxDoesNotExist: The mailbox identifier was not found + """ + + mailbox_registry = context_mailbox_registry.get() + + if mid not in mailbox_registry: + raise MailboxDoesNotExist(mid) + + unregister_all(mid) + + wchan, rchan = mailbox_registry.pop(mid) + await wchan.aclose() + await rchan.aclose() + + +def register(mid: MailboxID, name: str) -> None: + """ + Assign a name to a mailbox. + + :param mid: The mailbox identifier + :param name: The new name + + :raises MailboxDoesNotExist: The mailbox identifier was not found + :raises NameAlreadyExist: The name was already registered + """ + + mailbox_registry = context_mailbox_registry.get() + + if mid not in mailbox_registry: + raise MailboxDoesNotExist(mid) + + name_registry = context_name_registry.get() + if name in name_registry: + raise NameAlreadyExist(name) + + name_registry[name] = mid + + +def unregister(name: str) -> None: + """ + Unregister a mailbox's name. + + :param name: The name to unregister + :raises NameDoesNotExist: The name was not found + """ + + name_registry = context_name_registry.get() + if name not in name_registry: + raise NameDoesNotExist(name) + + name_registry.pop(name) + + +def unregister_all(mid: MailboxID) -> None: + """ + Unregister all names associated to a mailbox. + + :param mid: The mailbox identifier + """ + + name_registry = context_name_registry.get() + + for (name, mailbox_id) in list(name_registry.items()): + if mailbox_id == mid: + name_registry.pop(name) + + +@asynccontextmanager +async def open(name: Optional[str] = None) -> AsyncContextManager[MailboxID]: + """ + Shortcut for `create()`, `register()` followed by a `destroy()`. + + :param name: Optional name to register the mailbox + :returns: Asynchronous context manager for the mailbox + :raises NameAlreadyExist: If the `name` was already registered + + .. code-block:: python + :caption: Example + + async with mailbox.open(name='foo') as mid: + message = await mailbox.receive() + print(message) + """ + + mid = create() + + try: + if name is not None: + register(mid, name) + + yield mid + + finally: + await destroy(mid) + + +def _resolve(name: str) -> Optional[MailboxID]: + name_registry = context_name_registry.get() + return name_registry.get(name) + + +async def send(name_or_mid: Union[str, MailboxID], message: Any) -> None: + """ + Send a message to a mailbox. + + :param name_or_mid: Either a registered name, or the mailbox identifier + :param message: The message to send + :raises MailboxDoesNotExist: The mailbox was not found + """ + + mailbox_registry = context_mailbox_registry.get() + + mid = _resolve(name_or_mid) + if mid is None: + mid = name_or_mid + + if mid not in mailbox_registry: + raise MailboxDoesNotExist(mid) + + wchan, _ = mailbox_registry.get(mid) + await wchan.send(message) + + +async def receive( + mid: MailboxID, + timeout: Optional[float] = None, + on_timeout: Callable[[], Awaitable[Any]] = None, +) -> Any: + """ + Consume a message from a mailbox. + + :param mid: The mailbox identifier + :param timeout: If set, the call will fail after the timespan set in seconds + :param on_timeout: If set and `timeout` is set, instead of raising an + exception, the result of this async function will be + returned + + :raises MailboxDoesNotExist: The mailbox was not found + :raises trio.TooSlowError: If `timeout` is set, but `on_timeout` isn't, and + no message was received during the timespan set + """ + + mailbox_registry = context_mailbox_registry.get() + + if mid not in mailbox_registry: + raise MailboxDoesNotExist(mid) + + _, rchan = mailbox_registry.get(mid) + + if timeout is not None: + try: + with trio.fail_after(timeout): + return await rchan.receive() + + except trio.TooSlowError: + if on_timeout is None: + raise + + return await on_timeout() + + else: + return await rchan.receive() diff --git a/triotp/node.py b/triotp/node.py new file mode 100644 index 0000000..2c496a4 --- /dev/null +++ b/triotp/node.py @@ -0,0 +1,72 @@ +""" +A TriOTP node encapsulates the call to `trio.run` and allows you to specify a +list of application to start. + + **NB:** There is no dependency management between applications, it's up to + you to start the correct applications in the right order. + +.. code-block:: python + :caption: Example + + from pyotp import node, application + + from myproject import myapp1, myapp2 + + node.run( + apps=[ + application.app_spec( + module=myapp1, + start_arg=[], + ), + application.app_spec( + module=myapp2, + start_arg=[], + permanent=False, + ), + ], + ) +""" + +from triotp import mailbox, application, logging +from logbook import StreamHandler, NullHandler +import trio +import sys + +from typing import Optional + + +def run( + apps: list[application.app_spec], + loglevel: logging.LogLevel = logging.LogLevel.NONE, + logformat: Optional[str] = None, +) -> None: + """ + Start a new node by calling `trio.run`. + + :param apps: List of application to start + :param loglevel: Logging Level of the node + :param logformat: Format of log messages produced by the node + """ + + match loglevel: + case logging.LogLevel.NONE: + handler = NullHandler() + + case _: + handler = StreamHandler(sys.stdout, level=loglevel.to_logbook()) + + if logformat is not None: + handler.format_string = logformat + + with handler.applicationbound(): + trio.run(_start, apps) + + +async def _start(apps: list[application.app_spec]) -> None: + mailbox._init() + + async with trio.open_nursery() as nursery: + application._init(nursery) + + for app_spec in apps: + await application.start(app_spec) diff --git a/triotp/supervisor.py b/triotp/supervisor.py new file mode 100644 index 0000000..b0f2012 --- /dev/null +++ b/triotp/supervisor.py @@ -0,0 +1,196 @@ +""" +A supervisor is used to handle a set of asynchronous tasks. It takes care of +restarting them if they exit prematurely or if they crash. + +.. code-block:: python + :caption: Example + + from triotp import supervisor + from random import random + import trio + + async def loop(threshold): + while True: + if random() < threshold: + raise RuntimeError('bad luck') + + else: + await trio.sleep(0.1) + + async def start_supervisor(): + children = [ + supervisor.child_spec( + id='loop', + task=loop, + args=[0.5], + restart=supervisor.restart_strategy.PERMANENT, + ), + ] + opts = supervisor.options( + max_restarts=3, + max_seconds=5 + ) + await supervisor.start(children, opts) +""" + +from dataclasses import dataclass +from collections import deque +from enum import Enum, auto +from logbook import Logger +import tenacity + +import trio_util +import trio + +from collections.abc import Callable, Awaitable +from typing import Any + + +class restart_strategy(Enum): + """ + Describe when to restart an asynchronous task. + """ + + PERMANENT = auto() #: Always restart the task + TRANSIENT = auto() #: Restart the task only if it raises an exception + TEMPORARY = auto() #: Never restart a task + + +@dataclass +class child_spec: + """ + Describe an asynchronous task to supervise. + """ + + id: str #: Task identifier + task: Callable[..., Awaitable[None]] #: The task to run + args: list[Any] #: Arguments to pass to the task + restart: restart_strategy = restart_strategy.PERMANENT #: When to restart the task + + +@dataclass +class options: + """ + Describe the options for the supervisor. + """ + + max_restarts: int = 3 #: Maximum number of restart during a limited timespan + max_seconds: int = 5 #: Timespan duration + + +class _retry_strategy: + def __init__( + self, + restart: restart_strategy, + max_restarts: int, + max_seconds: float, + ): + self.restart = restart + self.max_restarts = max_restarts + self.max_seconds = max_seconds + + self.failure_times = deque() + self.deleteme = [] + + def __call__(self, retry_state: tenacity.RetryCallState): + match self.restart: + case restart_strategy.PERMANENT: + pass + + case restart_strategy.TRANSIENT: + if not retry_state.outcome.failed: + return False + + case restart_strategy.TEMPORARY: + return False + + now = trio.current_time() + self.failure_times.append(now) + + if len(self.failure_times) <= self.max_restarts: + return True + + oldest_failure = self.failure_times.popleft() + self.deleteme.append(now - oldest_failure) + + if now - oldest_failure < self.max_seconds: + return False + + return True + + +class _retry_logger: + def __init__(self, child_id: str): + self.logger = Logger(child_id) + + def __call__(self, retry_state: tenacity.RetryCallState) -> None: + if isinstance(retry_state.outcome.exception(), trio.Cancelled): + self.logger.info("task cancelled") + + elif retry_state.outcome.failed: + exception = retry_state.outcome.exception() + exc_info = (exception.__class__, exception, exception.__traceback__) + self.logger.error("restarting task after failure", exc_info=exc_info) + + else: + self.logger.error("restarting task after unexpected exit") + + +async def start( + child_specs: list[child_spec], + opts: options, + task_status=trio.TASK_STATUS_IGNORED, +) -> None: + """ + Start the supervisor and its children. + + :param child_specs: Asynchronous tasks to supervise + :param opts: Supervisor options + :param task_status: Used to notify the trio nursery that the task is ready + + .. code-block:: python + :caption: Example + + from triotp import supervisor + import trio + + async def example(): + children_a = [ + # ... + ] + children_b = [ + # ... + ] + opts = supervisor.options() + + async with trio.open_nursery() as nursery: + await nursery.start(supervisor.start, children_a, opts) + await nursery.start(supervisor.start, children_b, opts) + """ + + async with trio.open_nursery() as nursery: + for spec in child_specs: + await nursery.start(_child_monitor, spec, opts) + + task_status.started(None) + + +async def _child_monitor( + spec: child_spec, + opts: options, + task_status=trio.TASK_STATUS_IGNORED, +) -> None: + task_status.started(None) + + @tenacity.retry( + retry=_retry_strategy(spec.restart, opts.max_restarts, opts.max_seconds), + reraise=True, + sleep=trio.sleep, + after=_retry_logger(spec.id), + ) + async def _child_runner(): + with trio_util.defer_to_cancelled(): + async with trio.open_nursery() as nursery: + nursery.start_soon(spec.task, *spec.args) + + await _child_runner()