├── .github ├── pull_request_template.md └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── LICENSE ├── README.rst ├── docs ├── acknowledgements.rst ├── api.rst ├── conf.py ├── index.rst ├── tutorials │ ├── echo.rst │ ├── index.rst │ ├── snippets │ │ ├── echo1.py │ │ ├── webnotifier-app1.py │ │ ├── webnotifier-app2.py │ │ ├── webnotifier-app3.py │ │ ├── webnotifier-app4.py │ │ ├── webnotifier-detector1.py │ │ └── webnotifier-detector2.py │ └── webnotifier.rst ├── userguide │ ├── architecture.rst │ ├── components.rst │ ├── concurrency.rst │ ├── contexts.rst │ ├── deployment.rst │ ├── events.rst │ ├── index.rst │ ├── migration.rst │ ├── snippets │ │ └── components1.py │ └── testing.rst └── versionhistory.rst ├── examples ├── tutorial1 │ ├── echo │ │ ├── __init__.py │ │ ├── client.py │ │ └── server.py │ └── tests │ │ └── test_client_server.py └── tutorial2 │ ├── config.yaml │ └── webnotifier │ ├── __init__.py │ ├── app.py │ └── detector.py ├── pyproject.toml ├── src └── asphalt │ ├── __main__.py │ └── core │ ├── __init__.py │ ├── _cli.py │ ├── _component.py │ ├── _concurrent.py │ ├── _context.py │ ├── _event.py │ ├── _exceptions.py │ ├── _runner.py │ ├── _utils.py │ └── py.typed └── tests ├── common.py ├── test_cli.py ├── test_component.py ├── test_concurrent.py ├── test_context.py ├── test_event.py ├── test_runner.py └── test_utils.py /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | ## Changes 3 | 4 | Fixes #. 5 | 6 | 7 | 8 | ## Checklist 9 | 10 | If this is a user-facing code change, like a bugfix or a new feature, please ensure that 11 | you've fulfilled the following conditions (where applicable): 12 | 13 | - [ ] You've added tests (in `tests/`) added which would fail without your patch 14 | - [ ] You've updated the documentation (in `docs/`, in case of behavior changes or new 15 | features) 16 | - [ ] You've added a new changelog entry (in `docs/versionhistory.rst`). 17 | 18 | If this is a trivial change, like a typo fix or a code reformatting, then you can ignore 19 | these instructions. 20 | 21 | ### Updating the changelog 22 | 23 | If there are no entries after the last release, use `**UNRELEASED**` as the version. 24 | If, say, your patch fixes issue #123, the entry should look like this: 25 | 26 | ``` 27 | - Fix big bad boo-boo in task groups 28 | (`#123 `_; PR by @yourgithubaccount) 29 | ``` 30 | 31 | If there's no issue linked, just link to your pull request instead by updating the 32 | changelog after you've created the PR. 33 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish packages to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - "[0-9]+.[0-9]+.[0-9]+" 7 | - "[0-9]+.[0-9]+.[0-9]+.post[0-9]+" 8 | - "[0-9]+.[0-9]+.[0-9]+[a-b][0-9]+" 9 | - "[0-9]+.[0-9]+.[0-9]+rc[0-9]+" 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | environment: release 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: 3.x 21 | - name: Install dependencies 22 | run: pip install build 23 | - name: Create packages 24 | run: python -m build 25 | - name: Archive packages 26 | uses: actions/upload-artifact@v4 27 | with: 28 | name: dist 29 | path: dist 30 | 31 | publish: 32 | needs: build 33 | runs-on: ubuntu-latest 34 | environment: release 35 | permissions: 36 | id-token: write 37 | steps: 38 | - name: Retrieve packages 39 | uses: actions/download-artifact@v4 40 | - name: Upload packages 41 | uses: pypa/gh-action-pypi-publish@release/v1 42 | 43 | release: 44 | name: Create a GitHub release 45 | needs: build 46 | runs-on: ubuntu-latest 47 | permissions: 48 | contents: write 49 | steps: 50 | - uses: actions/checkout@v4 51 | - id: changelog 52 | uses: agronholm/release-notes@v1 53 | with: 54 | path: docs/versionhistory.rst 55 | - uses: ncipollo/release-action@v1 56 | with: 57 | body: ${{ steps.changelog.outputs.changelog }} 58 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test suite 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | 8 | jobs: 9 | pyright: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Python 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: 3.x 17 | - uses: actions/cache@v4 18 | with: 19 | path: ~/.cache/pip 20 | key: pip-pyright 21 | - name: Install dependencies 22 | run: pip install -e . pyright 23 | - name: Run pyright 24 | run: pyright --verifytypes asphalt.core 25 | 26 | test: 27 | strategy: 28 | fail-fast: false 29 | matrix: 30 | os: [ubuntu-latest] 31 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy-3.10"] 32 | include: 33 | - os: macos-latest 34 | python-version: "3.9" 35 | - os: macos-latest 36 | python-version: "3.13" 37 | - os: windows-latest 38 | python-version: "3.9" 39 | - os: windows-latest 40 | python-version: "3.13" 41 | runs-on: ${{ matrix.os }} 42 | steps: 43 | - uses: actions/checkout@v4 44 | - name: Set up Python ${{ matrix.python-version }} 45 | uses: actions/setup-python@v5 46 | with: 47 | python-version: ${{ matrix.python-version }} 48 | allow-prereleases: true 49 | cache: pip 50 | cache-dependency-path: pyproject.toml 51 | - name: Install dependencies 52 | run: pip install -e .[test] 53 | - name: Test with pytest 54 | run: coverage run -m pytest -v 55 | - name: Generate coverage report 56 | run: coverage xml 57 | - name: Upload Coverage 58 | uses: coverallsapp/github-action@v2 59 | with: 60 | parallel: true 61 | file: coverage.xml 62 | 63 | coveralls: 64 | name: Finish Coveralls 65 | needs: test 66 | runs-on: ubuntu-latest 67 | steps: 68 | - name: Finished 69 | uses: coverallsapp/github-action@v2 70 | with: 71 | parallel-finished: true 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .project 2 | .pydevproject 3 | .idea 4 | .tox 5 | .coverage 6 | .cache 7 | .mypy_cache 8 | .pytest_cache 9 | .ruff_cache 10 | .eggs/ 11 | *.egg-info/ 12 | __pycache__/ 13 | docs/_build/ 14 | dist/ 15 | build/ 16 | virtualenv/ 17 | venv* 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # This is the configuration file for pre-commit (https://pre-commit.com/). 2 | # To use: 3 | # * Install pre-commit (https://pre-commit.com/#installation) 4 | # * Copy this file as ".pre-commit-config.yaml" 5 | # * Run "pre-commit install". 6 | repos: 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v5.0.0 9 | hooks: 10 | - id: check-toml 11 | - id: check-yaml 12 | - id: debug-statements 13 | - id: end-of-file-fixer 14 | - id: mixed-line-ending 15 | args: [ "--fix=lf" ] 16 | - id: trailing-whitespace 17 | 18 | - repo: https://github.com/astral-sh/ruff-pre-commit 19 | rev: v0.8.3 20 | hooks: 21 | - id: ruff 22 | args: [--fix, --show-fixes] 23 | - id: ruff-format 24 | 25 | - repo: https://github.com/pre-commit/mirrors-mypy 26 | rev: v1.13.0 27 | hooks: 28 | - id: mypy 29 | additional_dependencies: 30 | - anyio 31 | - click 32 | - pytest 33 | - types-PyYAML 34 | 35 | - repo: https://github.com/pre-commit/pygrep-hooks 36 | rev: v1.10.0 37 | hooks: 38 | - id: rst-backticks 39 | - id: rst-directive-colons 40 | - id: rst-inline-touching-normal 41 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.9" 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | python: 12 | install: 13 | - method: pip 14 | path: . 15 | extra_requirements: [doc] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://github.com/asphalt-framework/asphalt/actions/workflows/test.yml/badge.svg 2 | :target: https://github.com/asphalt-framework/asphalt/actions/workflows/test.yml 3 | :alt: Build Status 4 | .. image:: https://coveralls.io/repos/github/asphalt-framework/asphalt/badge.svg?branch=master 5 | :target: https://coveralls.io/github/asphalt-framework/asphalt?branch=master 6 | :alt: Code Coverage 7 | .. image:: https://readthedocs.org/projects/asphalt/badge/?version=latest 8 | :target: https://asphalt.readthedocs.io/en/latest/?badge=latest 9 | :alt: Documentation Status 10 | 11 | Asphalt is an asynchronous application microframework. 12 | 13 | Its highlight features are: 14 | 15 | * An ecosystem of components for integrating functionality from third party libraries and external 16 | services 17 | * A configuration system where hard-coded defaults can be selectively overridden by external 18 | configuration 19 | * A sophisticated signal system that lets you connect different services to create complex 20 | event-driven interactions 21 | * Built on top of AnyIO_ to work with both Trio_ and asyncio_ 22 | * Designed for `Structured Concurrency`_ from the ground up 23 | * `Type hints`_ and `semantic versioning`_ used throughout the core and all component libraries 24 | 25 | Asphalt can be used to make any imaginable kind of networked application, ranging from trivial 26 | command line tools to highly complex component hierarchies that start multiple network servers 27 | and/or clients using different protocols. 28 | 29 | What really sets Asphalt apart from other frameworks is its resource sharing system – the kind of 30 | functionality usually only found in bulky application server software. Asphalt components publish 31 | their services as *resources* in a shared *context*. Components can build on resources provided by 32 | each other, making it possible to create components that offer highly sophisticated functionality 33 | with relatively little effort. 34 | 35 | Full documentation: https://asphalt.readthedocs.io/ 36 | 37 | .. _Structured Concurrency: https://en.wikipedia.org/wiki/Structured_concurrency 38 | .. _asyncio: https://docs.python.org/3/library/asyncio.html 39 | .. _Trio: https://github.com/python-trio/trio 40 | .. _Type hints: https://www.python.org/dev/peps/pep-0484/ 41 | .. _semantic versioning: http://semver.org/ 42 | -------------------------------------------------------------------------------- /docs/acknowledgements.rst: -------------------------------------------------------------------------------- 1 | Acknowledgements 2 | ================ 3 | 4 | Many thanks to following people for the time spent helping with Asphalt's development: 5 | 6 | * Alice Bevan-McGregor (brainstorming and documentation QA) 7 | * David Brochart (brainstorming and practical testing on Asphalt 5) 8 | * Guillaume "Cman" Brun (brainstorming) 9 | * Darin Gordon (brainstorming and documentation QA) 10 | * Antti Haapala (brainstorming) 11 | * Olli Paloheimo (Asphalt logo design). 12 | * Cody Scott (tutorials QA) 13 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API reference 2 | ============= 3 | 4 | .. py:currentmodule:: asphalt.core 5 | 6 | Components 7 | ---------- 8 | 9 | .. autoclass:: Component 10 | .. autoclass:: CLIApplicationComponent 11 | .. autofunction:: start_component 12 | .. autoexception:: ComponentStartError 13 | 14 | Concurrency 15 | ----------- 16 | 17 | .. autofunction:: start_background_task_factory 18 | .. autofunction:: start_service_task 19 | .. autoclass:: TaskFactory 20 | .. autoclass:: TaskHandle 21 | 22 | Contexts and resources 23 | ---------------------- 24 | 25 | .. autoclass:: Context 26 | .. autoclass:: ResourceEvent 27 | .. autofunction:: current_context 28 | .. autofunction:: context_teardown 29 | .. autofunction:: add_resource 30 | .. autofunction:: add_resource_factory 31 | .. autofunction:: add_teardown_callback 32 | .. autofunction:: get_resource 33 | .. autofunction:: get_resource_nowait 34 | .. autofunction:: inject 35 | .. autofunction:: resource 36 | .. autoexception:: AsyncResourceError 37 | .. autoexception:: NoCurrentContext 38 | .. autoexception:: ResourceConflict 39 | .. autoexception:: ResourceNotFound 40 | 41 | Events 42 | ------ 43 | 44 | .. autoclass:: Event 45 | .. autoclass:: Signal 46 | .. autoclass:: SignalQueueFull 47 | .. autofunction:: stream_events 48 | .. autofunction:: wait_event 49 | .. autoexception:: UnboundSignal 50 | 51 | Application runner 52 | ------------------ 53 | 54 | .. autofunction:: run_application 55 | 56 | Utilities 57 | --------- 58 | 59 | .. autoclass:: PluginContainer 60 | .. autofunction:: callable_name 61 | .. autofunction:: merge_config 62 | .. autofunction:: qualified_name 63 | .. autofunction:: resolve_reference 64 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import importlib.metadata 3 | 4 | from packaging.version import parse 5 | 6 | extensions = [ 7 | "sphinx.ext.autodoc", 8 | "sphinx.ext.intersphinx", 9 | "sphinx_autodoc_typehints", 10 | "sphinx_rtd_theme", 11 | ] 12 | 13 | templates_path = ["_templates"] 14 | source_suffix = ".rst" 15 | master_doc = "index" 16 | project = "The Asphalt Framework (core)" 17 | author = "Alex Grönholm" 18 | copyright = "2015, " + author 19 | 20 | v = parse(importlib.metadata.version("asphalt")) 21 | version = v.base_version 22 | release = v.public 23 | 24 | language = "en" 25 | 26 | exclude_patterns = ["_build"] 27 | pygments_style = "sphinx" 28 | autodoc_default_options = {"members": True, "show-inheritance": True} 29 | highlight_language = "python3" 30 | todo_include_todos = False 31 | 32 | html_theme = "sphinx_rtd_theme" 33 | htmlhelp_basename = "asphaltdoc" 34 | 35 | intersphinx_mapping = { 36 | "anyio": ("https://anyio.readthedocs.io/en/stable/", None), 37 | "asphalt-mailer": ("https://asphalt-mailer.readthedocs.io/en/stable/", None), 38 | "python": ("https://docs.python.org/3/", None), 39 | } 40 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | This is the core Asphalt library. If you're looking for documentation for some specific component 2 | project, you will find the appropriate link from the project's `Github page`_. 3 | 4 | If you're a new user, it's a good idea to start from the tutorials. Pick a tutorial that suits 5 | your current level of knowledge. 6 | 7 | .. _Github page: https://github.com/asphalt-framework 8 | 9 | Table of contents 10 | ----------------- 11 | 12 | .. toctree:: 13 | :maxdepth: 2 14 | 15 | tutorials/index 16 | userguide/index 17 | api 18 | versionhistory 19 | acknowledgements 20 | -------------------------------------------------------------------------------- /docs/tutorials/echo.rst: -------------------------------------------------------------------------------- 1 | Tutorial 1: Getting your feet wet – a simple echo server and client 2 | =================================================================== 3 | 4 | .. py:currentmodule:: asphalt.core 5 | 6 | This tutorial will get you started with Asphalt development from the ground up. 7 | You will learn how to build a simple network server that echoes back messages sent to 8 | it, along with a matching client application. It will however not yet touch more 9 | advanced concepts like using the ``asphalt`` command to run an application with a 10 | configuration file. 11 | 12 | Prerequisites 13 | ------------- 14 | 15 | Asphalt requires Python 3.8 or later. You will also need to have the ``venv`` module 16 | installed for your Python version of choice. It should come with most Python 17 | distributions, but if it does not, you can usually install it with your operating 18 | system's package manager (``python3-venv`` is a good guess). 19 | 20 | Setting up the virtual environment 21 | ---------------------------------- 22 | 23 | .. highlight:: bash 24 | 25 | Now that you have your base tools installed, it's time to create a *virtual environment* 26 | (referred to as simply ``virtualenv`` later). Installing Python libraries in a virtual 27 | environment isolates them from other projects, which may require different versions of 28 | the same libraries. 29 | 30 | Now, create a project directory and a virtualenv:: 31 | 32 | mkdir tutorial1 33 | cd tutorial1 34 | python -m venv tutorialenv 35 | source tutorialenv/bin/activate 36 | 37 | On Windows, the last line should be: 38 | 39 | .. code-block:: doscon 40 | 41 | tutorialenv\Scripts\activate 42 | 43 | The last command *activates* the virtualenv, meaning the shell will first look for 44 | commands in its ``bin`` directory (``Scripts`` on Windows) before searching elsewhere. 45 | Also, Python will now only import third party libraries from the virtualenv and not 46 | anywhere else. To exit the virtualenv, you can use the ``deactivate`` command (but 47 | don't do that now!). 48 | 49 | You can now proceed with installing Asphalt itself:: 50 | 51 | pip install asphalt 52 | 53 | Creating the project structure 54 | ------------------------------ 55 | 56 | Every project should have a top level package, so create one now:: 57 | 58 | mkdir echo 59 | touch echo/__init__.py 60 | 61 | On Windows, the last line should be: 62 | 63 | .. code-block:: doscon 64 | 65 | copy NUL echo\__init__.py 66 | 67 | Creating the first component 68 | ---------------------------- 69 | 70 | Now, let's write some code! Create a file named ``server.py`` in the ``echo`` package 71 | directory: 72 | 73 | .. literalinclude:: snippets/echo1.py 74 | :language: python 75 | :start-after: isort: off 76 | 77 | The ``ServerComponent`` class is the *root component* (and in this case, the only 78 | component) of this application. Its ``start()`` method is called by 79 | :func:`run_application` when it has set up the event loop. Finally, the 80 | ``if __name__ == '__main__':`` block is not strictly necessary but is good, common 81 | practice that prevents :func:`run_application()` from being called again if this module 82 | is ever imported from another module. 83 | 84 | You can now try running the above application. With the project directory 85 | (``tutorial``) as your current directory, do: 86 | 87 | .. code-block:: bash 88 | 89 | python -m echo.server 90 | 91 | This should print "Hello, world!" on the console. The event loop continues to run until 92 | you press Ctrl+C (Ctrl+Break on Windows). 93 | 94 | Making the server listen for connections 95 | ---------------------------------------- 96 | 97 | The next step is to make the server actually accept incoming connections. 98 | For this purpose, we will use AnyIO's :func:`~anyio.create_tcp_listener` function: 99 | 100 | .. literalinclude:: ../../examples/tutorial1/echo/server.py 101 | :language: python 102 | :start-after: isort: off 103 | 104 | Here, :func:`anyio.create_tcp_listener` is used to listen to incoming TCP connections on 105 | the ``localhost`` interface on port 64100. The port number is totally arbitrary and can 106 | be changed to any other legal value you want to use. 107 | 108 | Whenever a new connection is established, the listener spawns a new task to run 109 | ``handle()``. Tasks work much like `green threads`_ in that they're adjourned when 110 | waiting for something to happen and then resumed when the result is available. The main 111 | difference is that a coroutine running in a task needs to use the ``await`` statement 112 | (or ``async for`` or ``async with``) to yield control back to the event loop. In 113 | ``handle()``, the ``await`` on the first line will cause the task to be adjourned until 114 | a packet has been received from the socket stream. 115 | 116 | The ``handle()`` function receives a :class:`~anyio.abc.SocketStream` as the sole 117 | argument. This object encapsulates the server side of the newly established TCP 118 | connection. In ``handle()``, we read a single TCP packet from the client, write it back 119 | to the client and then close the connection. To get at least some output from the 120 | application, the function was made to print the received message on the console 121 | (decoding it from ``bytes`` to ``str`` and stripping the trailing newline character 122 | first). In production applications, you will want to use the :mod:`logging` module for 123 | this instead. 124 | 125 | If you have the ``netcat`` utility or similar, you can already test the server like 126 | this:: 127 | 128 | echo Hello | nc localhost 64100 129 | 130 | This command, if available, should print "Hello" on the console, as echoed by the 131 | server. 132 | 133 | .. _green threads: https://en.wikipedia.org/wiki/Green_threads 134 | 135 | Creating the client 136 | ------------------- 137 | 138 | No server is very useful without a client to access it, so we'll need to add a client 139 | module in this project. And to make things a bit more interesting, we'll make the client 140 | accept a message to be sent as a command line argument. 141 | 142 | Create the file ``client.py`` file in the ``echo`` package directory as follows: 143 | 144 | .. literalinclude:: ../../examples/tutorial1/echo/client.py 145 | :language: python 146 | :start-after: isort: off 147 | 148 | You may have noticed that ``ClientComponent`` inherits from 149 | :class:`CLIApplicationComponent` instead of :class:`Component` and that instead of 150 | overriding the :meth:`Component.start` method, :meth:`CLIApplicationComponent.run` is 151 | overridden. This is standard practice for Asphalt applications that just do one specific 152 | thing and then exit. 153 | 154 | The script instantiates ``ClientComponent`` using the first command line argument as the 155 | ``message`` argument to the component's constructor. Doing this instead of directly 156 | accessing ``sys.argv`` from the ``run()`` method makes this component easier to test and 157 | allows you to specify the message in a configuration file (covered in the next 158 | tutorial). 159 | 160 | When the client component runs, it grabs the message to be sent from the list of command 161 | line arguments (``sys.argv``), converts it from a unicode string to a bytestring and 162 | adds a newline character (so the server can use ``readline()``). Then, it connects to 163 | ``localhost`` on port 64100 and sends the bytestring to the other end. Next, it reads a 164 | response line from the server, closes the connection and prints the (decoded) response. 165 | When the ``run()`` method returns, the application exits. 166 | 167 | To send the "Hello" message to the server, run this in the project directory: 168 | 169 | .. code-block:: bash 170 | 171 | python -m echo.client Hello 172 | 173 | Conclusion 174 | ---------- 175 | 176 | This covers the basics of setting up a minimal Asphalt application. You've now learned 177 | to: 178 | 179 | * Create a virtual environment to isolate your application's dependencies from other 180 | applications 181 | * Create a package structure for your application 182 | * Start your application using :func:`~asphalt.core.run_application` 183 | * Use `AnyIO socket streams`_ to implement a basic client-server protocol 184 | 185 | This tutorial only scratches the surface of what's possible with Asphalt, however. The 186 | :doc:`second tutorial ` will build on the knowledge you gained here and 187 | teach you how to work with components, resources and configuration files to build more 188 | useful applications. 189 | 190 | .. _AnyIO socket streams: https://anyio.readthedocs.io/en/stable/networking.html#\ 191 | working-with-tcp-sockets 192 | -------------------------------------------------------------------------------- /docs/tutorials/index.rst: -------------------------------------------------------------------------------- 1 | Tutorials 2 | ========= 3 | 4 | The following tutorials will help you get acquainted with Asphalt application development. 5 | It is expected that the reader have at least basic understanding of the Python language. 6 | 7 | Code for all tutorials can be found in the ``examples`` directory in the source distribution or in 8 | the 9 | `Github repository `_. 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | echo 15 | webnotifier 16 | -------------------------------------------------------------------------------- /docs/tutorials/snippets/echo1.py: -------------------------------------------------------------------------------- 1 | # isort: off 2 | from __future__ import annotations 3 | 4 | from asphalt.core import Component, run_application 5 | 6 | 7 | class ServerComponent(Component): 8 | async def start(self) -> None: 9 | print("Hello, world!") 10 | 11 | 12 | if __name__ == "__main__": 13 | run_application(ServerComponent) 14 | -------------------------------------------------------------------------------- /docs/tutorials/snippets/webnotifier-app1.py: -------------------------------------------------------------------------------- 1 | # isort: off 2 | import logging 3 | 4 | import anyio 5 | import httpx 6 | from asphalt.core import CLIApplicationComponent, run_application 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class ApplicationComponent(CLIApplicationComponent): 12 | async def run(self) -> None: 13 | async with httpx.AsyncClient() as http: 14 | while True: 15 | await http.get("https://imgur.com") 16 | await anyio.sleep(10) 17 | 18 | 19 | if __name__ == "__main__": 20 | run_application(ApplicationComponent, logging=logging.DEBUG) 21 | -------------------------------------------------------------------------------- /docs/tutorials/snippets/webnotifier-app2.py: -------------------------------------------------------------------------------- 1 | # isort: off 2 | from __future__ import annotations 3 | 4 | import logging 5 | from typing import Any 6 | 7 | import anyio 8 | import httpx 9 | from asphalt.core import CLIApplicationComponent, run_application 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class ApplicationComponent(CLIApplicationComponent): 15 | async def run(self) -> None: 16 | last_modified = None 17 | async with httpx.AsyncClient() as http: 18 | while True: 19 | headers: dict[str, Any] = ( 20 | {"if-modified-since": last_modified} if last_modified else {} 21 | ) 22 | response = await http.get("https://imgur.com", headers=headers) 23 | logger.debug("Response status: %d", response.status_code) 24 | if response.status_code == 200: 25 | last_modified = response.headers["date"] 26 | logger.info("Contents changed") 27 | 28 | await anyio.sleep(10) 29 | 30 | 31 | if __name__ == "__main__": 32 | run_application(ApplicationComponent, logging=logging.DEBUG) 33 | -------------------------------------------------------------------------------- /docs/tutorials/snippets/webnotifier-app3.py: -------------------------------------------------------------------------------- 1 | # isort: off 2 | from __future__ import annotations 3 | 4 | import logging 5 | from difflib import unified_diff 6 | from typing import Any 7 | 8 | import anyio 9 | import httpx 10 | from asphalt.core import CLIApplicationComponent, run_application 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class ApplicationComponent(CLIApplicationComponent): 16 | async def run(self) -> None: 17 | async with httpx.AsyncClient() as http: 18 | last_modified, old_lines = None, None 19 | while True: 20 | logger.debug("Fetching webpage") 21 | headers: dict[str, Any] = ( 22 | {"if-modified-since": last_modified} if last_modified else {} 23 | ) 24 | response = await http.get("https://imgur.com", headers=headers) 25 | logger.debug("Response status: %d", response.status_code) 26 | if response.status_code == 200: 27 | last_modified = response.headers["date"] 28 | new_lines = response.text.split("\n") 29 | if old_lines is not None and old_lines != new_lines: 30 | difference = unified_diff(old_lines, new_lines) 31 | logger.info("Contents changed:\n%s", difference) 32 | 33 | old_lines = new_lines 34 | 35 | await anyio.sleep(10) 36 | 37 | 38 | if __name__ == "__main__": 39 | run_application(ApplicationComponent, logging=logging.DEBUG) 40 | -------------------------------------------------------------------------------- /docs/tutorials/snippets/webnotifier-app4.py: -------------------------------------------------------------------------------- 1 | # isort: off 2 | from __future__ import annotations 3 | 4 | import logging 5 | from difflib import HtmlDiff 6 | from typing import Any 7 | 8 | import anyio 9 | import httpx 10 | from asphalt.core import CLIApplicationComponent, run_application 11 | from asphalt.core import inject, resource 12 | from asphalt.mailer import Mailer 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class ApplicationComponent(CLIApplicationComponent): 18 | def __init__(self) -> None: 19 | self.add_component( 20 | "mailer", 21 | backend="smtp", 22 | host="your.smtp.server.here", 23 | message_defaults={"sender": "your@email.here", "to": "your@email.here"}, 24 | ) 25 | 26 | @inject 27 | async def run(self, *, mailer: Mailer = resource()) -> None: 28 | async with httpx.AsyncClient() as http: 29 | last_modified, old_lines = None, None 30 | diff = HtmlDiff() 31 | while True: 32 | logger.debug("Fetching webpage") 33 | headers: dict[str, Any] = ( 34 | {"if-modified-since": last_modified} if last_modified else {} 35 | ) 36 | response = await http.get("https://imgur.com", headers=headers) 37 | logger.debug("Response status: %d", response.status_code) 38 | if response.status_code == 200: 39 | last_modified = response.headers["date"] 40 | new_lines = response.text.split("\n") 41 | if old_lines is not None and old_lines != new_lines: 42 | difference = diff.make_file(old_lines, new_lines, context=True) 43 | await mailer.create_and_deliver( 44 | subject="Change detected in web page", html_body=difference 45 | ) 46 | logger.info("Sent notification email") 47 | 48 | old_lines = new_lines 49 | 50 | await anyio.sleep(10) 51 | 52 | 53 | if __name__ == "__main__": 54 | run_application(ApplicationComponent, logging=logging.DEBUG) 55 | -------------------------------------------------------------------------------- /docs/tutorials/snippets/webnotifier-detector1.py: -------------------------------------------------------------------------------- 1 | # isort: off 2 | from __future__ import annotations 3 | 4 | from dataclasses import dataclass 5 | 6 | from asphalt.core import Event 7 | 8 | 9 | @dataclass 10 | class WebPageChangeEvent(Event): 11 | old_lines: list[str] 12 | new_lines: list[str] 13 | -------------------------------------------------------------------------------- /docs/tutorials/snippets/webnotifier-detector2.py: -------------------------------------------------------------------------------- 1 | # isort: off 2 | from __future__ import annotations 3 | 4 | import logging 5 | from dataclasses import dataclass 6 | from typing import Any 7 | 8 | import anyio 9 | import httpx 10 | 11 | from asphalt.core import Event, Signal 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | @dataclass 17 | class WebPageChangeEvent(Event): 18 | old_lines: list[str] 19 | new_lines: list[str] 20 | 21 | 22 | class Detector: 23 | changed = Signal(WebPageChangeEvent) 24 | 25 | def __init__(self, url: str, delay: float): 26 | self.url = url 27 | self.delay = delay 28 | 29 | async def run(self) -> None: 30 | async with httpx.AsyncClient() as http: 31 | last_modified, old_lines = None, None 32 | while True: 33 | logger.debug("Fetching contents of %s", self.url) 34 | headers: dict[str, Any] = ( 35 | {"if-modified-since": last_modified} if last_modified else {} 36 | ) 37 | response = await http.get("https://imgur.com", headers=headers) 38 | logger.debug("Response status: %d", response.status_code) 39 | if response.status_code == 200: 40 | last_modified = response.headers["date"] 41 | new_lines = response.text.split("\n") 42 | if old_lines is not None and old_lines != new_lines: 43 | self.changed.dispatch(WebPageChangeEvent(old_lines, new_lines)) 44 | 45 | old_lines = new_lines 46 | 47 | await anyio.sleep(self.delay) 48 | -------------------------------------------------------------------------------- /docs/tutorials/webnotifier.rst: -------------------------------------------------------------------------------- 1 | Tutorial 2: Something a little more practical – a web page change detector 2 | ========================================================================== 3 | 4 | .. py:currentmodule:: asphalt.core 5 | 6 | Now that you've gone through the basics of creating an Asphalt application, it's time to 7 | expand your horizons a little. In this tutorial you will learn to use a container 8 | component to create a multi-component application and how to set up a configuration file 9 | for that. 10 | 11 | The application you will build this time will periodically load a web page and see if it 12 | has changed since the last check. When changes are detected, it will then present the 13 | user with the computed differences between the old and the new versions. 14 | 15 | Setting up the project structure 16 | -------------------------------- 17 | 18 | As in the previous tutorial, you will need a project directory and a virtual 19 | environment. Create a directory named ``tutorial2`` and make a new virtual environment 20 | inside it. Then activate it and use ``pip`` to install the ``asphalt-mailer`` and 21 | ``httpx`` libraries: 22 | 23 | .. code-block:: bash 24 | 25 | pip install asphalt-mailer httpx 26 | 27 | This will also pull in the core Asphalt library as a dependency. 28 | 29 | Next, create a package directory named ``webnotifier`` and a module named ``app`` 30 | (``app.py``). The code in the following sections should be put in the ``app`` module 31 | (unless explicitly stated otherwise). 32 | 33 | Detecting changes in a web page 34 | ------------------------------- 35 | 36 | The first task is to set up a loop that periodically retrieves the web page. To that 37 | end, you need to set up an asynchronous HTTP client using the httpx_ library: 38 | 39 | .. literalinclude:: snippets/webnotifier-app1.py 40 | :language: python 41 | :start-after: isort: off 42 | 43 | Great, so now the code fetches the contents of ``https://imgur.com`` at 10 second 44 | intervals. But this isn't very useful yet – you need something that compares the old and 45 | new versions of the contents somehow. Furthermore, constantly loading the contents of a 46 | page exerts unnecessary strain on the hosting provider. We want our application to be as 47 | polite and efficient as reasonably possible. 48 | 49 | To that end, you can use the ``if-modified-since`` header in the request. If the 50 | requests after the initial one specify the last modified date value in the request 51 | headers, the remote server will respond with a ``304 Not Modified`` if the contents have 52 | not changed since that moment. 53 | 54 | So, modify the code as follows: 55 | 56 | .. literalinclude:: snippets/webnotifier-app2.py 57 | :language: python 58 | :start-after: isort: off 59 | 60 | The code here stores the ``date`` header from the first response and uses it in the 61 | ``if-modified-since`` header of the next request. A ``200`` response indicates that the 62 | web page has changed so the last modified date is updated and the contents are retrieved 63 | from the response. Some logging calls were also sprinkled in the code to give you an 64 | idea of what's happening. 65 | 66 | .. _httpx: https://www.python-httpx.org/async/ 67 | 68 | Computing the changes between old and new versions 69 | -------------------------------------------------- 70 | 71 | Now you have code that actually detects when the page has been modified between the 72 | requests. But it doesn't yet show *what* in its contents has changed. The next step will 73 | then be to use the standard library :mod:`difflib` module to calculate the difference 74 | between the contents and send it to the logger: 75 | 76 | .. literalinclude:: snippets/webnotifier-app3.py 77 | :language: python 78 | :start-after: isort: off 79 | 80 | This modified code now stores the old and new contents in different variables to enable 81 | them to be compared. The ``.split("\n")`` is needed because 82 | :func:`~difflib.unified_diff` requires the input to be iterables of strings. Likewise, 83 | the ``"\n".join(...)`` is necessary because the output is also an iterable of strings. 84 | 85 | Sending changes via email 86 | ------------------------- 87 | 88 | While an application that logs the changes on the console could be useful on its own, 89 | it'd be much better if it actually notified the user by means of some communication 90 | medium, wouldn't it? For this specific purpose you need the ``asphalt-mailer`` library 91 | you installed in the beginning. The next modification will send the HTML formatted 92 | differences to you by email. 93 | 94 | But, you only have a single component in your app now. To use ``asphalt-mailer``, you 95 | will need to add its component to your application somehow. This is exactly what 96 | :meth:`Component.add_component` is for. With that, you can create a hierarchy of 97 | components where the ``mailer`` component is a child component of your own container 98 | component. 99 | 100 | To use the mailer resource provided by ``asphalt-mailer``, inject it to the ``run()`` 101 | function as a resource by adding a keyword-only argument, annotated with the type of 102 | the resource you want to inject (:class:`~asphalt.mailer.Mailer`). 103 | 104 | And to make the the results look nicer in an email message, you can switch to using 105 | :class:`difflib.HtmlDiff` to produce the delta output: 106 | 107 | .. literalinclude:: snippets/webnotifier-app4.py 108 | :language: python 109 | :start-after: isort: off 110 | 111 | You'll need to replace the ``host``, ``sender`` and ``to`` arguments for the mailer 112 | component and possibly add the ``username`` and ``password`` arguments if your SMTP 113 | server requires authentication. 114 | 115 | With these changes, you'll get a new HTML formatted email each time the code detects 116 | changes in the target web page. 117 | 118 | Separating the change detection logic 119 | ------------------------------------- 120 | 121 | While the application now works as intended, you're left with two small problems. First 122 | off, the target URL and checking frequency are hard coded. That is, they can only be 123 | changed by modifying the program code. It is not reasonable to expect non-technical 124 | users to modify the code when they want to simply change the target website or the 125 | frequency of checks. Second, the change detection logic is hardwired to the notification 126 | code. A well designed application should maintain proper `separation of concerns`_. One 127 | way to do this is to separate the change detection logic to its own class. 128 | 129 | Create a new module named ``detector`` in the ``webnotifier`` package. Then, add the 130 | change event class to it: 131 | 132 | .. literalinclude:: snippets/webnotifier-detector1.py 133 | :language: python 134 | :start-after: isort: off 135 | 136 | This class defines the type of event that the notifier will emit when the target web 137 | page changes. The old and new content are stored in the event instance to allow the 138 | event listener to generate the output any way it wants. 139 | 140 | Next, add another class in the same module that will do the HTTP requests and change 141 | detection: 142 | 143 | .. literalinclude:: snippets/webnotifier-detector2.py 144 | :language: python 145 | :start-after: isort: off 146 | 147 | The initializer arguments allow you to freely specify the parameters for the detection 148 | process. The class includes a signal named ``changed`` that uses the previously created 149 | ``WebPageChangeEvent`` class. The code dispatches such an event when a change in the 150 | target web page is detected. 151 | 152 | Finally, add the component class which will allow you to integrate this functionality 153 | into any Asphalt application: 154 | 155 | .. literalinclude:: ../../examples/tutorial2/webnotifier/detector.py 156 | :language: python 157 | :start-after: isort: off 158 | 159 | The component's ``start()`` method starts the detector's ``run()`` method as a new task, 160 | adds the detector object as resource and installs an event listener that will shut down 161 | the detector when the context is torn down. 162 | 163 | Now that you've moved the change detection code to its own module, 164 | ``ApplicationComponent`` will become somewhat lighter: 165 | 166 | .. literalinclude:: ../../examples/tutorial2/webnotifier/app.py 167 | :language: python 168 | :start-after: isort: off 169 | 170 | The main application component will now use the detector resource added by 171 | ``ChangeDetectorComponent``. It adds one event listener which reacts to change events by 172 | creating an HTML formatted difference and sending it to the default recipient. 173 | 174 | Once the ``start()`` method here has run to completion, the event loop finally has a 175 | chance to run the task created for ``Detector.run()``. This will allow the detector to 176 | do its work and dispatch those ``changed`` events that the ``page_changed()`` listener 177 | callback expects. 178 | 179 | .. _separation of concerns: https://en.wikipedia.org/wiki/Separation_of_concerns 180 | 181 | Setting up the configuration file 182 | --------------------------------- 183 | 184 | Now that your application code is in good shape, you will need to give the user an easy 185 | way to configure it. This is where YAML_ configuration files come in handy. They're 186 | clearly structured and are far less intimidating to end users than program code. And you 187 | can also have more than one of them, in case you want to run the program with a 188 | different configuration. 189 | 190 | In your project directory (``tutorial2``), create a file named ``config.yaml`` with the 191 | following contents: 192 | 193 | .. literalinclude:: ../../examples/tutorial2/config.yaml 194 | :language: yaml 195 | 196 | The ``component`` section defines parameters for the root component. Aside from the 197 | special ``type`` key which tells the runner where to find the component class, all the 198 | keys in this section are passed to the constructor of ``ApplicationComponent`` as 199 | keyword arguments. Keys under ``components`` will match the alias of each child 200 | component, which is given as the first argument to :meth:`Component.add_component`. Any 201 | component parameters given here can now be removed from the ``add_component()`` call in 202 | ``ApplicationComponent``'s code. 203 | 204 | The logging configuration here sets up two loggers, one for ``webnotifier`` and its 205 | descendants and another (``root``) as a catch-all for everything else. It specifies one 206 | handler that just writes all log entries to the standard output. To learn more about 207 | what you can do with the logging configuration, consult the 208 | :ref:`python:logging-config-dictschema` section in the standard library documentation. 209 | 210 | You can now run your app with the ``asphalt run`` command, provided that the project 211 | directory is on Python's search path. When your application is `properly packaged`_ and 212 | installed in ``site-packages``, this won't be a problem. But for the purposes of this 213 | tutorial, you can temporarily add it to the search path by setting the ``PYTHONPATH`` 214 | environment variable: 215 | 216 | .. code-block:: bash 217 | 218 | PYTHONPATH=. asphalt run config.yaml 219 | 220 | On Windows: 221 | 222 | .. code-block:: doscon 223 | 224 | set PYTHONPATH=%CD% 225 | asphalt run config.yaml 226 | 227 | .. note:: 228 | The ``if __name__ == '__main__':`` block is no longer needed since ``asphalt run`` 229 | is now used as the entry point for the application. 230 | 231 | .. _YAML: https://yaml.org/ 232 | .. _properly packaged: https://packaging.python.org/ 233 | 234 | Conclusion 235 | ---------- 236 | 237 | You now know how to take advantage of Asphalt's component system to add structure to 238 | your application. You've learned how to build reusable components and how to make the 239 | components work together through the use of resources. Last, but not least, you've 240 | learned to set up a YAML configuration file for your application and to set up a fine 241 | grained logging configuration in it. 242 | 243 | You now possess enough knowledge to leverage Asphalt to create practical applications. 244 | You are now encouraged to find out what `Asphalt component projects`_ exist to aid your 245 | application development. Happy coding ☺ 246 | 247 | .. _Asphalt component projects: https://github.com/asphalt-framework 248 | -------------------------------------------------------------------------------- /docs/userguide/architecture.rst: -------------------------------------------------------------------------------- 1 | Application architecture 2 | ======================== 3 | 4 | .. py:currentmodule:: asphalt.core 5 | 6 | Asphalt applications are centered around the following building blocks: 7 | 8 | * components 9 | * contexts 10 | * resources 11 | * signals/events 12 | * the application runner 13 | 14 | *Components* (:class:`Component`) are classes that initialize one or more 15 | services, like network servers or database connections and add them to the *context* as 16 | *resources*. Components are started by the application runner and usually discarded 17 | afterwards. 18 | 19 | *Contexts* (:class:`Context`) are "hubs" through which *resources* are shared 20 | between components. Contexts can be chained by setting a parent context for a new 21 | context. A context has access to all its parents' resources but parent contexts cannot 22 | access the resources of their children. 23 | 24 | *Resources* are any arbitrary objects shared through a context. Every resource is shared 25 | on a context using its type (class) and name (chosen by the component). Every 26 | combination of type/name is unique in a context. 27 | 28 | *Resource factories* are callables used to provide a resource of certain type to a 29 | context *on demand*. These are used, for example, to provide a database session or 30 | transaction, where sharing a single resource across multiple tasks would be undesirable, 31 | and you want to bind the life cycle of the resource to the context where the resource is 32 | created. 33 | 34 | *Signals* are the standard way in Asphalt applications to send events to interested 35 | parties. Events are dispatched asynchronously without blocking the sender. The signal 36 | system was loosely modeled after the signal system in the Qt_ toolkit. 37 | 38 | The *application runner* (:func:`~run_application`) is a function that is used 39 | to start an Asphalt application. It configures the Python logging module, creates the 40 | root context, sets up any signal handlers, starts the root component and then runs the 41 | event loop until the application exits. A command line tool (``asphalt``) is provided to 42 | better facilitate the running of Asphalt applications. This tool reads the application 43 | configuration from one or more YAML_ formatted configuration files and calls 44 | :func:`run_application` with the resulting configuration dictionary as keyword 45 | arguments. The settings from the configuration file are merged with hard coded defaults 46 | so the config file only needs to override settings where necessary. 47 | 48 | The following chapters describe in detail how each of these building blocks work. 49 | 50 | .. _Qt: https://www.qt.io/ 51 | .. _YAML: https://yaml.org/ 52 | -------------------------------------------------------------------------------- /docs/userguide/components.rst: -------------------------------------------------------------------------------- 1 | Working with components 2 | ======================= 3 | 4 | .. py:currentmodule:: asphalt.core 5 | 6 | Components are the basic building blocks of an Asphalt application. They have a narrowly 7 | defined set of responsibilities: 8 | 9 | #. Take in configuration through the initializer 10 | #. Validate the configuration 11 | #. Add resources to the context (in either :meth:`Component.prepare`, 12 | :meth:`Component.start` or both) 13 | #. Close/shut down/clean up resources when the context is torn down (by directly adding 14 | a callback on the context with :meth:`Context.add_teardown_callback`, or by using 15 | :func:`context_teardown`) 16 | 17 | Any Asphalt component can have *child components* added to it. Child components can 18 | either provide resources required by the parent component, or extend the parent 19 | component's functionality in some way. 20 | 21 | For example, a web application component typically has child components provide 22 | functionality like database access, job queues, and/or integrations with third party 23 | services. Likewise, child components might also extend the web application by adding 24 | new routes to it. 25 | 26 | Component startup 27 | ----------------- 28 | 29 | To start a component, be it a solitary component or the root component of a hierarchy, 30 | call :func:`start_component` from within an active :class:`Context` and pass it the 31 | component class as the first positional argument, and its configuration options as the 32 | second argument. 33 | 34 | .. warning:: **NEVER** start components by directly calling :meth:`Component.start`! 35 | While this may work for simple components, more complex components may fail to start 36 | as their child components are not started, nor is the :meth:`Component.prepare` 37 | method never called this way. 38 | 39 | The sequence of events when a component is started by :func:`start_component`, goes as 40 | follows: 41 | 42 | #. The entire hierarchy of components is instantiated using the combination of 43 | hard-coded defaults (as passed to :meth:`Component.add_component`) and any 44 | configuration overrides 45 | #. The component's :meth:`~Component.prepare` method is called 46 | #. All child components of this component are started concurrently (starting from the 47 | :meth:`~Component.prepare` step) 48 | #. The component's :meth:`~Component.start` method is called 49 | 50 | For example, let's say you have the following components: 51 | 52 | .. literalinclude:: snippets/components1.py 53 | 54 | You should see the following lines in the output: 55 | 56 | .. code-block:: text 57 | 58 | ParentComponent.prepare() 59 | ChildComponent.prepare() [child1] 60 | ChildComponent.start() [child1] 61 | ChildComponent.prepare() [child2] 62 | ChildComponent.start() [child2] 63 | ParentComponent.start() 64 | Hello, world from child1! 65 | Hello, world from child2! 66 | 67 | As you can see from the output, the parent component's :meth:`~Component.prepare` method 68 | is called first. Then, the child components are started, and their 69 | :meth:`~Component.prepare` methods are called first, then :meth:`~Component.start`. 70 | When all the child components have been started, only then is the parent component 71 | started. 72 | 73 | The parent component can only use resources from the child components in its 74 | :meth:`~Component.start` method, as only then have the child components that provide 75 | those resources been started. Conversely, the child components cannot depend on 76 | resources added by the parent in its :meth:`~Component.start` method, as this method is 77 | only run after the child components have already been started. 78 | 79 | As ``child1`` and ``child2`` are started concurrently, they are able to use 80 | :func:`get_resource` to request resources from each other. Just make sure they don't get 81 | deadlocked by depending on resources provided by each other at the same time, in which 82 | case both would be stuck waiting forever. 83 | 84 | As a recap, here is what components can do with resources relative to their parent, 85 | sibling and child components: 86 | 87 | * Initializer (``__init__()``): 88 | 89 | * ✅ Can add child components 90 | * ❌ Cannot acquire resources 91 | * ❌ Cannot provide resources 92 | 93 | * :meth:`Component.prepare`: 94 | 95 | * ❌ Cannot add child components 96 | * ✅ Can acquire resources provided by parent components in their 97 | :meth:`~Component.prepare` methods 98 | * ❌ Cannot acquire resources provided by parent components in their 99 | :meth:`~Component.start` methods 100 | * ✅ Can acquire resources provided by sibling components (but you must use 101 | :func:`get_resource` to avoid race conditions) 102 | * ❌ Cannot acquire resources provided by child components 103 | * ✅ Can provide resources to child components 104 | 105 | * :meth:`Component.start`: 106 | 107 | * ❌ Cannot add child components 108 | * ✅ Can acquire resources provided by parent components in their 109 | :meth:`~Component.prepare` methods 110 | * ❌ Cannot acquire resources provided by parent components in their 111 | :meth:`~Component.start` methods 112 | * ✅ Can acquire resources provided by sibling components (but you must use 113 | :func:`get_resource` to avoid race conditions) 114 | * ✅ Can acquire resources provided by child components 115 | * ❌ Cannot provide resources to child components 116 | -------------------------------------------------------------------------------- /docs/userguide/concurrency.rst: -------------------------------------------------------------------------------- 1 | Working with tasks and threads 2 | ============================== 3 | 4 | .. py:currentmodule:: asphalt.core 5 | 6 | Asphalt, as of version 5, leverages AnyIO_ to provide both `structured concurrency`_ and 7 | support for multiple asynchronous event loop implementations (asyncio and Trio, as of 8 | this writing). It is therefore highly recommended that you use AnyIO, rather than 9 | asyncio, for your task management, synchronization, concurrency and file I/O needs 10 | whenever possible. This ensures compatibility with AnyIO's "level cancellation" model, 11 | and potentially enables you to switch to a different event loop implementation, should 12 | the need arise. 13 | 14 | Working with asynchronous tasks 15 | ------------------------------- 16 | 17 | The main idea behind `structured concurrency`_ is that each task must have a parent task 18 | watching over it, and if a task raises an exception that is not handled, then the 19 | exception propagates to the parent task, taking all the other tasks down with it until 20 | the exception is finally handled somewhere, or the entire process crashes. This ensures 21 | that tasks never crash quietly, as is often the case with asyncio applications using 22 | :func:`~asyncio.create_task` to spawn "fire-and-forget" tasks. 23 | 24 | To enable Asphalt users to work with tasks while respecting structured concurrency, two 25 | ways of launching tasks are provided: **service tasks** and 26 | **background task factories**. 27 | 28 | Service tasks 29 | +++++++++++++ 30 | 31 | Service tasks are started to last throughout the lifespan of the context. 32 | They're typically used to run network services like web apps. 33 | 34 | When the context is torn down, service tasks will also be shut down. By default, they 35 | will be cancelled, but you can control this behavior by passing a different value as 36 | ``teardown_action``: 37 | 38 | * ``cancel`` (the default): cancel the task and wait for it to finish 39 | * a callable: run the given callable at teardown to trigger the task to shut itself down 40 | (and wait for the task to finish on its own) 41 | * ``None``: do nothing and just wait for the task to finish on its own 42 | 43 | If a service task crashes, it will take down the whole application with it. This is part 44 | of the `structured concurrency`_ design that is intended to ensure that failures don't 45 | go unnoticed. You're responsible for catching and handling any exceptions raised in 46 | service tasks. 47 | 48 | Background task factories 49 | +++++++++++++++++++++++++ 50 | 51 | Background task factories are meant to be used for dynamically spawning background tasks 52 | on demand. A typical example would be a web app request handler that needs to send an 53 | email, but wants to return a response to the end user straight away. The background 54 | task will thus outlive the task that was spawned to handle the request. 55 | 56 | In contrast to service tasks, any running background task will block the teardown of 57 | the task factory from which they were spawned. 58 | 59 | By default, an unhandled exception raised in a task spawned from a background task 60 | factory will propagate and then crash the entire application. You can, however, control 61 | this behavior by passing a callable as ``exception_handler`` to 62 | :func:`start_background_task_factory`. If a background task crashes, the exception 63 | handler is called with the exception as the sole argument. If the handler returns a 64 | truthy value, the exception is ignored. In all other cases it is reraised. 65 | 66 | Working with threads 67 | -------------------- 68 | 69 | Threads are usually used when calling functions that take a long time (more than tens of 70 | milliseconds) to execute, either because they use significant amounts of CPU time, or 71 | they access external devices. The recommended way to use threads from asynchronous code 72 | is to use :func:`anyio.to_thread.run_sync`:: 73 | 74 | from functools import partial 75 | 76 | from anyio import to_thread 77 | 78 | def my_synchronous_func(a: int, b: int, *, kwarg: str = "") -> int: 79 | ... 80 | 81 | async def my_async_func() -> None: 82 | retval = await to_thread.run_sync(my_synchronous_func, 2, 5) 83 | 84 | # Use partial() if you need to pass keyword arguments 85 | retval = await to_thread.run_sync(partial(my_synchronous_func, 3, 6, kwarg="foo")) 86 | 87 | .. seealso:: To learn more about working with threads using AnyIO's APIs, see the 88 | :doc:`anyio:threads` section in AnyIO's documentation. 89 | 90 | Configuring the maximum amount of worker threads 91 | ++++++++++++++++++++++++++++++++++++++++++++++++ 92 | 93 | The configuration option ``max_threads`` sets the limit on how many worker threads will 94 | at most be used with :func:`anyio.to_thread.run_sync` if no explicit capacity limiter 95 | is passed. 96 | 97 | For example, this YAML configuration will set the thread limit to 60 in the default 98 | capacity limiter: 99 | 100 | .. code-block:: yaml 101 | 102 | max_threads: 60 103 | component: 104 | ... 105 | 106 | .. note:: This will **not** affect backend-specific thread APIs like 107 | :func:`asyncio.to_thread` or :meth:`asyncio.loop.run_in_executor`! 108 | 109 | .. _AnyIO: https://github.com/agronholm/anyio/ 110 | .. _structured concurrency: https://en.wikipedia.org/wiki/Structured_concurrency 111 | -------------------------------------------------------------------------------- /docs/userguide/contexts.rst: -------------------------------------------------------------------------------- 1 | Working with contexts and resources 2 | =================================== 3 | 4 | .. py:currentmodule:: asphalt.core 5 | 6 | Every Asphalt application has at least one context: the root context. The root context 7 | is typically created by the :func:`run_application` function and passed to the root 8 | component. This context will only be closed when the application is shutting down. 9 | 10 | Most nontrivial applications will make use of *subcontexts*. A subcontext is a context 11 | that has a parent context. A subcontext can make use of its parent's resources, but the 12 | parent cannot access the resources of its children. This enables developers to create 13 | complex services that work together without risking interfering with each other. 14 | 15 | Subcontexts can be roughly divided into two types: long lived and short lived ones. Long 16 | lived subcontexts are typically used in container components to isolate its resources 17 | from the rest of the application. Short lived subcontexts, on the other hand, usually 18 | encompass some *unit of work* (UOW). Examples of such UOWs are: 19 | 20 | * handling of a request in a network service 21 | * running a scheduled task 22 | * running a test in a test suite 23 | 24 | Contexts are "activated" by entering them using ``async with Context():``, and exited by 25 | leaving that block. When entered, the previous active context becomes the parent context 26 | of the new one and the new context becomes the currently active context. When the 27 | ``async with`` block is left, the previously active context once again becomes the 28 | active context. The currently active context can be retrieved using 29 | :func:`current_context`. 30 | 31 | .. warning:: Activating contexts in asynchronous generators can lead to corruption of 32 | the context stack. This is particularly common in asynchronous pytest fixtures 33 | because pytest helper libraries such as pytest-asyncio_ run the async generator 34 | using two different tasks. In such cases the workaround is to activate the context 35 | in the actual test function. 36 | 37 | .. _pytest-asyncio: https://pypi.org/project/pytest-asyncio/ 38 | 39 | Adding resources to a context 40 | ----------------------------- 41 | 42 | The resource system in Asphalt exists for two principal reasons: 43 | 44 | * To avoid having to duplicate configuration 45 | * To enable sharing of pooled resources, like database connection pools 46 | 47 | Here are a few examples of services that will likely benefit from resource sharing: 48 | 49 | * Database connections 50 | * Remote service handles 51 | * Serializers 52 | * Template renderers 53 | * SSL contexts 54 | 55 | When you add a resource, you should make sure that the resource is discoverable using 56 | any abstract interface or base class that it implements. This is so that consumers of 57 | the service don't have to care if you switch the implementation to another. For example, 58 | consider a mailer service, provided by asphalt-mailer_. The library has an abstract base 59 | class for all mailers, ``asphalt.mailer.Mailer``. To facilitate this loose coupling of 60 | services, it adds all its configure mailer services using the ``Mailer`` interface so 61 | that components that just need *some* was to send email don't have to care what 62 | implementation was chosen in the configuration. 63 | 64 | Resources can be added to a context in two forms: static resources and resource 65 | factories. A static resource can be any arbitrary object (except ``None``). The same 66 | object can be added to the context under several different types, as long as the 67 | type/name combination remains unique within the same context. 68 | 69 | A resource factory is a callable that takes a :class:`Context` as an argument an returns 70 | the value of the resource. There are at least a couple reasons to use resource factories 71 | instead of static resources: 72 | 73 | * the resource's lifecycle needs to be bound to the local context (example: database 74 | transactions) 75 | * the resource requires access to the local context (example: template renderers) 76 | 77 | .. _asphalt-mailer: https://github.com/asphalt-framework/asphalt-mailer 78 | 79 | Getting resources from a context 80 | -------------------------------- 81 | 82 | The primary ways to retrieve a resource from the current context are the 83 | :func:`get_resource` and :func:`get_resource_nowait` functions. They look for a resource 84 | or resource factory matching the given type and name. If a matching resource is found, 85 | it is returned from the call. If a resource is not found, but a matching resource 86 | factory is found, it is used to generate a matching resource which is then returned and 87 | also stored in the context for future requests. 88 | 89 | The :func:`get_resource` and :func:`get_resource_nowait` functions each have their own 90 | pros and cons: 91 | 92 | #. :func:`get_resource` works with asynchronous resource factories as well as static 93 | resources, but needs to be used with an ``await`` 94 | #. :func:`get_resource_nowait` doesn't work with asynchronous resource factories, but 95 | can be called from synchronous callbacks – that is, it doesn't need the ``await`` 96 | 97 | Additionally, the :func:`get_resource` function has special behavior during component 98 | startup. If the designated resource is not found and the ``optional=False`` option was 99 | not given, it will wait until another component makes the resource available. Normally, 100 | if the resource is not found, the call raises :exc:`ResourceNotFound`. 101 | 102 | Both variants can be made to return ``None`` if no matching resource is found, by 103 | passing ``optional=True``. 104 | 105 | Injecting resources to functions 106 | -------------------------------- 107 | 108 | A type-safe way to use context resources is to use `dependency injection`_. In Asphalt, 109 | this is done by adding parameters to a function so that they have the resource type as 110 | the type annotation, and a :func:`resource` instance as the default value. The function 111 | then needs to be decorated using :func:`inject`:: 112 | 113 | from asphalt.core import inject, resource 114 | 115 | @inject 116 | async def some_function(some_arg, some_resource: MyResourceType = resource()): 117 | ... 118 | 119 | To specify a non-default name for the dependency, you can pass that name as an argument 120 | to :func:`resource`:: 121 | 122 | @inject 123 | async def some_function( 124 | some_arg, 125 | some_resource: MyResourceType = resource('alternate') 126 | ): 127 | ... 128 | 129 | Resources can be declared to be optional too, by using either :data:`~typing.Optional` 130 | or ``| None`` (Python 3.10 or later only):: 131 | 132 | @inject 133 | async def some_function( 134 | some_arg, 135 | *, 136 | some_resource: Optional[MyResourceType] = resource('alternate') 137 | ): 138 | ... # some_resource will be None if it's not found 139 | 140 | Restrictions: 141 | 142 | * The resource arguments must not be positional-only arguments 143 | * The resources (or their relevant factories) must already be present in the context 144 | stack (unless declared optional) when the decorated function is called, or otherwise 145 | :exc:`ResourceNotFound` is raised 146 | 147 | .. _dependency injection: https://en.wikipedia.org/wiki/Dependency_injection 148 | 149 | Handling resource cleanup 150 | ------------------------- 151 | 152 | Any code that adds resources to a context is also responsible for cleaning them up when 153 | the context is closed. This usually involves closing sockets and files and freeing 154 | whatever system resources were allocated. This should be done in a *teardown callback*, 155 | scheduled using :func:`add_teardown_callback`. When the context is closed, teardown 156 | callbacks are run in the reverse order in which they were added, and always one at a 157 | time, unlike with the :class:`Signal` class. This ensures that a resource that is still 158 | in use by another resource is never cleaned up prematurely. 159 | 160 | For example:: 161 | 162 | from asphalt.core import Component, add_resource, add_teardown_callback 163 | 164 | 165 | class FooComponent(Component): 166 | async def start(): 167 | service = SomeService() 168 | await service.start() 169 | add_teardown_callback(service.stop) 170 | add_resource(service) 171 | 172 | 173 | There also exists a convenience decorator, :func:`context_teardown`, which makes use of 174 | asynchronous generators:: 175 | 176 | from collections.abc import AsyncGenerator 177 | 178 | from asphalt.core import Component, add_resource, context_teardown 179 | 180 | 181 | class FooComponent(Component): 182 | @context_teardown 183 | async def start() -> AsyncGenerator[Any, BaseException]: 184 | service = SomeService() 185 | await service.start() 186 | add_resource(service) 187 | 188 | yield 189 | 190 | # This part of the function is run when the context is closing 191 | service.stop() 192 | 193 | Sometimes you may want the cleanup to know whether the context was ended because of an 194 | unhandled exception. The one use that has come up so far is committing or rolling back a 195 | database transaction. This can be achieved by passing the ``pass_exception`` keyword 196 | argument to :func:`add_teardown_callback`:: 197 | 198 | from asphalt.core import Component, add_resource, add_teardown_callback 199 | 200 | 201 | class FooComponent(Component): 202 | async def start() -> None: 203 | def teardown(exception: Optional[BaseException]): 204 | if exception: 205 | db.rollback() 206 | else: 207 | db.commit() 208 | 209 | db = SomeDatabase() 210 | await db.start() 211 | add_teardown_callback(teardown, pass_exception=True) 212 | add_resource(db) 213 | 214 | The same can be achieved with :func:`context_teardown` by storing the yielded value:: 215 | 216 | from collections.abc import AsyncGenerator 217 | from typing import Any 218 | 219 | class FooComponent(Component): 220 | @context_teardown 221 | async def start() -> AsyncGenerator[Any, BaseException]: 222 | db = SomeDatabase() 223 | await db.start() 224 | add_resource(db) 225 | 226 | exception = yield 227 | 228 | if exception: 229 | db.rollback() 230 | else: 231 | db.commit() 232 | 233 | If any of the teardown callbacks raises an exception, the cleanup process will still 234 | continue, but all those raised exceptions will be reraised at the end inside an 235 | :exc:`ExceptionGroup` (or :exc:`BaseExceptionGroup`). 236 | -------------------------------------------------------------------------------- /docs/userguide/deployment.rst: -------------------------------------------------------------------------------- 1 | Configuration and deployment 2 | ============================ 3 | 4 | .. py:currentmodule:: asphalt.core 5 | 6 | As your application grows more complex, you may find that you need to have different 7 | settings for your development environment and your production environment. You may even 8 | have multiple deployments that all need their own custom configuration. 9 | 10 | For this purpose, Asphalt provides a command line interface that will read a YAML_ 11 | formatted configuration file and run the application it describes. 12 | 13 | .. _YAML: https://yaml.org/ 14 | 15 | Running the Asphalt launcher 16 | ---------------------------- 17 | 18 | Running the launcher is very straightfoward: 19 | 20 | .. code-block:: bash 21 | 22 | asphalt run [yourconfig.yaml your-overrides.yml...] [--set path.to.key=val] 23 | 24 | Or alternatively: 25 | 26 | .. code-block:: bash 27 | 28 | python -m asphalt run [yourconfig.yaml your-overrides.yml...] [--set path.to.key=val] 29 | 30 | What this will do is: 31 | 32 | #. read all the given configuration files, if any, starting from ``yourconfig.yaml`` 33 | #. read the command line configuration options passed with ``--set``, if any 34 | #. merge the configuration files' contents and the command line configuration options 35 | into a single configuration dictionary using :func:`merge_config`. 36 | #. call :func:`run_application` using the configuration dictionary as keyword 37 | arguments 38 | 39 | Writing a configuration file 40 | ---------------------------- 41 | 42 | .. highlight:: yaml 43 | 44 | A production-ready configuration file should contain at least the following options: 45 | 46 | * ``component``: a dictionary containing the class name and keyword arguments for its 47 | initializer 48 | * ``logging``: a dictionary to be passed to :func:`logging.config.dictConfig` 49 | 50 | Suppose you had the following component class as your root component:: 51 | 52 | class MyRootComponent(Component): 53 | def __init__(self, data_directory: str): 54 | self.data_directory = data_directory 55 | self.add_component('mailer', backend='smtp') 56 | self.add_component('sqlalchemy') 57 | 58 | You could then write a configuration file like this:: 59 | 60 | --- 61 | max_threads: 20 62 | component: 63 | type: !!python/name:myproject.MyRootComponent 64 | data_directory: /some/file/somewhere 65 | components: 66 | mailer: 67 | host: smtp.mycompany.com 68 | ssl: true 69 | sqlalchemy: 70 | url: postgresql:///mydatabase 71 | 72 | logging: 73 | version: 1 74 | disable_existing_loggers: false 75 | handlers: 76 | console: 77 | class: logging.StreamHandler 78 | formatter: generic 79 | formatters: 80 | generic: 81 | format: "%(asctime)s:%(levelname)s:%(name)s:%(message)s" 82 | root: 83 | handlers: [console] 84 | level: INFO 85 | 86 | In the above configuration you have three top level configuration keys: ``max_threads``, 87 | ``component`` and ``logging``, all of which are directly passed to 88 | :func:`run_application` as keyword arguments. 89 | 90 | The ``component`` section defines the type of the root component using the specially 91 | processed ``type`` option. You can either specify a setuptools entry point name (from 92 | the ``asphalt.components`` namespace) or a text reference like ``module:class`` (see 93 | :func:`resolve_reference` for details). The rest of the keys in this section are 94 | passed directly to the constructor of the ``MyRootComponent`` class. 95 | 96 | The ``components`` section within ``component`` is processed in a similar fashion. 97 | Each subsection here is a component type alias and its keys and values are the 98 | constructor arguments to the relevant component class. The per-component configuration 99 | values are merged with those provided in the ``start()`` method of ``MyRootComponent``. 100 | See the next section for a more elaborate explanation. 101 | 102 | With ``max_threads: 20``, the maximum number of threads that functions like 103 | :func:`anyio.to_thread.run_sync` can have running is set to 20. 104 | 105 | The ``logging`` configuration tree here sets up a root logger that prints all log 106 | entries of at least ``INFO`` level to the console. You may want to set up more granular 107 | logging in your own configuration file. See the 108 | :ref:`Python standard library documentation ` for 109 | details. 110 | 111 | Using multiple components of the same type under the same parent component 112 | -------------------------------------------------------------------------- 113 | 114 | Occasionally, you may run into a situation where you need two instances of the same 115 | component under the same parent component. A practical example of this is two SQLAlchemy 116 | engines, one for the master, one for the replica database. But specifying this in the 117 | configuration won't work:: 118 | 119 | --- 120 | component: 121 | type: !!python/name:myproject.MyRootComponent 122 | components: 123 | sqlalchemy: 124 | url: postgresql://user:pass@postgres-master/dbname 125 | sqlalchemy: 126 | url: postgresql://user:pass@postgres-replica/dbname 127 | 128 | Not only is there a conflict as you can't have two identical aliases within the same 129 | parent, but even if you could start the component tree like this, the two SQLALchemy 130 | components would try to publish resources with the same type and name combinations. 131 | 132 | The solution to the problem is to use different aliases:: 133 | 134 | --- 135 | component: 136 | type: !!python/name:myproject.MyRootComponent 137 | components: 138 | sqlalchemy: 139 | url: postgresql://user:pass@postgres-master/dbname 140 | sqlalchemy/replica: 141 | url: postgresql://user:pass@postgres-replica/dbname 142 | 143 | As of v5.0, the framework understands the ``component-type/resource-name`` notation, and 144 | fills in the ``type`` field of the child component with ``sqlalchemy``, if there's no 145 | existing ``type`` key. 146 | 147 | With this configuration, you get two distinct SQLAlchemy components, and the second one 148 | will publish its engine and session factory resources using the ``replica`` name rather 149 | than ``default``. 150 | 151 | .. note:: The altered resource name only applies to the :meth:`Component.start` method, 152 | **not** :meth:`Component.prepare`, as the latter is meant to provide resources to 153 | child components, and they would need to know beforehand what resource name to 154 | expect. 155 | 156 | Using data from environment variables and files 157 | ----------------------------------------------- 158 | 159 | Many deployment environments (Kubernetes, Docker Swarm, Heroku, etc.) require 160 | applications to input configuration values and/or secrets using environment variables or 161 | external files. To support this, Asphalt extends the YAML parser with three custom tags: 162 | 163 | * ``!Env``: substitute with the value of an environment variable 164 | * ``!TextFile`` substitute with the contents of a (UTF-8 encoded) text file (as ``str``) 165 | * ``!BinaryFile`` substitute with the contents of a file (as ``bytes``) 166 | 167 | For example:: 168 | 169 | --- 170 | component: 171 | type: !!python/name:myproject.MyRootComponent 172 | param_from_environment: !Env MY_ENV_VAR 173 | files: 174 | - !TextFile /path/to/file.txt 175 | - !BinaryFile /path/to/file.bin 176 | 177 | If a file path contains spaces, you can just quote it:: 178 | 179 | --- 180 | component: 181 | type: !!python/name:myproject.MyRootComponent 182 | param_from_text_file: !TextFile "/path with spaces/to/file.txt" 183 | 184 | .. note:: This does **not** allow you to include other YAML documents as part of the 185 | configuration, except as text/binary blobs. See the next section if this is what you 186 | want. 187 | 188 | .. versionadded:: 4.5.0 189 | 190 | Configuration overlays 191 | ---------------------- 192 | 193 | Component configuration can be specified on several levels: 194 | 195 | * Hard-coded arguments to :meth:`Component.add_component` 196 | * First configuration file argument to ``asphalt run`` 197 | * Second configuration file argument to ``asphalt run`` 198 | * ... 199 | * Command line configuration options to ``asphalt run --set`` 200 | 201 | Any options you specify on each level override or augment any options given on previous 202 | levels. The command line configuration options have precedence over the configuration 203 | files. To minimize the effort required to build a working configuration file for your 204 | application, it is suggested that you pass as many of the options directly in the 205 | component initialization code and leave only deployment specific options like API keys, 206 | access credentials and such to the configuration file. 207 | 208 | With the configuration presented in the earlier paragraphs, the ``mailer`` component's 209 | constructor gets passed three keyword arguments: 210 | 211 | * ``backend='smtp'`` 212 | * ``host='smtp.mycompany.com'`` 213 | * ``ssl=True`` 214 | 215 | The first one is provided in the root component code while the other two options come 216 | from the YAML file. You could also override the mailer backend in the configuration file 217 | if you wanted, or at the command line (with the configuration file saved as 218 | ``config.yaml``): 219 | 220 | .. code-block:: bash 221 | 222 | asphalt run config.yaml --set component.components.mailer.backend=sendmail 223 | 224 | .. note:: 225 | Note that if you want a ``.`` to be treated as part of an identifier, and not as a 226 | separator, you need to escape it at the command line with ``\``. For instance, in 227 | both commands: 228 | 229 | .. code-block:: bash 230 | 231 | asphalt run config.yaml --set "logging.loggers.asphalt\.templating.level=DEBUG" 232 | asphalt run config.yaml --set logging.loggers.asphalt\\.templating.level=DEBUG 233 | 234 | The logging level for the ``asphalt.templating`` logger will be set to ``DEBUG``. 235 | 236 | The same effect can be achieved programmatically by supplying the override configuration 237 | to the container component via its ``components`` constructor argument. This is very 238 | useful when writing tests against your application. For example, you might want to use 239 | the ``mock`` mailer in your test suite configuration to test that the application 240 | correctly sends out emails (and to prevent them from actually being sent to 241 | recipients!). 242 | 243 | Defining multiple services 244 | -------------------------- 245 | 246 | .. versionadded:: 4.1.0 247 | 248 | Sometimes it may be more convenient to use a single configuration file for launching 249 | your application with different configurations or entry points. To this end, the runner 250 | supports the notion of "service definitions" in the configuration file. This is done by 251 | replacing the ``component`` dictionary with a ``services`` dictionary at the top level 252 | of the configuration file and either setting the ``ASPHALT_SERVICE`` environment 253 | variable or by passing the ``--service`` (or ``-s``) option when launching the runner. 254 | This approach provides the additional advantage of allowing the use of YAML references, 255 | like so:: 256 | 257 | --- 258 | services: 259 | server: 260 | max_threads: 30 261 | component: 262 | type: !!python/name:myproject.server.ServerComponent 263 | components: 264 | wamp: &wamp 265 | host: wamp.example.org 266 | port: 8000 267 | tls: true 268 | auth_id: serveruser 269 | auth_secret: serverpass 270 | mailer: 271 | backend: smtp 272 | 273 | client: 274 | component: 275 | type: !!python/name:myproject.client.ClientComponent 276 | components: 277 | wamp: 278 | <<: *wamp 279 | auth_id: clientuser 280 | auth_secret: clientpass 281 | 282 | Each section under ``services`` is like its own distinct top level configuration. 283 | Additionally, the keys under each service are merged with any top level configuration, 284 | so you can, for example, define a logging configuration there. 285 | 286 | Now, to run the ``server`` service, do: 287 | 288 | .. code-block:: bash 289 | 290 | asphalt run -s server config.yaml 291 | 292 | The ``client`` service is run in the same fashion: 293 | 294 | .. code-block:: bash 295 | 296 | asphalt run -s client config.yaml 297 | 298 | You can also define a service with a special name, ``default``, which is used in case 299 | multiple services are present and no service has been explicitly selected. 300 | 301 | .. note:: The ``-s/--service`` command line switch overrides the ``ASPHALT_SERVICE`` 302 | environment variable. 303 | 304 | Performance tuning 305 | ------------------ 306 | 307 | When you want maximum performance, you'll also want to use the fastest available event 308 | loop implementation. If you're running on the asyncio backend (the default), you can 309 | get a nice performance boost by enabling uvloop_ (assuming it's installed). 310 | Add the following piece to your application's configuration: 311 | 312 | .. code-block:: yaml 313 | 314 | backend_options: 315 | use_uvloop: true 316 | 317 | .. _uvloop: https://magic.io/blog/uvloop-make-python-networking-great-again/ 318 | -------------------------------------------------------------------------------- /docs/userguide/events.rst: -------------------------------------------------------------------------------- 1 | Working with signals and events 2 | =============================== 3 | 4 | .. py:currentmodule:: asphalt.core 5 | 6 | Events are a handy way to make your code react to changes in another part of the 7 | application. To dispatch and listen to events, you first need to have one or more 8 | :class:`Signal` instances as attributes of some class. Each signal needs to be 9 | associated with some :class:`Event` class. Then, when you dispatch a new event 10 | by calling :meth:`Signal.dispatch`, the given event will be passed to all tasks 11 | currently subscribed to that signal. 12 | 13 | For listening to events dispatched from a signal, you have two options: 14 | 15 | #. :func:`wait_event` (for returning after receiving one event) 16 | #. :func:`stream_events` (for asynchronously iterating over events as they come) 17 | 18 | If you only intend to listen to a single signal at once, you can use 19 | :meth:`Signal.wait_event` or :meth:`Signal.stream_events` as shortcuts. 20 | 21 | Receiving events iteratively 22 | ---------------------------- 23 | 24 | Here's an example of an event source containing two signals (``somesignal`` and 25 | ``customsignal``) and code that subscribes to said signals, dispatches an event on both 26 | signals and then prints them out as they are received:: 27 | 28 | from dataclasses import dataclass 29 | 30 | from asphalt.core import Event, Signal, stream_events 31 | 32 | 33 | @dataclass 34 | class CustomEvent(Event): 35 | extra_argument: str 36 | 37 | 38 | class MyEventSource: 39 | somesignal = Signal(Event) 40 | customsignal = Signal(CustomEvent) 41 | 42 | 43 | async def some_handler(): 44 | source = MyEventSource() 45 | async with stream_events([source.somesignal, source.customsignal]) as events: 46 | # Dispatch a basic Event 47 | source.somesignal.dispatch(Event()) 48 | 49 | # Dispatch a CustomEvent 50 | source.customsignal.dispatch(CustomEvent("extra argument here")) 51 | 52 | async for event in events: 53 | print(f"received event: {event}") 54 | 55 | Waiting for a single event 56 | -------------------------- 57 | 58 | To wait for the next event dispatched from a given signal, you can use the 59 | :meth:`Signal.wait_event` method:: 60 | 61 | async def print_next_event(source: MyEventSource) -> None: 62 | event = await source.somesignal.wait_event() 63 | print(event) 64 | 65 | You can even wait for the next event dispatched from any of several signals using the 66 | :func:`wait_event` function:: 67 | 68 | from asphalt.core import wait_event 69 | 70 | 71 | async def print_next_event( 72 | source1: MyEventSource, 73 | source2: MyEventSource, 74 | source3: MyEventSource, 75 | ) -> None: 76 | event = await wait_event( 77 | [source1.some_signal, source2.custom_signal, source3.some_signal] 78 | ) 79 | print(event) 80 | 81 | Filtering received events 82 | ------------------------- 83 | 84 | You can provide a filter callback that will take an event as the sole argument. Only if 85 | the callback returns ``True``, will the event be received by the listener:: 86 | 87 | async def print_next_matching_event(source: MyEventSource) -> None: 88 | event = await source.customsignal.wait_event( 89 | lambda event: event.extra_argument == "foo" 90 | ) 91 | print("Got an event with 'foo' as extra_argument") 92 | 93 | The same works for event streams too:: 94 | 95 | async def print_matching_events(source: MyEventSource) -> None: 96 | async with source.customsignal.stream_events( 97 | lambda event: event.extra_argument == "foo" 98 | ) as events: 99 | async for event in events: 100 | print(event) 101 | -------------------------------------------------------------------------------- /docs/userguide/index.rst: -------------------------------------------------------------------------------- 1 | User guide 2 | ========== 3 | 4 | This is the reference documentation. If you're looking to learn Asphalt from scratch, you should 5 | take a look at the :doc:`../tutorials/index` first. 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | 10 | architecture 11 | components 12 | contexts 13 | concurrency 14 | events 15 | testing 16 | deployment 17 | migration 18 | -------------------------------------------------------------------------------- /docs/userguide/migration.rst: -------------------------------------------------------------------------------- 1 | Migrating from Asphalt 4.x to 5.x 2 | ================================= 3 | 4 | .. py:currentmodule:: asphalt.core 5 | 6 | Resources 7 | --------- 8 | 9 | #. Adding resources should now be done with the :func:`add_resource` function rather than 10 | the :meth:`Context.add_resource` method 11 | #. ``require_resource()`` is now :func:`get_resource_nowait` 12 | #. ``get_resource()`` is now :func:`get_resource_nowait` with ``optional=True`` 13 | #. ``request_resource()`` is now :func:`get_resource` 14 | 15 | Component classes 16 | ----------------- 17 | 18 | #. The ``ctx`` parameter was removed from :meth:`Component.start` 19 | #. The functions for adding and getting resources have changed (see above) 20 | 21 | Before:: 22 | 23 | from asphalt.core import Component, Context, add_resource, request_resource 24 | 25 | class MyComponent(Component): 26 | async def start(self, ctx: Context) -> None: 27 | resource = await ctx.request_resource(int, "integer_resource") 28 | ctx.add_resource("simple-string") 29 | 30 | After:: 31 | 32 | from asphalt.core import Component, add_resource, get_resource 33 | 34 | class MyComponent(Component): 35 | async def start(self) -> None: 36 | resource = await get_resource(int, "integer_resource") 37 | add_resource("simple-string") 38 | 39 | Container components 40 | -------------------- 41 | 42 | #. The ``ContainerComponent`` class has been removed in favor of allowing any 43 | :class:`Component` subclass to have subcomponents. As such, they no longer take a 44 | ``components`` argument in their ``__init__()`` methods, so you're free to make them 45 | data classes if you like. 46 | #. There is no longer any need to call ``super().start()`` in the 47 | :meth:`~Component.start` method 48 | #. Any :meth:`~Component.add_component` calls must be made in the initializer instead of 49 | the :meth:`~Component.start` method 50 | 51 | Before:: 52 | 53 | from asphalt.core import ContainerComponent, Context 54 | 55 | class MyContainer(ContainerComponent): 56 | def __init__(self, components): 57 | super().__init__(components) 58 | 59 | async def start(self, ctx: Context) -> None: 60 | await super().start(ctx) 61 | self.add_component("another", AnotherComponent) 62 | ... 63 | 64 | After:: 65 | 66 | from asphalt.core import Component 67 | 68 | class MyContainer(Component): 69 | def __init__(self) -> None: 70 | self.add_component("another", AnotherComponent) 71 | 72 | async def start(self) -> None: 73 | ... 74 | 75 | CLI application components 76 | -------------------------- 77 | 78 | The ``ctx`` parameter has been removed from the :meth:`CLIApplicationComponent.run` 79 | method. 80 | 81 | Before:: 82 | 83 | from asphalt.core import CLIApplicationComponent 84 | 85 | class MyApp(CLIApplicationComponent): 86 | def __init__(self, components): 87 | super().__init__(components) 88 | 89 | async def start(self, ctx: Context) -> None: 90 | self.add_component("another", AnotherComponent) 91 | ... 92 | 93 | async def run(self, ctx: Context) -> None: 94 | ... 95 | 96 | After:: 97 | 98 | from asphalt.core import CLIApplicationComponent 99 | 100 | class MyApp(CLIApplicationComponent): 101 | def __init__(self) -> None: 102 | self.add_component("another", AnotherComponent) 103 | 104 | async def start(self) -> None: 105 | ... 106 | 107 | async def run(self) -> None: 108 | ... 109 | 110 | Starting tasks at component startup 111 | ----------------------------------- 112 | 113 | As Asphalt is now built on top of AnyIO_, tasks should be started and torn down in 114 | compliance with `structured concurrency`_, and using AnyIO's task APIs. In practice, 115 | if you're starting tasks in :meth:`Component.start`, you should probably use the 116 | :func:`start_service_task` function. 117 | 118 | .. note:: Note that the task spawning functions take callables, not coroutine objects, 119 | so drop the ``()``. If you need to pass keyword arguments, use either a lambda or 120 | :func:`functools.partial` to do so. 121 | 122 | Before:: 123 | 124 | from asyncio import CancelledError, create_task 125 | from contextlib import suppress 126 | 127 | from asphalt.core import Component, Context 128 | from asphalt.core.context import context_teardown 129 | 130 | class MyComponent(Component): 131 | @context_teardown 132 | async def start(self, ctx: Context) -> None: 133 | task = create_task(self.sometaskfunc(1, kwarg="foo")) 134 | yield 135 | task.cancel() 136 | with suppress(CancelledError): 137 | await task 138 | 139 | async def sometaskfunc(self, arg, *, kwarg) -> None: 140 | ... 141 | 142 | After:: 143 | 144 | from functools import partial 145 | 146 | from asphalt.core import Component, start_service_task 147 | 148 | class MyComponent(Component): 149 | async def start(self) -> None: 150 | await start_service_task(partial(self.sometaskfunc, 1, kwarg="foo"), "sometask") 151 | 152 | async def sometaskfunc(self, arg, *, kwarg) -> None: 153 | ... 154 | 155 | .. seealso:: :doc:`concurrency` 156 | 157 | Starting ad-hoc tasks after application startup 158 | ----------------------------------------------- 159 | 160 | Starting tasks that complete by themselves within the run time of the application is now 161 | done using **task factories**. Task factories start their tasks in the same AnyIO task 162 | group, and you can pass settings common to all the spawned tasks to 163 | :func:`start_background_task_factory`. 164 | 165 | Before:: 166 | 167 | from asyncio import create_task 168 | 169 | async def my_function() -> None: 170 | task = create_task(sometaskfunc(1, kwarg="foo")) 171 | 172 | async def sometaskfunc(arg, *, kwarg) -> None: 173 | ... 174 | 175 | After:: 176 | 177 | from functools import partial 178 | 179 | from asphalt.core import Component, add_resource, start_background_task_factory 180 | 181 | class MyServiceComponent(Component): 182 | async def start(self) -> None: 183 | factory = await start_background_task_factory() 184 | add_resource(factory) 185 | 186 | # And then in another module: 187 | from asphalt.core import TaskFactory, get_resource_nowait 188 | 189 | async def my_function() -> None: 190 | factory = get_resource_nowait(TaskFactory) 191 | task = factory.start_task_soon(partial(sometaskfunc, 1, kwarg="foo")) 192 | 193 | async def sometaskfunc(arg, *, kwarg) -> None: 194 | ... 195 | 196 | Threads 197 | ------- 198 | 199 | #. All thread-related functions have been removed in favor of the ``anyio.to_thread`` 200 | and ``anyio.from_thread`` modules. 201 | #. The ``@executor`` decorator has been dropped as incompatible with the new design, so 202 | it should be replaced with appropriate calls to :func:`anyio.to_thread.run_sync`. If 203 | you need to run an entire function in a thread, you can refactor it into a nested 204 | function on a coroutine function. 205 | 206 | Replacing ``call_in_executor()`` and ``call_async()`` 207 | +++++++++++++++++++++++++++++++++++++++++++++++++++++ 208 | 209 | Before:: 210 | 211 | from asyncio import Event 212 | from asphalt.core import call_async, call_in_executor 213 | 214 | def my_blocking_function(ctx: Context, event: Event) -> None: 215 | call_async(event.set) 216 | 217 | async def origin_async_func() -> None: 218 | event = Event() 219 | await call_in_executor(my_blocking_function, ctx, event) 220 | await event.wait() 221 | 222 | After:: 223 | 224 | from anyio import Event, from_thread, to_thread 225 | 226 | def my_blocking_function(event: Event) -> None: 227 | from_thread.run_sync(event.set) 228 | 229 | async def origin_async_func() -> None: 230 | event = Event() 231 | await to_thread.run_sync(some_blocking_function, arg1) 232 | await event.wait() 233 | 234 | Replacing ``@executor`` 235 | +++++++++++++++++++++++ 236 | 237 | As there is no direct equivalent for ``@executor`` in AnyIO, you'll have to explicitly 238 | run the function using :func:`anyio.to_thread.run_sync`. 239 | 240 | Before:: 241 | 242 | from asphalt.core.context import executor 243 | 244 | @executor 245 | def my_func(): 246 | ... 247 | 248 | async def origin_async_func() -> None: 249 | await my_func() 250 | 251 | After:: 252 | 253 | from anyio import to_thread 254 | 255 | def my_func(): 256 | ... 257 | 258 | async def origin_async_func() -> None: 259 | await to_thread.run_sync(my_func) 260 | 261 | Replacing ``Context.threadpool()`` 262 | ++++++++++++++++++++++++++++++++++ 263 | 264 | As there is no equivalent for ``Context.threadpool()`` in AnyIO, you need to place the 265 | code that needs to be run in a thread in its own function, and then use 266 | :func:`anyio.to_thread.run_sync` to run that function. 267 | 268 | Before:: 269 | 270 | async def my_func(): 271 | var = 1 272 | async with threadpool(): 273 | time.sleep(2) 274 | var = 2 275 | 276 | After:: 277 | 278 | from anyio import to_thread 279 | 280 | async def my_func(): 281 | var = 1 282 | 283 | def wrapper(): 284 | nonlocal var 285 | time.sleep(2) 286 | var = 2 287 | 288 | await to_thread.run_sync(wrapper) 289 | 290 | Signals and events 291 | ------------------ 292 | 293 | In support of `structured concurrency`_, the signalling system (not to be confused with 294 | operating system signals like ``SIGTERM`` et al), has been refactored to require the use 295 | of context managers wherever possible. 296 | 297 | Migrating custom event classes 298 | ++++++++++++++++++++++++++++++ 299 | 300 | As the :class:`Event` class no longer has an initializer, you need to remove the 301 | ``source`` and ``topic`` initializer parameters from your own subclasses, and drop the 302 | ``super().__init__(source, topic)`` call. You may also want to take this opportunity to 303 | refactor them into data classes. 304 | 305 | Before:: 306 | 307 | from asphalt.core import Event 308 | 309 | class MyEvent(Event): 310 | def __init__(self, source, topic, an_attribute: str): 311 | super().__init__(source, topic) 312 | self.an_attribute = an_attribute 313 | 314 | After:: 315 | 316 | from dataclasses import dataclass 317 | 318 | from asphalt.core import Event 319 | 320 | @dataclass 321 | class MyEvent(Event): 322 | an_attribute: str 323 | 324 | Iterating over events 325 | +++++++++++++++++++++ 326 | 327 | As the ``connect()`` and ``disconnect()`` signal methods have been eliminated, you need 328 | to use the :meth:`Signal.stream_events` method or the :func:`stream_events` function. 329 | 330 | Before:: 331 | 332 | from asphalt.core import Signal, Event 333 | 334 | class MyEvent(Event): 335 | ... 336 | 337 | class MyService: 338 | something = Signal(MyEvent) 339 | 340 | def event_listener(event: MyEvent) -> None: 341 | print("got an event") 342 | 343 | async def myfunc(service: MyService) -> None: 344 | service.something.connect(event_listener) 345 | ... 346 | service.something.disconnect(event_listener) 347 | 348 | After:: 349 | 350 | from asphalt.core import Signal, Event 351 | 352 | class MyEvent(Event): 353 | ... 354 | 355 | class MyService: 356 | something = Signal(MyEvent) 357 | 358 | async def myfunc(service: MyService) -> None: 359 | async with service.something.stream_events() as event_stream: 360 | async for event in event_stream: 361 | print("got an event") 362 | 363 | Dispatching events 364 | ++++++++++++++++++ 365 | 366 | The :meth:`~Signal.dispatch` method has been changed to work like 367 | ``Signal.dispatch_raw()``. That is, you will need to pass it an appropriate 368 | :class:`Event` object. 369 | 370 | Before:: 371 | 372 | from asphalt.core import Signal, Event 373 | 374 | class MyEvent(Event): 375 | def __init__(self, source, topic, an_attribute: str): 376 | super().__init__(source, topic) 377 | self.an_attribute = an_attribute 378 | 379 | class MyService: 380 | something = Signal(MyEvent) 381 | 382 | async def myfunc(service: MyService) -> None: 383 | service.something.dispatch("value") 384 | 385 | After:: 386 | 387 | from dataclasses import dataclass 388 | 389 | from asphalt.core import Signal, Event 390 | 391 | @dataclass 392 | class MyEvent(Event): 393 | an_attribute: str 394 | 395 | class MyService: 396 | something = Signal(MyEvent) 397 | 398 | async def myfunc(service: MyService) -> None: 399 | service.something.dispatch(MyEvent("value")) 400 | 401 | Configuration 402 | ------------- 403 | 404 | The ability to specify "shortcuts" using dots in the configuration keys has been 405 | removed, as it interfered with logging configuration. 406 | 407 | .. highlight:: yaml 408 | 409 | Before:: 410 | 411 | foo.bar: value 412 | 413 | After:: 414 | 415 | foo: 416 | bar: value 417 | 418 | If your application uses two components of the same type, you've probably had to work 419 | around the resource namespace conflicts with a configuration similar to this:: 420 | 421 | my_component: 422 | foo: bar 423 | my_component_alter: 424 | type: my_component 425 | resource_name: alter 426 | foo: baz 427 | 428 | On Asphalt 5, you can simplify this configuration:: 429 | 430 | my_component: 431 | foo: bar 432 | my_component/alter: 433 | foo: baz 434 | 435 | The slash in the key separates the component alias and the default resource name (which 436 | is used in place of ``default``) when a component adds a resource during startup. 437 | 438 | .. _AnyIO: https://github.com/agronholm/anyio/ 439 | .. _structured concurrency: https://en.wikipedia.org/wiki/Structured_concurrency 440 | -------------------------------------------------------------------------------- /docs/userguide/snippets/components1.py: -------------------------------------------------------------------------------- 1 | from asphalt.core import ( 2 | Component, 3 | add_resource, 4 | get_resource, 5 | get_resource_nowait, 6 | run_application, 7 | ) 8 | 9 | 10 | class ParentComponent(Component): 11 | def __init__(self) -> None: 12 | self.add_component("child1", ChildComponent, name="child1") 13 | self.add_component("child2", ChildComponent, name="child2") 14 | 15 | async def prepare(self) -> None: 16 | print("ParentComponent.prepare()") 17 | add_resource("Hello") # adds a `str` type resource by the name `default` 18 | 19 | async def start(self) -> None: 20 | print("ParentComponent.start()") 21 | print(get_resource_nowait(str, "child1_resource")) 22 | print(get_resource_nowait(str, "child2_resource")) 23 | 24 | 25 | class ChildComponent(Component): 26 | parent_resource: str 27 | sibling_resource: str 28 | 29 | def __init__(self, name: str) -> None: 30 | self.name = name 31 | 32 | async def prepare(self) -> None: 33 | self.parent_resource = get_resource_nowait(str) 34 | print(f"ChildComponent.prepare() [{self.name}]") 35 | 36 | async def start(self) -> None: 37 | print(f"ChildComponent.start() [{self.name}]") 38 | 39 | # Add a `str` type resource, with a name like `childX_resource` 40 | add_resource( 41 | f"{self.parent_resource}, world from {self.name}!", f"{self.name}_resource" 42 | ) 43 | 44 | # Do this only after adding our own resource, or we end up in a deadlock 45 | resource = "child1_resource" if self.name == "child2" else "child1_resource" 46 | await get_resource(str, resource) 47 | 48 | 49 | run_application(ParentComponent) 50 | -------------------------------------------------------------------------------- /docs/userguide/testing.rst: -------------------------------------------------------------------------------- 1 | Testing Asphalt components 2 | ========================== 3 | 4 | .. py:currentmodule:: asphalt.core 5 | 6 | Testing Asphalt components and component hierarchies is a relatively simple procedure: 7 | 8 | #. Create a :class:`~Context` and enter it with ``async with ...`` 9 | #. Run :func:`start_component` with your component class as the first argument (and the 10 | configuration dictionary, if you have one, as the second argument) 11 | #. Run the test code itself 12 | 13 | With Asphalt projects, it is recommended to use the pytest_ testing framework because it 14 | is already being used with Asphalt core and it provides easy testing of asynchronous 15 | code (via `AnyIO's pytest plugin`_). 16 | 17 | Example 18 | ------- 19 | 20 | Let's build a test suite for the :doc:`Echo Tutorial <../tutorials/echo>`. 21 | 22 | The client and server components could be tested separately, but to make things easier, 23 | we'll test them against each other. 24 | 25 | Create a ``tests`` directory at the root of the project directory and create a module 26 | named ``test_client_server`` there (the ``test_`` prefix is important): 27 | 28 | .. literalinclude:: ../../examples/tutorial1/tests/test_client_server.py 29 | :language: python 30 | 31 | In the above test module, the first thing you should note is 32 | ``pytestmark = pytest.mark.anyio``. This is the pytest marker that marks all coroutine 33 | functions in the module to be run via AnyIO's pytest plugin. 34 | 35 | The next item in the module is the ``server`` asynchronous generator fixture. Fixtures 36 | like these are run by AnyIO's pytest plugin in their respective tasks, making the 37 | practice of straddling a :class:`Context` on a ``yield`` safe. This would normally be 38 | bad, as the context contains a :class:`~anyio.abc.TaskGroup` which usually should not be 39 | used together with ``yield``, unless it's carefully managed like it is here. 40 | 41 | The actual test function, ``test_client_and_server()`` first declares a dependency 42 | on the ``server`` fixture, and then on another fixture (capsys_). This other fixture is 43 | provided by ``pytest``, and it captures standard output and error, letting us find out 44 | what message the components printed. Note that the ``server`` fixture also depends on 45 | this fixture so that outputs from both the server and client are properly captured. 46 | 47 | In this test function, the client component is instantiated and run. Because the client 48 | component is a :class:`CLIApplicationComponent`, we can just run it directly by calling 49 | its ``run()`` method. While the client component does not contain any child components 50 | or other startup logic, we're nevertheless calling its ``start()`` method first, as this 51 | is a "best practice". 52 | 53 | Finally, we exit the context and check that the server and the client printed the 54 | messages they were supposed to. When the server receives a line from the client, it 55 | prints a message to standard output using :func:`print`. Likewise, when the client gets 56 | a response from the server, it too prints out its own message. By using the capsys_ 57 | fixture, we can capture the output and verify it against the expected lines. 58 | 59 | To run the test suite, make sure you're in the project directory and then do: 60 | 61 | .. code-block:: bash 62 | 63 | PYTHONPATH=. pytest tests 64 | 65 | For more elaborate examples, please see the test suites of various 66 | `Asphalt subprojects`_. 67 | 68 | .. _pytest: https://pytest.org/ 69 | .. _AnyIO's pytest plugin: https://anyio.readthedocs.io/en/stable/testing.html 70 | .. _capsys: https://docs.pytest.org/en/stable/how-to/capture-stdout-stderr.html\ 71 | #accessing-captured-output-from-a-test-function 72 | .. _Asphalt subprojects: https://github.com/asphalt-framework 73 | -------------------------------------------------------------------------------- /examples/tutorial1/echo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asphalt-framework/asphalt/e1f4a17f03d9cc9d1400038df476bce1975f353c/examples/tutorial1/echo/__init__.py -------------------------------------------------------------------------------- /examples/tutorial1/echo/client.py: -------------------------------------------------------------------------------- 1 | """This is the client code for the Asphalt echo server tutorial.""" 2 | 3 | # isort: off 4 | import sys 5 | from dataclasses import dataclass 6 | 7 | import anyio 8 | from asphalt.core import CLIApplicationComponent, run_application 9 | 10 | 11 | @dataclass 12 | class ClientComponent(CLIApplicationComponent): 13 | message: str 14 | 15 | async def run(self) -> None: 16 | async with await anyio.connect_tcp("localhost", 64100) as stream: 17 | await stream.send(self.message.encode() + b"\n") 18 | response = await stream.receive() 19 | 20 | print("Server responded:", response.decode().rstrip()) 21 | 22 | 23 | if __name__ == "__main__": 24 | run_application(ClientComponent, {"message": sys.argv[1]}) 25 | -------------------------------------------------------------------------------- /examples/tutorial1/echo/server.py: -------------------------------------------------------------------------------- 1 | """This is the server code for the Asphalt echo server tutorial.""" 2 | 3 | # isort: off 4 | from __future__ import annotations 5 | 6 | import anyio 7 | from anyio.abc import SocketStream, TaskStatus 8 | from asphalt.core import Component, run_application, start_service_task 9 | 10 | 11 | async def handle(stream: SocketStream) -> None: 12 | message = await stream.receive() 13 | await stream.send(message) 14 | print("Message from client:", message.decode().rstrip()) 15 | 16 | 17 | async def serve_requests(*, task_status: TaskStatus[None]) -> None: 18 | async with await anyio.create_tcp_listener( 19 | local_host="localhost", local_port=64100 20 | ) as listener: 21 | task_status.started() 22 | await listener.serve(handle) 23 | 24 | 25 | class ServerComponent(Component): 26 | async def start(self) -> None: 27 | await start_service_task(serve_requests, "Echo server") 28 | 29 | 30 | if __name__ == "__main__": 31 | run_application(ServerComponent) 32 | -------------------------------------------------------------------------------- /examples/tutorial1/tests/test_client_server.py: -------------------------------------------------------------------------------- 1 | # isort: off 2 | from __future__ import annotations 3 | 4 | from collections.abc import AsyncGenerator 5 | 6 | import pytest 7 | from anyio import wait_all_tasks_blocked 8 | from pytest import CaptureFixture 9 | from asphalt.core import Context, start_component 10 | 11 | from echo.client import ClientComponent 12 | from echo.server import ServerComponent 13 | 14 | pytestmark = pytest.mark.anyio 15 | 16 | 17 | @pytest.fixture 18 | async def server(capsys: CaptureFixture[str]) -> AsyncGenerator[None, None]: 19 | async with Context(): 20 | await start_component(ServerComponent) 21 | yield 22 | 23 | 24 | async def test_client_and_server(server: None, capsys: CaptureFixture[str]) -> None: 25 | async with Context(): 26 | component = await start_component(ClientComponent, {"message": "Hello!"}) 27 | await component.run() 28 | 29 | # Grab the captured output of sys.stdout and sys.stderr from the capsys fixture 30 | await wait_all_tasks_blocked() 31 | out, err = capsys.readouterr() 32 | assert "Message from client: Hello!" in out 33 | assert "Server responded: Hello!" in out 34 | -------------------------------------------------------------------------------- /examples/tutorial2/config.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | default: 3 | component: 4 | type: webnotifier.app:ApplicationComponent 5 | components: 6 | detector: 7 | url: https://imgur.com/ 8 | delay: 15 9 | mailer: 10 | host: your.smtp.server.here 11 | username: yourusername 12 | password: yourpassword 13 | message_defaults: 14 | sender: your@email.here 15 | to: your@email.here 16 | 17 | logging: 18 | version: 1 19 | disable_existing_loggers: false 20 | formatters: 21 | default: 22 | format: '[%(asctime)s %(levelname)s] %(message)s' 23 | handlers: 24 | console: 25 | class: logging.StreamHandler 26 | formatter: default 27 | root: 28 | handlers: [console] 29 | level: INFO 30 | loggers: 31 | webnotifier: 32 | level: DEBUG 33 | -------------------------------------------------------------------------------- /examples/tutorial2/webnotifier/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asphalt-framework/asphalt/e1f4a17f03d9cc9d1400038df476bce1975f353c/examples/tutorial2/webnotifier/__init__.py -------------------------------------------------------------------------------- /examples/tutorial2/webnotifier/app.py: -------------------------------------------------------------------------------- 1 | """This is the root component for the Asphalt webnotifier tutorial.""" 2 | 3 | # isort: off 4 | from __future__ import annotations 5 | 6 | import logging 7 | from difflib import HtmlDiff 8 | 9 | from asphalt.core import CLIApplicationComponent, inject, resource 10 | from asphalt.mailer import Mailer 11 | 12 | from webnotifier.detector import ChangeDetectorComponent, Detector 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class ApplicationComponent(CLIApplicationComponent): 18 | def __init__(self) -> None: 19 | self.add_component("detector", ChangeDetectorComponent) 20 | self.add_component("mailer", backend="smtp") 21 | 22 | @inject 23 | async def run( 24 | self, 25 | *, 26 | mailer: Mailer = resource(), 27 | detector: Detector = resource(), 28 | ) -> None: 29 | diff = HtmlDiff() 30 | async with detector.changed.stream_events() as stream: 31 | async for event in stream: 32 | difference = diff.make_file( 33 | event.old_lines, event.new_lines, context=True 34 | ) 35 | await mailer.create_and_deliver( 36 | subject=f"Change detected in {event.source.url}", 37 | html_body=difference, 38 | ) 39 | logger.info("Sent notification email") 40 | -------------------------------------------------------------------------------- /examples/tutorial2/webnotifier/detector.py: -------------------------------------------------------------------------------- 1 | """This is the change detector component for the Asphalt webnotifier tutorial.""" 2 | 3 | # isort: off 4 | from __future__ import annotations 5 | 6 | import logging 7 | from dataclasses import dataclass 8 | from typing import Any 9 | 10 | import anyio 11 | import httpx 12 | from asphalt.core import ( 13 | Component, 14 | Event, 15 | Signal, 16 | add_resource, 17 | start_service_task, 18 | ) 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | @dataclass 24 | class WebPageChangeEvent(Event): 25 | old_lines: list[str] 26 | new_lines: list[str] 27 | 28 | 29 | class Detector: 30 | changed = Signal(WebPageChangeEvent) 31 | 32 | def __init__(self, url: str, delay: float): 33 | self.url = url 34 | self.delay = delay 35 | 36 | async def run(self) -> None: 37 | async with httpx.AsyncClient() as http: 38 | last_modified, old_lines = None, None 39 | while True: 40 | logger.debug("Fetching contents of %s", self.url) 41 | headers: dict[str, Any] = ( 42 | {"if-modified-since": last_modified} if last_modified else {} 43 | ) 44 | response = await http.get(self.url, headers=headers) 45 | logger.debug("Response status: %d", response.status_code) 46 | if response.status_code == 200: 47 | last_modified = response.headers["date"] 48 | new_lines = response.text.split("\n") 49 | if old_lines is not None and old_lines != new_lines: 50 | self.changed.dispatch(WebPageChangeEvent(old_lines, new_lines)) 51 | 52 | old_lines = new_lines 53 | 54 | await anyio.sleep(self.delay) 55 | 56 | 57 | class ChangeDetectorComponent(Component): 58 | def __init__(self, url: str, delay: int = 10): 59 | self.url = url 60 | self.delay = delay 61 | 62 | async def start(self) -> None: 63 | detector = Detector(self.url, self.delay) 64 | add_resource(detector) 65 | await start_service_task(detector.run, "Web page change detector") 66 | logging.info( 67 | 'Started web page change detector for url "%s" with a delay of %d seconds', 68 | self.url, 69 | self.delay, 70 | ) 71 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools >= 64", 4 | "setuptools_scm >= 6.4" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [project] 9 | name = "asphalt" 10 | description = "A microframework for network oriented applications" 11 | readme = "README.rst" 12 | authors = [{name = "Alex Grönholm", email = "alex.gronholm@nextday.fi"}] 13 | license = {text = "Apache License 2.0"} 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "Intended Audience :: Developers", 17 | "License :: OSI Approved :: Apache Software License", 18 | "Topic :: Software Development :: Libraries :: Application Frameworks", 19 | "Framework :: AnyIO", 20 | "Framework :: AsyncIO", 21 | "Framework :: Trio", 22 | "Typing :: Typed", 23 | "Programming Language :: Python", 24 | "Programming Language :: Python :: 3 :: Only", 25 | "Programming Language :: Python :: 3.9", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | "Programming Language :: Python :: 3.13", 30 | ] 31 | requires-python = ">=3.9" 32 | dependencies = [ 33 | "anyio ~= 4.1", 34 | "importlib_metadata >= 4.4; python_version < '3.10'", 35 | "typing_extensions; python_version < '3.10'", 36 | "exceptiongroup >= 1.2.0; python_version < '3.11'", 37 | "pyyaml ~= 6.0", 38 | "click >= 6.6" 39 | ] 40 | dynamic = ["version"] 41 | 42 | [project.urls] 43 | "Component projects" = "https://github.com/asphalt-framework" 44 | Documentation = "https://asphalt.readthedocs.org/en/latest/" 45 | "Help and support" = "https://github.com/asphalt-framework/asphalt/wiki/Help-and-support" 46 | "Source code" = "https://github.com/asphalt-framework/asphalt" 47 | "Issue tracker" = "https://github.com/asphalt-framework/asphalt/issues" 48 | 49 | [project.optional-dependencies] 50 | test = [ 51 | "anyio[trio] ~= 4.1", 52 | "coverage >= 7", 53 | "pytest >= 7", 54 | ] 55 | doc = [ 56 | "packaging", 57 | "Sphinx >= 7.0", 58 | "sphinx-rtd-theme >= 1.3.0", 59 | "sphinx-autodoc-typehints >= 1.22", 60 | ] 61 | 62 | [project.scripts] 63 | asphalt = "asphalt.core._cli:main" 64 | 65 | [tool.setuptools_scm] 66 | version_scheme = "post-release" 67 | local_scheme = "dirty-tag" 68 | 69 | [tool.ruff.lint] 70 | extend-select = [ 71 | "ASYNC", # flake8-async 72 | "G", # flake8-logging-format 73 | "I", # isort 74 | "ISC", # flake8-implicit-str-concat 75 | "PGH", # pygrep-hooks 76 | "RUF", # Ruff-specific rules 77 | "UP", # pyupgrade 78 | "W", # pycodestyle warnings 79 | ] 80 | ignore = [ 81 | "ASYNC109", 82 | "ASYNC115", 83 | "RUF001", 84 | ] 85 | 86 | [tool.ruff.lint.isort] 87 | known-first-party = ["asphalt.core"] 88 | 89 | [tool.pytest.ini_options] 90 | addopts = ["-rsfE", "--tb=short"] 91 | testpaths = ["tests"] 92 | 93 | [tool.mypy] 94 | python_version = "3.9" 95 | strict = true 96 | explicit_package_bases = true 97 | mypy_path = ["src", "tests", "examples/tutorial1", "examples/tutorial2"] 98 | 99 | [tool.coverage.run] 100 | source = ["asphalt.core"] 101 | relative_files = true 102 | branch = true 103 | 104 | [tool.coverage.report] 105 | show_missing = true 106 | exclude_also = [ 107 | "@overload", 108 | "if TYPE_CHECKING:" 109 | ] 110 | 111 | [tool.tox] 112 | env_list = ["py39", "py310", "py311", "py312", "py313", "pypy3"] 113 | skip_missing_interpreters = true 114 | 115 | [tool.tox.env_run_base] 116 | commands = [["python", "-m", "pytest", { replace = "posargs", extend = true }]] 117 | package = "editable" 118 | extras = ["test"] 119 | 120 | [tool.tox.env.pyright] 121 | deps = ["pyright"] 122 | commands = [["pyright", "--verifytypes", "asphalt.core"]] 123 | 124 | [tool.tox.env.docs] 125 | commands = [["sphinx-build", "-n", "docs", "build/sphinx", { replace = "posargs", extend = true }]] 126 | extras = ["doc"] 127 | -------------------------------------------------------------------------------- /src/asphalt/__main__.py: -------------------------------------------------------------------------------- 1 | from .core._cli import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /src/asphalt/core/__init__.py: -------------------------------------------------------------------------------- 1 | from ._component import CLIApplicationComponent as CLIApplicationComponent 2 | from ._component import Component as Component 3 | from ._component import start_component as start_component 4 | from ._concurrent import TaskFactory as TaskFactory 5 | from ._concurrent import TaskHandle as TaskHandle 6 | from ._context import Context as Context 7 | from ._context import ResourceEvent as ResourceEvent 8 | from ._context import add_resource as add_resource 9 | from ._context import add_resource_factory as add_resource_factory 10 | from ._context import add_teardown_callback as add_teardown_callback 11 | from ._context import context_teardown as context_teardown 12 | from ._context import current_context as current_context 13 | from ._context import get_resource as get_resource 14 | from ._context import get_resource_nowait as get_resource_nowait 15 | from ._context import get_resources as get_resources 16 | from ._context import inject as inject 17 | from ._context import resource as resource 18 | from ._context import start_background_task_factory as start_background_task_factory 19 | from ._context import start_service_task as start_service_task 20 | from ._event import Event as Event 21 | from ._event import Signal as Signal 22 | from ._event import SignalQueueFull as SignalQueueFull 23 | from ._event import stream_events as stream_events 24 | from ._event import wait_event as wait_event 25 | from ._exceptions import AsyncResourceError as AsyncResourceError 26 | from ._exceptions import ComponentStartError as ComponentStartError 27 | from ._exceptions import NoCurrentContext as NoCurrentContext 28 | from ._exceptions import ResourceConflict as ResourceConflict 29 | from ._exceptions import ResourceNotFound as ResourceNotFound 30 | from ._exceptions import UnboundSignal as UnboundSignal 31 | from ._runner import run_application as run_application 32 | from ._utils import PluginContainer as PluginContainer 33 | from ._utils import callable_name as callable_name 34 | from ._utils import merge_config as merge_config 35 | from ._utils import qualified_name as qualified_name 36 | from ._utils import resolve_reference as resolve_reference 37 | 38 | # Re-export imports so they look like they live directly in this package 39 | for __value in list(locals().values()): 40 | if getattr(__value, "__module__", "").startswith(f"{__name__}."): 41 | __value.__module__ = __name__ 42 | 43 | del __value 44 | -------------------------------------------------------------------------------- /src/asphalt/core/_cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import re 5 | from collections.abc import Mapping, Sequence 6 | from pathlib import Path 7 | from typing import Any 8 | 9 | import click 10 | import yaml 11 | from yaml import Loader, ScalarNode 12 | 13 | from ._runner import run_application 14 | from ._utils import merge_config, qualified_name 15 | 16 | 17 | def env_constructor(loader: Loader, node: ScalarNode) -> str | None: 18 | return os.getenv(node.value) 19 | 20 | 21 | def text_file_constructor(loader: Loader, node: ScalarNode) -> str: 22 | return Path(node.value).read_text() 23 | 24 | 25 | def binary_file_constructor(loader: Loader, node: ScalarNode) -> bytes: 26 | return Path(node.value).read_bytes() 27 | 28 | 29 | class AsphaltLoader(Loader): 30 | pass 31 | 32 | 33 | AsphaltLoader.add_constructor("!Env", env_constructor) 34 | AsphaltLoader.add_constructor("!TextFile", text_file_constructor) 35 | AsphaltLoader.add_constructor("!BinaryFile", binary_file_constructor) 36 | 37 | 38 | @click.group() 39 | def main() -> None: 40 | pass # pragma: no cover 41 | 42 | 43 | @main.command( 44 | help=( 45 | "Read configuration files, pass configuration options, and start the " 46 | "application." 47 | ) 48 | ) 49 | @click.argument("configfile", type=click.File(), nargs=-1) 50 | @click.option( 51 | "-s", 52 | "--service", 53 | type=str, 54 | help="service to run (if the configuration file contains multiple services)", 55 | ) 56 | @click.option( 57 | "--set", 58 | "set_", 59 | multiple=True, 60 | type=str, 61 | help="set configuration", 62 | ) 63 | def run(configfile: Sequence[str], service: str | None, set_: list[str]) -> None: 64 | # Read the configuration from the supplied YAML files 65 | config: dict[str, Any] = {} 66 | for path in configfile: 67 | config_data = yaml.load(path, AsphaltLoader) 68 | assert isinstance( 69 | config_data, dict 70 | ), "the document root element must be a dictionary" 71 | config = merge_config(config, config_data) 72 | 73 | # Override config options 74 | for override in set_: 75 | if "=" not in override: 76 | raise click.ClickException( 77 | f"Configuration must be set with '=', got: {override}" 78 | ) 79 | 80 | key, value = override.split("=", 1) 81 | parsed_value = yaml.load(value, AsphaltLoader) 82 | keys = [k.replace(r"\.", ".") for k in re.split(r"(?= (3, 10): 21 | from typing import TypeAlias 22 | else: 23 | from typing_extensions import TypeAlias 24 | 25 | if TYPE_CHECKING: 26 | from ._context import Context 27 | 28 | T_Retval = TypeVar("T_Retval") 29 | ExceptionHandler: TypeAlias = Callable[[Exception], bool] 30 | TeardownAction: TypeAlias = Union[Callable[[], Any], Literal["cancel"], None] 31 | 32 | logger = logging.getLogger("asphalt.core") 33 | 34 | 35 | @dataclass(eq=False) 36 | class TaskHandle: 37 | """ 38 | A representation of a task started from :class:`TaskFactory`. 39 | 40 | :ivar name: the name of the task 41 | :ivar start_value: the start value passed to ``task_status.started()`` if the target 42 | function supported that 43 | """ 44 | 45 | name: str 46 | start_value: Any = field(init=False, repr=False) 47 | _cancel_scope: CancelScope = field( 48 | init=False, default_factory=CancelScope, repr=False 49 | ) 50 | _finished_event: Event = field(init=False, default_factory=Event, repr=False) 51 | 52 | def cancel(self) -> None: 53 | """Schedule the task to be cancelled.""" 54 | self._cancel_scope.cancel() 55 | 56 | async def wait_finished(self) -> None: 57 | """Wait until the task is finished.""" 58 | await self._finished_event.wait() 59 | 60 | 61 | async def run_background_task( 62 | func: Callable[..., Coroutine[Any, Any, Any]], 63 | ctx: Context, 64 | task_handle: TaskHandle, 65 | exception_handler: ExceptionHandler | None = None, 66 | *, 67 | task_status: TaskStatus[Any] = TASK_STATUS_IGNORED, 68 | ) -> None: 69 | __tracebackhide__ = True # trick supported by certain debugger frameworks 70 | 71 | from ._context import Context 72 | 73 | # Check if the function has a parameter named "task_status" 74 | has_task_status = any( 75 | param.name == "task_status" and param.kind != Parameter.POSITIONAL_ONLY 76 | for param in signature(func).parameters.values() 77 | ) 78 | logger.debug("Background task (%s) starting", task_handle.name) 79 | 80 | try: 81 | with task_handle._cancel_scope: 82 | async with Context(ctx): 83 | if has_task_status: 84 | await func(task_status=task_status) 85 | else: 86 | task_status.started() 87 | await func() 88 | except Exception as exc: 89 | logger.exception("Background task (%s) crashed", task_handle.name) 90 | if exception_handler is not None and exception_handler(exc): 91 | return 92 | 93 | raise 94 | else: 95 | logger.debug("Background task (%s) finished successfully", task_handle.name) 96 | finally: 97 | task_handle._finished_event.set() 98 | 99 | 100 | @dataclass 101 | class TaskFactory: 102 | exception_handler: ExceptionHandler | None = None 103 | _finished_event: Event = field(init=False, default_factory=Event) 104 | _task_group: TaskGroup = field(init=False) 105 | _tasks: set[TaskHandle] = field(init=False, default_factory=set) 106 | _ctx: Context = field(init=False) 107 | 108 | def all_task_handles(self) -> set[TaskHandle]: 109 | """ 110 | Return task handles for all the tasks currently running on the factory's task 111 | group. 112 | 113 | """ 114 | return self._tasks.copy() 115 | 116 | async def start_task( 117 | self, 118 | func: Callable[..., Coroutine[Any, Any, T_Retval]], 119 | name: str | None = None, 120 | ) -> TaskHandle: 121 | """ 122 | Start a background task in the factory's task group. 123 | 124 | The task runs in its own context, inherited from the root context. 125 | If the task raises an exception (inherited from :exc:`Exception`), it is logged 126 | with a descriptive message containing the task's name. 127 | 128 | To pass arguments to the target callable, pass them via lambda (e.g. 129 | ``lambda: yourfunc(arg1, arg2, kw=val)``) 130 | 131 | If ``func`` takes an argument named ``task_status``, then this method will only 132 | return when the function has called ``task_status.started()``. See 133 | :meth:`anyio.abc.TaskGroup.start` for details. The value passed to 134 | ``task_status.started()`` will be available as the ``start_value`` property on 135 | the :class:`TaskHandle`. 136 | 137 | :param func: the coroutine function to run 138 | :param name: descriptive name for the task 139 | :return: a task handle that can be used to await on the result or cancel the 140 | task 141 | 142 | """ 143 | task_handle = TaskHandle(name=name or callable_name(func)) 144 | self._tasks.add(task_handle) 145 | task_handle.start_value = await self._task_group.start( 146 | self._run_background_task, 147 | func, 148 | task_handle, 149 | self.exception_handler, 150 | name=task_handle.name, 151 | ) 152 | return task_handle 153 | 154 | def start_task_soon( 155 | self, 156 | func: Callable[..., Coroutine[Any, Any, T_Retval]], 157 | name: str | None = None, 158 | ) -> TaskHandle: 159 | """ 160 | Start a background task in the factory's task group. 161 | 162 | This is similar to :meth:`start_task`, but works from synchronous callbacks and 163 | doesn't support :class:`~anyio.abc.TaskStatus`. 164 | 165 | :param func: the coroutine function to run 166 | :param name: descriptive name for the task 167 | :return: a task handle that can be used to await on the result or cancel the 168 | task 169 | 170 | """ 171 | task_handle = TaskHandle(name=name or callable_name(func)) 172 | self._tasks.add(task_handle) 173 | self._task_group.start_soon( 174 | self._run_background_task, 175 | func, 176 | task_handle, 177 | self.exception_handler, 178 | name=task_handle.name, 179 | ) 180 | return task_handle 181 | 182 | async def _run_background_task( 183 | self, 184 | func: Callable[..., Coroutine[Any, Any, Any]], 185 | task_handle: TaskHandle, 186 | exception_handler: ExceptionHandler | None = None, 187 | *, 188 | task_status: TaskStatus[Any] = TASK_STATUS_IGNORED, 189 | ) -> None: 190 | __tracebackhide__ = True # trick supported by certain debugger frameworks 191 | try: 192 | await run_background_task( 193 | func, self._ctx, task_handle, exception_handler, task_status=task_status 194 | ) 195 | finally: 196 | self._tasks.remove(task_handle) 197 | 198 | async def _run(self, *, task_status: TaskStatus[None]) -> None: 199 | from ._context import current_context 200 | 201 | self._ctx = current_context() 202 | async with create_task_group() as self._task_group: 203 | task_status.started() 204 | await self._finished_event.wait() 205 | -------------------------------------------------------------------------------- /src/asphalt/core/_event.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import weakref 4 | from collections.abc import ( 5 | AsyncGenerator, 6 | AsyncIterator, 7 | Callable, 8 | Generator, 9 | Hashable, 10 | Sequence, 11 | ) 12 | from contextlib import ( 13 | AbstractAsyncContextManager, 14 | AsyncExitStack, 15 | asynccontextmanager, 16 | contextmanager, 17 | ) 18 | from dataclasses import dataclass, field 19 | from datetime import datetime, timezone 20 | from time import time as stdlib_time 21 | from typing import Any, Generic, TypeVar 22 | from warnings import warn 23 | from weakref import ReferenceType, WeakKeyDictionary 24 | 25 | from anyio import BrokenResourceError, WouldBlock, create_memory_object_stream 26 | from anyio.streams.memory import MemoryObjectSendStream 27 | 28 | from ._exceptions import UnboundSignal 29 | from ._utils import qualified_name 30 | 31 | T_Event = TypeVar("T_Event", bound="Event") 32 | bound_signals = WeakKeyDictionary[Hashable, "Signal[Any]"]() 33 | 34 | 35 | class SignalQueueFull(UserWarning): 36 | """ 37 | Warning about signal delivery failing due to a subscriber's queue being full 38 | because the subscriber could not receive the events quickly enough. 39 | """ 40 | 41 | 42 | class Event: 43 | """ 44 | The base class for all events. 45 | 46 | :ivar source: the object where this event originated from 47 | :ivar str topic: the topic 48 | :ivar float time: event creation time as seconds from the UNIX epoch 49 | """ 50 | 51 | __slots__ = "source", "time", "topic" 52 | 53 | source: Any 54 | topic: str 55 | time: float 56 | 57 | @property 58 | def utc_timestamp(self) -> datetime: 59 | """ 60 | Return a timezone aware :class:`~datetime.datetime` corresponding to the 61 | ``time`` variable, using the UTC timezone. 62 | 63 | """ 64 | return datetime.fromtimestamp(self.time, timezone.utc) 65 | 66 | def __repr__(self) -> str: 67 | return ( 68 | f"{self.__class__.__name__}(source={self.source!r}, " 69 | f"topic={self.topic!r})" 70 | ) 71 | 72 | 73 | @dataclass 74 | class Signal(Generic[T_Event]): 75 | """ 76 | Declaration of a signal that can be used to dispatch events. 77 | 78 | This is a descriptor that returns itself on class level attribute access and a bound 79 | version of itself on instance level access. Dispatching and streaming 80 | events only works with these bound instances. 81 | 82 | Each signal must be assigned to a class attribute, but only once. The Signal will 83 | not function correctly if the same Signal instance is assigned to multiple 84 | attributes. 85 | 86 | :param event_class: an event class 87 | """ 88 | 89 | event_class: type[T_Event] 90 | 91 | _instance: ReferenceType[Hashable] = field(init=False) 92 | _topic: str = field(init=False) 93 | _send_streams: list[MemoryObjectSendStream[T_Event]] = field(init=False) 94 | 95 | def __get__(self, instance: Hashable, owner: Any) -> Signal[T_Event]: 96 | if instance is None: 97 | return self 98 | 99 | try: 100 | return bound_signals[instance] 101 | except KeyError: 102 | bound_signal = Signal(self.event_class) 103 | bound_signal._topic = self._topic 104 | bound_signal._instance = weakref.ref(instance) 105 | bound_signal._send_streams = [] 106 | bound_signals[instance] = bound_signal 107 | return bound_signal 108 | 109 | def __set_name__(self, owner: Any, name: str) -> None: 110 | self._topic = name 111 | 112 | def _check_is_bound_signal(self) -> None: 113 | if not hasattr(self, "_instance"): 114 | raise UnboundSignal 115 | 116 | @contextmanager 117 | def _subscribe(self, send: MemoryObjectSendStream[T_Event]) -> Generator[None]: 118 | self._check_is_bound_signal() 119 | self._send_streams.append(send) 120 | try: 121 | yield 122 | finally: 123 | self._send_streams.remove(send) 124 | 125 | def dispatch(self, event: T_Event) -> None: 126 | """ 127 | Dispatch an event. 128 | 129 | :raises UnboundSignal: if attempting to dispatch an event on a signal not bound 130 | to any instance of the containing class 131 | 132 | """ 133 | self._check_is_bound_signal() 134 | if not isinstance(event, self.event_class): 135 | raise TypeError( 136 | f"Event type mismatch: event ({qualified_name(event)}) is not a " 137 | f"subclass of {qualified_name(self.event_class)}" 138 | ) 139 | 140 | event.source = self._instance() 141 | event.topic = self._topic 142 | event.time = stdlib_time() 143 | 144 | for stream in list(self._send_streams): 145 | try: 146 | stream.send_nowait(event) 147 | except BrokenResourceError: 148 | pass 149 | except WouldBlock: 150 | warn( 151 | f"Queue full ({stream.statistics().max_buffer_size}) when trying " 152 | f"to send dispatched event to subscriber", 153 | SignalQueueFull, 154 | stacklevel=2, 155 | ) 156 | 157 | async def wait_event( 158 | self, 159 | filter: Callable[[T_Event], bool] | None = None, 160 | ) -> T_Event: 161 | """ 162 | Shortcut for calling :func:`wait_event` with this signal in the first argument. 163 | 164 | """ 165 | return await wait_event([self], filter) 166 | 167 | def stream_events( 168 | self, 169 | filter: Callable[[T_Event], bool] | None = None, 170 | *, 171 | max_queue_size: int = 50, 172 | ) -> AbstractAsyncContextManager[AsyncIterator[T_Event]]: 173 | """ 174 | Shortcut for calling :func:`stream_events` with this signal in the first 175 | argument. 176 | 177 | """ 178 | return stream_events([self], filter, max_queue_size=max_queue_size) 179 | 180 | 181 | @asynccontextmanager 182 | async def stream_events( 183 | signals: Sequence[Signal[T_Event]], 184 | filter: Callable[[T_Event], bool] | None = None, 185 | *, 186 | max_queue_size: int = 50, 187 | ) -> AsyncIterator[AsyncIterator[T_Event]]: 188 | """ 189 | Return an async generator that yields events from the given signals. 190 | 191 | Only events that pass the filter callable (if one has been given) are returned. 192 | If no filter function was given, all events are yielded from the generator. 193 | 194 | If another event is received from any of the signals while the previous one is still 195 | being yielded, it will stay in the queue. If the queue fills up, then 196 | :meth:`~.Signal.dispatch` on all the signals will block until the yield has been 197 | processed. 198 | 199 | The listening of events from the given signals starts when this function is called. 200 | 201 | :param signals: the signals to get events from 202 | :param filter: a callable that takes an event object as an argument and returns a 203 | truthy value if the event should pass 204 | :param max_queue_size: maximum number of unprocessed events in the queue 205 | :return: an async generator yielding all events (that pass the filter, if any) from 206 | all the given signals 207 | :raises UnboundSignal: if attempting to listen to events on a signal not bound to 208 | any instance of the containing class 209 | 210 | """ 211 | 212 | async def filter_events() -> AsyncGenerator[T_Event, None]: 213 | async for event in receive: 214 | if filter is None or filter(event): 215 | yield event 216 | 217 | send, receive = create_memory_object_stream[T_Event](max_queue_size) 218 | async with AsyncExitStack() as exit_stack: 219 | filtered_receive = filter_events() 220 | exit_stack.push_async_callback(filtered_receive.aclose) 221 | exit_stack.enter_context(send) 222 | exit_stack.enter_context(receive) 223 | for signal in signals: 224 | exit_stack.enter_context(signal._subscribe(send)) 225 | 226 | yield filtered_receive 227 | 228 | 229 | async def wait_event( 230 | signals: Sequence[Signal[T_Event]], 231 | filter: Callable[[T_Event], bool] | None = None, 232 | ) -> T_Event: 233 | """ 234 | Wait until any of the given signals dispatches an event that satisfies the filter 235 | (if any). 236 | 237 | If no filter has been given, the first event dispatched from any of the signals is 238 | returned. 239 | 240 | The listening of events from the given signals starts when this function is called. 241 | 242 | :param signals: the signals to get events from 243 | :param filter: a callable that takes an event object as an argument and returns a 244 | truthy value if the event should pass 245 | :return: the first event (that passed the filter, if any) that was dispatched from 246 | any of the signals 247 | :raises UnboundSignal: if attempting to listen to events on a signal not bound to 248 | any instance of the containing class 249 | 250 | """ 251 | async with stream_events(signals, filter) as stream: 252 | return await stream.__anext__() 253 | -------------------------------------------------------------------------------- /src/asphalt/core/_exceptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Literal 4 | 5 | from ._utils import format_component_name, qualified_name 6 | 7 | if TYPE_CHECKING: 8 | from ._component import Component 9 | 10 | 11 | class AsyncResourceError(Exception): 12 | """ 13 | Raised when :meth:`Context.get_resource_nowait` received a coroutine object from a 14 | resource factory (the factory was asynchronous). 15 | """ 16 | 17 | 18 | class ComponentStartError(Exception): 19 | """ 20 | Raised by :func:`start_component` when there's an error instantiating or starting a 21 | component. 22 | 23 | .. note:: The underlying exception can be retrieved from the ``__cause__`` 24 | attribute. 25 | 26 | :ivar Literal["creating", "preparing", "starting"] phase: the phase of the component 27 | initialization in which the error occurred 28 | :ivar str path: the path of the component in the configuration (an empty string if 29 | this is the root component) 30 | :ivar type[Component] component_type: the component class 31 | """ 32 | 33 | def __init__( 34 | self, 35 | phase: Literal["creating", "preparing", "starting"], 36 | path: str, 37 | component_type: type[Component], 38 | ): 39 | super().__init__(phase, path, component_type) 40 | self.phase = phase 41 | self.path = path 42 | self.component_type = component_type 43 | 44 | def __str__(self) -> str: 45 | exc_msg = qualified_name(self.__cause__) 46 | if exc_str := str(self.__cause__): 47 | exc_msg += f": {exc_str}" 48 | 49 | formatted_name = format_component_name(self.path, self.component_type) 50 | return f"error {self.phase} {formatted_name}: {exc_msg}" 51 | 52 | 53 | class NoCurrentContext(Exception): 54 | """Raised by :func: `current_context` when there is no active context.""" 55 | 56 | def __init__(self) -> None: 57 | super().__init__("there is no active context") 58 | 59 | 60 | class ResourceConflict(Exception): 61 | """ 62 | Raised when a new resource that is being published conflicts with an existing 63 | resource or context variable. 64 | """ 65 | 66 | 67 | class ResourceNotFound(LookupError): 68 | """Raised when a resource request cannot be fulfilled within the allotted time.""" 69 | 70 | def __init__(self, type: type, name: str) -> None: 71 | super().__init__(type, name) 72 | self.type = type 73 | self.name = name 74 | 75 | def __str__(self) -> str: 76 | return ( 77 | f"no matching resource was found for type={qualified_name(self.type)} " 78 | f"name={self.name!r}" 79 | ) 80 | 81 | 82 | class UnboundSignal(Exception): 83 | """ 84 | Raised when attempting to dispatch or listen to events on a :class:`Signal` on 85 | the class level, rather than on an instance of the class. 86 | """ 87 | 88 | def __init__(self) -> None: 89 | super().__init__("attempted to use a signal that is not bound to an instance") 90 | -------------------------------------------------------------------------------- /src/asphalt/core/_runner.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import platform 4 | import signal 5 | import sys 6 | from functools import partial 7 | from logging import INFO, basicConfig, getLogger 8 | from logging.config import dictConfig 9 | from typing import Any 10 | from warnings import warn 11 | 12 | import anyio 13 | from anyio import ( 14 | CancelScope, 15 | Event, 16 | get_cancelled_exc_class, 17 | to_thread, 18 | ) 19 | from anyio.abc import TaskStatus 20 | 21 | from ._component import ( 22 | CLIApplicationComponent, 23 | Component, 24 | start_component, 25 | ) 26 | from ._context import Context, start_service_task 27 | from ._utils import qualified_name 28 | 29 | logger = getLogger("asphalt.core") 30 | 31 | 32 | async def handle_signals( 33 | startup_scope: CancelScope, event: Event, *, task_status: TaskStatus[None] 34 | ) -> None: 35 | with anyio.open_signal_receiver(signal.SIGTERM, signal.SIGINT) as signals: 36 | task_status.started() 37 | async for signum in signals: 38 | signal_name = signal.strsignal(signum) or "" 39 | logger.info( 40 | "Received signal (%s) – terminating application", 41 | signal_name.split(":", 1)[0], # macOS has ": " after the name 42 | ) 43 | startup_scope.cancel() 44 | event.set() 45 | break 46 | 47 | 48 | async def _run_application_async( 49 | component_class: type[Component] | str, 50 | config: dict[str, Any] | None, 51 | max_threads: int | None, 52 | start_timeout: float | None, 53 | ) -> int: 54 | # Apply the maximum worker thread limit 55 | if max_threads is not None: 56 | to_thread.current_default_thread_limiter().total_tokens = max_threads 57 | 58 | logger.info("Starting application") 59 | try: 60 | event = Event() 61 | async with Context(): 62 | with CancelScope() as startup_scope: 63 | if platform.system() != "Windows": 64 | await start_service_task( 65 | partial(handle_signals, startup_scope, event), 66 | "Asphalt signal handler", 67 | ) 68 | 69 | try: 70 | component = await start_component( 71 | component_class, config, timeout=start_timeout 72 | ) 73 | except (get_cancelled_exc_class(), TimeoutError): 74 | # This happens when a signal handler cancels the startup or 75 | # start_component() times out 76 | return 1 77 | except BaseException: 78 | logger.exception("Error during application startup") 79 | return 1 80 | 81 | logger.info("Application started") 82 | 83 | if isinstance(component, CLIApplicationComponent): 84 | exit_code = await component.run() 85 | if isinstance(exit_code, int): 86 | if 0 <= exit_code <= 127: 87 | return exit_code 88 | else: 89 | warn(f"exit code out of range: {exit_code}") 90 | return 1 91 | elif exit_code is not None: 92 | warn( 93 | f"run() must return an integer or None, not " 94 | f"{qualified_name(exit_code.__class__)}" 95 | ) 96 | return 1 97 | else: 98 | await event.wait() 99 | 100 | return 0 101 | finally: 102 | logger.info("Application stopped") 103 | 104 | 105 | def run_application( 106 | component_class: type[Component] | str, 107 | config: dict[str, Any] | None = None, 108 | *, 109 | backend: str = "asyncio", 110 | backend_options: dict[str, Any] | None = None, 111 | max_threads: int | None = None, 112 | logging: dict[str, Any] | int | None = INFO, 113 | start_timeout: int | float | None = 10, 114 | ) -> None: 115 | """ 116 | Configure logging and start the given component. 117 | 118 | Assuming the root component was started successfully, the event loop will continue 119 | running until the process is terminated. 120 | 121 | Initializes the logging system first based on the value of ``logging``: 122 | * If the value is a dictionary, it is passed to :func:`logging.config.dictConfig` 123 | as argument. 124 | * If the value is an integer, it is passed to :func:`logging.basicConfig` as the 125 | logging level. 126 | * If the value is ``None``, logging setup is skipped entirely. 127 | 128 | By default, the logging system is initialized using :func:`~logging.basicConfig` 129 | using the ``INFO`` logging level. 130 | 131 | The default executor in the event loop is replaced with a new 132 | :class:`~concurrent.futures.ThreadPoolExecutor` where the maximum number of threads 133 | is set to the value of ``max_threads`` or, if omitted, the default value of 134 | :class:`~concurrent.futures.ThreadPoolExecutor`. 135 | 136 | :param component_class: the root component class, an entry point name in the 137 | ``asphalt.components`` namespace or a ``modulename:varname`` reference 138 | :param config: configuration options for the root component 139 | :param backend: name of the AnyIO backend (e.g. ``asyncio`` or ``trio``) 140 | :param backend_options: options to pass to the AnyIO backend (see the 141 | `AnyIO documentation`_ for reference) 142 | :param max_threads: the maximum number of worker threads in the default thread pool 143 | executor (the default value depends on the event loop implementation) 144 | :param logging: a logging configuration dictionary, :ref:`logging level 145 | ` or`None`` 146 | :param start_timeout: seconds to wait for the root component (and its subcomponents) 147 | to start up before giving up (``None`` = wait forever) 148 | :raises SystemExit: if the root component fails to start, or an exception is raised 149 | when the application is running 150 | 151 | .. _AnyIO documentation: https://anyio.readthedocs.io/en/stable/basics.html\ 152 | #backend-specific-options 153 | 154 | """ 155 | # Configure the logging system 156 | if isinstance(logging, dict): 157 | dictConfig(logging) 158 | elif isinstance(logging, int): 159 | basicConfig(level=logging) 160 | 161 | # Inform the user whether -O or PYTHONOPTIMIZE was set when Python was launched 162 | logger.info("Running in %s mode", "development" if __debug__ else "production") 163 | 164 | if exit_code := anyio.run( 165 | _run_application_async, 166 | component_class, 167 | config, 168 | max_threads, 169 | start_timeout, 170 | backend=backend, 171 | backend_options=backend_options, 172 | ): 173 | sys.exit(exit_code) 174 | -------------------------------------------------------------------------------- /src/asphalt/core/_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from collections.abc import AsyncIterator, Callable, Mapping 5 | from contextlib import asynccontextmanager 6 | from functools import partial 7 | from importlib import import_module 8 | from inspect import isclass 9 | from typing import TYPE_CHECKING, Any, TypeVar, overload 10 | 11 | if sys.version_info >= (3, 10): 12 | from importlib.metadata import entry_points 13 | else: 14 | from importlib_metadata import entry_points 15 | 16 | if sys.version_info < (3, 11): 17 | from exceptiongroup import ExceptionGroup 18 | 19 | if TYPE_CHECKING: 20 | from ._component import Component 21 | 22 | T_Object = TypeVar("T_Object") 23 | 24 | 25 | def resolve_reference(ref: object) -> Any: 26 | """ 27 | Return the object pointed to by ``ref``. 28 | If ``ref`` is not a string or does not contain ``:``, it is returned as is. 29 | References must be in the form : where is the 30 | fully qualified module name and varname is the path to the variable inside that 31 | module. For example, "concurrent.futures:Future" would give you the 32 | :class:`~concurrent.futures.Future` class. 33 | 34 | :raises LookupError: if the reference could not be resolved 35 | 36 | """ 37 | if not isinstance(ref, str) or ":" not in ref: 38 | return ref 39 | 40 | modulename, rest = ref.split(":", 1) 41 | try: 42 | obj = import_module(modulename) 43 | except ImportError as e: 44 | raise LookupError( 45 | f"error resolving reference {ref}: could not import module" 46 | ) from e 47 | 48 | try: 49 | for name in rest.split("."): 50 | obj = getattr(obj, name) 51 | 52 | return obj 53 | except AttributeError: 54 | raise LookupError(f"error resolving reference {ref}: error looking up object") 55 | 56 | 57 | def qualified_name(obj: object) -> str: 58 | """ 59 | Return the qualified name (e.g. package.module.Type) for the given object. 60 | 61 | If ``obj`` is not a class, the returned name will match its type instead. 62 | 63 | """ 64 | cls = obj if isclass(obj) else type(obj) 65 | if cls.__module__ == "builtins": 66 | return cls.__name__ 67 | else: 68 | return f"{cls.__module__}.{cls.__qualname__}" 69 | 70 | 71 | def callable_name(func: Callable[..., Any]) -> str: 72 | """Return the qualified name (e.g. package.module.func) for the given callable.""" 73 | if isinstance(func, partial): 74 | func = func.func 75 | 76 | if func.__module__ == "builtins": 77 | return func.__name__ 78 | else: 79 | return f"{func.__module__}.{func.__qualname__}" 80 | 81 | 82 | def merge_config( 83 | original: Mapping[str, Any] | None, overrides: Mapping[str, Any] | None 84 | ) -> dict[str, Any]: 85 | """ 86 | Return a copy of the ``original`` configuration dictionary, with overrides from 87 | ``overrides`` applied. 88 | 89 | This similar to what :meth:`dict.update` does, but when a dictionary is about to be 90 | replaced with another dictionary, it instead merges the contents. 91 | 92 | :param original: a configuration dictionary (or ``None``) 93 | :param overrides: a dictionary containing overriding values to the configuration 94 | (or ``None``) 95 | :return: the merge result 96 | 97 | .. versionchanged:: 5.0 98 | Previously, if a key in ``overrides`` was a dotted path (ie. 99 | ``foo.bar.baz: value``), it was assumed to be a shorthand for 100 | ``foo: {bar: {baz: value}}``. In v5.0, this feature was removed, as it turned 101 | out to be a problem with logging configuration, as it was not possible to 102 | configure any logging that had a dot in its name (as is the case with most 103 | loggers). 104 | 105 | """ 106 | copied = dict(original) if original else {} 107 | if overrides: 108 | for key, value in overrides.items(): 109 | orig_value = copied.get(key) 110 | if isinstance(orig_value, dict) and isinstance(value, dict): 111 | copied[key] = merge_config(orig_value, value) 112 | else: 113 | copied[key] = value 114 | 115 | return copied 116 | 117 | 118 | @asynccontextmanager 119 | async def coalesce_exceptions() -> AsyncIterator[None]: 120 | try: 121 | yield 122 | except ExceptionGroup as excgrp: 123 | if len(excgrp.exceptions) == 1 and not isinstance( 124 | excgrp.exceptions[0], ExceptionGroup 125 | ): 126 | raise excgrp.exceptions[0] from excgrp.exceptions[0].__cause__ 127 | 128 | raise 129 | 130 | 131 | def format_component_name( 132 | path: str, 133 | component_class: type[Component] | None = None, 134 | *, 135 | capitalize: bool = False, 136 | ) -> str: 137 | formatted = f"component {path!r}" if path else "the root component" 138 | if component_class is not None: 139 | formatted += f" ({qualified_name(component_class)})" 140 | 141 | if capitalize: 142 | formatted = formatted[0].upper() + formatted[1:] 143 | 144 | return formatted 145 | 146 | 147 | class PluginContainer: 148 | """ 149 | A convenience class for loading and instantiating plugins through the use of entry 150 | points. 151 | 152 | :param namespace: a setuptools entry points namespace 153 | :param base_class: the base class for plugins of this type (or ``None`` if the 154 | entry points don't point to classes) 155 | """ 156 | 157 | __slots__ = "_entrypoints", "_resolved", "base_class", "namespace" 158 | 159 | def __init__(self, namespace: str, base_class: type | None = None) -> None: 160 | self.namespace: str = namespace 161 | self.base_class: type | None = base_class 162 | group = entry_points(group=namespace) 163 | self._entrypoints = {ep.name: ep for ep in group} 164 | self._resolved: dict[str, Any] = {} 165 | 166 | @overload 167 | def resolve(self, obj: str) -> Any: 168 | pass 169 | 170 | @overload 171 | def resolve(self, obj: T_Object) -> T_Object: 172 | pass 173 | 174 | def resolve(self, obj: Any) -> Any: 175 | """ 176 | Resolve a reference to an entry point. 177 | 178 | If ``obj`` is a string, the named entry point is loaded from this container's 179 | namespace. Otherwise, ``obj`` is returned as is. 180 | 181 | :param obj: an entry point identifier, an object reference or an arbitrary 182 | object 183 | :return: the loaded entry point, resolved object or the unchanged input value 184 | :raises LookupError: if ``obj`` was a string but the named entry point was not 185 | found 186 | 187 | """ 188 | if not isinstance(obj, str): 189 | return obj 190 | elif ":" in obj: 191 | return resolve_reference(obj) 192 | elif obj in self._resolved: 193 | return self._resolved[obj] 194 | 195 | value = self._entrypoints.get(obj) 196 | if value is None: 197 | raise LookupError(f"no such entry point in {self.namespace}: {obj}") 198 | 199 | value = self._resolved[obj] = value.load() 200 | return value 201 | 202 | def create_object(self, type: type | str, **constructor_kwargs: Any) -> Any: 203 | """ 204 | Instantiate a plugin. 205 | 206 | The entry points in this namespace must point to subclasses of the 207 | ``base_class`` parameter passed to this container. 208 | 209 | :param type: an entry point identifier or an actual class object 210 | :param constructor_kwargs: keyword arguments passed to the constructor of the 211 | plugin class 212 | :return: the plugin instance 213 | 214 | """ 215 | assert self.base_class, "base class has not been defined" 216 | plugin_class = self.resolve(type) 217 | if not isclass(plugin_class) or not issubclass(plugin_class, self.base_class): 218 | raise TypeError( 219 | f"{qualified_name(plugin_class)} is not a subclass of " 220 | f"{qualified_name(self.base_class)}" 221 | ) 222 | 223 | return plugin_class(**constructor_kwargs) 224 | 225 | @property 226 | def names(self) -> list[str]: 227 | """Return names of all entry points in this namespace.""" 228 | return list(self._entrypoints) 229 | 230 | def all(self) -> list[Any]: 231 | """ 232 | Load all entry points (if not already loaded) in this namespace and return the 233 | resulting objects as a list. 234 | 235 | """ 236 | values = [] 237 | for name, ep in self._entrypoints.items(): 238 | if name in self._resolved: 239 | value = self._resolved[name] 240 | else: 241 | value = self._resolved[name] = ep.load() 242 | 243 | values.append(value) 244 | 245 | return values 246 | 247 | def __repr__(self) -> str: 248 | return ( 249 | f"{self.__class__.__name__}(namespace={self.namespace!r}, " 250 | f"base_class={qualified_name(self.base_class)})" 251 | ) 252 | -------------------------------------------------------------------------------- /src/asphalt/core/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asphalt-framework/asphalt/e1f4a17f03d9cc9d1400038df476bce1975f353c/src/asphalt/core/py.typed -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from collections.abc import Generator 5 | from contextlib import contextmanager 6 | from types import TracebackType 7 | from typing import Any, cast 8 | 9 | import pytest 10 | 11 | if sys.version_info < (3, 11): 12 | from exceptiongroup import BaseExceptionGroup 13 | 14 | 15 | @contextmanager 16 | def raises_in_exception_group( 17 | exc_type: type[BaseException], match: str | None = None 18 | ) -> Generator[Any, None, None]: 19 | with pytest.raises(BaseExceptionGroup) as exc_match: 20 | excinfo: pytest.ExceptionInfo[Any] = pytest.ExceptionInfo.for_later() 21 | yield excinfo 22 | 23 | if exc_match: 24 | exc: BaseException = exc_match.value 25 | while isinstance(exc, BaseExceptionGroup) and len(exc.exceptions) == 1: 26 | exc = exc.exceptions[0] 27 | 28 | with pytest.raises(exc_type, match=match): 29 | raise exc 30 | 31 | excinfo.fill_unfilled((type(exc), exc, cast(TracebackType, exc.__traceback__))) 32 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from pathlib import Path 5 | from typing import Any 6 | from unittest.mock import patch 7 | 8 | import pytest 9 | from click.testing import CliRunner 10 | from pytest import MonkeyPatch 11 | 12 | from asphalt.core import CLIApplicationComponent, _cli 13 | 14 | pytestmark = pytest.mark.anyio() 15 | 16 | 17 | class DummyComponent(CLIApplicationComponent): 18 | def __init__(self, **kwargs: Any) -> None: 19 | self.kwargs = kwargs 20 | 21 | async def run(self) -> None: 22 | def convert_binary(val: Any) -> Any: 23 | if isinstance(val, bytes): 24 | return repr(val) 25 | 26 | return val 27 | 28 | print(json.dumps(self.kwargs, default=convert_binary)) 29 | 30 | 31 | @pytest.fixture 32 | def runner() -> CliRunner: 33 | return CliRunner() 34 | 35 | 36 | def test_run( 37 | runner: CliRunner, 38 | anyio_backend_name: str, 39 | monkeypatch: MonkeyPatch, 40 | tmp_path: Path, 41 | ) -> None: 42 | monkeypatch.setenv("MYENVVAR", "from environment") 43 | tmp_path = tmp_path.joinpath("tmpfile") 44 | tmp_path.write_text("Hello, World!") 45 | 46 | config = f"""\ 47 | --- 48 | backend: {anyio_backend_name} 49 | component: 50 | type: !!python/name:{DummyComponent.__module__}.{DummyComponent.__name__} 51 | dummyval1: testval 52 | envval: !Env MYENVVAR 53 | textfileval: !TextFile {tmp_path} 54 | binaryfileval: !BinaryFile {tmp_path} 55 | logging: 56 | version: 1 57 | disable_existing_loggers: false 58 | """ 59 | with runner.isolated_filesystem(): 60 | Path("test.yml").write_text(config) 61 | result = runner.invoke(_cli.run, ["test.yml"]) 62 | 63 | assert result.exit_code == 0 64 | kwargs = json.loads(result.stdout) 65 | assert kwargs == { 66 | "dummyval1": "testval", 67 | "envval": "from environment", 68 | "textfileval": "Hello, World!", 69 | "binaryfileval": "b'Hello, World!'", 70 | } 71 | 72 | 73 | def test_run_bad_override(runner: CliRunner) -> None: 74 | config = """\ 75 | component: 76 | type: does.not.exist:Component 77 | """ 78 | with runner.isolated_filesystem(): 79 | Path("test.yml").write_text(config) 80 | result = runner.invoke(_cli.run, ["test.yml", "--set", "foobar"]) 81 | assert result.exit_code == 1 82 | assert result.stdout == ( 83 | "Error: Configuration must be set with '=', got: foobar\n" 84 | ) 85 | 86 | 87 | def test_run_missing_root_component_config(runner: CliRunner) -> None: 88 | config = """\ 89 | services: 90 | default: 91 | """ 92 | with runner.isolated_filesystem(): 93 | Path("test.yml").write_text(config) 94 | result = runner.invoke(_cli.run, ["test.yml"]) 95 | assert result.exit_code == 1 96 | assert result.stdout == ( 97 | "Error: Service configuration is missing the 'component' key\n" 98 | ) 99 | 100 | 101 | def test_run_missing_root_component_type(runner: CliRunner) -> None: 102 | config = """\ 103 | services: 104 | default: 105 | component: {} 106 | """ 107 | with runner.isolated_filesystem(): 108 | Path("test.yml").write_text(config) 109 | result = runner.invoke(_cli.run, ["test.yml"]) 110 | assert result.exit_code == 1 111 | assert result.stdout == ( 112 | "Error: Root component configuration is missing the 'type' key\n" 113 | ) 114 | 115 | 116 | def test_run_bad_path(runner: CliRunner) -> None: 117 | config = """\ 118 | component: 119 | type: does.not.exist:Component 120 | listvalue: [] 121 | """ 122 | with runner.isolated_filesystem(): 123 | Path("test.yml").write_text(config) 124 | result = runner.invoke( 125 | _cli.run, ["test.yml", "--set", "component.listvalue.foo=1"] 126 | ) 127 | assert result.exit_code == 1 128 | assert result.stdout == ( 129 | "Error: Cannot apply override for 'component.listvalue.foo': value at " 130 | "component ⟶ listvalue is not a mapping, but list\n" 131 | ) 132 | 133 | 134 | def test_run_multiple_configs(runner: CliRunner) -> None: 135 | component_class = f"{DummyComponent.__module__}:{DummyComponent.__name__}" 136 | config1 = f"""\ 137 | --- 138 | component: 139 | type: {component_class} 140 | dummyval1: testval 141 | logging: 142 | version: 1 143 | disable_existing_loggers: false 144 | """ 145 | config2 = """\ 146 | --- 147 | component: 148 | dummyval1: alternate 149 | dummyval2: 10 150 | dummyval3: foo 151 | """ 152 | 153 | with ( 154 | runner.isolated_filesystem(), 155 | patch("asphalt.core._cli.run_application") as run_app, 156 | ): 157 | Path("conf1.yml").write_text(config1) 158 | Path("conf2.yml").write_text(config2) 159 | result = runner.invoke( 160 | _cli.run, 161 | [ 162 | "conf1.yml", 163 | "conf2.yml", 164 | "--set", 165 | "component.dummyval3=bar", 166 | "--set", 167 | "component.dummyval4=baz", 168 | ], 169 | ) 170 | 171 | assert result.exit_code == 0 172 | assert run_app.call_count == 1 173 | args, kwargs = run_app.call_args 174 | assert args == ( 175 | component_class, 176 | { 177 | "dummyval1": "alternate", 178 | "dummyval2": 10, 179 | "dummyval3": "bar", 180 | "dummyval4": "baz", 181 | }, 182 | ) 183 | assert kwargs == { 184 | "backend": "asyncio", 185 | "backend_options": {}, 186 | "logging": {"version": 1, "disable_existing_loggers": False}, 187 | } 188 | 189 | 190 | class TestServices: 191 | def write_config(self) -> None: 192 | Path("config.yml").write_text( 193 | """\ 194 | --- 195 | max_threads: 15 196 | services: 197 | server: 198 | max_threads: 30 199 | component: 200 | type: myproject.server.ServerComponent 201 | components: 202 | wamp: &wamp 203 | host: wamp.example.org 204 | port: 8000 205 | tls: true 206 | auth_id: serveruser 207 | auth_secret: serverpass 208 | mailer: 209 | backend: smtp 210 | client: 211 | component: 212 | type: myproject.client.ClientComponent 213 | components: 214 | wamp: 215 | <<: *wamp 216 | auth_id: clientuser 217 | auth_secret: clientpass 218 | logging: 219 | version: 1 220 | disable_existing_loggers: false 221 | """ 222 | ) 223 | 224 | @pytest.mark.parametrize("service", ["server", "client"]) 225 | def test_run_service(self, runner: CliRunner, service: str) -> None: 226 | with ( 227 | runner.isolated_filesystem(), 228 | patch("asphalt.core._cli.run_application") as run_app, 229 | ): 230 | self.write_config() 231 | result = runner.invoke(_cli.run, ["-s", service, "config.yml"]) 232 | 233 | assert result.exit_code == 0 234 | assert run_app.call_count == 1 235 | args, kwargs = run_app.call_args 236 | if service == "server": 237 | assert args == ( 238 | "myproject.server.ServerComponent", 239 | { 240 | "components": { 241 | "wamp": { 242 | "host": "wamp.example.org", 243 | "port": 8000, 244 | "tls": True, 245 | "auth_id": "serveruser", 246 | "auth_secret": "serverpass", 247 | }, 248 | "mailer": {"backend": "smtp"}, 249 | }, 250 | }, 251 | ) 252 | assert kwargs == { 253 | "backend": "asyncio", 254 | "backend_options": {}, 255 | "max_threads": 30, 256 | "logging": {"version": 1, "disable_existing_loggers": False}, 257 | } 258 | else: 259 | assert args == ( 260 | "myproject.client.ClientComponent", 261 | { 262 | "components": { 263 | "wamp": { 264 | "host": "wamp.example.org", 265 | "port": 8000, 266 | "tls": True, 267 | "auth_id": "clientuser", 268 | "auth_secret": "clientpass", 269 | } 270 | }, 271 | }, 272 | ) 273 | assert kwargs == { 274 | "backend": "asyncio", 275 | "backend_options": {}, 276 | "max_threads": 15, 277 | "logging": {"version": 1, "disable_existing_loggers": False}, 278 | } 279 | 280 | def test_service_not_found(self, runner: CliRunner) -> None: 281 | with ( 282 | runner.isolated_filesystem(), 283 | patch("asphalt.core._cli.run_application") as run_app, 284 | ): 285 | self.write_config() 286 | result = runner.invoke(_cli.run, ["-s", "foobar", "config.yml"]) 287 | 288 | assert result.exit_code == 1 289 | assert run_app.call_count == 0 290 | assert result.output == "Error: Service 'foobar' has not been defined\n" 291 | 292 | def test_no_service_selected(self, runner: CliRunner) -> None: 293 | with ( 294 | runner.isolated_filesystem(), 295 | patch("asphalt.core._cli.run_application") as run_app, 296 | ): 297 | self.write_config() 298 | result = runner.invoke(_cli.run, ["config.yml"]) 299 | 300 | assert result.exit_code == 1 301 | assert run_app.call_count == 0 302 | assert result.output == ( 303 | "Error: Multiple services present in configuration file but no " 304 | "default service has been defined and no service was explicitly " 305 | "selected with -s / --service\n" 306 | ) 307 | 308 | def test_bad_services_type(self, runner: CliRunner) -> None: 309 | with ( 310 | runner.isolated_filesystem(), 311 | patch("asphalt.core._cli.run_application") as run_app, 312 | ): 313 | Path("config.yml").write_text( 314 | """\ 315 | --- 316 | services: blah 317 | logging: 318 | version: 1 319 | disable_existing_loggers: false 320 | """ 321 | ) 322 | result = runner.invoke(_cli.run, ["config.yml"]) 323 | 324 | assert result.exit_code == 1 325 | assert run_app.call_count == 0 326 | assert ( 327 | result.output == 'Error: The "services" key must be a dict, not str\n' 328 | ) 329 | 330 | def test_no_services_defined(self, runner: CliRunner) -> None: 331 | with ( 332 | runner.isolated_filesystem(), 333 | patch("asphalt.core._cli.run_application") as run_app, 334 | ): 335 | Path("config.yml").write_text( 336 | """\ 337 | --- 338 | services: {} 339 | logging: 340 | version: 1 341 | disable_existing_loggers: false 342 | """ 343 | ) 344 | result = runner.invoke(_cli.run, ["config.yml"]) 345 | 346 | assert result.exit_code == 1 347 | assert run_app.call_count == 0 348 | assert result.output == "Error: No services have been defined\n" 349 | 350 | def test_run_only_service(self, runner: CliRunner) -> None: 351 | with ( 352 | runner.isolated_filesystem(), 353 | patch("asphalt.core._cli.run_application") as run_app, 354 | ): 355 | Path("config.yml").write_text( 356 | """\ 357 | --- 358 | services: 359 | whatever: 360 | component: 361 | type: myproject.client.ClientComponent 362 | logging: 363 | version: 1 364 | disable_existing_loggers: false 365 | """ 366 | ) 367 | result = runner.invoke(_cli.run, ["config.yml"]) 368 | 369 | assert result.exit_code == 0 370 | assert run_app.call_count == 1 371 | args, kwargs = run_app.call_args 372 | assert args == ("myproject.client.ClientComponent", {}) 373 | assert kwargs == { 374 | "backend": "asyncio", 375 | "backend_options": {}, 376 | "logging": {"version": 1, "disable_existing_loggers": False}, 377 | } 378 | 379 | def test_run_default_service(self, runner: CliRunner) -> None: 380 | with ( 381 | runner.isolated_filesystem(), 382 | patch("asphalt.core._cli.run_application") as run_app, 383 | ): 384 | Path("config.yml").write_text( 385 | """\ 386 | --- 387 | services: 388 | whatever: 389 | component: 390 | type: myproject.client.ClientComponent 391 | default: 392 | component: 393 | type: myproject.server.ServerComponent 394 | logging: 395 | version: 1 396 | disable_existing_loggers: false 397 | """ 398 | ) 399 | result = runner.invoke(_cli.run, ["config.yml"]) 400 | 401 | assert result.exit_code == 0 402 | assert run_app.call_count == 1 403 | args, kwargs = run_app.call_args 404 | assert args == ("myproject.server.ServerComponent", {}) 405 | assert kwargs == { 406 | "backend": "asyncio", 407 | "backend_options": {}, 408 | "logging": {"version": 1, "disable_existing_loggers": False}, 409 | } 410 | 411 | def test_service_env_variable( 412 | self, runner: CliRunner, monkeypatch: MonkeyPatch 413 | ) -> None: 414 | with ( 415 | runner.isolated_filesystem(), 416 | patch("asphalt.core._cli.run_application") as run_app, 417 | ): 418 | Path("config.yml").write_text( 419 | """\ 420 | --- 421 | services: 422 | whatever: 423 | component: 424 | type: myproject.client.ClientComponent 425 | default: 426 | component: 427 | type: myproject.server.ServerComponent 428 | logging: 429 | version: 1 430 | disable_existing_loggers: false 431 | """ 432 | ) 433 | monkeypatch.setenv("ASPHALT_SERVICE", "whatever") 434 | result = runner.invoke(_cli.run, ["config.yml"]) 435 | 436 | assert result.exit_code == 0 437 | assert run_app.call_count == 1 438 | args, kwargs = run_app.call_args 439 | assert args == ("myproject.client.ClientComponent", {}) 440 | assert kwargs == { 441 | "backend": "asyncio", 442 | "backend_options": {}, 443 | "logging": {"version": 1, "disable_existing_loggers": False}, 444 | } 445 | 446 | def test_service_env_variable_override( 447 | self, runner: CliRunner, monkeypatch: MonkeyPatch 448 | ) -> None: 449 | with ( 450 | runner.isolated_filesystem(), 451 | patch("asphalt.core._cli.run_application") as run_app, 452 | ): 453 | Path("config.yml").write_text( 454 | """\ 455 | --- 456 | services: 457 | whatever: 458 | component: 459 | type: myproject.client.ClientComponent 460 | default: 461 | component: 462 | type: myproject.server.ServerComponent 463 | logging: 464 | version: 1 465 | disable_existing_loggers: false 466 | """ 467 | ) 468 | monkeypatch.setenv("ASPHALT_SERVICE", "whatever") 469 | result = runner.invoke(_cli.run, ["-s", "default", "config.yml"]) 470 | 471 | assert result.exit_code == 0 472 | assert run_app.call_count == 1 473 | args, kwargs = run_app.call_args 474 | assert args == ("myproject.server.ServerComponent", {}) 475 | assert kwargs == { 476 | "backend": "asyncio", 477 | "backend_options": {}, 478 | "logging": {"version": 1, "disable_existing_loggers": False}, 479 | } 480 | -------------------------------------------------------------------------------- /tests/test_concurrent.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import NoReturn 5 | 6 | import anyio 7 | import pytest 8 | from anyio import Event, fail_after, get_current_task, sleep 9 | from anyio.abc import TaskStatus 10 | from pytest import LogCaptureFixture 11 | 12 | from asphalt.core import ( 13 | Context, 14 | add_resource, 15 | get_resource_nowait, 16 | start_background_task_factory, 17 | start_service_task, 18 | ) 19 | 20 | if sys.version_info < (3, 11): 21 | from exceptiongroup import ExceptionGroup 22 | 23 | pytestmark = pytest.mark.anyio() 24 | 25 | 26 | class TestTaskFactory: 27 | async def test_start(self) -> None: 28 | async def taskfunc() -> str: 29 | assert get_current_task().name == "taskfunc" 30 | return "returnvalue" 31 | 32 | async with Context(): 33 | factory = await start_background_task_factory() 34 | handle = await factory.start_task(taskfunc, "taskfunc") 35 | assert handle.start_value is None 36 | await handle.wait_finished() 37 | 38 | async def test_start_empty_name(self) -> None: 39 | async def taskfunc() -> None: 40 | assert get_current_task().name == expected_name 41 | 42 | expected_name = ( 43 | f"{__name__}.{self.__class__.__name__}.test_start_empty_name." 44 | f".taskfunc" 45 | ) 46 | async with Context(): 47 | factory = await start_background_task_factory() 48 | handle = await factory.start_task(taskfunc) 49 | assert handle.name == expected_name 50 | 51 | async def test_start_in_subcontext(self) -> None: 52 | async def taskfunc() -> str: 53 | assert get_current_task().name == "taskfunc" 54 | return "returnvalue" 55 | 56 | async with Context(), Context(): 57 | factory = await start_background_task_factory() 58 | handle = await factory.start_task(taskfunc, "taskfunc") 59 | assert handle.start_value is None 60 | await handle.wait_finished() 61 | 62 | async def test_start_status(self) -> None: 63 | async def taskfunc(task_status: TaskStatus[str]) -> str: 64 | assert get_current_task().name == "taskfunc" 65 | task_status.started("startval") 66 | return "returnvalue" 67 | 68 | async with Context(): 69 | factory = await start_background_task_factory() 70 | handle = await factory.start_task(taskfunc, "taskfunc") 71 | assert handle.start_value == "startval" 72 | await handle.wait_finished() 73 | 74 | async def test_start_cancel(self) -> None: 75 | started = False 76 | finished = False 77 | 78 | async def taskfunc() -> None: 79 | nonlocal started, finished 80 | assert get_current_task().name == "taskfunc" 81 | started = True 82 | await sleep(3) 83 | finished = True 84 | 85 | async with Context(): 86 | factory = await start_background_task_factory() 87 | handle = await factory.start_task(taskfunc, "taskfunc") 88 | handle.cancel() 89 | 90 | assert started 91 | assert not finished 92 | 93 | async def test_start_exception(self) -> None: 94 | async def taskfunc() -> NoReturn: 95 | raise Exception("foo") 96 | 97 | with pytest.raises(ExceptionGroup) as excinfo: 98 | async with Context(): 99 | factory = await start_background_task_factory() 100 | await factory.start_task(taskfunc, "taskfunc") 101 | 102 | assert len(excinfo.value.exceptions) == 1 103 | assert isinstance(excinfo.value.exceptions[0], ExceptionGroup) 104 | excgrp = excinfo.value.exceptions[0] 105 | assert len(excgrp.exceptions) == 1 106 | assert str(excgrp.exceptions[0]) == "foo" 107 | 108 | async def test_start_exception_handled(self) -> None: 109 | handled_exception: Exception | None = None 110 | 111 | def handle_exception(exc: Exception) -> bool: 112 | nonlocal handled_exception 113 | handled_exception = exc 114 | return True 115 | 116 | async def taskfunc() -> NoReturn: 117 | raise Exception("foo") 118 | 119 | async with Context(): 120 | factory = await start_background_task_factory( 121 | exception_handler=handle_exception 122 | ) 123 | await factory.start_task(taskfunc, "taskfunc") 124 | 125 | assert str(handled_exception) == "foo" 126 | 127 | @pytest.mark.parametrize("name", ["taskname", None]) 128 | async def test_start_soon(self, name: str | None) -> None: 129 | expected_name = ( 130 | name 131 | or f"{__name__}.{self.__class__.__name__}.test_start_soon..taskfunc" 132 | ) 133 | 134 | async def taskfunc() -> str: 135 | assert get_current_task().name == expected_name 136 | return "returnvalue" 137 | 138 | async with Context(): 139 | factory = await start_background_task_factory() 140 | handle = factory.start_task_soon(taskfunc, name) 141 | await handle.wait_finished() 142 | 143 | assert handle.name == expected_name 144 | 145 | async def test_context_isolation(self) -> None: 146 | """ 147 | Test that the background task has no access to resources added after it was 148 | started. 149 | 150 | """ 151 | 152 | async def taskfunc() -> None: 153 | assert get_resource_nowait(str) == "test" 154 | assert get_resource_nowait(int, optional=True) is None 155 | 156 | async with Context(): 157 | add_resource("test") 158 | factory = await start_background_task_factory() 159 | add_resource(5) 160 | await factory.start_task(taskfunc) 161 | 162 | async def test_all_task_handles(self) -> None: 163 | event = Event() 164 | 165 | async def taskfunc() -> None: 166 | await event.wait() 167 | 168 | async with Context(): 169 | factory = await start_background_task_factory() 170 | handle1 = await factory.start_task(taskfunc) 171 | handle2 = factory.start_task_soon(taskfunc) 172 | assert factory.all_task_handles() == {handle1, handle2} 173 | event.set() 174 | for handle in (handle1, handle2): 175 | await handle.wait_finished() 176 | 177 | assert factory.all_task_handles() == set() 178 | 179 | 180 | class TestServiceTask: 181 | async def test_bad_teardown_action(self, caplog: LogCaptureFixture) -> None: 182 | async def service_func() -> None: 183 | await event.wait() 184 | 185 | event = anyio.Event() 186 | async with Context(): 187 | with pytest.raises(ValueError, match="teardown_action must be a callable"): 188 | await start_service_task( 189 | service_func, 190 | "Dummy", 191 | teardown_action="fail", # type: ignore[arg-type] 192 | ) 193 | 194 | async def test_teardown_async(self) -> None: 195 | async def teardown_callback() -> None: 196 | event.set() 197 | 198 | async def service_func() -> None: 199 | await event.wait() 200 | 201 | event = anyio.Event() 202 | with fail_after(1): 203 | async with Context(): 204 | await start_service_task( 205 | service_func, "Dummy", teardown_action=teardown_callback 206 | ) 207 | 208 | async def test_teardown_fail(self, caplog: LogCaptureFixture) -> None: 209 | def teardown_callback() -> NoReturn: 210 | raise Exception("foo") 211 | 212 | async def service_func() -> None: 213 | await event.wait() 214 | 215 | event = anyio.Event() 216 | with fail_after(1): 217 | async with Context(): 218 | await start_service_task( 219 | service_func, "Dummy", teardown_action=teardown_callback 220 | ) 221 | 222 | assert caplog.messages == [ 223 | f"Error calling teardown callback ({__name__}.{self.__class__.__name__}" 224 | f".test_teardown_fail..teardown_callback) for service task 'Dummy'" 225 | ] 226 | -------------------------------------------------------------------------------- /tests/test_event.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import gc 4 | from collections.abc import Callable 5 | from datetime import datetime, timedelta, timezone 6 | from typing import Any 7 | 8 | import pytest 9 | from anyio import create_task_group, fail_after 10 | from anyio.abc import TaskStatus 11 | from anyio.lowlevel import checkpoint 12 | 13 | from asphalt.core import Event, Signal, SignalQueueFull, stream_events, wait_event 14 | from asphalt.core._exceptions import UnboundSignal 15 | 16 | pytestmark = pytest.mark.anyio() 17 | 18 | 19 | class DummyEvent(Event): 20 | def __init__(self, *args: Any, **kwargs: Any): 21 | self.args = args 22 | self.kwargs = kwargs 23 | 24 | 25 | class DummySource: 26 | event_a = Signal(DummyEvent) 27 | event_b = Signal(DummyEvent) 28 | 29 | 30 | @pytest.fixture 31 | def source() -> DummySource: 32 | return DummySource() 33 | 34 | 35 | class TestEvent: 36 | def test_utc_timestamp(self, source: DummySource) -> None: 37 | timestamp = datetime.now(timezone(timedelta(hours=2))) 38 | event = Event() 39 | event.time = timestamp.timestamp() 40 | assert event.utc_timestamp == timestamp 41 | assert event.utc_timestamp.tzinfo == timezone.utc 42 | 43 | def test_event_repr(self, source: DummySource) -> None: 44 | event = Event() 45 | event.source = source 46 | event.topic = "sometopic" 47 | assert repr(event) == f"Event(source={source!r}, topic='sometopic')" 48 | 49 | 50 | class TestSignal: 51 | def test_class_attribute_access(self) -> None: 52 | """ 53 | Test that accessing the descriptor on the class level returns the same signal 54 | instance. 55 | 56 | """ 57 | signal = Signal(DummyEvent) 58 | 59 | class EventSource: 60 | dummysignal = signal 61 | 62 | assert EventSource.dummysignal is signal 63 | 64 | async def test_dispatch_event_type_mismatch(self, source: DummySource) -> None: 65 | """Test that trying to dispatch an event of the wrong type raises TypeError.""" 66 | pattern = ( 67 | f"Event type mismatch: event \\(str\\) is not a subclass of " 68 | f"{__name__}.DummyEvent" 69 | ) 70 | with pytest.raises(TypeError, match=pattern): 71 | source.event_a.dispatch("foo") # type: ignore[arg-type] 72 | 73 | async def test_dispatch_event_no_listeners(self, source: DummySource) -> None: 74 | """ 75 | Test that dispatching an event when there are no listeners will still work. 76 | 77 | """ 78 | source.event_a.dispatch(DummyEvent()) 79 | 80 | async def test_dispatch_event_buffer_overflow(self, source: DummySource) -> None: 81 | """ 82 | Test that dispatching to a subscriber that has a full queue raises the 83 | SignalQueueFull warning. 84 | 85 | """ 86 | received_events = [] 87 | 88 | async def receive_events(task_status: TaskStatus[None]) -> None: 89 | async with source.event_a.stream_events(max_queue_size=1) as stream: 90 | task_status.started() 91 | async for event in stream: 92 | received_events.append(event) 93 | 94 | async with create_task_group() as tg: 95 | await tg.start(receive_events) 96 | source.event_a.dispatch(DummyEvent(1)) 97 | with pytest.warns(SignalQueueFull): 98 | source.event_a.dispatch(DummyEvent(2)) 99 | source.event_a.dispatch(DummyEvent(3)) 100 | 101 | # Give the task a chance to run, then cancel 102 | await checkpoint() 103 | tg.cancel_scope.cancel() 104 | 105 | assert len(received_events) == 1 106 | 107 | @pytest.mark.parametrize( 108 | "filter, expected_value", 109 | [ 110 | pytest.param(None, 1, id="nofilter"), 111 | pytest.param(lambda event: event.args[0] == 3, 3, id="filter"), 112 | ], 113 | ) 114 | async def test_wait_event( 115 | self, 116 | source: DummySource, 117 | filter: Callable[[Event], bool] | None, 118 | expected_value: int, 119 | ) -> None: 120 | async def dispatch_events() -> None: 121 | for i in range(1, 4): 122 | source.event_a.dispatch(DummyEvent(i)) 123 | 124 | async with create_task_group() as tg: 125 | tg.start_soon(dispatch_events) 126 | with fail_after(1): 127 | event = await wait_event([source.event_a], filter) 128 | 129 | assert event.args == (expected_value,) 130 | 131 | @pytest.mark.parametrize( 132 | "filter, expected_values", 133 | [ 134 | pytest.param(None, [1, 2, 3], id="nofilter"), 135 | pytest.param(lambda event: event.args[0] in (3, None), [3], id="filter"), 136 | ], 137 | ) 138 | async def test_stream_events( 139 | self, 140 | source: DummySource, 141 | filter: Callable[[DummyEvent], bool] | None, 142 | expected_values: list[int], 143 | ) -> None: 144 | values = [] 145 | async with source.event_a.stream_events(filter) as stream: 146 | for i in range(1, 4): 147 | source.event_a.dispatch(DummyEvent(i)) 148 | 149 | async for event in stream: 150 | values.append(event.args[0]) 151 | if event.args[0] == 3: 152 | break 153 | 154 | assert values == expected_values 155 | 156 | def test_memory_leak(self) -> None: 157 | """ 158 | Test that activating a Signal does not prevent its owner object from being 159 | garbage collected. 160 | 161 | """ 162 | 163 | class SignalOwner: 164 | dummy = Signal(Event) 165 | 166 | owner = SignalOwner() 167 | owner.dummy 168 | del owner 169 | gc.collect() # needed on PyPy 170 | assert ( 171 | next((x for x in gc.get_objects() if isinstance(x, SignalOwner)), None) 172 | is None 173 | ) 174 | 175 | def test_dispatch_unbound_signal(self) -> None: 176 | with pytest.raises( 177 | UnboundSignal, 178 | match="attempted to use a signal that is not bound to an instance", 179 | ): 180 | DummySource.event_a.dispatch(DummyEvent()) 181 | 182 | async def test_wait_unbound_signal(self) -> None: 183 | with pytest.raises( 184 | UnboundSignal, 185 | match="attempted to use a signal that is not bound to an instance", 186 | ): 187 | await DummySource.event_a.wait_event() 188 | 189 | 190 | @pytest.mark.parametrize( 191 | "filter, expected_value", 192 | [ 193 | pytest.param(None, 1, id="nofilter"), 194 | pytest.param(lambda event: event.args[0] == 3, 3, id="filter"), 195 | ], 196 | ) 197 | async def test_wait_event( 198 | source: DummySource, 199 | filter: Callable[[DummyEvent], bool] | None, 200 | expected_value: int, 201 | ) -> None: 202 | """ 203 | Test that wait_event returns the first event matched by the filter, or the first 204 | event if there is no filter. 205 | 206 | """ 207 | 208 | async def dispatch_events() -> None: 209 | for i in range(1, 4): 210 | source.event_a.dispatch(DummyEvent(i)) 211 | 212 | async with create_task_group() as tg: 213 | tg.start_soon(dispatch_events) 214 | with fail_after(1): 215 | event = await wait_event([source.event_a], filter) 216 | 217 | assert event.args == (expected_value,) 218 | 219 | 220 | @pytest.mark.parametrize( 221 | "filter, expected_values", 222 | [ 223 | pytest.param(None, [1, 2, 3, 1, 2, 3], id="nofilter"), 224 | pytest.param(lambda event: event.args[0] in (3, None), [3, 3], id="filter"), 225 | ], 226 | ) 227 | async def test_stream_events( 228 | filter: Callable[[DummyEvent], bool] | None, expected_values: list[int] 229 | ) -> None: 230 | source1, source2 = DummySource(), DummySource() 231 | values = [] 232 | async with stream_events([source1.event_a, source2.event_b], filter) as stream: 233 | for signal in [source1.event_a, source2.event_b]: 234 | for i in range(1, 4): 235 | signal.dispatch(DummyEvent(i)) 236 | 237 | signal.dispatch(DummyEvent(None)) 238 | 239 | async for event in stream: 240 | if event.args[0] is None: 241 | break 242 | 243 | values.append(event.args[0]) 244 | 245 | assert values == expected_values 246 | -------------------------------------------------------------------------------- /tests/test_runner.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import platform 5 | import re 6 | import signal 7 | from textwrap import dedent 8 | from typing import Any, Literal 9 | from unittest.mock import patch 10 | 11 | import anyio 12 | import pytest 13 | from _pytest.logging import LogCaptureFixture 14 | from anyio import sleep, to_thread, wait_all_tasks_blocked 15 | 16 | from asphalt.core import ( 17 | CLIApplicationComponent, 18 | Component, 19 | add_teardown_callback, 20 | get_resource, 21 | run_application, 22 | start_service_task, 23 | ) 24 | 25 | pytestmark = pytest.mark.anyio() 26 | windows_signal_mark = pytest.mark.skipif( 27 | platform.system() == "Windows", reason="Signals don't work on Windows" 28 | ) 29 | 30 | 31 | class ShutdownComponent(Component): 32 | def __init__(self, method: Literal["keyboard", "sigterm", "exception"]): 33 | self.method = method 34 | self.teardown_callback_called = False 35 | 36 | async def stop_app(self) -> None: 37 | await wait_all_tasks_blocked() 38 | if self.method == "keyboard": 39 | signal.raise_signal(signal.SIGINT) 40 | elif self.method == "sigterm": 41 | signal.raise_signal(signal.SIGTERM) 42 | elif self.method == "exception": 43 | raise RuntimeError("this should crash the application") 44 | 45 | async def start(self) -> None: 46 | await start_service_task(self.stop_app, "Application terminator") 47 | 48 | 49 | class CrashComponent(Component): 50 | def __init__(self, method: str = "exit"): 51 | self.method = method 52 | 53 | async def start(self) -> None: 54 | if self.method == "keyboard": 55 | signal.raise_signal(signal.SIGINT) 56 | await sleep(3) 57 | elif self.method == "sigterm": 58 | signal.raise_signal(signal.SIGTERM) 59 | await sleep(3) 60 | elif self.method == "exception": 61 | raise RuntimeError("this should crash the application") 62 | 63 | 64 | class DummyCLIApp(CLIApplicationComponent): 65 | def __init__(self, exit_code: int | None = None): 66 | super().__init__() 67 | self.exit_code = exit_code 68 | self.exception: BaseException | None = None 69 | 70 | def teardown_callback(self, exception: BaseException | None) -> None: 71 | logging.getLogger(__name__).info("Teardown callback called") 72 | self.exception = exception 73 | 74 | async def start(self) -> None: 75 | add_teardown_callback(self.teardown_callback, pass_exception=True) 76 | 77 | async def run(self) -> int | None: 78 | return self.exit_code 79 | 80 | 81 | @pytest.mark.parametrize( 82 | "logging_config", 83 | [ 84 | pytest.param(None, id="disabled"), 85 | pytest.param(logging.INFO, id="loglevel"), 86 | pytest.param( 87 | {"version": 1, "loggers": {"asphalt": {"level": "INFO"}}}, id="dictconfig" 88 | ), 89 | ], 90 | ) 91 | def test_run_logging_config( 92 | logging_config: dict[str, Any] | int | None, anyio_backend_name: str 93 | ) -> None: 94 | """Test that logging initialization happens as expected.""" 95 | with ( 96 | patch("asphalt.core._runner.basicConfig") as basicConfig, 97 | patch("asphalt.core._runner.dictConfig") as dictConfig, 98 | ): 99 | run_application(DummyCLIApp, logging=logging_config, backend=anyio_backend_name) 100 | 101 | assert basicConfig.call_count == (1 if logging_config == logging.INFO else 0) 102 | assert dictConfig.call_count == (1 if isinstance(logging_config, dict) else 0) 103 | 104 | 105 | @pytest.mark.parametrize("max_threads", [None, 3]) 106 | def test_run_max_threads(max_threads: int | None, anyio_backend_name: str) -> None: 107 | """ 108 | Test that a new default executor is installed if and only if the max_threads 109 | argument is given. 110 | 111 | """ 112 | observed_total_tokens: float | None = None 113 | 114 | class MaxThreadsComponent(CLIApplicationComponent): 115 | async def run(self) -> int | None: 116 | nonlocal observed_total_tokens 117 | limiter = to_thread.current_default_thread_limiter() 118 | observed_total_tokens = limiter.total_tokens 119 | return None 120 | 121 | async def get_default_total_tokens() -> float: 122 | limiter = to_thread.current_default_thread_limiter() 123 | return limiter.total_tokens 124 | 125 | expected_total_tokens = max_threads or anyio.run( 126 | get_default_total_tokens, backend=anyio_backend_name 127 | ) 128 | run_application( 129 | MaxThreadsComponent, max_threads=max_threads, backend=anyio_backend_name 130 | ) 131 | assert observed_total_tokens == expected_total_tokens 132 | 133 | 134 | def test_run_callbacks(caplog: LogCaptureFixture, anyio_backend_name: str) -> None: 135 | """ 136 | Test that the teardown callbacks are run when the application is started and shut 137 | down properly and that the proper logging messages are emitted. 138 | 139 | """ 140 | caplog.set_level(logging.INFO) 141 | run_application(DummyCLIApp, backend=anyio_backend_name) 142 | 143 | assert caplog.messages == [ 144 | "Running in development mode", 145 | "Starting application", 146 | "Application started", 147 | "Teardown callback called", 148 | "Application stopped", 149 | ] 150 | 151 | 152 | @pytest.mark.parametrize( 153 | "method, expected_stop_message", 154 | [ 155 | pytest.param( 156 | "keyboard", 157 | "Received signal (Interrupt) – terminating application", 158 | id="keyboard", 159 | marks=[windows_signal_mark], 160 | ), 161 | pytest.param( 162 | "sigterm", 163 | "Received signal (Terminated) – terminating application", 164 | id="sigterm", 165 | marks=[windows_signal_mark], 166 | ), 167 | ], 168 | ) 169 | def test_clean_exit( 170 | caplog: LogCaptureFixture, 171 | method: Literal["keyboard", "sigterm"], 172 | expected_stop_message: str | None, 173 | anyio_backend_name: str, 174 | ) -> None: 175 | """ 176 | Test that when application termination is explicitly requested either externally or 177 | directly from a service task, it exits cleanly. 178 | 179 | """ 180 | caplog.set_level(logging.INFO, "asphalt.core") 181 | run_application(ShutdownComponent, {"method": method}, backend=anyio_backend_name) 182 | 183 | expected_messages = [ 184 | "Running in development mode", 185 | "Starting application", 186 | "Application started", 187 | "Application stopped", 188 | ] 189 | if expected_stop_message: 190 | expected_messages.insert(3, expected_stop_message) 191 | 192 | assert caplog.messages == expected_messages 193 | 194 | 195 | @pytest.mark.parametrize( 196 | "method, expected_stop_message", 197 | [ 198 | pytest.param( 199 | "exception", 200 | "Error during application startup", 201 | id="exception", 202 | ), 203 | pytest.param( 204 | "keyboard", 205 | "Received signal (Interrupt) – terminating application", 206 | id="keyboard", 207 | marks=[windows_signal_mark], 208 | ), 209 | pytest.param( 210 | "sigterm", 211 | "Received signal (Terminated) – terminating application", 212 | id="sigterm", 213 | marks=[windows_signal_mark], 214 | ), 215 | ], 216 | ) 217 | def test_start_exception( 218 | caplog: LogCaptureFixture, 219 | anyio_backend_name: str, 220 | method: str, 221 | expected_stop_message: str, 222 | ) -> None: 223 | """ 224 | Test that an exception caught during the application initialization is put into the 225 | application context and made available to teardown callbacks. 226 | 227 | """ 228 | caplog.set_level(logging.INFO, "asphalt.core") 229 | with pytest.raises(SystemExit) as exc_info: 230 | run_application(CrashComponent, {"method": method}, backend=anyio_backend_name) 231 | 232 | assert exc_info.value.code == 1 233 | assert caplog.messages == [ 234 | "Running in development mode", 235 | "Starting application", 236 | expected_stop_message, 237 | "Application stopped", 238 | ] 239 | 240 | 241 | def test_start_timeout(caplog: LogCaptureFixture, anyio_backend_name: str) -> None: 242 | class StallingComponent(Component): 243 | def __init__(self, level: int = 1): 244 | super().__init__() 245 | self.is_leaf = level == 4 246 | if not self.is_leaf: 247 | self.add_component("child1", StallingComponent, level=level + 1) 248 | self.add_component("child2", StallingComponent, level=level + 1) 249 | self.add_component("child3", Component) 250 | 251 | async def start(self) -> None: 252 | if self.is_leaf: 253 | # Wait forever for a non-existent resource 254 | await get_resource(float) 255 | 256 | caplog.set_level(logging.INFO) 257 | with pytest.raises(SystemExit) as exc_info: 258 | run_application( 259 | StallingComponent, 260 | {}, 261 | start_timeout=0.1, 262 | backend=anyio_backend_name, 263 | ) 264 | 265 | assert exc_info.value.code == 1 266 | assert caplog.messages == [ 267 | "Running in development mode", 268 | "Starting application", 269 | caplog.messages[2], 270 | "Application stopped", 271 | ] 272 | assert caplog.messages[2].startswith( 273 | dedent( 274 | """\ 275 | Timeout waiting for the component tree to start 276 | 277 | Current status of the components still waiting to finish startup 278 | ---------------------------------------------------------------- 279 | 280 | (root): starting children 281 | child1: starting children 282 | child1: starting children 283 | child1: starting 284 | child2: starting 285 | child2: starting children 286 | child1: starting 287 | child2: starting 288 | child2: starting children 289 | child1: starting children 290 | child1: starting 291 | child2: starting 292 | child2: starting children 293 | child1: starting 294 | child2: starting 295 | 296 | Stack summaries of components still waiting to start 297 | ---------------------------------------------------- 298 | 299 | """ 300 | ) 301 | ) 302 | 303 | expected_component_name = ( 304 | f"{__name__}.test_start_timeout..StallingComponent" 305 | ) 306 | paths = set() 307 | for line in caplog.messages[2].splitlines()[24:]: 308 | if line.startswith(" "): 309 | pass 310 | elif line.startswith(" "): 311 | assert line.startswith(' File "') 312 | elif line: 313 | match = re.match(r"(child\d\.child\d\.child\d) \((.+)\):$", line) 314 | assert match 315 | paths.add(match.group(1)) 316 | assert match.group(2) == expected_component_name 317 | 318 | assert paths == { 319 | "child1.child1.child1", 320 | "child1.child1.child2", 321 | "child1.child2.child1", 322 | "child1.child2.child2", 323 | "child2.child1.child1", 324 | "child2.child1.child2", 325 | "child2.child2.child1", 326 | "child2.child2.child2", 327 | } 328 | 329 | 330 | def test_run_cli_application( 331 | caplog: LogCaptureFixture, anyio_backend_name: str 332 | ) -> None: 333 | caplog.set_level(logging.INFO) 334 | with pytest.raises(SystemExit) as exc: 335 | run_application(DummyCLIApp, {"exit_code": 20}, backend=anyio_backend_name) 336 | 337 | assert exc.value.code == 20 338 | 339 | assert caplog.messages == [ 340 | "Running in development mode", 341 | "Starting application", 342 | "Application started", 343 | "Teardown callback called", 344 | "Application stopped", 345 | ] 346 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import sys 5 | from collections.abc import Callable 6 | from functools import partial 7 | from typing import Any 8 | from unittest.mock import Mock 9 | 10 | import pytest 11 | 12 | from asphalt.core import ( 13 | PluginContainer, 14 | callable_name, 15 | merge_config, 16 | qualified_name, 17 | resolve_reference, 18 | ) 19 | 20 | if sys.version_info >= (3, 10): 21 | from importlib.metadata import EntryPoint 22 | else: 23 | from importlib_metadata import EntryPoint 24 | 25 | 26 | class BaseDummyPlugin: 27 | pass 28 | 29 | 30 | class DummyPlugin(BaseDummyPlugin): 31 | def __init__(self, **kwargs: Any) -> None: 32 | self.kwargs = kwargs 33 | 34 | 35 | @pytest.mark.parametrize( 36 | "inputval", 37 | ["asphalt.core:resolve_reference", resolve_reference], 38 | ids=["reference", "object"], 39 | ) 40 | def test_resolve_reference(inputval: Any) -> None: 41 | assert resolve_reference(inputval) is resolve_reference 42 | 43 | 44 | @pytest.mark.parametrize( 45 | "inputval, error_type, error_text", 46 | [ 47 | pytest.param( 48 | "x.y:foo", 49 | LookupError, 50 | "error resolving reference x.y:foo: could not import module", 51 | id="module_not_found", 52 | ), 53 | pytest.param( 54 | "asphalt.core:foo", 55 | LookupError, 56 | "error resolving reference asphalt.core:foo: error looking up object", 57 | id="object_not_found", 58 | ), 59 | ], 60 | ) 61 | def test_resolve_reference_error( 62 | inputval: str, error_type: type[Exception], error_text: str 63 | ) -> None: 64 | exc = pytest.raises(error_type, resolve_reference, inputval) 65 | assert str(exc.value) == error_text 66 | 67 | 68 | def test_merge_config() -> None: 69 | original = {"a": 1, "b": 2.5, "x.y.z": [3, 4]} 70 | overrides = {"a": 2, "foo": 6, "x.y.z": [6, 7]} 71 | expected = {"a": 2, "b": 2.5, "foo": 6, "x.y.z": [6, 7]} 72 | assert merge_config(original, overrides) == expected 73 | 74 | 75 | @pytest.mark.parametrize( 76 | "original, overrides", 77 | [ 78 | pytest.param(None, {"a": 1}, id="original_none"), 79 | pytest.param({"a": 1}, None, id="override_none"), 80 | ], 81 | ) 82 | def test_merge_config_none_args( 83 | original: dict[str, Any] | None, overrides: dict[str, Any] | None 84 | ) -> None: 85 | assert merge_config(original, overrides) == {"a": 1} 86 | 87 | 88 | @pytest.mark.parametrize( 89 | "inputval, expected", 90 | [ 91 | pytest.param(qualified_name, "function", id="func"), 92 | pytest.param(asyncio.Event(), "asyncio.locks.Event", id="instance"), 93 | pytest.param(int, "int", id="builtintype"), 94 | ], 95 | ) 96 | def test_qualified_name(inputval: object, expected: str) -> None: 97 | assert qualified_name(inputval) == expected 98 | 99 | 100 | @pytest.mark.parametrize( 101 | "inputval, expected", 102 | [ 103 | pytest.param(qualified_name, "asphalt.core.qualified_name", id="python"), 104 | pytest.param(len, "len", id="builtin"), 105 | pytest.param(partial(len, []), "len", id="partial"), 106 | ], 107 | ) 108 | def test_callable_name(inputval: Callable[..., Any], expected: str) -> None: 109 | assert callable_name(inputval) == expected 110 | 111 | 112 | class TestPluginContainer: 113 | @pytest.fixture 114 | def container(self) -> PluginContainer: 115 | container = PluginContainer( 116 | "asphalt.core.test_plugin_container", BaseDummyPlugin 117 | ) 118 | entrypoint = Mock(EntryPoint) 119 | entrypoint.load.configure_mock(return_value=DummyPlugin) 120 | container._entrypoints = {"dummy": entrypoint} 121 | return container 122 | 123 | @pytest.mark.parametrize( 124 | "inputvalue", 125 | [ 126 | pytest.param("dummy", id="entrypoint"), 127 | pytest.param(f"{__name__}:DummyPlugin", id="reference"), 128 | pytest.param(DummyPlugin, id="arbitrary_object"), 129 | ], 130 | ) 131 | def test_resolve(self, container: PluginContainer, inputvalue: type | str) -> None: 132 | assert container.resolve(inputvalue) is DummyPlugin 133 | 134 | def test_resolve_bad_entrypoint(self, container: PluginContainer) -> None: 135 | with pytest.raises( 136 | LookupError, 137 | match="no such entry point in asphalt.core.test_plugin_container: blah", 138 | ): 139 | container.resolve("blah") 140 | 141 | @pytest.mark.parametrize( 142 | "argument", 143 | [ 144 | pytest.param(DummyPlugin, id="explicit_class"), 145 | pytest.param("dummy", id="entrypoint"), 146 | ], 147 | ) 148 | def test_create_object( 149 | self, container: PluginContainer, argument: type | str 150 | ) -> None: 151 | """ 152 | Test that create_object works with all three supported ways of passing a plugin 153 | class reference. 154 | 155 | """ 156 | component = container.create_object(argument, a=5, b=2) 157 | 158 | assert isinstance(component, DummyPlugin) 159 | assert component.kwargs == {"a": 5, "b": 2} 160 | 161 | def test_create_object_bad_type(self, container: PluginContainer) -> None: 162 | exc = pytest.raises(TypeError, container.create_object, int) 163 | assert str(exc.value) == "int is not a subclass of test_utils.BaseDummyPlugin" 164 | 165 | def test_names(self, container: PluginContainer) -> None: 166 | assert container.names == ["dummy"] 167 | 168 | def test_all(self, container: PluginContainer) -> None: 169 | """ 170 | Test that all() returns the same results before and after the entry points have 171 | been loaded. 172 | 173 | """ 174 | assert container.all() == [DummyPlugin] 175 | assert container.all() == [DummyPlugin] 176 | 177 | def test_repr(self, container: PluginContainer) -> None: 178 | assert repr(container) == ( 179 | "PluginContainer(namespace='asphalt.core.test_plugin_container', " 180 | "base_class=test_utils.BaseDummyPlugin)" 181 | ) 182 | --------------------------------------------------------------------------------