├── .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 |
--------------------------------------------------------------------------------