├── .github └── workflows │ ├── publish-docs.yml │ └── test-suite.yml ├── .gitignore ├── LICENSE.txt ├── README.rst ├── docs ├── Makefile ├── make.bat └── source │ ├── api │ ├── index.rst │ └── triotp.rst │ ├── conf.py │ ├── guides │ ├── index.rst │ ├── message-passing.rst │ ├── simple-app.rst │ └── webserver.rst │ └── index.rst ├── pdm.lock ├── pyproject.toml ├── pytest.ini ├── src └── triotp │ ├── __init__.py │ ├── application.py │ ├── dynamic_supervisor.py │ ├── gen_server.py │ ├── helpers.py │ ├── logging.py │ ├── mailbox.py │ ├── node.py │ └── supervisor.py └── tests ├── __init__.py ├── conftest.py ├── test_application ├── __init__.py ├── conftest.py ├── sample │ ├── __init__.py │ ├── app_a.py │ ├── app_b.py │ └── app_c.py ├── test_restart.py └── test_stop.py ├── test_dynamic_supervisor.py ├── test_gen_server ├── __init__.py ├── conftest.py ├── sample_kvstore.py ├── test_api.py ├── test_call.py ├── test_cast.py └── test_info.py ├── test_helpers ├── __init__.py ├── sample.py └── test_sample.py ├── test_logging.py ├── test_mailbox.py ├── test_node ├── __init__.py ├── conftest.py ├── sample_app.py └── test_run.py └── test_supervisor.py /.github/workflows/publish-docs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: publish-docs 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | docs: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: checkout-code@scm 14 | uses: actions/checkout@main 15 | - name: checkout-ghpages@scm 16 | uses: actions/checkout@main 17 | with: 18 | ref: gh-pages 19 | path: docs/build/html 20 | 21 | - name: setup@python 22 | uses: pdm-project/setup-pdm@v4 23 | with: 24 | python-version: '3.13' 25 | 26 | - name: setup@venv 27 | run: pdm install 28 | 29 | - name: docs@sphinx 30 | run: pdm run make -C docs html 31 | 32 | - name: publish@scm 33 | run: | 34 | cd docs/build/html 35 | touch .nojekyll 36 | git config --local user.email "action@github.com" 37 | git config --local user.name "GitHub Action" 38 | git add . 39 | git commit -m ":construction_worker: publish documentation" --allow-empty 40 | git push origin gh-pages 41 | -------------------------------------------------------------------------------- /.github/workflows/test-suite.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: run-test-suite 3 | 4 | on: [push] 5 | 6 | jobs: 7 | test-suite: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: checkout@scm 11 | uses: actions/checkout@main 12 | 13 | - name: setup@python 14 | uses: pdm-project/setup-pdm@v4 15 | with: 16 | python-version: '3.13' 17 | 18 | - name: setup@venv 19 | run: pdm install 20 | 21 | - name: lint@black 22 | run: pdm run black --check src/ --target-version py310 23 | 24 | - name: test@pytest 25 | run: pdm run pytest --cov src/ --cov-report xml 26 | 27 | - name: coverage@coveralls 28 | run: pdm run coveralls 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm-project.org/#use-with-ide 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | 164 | # Ruff 165 | .ruff_cache/ 166 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2021, David Delassus 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | TriOTP, the OTP framework for Python Trio 2 | ========================================= 3 | 4 | See documentation_ for more informations. 5 | 6 | .. _documentation: https://linkdd.github.io/triotp 7 | 8 | .. image:: https://img.shields.io/pypi/l/triotp.svg?style=flat-square 9 | :target: https://pypi.python.org/pypi/triotp/ 10 | :alt: License 11 | 12 | .. image:: https://img.shields.io/pypi/status/triotp.svg?style=flat-square 13 | :target: https://pypi.python.org/pypi/triotp/ 14 | :alt: Development Status 15 | 16 | .. image:: https://img.shields.io/pypi/v/triotp.svg?style=flat-square 17 | :target: https://pypi.python.org/pypi/triotp/ 18 | :alt: Latest release 19 | 20 | .. image:: https://img.shields.io/pypi/pyversions/triotp.svg?style=flat-square 21 | :target: https://pypi.python.org/pypi/triotp/ 22 | :alt: Supported Python versions 23 | 24 | .. image:: https://img.shields.io/pypi/implementation/triotp.svg?style=flat-square 25 | :target: https://pypi.python.org/pypi/triotp/ 26 | :alt: Supported Python implementations 27 | 28 | .. image:: https://img.shields.io/pypi/wheel/triotp.svg?style=flat-square 29 | :target: https://pypi.python.org/pypi/triotp 30 | :alt: Download format 31 | 32 | .. image:: https://github.com/linkdd/triotp/actions/workflows/test-suite.yml/badge.svg 33 | :target: https://github.com/linkdd/triotp 34 | :alt: Build status 35 | 36 | .. image:: https://coveralls.io/repos/github/linkdd/triotp/badge.svg?style=flat-square 37 | :target: https://coveralls.io/r/linkdd/triotp 38 | :alt: Code test coverage 39 | 40 | .. image:: https://img.shields.io/pypi/dm/triotp.svg?style=flat-square 41 | :target: https://pypi.python.org/pypi/triotp/ 42 | :alt: Downloads 43 | 44 | Introduction 45 | ------------ 46 | 47 | This project is a simplified implementation of the Erlang_/Elixir_ OTP_ 48 | framework. 49 | 50 | .. _erlang: https://erlang.org 51 | .. _elixir: https://elixir-lang.org/ 52 | .. _otp: https://en.wikipedia.org/wiki/Open_Telecom_Platform 53 | 54 | It is built on top of the Trio_ async library and provides: 55 | 56 | - **applications:** the root of a supervision tree 57 | - **supervisors:** automatic restart of children tasks 58 | - **mailboxes:** message-passing between tasks 59 | - **gen_servers:** generic server task 60 | 61 | .. _trio: https://trio.readthedocs.io 62 | 63 | Why ? 64 | ----- 65 | 66 | Since I started writing Erlang/Elixir code, I've always wanted to use its 67 | concepts in other languages. 68 | 69 | I made this library for fun and most importantly: to see if it was possible. 70 | As it turns out, it is! -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.https://www.sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/api/index.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | triotp 8 | -------------------------------------------------------------------------------- /docs/source/api/triotp.rst: -------------------------------------------------------------------------------- 1 | triotp package 2 | ============== 3 | 4 | Module contents 5 | --------------- 6 | 7 | .. automodule:: triotp 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | Submodules 13 | ---------- 14 | 15 | triotp.node module 16 | ~~~~~~~~~~~~~~~~~~ 17 | 18 | .. automodule:: triotp.node 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | triotp.application module 24 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 25 | 26 | .. automodule:: triotp.application 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | triotp.supervisor module 32 | ~~~~~~~~~~~~~~~~~~~~~~~~ 33 | 34 | .. automodule:: triotp.supervisor 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | triotp.mailbox module 40 | ~~~~~~~~~~~~~~~~~~~~~ 41 | 42 | .. automodule:: triotp.mailbox 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | triotp.gen\_server module 48 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 49 | 50 | .. automodule:: triotp.gen_server 51 | :members: 52 | :undoc-members: 53 | :show-inheritance: 54 | 55 | triotp.dynamic_supervisor module 56 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 57 | 58 | .. automodule:: triotp.dynamic_supervisor 59 | :members: 60 | :undoc-members: 61 | :show-inheritance: 62 | 63 | triotp.logging module 64 | ~~~~~~~~~~~~~~~~~~~~~ 65 | 66 | .. automodule:: triotp.logging 67 | :members: 68 | :undoc-members: 69 | :show-inheritance: 70 | 71 | triotp.helpers module 72 | ~~~~~~~~~~~~~~~~~~~~~ 73 | 74 | .. automodule:: triotp.helpers 75 | :members: 76 | :undoc-members: 77 | :show-inheritance: 78 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | from pathlib import Path 10 | 11 | BASE_DIR = Path.cwd().parent.parent 12 | 13 | # -- Project information ----------------------------------------------------- 14 | import toml 15 | 16 | pyproject_path = BASE_DIR / "pyproject.toml" 17 | 18 | with open(pyproject_path) as f: 19 | pyproject = toml.load(f) 20 | 21 | project = pyproject["project"]["name"] 22 | author = "{name}<{email}>".format(**pyproject["project"]["authors"][0]) 23 | copyright = f"2021, {author}" 24 | 25 | # The full version, including alpha/beta/rc tags 26 | release = pyproject["project"]["version"] 27 | 28 | 29 | # -- General configuration --------------------------------------------------- 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = ["sphinx.ext.autodoc"] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ["_templates"] 38 | 39 | # List of patterns, relative to source directory, that match files and 40 | # directories to ignore when looking for source files. 41 | # This pattern also affects html_static_path and html_extra_path. 42 | exclude_patterns = [] 43 | 44 | 45 | # -- Options for HTML output ------------------------------------------------- 46 | 47 | # The theme to use for HTML and HTML Help pages. See the documentation for 48 | # a list of builtin themes. 49 | # 50 | html_theme = "sphinx_rtd_theme" 51 | 52 | # Add any paths that contain custom static files (such as style sheets) here, 53 | # relative to this directory. They are copied after the builtin static files, 54 | # so a file named "default.css" will overwrite the builtin "default.css". 55 | html_static_path = [] 56 | 57 | html_baseurl = "https://linkdd.github.io/triotp/" 58 | -------------------------------------------------------------------------------- /docs/source/guides/index.rst: -------------------------------------------------------------------------------- 1 | Tutorials 2 | ========= 3 | 4 | .. toctree:: 5 | :maxdepth: 1 6 | :caption: Contents: 7 | 8 | message-passing 9 | simple-app 10 | webserver 11 | -------------------------------------------------------------------------------- /docs/source/guides/message-passing.rst: -------------------------------------------------------------------------------- 1 | Message-Passing between tasks with mailboxes 2 | ============================================ 3 | 4 | **trio** provides a mechanism to exchange messages between asynchronous tasks. 5 | 6 | The **triotp** mailbox is an abstraction of that mechanism. This abstraction can 7 | be used without the rest of **triotp**. 8 | 9 | In this tutorial, we'll see how it works. 10 | 11 | Initializing the mailbox registry 12 | --------------------------------- 13 | 14 | This step is done automatically when a node starts, but it is required if you 15 | want to use it standalone: 16 | 17 | .. code-block:: python 18 | 19 | from triotp import mailbox 20 | import trio 21 | 22 | 23 | async def main(): 24 | mailbox._init() 25 | 26 | 27 | trio.run(main) 28 | 29 | 30 | Creating a mailbox 31 | ------------------ 32 | 33 | There is 2 ways of creating a mailbox: 34 | 35 | .. code-block:: python 36 | 37 | async def your_task(): 38 | mid = mailbox.create() 39 | # do stuff 40 | await mailbox.destroy(mid) 41 | 42 | Or: 43 | 44 | .. code-block:: python 45 | 46 | async def your_task(): 47 | async with mailbox.open() as mid: 48 | # do stuff 49 | 50 | The `mid` variable holds a unique reference to the mailbox. But it can be hard to 51 | pass this reference between tasks. Therefore, you can register a name referencing 52 | this identifier: 53 | 54 | .. code-block:: python 55 | 56 | async def your_task(): 57 | mid = mailbox.create() 58 | mailbox.register(mid, 'foo') 59 | # do stuff 60 | await mailbox.destroy() 61 | 62 | Or: 63 | 64 | .. code-block:: python 65 | 66 | async def your_task(): 67 | async with mailbox.open(name='foo'): 68 | # do stuff 69 | 70 | Sending and receiving messages 71 | ------------------------------ 72 | 73 | In this example, we create a mailbox, wait for a single message, then close it: 74 | 75 | .. code-block:: python 76 | 77 | from triotp import mailbox 78 | import trio 79 | 80 | 81 | async def consumer(task_status=trio.TASK_STATUS_IGNORED): 82 | async with mailbox.open('foo') as mid: 83 | task_status.started(None) 84 | 85 | message = await mailbox.receive(mid) 86 | print(message) 87 | 88 | 89 | async def producer(): 90 | await mailbox.send('foo', 'Hello World!') 91 | 92 | 93 | async def main(): 94 | async with trio.open_nursery() as nursery: 95 | await nursery.start(consumer) 96 | nursery.start_soon(producer) 97 | 98 | 99 | trio.run(main) 100 | -------------------------------------------------------------------------------- /docs/source/guides/simple-app.rst: -------------------------------------------------------------------------------- 1 | A simple application 2 | ==================== 3 | 4 | In this tutorial, we will create a single application with 2 processes: 5 | 6 | - a generic server which echo messages 7 | - a client to interact with the generic server 8 | 9 | First, let's create our folder structure: 10 | 11 | .. code-block:: shell 12 | 13 | $ mkdir simple_triotp 14 | $ touch simple_triotp/__init__.py 15 | 16 | The Generic Server 17 | ------------------ 18 | 19 | A generic server is an abstract of a process with a mailbox receiving messages 20 | and sending responses back to the caller (server/client architecture). 21 | 22 | Let's create a file `simple_triotp/echo_server.py`: 23 | 24 | .. code-block:: python 25 | 26 | from triotp.helpers import current_module 27 | from triotp import gen_server 28 | 29 | 30 | __module__ = current_module() 31 | 32 | 33 | async def start(): 34 | await gen_server.start(__module__, init_arg=None, name=__name__) 35 | 36 | 37 | async def echo(message): 38 | return await gen_server.call(__name__, ('echo', message)) 39 | 40 | 41 | async def stop(): 42 | await gen_server.cast(__name__, 'stop') 43 | 44 | # gen_server callbacks 45 | 46 | async def init(_init_arg): 47 | return 'nostate' 48 | 49 | 50 | async def terminate(reason, state): 51 | print('exited with reason', reason, 'and state', state) 52 | 53 | 54 | async def handle_call(message, _caller, state): 55 | match message: 56 | case ('echo', message): 57 | return (gen_server.Reply(message), state) 58 | 59 | case _: 60 | exc = NotImplementedError('unkown command') 61 | return (gen_server.Reply(exc), state) 62 | 63 | 64 | async def handle_cast(message, state): 65 | match message: 66 | case 'stop': 67 | return (gen_server.Stop(), state) 68 | 69 | case _: 70 | exc = NotImplementedError('unknown command') 71 | return (gen_server.Stop(exc), state) 72 | 73 | Let's look at it step by step: 74 | 75 | .. code-block:: python 76 | 77 | async def start(): 78 | await gen_server.start(__module__, init_arg=None, name=__name__) 79 | 80 | This function will start our generic server: 81 | 82 | - the first argument is the module which defines our callbacks: 83 | - `init`: to create the state of the server 84 | - `terminate`: called whenever the generic server stops 85 | - `handle_call`: called whenever a call to the generic server is made 86 | - `handle_cast`: called whenever a cast to the generic server is made 87 | - `handle_info`: called whenever a message is sent directly to the generic server's mailbox 88 | - the second argument is the value passed to the `init` callback 89 | - the third argument is the name to use to send message to the generic server's mailbox 90 | 91 | .. code-block:: python 92 | 93 | async def echo(message): 94 | return await gen_server.call(__name__, ('echo', message)) 95 | 96 | The `gen_server.call()` function sends a message to the generic server's mailbox, 97 | and then wait for a response. The request is handled by the `handle_call` callback. 98 | 99 | **NB:** If the response is an exception, it will be raised as soon as it is 100 | received, delegating the error handling to the caller. 101 | 102 | .. code-block:: python 103 | 104 | async def stop(): 105 | await gen_server.cast(__name__, 'stop') 106 | 107 | The `gen_server.cast()` function sends a message to the generic server's mailbox, 108 | but it does not wait for a response and returns immediately. The request will be 109 | handled by the `handle_cast` callback. 110 | 111 | You can also send messages directly to the generic server's maiblox: 112 | 113 | .. code-block:: python 114 | 115 | async def notify(): 116 | await mailbox.send(__name__, 'notify') 117 | 118 | The message will then be handled by the `handle_info` callback. 119 | This is useful to allow a generic server to send messages to itself. 120 | 121 | .. code-block:: python 122 | 123 | async def init(_init_arg): 124 | return 'nostate' 125 | 126 | This callback creates and return the state for the generic server. This state 127 | will be passed to every other callback. It can be anything like: 128 | 129 | - a data structure 130 | - a database connection 131 | - a state machine 132 | - ... 133 | 134 | .. code-block:: python 135 | 136 | async def terminate(reason, state): 137 | print('exited with reason', reason, 'and state', state) 138 | 139 | This callback is called when the generic server is stopped. The `reason` is either 140 | `None` or the exception that triggered the generic server to stop. 141 | 142 | .. code-block:: python 143 | 144 | async def handle_call(message, _caller, state): 145 | match message: 146 | case ('echo', message): 147 | return (gen_server.Reply(message), state) 148 | 149 | case _: 150 | exc = NotImplementedError('unkown command') 151 | return (gen_server.Reply(exc), state) 152 | 153 | This callback is called to handle requests made with `gen_server.call()`, it 154 | must always return a tuple whose second element is the new state (for later calls 155 | to any callback function). 156 | 157 | The first argument can be either: 158 | 159 | - `gen_server.NoReply()`: implying a call to `gen_server.reply()` will be made 160 | in the future to send the response back to the caller 161 | - `gen_server.Reply()`: to send a response back to the caller 162 | - `gen_server.Stop(reason=None)`: to exit the generic server. The caller will 163 | then raise a `GenServerExited` exception 164 | 165 | .. code-block:: python 166 | 167 | async def handle_cast(message, state): 168 | match message: 169 | case 'stop': 170 | return (gen_server.Stop(), state) 171 | 172 | case _: 173 | exc = NotImplementedError('unknown command') 174 | return (gen_server.Stop(exc), state) 175 | 176 | This callback is called to handle requests made with `gen_server.call()`, it 177 | must always return a tuple whose second element is the new state (for later calls 178 | to any callback function). 179 | 180 | The first argument can be either: 181 | 182 | - `gen_server.NoReply()`: no reply will be sent to the caller 183 | - `gen_server.Stop(reason=None)`: to exit the generic server 184 | 185 | **NB:** the `handle_info` callback works exactly the same. 186 | 187 | The client process 188 | ------------------ 189 | 190 | This task will only send some messages to the generic server, and finally stop it. 191 | 192 | Let's create a `simple_triotp/echo_client.py` file: 193 | 194 | .. code-block:: python 195 | 196 | from . import echo_server 197 | 198 | 199 | async def run(): 200 | response = await echo_server.echo('hello') 201 | assert response == 'hello' 202 | 203 | response = await echo_server.echo('world') 204 | assert response == 'world' 205 | 206 | await echo_server.stop() 207 | 208 | The supervisor 209 | -------------- 210 | 211 | A supervisor handles automatic restart of its children whenever they exit 212 | prematurely, or after a crash. 213 | 214 | It is useful to restart a generic server handling connections to a database, 215 | after a temporary network failure. 216 | 217 | In this case, the supervisor will have 2 children, the generic server and the 218 | client: 219 | 220 | .. code-block:: python 221 | 222 | from triotp import supervisor 223 | from . import echo_server, echo_client 224 | 225 | 226 | async def start(): 227 | children = [ 228 | supervisor.child_spec( 229 | id='server', 230 | task=echo_server.start, 231 | args=[], 232 | restart=supervisor.restart_strategy.TRANSIENT, 233 | ), 234 | supervisor.child_spec( 235 | id='client', 236 | task=echo_client.run, 237 | args=[], 238 | restart=supervisor.restart_strategy.TRANSIENT, 239 | ), 240 | ] 241 | opts = supervisor.options( 242 | max_restarts=3, 243 | max_seconds=5, 244 | ) 245 | await supervisor.start(children, opts) 246 | 247 | There are 3 supported restart strategy: 248 | 249 | - `PERMANENT`: the task should always be restarted 250 | - `TRANSIENT`: the task should be restarted only if it crashed 251 | - `TEMPORARY`: the task should never be restarted 252 | 253 | If a child restart more than `max_restarts` within a `max_seconds` period, the 254 | supervisor will also crash (maybe a parent supervisor will try to restart it). 255 | 256 | The application 257 | --------------- 258 | 259 | An application is the root of a supervision tree. 260 | 261 | We'll use this to start our supervisor, let's create a file 262 | `simple_triotp/echo_app.py`: 263 | 264 | .. code-block:: python 265 | 266 | from triotp.helpers import current_module 267 | from triotp import application 268 | from . import echo_supervisor 269 | 270 | 271 | __module__ = current_module() 272 | 273 | 274 | def spec(): 275 | return application.app_spec( 276 | module=__module__, 277 | start_arg=None, 278 | permanent=False, 279 | ) 280 | 281 | 282 | async def start(_start_arg): 283 | await echo_supervisor.start() 284 | 285 | Starting the node 286 | ----------------- 287 | 288 | Finally, we need to create our entrypoint, this can be done in the file 289 | `simple_triotp/main.py`: 290 | 291 | .. code-block:: python 292 | 293 | from triotp import node 294 | from . import echo_app 295 | 296 | 297 | def main(): 298 | node.run(apps=[ 299 | echo_app.spec(), 300 | ]) 301 | 302 | 303 | if __name__ == '__main__': 304 | main() 305 | 306 | Now, you can run the whole program with: 307 | 308 | .. code-block:: shell 309 | 310 | $ python -m simple_triotp.main 311 | -------------------------------------------------------------------------------- /docs/source/guides/webserver.rst: -------------------------------------------------------------------------------- 1 | Running a webserver 2 | =================== 3 | 4 | In this tutorial, you'll learn how to run an ASGI or WSGI web application as part 5 | of your supervision tree. 6 | 7 | Before starting, you'll need `hypercorn `_: 8 | 9 | .. code-block:: shell 10 | 11 | $ pip install hypercorn 12 | 13 | It is a production-ready HTTP webserver able to run on top of **AsyncIO** or 14 | **trio**. 15 | 16 | Then in a supervisor: 17 | 18 | .. code-block:: python 19 | 20 | from triotp import supervisor 21 | from myproject.asgi import app as asgi_app 22 | from myproject.wsgi import app as wsgi_app 23 | 24 | from hypercorn.middleware import TrioWSGIMiddleware 25 | from hypercorn.config import Config 26 | from hypercorn.trio import serve 27 | 28 | 29 | async def start(): 30 | config_a = Config() 31 | config_a.bind = ["localhost:8000"] 32 | 33 | config_b = Config() 34 | config_b.bind = ["localhost:8001"] 35 | 36 | children = [ 37 | supervisor.child_spec( 38 | id='endpoint-a', 39 | task=serve, 40 | args=[asgi_app, config_a], 41 | ), 42 | supervisor.child_spec( 43 | id='endpoint-b', 44 | task=serve, 45 | args=[TrioWSGIMiddleware(wsgi_app), config_b], 46 | ), 47 | ] 48 | opts = supervisor.options() 49 | await supervisor.start(children, opts) 50 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../README.rst 2 | 3 | Getting Started 4 | =============== 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | :caption: Contents: 9 | 10 | guides/index 11 | api/index 12 | 13 | Indices and tables 14 | ================== 15 | 16 | * :ref:`genindex` 17 | * :ref:`modindex` 18 | * :ref:`search` 19 | -------------------------------------------------------------------------------- /pdm.lock: -------------------------------------------------------------------------------- 1 | # This file is @generated by PDM. 2 | # It is not intended for manual editing. 3 | 4 | [metadata] 5 | groups = ["default", "dev", "docs"] 6 | strategy = ["inherit_metadata"] 7 | lock_version = "4.5.0" 8 | content_hash = "sha256:bee6f65e499e92229b33b607f544857ed0cf900daf57ba07fe5cbb733979f05e" 9 | 10 | [[metadata.targets]] 11 | requires_python = ">=3.13" 12 | 13 | [[package]] 14 | name = "alabaster" 15 | version = "1.0.0" 16 | requires_python = ">=3.10" 17 | summary = "A light, configurable Sphinx theme" 18 | groups = ["dev"] 19 | files = [ 20 | {file = "alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b"}, 21 | {file = "alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e"}, 22 | ] 23 | 24 | [[package]] 25 | name = "attrs" 26 | version = "25.3.0" 27 | requires_python = ">=3.8" 28 | summary = "Classes Without Boilerplate" 29 | groups = ["default", "dev"] 30 | files = [ 31 | {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, 32 | {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, 33 | ] 34 | 35 | [[package]] 36 | name = "babel" 37 | version = "2.17.0" 38 | requires_python = ">=3.8" 39 | summary = "Internationalization utilities" 40 | groups = ["dev"] 41 | dependencies = [ 42 | "pytz>=2015.7; python_version < \"3.9\"", 43 | ] 44 | files = [ 45 | {file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"}, 46 | {file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"}, 47 | ] 48 | 49 | [[package]] 50 | name = "black" 51 | version = "25.1.0" 52 | requires_python = ">=3.9" 53 | summary = "The uncompromising code formatter." 54 | groups = ["dev"] 55 | dependencies = [ 56 | "click>=8.0.0", 57 | "mypy-extensions>=0.4.3", 58 | "packaging>=22.0", 59 | "pathspec>=0.9.0", 60 | "platformdirs>=2", 61 | "tomli>=1.1.0; python_version < \"3.11\"", 62 | "typing-extensions>=4.0.1; python_version < \"3.11\"", 63 | ] 64 | files = [ 65 | {file = "black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f"}, 66 | {file = "black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3"}, 67 | {file = "black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171"}, 68 | {file = "black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18"}, 69 | {file = "black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717"}, 70 | {file = "black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666"}, 71 | ] 72 | 73 | [[package]] 74 | name = "certifi" 75 | version = "2025.1.31" 76 | requires_python = ">=3.6" 77 | summary = "Python package for providing Mozilla's CA Bundle." 78 | groups = ["dev"] 79 | files = [ 80 | {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, 81 | {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, 82 | ] 83 | 84 | [[package]] 85 | name = "cffi" 86 | version = "1.17.1" 87 | requires_python = ">=3.8" 88 | summary = "Foreign Function Interface for Python calling C code." 89 | groups = ["default", "dev"] 90 | marker = "os_name == \"nt\" and implementation_name != \"pypy\"" 91 | dependencies = [ 92 | "pycparser", 93 | ] 94 | files = [ 95 | {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, 96 | {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, 97 | {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, 98 | {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, 99 | {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, 100 | {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, 101 | {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, 102 | {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, 103 | {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, 104 | {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, 105 | {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, 106 | {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, 107 | ] 108 | 109 | [[package]] 110 | name = "charset-normalizer" 111 | version = "3.4.1" 112 | requires_python = ">=3.7" 113 | summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 114 | groups = ["dev"] 115 | files = [ 116 | {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, 117 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, 118 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, 119 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, 120 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, 121 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, 122 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, 123 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, 124 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, 125 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, 126 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, 127 | {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, 128 | {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, 129 | {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, 130 | {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, 131 | ] 132 | 133 | [[package]] 134 | name = "click" 135 | version = "8.1.8" 136 | requires_python = ">=3.7" 137 | summary = "Composable command line interface toolkit" 138 | groups = ["dev"] 139 | dependencies = [ 140 | "colorama; platform_system == \"Windows\"", 141 | "importlib-metadata; python_version < \"3.8\"", 142 | ] 143 | files = [ 144 | {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, 145 | {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, 146 | ] 147 | 148 | [[package]] 149 | name = "colorama" 150 | version = "0.4.6" 151 | requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 152 | summary = "Cross-platform colored terminal text." 153 | groups = ["dev"] 154 | marker = "sys_platform == \"win32\" or platform_system == \"Windows\"" 155 | files = [ 156 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 157 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 158 | ] 159 | 160 | [[package]] 161 | name = "coverage" 162 | version = "6.5.0" 163 | requires_python = ">=3.7" 164 | summary = "Code coverage measurement for Python" 165 | groups = ["dev"] 166 | files = [ 167 | {file = "coverage-6.5.0.tar.gz", hash = "sha256:f642e90754ee3e06b0e7e51bce3379590e76b7f76b708e1a71ff043f87025c84"}, 168 | ] 169 | 170 | [[package]] 171 | name = "coverage" 172 | version = "6.5.0" 173 | extras = ["toml"] 174 | requires_python = ">=3.7" 175 | summary = "Code coverage measurement for Python" 176 | groups = ["dev"] 177 | dependencies = [ 178 | "coverage==6.5.0", 179 | "tomli; python_full_version <= \"3.11.0a6\"", 180 | ] 181 | files = [ 182 | {file = "coverage-6.5.0.tar.gz", hash = "sha256:f642e90754ee3e06b0e7e51bce3379590e76b7f76b708e1a71ff043f87025c84"}, 183 | ] 184 | 185 | [[package]] 186 | name = "coveralls" 187 | version = "3.3.1" 188 | requires_python = ">= 3.5" 189 | summary = "Show coverage stats online via coveralls.io" 190 | groups = ["dev"] 191 | dependencies = [ 192 | "coverage!=6.0.*,!=6.1,!=6.1.1,<7.0,>=4.1", 193 | "docopt>=0.6.1", 194 | "requests>=1.0.0", 195 | ] 196 | files = [ 197 | {file = "coveralls-3.3.1-py2.py3-none-any.whl", hash = "sha256:f42015f31d386b351d4226389b387ae173207058832fbf5c8ec4b40e27b16026"}, 198 | {file = "coveralls-3.3.1.tar.gz", hash = "sha256:b32a8bb5d2df585207c119d6c01567b81fba690c9c10a753bfe27a335bfc43ea"}, 199 | ] 200 | 201 | [[package]] 202 | name = "docopt" 203 | version = "0.6.2" 204 | summary = "Pythonic argument parser, that will make you smile" 205 | groups = ["dev"] 206 | files = [ 207 | {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, 208 | ] 209 | 210 | [[package]] 211 | name = "docutils" 212 | version = "0.21.2" 213 | requires_python = ">=3.9" 214 | summary = "Docutils -- Python Documentation Utilities" 215 | groups = ["dev"] 216 | files = [ 217 | {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"}, 218 | {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, 219 | ] 220 | 221 | [[package]] 222 | name = "idna" 223 | version = "3.10" 224 | requires_python = ">=3.6" 225 | summary = "Internationalized Domain Names in Applications (IDNA)" 226 | groups = ["default", "dev"] 227 | files = [ 228 | {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, 229 | {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, 230 | ] 231 | 232 | [[package]] 233 | name = "imagesize" 234 | version = "1.4.1" 235 | requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 236 | summary = "Getting image size from png/jpeg/jpeg2000/gif file" 237 | groups = ["dev"] 238 | files = [ 239 | {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, 240 | {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, 241 | ] 242 | 243 | [[package]] 244 | name = "iniconfig" 245 | version = "2.1.0" 246 | requires_python = ">=3.8" 247 | summary = "brain-dead simple config-ini parsing" 248 | groups = ["dev"] 249 | files = [ 250 | {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, 251 | {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, 252 | ] 253 | 254 | [[package]] 255 | name = "jinja2" 256 | version = "3.1.6" 257 | requires_python = ">=3.7" 258 | summary = "A very fast and expressive template engine." 259 | groups = ["dev"] 260 | dependencies = [ 261 | "MarkupSafe>=2.0", 262 | ] 263 | files = [ 264 | {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, 265 | {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, 266 | ] 267 | 268 | [[package]] 269 | name = "logbook" 270 | version = "1.8.1" 271 | requires_python = ">=3.9" 272 | summary = "A logging replacement for Python" 273 | groups = ["default"] 274 | files = [ 275 | {file = "logbook-1.8.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d094397b624aa70656a8575230fd9b8373dd9c978dfc912691e5227cb3c804fc"}, 276 | {file = "logbook-1.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4d30983ac1dce2f09d9acbf86ecf78696158148354162faff26c575b3a52ec7"}, 277 | {file = "logbook-1.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6c0d52293645aa46b2edfe7da8d61cf7839955d4ccf073c615972b55a44cefc"}, 278 | {file = "logbook-1.8.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95e7a7ba8610f0fd49e04e8cd2ac986d9e27cf1d232633d46563de1e0aeadacb"}, 279 | {file = "logbook-1.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ee8107845319fc0f974a86af8b029b555a1488616c979ba42245c18278178af"}, 280 | {file = "logbook-1.8.1-cp313-cp313-win32.whl", hash = "sha256:db7213329c72f85d78d3c806d30c31b4e76c9da72eea98be4f986f581fd18ed9"}, 281 | {file = "logbook-1.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:084265d5ccf48c9259b49477bbf30913f405c2812b96f739d5feebea828cc275"}, 282 | {file = "logbook-1.8.1.tar.gz", hash = "sha256:221e6f884f035fffd77935802ab47dc0a3aa7c833010cd7b51354649a7eb462d"}, 283 | ] 284 | 285 | [[package]] 286 | name = "markupsafe" 287 | version = "3.0.2" 288 | requires_python = ">=3.9" 289 | summary = "Safely add untrusted strings to HTML/XML markup." 290 | groups = ["dev"] 291 | files = [ 292 | {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, 293 | {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, 294 | {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, 295 | {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, 296 | {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, 297 | {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, 298 | {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, 299 | {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, 300 | {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, 301 | {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, 302 | {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, 303 | {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, 304 | {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, 305 | {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, 306 | {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, 307 | {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, 308 | {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, 309 | {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, 310 | {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, 311 | {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, 312 | {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, 313 | ] 314 | 315 | [[package]] 316 | name = "mypy" 317 | version = "1.15.0" 318 | requires_python = ">=3.9" 319 | summary = "Optional static typing for Python" 320 | groups = ["dev"] 321 | dependencies = [ 322 | "mypy-extensions>=1.0.0", 323 | "tomli>=1.1.0; python_version < \"3.11\"", 324 | "typing-extensions>=4.6.0", 325 | ] 326 | files = [ 327 | {file = "mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445"}, 328 | {file = "mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d"}, 329 | {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5"}, 330 | {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036"}, 331 | {file = "mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357"}, 332 | {file = "mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf"}, 333 | {file = "mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e"}, 334 | {file = "mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43"}, 335 | ] 336 | 337 | [[package]] 338 | name = "mypy-extensions" 339 | version = "1.0.0" 340 | requires_python = ">=3.5" 341 | summary = "Type system extensions for programs checked with the mypy type checker." 342 | groups = ["dev"] 343 | files = [ 344 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 345 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 346 | ] 347 | 348 | [[package]] 349 | name = "outcome" 350 | version = "1.3.0.post0" 351 | requires_python = ">=3.7" 352 | summary = "Capture the outcome of Python function calls." 353 | groups = ["default", "dev"] 354 | dependencies = [ 355 | "attrs>=19.2.0", 356 | ] 357 | files = [ 358 | {file = "outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"}, 359 | {file = "outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8"}, 360 | ] 361 | 362 | [[package]] 363 | name = "packaging" 364 | version = "24.2" 365 | requires_python = ">=3.8" 366 | summary = "Core utilities for Python packages" 367 | groups = ["dev"] 368 | files = [ 369 | {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, 370 | {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, 371 | ] 372 | 373 | [[package]] 374 | name = "pathspec" 375 | version = "0.12.1" 376 | requires_python = ">=3.8" 377 | summary = "Utility library for gitignore style pattern matching of file paths." 378 | groups = ["dev"] 379 | files = [ 380 | {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, 381 | {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, 382 | ] 383 | 384 | [[package]] 385 | name = "platformdirs" 386 | version = "4.3.7" 387 | requires_python = ">=3.9" 388 | summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." 389 | groups = ["dev"] 390 | files = [ 391 | {file = "platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94"}, 392 | {file = "platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351"}, 393 | ] 394 | 395 | [[package]] 396 | name = "pluggy" 397 | version = "1.5.0" 398 | requires_python = ">=3.8" 399 | summary = "plugin and hook calling mechanisms for python" 400 | groups = ["dev"] 401 | files = [ 402 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 403 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 404 | ] 405 | 406 | [[package]] 407 | name = "pycparser" 408 | version = "2.22" 409 | requires_python = ">=3.8" 410 | summary = "C parser in Python" 411 | groups = ["default", "dev"] 412 | marker = "os_name == \"nt\" and implementation_name != \"pypy\"" 413 | files = [ 414 | {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, 415 | {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, 416 | ] 417 | 418 | [[package]] 419 | name = "pygments" 420 | version = "2.19.1" 421 | requires_python = ">=3.8" 422 | summary = "Pygments is a syntax highlighting package written in Python." 423 | groups = ["dev"] 424 | files = [ 425 | {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, 426 | {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, 427 | ] 428 | 429 | [[package]] 430 | name = "pytest" 431 | version = "8.3.5" 432 | requires_python = ">=3.8" 433 | summary = "pytest: simple powerful testing with Python" 434 | groups = ["dev"] 435 | dependencies = [ 436 | "colorama; sys_platform == \"win32\"", 437 | "exceptiongroup>=1.0.0rc8; python_version < \"3.11\"", 438 | "iniconfig", 439 | "packaging", 440 | "pluggy<2,>=1.5", 441 | "tomli>=1; python_version < \"3.11\"", 442 | ] 443 | files = [ 444 | {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, 445 | {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, 446 | ] 447 | 448 | [[package]] 449 | name = "pytest-cov" 450 | version = "5.0.0" 451 | requires_python = ">=3.8" 452 | summary = "Pytest plugin for measuring coverage." 453 | groups = ["dev"] 454 | dependencies = [ 455 | "coverage[toml]>=5.2.1", 456 | "pytest>=4.6", 457 | ] 458 | files = [ 459 | {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, 460 | {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, 461 | ] 462 | 463 | [[package]] 464 | name = "pytest-trio" 465 | version = "0.8.0" 466 | requires_python = ">=3.7" 467 | summary = "Pytest plugin for trio" 468 | groups = ["dev"] 469 | dependencies = [ 470 | "outcome>=1.1.0", 471 | "pytest>=7.2.0", 472 | "trio>=0.22.0", 473 | ] 474 | files = [ 475 | {file = "pytest-trio-0.8.0.tar.gz", hash = "sha256:8363db6336a79e6c53375a2123a41ddbeccc4aa93f93788651641789a56fb52e"}, 476 | {file = "pytest_trio-0.8.0-py3-none-any.whl", hash = "sha256:e6a7e7351ae3e8ec3f4564d30ee77d1ec66e1df611226e5618dbb32f9545c841"}, 477 | ] 478 | 479 | [[package]] 480 | name = "requests" 481 | version = "2.32.3" 482 | requires_python = ">=3.8" 483 | summary = "Python HTTP for Humans." 484 | groups = ["dev"] 485 | dependencies = [ 486 | "certifi>=2017.4.17", 487 | "charset-normalizer<4,>=2", 488 | "idna<4,>=2.5", 489 | "urllib3<3,>=1.21.1", 490 | ] 491 | files = [ 492 | {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, 493 | {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, 494 | ] 495 | 496 | [[package]] 497 | name = "roman-numerals-py" 498 | version = "3.1.0" 499 | requires_python = ">=3.9" 500 | summary = "Manipulate well-formed Roman numerals" 501 | groups = ["dev"] 502 | files = [ 503 | {file = "roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c"}, 504 | {file = "roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d"}, 505 | ] 506 | 507 | [[package]] 508 | name = "sniffio" 509 | version = "1.3.1" 510 | requires_python = ">=3.7" 511 | summary = "Sniff out which async library your code is running under" 512 | groups = ["default", "dev"] 513 | files = [ 514 | {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, 515 | {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, 516 | ] 517 | 518 | [[package]] 519 | name = "snowballstemmer" 520 | version = "2.2.0" 521 | summary = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." 522 | groups = ["dev"] 523 | files = [ 524 | {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, 525 | {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, 526 | ] 527 | 528 | [[package]] 529 | name = "sortedcontainers" 530 | version = "2.4.0" 531 | summary = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" 532 | groups = ["default", "dev"] 533 | files = [ 534 | {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, 535 | {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, 536 | ] 537 | 538 | [[package]] 539 | name = "sphinx" 540 | version = "8.2.3" 541 | requires_python = ">=3.11" 542 | summary = "Python documentation generator" 543 | groups = ["dev"] 544 | dependencies = [ 545 | "Jinja2>=3.1", 546 | "Pygments>=2.17", 547 | "alabaster>=0.7.14", 548 | "babel>=2.13", 549 | "colorama>=0.4.6; sys_platform == \"win32\"", 550 | "docutils<0.22,>=0.20", 551 | "imagesize>=1.3", 552 | "packaging>=23.0", 553 | "requests>=2.30.0", 554 | "roman-numerals-py>=1.0.0", 555 | "snowballstemmer>=2.2", 556 | "sphinxcontrib-applehelp>=1.0.7", 557 | "sphinxcontrib-devhelp>=1.0.6", 558 | "sphinxcontrib-htmlhelp>=2.0.6", 559 | "sphinxcontrib-jsmath>=1.0.1", 560 | "sphinxcontrib-qthelp>=1.0.6", 561 | "sphinxcontrib-serializinghtml>=1.1.9", 562 | ] 563 | files = [ 564 | {file = "sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3"}, 565 | {file = "sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348"}, 566 | ] 567 | 568 | [[package]] 569 | name = "sphinx-rtd-theme" 570 | version = "3.0.2" 571 | requires_python = ">=3.8" 572 | summary = "Read the Docs theme for Sphinx" 573 | groups = ["dev"] 574 | dependencies = [ 575 | "docutils<0.22,>0.18", 576 | "sphinx<9,>=6", 577 | "sphinxcontrib-jquery<5,>=4", 578 | ] 579 | files = [ 580 | {file = "sphinx_rtd_theme-3.0.2-py2.py3-none-any.whl", hash = "sha256:422ccc750c3a3a311de4ae327e82affdaf59eb695ba4936538552f3b00f4ee13"}, 581 | {file = "sphinx_rtd_theme-3.0.2.tar.gz", hash = "sha256:b7457bc25dda723b20b086a670b9953c859eab60a2a03ee8eb2bb23e176e5f85"}, 582 | ] 583 | 584 | [[package]] 585 | name = "sphinxcontrib-applehelp" 586 | version = "2.0.0" 587 | requires_python = ">=3.9" 588 | summary = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" 589 | groups = ["dev"] 590 | files = [ 591 | {file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"}, 592 | {file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"}, 593 | ] 594 | 595 | [[package]] 596 | name = "sphinxcontrib-devhelp" 597 | version = "2.0.0" 598 | requires_python = ">=3.9" 599 | summary = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" 600 | groups = ["dev"] 601 | files = [ 602 | {file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"}, 603 | {file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"}, 604 | ] 605 | 606 | [[package]] 607 | name = "sphinxcontrib-htmlhelp" 608 | version = "2.1.0" 609 | requires_python = ">=3.9" 610 | summary = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" 611 | groups = ["dev"] 612 | files = [ 613 | {file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"}, 614 | {file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"}, 615 | ] 616 | 617 | [[package]] 618 | name = "sphinxcontrib-jquery" 619 | version = "4.1" 620 | requires_python = ">=2.7" 621 | summary = "Extension to include jQuery on newer Sphinx releases" 622 | groups = ["dev"] 623 | dependencies = [ 624 | "Sphinx>=1.8", 625 | ] 626 | files = [ 627 | {file = "sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a"}, 628 | {file = "sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae"}, 629 | ] 630 | 631 | [[package]] 632 | name = "sphinxcontrib-jsmath" 633 | version = "1.0.1" 634 | requires_python = ">=3.5" 635 | summary = "A sphinx extension which renders display math in HTML via JavaScript" 636 | groups = ["dev"] 637 | files = [ 638 | {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, 639 | {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, 640 | ] 641 | 642 | [[package]] 643 | name = "sphinxcontrib-qthelp" 644 | version = "2.0.0" 645 | requires_python = ">=3.9" 646 | summary = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" 647 | groups = ["dev"] 648 | files = [ 649 | {file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"}, 650 | {file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"}, 651 | ] 652 | 653 | [[package]] 654 | name = "sphinxcontrib-serializinghtml" 655 | version = "2.0.0" 656 | requires_python = ">=3.9" 657 | summary = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" 658 | groups = ["dev"] 659 | files = [ 660 | {file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"}, 661 | {file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"}, 662 | ] 663 | 664 | [[package]] 665 | name = "tenacity" 666 | version = "9.0.0" 667 | requires_python = ">=3.8" 668 | summary = "Retry code until it succeeds" 669 | groups = ["default"] 670 | files = [ 671 | {file = "tenacity-9.0.0-py3-none-any.whl", hash = "sha256:93de0c98785b27fcf659856aa9f54bfbd399e29969b0621bc7f762bd441b4539"}, 672 | {file = "tenacity-9.0.0.tar.gz", hash = "sha256:807f37ca97d62aa361264d497b0e31e92b8027044942bfa756160d908320d73b"}, 673 | ] 674 | 675 | [[package]] 676 | name = "toml" 677 | version = "0.10.2" 678 | requires_python = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 679 | summary = "Python Library for Tom's Obvious, Minimal Language" 680 | groups = ["docs"] 681 | files = [ 682 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 683 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 684 | ] 685 | 686 | [[package]] 687 | name = "trio" 688 | version = "0.29.0" 689 | requires_python = ">=3.9" 690 | summary = "A friendly Python library for async concurrency and I/O" 691 | groups = ["default", "dev"] 692 | dependencies = [ 693 | "attrs>=23.2.0", 694 | "cffi>=1.14; os_name == \"nt\" and implementation_name != \"pypy\"", 695 | "exceptiongroup; python_version < \"3.11\"", 696 | "idna", 697 | "outcome", 698 | "sniffio>=1.3.0", 699 | "sortedcontainers", 700 | ] 701 | files = [ 702 | {file = "trio-0.29.0-py3-none-any.whl", hash = "sha256:d8c463f1a9cc776ff63e331aba44c125f423a5a13c684307e828d930e625ba66"}, 703 | {file = "trio-0.29.0.tar.gz", hash = "sha256:ea0d3967159fc130acb6939a0be0e558e364fee26b5deeecc893a6b08c361bdf"}, 704 | ] 705 | 706 | [[package]] 707 | name = "typing-extensions" 708 | version = "4.13.0" 709 | requires_python = ">=3.8" 710 | summary = "Backported and Experimental Type Hints for Python 3.8+" 711 | groups = ["dev"] 712 | files = [ 713 | {file = "typing_extensions-4.13.0-py3-none-any.whl", hash = "sha256:c8dd92cc0d6425a97c18fbb9d1954e5ff92c1ca881a309c45f06ebc0b79058e5"}, 714 | {file = "typing_extensions-4.13.0.tar.gz", hash = "sha256:0a4ac55a5820789d87e297727d229866c9650f6521b64206413c4fbada24d95b"}, 715 | ] 716 | 717 | [[package]] 718 | name = "urllib3" 719 | version = "2.3.0" 720 | requires_python = ">=3.9" 721 | summary = "HTTP library with thread-safe connection pooling, file post, and more." 722 | groups = ["dev"] 723 | files = [ 724 | {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, 725 | {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, 726 | ] 727 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "triotp" 3 | version = "0.3.1" 4 | description = "The OTP framework for Python Trio" 5 | 6 | authors = [ 7 | {name = "David Delassus", email = "david.jose.delassus@gmail.com"}, 8 | ] 9 | 10 | readme = "README.rst" 11 | license = {text = "MIT"} 12 | keywords = ["trio", "async", "otp", "triotp"] 13 | classifiers = [ 14 | "Development Status :: 4 - Beta", 15 | "Framework :: Trio", 16 | "Intended Audience :: Developers", 17 | "Topic :: Software Development", 18 | ] 19 | 20 | requires-python = ">=3.13" 21 | dependencies = [ 22 | "trio>=0.29.0", 23 | "tenacity>=9.0.0", 24 | "Logbook>=1.8.1", 25 | ] 26 | 27 | [project.urls] 28 | homepage = "https://linkdd.github.io/triotp" 29 | repository = "https://github.com/linkdd/triotp" 30 | 31 | [dependency-groups] 32 | dev = [ 33 | "pytest>=8.3.5", 34 | "pytest-trio>=0.8.0", 35 | "Sphinx>=8.2.3", 36 | "sphinx-rtd-theme>=3.0.2", 37 | "pytest-cov>=5.0.0", 38 | "black>=25.1.0", 39 | "mypy>=1.15.0", 40 | "coveralls>=3.3.1", 41 | ] 42 | docs = [ 43 | "toml>=0.10.2", 44 | ] 45 | 46 | [build-system] 47 | requires = ["pdm-backend"] 48 | build-backend = "pdm.backend" 49 | 50 | [tool.pdm] 51 | distribution = true 52 | 53 | [tool.pdm.build] 54 | includes = ["LICENSE.txt", "src"] 55 | excludes = ["tests"] -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | trio_mode = true 3 | -------------------------------------------------------------------------------- /src/triotp/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | TriOTP is built on top of the Trio_ async library. Therefore, it is not directly 3 | compatible with AsyncIO libraries. 4 | 5 | .. _trio: https://trio.readthedocs.io 6 | 7 | This library revolves around the folllwing concepts: 8 | 9 | - a Node represent a single asynchronous loop (`trio.run`) 10 | - an application represent the root of a supervision tree 11 | - a supervisor handles automatic restart of child processes 12 | - a mailbox enables message passing between asynchronous tasks 13 | 14 | On top of this concepts, this library provides: 15 | 16 | - generic servers to handle requests from other tasks 17 | - dynamic supervisors to schedule new tasks 18 | 19 | .. _zeromq: https://zeromq.org/languages/python/ 20 | 21 | **NB:** You don't get distributed computing out of the box like you would 22 | with Erlang_/Elixir_, this library is single-threaded and works within a 23 | Python application only. 24 | 25 | .. _erlang: https://erlang.org 26 | .. _elixir: https://elixir-lang.org/ 27 | """ 28 | -------------------------------------------------------------------------------- /src/triotp/application.py: -------------------------------------------------------------------------------- 1 | """ 2 | An application is a Python module defining an asynchronous function `start`. 3 | 4 | .. code-block:: python 5 | :caption: Example 6 | 7 | async def start(_start_arg): 8 | print('Hello world') 9 | 10 | Usually, the application will start a supervisor containing the child tasks to 11 | run. 12 | """ 13 | 14 | from typing import Optional, Any 15 | from types import ModuleType 16 | 17 | 18 | from contextvars import ContextVar 19 | from dataclasses import dataclass 20 | 21 | import trio 22 | 23 | from triotp import supervisor 24 | 25 | 26 | context_app_nursery = ContextVar[trio.Nursery]("app_nursery") 27 | context_app_registry = ContextVar[dict[str, trio.Nursery]]("app_registry") 28 | 29 | 30 | @dataclass 31 | class app_spec: 32 | """Describe an application""" 33 | 34 | module: ModuleType #: Application module 35 | start_arg: Any #: Argument to pass to the module's start function 36 | permanent: bool = ( 37 | True #: If `False`, the application won't be restarted if it exits 38 | ) 39 | opts: Optional[supervisor.options] = ( 40 | None #: Options for the supervisor managing the application task 41 | ) 42 | 43 | 44 | def _init(nursery: trio.Nursery) -> None: 45 | context_app_nursery.set(nursery) 46 | context_app_registry.set({}) 47 | 48 | 49 | async def start(app: app_spec) -> None: 50 | """ 51 | Starts an application on the current node. If the application is already 52 | started, it does nothing. 53 | 54 | **NB:** This function cannot be called outside a node. 55 | 56 | :param app: The application to start 57 | """ 58 | 59 | nursery = context_app_nursery.get() 60 | registry = context_app_registry.get() 61 | 62 | if app.module.__name__ not in registry: 63 | local_nursery = await nursery.start(_app_scope, app) 64 | assert local_nursery is not None 65 | 66 | registry[app.module.__name__] = local_nursery 67 | 68 | 69 | async def stop(app_name: str) -> None: 70 | """ 71 | Stops an application. If the application was not running, it does nothing. 72 | 73 | **NB:** This function cannot be called outside a node. 74 | 75 | :param app_name: `__name__` of the application module 76 | """ 77 | 78 | registry = context_app_registry.get() 79 | 80 | if app_name in registry: 81 | local_nursery = registry.pop(app_name) 82 | local_nursery.cancel_scope.cancel() 83 | 84 | 85 | async def _app_scope(app: app_spec, task_status=trio.TASK_STATUS_IGNORED): 86 | if app.permanent: 87 | restart = supervisor.restart_strategy.PERMANENT 88 | 89 | else: 90 | restart = supervisor.restart_strategy.TRANSIENT 91 | 92 | async with trio.open_nursery() as nursery: 93 | task_status.started(nursery) 94 | 95 | children = [ 96 | supervisor.child_spec( 97 | id=app.module.__name__, 98 | task=app.module.start, 99 | args=[app.start_arg], 100 | restart=restart, 101 | ) 102 | ] 103 | opts = app.opts if app.opts is not None else supervisor.options() 104 | 105 | nursery.start_soon(supervisor.start, children, opts) 106 | -------------------------------------------------------------------------------- /src/triotp/dynamic_supervisor.py: -------------------------------------------------------------------------------- 1 | """ 2 | A dynamic supervisor is almost identical to a normal supervisor. 3 | 4 | The only difference is that a dynamic supervisor creates a mailbox in order to 5 | receive requests to start new children from other tasks. 6 | 7 | .. code-block:: python 8 | :caption: Example 9 | 10 | # app.py 11 | 12 | from triotp import supervisor, dynamic_supervisor 13 | import trio 14 | 15 | from . import worker 16 | 17 | 18 | async def start(): 19 | opts = supervisor.options() 20 | children = [ 21 | supervisor.child_spec( 22 | id='worker_pool', 23 | task=dynamic_supervisor.start, 24 | args=[opts, 'worker-pool'], 25 | ), 26 | ] 27 | 28 | async with trio.open_nursery() as nursery: 29 | await nursery.start_soon(supervisor.start, children, opts) 30 | 31 | await dynamic_supervisor.start_child( 32 | 'worker-pool', 33 | supervisor.child_spec( 34 | id='worker-0', 35 | task=worker.start, 36 | args=[], 37 | restart=supervisor.restart_strategy.TRANSIENT, 38 | ), 39 | ) 40 | """ 41 | 42 | from typing import Optional, Union 43 | 44 | import trio 45 | 46 | from triotp import supervisor, mailbox 47 | 48 | 49 | async def start( 50 | opts: supervisor.options, 51 | name: Optional[str] = None, 52 | task_status=trio.TASK_STATUS_IGNORED, 53 | ) -> None: 54 | """ 55 | Starts a new dynamic supervisor. 56 | 57 | This function creates a new mailbox to receive request for new children. 58 | 59 | :param opts: Supervisor options 60 | :param name: Optional name to use to register the supervisor's mailbox 61 | :param task_status: Used to notify the trio nursery that the supervisor is ready 62 | :raises triotp.mailbox.NameAlreadyExist: If the `name` was already registered 63 | 64 | .. code-block:: python 65 | :caption: Example 66 | 67 | from triotp import dynamic_supervisor, supervisor 68 | import trio 69 | 70 | 71 | async def example(): 72 | opts = supervisor.options() 73 | child_spec = # ... 74 | 75 | async with trio.open_nursery() as nursery: 76 | mid = await nursery.start(dynamic_supervisor.start, opts) 77 | await dynamic_supervisor.start_child(mid, child_spec) 78 | """ 79 | 80 | async with mailbox.open(name) as mid: 81 | task_status.started(mid) 82 | 83 | async with trio.open_nursery() as nursery: 84 | await nursery.start(_child_listener, mid, opts, nursery) 85 | 86 | 87 | async def start_child( 88 | name_or_mid: Union[str, mailbox.MailboxID], 89 | child_spec: supervisor.child_spec, 90 | ) -> None: 91 | """ 92 | Start a new task in the specified supervisor. 93 | 94 | :param name_or_mid: Dynamic supervisor's mailbox identifier 95 | :param child_spec: Child specification to start 96 | """ 97 | 98 | await mailbox.send(name_or_mid, child_spec) 99 | 100 | 101 | async def _child_listener( 102 | mid: mailbox.MailboxID, 103 | opts: supervisor.options, 104 | nursery: trio.Nursery, 105 | task_status=trio.TASK_STATUS_IGNORED, 106 | ) -> None: 107 | task_status.started(None) 108 | 109 | while True: 110 | request = await mailbox.receive(mid) 111 | 112 | match request: 113 | case supervisor.child_spec() as spec: 114 | await nursery.start(supervisor._child_monitor, spec, opts) 115 | 116 | case _: 117 | pass 118 | -------------------------------------------------------------------------------- /src/triotp/gen_server.py: -------------------------------------------------------------------------------- 1 | """ 2 | A generic server is an abstraction of a server loop built on top of the mailbox 3 | module. 4 | 5 | It is best used to build components that accept request from other components in 6 | your application such as: 7 | 8 | - an in-memory key-value store 9 | - a TCP server handler 10 | - a finite state machine 11 | 12 | There are 3 ways of sending messages to a generic server: 13 | 14 | - **cast:** send a message 15 | - **call:** send a message an wait for a response 16 | - directly to the mailbox 17 | 18 | > **NB:** If a call returns an exception to the caller, the exception will be 19 | > raised on the caller side. 20 | 21 | .. code-block:: python 22 | :caption: Example 23 | 24 | from triotp.helpers import current_module 25 | from triotp import gen_server, mailbox 26 | 27 | 28 | __module__ = current_module() 29 | 30 | 31 | async def start(): 32 | await gen_server.start(__module__, name='kvstore') 33 | 34 | 35 | async def get(key): 36 | return await gen_server.call('kvstore', ('get', key)) 37 | 38 | 39 | async def set(key, val): 40 | return await gen_server.call('kvstore', ('set', key, val)) 41 | 42 | 43 | async def stop(): 44 | await gen_server.cast('kvstore', 'stop') 45 | 46 | 47 | async def printstate(): 48 | await mailbox.send('kvstore', 'printstate') 49 | 50 | # gen_server callbacks 51 | 52 | async def init(_init_arg): 53 | state = {} 54 | return state 55 | 56 | 57 | # optional 58 | async def terminate(reason, state): 59 | if reason is not None: 60 | print('An error occured:', reason) 61 | 62 | print('Exited with state:', state) 63 | 64 | 65 | # if not defined, the gen_server will stop with a NotImplementedError when 66 | # receiving a call 67 | async def handle_call(message, _caller, state): 68 | match message: 69 | case ('get', key): 70 | val = state.get(key, None) 71 | return (gen_server.Reply(payload=val), state) 72 | 73 | case ('set', key, val): 74 | prev = state.get(key, None) 75 | state[key] = val 76 | return (gen_server.Reply(payload=prev), state) 77 | 78 | case _: 79 | exc = NotImplementedError('unknown request') 80 | return (gen_server.Reply(payload=exc), state) 81 | 82 | 83 | # if not defined, the gen_server will stop with a NotImplementedError when 84 | # receiving a cast 85 | async def handle_cast(message, state): 86 | match message: 87 | case 'stop': 88 | return (gen_server.Stop(), state) 89 | 90 | case _: 91 | print('unknown request') 92 | return (gen_server.NoReply(), state) 93 | 94 | 95 | # optional 96 | async def handle_info(message, state): 97 | match message: 98 | case 'printstate': 99 | print(state) 100 | 101 | case _: 102 | pass 103 | 104 | return (gen_server.NoReply(), state) 105 | """ 106 | 107 | from typing import TypeVar, Union, Optional, Any 108 | from types import ModuleType 109 | 110 | from dataclasses import dataclass 111 | 112 | import trio 113 | 114 | from triotp import mailbox, logging 115 | 116 | 117 | State = TypeVar("State") 118 | 119 | 120 | class GenServerExited(Exception): 121 | """ 122 | Raised when the generic server exited during a call. 123 | """ 124 | 125 | 126 | @dataclass 127 | class _Loop: 128 | yes: bool 129 | 130 | 131 | @dataclass 132 | class _Raise: 133 | exc: BaseException 134 | 135 | 136 | Continuation = Union[_Loop, _Raise] 137 | 138 | 139 | @dataclass 140 | class Reply: 141 | """ 142 | Return an instance of this class to send a reply to the caller. 143 | """ 144 | 145 | payload: Any #: The response to send back 146 | 147 | 148 | @dataclass 149 | class NoReply: 150 | """ 151 | Return an instance of this class to not send a reply to the caller. 152 | """ 153 | 154 | 155 | @dataclass 156 | class Stop: 157 | """ 158 | Return an instance of this class to stop the generic server. 159 | """ 160 | 161 | reason: Optional[BaseException] = ( 162 | None #: Eventual exception that caused the gen_server to stop 163 | ) 164 | 165 | 166 | @dataclass 167 | class _CallMessage: 168 | source: trio.MemorySendChannel 169 | payload: Any 170 | 171 | 172 | @dataclass 173 | class _CastMessage: 174 | payload: Any 175 | 176 | 177 | async def start( 178 | module: ModuleType, 179 | init_arg: Optional[Any] = None, 180 | name: Optional[str] = None, 181 | ) -> None: 182 | """ 183 | Starts the generic server loop. 184 | 185 | :param module: Module containing the generic server's callbacks 186 | :param init_arg: Optional argument passed to the `init` callback 187 | :param name: Optional name to use to register the generic server's mailbox 188 | 189 | :raises triotp.mailbox.NameAlreadyExist: If the `name` was already registered 190 | :raises Exception: If the generic server terminated with a non-null reason 191 | """ 192 | 193 | await _loop(module, init_arg, name) 194 | 195 | 196 | async def call( 197 | name_or_mid: Union[str, mailbox.MailboxID], 198 | payload: Any, 199 | timeout: Optional[float] = None, 200 | ) -> Any: 201 | """ 202 | Send a request to the generic server and wait for a response. 203 | 204 | This function creates a temporary bi-directional channel. The writer is 205 | passed to the `handle_call` function and is used to send the response back 206 | to the caller. 207 | 208 | :param name_or_mid: The generic server's mailbox identifier 209 | :param payload: The message to send to the generic server 210 | :param timeout: Optional timeout after which this function fails 211 | :returns: The response from the generic server 212 | :raises GenServerExited: If the generic server exited after handling the call 213 | :raises Exception: If the response is an exception 214 | 215 | """ 216 | 217 | wchan, rchan = trio.open_memory_channel[Exception | Any](0) 218 | message = _CallMessage(source=wchan, payload=payload) 219 | 220 | await mailbox.send(name_or_mid, message) 221 | 222 | try: 223 | if timeout is not None: 224 | with trio.fail_after(timeout): 225 | val = await rchan.receive() 226 | 227 | else: 228 | val = await rchan.receive() 229 | 230 | if isinstance(val, Exception): 231 | raise val 232 | 233 | return val 234 | 235 | finally: 236 | await wchan.aclose() 237 | await rchan.aclose() 238 | 239 | 240 | async def cast( 241 | name_or_mid: Union[str, mailbox.MailboxID], 242 | payload: Any, 243 | ) -> None: 244 | """ 245 | Send a message to the generic server without expecting a response. 246 | 247 | :param name_or_mid: The generic server's mailbox identifier 248 | :param payload: The message to send 249 | """ 250 | 251 | message = _CastMessage(payload=payload) 252 | await mailbox.send(name_or_mid, message) 253 | 254 | 255 | async def reply(caller: trio.MemorySendChannel[Any], response: Any) -> None: 256 | """ 257 | The `handle_call` callback can start a background task to handle a slow 258 | request and return a `NoReply` instance. Use this function in the background 259 | task to send the response to the caller at a later time. 260 | 261 | :param caller: The caller `SendChannel` to use to send the response 262 | :param response: The response to send back to the caller 263 | 264 | .. code-block:: python 265 | :caption: Example 266 | 267 | from triotp import gen_server, supervisor, dynamic_supervisor 268 | import trio 269 | 270 | 271 | async def slow_task(message, caller): 272 | # do stuff with message 273 | await gen_server.reply(caller, response) 274 | 275 | 276 | async def handle_call(message, caller, state): 277 | await dynamic_supervisor.start_child( 278 | 'slow-task-pool', 279 | supervisor.child_spec( 280 | id='some-slow-task', 281 | task=slow_task, 282 | args=[message, caller], 283 | restart=supervisor.restart_strategy.TEMPORARY, 284 | ), 285 | ) 286 | 287 | return (gen_server.NoReply(), state) 288 | """ 289 | 290 | await caller.send(response) 291 | 292 | 293 | async def _loop( 294 | module: ModuleType, 295 | init_arg: Optional[Any], 296 | name: Optional[str], 297 | ) -> None: 298 | async with mailbox.open(name) as mid: 299 | try: 300 | state: Any = await _init(module, init_arg) 301 | looping = True 302 | 303 | while looping: 304 | message = await mailbox.receive(mid) 305 | 306 | match message: 307 | case _CallMessage(source, payload): 308 | continuation, state = await _handle_call( 309 | module, payload, source, state 310 | ) 311 | 312 | case _CastMessage(payload): 313 | continuation, state = await _handle_cast(module, payload, state) 314 | 315 | case _: 316 | continuation, state = await _handle_info(module, message, state) 317 | 318 | match continuation: 319 | case _Loop(yes=False): 320 | looping = False 321 | 322 | case _Loop(yes=True): 323 | looping = True 324 | 325 | case _Raise(exc=err): 326 | raise err 327 | 328 | except Exception as err: 329 | await _terminate(module, err, state) 330 | raise err from None 331 | 332 | else: 333 | await _terminate(module, None, state) 334 | 335 | 336 | async def _init(module: ModuleType, init_arg: Any) -> State: 337 | return await module.init(init_arg) 338 | 339 | 340 | async def _terminate( 341 | module: ModuleType, 342 | reason: Optional[BaseException], 343 | state: State, 344 | ) -> None: 345 | handler = getattr(module, "terminate", None) 346 | if handler is not None: 347 | await handler(reason, state) 348 | 349 | elif reason is not None: 350 | logger = logging.getLogger(module.__name__) 351 | logger.exception(reason) 352 | 353 | 354 | async def _handle_call( 355 | module: ModuleType, 356 | message: Any, 357 | source: trio.MemorySendChannel, 358 | state: State, 359 | ) -> tuple[Continuation, State]: 360 | handler = getattr(module, "handle_call", None) 361 | if handler is None: 362 | raise NotImplementedError(f"{module.__name__}.handle_call") 363 | 364 | result = await handler(message, source, state) 365 | continuation: _Loop | _Raise 366 | 367 | match result: 368 | case (Reply(payload), new_state): 369 | state = new_state 370 | await reply(source, payload) 371 | continuation = _Loop(yes=True) 372 | 373 | case (NoReply(), new_state): 374 | state = new_state 375 | continuation = _Loop(yes=True) 376 | 377 | case (Stop(reason), new_state): 378 | state = new_state 379 | await reply(source, GenServerExited()) 380 | 381 | if reason is not None: 382 | continuation = _Raise(reason) 383 | 384 | else: 385 | continuation = _Loop(yes=False) 386 | 387 | case _: 388 | raise TypeError( 389 | f"{module.__name__}.handle_call did not return a valid value" 390 | ) 391 | 392 | return continuation, state 393 | 394 | 395 | async def _handle_cast( 396 | module: ModuleType, 397 | message: Any, 398 | state: State, 399 | ) -> tuple[Continuation, State]: 400 | handler = getattr(module, "handle_cast", None) 401 | if handler is None: 402 | raise NotImplementedError(f"{module.__name__}.handle_cast") 403 | 404 | result = await handler(message, state) 405 | continuation: _Loop | _Raise 406 | 407 | match result: 408 | case (NoReply(), new_state): 409 | state = new_state 410 | continuation = _Loop(yes=True) 411 | 412 | case (Stop(reason), new_state): 413 | state = new_state 414 | 415 | if reason is not None: 416 | continuation = _Raise(reason) 417 | 418 | else: 419 | continuation = _Loop(yes=False) 420 | 421 | case _: 422 | raise TypeError( 423 | f"{module.__name__}.handle_cast did not return a valid value" 424 | ) 425 | 426 | return continuation, state 427 | 428 | 429 | async def _handle_info( 430 | module: ModuleType, 431 | message: Any, 432 | state: State, 433 | ) -> tuple[Continuation, State]: 434 | handler = getattr(module, "handle_info", None) 435 | if handler is None: 436 | return _Loop(yes=True), state 437 | 438 | result = await handler(message, state) 439 | continuation: _Loop | _Raise 440 | 441 | match result: 442 | case (NoReply(), new_state): 443 | state = new_state 444 | continuation = _Loop(yes=True) 445 | 446 | case (Stop(reason), new_state): 447 | state = new_state 448 | 449 | if reason is not None: 450 | continuation = _Raise(reason) 451 | 452 | else: 453 | continuation = _Loop(yes=False) 454 | 455 | case _: 456 | raise TypeError( 457 | f"{module.__name__}.handle_info did not return a valid value" 458 | ) 459 | 460 | return continuation, state 461 | -------------------------------------------------------------------------------- /src/triotp/helpers.py: -------------------------------------------------------------------------------- 1 | from types import ModuleType 2 | 3 | import inspect 4 | import sys 5 | 6 | 7 | def current_module() -> ModuleType: 8 | """ 9 | This function should be called at the root of a module. 10 | 11 | :returns: The current module (similar to `__name__` for the current module name) 12 | 13 | .. code-block:: python 14 | :caption: Example 15 | 16 | from triotp.helpers import current_module 17 | 18 | __module__ = current_module() # THIS WORKS 19 | 20 | 21 | def get_module(): 22 | return current_module() # THIS WON'T WORK 23 | """ 24 | 25 | stack_frame = inspect.currentframe() 26 | 27 | while stack_frame: 28 | if stack_frame.f_code.co_name == "": 29 | if stack_frame.f_code.co_filename != "": 30 | caller_module = inspect.getmodule(stack_frame) 31 | 32 | else: 33 | caller_module = sys.modules["__main__"] 34 | 35 | if caller_module is not None: 36 | return caller_module 37 | 38 | break 39 | 40 | stack_frame = stack_frame.f_back 41 | 42 | raise RuntimeError("Unable to determine the current module.") 43 | -------------------------------------------------------------------------------- /src/triotp/logging.py: -------------------------------------------------------------------------------- 1 | """ 2 | TriOTP logging system relies on the Logbook_ library. Each node has its own log 3 | handler. 4 | 5 | .. _logbook: https://logbook.readthedocs.io/ 6 | """ 7 | 8 | from enum import Enum, auto 9 | 10 | import logbook # type: ignore[import-untyped] 11 | 12 | 13 | class LogLevel(Enum): 14 | """ 15 | TriOTP node's logging level 16 | """ 17 | 18 | NONE = auto() #: Logging is disabled 19 | DEBUG = auto() 20 | INFO = auto() 21 | WARNING = auto() 22 | ERROR = auto() 23 | CRITICAL = auto() 24 | 25 | def to_logbook(self) -> int: 26 | """ 27 | Convert this enum to a Logbook log level. 28 | 29 | :returns: Logbook log level 30 | """ 31 | 32 | return logbook.lookup_level(self.name) 33 | 34 | 35 | def getLogger(name: str) -> logbook.Logger: 36 | """ 37 | Get a logger by name. 38 | 39 | :param name: Name of the logger 40 | :returns: Logbook Logger instance 41 | """ 42 | 43 | return logbook.Logger(name) 44 | -------------------------------------------------------------------------------- /src/triotp/mailbox.py: -------------------------------------------------------------------------------- 1 | """ 2 | In Erlang_/Elixir_, each process have a PID that can be used to receive message 3 | from other processes. 4 | 5 | .. _erlang: https://erlang.org 6 | .. _elixir: https://elixir-lang.org/ 7 | 8 | With trio, there is no such thing as a process. There is only asynchronous tasks 9 | started within a nursery. 10 | 11 | This module provides an encapsulation of trio's memory channels_ which allows 12 | tasks to communicate with each other. 13 | 14 | .. _channels: https://trio.readthedocs.io/en/stable/reference-core.html#using-channels-to-pass-values-between-tasks 15 | 16 | .. code-block:: python 17 | :caption: Example 18 | 19 | from triotp import mailbox 20 | 21 | 22 | async def task_a(task_status=trio.TASK_STATUS_IGNORED): 23 | async with mailbox.open(name='task_a') as mid: 24 | task_status.started(None) 25 | 26 | msg = await mailbox.receive(mid) 27 | print(msg) 28 | 29 | 30 | async def task_b(): 31 | await mailbox.send('task_a', 'hello world') 32 | 33 | 34 | async def main(): 35 | async with trio.open_nursery() as nursery: 36 | await nursery.start(task_a) 37 | nursery.start_soon(task_b) 38 | """ 39 | 40 | from collections.abc import Callable, Awaitable 41 | from typing import Union, Optional, Any, AsyncIterator 42 | 43 | from contextlib import asynccontextmanager 44 | from contextvars import ContextVar 45 | from uuid import uuid4 46 | 47 | import trio 48 | 49 | 50 | type MailboxID = str #: Mailbox identifier (UUID4) 51 | 52 | type MailboxRegistry = dict[ 53 | MailboxID, 54 | tuple[trio.MemorySendChannel, trio.MemoryReceiveChannel], 55 | ] 56 | 57 | type NameRegistry = dict[str, MailboxID] 58 | 59 | context_mailbox_registry = ContextVar[MailboxRegistry]("mailbox_registry") 60 | context_name_registry = ContextVar[NameRegistry]("name_registry") 61 | 62 | 63 | class MailboxDoesNotExist(RuntimeError): 64 | """ 65 | Error thrown when the mailbox identifier was not found. 66 | """ 67 | 68 | def __init__(self, mid: MailboxID): 69 | super().__init__(f"mailbox {mid} does not exist") 70 | 71 | 72 | class NameAlreadyExist(RuntimeError): 73 | """ 74 | Error thrown when trying to register a mailbox to an already registered 75 | name. 76 | """ 77 | 78 | def __init__(self, name: str): 79 | super().__init__(f"mailbox {name} already registered") 80 | 81 | 82 | class NameDoesNotExist(RuntimeError): 83 | """ 84 | Error thrown when trying to unregister a non-existing name. 85 | """ 86 | 87 | def __init__(self, name: str): 88 | super().__init__(f"mailbox {name} does not exist") 89 | 90 | 91 | def _init() -> None: 92 | context_mailbox_registry.set({}) 93 | context_name_registry.set({}) 94 | 95 | 96 | def create() -> MailboxID: 97 | """ 98 | Create a new mailbox. 99 | 100 | :returns: The mailbox unique identifier 101 | """ 102 | 103 | mid = str(uuid4()) 104 | 105 | mailbox_registry = context_mailbox_registry.get() 106 | mailbox_registry[mid] = trio.open_memory_channel(0) 107 | 108 | return mid 109 | 110 | 111 | async def destroy(mid: MailboxID) -> None: 112 | """ 113 | Close and destroy a mailbox. 114 | 115 | :param mid: The mailbox identifier 116 | :raises MailboxDoesNotExist: The mailbox identifier was not found 117 | """ 118 | 119 | mailbox_registry = context_mailbox_registry.get() 120 | 121 | if mid not in mailbox_registry: 122 | raise MailboxDoesNotExist(mid) 123 | 124 | unregister_all(mid) 125 | 126 | wchan, rchan = mailbox_registry.pop(mid) 127 | await wchan.aclose() 128 | await rchan.aclose() 129 | 130 | 131 | def register(mid: MailboxID, name: str) -> None: 132 | """ 133 | Assign a name to a mailbox. 134 | 135 | :param mid: The mailbox identifier 136 | :param name: The new name 137 | 138 | :raises MailboxDoesNotExist: The mailbox identifier was not found 139 | :raises NameAlreadyExist: The name was already registered 140 | """ 141 | 142 | mailbox_registry = context_mailbox_registry.get() 143 | 144 | if mid not in mailbox_registry: 145 | raise MailboxDoesNotExist(mid) 146 | 147 | name_registry = context_name_registry.get() 148 | if name in name_registry: 149 | raise NameAlreadyExist(name) 150 | 151 | name_registry[name] = mid 152 | 153 | 154 | def unregister(name: str) -> None: 155 | """ 156 | Unregister a mailbox's name. 157 | 158 | :param name: The name to unregister 159 | :raises NameDoesNotExist: The name was not found 160 | """ 161 | 162 | name_registry = context_name_registry.get() 163 | if name not in name_registry: 164 | raise NameDoesNotExist(name) 165 | 166 | name_registry.pop(name) 167 | 168 | 169 | def unregister_all(mid: MailboxID) -> None: 170 | """ 171 | Unregister all names associated to a mailbox. 172 | 173 | :param mid: The mailbox identifier 174 | """ 175 | 176 | name_registry = context_name_registry.get() 177 | 178 | for name, mailbox_id in list(name_registry.items()): 179 | if mailbox_id == mid: 180 | name_registry.pop(name) 181 | 182 | 183 | @asynccontextmanager 184 | async def open(name: Optional[str] = None) -> AsyncIterator[MailboxID]: 185 | """ 186 | Shortcut for `create()`, `register()` followed by a `destroy()`. 187 | 188 | :param name: Optional name to register the mailbox 189 | :returns: Asynchronous context manager for the mailbox 190 | :raises NameAlreadyExist: If the `name` was already registered 191 | 192 | .. code-block:: python 193 | :caption: Example 194 | 195 | async with mailbox.open(name='foo') as mid: 196 | message = await mailbox.receive() 197 | print(message) 198 | """ 199 | 200 | mid = create() 201 | 202 | try: 203 | if name is not None: 204 | register(mid, name) 205 | 206 | yield mid 207 | 208 | finally: 209 | await destroy(mid) 210 | 211 | 212 | def _resolve(name: str) -> Optional[MailboxID]: 213 | name_registry = context_name_registry.get() 214 | return name_registry.get(name) 215 | 216 | 217 | async def send(name_or_mid: Union[str, MailboxID], message: Any) -> None: 218 | """ 219 | Send a message to a mailbox. 220 | 221 | :param name_or_mid: Either a registered name, or the mailbox identifier 222 | :param message: The message to send 223 | :raises MailboxDoesNotExist: The mailbox was not found 224 | """ 225 | 226 | mailbox_registry = context_mailbox_registry.get() 227 | 228 | mid = _resolve(name_or_mid) 229 | if mid is None: 230 | mid = name_or_mid 231 | 232 | if mid not in mailbox_registry: 233 | raise MailboxDoesNotExist(mid) 234 | 235 | wchan, _ = mailbox_registry[mid] 236 | await wchan.send(message) 237 | 238 | 239 | async def receive( 240 | mid: MailboxID, 241 | timeout: Optional[float] = None, 242 | on_timeout: Optional[Callable[[], Awaitable[Any]]] = None, 243 | ) -> Any: 244 | """ 245 | Consume a message from a mailbox. 246 | 247 | :param mid: The mailbox identifier 248 | :param timeout: If set, the call will fail after the timespan set in seconds 249 | :param on_timeout: If set and `timeout` is set, instead of raising an 250 | exception, the result of this async function will be 251 | returned 252 | 253 | :raises MailboxDoesNotExist: The mailbox was not found 254 | :raises trio.TooSlowError: If `timeout` is set, but `on_timeout` isn't, and 255 | no message was received during the timespan set 256 | """ 257 | 258 | mailbox_registry = context_mailbox_registry.get() 259 | 260 | if mid not in mailbox_registry: 261 | raise MailboxDoesNotExist(mid) 262 | 263 | _, rchan = mailbox_registry[mid] 264 | 265 | if timeout is not None: 266 | try: 267 | with trio.fail_after(timeout): 268 | return await rchan.receive() 269 | 270 | except trio.TooSlowError: 271 | if on_timeout is None: 272 | raise 273 | 274 | return await on_timeout() 275 | 276 | else: 277 | return await rchan.receive() 278 | -------------------------------------------------------------------------------- /src/triotp/node.py: -------------------------------------------------------------------------------- 1 | """ 2 | A TriOTP node encapsulates the call to `trio.run` and allows you to specify a 3 | list of application to start. 4 | 5 | **NB:** There is no dependency management between applications, it's up to 6 | you to start the correct applications in the right order. 7 | 8 | .. code-block:: python 9 | :caption: Example 10 | 11 | from pyotp import node, application 12 | 13 | from myproject import myapp1, myapp2 14 | 15 | node.run( 16 | apps=[ 17 | application.app_spec( 18 | module=myapp1, 19 | start_arg=[], 20 | ), 21 | application.app_spec( 22 | module=myapp2, 23 | start_arg=[], 24 | permanent=False, 25 | ), 26 | ], 27 | ) 28 | """ 29 | 30 | from typing import Optional 31 | 32 | import sys 33 | 34 | from logbook import StreamHandler, NullHandler # type: ignore[import-untyped] 35 | import trio 36 | 37 | from triotp import mailbox, application, logging 38 | 39 | 40 | def run( 41 | apps: list[application.app_spec], 42 | loglevel: logging.LogLevel = logging.LogLevel.NONE, 43 | logformat: Optional[str] = None, 44 | ) -> None: 45 | """ 46 | Start a new node by calling `trio.run`. 47 | 48 | :param apps: List of application to start 49 | :param loglevel: Logging Level of the node 50 | :param logformat: Format of log messages produced by the node 51 | """ 52 | 53 | match loglevel: 54 | case logging.LogLevel.NONE: 55 | handler = NullHandler() 56 | 57 | case _: 58 | handler = StreamHandler(sys.stdout, level=loglevel.to_logbook()) 59 | 60 | if logformat is not None: 61 | handler.format_string = logformat 62 | 63 | with handler.applicationbound(): 64 | trio.run(_start, apps) 65 | 66 | 67 | async def _start(apps: list[application.app_spec]) -> None: 68 | mailbox._init() 69 | 70 | async with trio.open_nursery() as nursery: 71 | application._init(nursery) 72 | 73 | for app_spec in apps: 74 | await application.start(app_spec) 75 | -------------------------------------------------------------------------------- /src/triotp/supervisor.py: -------------------------------------------------------------------------------- 1 | """ 2 | A supervisor is used to handle a set of asynchronous tasks. It takes care of 3 | restarting them if they exit prematurely or if they crash. 4 | 5 | .. code-block:: python 6 | :caption: Example 7 | 8 | from triotp import supervisor 9 | from random import random 10 | import trio 11 | 12 | async def loop(threshold): 13 | while True: 14 | if random() < threshold: 15 | raise RuntimeError('bad luck') 16 | 17 | else: 18 | await trio.sleep(0.1) 19 | 20 | async def start_supervisor(): 21 | children = [ 22 | supervisor.child_spec( 23 | id='loop', 24 | task=loop, 25 | args=[0.5], 26 | restart=supervisor.restart_strategy.PERMANENT, 27 | ), 28 | ] 29 | opts = supervisor.options( 30 | max_restarts=3, 31 | max_seconds=5 32 | ) 33 | await supervisor.start(children, opts) 34 | """ 35 | 36 | from collections.abc import Callable, Awaitable 37 | from typing import Any 38 | 39 | from collections import deque, defaultdict 40 | from contextlib import contextmanager 41 | from dataclasses import dataclass 42 | from enum import Enum, auto 43 | 44 | from logbook import Logger # type: ignore[import-untyped] 45 | import tenacity 46 | 47 | import trio 48 | 49 | 50 | class restart_strategy(Enum): 51 | """ 52 | Describe when to restart an asynchronous task. 53 | """ 54 | 55 | PERMANENT = auto() #: Always restart the task 56 | TRANSIENT = auto() #: Restart the task only if it raises an exception 57 | TEMPORARY = auto() #: Never restart a task 58 | 59 | 60 | @dataclass 61 | class child_spec: 62 | """ 63 | Describe an asynchronous task to supervise. 64 | """ 65 | 66 | id: str #: Task identifier 67 | task: Callable[..., Awaitable[None]] #: The task to run 68 | args: list[Any] #: Arguments to pass to the task 69 | restart: restart_strategy = restart_strategy.PERMANENT #: When to restart the task 70 | 71 | 72 | @dataclass 73 | class options: 74 | """ 75 | Describe the options for the supervisor. 76 | """ 77 | 78 | max_restarts: int = 3 #: Maximum number of restart during a limited timespan 79 | max_seconds: int = 5 #: Timespan duration 80 | 81 | 82 | class _retry_strategy: 83 | def __init__( 84 | self, 85 | restart: restart_strategy, 86 | max_restarts: int, 87 | max_seconds: float, 88 | ): 89 | self.restart = restart 90 | self.max_restarts = max_restarts 91 | self.max_seconds = max_seconds 92 | 93 | self.failure_times = deque[float]() 94 | 95 | def __call__(self, retry_state: tenacity.RetryCallState): 96 | assert retry_state.outcome is not None 97 | 98 | match self.restart: 99 | case restart_strategy.PERMANENT: 100 | pass 101 | 102 | case restart_strategy.TRANSIENT: 103 | if not retry_state.outcome.failed: 104 | return False 105 | 106 | case restart_strategy.TEMPORARY: 107 | return False 108 | 109 | now = trio.current_time() 110 | self.failure_times.append(now) 111 | 112 | if len(self.failure_times) <= self.max_restarts: 113 | return True 114 | 115 | oldest_failure = self.failure_times.popleft() 116 | return now - oldest_failure >= self.max_seconds 117 | 118 | 119 | class _retry_logger: 120 | def __init__(self, child_id: str): 121 | self.logger = Logger(child_id) 122 | 123 | def __call__(self, retry_state: tenacity.RetryCallState) -> None: 124 | assert retry_state.outcome is not None 125 | 126 | if isinstance(retry_state.outcome.exception(), trio.Cancelled): 127 | self.logger.info("task cancelled") 128 | 129 | elif retry_state.outcome.failed: 130 | exception = retry_state.outcome.exception() 131 | assert exception is not None 132 | 133 | exc_info = (exception.__class__, exception, exception.__traceback__) 134 | self.logger.error("restarting task after failure", exc_info=exc_info) 135 | 136 | else: 137 | self.logger.error("restarting task after unexpected exit") 138 | 139 | 140 | async def start( 141 | child_specs: list[child_spec], 142 | opts: options, 143 | task_status=trio.TASK_STATUS_IGNORED, 144 | ) -> None: 145 | """ 146 | Start the supervisor and its children. 147 | 148 | :param child_specs: Asynchronous tasks to supervise 149 | :param opts: Supervisor options 150 | :param task_status: Used to notify the trio nursery that the task is ready 151 | 152 | .. code-block:: python 153 | :caption: Example 154 | 155 | from triotp import supervisor 156 | import trio 157 | 158 | async def example(): 159 | children_a = [ 160 | # ... 161 | ] 162 | children_b = [ 163 | # ... 164 | ] 165 | opts = supervisor.options() 166 | 167 | async with trio.open_nursery() as nursery: 168 | await nursery.start(supervisor.start, children_a, opts) 169 | await nursery.start(supervisor.start, children_b, opts) 170 | """ 171 | 172 | async with trio.open_nursery() as nursery: 173 | for spec in child_specs: 174 | await nursery.start(_child_monitor, spec, opts) 175 | 176 | task_status.started(None) 177 | 178 | 179 | async def _child_monitor( 180 | spec: child_spec, 181 | opts: options, 182 | task_status=trio.TASK_STATUS_IGNORED, 183 | ) -> None: 184 | task_status.started(None) 185 | 186 | @tenacity.retry( 187 | retry=_retry_strategy(spec.restart, opts.max_restarts, opts.max_seconds), 188 | reraise=True, 189 | sleep=trio.sleep, 190 | after=_retry_logger(spec.id), 191 | ) 192 | async def _child_runner(): 193 | with defer_to_cancelled(): 194 | async with trio.open_nursery() as nursery: 195 | nursery.start_soon(spec.task, *spec.args) 196 | 197 | await _child_runner() 198 | 199 | 200 | @contextmanager 201 | def defer_to_cancelled(): 202 | """ 203 | Defer an exception group to the ``trio.Cancelled`` exception. 204 | """ 205 | 206 | privileged_types = (trio.Cancelled,) 207 | propagate = True 208 | strict = True 209 | 210 | try: 211 | yield 212 | 213 | except BaseExceptionGroup as root_exc_group: 214 | exc_groups = [root_exc_group] 215 | excs_by_repr = {} 216 | 217 | while exc_groups: 218 | exc_group = exc_groups.pop() 219 | 220 | for exc in exc_group.exceptions: 221 | if isinstance(exc, BaseExceptionGroup): 222 | exc_groups.append(exc) 223 | continue 224 | 225 | if not isinstance(exc, privileged_types): 226 | if propagate: 227 | raise 228 | 229 | raise RuntimeError("Unhandled exception group") from root_exc_group 230 | 231 | excs_by_repr[repr(exc)] = exc 232 | 233 | excs_by_priority = defaultdict(list) 234 | 235 | for exc in excs_by_repr.values(): 236 | for priority, privileged_type in enumerate(privileged_types): 237 | if isinstance(exc, privileged_type): 238 | excs_by_priority[priority].append(exc) 239 | 240 | priority_excs = excs_by_priority[min(excs_by_priority)] 241 | 242 | if strict and len(priority_excs) > 1: 243 | if propagate: 244 | raise 245 | 246 | raise RuntimeError("Unhandled exception group") from root_exc_group 247 | 248 | raise priority_excs[0] 249 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linkdd/triotp/01d362602cfa8eca5f4393013b595e1c20cb80ea/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from triotp import mailbox, application 4 | import trio 5 | 6 | import logbook 7 | 8 | 9 | @pytest.fixture 10 | def log_handler(): 11 | handler = logbook.TestHandler(level=logbook.DEBUG) 12 | 13 | with handler.applicationbound(): 14 | yield handler 15 | 16 | 17 | @pytest.fixture 18 | def mailbox_env(): 19 | mailbox._init() 20 | -------------------------------------------------------------------------------- /tests/test_application/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linkdd/triotp/01d362602cfa8eca5f4393013b595e1c20cb80ea/tests/test_application/__init__.py -------------------------------------------------------------------------------- /tests/test_application/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import trio 4 | 5 | 6 | class SampleData: 7 | def __init__(self): 8 | self.count = 0 9 | self.stop = trio.Event() 10 | 11 | 12 | @pytest.fixture 13 | def test_data(): 14 | return SampleData() 15 | -------------------------------------------------------------------------------- /tests/test_application/sample/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linkdd/triotp/01d362602cfa8eca5f4393013b595e1c20cb80ea/tests/test_application/sample/__init__.py -------------------------------------------------------------------------------- /tests/test_application/sample/app_a.py: -------------------------------------------------------------------------------- 1 | async def start(test_data): 2 | test_data.count += 1 3 | -------------------------------------------------------------------------------- /tests/test_application/sample/app_b.py: -------------------------------------------------------------------------------- 1 | async def start(test_data): 2 | test_data.count += 1 3 | raise RuntimeError("pytest") 4 | -------------------------------------------------------------------------------- /tests/test_application/sample/app_c.py: -------------------------------------------------------------------------------- 1 | async def start(test_data): 2 | test_data.count += 1 3 | await test_data.stop.wait() 4 | -------------------------------------------------------------------------------- /tests/test_application/test_restart.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from triotp import application, supervisor 4 | import trio 5 | 6 | from .sample import app_a, app_b 7 | 8 | 9 | @pytest.mark.parametrize("max_restarts", [1, 3, 5]) 10 | async def test_app_automatic_restart_permanent(test_data, max_restarts, log_handler): 11 | async with trio.open_nursery() as nursery: 12 | application._init(nursery) 13 | 14 | await application.start( 15 | application.app_spec( 16 | module=app_a, 17 | start_arg=test_data, 18 | permanent=True, 19 | opts=supervisor.options( 20 | max_restarts=max_restarts, 21 | ), 22 | ) 23 | ) 24 | 25 | assert test_data.count == (max_restarts + 1) 26 | assert log_handler.has_errors 27 | 28 | 29 | @pytest.mark.parametrize("max_restarts", [1, 3, 5]) 30 | async def test_app_automatic_restart_crash(test_data, max_restarts, log_handler): 31 | with trio.testing.RaisesGroup(RuntimeError, flatten_subgroups=True): 32 | async with trio.open_nursery() as nursery: 33 | application._init(nursery) 34 | 35 | await application.start( 36 | application.app_spec( 37 | module=app_b, 38 | start_arg=test_data, 39 | permanent=False, 40 | opts=supervisor.options( 41 | max_restarts=max_restarts, 42 | ), 43 | ) 44 | ) 45 | 46 | assert test_data.count == (max_restarts + 1) 47 | assert log_handler.has_errors 48 | 49 | 50 | async def test_app_no_automatic_restart(test_data, log_handler): 51 | async with trio.open_nursery() as nursery: 52 | application._init(nursery) 53 | 54 | await application.start( 55 | application.app_spec( 56 | module=app_a, 57 | start_arg=test_data, 58 | permanent=False, 59 | ) 60 | ) 61 | 62 | assert test_data.count == 1 63 | assert not log_handler.has_errors 64 | -------------------------------------------------------------------------------- /tests/test_application/test_stop.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from triotp import application, supervisor 4 | import trio 5 | 6 | from .sample import app_c 7 | 8 | 9 | async def test_app_stop(test_data, log_handler): 10 | async with trio.open_nursery() as nursery: 11 | application._init(nursery) 12 | 13 | await application.start( 14 | application.app_spec( 15 | module=app_c, 16 | start_arg=test_data, 17 | permanent=True, 18 | ) 19 | ) 20 | 21 | await trio.sleep(0.01) 22 | await application.stop(app_c.__name__) 23 | 24 | assert test_data.count == 1 25 | -------------------------------------------------------------------------------- /tests/test_dynamic_supervisor.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from triotp import supervisor, dynamic_supervisor 4 | import trio 5 | 6 | 7 | class SampleData: 8 | def __init__(self): 9 | self.exec_count = 0 10 | 11 | 12 | async def sample_task(test_data): 13 | test_data.exec_count += 1 14 | 15 | 16 | async def sample_task_error(test_data): 17 | test_data.exec_count += 1 18 | raise RuntimeError("pytest") 19 | 20 | 21 | @pytest.mark.parametrize("max_restarts", [1, 3, 5]) 22 | async def test_automatic_restart_permanent(max_restarts, log_handler, mailbox_env): 23 | test_data = SampleData() 24 | 25 | async with trio.open_nursery() as nursery: 26 | children = [ 27 | supervisor.child_spec( 28 | id="sample_task", 29 | task=sample_task, 30 | args=[test_data], 31 | restart=supervisor.restart_strategy.PERMANENT, 32 | ), 33 | ] 34 | opts = supervisor.options( 35 | max_restarts=max_restarts, 36 | max_seconds=5, 37 | ) 38 | mid = await nursery.start(dynamic_supervisor.start, opts) 39 | 40 | for child_spec in children: 41 | await dynamic_supervisor.start_child(mid, child_spec) 42 | 43 | await trio.sleep(0.5) 44 | nursery.cancel_scope.cancel() 45 | 46 | assert test_data.exec_count == (max_restarts + 1) 47 | assert log_handler.has_errors 48 | 49 | 50 | @pytest.mark.parametrize("max_restarts", [1, 3, 5]) 51 | @pytest.mark.parametrize( 52 | "strategy", 53 | [ 54 | supervisor.restart_strategy.PERMANENT, 55 | supervisor.restart_strategy.TRANSIENT, 56 | ], 57 | ) 58 | async def test_automatic_restart_crash( 59 | max_restarts, 60 | strategy, 61 | log_handler, 62 | mailbox_env, 63 | ): 64 | test_data = SampleData() 65 | 66 | with trio.testing.RaisesGroup(RuntimeError, flatten_subgroups=True): 67 | async with trio.open_nursery() as nursery: 68 | children = [ 69 | supervisor.child_spec( 70 | id="sample_task", 71 | task=sample_task_error, 72 | args=[test_data], 73 | restart=strategy, 74 | ), 75 | ] 76 | opts = supervisor.options( 77 | max_restarts=max_restarts, 78 | max_seconds=5, 79 | ) 80 | mid = await nursery.start(dynamic_supervisor.start, opts) 81 | 82 | for child_spec in children: 83 | await dynamic_supervisor.start_child(mid, child_spec) 84 | 85 | await trio.sleep(0.5) 86 | nursery.cancel_scope.cancel() 87 | 88 | assert test_data.exec_count == (max_restarts + 1) 89 | assert log_handler.has_errors 90 | 91 | 92 | @pytest.mark.parametrize( 93 | "strategy", 94 | [ 95 | supervisor.restart_strategy.TEMPORARY, 96 | supervisor.restart_strategy.TRANSIENT, 97 | ], 98 | ) 99 | async def test_no_restart(strategy, log_handler, mailbox_env): 100 | test_data = SampleData() 101 | 102 | async with trio.open_nursery() as nursery: 103 | children = [ 104 | supervisor.child_spec( 105 | id="sample_task", 106 | task=sample_task, 107 | args=[test_data], 108 | restart=strategy, 109 | ), 110 | ] 111 | opts = supervisor.options( 112 | max_restarts=3, 113 | max_seconds=5, 114 | ) 115 | mid = await nursery.start(dynamic_supervisor.start, opts) 116 | 117 | for child_spec in children: 118 | await dynamic_supervisor.start_child(mid, child_spec) 119 | 120 | await trio.sleep(0.5) 121 | nursery.cancel_scope.cancel() 122 | 123 | assert test_data.exec_count == 1 124 | assert not log_handler.has_errors 125 | -------------------------------------------------------------------------------- /tests/test_gen_server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linkdd/triotp/01d362602cfa8eca5f4393013b595e1c20cb80ea/tests/test_gen_server/__init__.py -------------------------------------------------------------------------------- /tests/test_gen_server/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from . import sample_kvstore 4 | import trio 5 | 6 | 7 | class GenServerTestState: 8 | def __init__(self): 9 | self.ready = trio.Event() 10 | self.stopped = trio.Event() 11 | self.info = trio.Event() 12 | self.casted = trio.Event() 13 | 14 | self.data = {} 15 | self.did_raise = None 16 | self.terminated_with = None 17 | 18 | self.info_val = None 19 | self.unknown_info = [] 20 | 21 | 22 | @pytest.fixture 23 | async def test_state(mailbox_env): 24 | test_state = GenServerTestState() 25 | 26 | async with trio.open_nursery() as nursery: 27 | nursery.start_soon(sample_kvstore.start, test_state) 28 | 29 | with trio.fail_after(0.1): 30 | await test_state.ready.wait() 31 | 32 | yield test_state 33 | 34 | nursery.cancel_scope.cancel() 35 | -------------------------------------------------------------------------------- /tests/test_gen_server/sample_kvstore.py: -------------------------------------------------------------------------------- 1 | from triotp.helpers import current_module 2 | from triotp import gen_server, mailbox 3 | import trio 4 | 5 | 6 | __module__ = current_module() 7 | 8 | 9 | async def start(test_state): 10 | try: 11 | await gen_server.start(__module__, test_state, name=__name__) 12 | 13 | except Exception as err: 14 | test_state.did_raise = err 15 | 16 | finally: 17 | test_state.stopped.set() 18 | 19 | 20 | class api: 21 | """ 22 | Normal KVStore API 23 | """ 24 | 25 | @staticmethod 26 | async def get(key): 27 | return await gen_server.call(__name__, ("api_get", key)) 28 | 29 | @staticmethod 30 | async def set(key, val): 31 | return await gen_server.call(__name__, ("api_set", key, val)) 32 | 33 | @staticmethod 34 | async def clear(): 35 | return await gen_server.call(__name__, "api_clear") 36 | 37 | 38 | class special_call: 39 | """ 40 | Special edge cases for gen_server.call 41 | """ 42 | 43 | @staticmethod 44 | async def delayed(nursery): 45 | return await gen_server.call(__name__, ("special_call_delayed", nursery)) 46 | 47 | @staticmethod 48 | async def timedout(timeout): 49 | return await gen_server.call(__name__, "special_call_timedout", timeout=timeout) 50 | 51 | @staticmethod 52 | async def stopped(): 53 | return await gen_server.call(__name__, "special_call_stopped") 54 | 55 | @staticmethod 56 | async def failure(): 57 | return await gen_server.call(__name__, "special_call_failure") 58 | 59 | 60 | class special_cast: 61 | """ 62 | Special edge cases for gen_server.cast 63 | """ 64 | 65 | @staticmethod 66 | async def normal(): 67 | await gen_server.cast(__name__, "special_cast_normal") 68 | 69 | @staticmethod 70 | async def stop(): 71 | await gen_server.cast(__name__, "special_cast_stop") 72 | 73 | @staticmethod 74 | async def fail(): 75 | await gen_server.cast(__name__, "special_cast_fail") 76 | 77 | 78 | class special_info: 79 | """ 80 | Special edge cases for direct messages 81 | """ 82 | 83 | async def matched(val): 84 | await mailbox.send(__name__, ("special_info_matched", val)) 85 | 86 | async def no_match(val): 87 | await mailbox.send(__name__, ("special_info_no_match", val)) 88 | 89 | async def stop(): 90 | await mailbox.send(__name__, "special_info_stop") 91 | 92 | async def fail(): 93 | await mailbox.send(__name__, "special_info_fail") 94 | 95 | 96 | # gen_server callbacks 97 | 98 | 99 | async def init(test_state): 100 | test_state.ready.set() 101 | return test_state 102 | 103 | 104 | async def terminate(reason, test_state): 105 | test_state.terminated_with = reason 106 | 107 | 108 | async def handle_call(message, caller, test_state): 109 | match message: 110 | case ("api_get", key): 111 | val = test_state.data.get(key) 112 | return (gen_server.Reply(val), test_state) 113 | 114 | case ("api_set", key, val): 115 | prev = test_state.data.get(key) 116 | test_state.data[key] = val 117 | return (gen_server.Reply(prev), test_state) 118 | 119 | case ("special_call_delayed", nursery): 120 | 121 | async def slow_task(): 122 | await trio.sleep(0) 123 | await gen_server.reply(caller, "done") 124 | 125 | nursery.start_soon(slow_task) 126 | return (gen_server.NoReply(), test_state) 127 | 128 | case "special_call_timedout": 129 | return (gen_server.NoReply(), test_state) 130 | 131 | case "special_call_stopped": 132 | return (gen_server.Stop(), test_state) 133 | 134 | case "special_call_failure": 135 | exc = RuntimeError("pytest") 136 | return (gen_server.Stop(exc), test_state) 137 | 138 | case _: 139 | exc = NotImplementedError("wrong call") 140 | return (gen_server.Reply(exc), test_state) 141 | 142 | 143 | async def handle_cast(message, test_state): 144 | match message: 145 | case "special_cast_normal": 146 | test_state.casted.set() 147 | return (gen_server.NoReply(), test_state) 148 | 149 | case "special_cast_stop": 150 | return (gen_server.Stop(), test_state) 151 | 152 | case _: 153 | exc = NotImplementedError("wrong cast") 154 | return (gen_server.Stop(exc), test_state) 155 | 156 | 157 | async def handle_info(message, test_state): 158 | match message: 159 | case ("special_info_matched", val): 160 | test_state.info_val = val 161 | test_state.info.set() 162 | return (gen_server.NoReply(), test_state) 163 | 164 | case "special_info_stop": 165 | return (gen_server.Stop(), test_state) 166 | 167 | case "special_info_fail": 168 | exc = RuntimeError("pytest") 169 | return (gen_server.Stop(exc), test_state) 170 | 171 | case _: 172 | test_state.unknown_info.append(message) 173 | test_state.info.set() 174 | return (gen_server.NoReply(), test_state) 175 | -------------------------------------------------------------------------------- /tests/test_gen_server/test_api.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from . import sample_kvstore as kvstore 4 | 5 | 6 | async def test_kvstore_api(test_state): 7 | val = await kvstore.api.get("foo") 8 | assert val is None 9 | 10 | val = await kvstore.api.set("foo", "bar") 11 | assert val is None 12 | 13 | val = await kvstore.api.get("foo") 14 | assert val == "bar" 15 | 16 | val = await kvstore.api.set("foo", "baz") 17 | assert val == "bar" 18 | 19 | val = await kvstore.api.get("foo") 20 | assert val == "baz" 21 | 22 | with pytest.raises(NotImplementedError): 23 | await kvstore.api.clear() 24 | -------------------------------------------------------------------------------- /tests/test_gen_server/test_call.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from . import sample_kvstore as kvstore 4 | 5 | from triotp.gen_server import GenServerExited 6 | import trio 7 | 8 | 9 | async def test_kvstore_call_delayed(test_state): 10 | async with trio.open_nursery() as nursery: 11 | resp = await kvstore.special_call.delayed(nursery) 12 | 13 | assert resp == "done" 14 | 15 | 16 | async def test_kvstore_call_timeout(test_state): 17 | with pytest.raises(trio.TooSlowError): 18 | await kvstore.special_call.timedout(0.01) 19 | 20 | 21 | async def test_kvstore_call_stopped(test_state): 22 | with pytest.raises(GenServerExited): 23 | await kvstore.special_call.stopped() 24 | 25 | with trio.fail_after(0.1): 26 | await test_state.stopped.wait() 27 | 28 | assert test_state.terminated_with is None 29 | assert test_state.did_raise is None 30 | 31 | 32 | async def test_kvstore_call_failure(test_state): 33 | with pytest.raises(GenServerExited): 34 | await kvstore.special_call.failure() 35 | 36 | with trio.fail_after(0.1): 37 | await test_state.stopped.wait() 38 | 39 | assert isinstance(test_state.terminated_with, RuntimeError) 40 | assert test_state.did_raise is test_state.terminated_with 41 | -------------------------------------------------------------------------------- /tests/test_gen_server/test_cast.py: -------------------------------------------------------------------------------- 1 | from . import sample_kvstore as kvstore 2 | import trio 3 | 4 | 5 | async def test_kvstore_cast_normal(test_state): 6 | await kvstore.special_cast.normal() 7 | 8 | with trio.fail_after(0.1): 9 | await test_state.casted.wait() 10 | 11 | 12 | async def tests_kvstore_cast_stop(test_state): 13 | await kvstore.special_cast.stop() 14 | 15 | with trio.fail_after(0.1): 16 | await test_state.stopped.wait() 17 | 18 | assert test_state.terminated_with is None 19 | assert test_state.did_raise is None 20 | 21 | 22 | async def test_kvstore_cast_fail(test_state): 23 | await kvstore.special_cast.fail() 24 | 25 | with trio.fail_after(0.1): 26 | await test_state.stopped.wait() 27 | 28 | assert isinstance(test_state.terminated_with, NotImplementedError) 29 | assert test_state.did_raise is test_state.terminated_with 30 | -------------------------------------------------------------------------------- /tests/test_gen_server/test_info.py: -------------------------------------------------------------------------------- 1 | from . import sample_kvstore as kvstore 2 | import trio 3 | 4 | 5 | async def test_kvstore_info_stop(test_state): 6 | await kvstore.special_info.stop() 7 | 8 | with trio.fail_after(0.1): 9 | await test_state.stopped.wait() 10 | 11 | assert test_state.terminated_with is None 12 | assert test_state.did_raise is None 13 | 14 | 15 | async def test_kvstore_info_fail(test_state): 16 | await kvstore.special_info.fail() 17 | 18 | with trio.fail_after(0.1): 19 | await test_state.stopped.wait() 20 | 21 | assert isinstance(test_state.terminated_with, RuntimeError) 22 | assert test_state.did_raise is test_state.terminated_with 23 | 24 | 25 | async def test_kvstore_info_matched(test_state): 26 | await kvstore.special_info.matched("foo") 27 | 28 | with trio.fail_after(0.1): 29 | await test_state.info.wait() 30 | 31 | assert test_state.info_val == "foo" 32 | 33 | 34 | async def test_kvstore_info_no_match(test_state): 35 | await kvstore.special_info.no_match("foo") 36 | 37 | with trio.fail_after(0.1): 38 | await test_state.info.wait() 39 | 40 | assert test_state.info_val is None 41 | assert len(test_state.unknown_info) == 1 42 | assert test_state.unknown_info[0] == ("special_info_no_match", "foo") 43 | -------------------------------------------------------------------------------- /tests/test_helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linkdd/triotp/01d362602cfa8eca5f4393013b595e1c20cb80ea/tests/test_helpers/__init__.py -------------------------------------------------------------------------------- /tests/test_helpers/sample.py: -------------------------------------------------------------------------------- 1 | from triotp.helpers import current_module 2 | 3 | 4 | __module__ = current_module() 5 | 6 | 7 | def get_module(): 8 | return current_module() 9 | -------------------------------------------------------------------------------- /tests/test_helpers/test_sample.py: -------------------------------------------------------------------------------- 1 | from . import sample 2 | 3 | 4 | def test_current_module(): 5 | assert sample is sample.__module__ 6 | assert sample is not sample.get_module() 7 | -------------------------------------------------------------------------------- /tests/test_logging.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from triotp import logging 4 | import logbook 5 | 6 | 7 | def test_logenum(): 8 | assert logging.LogLevel.DEBUG.to_logbook() == logbook.DEBUG 9 | assert logging.LogLevel.INFO.to_logbook() == logbook.INFO 10 | assert logging.LogLevel.WARNING.to_logbook() == logbook.WARNING 11 | assert logging.LogLevel.ERROR.to_logbook() == logbook.ERROR 12 | assert logging.LogLevel.CRITICAL.to_logbook() == logbook.CRITICAL 13 | 14 | with pytest.raises(LookupError): 15 | logging.LogLevel.NONE.to_logbook() 16 | 17 | 18 | def test_logger(log_handler): 19 | logger = logging.getLogger("pytest") 20 | 21 | logger.debug("foo") 22 | assert log_handler.has_debug("foo", channel="pytest") 23 | 24 | logger.info("foo") 25 | assert log_handler.has_info("foo", channel="pytest") 26 | 27 | logger.warn("foo") 28 | assert log_handler.has_warning("foo", channel="pytest") 29 | 30 | logger.error("foo") 31 | assert log_handler.has_error("foo", channel="pytest") 32 | 33 | logger.critical("foo") 34 | assert log_handler.has_critical("foo", channel="pytest") 35 | -------------------------------------------------------------------------------- /tests/test_mailbox.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from triotp import mailbox 4 | import trio 5 | 6 | 7 | class Producer: 8 | def __init__(self, mbox): 9 | self.mbox = mbox 10 | 11 | async def __call__(self, message): 12 | await mailbox.send(self.mbox, message) 13 | 14 | 15 | class Consumer: 16 | def __init__(self, mbox, timeout=None, with_on_timeout=True): 17 | self.mbox = mbox 18 | self.timeout = timeout 19 | self.with_on_timeout = with_on_timeout 20 | 21 | self.received_message = None 22 | self.timed_out = False 23 | 24 | async def on_timeout(self): 25 | self.timed_out = True 26 | return None 27 | 28 | async def __call__(self, task_status=trio.TASK_STATUS_IGNORED): 29 | async with mailbox.open(self.mbox) as mid: 30 | task_status.started(mid) 31 | 32 | cb = self.on_timeout if self.with_on_timeout else None 33 | self.received_message = await mailbox.receive( 34 | mid, 35 | timeout=self.timeout, 36 | on_timeout=cb, 37 | ) 38 | 39 | 40 | async def test_receive_no_timeout(mailbox_env): 41 | producer = Producer("pytest") 42 | consumer = Consumer("pytest") 43 | 44 | async with trio.open_nursery() as nursery: 45 | await nursery.start(consumer) 46 | nursery.start_soon(producer, "foo") 47 | 48 | assert not consumer.timed_out 49 | assert consumer.received_message == "foo" 50 | 51 | 52 | async def test_receive_on_timeout(mailbox_env): 53 | consumer = Consumer("pytest", timeout=0.01) 54 | 55 | async with trio.open_nursery() as nursery: 56 | await nursery.start(consumer) 57 | 58 | assert consumer.timed_out 59 | assert consumer.received_message is None 60 | 61 | 62 | async def test_receive_too_slow(mailbox_env): 63 | consumer = Consumer("pytest", timeout=0.01, with_on_timeout=False) 64 | 65 | with trio.testing.RaisesGroup(trio.TooSlowError, flatten_subgroups=True): 66 | async with trio.open_nursery() as nursery: 67 | await nursery.start(consumer) 68 | 69 | assert not consumer.timed_out 70 | assert consumer.received_message is None 71 | 72 | 73 | async def test_no_mailbox(mailbox_env): 74 | producer = Producer("pytest") 75 | 76 | with pytest.raises(mailbox.MailboxDoesNotExist): 77 | await producer("foo") 78 | 79 | with pytest.raises(mailbox.MailboxDoesNotExist): 80 | await mailbox.receive("pytest") 81 | 82 | 83 | async def test_direct(mailbox_env): 84 | consumer = Consumer(None) 85 | 86 | async with trio.open_nursery() as nursery: 87 | mid = await nursery.start(consumer) 88 | producer = Producer(mid) 89 | nursery.start_soon(producer, "foo") 90 | 91 | assert not consumer.timed_out 92 | assert consumer.received_message == "foo" 93 | 94 | 95 | async def test_register(mailbox_env): 96 | consumer = Consumer("pytest") 97 | 98 | with pytest.raises(mailbox.MailboxDoesNotExist): 99 | mailbox.register("not-found", "pytest") 100 | 101 | with trio.testing.RaisesGroup(mailbox.NameAlreadyExist, flatten_subgroups=True): 102 | async with trio.open_nursery() as nursery: 103 | await nursery.start(consumer) 104 | await nursery.start(consumer) 105 | 106 | 107 | async def test_unregister(mailbox_env): 108 | consumer = Consumer("pytest") 109 | producer = Producer("pytest") 110 | 111 | with trio.testing.RaisesGroup(mailbox.MailboxDoesNotExist, flatten_subgroups=True): 112 | async with trio.open_nursery() as nursery: 113 | await nursery.start(consumer) 114 | 115 | mailbox.unregister("pytest") 116 | 117 | with pytest.raises(mailbox.NameDoesNotExist): 118 | mailbox.unregister("pytest") 119 | 120 | nursery.start_soon(producer, "foo") 121 | 122 | 123 | async def test_destroy_unknown(mailbox_env): 124 | with pytest.raises(mailbox.MailboxDoesNotExist): 125 | await mailbox.destroy("not-found") 126 | -------------------------------------------------------------------------------- /tests/test_node/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linkdd/triotp/01d362602cfa8eca5f4393013b595e1c20cb80ea/tests/test_node/__init__.py -------------------------------------------------------------------------------- /tests/test_node/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | class SampleData: 5 | def __init__(self): 6 | self.count = 0 7 | 8 | 9 | @pytest.fixture 10 | def test_data(): 11 | return SampleData() 12 | -------------------------------------------------------------------------------- /tests/test_node/sample_app.py: -------------------------------------------------------------------------------- 1 | async def start(test_data): 2 | test_data.count += 1 3 | -------------------------------------------------------------------------------- /tests/test_node/test_run.py: -------------------------------------------------------------------------------- 1 | from triotp import node, application 2 | 3 | from . import sample_app 4 | 5 | 6 | def test_node_run(test_data): 7 | node.run( 8 | apps=[ 9 | application.app_spec( 10 | module=sample_app, start_arg=test_data, permanent=False 11 | ) 12 | ] 13 | ) 14 | 15 | assert test_data.count == 1 16 | -------------------------------------------------------------------------------- /tests/test_supervisor.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from triotp import supervisor 4 | import trio 5 | 6 | 7 | class SampleData: 8 | def __init__(self): 9 | self.exec_count = 0 10 | 11 | 12 | async def sample_task(test_data): 13 | test_data.exec_count += 1 14 | 15 | 16 | async def sample_task_error(test_data): 17 | test_data.exec_count += 1 18 | raise RuntimeError("pytest") 19 | 20 | 21 | @pytest.mark.parametrize("max_restarts", [1, 3, 5]) 22 | async def test_automatic_restart_permanent(max_restarts, log_handler): 23 | test_data = SampleData() 24 | 25 | async with trio.open_nursery() as nursery: 26 | children = [ 27 | supervisor.child_spec( 28 | id="sample_task", 29 | task=sample_task, 30 | args=[test_data], 31 | restart=supervisor.restart_strategy.PERMANENT, 32 | ), 33 | ] 34 | opts = supervisor.options( 35 | max_restarts=max_restarts, 36 | max_seconds=5, 37 | ) 38 | await nursery.start(supervisor.start, children, opts) 39 | 40 | assert test_data.exec_count == (max_restarts + 1) 41 | assert log_handler.has_errors 42 | 43 | 44 | @pytest.mark.parametrize("max_restarts", [1, 3, 5]) 45 | @pytest.mark.parametrize( 46 | "strategy", 47 | [ 48 | supervisor.restart_strategy.PERMANENT, 49 | supervisor.restart_strategy.TRANSIENT, 50 | ], 51 | ) 52 | async def test_automatic_restart_crash(max_restarts, strategy, log_handler): 53 | test_data = SampleData() 54 | 55 | with trio.testing.RaisesGroup(RuntimeError, flatten_subgroups=True): 56 | async with trio.open_nursery() as nursery: 57 | children = [ 58 | supervisor.child_spec( 59 | id="sample_task", 60 | task=sample_task_error, 61 | args=[test_data], 62 | restart=strategy, 63 | ), 64 | ] 65 | opts = supervisor.options( 66 | max_restarts=max_restarts, 67 | max_seconds=5, 68 | ) 69 | await nursery.start(supervisor.start, children, opts) 70 | 71 | assert test_data.exec_count == (max_restarts + 1) 72 | assert log_handler.has_errors 73 | 74 | 75 | @pytest.mark.parametrize( 76 | "strategy", 77 | [ 78 | supervisor.restart_strategy.TEMPORARY, 79 | supervisor.restart_strategy.TRANSIENT, 80 | ], 81 | ) 82 | async def test_no_restart(strategy, log_handler): 83 | test_data = SampleData() 84 | 85 | async with trio.open_nursery() as nursery: 86 | children = [ 87 | supervisor.child_spec( 88 | id="sample_task", 89 | task=sample_task, 90 | args=[test_data], 91 | restart=strategy, 92 | ), 93 | ] 94 | opts = supervisor.options( 95 | max_restarts=3, 96 | max_seconds=5, 97 | ) 98 | await nursery.start(supervisor.start, children, opts) 99 | 100 | assert test_data.exec_count == 1 101 | assert not log_handler.has_errors 102 | --------------------------------------------------------------------------------