asyncio-extensions 0.0.2__tar.gz → 0.0.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {asyncio_extensions-0.0.2 → asyncio_extensions-0.0.4}/.github/dependabot.yml +0 -4
- asyncio_extensions-0.0.4/.github/workflows/release.yml +57 -0
- {asyncio_extensions-0.0.2 → asyncio_extensions-0.0.4}/.github/workflows/tests.yml +15 -3
- asyncio_extensions-0.0.4/.gitignore +5 -0
- asyncio_extensions-0.0.4/.pre-commit-config.yaml +36 -0
- asyncio_extensions-0.0.4/CHANGELOG.md +47 -0
- asyncio_extensions-0.0.4/PKG-INFO +163 -0
- asyncio_extensions-0.0.4/README.md +134 -0
- asyncio_extensions-0.0.4/codecov.yml +13 -0
- {asyncio_extensions-0.0.2 → asyncio_extensions-0.0.4}/pyproject.toml +12 -2
- asyncio_extensions-0.0.4/src/asyncio_extensions/__init__.py +21 -0
- asyncio_extensions-0.0.4/src/asyncio_extensions/_compat.py +30 -0
- asyncio_extensions-0.0.4/src/asyncio_extensions/bridge.py +47 -0
- asyncio_extensions-0.0.4/src/asyncio_extensions/taskgroups.py +93 -0
- asyncio_extensions-0.0.4/src/asyncio_extensions/utils.py +49 -0
- asyncio_extensions-0.0.4/tests/test_bridge.py +106 -0
- asyncio_extensions-0.0.4/tests/test_taskgroups.py +109 -0
- {asyncio_extensions-0.0.2 → asyncio_extensions-0.0.4}/tests/test_utils.py +13 -2
- asyncio_extensions-0.0.2/.github/workflows/release.yml +0 -33
- asyncio_extensions-0.0.2/.gitignore +0 -2
- asyncio_extensions-0.0.2/PKG-INFO +0 -47
- asyncio_extensions-0.0.2/README.md +0 -21
- asyncio_extensions-0.0.2/src/asyncio_extensions/__init__.py +0 -12
- asyncio_extensions-0.0.2/src/asyncio_extensions/taskgroups.py +0 -38
- asyncio_extensions-0.0.2/src/asyncio_extensions/utils.py +0 -27
- asyncio_extensions-0.0.2/tests/__init__.py +0 -0
- asyncio_extensions-0.0.2/tests/test_taskgroups.py +0 -54
- {asyncio_extensions-0.0.2 → asyncio_extensions-0.0.4}/LICENSE.txt +0 -0
- {asyncio_extensions-0.0.2 → asyncio_extensions-0.0.4}/src/asyncio_extensions/py.typed +0 -0
- /asyncio_extensions-0.0.2/tests/utils.py → /asyncio_extensions-0.0.4/tests/__init__.py +0 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
name: Build & Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
build:
|
|
12
|
+
name: Build distribution 📦
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v6
|
|
17
|
+
with:
|
|
18
|
+
persist-credentials: false
|
|
19
|
+
|
|
20
|
+
- name: Set up Python
|
|
21
|
+
uses: actions/setup-python@v6
|
|
22
|
+
with:
|
|
23
|
+
python-version: "3.x"
|
|
24
|
+
|
|
25
|
+
- name: Install pypa/build
|
|
26
|
+
run: |
|
|
27
|
+
python -m pip install --upgrade pip build --user
|
|
28
|
+
|
|
29
|
+
- name: Build a binary wheel and a source tarball
|
|
30
|
+
run: python -m build
|
|
31
|
+
|
|
32
|
+
- name: Store the distribution packages
|
|
33
|
+
uses: actions/upload-artifact@v7
|
|
34
|
+
with:
|
|
35
|
+
name: python-package-distributions
|
|
36
|
+
path: dist/
|
|
37
|
+
|
|
38
|
+
publish-to-pypi:
|
|
39
|
+
name: Publish Python 🐍 distribution 📦 to PyPI
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
needs:
|
|
42
|
+
- build
|
|
43
|
+
environment:
|
|
44
|
+
name: release
|
|
45
|
+
url: https://pypi.org/p/asyncio-extensions
|
|
46
|
+
permissions:
|
|
47
|
+
id-token: write
|
|
48
|
+
|
|
49
|
+
steps:
|
|
50
|
+
- name: Download all the dists
|
|
51
|
+
uses: actions/download-artifact@v8
|
|
52
|
+
with:
|
|
53
|
+
name: python-package-distributions
|
|
54
|
+
path: dist/
|
|
55
|
+
|
|
56
|
+
- name: Publish distribution 📦 to PyPI
|
|
57
|
+
uses: pypa/gh-action-pypi-publish@v1.13.0
|
|
@@ -27,7 +27,7 @@ jobs:
|
|
|
27
27
|
python-version: "3.13"
|
|
28
28
|
|
|
29
29
|
- name: Setup Hatch
|
|
30
|
-
run: pipx
|
|
30
|
+
run: pipx install --preinstall "virtualenv<21" hatch
|
|
31
31
|
|
|
32
32
|
- name: Run static analysis
|
|
33
33
|
run: hatch fmt --check
|
|
@@ -51,8 +51,20 @@ jobs:
|
|
|
51
51
|
python-version: ${{ matrix.python-version }}
|
|
52
52
|
|
|
53
53
|
- name: Setup Hatch
|
|
54
|
-
run: pipx
|
|
54
|
+
run: pipx install --preinstall "virtualenv<21" hatch
|
|
55
55
|
|
|
56
56
|
- name: Run tests
|
|
57
57
|
run: |
|
|
58
|
-
hatch test -i py=${{ matrix.python-version }} --cover
|
|
58
|
+
hatch test -i py=${{ matrix.python-version }} --cover --junitxml=junit.xml
|
|
59
|
+
|
|
60
|
+
- name: Upload results to Codecov
|
|
61
|
+
uses: codecov/codecov-action@v5
|
|
62
|
+
with:
|
|
63
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
|
64
|
+
|
|
65
|
+
- name: Upload test results to Codecov
|
|
66
|
+
if: ${{ !cancelled() }}
|
|
67
|
+
uses: codecov/codecov-action@v5
|
|
68
|
+
with:
|
|
69
|
+
report_type: test_results
|
|
70
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
3
|
+
rev: v6.0.0
|
|
4
|
+
hooks:
|
|
5
|
+
- id: check-added-large-files
|
|
6
|
+
- id: check-ast
|
|
7
|
+
- id: check-builtin-literals
|
|
8
|
+
- id: check-case-conflict
|
|
9
|
+
- id: check-executables-have-shebangs
|
|
10
|
+
- id: check-illegal-windows-names
|
|
11
|
+
- id: check-json
|
|
12
|
+
- id: check-merge-conflict
|
|
13
|
+
- id: check-shebang-scripts-are-executable
|
|
14
|
+
- id: check-symlinks
|
|
15
|
+
- id: check-toml
|
|
16
|
+
- id: check-vcs-permalinks
|
|
17
|
+
- id: check-xml
|
|
18
|
+
- id: check-yaml
|
|
19
|
+
- id: debug-statements
|
|
20
|
+
- id: destroyed-symlinks
|
|
21
|
+
- id: detect-private-key
|
|
22
|
+
- id: end-of-file-fixer
|
|
23
|
+
- id: fix-byte-order-marker
|
|
24
|
+
- id: forbid-submodules
|
|
25
|
+
- id: name-tests-test
|
|
26
|
+
args:
|
|
27
|
+
- --pytest-test-first
|
|
28
|
+
- id: no-commit-to-branch
|
|
29
|
+
- id: trailing-whitespace
|
|
30
|
+
|
|
31
|
+
- repo: https://github.com/commitizen-tools/commitizen
|
|
32
|
+
rev: v4.13.9
|
|
33
|
+
hooks:
|
|
34
|
+
- id: commitizen
|
|
35
|
+
- id: commitizen-branch
|
|
36
|
+
stages: [pre-push]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
|
+
|
|
7
|
+
This project adheres to both [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
|
|
8
|
+
|
|
9
|
+
## [UNRELEASED]
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `asyncify` function to wrap synchronous callables so they can be awaited, running them in a separate thread via `asyncio.to_thread`.
|
|
14
|
+
|
|
15
|
+
## [0.0.3] - 2026-02-26
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- This CHANGELOG file.
|
|
20
|
+
- pre-commit config.
|
|
21
|
+
- test coverage using Codecov
|
|
22
|
+
- LimitedTaskGroup to execute tasks limiting concurrency
|
|
23
|
+
- heartbeat function to run something at regular interval
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Package building and publishing are run in separate jobs to avoid credential sharing.
|
|
28
|
+
- Improved README
|
|
29
|
+
|
|
30
|
+
## [0.0.2] - 2025-12-23
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- `TaskGroup` now inherits from `asyncio.TaskGroup`.
|
|
35
|
+
|
|
36
|
+
## [0.0.1] - 2025-12-23
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- `TaskGroup` subclass with a cancel method.
|
|
41
|
+
- `checkpoint` function to yield control to the event loop.
|
|
42
|
+
- `sleep_forever` function.
|
|
43
|
+
|
|
44
|
+
[unreleased]: https://github.com/hartungstenio/asyncio-extensions/compare/0.0.3...HEAD
|
|
45
|
+
[0.0.3]: https://github.com/hartungstenio/asyncio-extensions/compare/0.0.2...0.0.3
|
|
46
|
+
[0.0.2]: https://github.com/hartungstenio/asyncio-extensions/compare/0.0.1...0.0.2
|
|
47
|
+
[0.0.1]: https://github.com/hartungstenio/asyncio-extensions/releases/tag/0.0.1
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: asyncio-extensions
|
|
3
|
+
Version: 0.0.4
|
|
4
|
+
Summary: Some (maybe) useful extensions for asyncio
|
|
5
|
+
Project-URL: Source, https://github.com/hartungstenio/asyncio-extensions
|
|
6
|
+
Project-URL: Changelog, https://github.com/hartungstenio/asyncio-extensions/blob/main/CHANGELOG.md
|
|
7
|
+
Project-URL: ReleaseNotes, https://github.com/hartungstenio/asyncio-extensions/releases
|
|
8
|
+
Project-URL: Documentation, https://github.com/hartungstenio/asyncio-extensions#readme
|
|
9
|
+
Project-URL: Issues, https://github.com/hartungstenio/asyncio-extensions/issues
|
|
10
|
+
Author-email: Christian Hartung <6785871+hartungstenio@users.noreply.github.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE.txt
|
|
13
|
+
Keywords: asyncio,tasks
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Framework :: AsyncIO
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Programming Language :: Python
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
23
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.11
|
|
27
|
+
Requires-Dist: typing-extensions; python_version < '3.12'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# asyncio-extensions
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/asyncio-extensions)
|
|
33
|
+
[](https://pypi.org/project/asyncio-extensions)
|
|
34
|
+
[](https://codecov.io/github/hartungstenio/asyncio-extensions)
|
|
35
|
+
[](https://results.pre-commit.ci/latest/github/hartungstenio/asyncio-extensions/main)
|
|
36
|
+
|
|
37
|
+
-----
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```console
|
|
42
|
+
pip install asyncio-extensions
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
### TaskGroup
|
|
48
|
+
|
|
49
|
+
`asyncio-extensions` provides a cancellable version of AsyncIO's `TaskGroup`.
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import asyncio
|
|
53
|
+
|
|
54
|
+
from asyncio_extensions import TaskGroup
|
|
55
|
+
|
|
56
|
+
queue = asyncio.Queue()
|
|
57
|
+
async with TaskGroup() as tg:
|
|
58
|
+
for _ in range(10):
|
|
59
|
+
tg.create_task(consume_from_queue(queue))
|
|
60
|
+
|
|
61
|
+
await add_to_queue(queue)
|
|
62
|
+
await queue.join()
|
|
63
|
+
tg.cancel()
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
#### LimitedTaskGroup
|
|
67
|
+
|
|
68
|
+
A version of `TaskGroup` that limits the number of concurrently running tasks.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import asyncio
|
|
72
|
+
|
|
73
|
+
from asyncio_extensions import LimitedTaskGroup
|
|
74
|
+
|
|
75
|
+
queue = asyncio.Queue()
|
|
76
|
+
async with LimitedTaskGroup(3) as tg:
|
|
77
|
+
for _ in range(50):
|
|
78
|
+
tg.create_task(some_expensive_operation(queue))
|
|
79
|
+
|
|
80
|
+
await add_to_queue(queue)
|
|
81
|
+
await queue.join()
|
|
82
|
+
tg.cancel()
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### checkpoint
|
|
86
|
+
|
|
87
|
+
The `checkpoint` function yields control to the event loop. It is a more elegant approach to do-nothing tasks, giving other tasks a chance to run.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from asyncio_extensions import checkpoint
|
|
91
|
+
|
|
92
|
+
class DummyChannel:
|
|
93
|
+
async def send_message(self, message):
|
|
94
|
+
await checkpoint()
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### sleep_forever
|
|
98
|
+
|
|
99
|
+
The `sleep_forever` function never returns. It simply keeps yielding control to the event loop.
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from asyncio_extensions import sleep_forever
|
|
103
|
+
|
|
104
|
+
class DummyChannel:
|
|
105
|
+
async def receive_message(self):
|
|
106
|
+
await sleep_forever()
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### heartbeat
|
|
110
|
+
|
|
111
|
+
The `heartbeat` function runs a given callable at a regular interval.
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from asyncio_extensions import heartbeat
|
|
115
|
+
|
|
116
|
+
async def ping():
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
async with TaskGroup() as tg:
|
|
120
|
+
tg.create_task(heartbeat(5, ping))
|
|
121
|
+
|
|
122
|
+
await some_long_running_process()
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### asyncify
|
|
126
|
+
|
|
127
|
+
The `asyncify` function ensures a callable can be awaited. If the callable is already a coroutine function, it is returned as-is. Otherwise, it is wrapped so that calls run in a separate thread.
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from asyncio_extensions import asyncify
|
|
131
|
+
|
|
132
|
+
def blocking_read(path: str) -> str:
|
|
133
|
+
with open(path) as f:
|
|
134
|
+
return f.read()
|
|
135
|
+
|
|
136
|
+
async def main():
|
|
137
|
+
content = await asyncify(blocking_read)("data.txt")
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
It can also be used as a decorator:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from asyncio_extensions import asyncify
|
|
144
|
+
|
|
145
|
+
@asyncify
|
|
146
|
+
def blocking_read(path: str) -> str:
|
|
147
|
+
with open(path) as f:
|
|
148
|
+
return f.read()
|
|
149
|
+
|
|
150
|
+
async def main():
|
|
151
|
+
content = await blocking_read("data.txt")
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Contributing
|
|
155
|
+
|
|
156
|
+
Pull requests are welcome. For major changes, please open an issue first
|
|
157
|
+
to discuss what you would like to change.
|
|
158
|
+
|
|
159
|
+
Please make sure to update tests as appropriate.
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
`asyncio-extensions` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# asyncio-extensions
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/asyncio-extensions)
|
|
4
|
+
[](https://pypi.org/project/asyncio-extensions)
|
|
5
|
+
[](https://codecov.io/github/hartungstenio/asyncio-extensions)
|
|
6
|
+
[](https://results.pre-commit.ci/latest/github/hartungstenio/asyncio-extensions/main)
|
|
7
|
+
|
|
8
|
+
-----
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```console
|
|
13
|
+
pip install asyncio-extensions
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
### TaskGroup
|
|
19
|
+
|
|
20
|
+
`asyncio-extensions` provides a cancellable version of AsyncIO's `TaskGroup`.
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import asyncio
|
|
24
|
+
|
|
25
|
+
from asyncio_extensions import TaskGroup
|
|
26
|
+
|
|
27
|
+
queue = asyncio.Queue()
|
|
28
|
+
async with TaskGroup() as tg:
|
|
29
|
+
for _ in range(10):
|
|
30
|
+
tg.create_task(consume_from_queue(queue))
|
|
31
|
+
|
|
32
|
+
await add_to_queue(queue)
|
|
33
|
+
await queue.join()
|
|
34
|
+
tg.cancel()
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
#### LimitedTaskGroup
|
|
38
|
+
|
|
39
|
+
A version of `TaskGroup` that limits the number of concurrently running tasks.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import asyncio
|
|
43
|
+
|
|
44
|
+
from asyncio_extensions import LimitedTaskGroup
|
|
45
|
+
|
|
46
|
+
queue = asyncio.Queue()
|
|
47
|
+
async with LimitedTaskGroup(3) as tg:
|
|
48
|
+
for _ in range(50):
|
|
49
|
+
tg.create_task(some_expensive_operation(queue))
|
|
50
|
+
|
|
51
|
+
await add_to_queue(queue)
|
|
52
|
+
await queue.join()
|
|
53
|
+
tg.cancel()
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### checkpoint
|
|
57
|
+
|
|
58
|
+
The `checkpoint` function yields control to the event loop. It is a more elegant approach to do-nothing tasks, giving other tasks a chance to run.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from asyncio_extensions import checkpoint
|
|
62
|
+
|
|
63
|
+
class DummyChannel:
|
|
64
|
+
async def send_message(self, message):
|
|
65
|
+
await checkpoint()
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### sleep_forever
|
|
69
|
+
|
|
70
|
+
The `sleep_forever` function never returns. It simply keeps yielding control to the event loop.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from asyncio_extensions import sleep_forever
|
|
74
|
+
|
|
75
|
+
class DummyChannel:
|
|
76
|
+
async def receive_message(self):
|
|
77
|
+
await sleep_forever()
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### heartbeat
|
|
81
|
+
|
|
82
|
+
The `heartbeat` function runs a given callable at a regular interval.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from asyncio_extensions import heartbeat
|
|
86
|
+
|
|
87
|
+
async def ping():
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
async with TaskGroup() as tg:
|
|
91
|
+
tg.create_task(heartbeat(5, ping))
|
|
92
|
+
|
|
93
|
+
await some_long_running_process()
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### asyncify
|
|
97
|
+
|
|
98
|
+
The `asyncify` function ensures a callable can be awaited. If the callable is already a coroutine function, it is returned as-is. Otherwise, it is wrapped so that calls run in a separate thread.
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from asyncio_extensions import asyncify
|
|
102
|
+
|
|
103
|
+
def blocking_read(path: str) -> str:
|
|
104
|
+
with open(path) as f:
|
|
105
|
+
return f.read()
|
|
106
|
+
|
|
107
|
+
async def main():
|
|
108
|
+
content = await asyncify(blocking_read)("data.txt")
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
It can also be used as a decorator:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from asyncio_extensions import asyncify
|
|
115
|
+
|
|
116
|
+
@asyncify
|
|
117
|
+
def blocking_read(path: str) -> str:
|
|
118
|
+
with open(path) as f:
|
|
119
|
+
return f.read()
|
|
120
|
+
|
|
121
|
+
async def main():
|
|
122
|
+
content = await blocking_read("data.txt")
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Contributing
|
|
126
|
+
|
|
127
|
+
Pull requests are welcome. For major changes, please open an issue first
|
|
128
|
+
to discuss what you would like to change.
|
|
129
|
+
|
|
130
|
+
Please make sure to update tests as appropriate.
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
`asyncio-extensions` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
|
|
@@ -23,13 +23,17 @@ classifiers = [
|
|
|
23
23
|
"Topic :: Software Development :: Libraries",
|
|
24
24
|
"Typing :: Typed",
|
|
25
25
|
]
|
|
26
|
-
dependencies = [
|
|
26
|
+
dependencies = [
|
|
27
|
+
"typing-extensions; python_version < '3.12'",
|
|
28
|
+
]
|
|
27
29
|
dynamic = ["version"]
|
|
28
30
|
|
|
29
31
|
[project.urls]
|
|
32
|
+
Source = "https://github.com/hartungstenio/asyncio-extensions"
|
|
33
|
+
Changelog = "https://github.com/hartungstenio/asyncio-extensions/blob/main/CHANGELOG.md"
|
|
34
|
+
ReleaseNotes = "https://github.com/hartungstenio/asyncio-extensions/releases"
|
|
30
35
|
Documentation = "https://github.com/hartungstenio/asyncio-extensions#readme"
|
|
31
36
|
Issues = "https://github.com/hartungstenio/asyncio-extensions/issues"
|
|
32
|
-
Source = "https://github.com/hartungstenio/asyncio-extensions"
|
|
33
37
|
|
|
34
38
|
[dependency-groups]
|
|
35
39
|
dev = [
|
|
@@ -86,6 +90,12 @@ extra-dependencies = [
|
|
|
86
90
|
[[tool.hatch.envs.hatch-test.matrix]]
|
|
87
91
|
python = ["3.11", "3.12", "3.13", "3.14"]
|
|
88
92
|
|
|
93
|
+
[tool.hatch.envs.hatch-test.scripts]
|
|
94
|
+
run = "pytest{env:HATCH_TEST_ARGS:} {args}"
|
|
95
|
+
run-cov = "coverage run -m pytest{env:HATCH_TEST_ARGS:} {args}"
|
|
96
|
+
cov-combine = "coverage combine"
|
|
97
|
+
cov-report = "coverage xml"
|
|
98
|
+
|
|
89
99
|
[tool.pytest]
|
|
90
100
|
asyncio_default_fixture_loop_scope = "function"
|
|
91
101
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Utility functions and extensions for AsyncIO."""
|
|
2
|
+
|
|
3
|
+
from .bridge import asyncify
|
|
4
|
+
from .taskgroups import (
|
|
5
|
+
LimitedTaskGroup,
|
|
6
|
+
TaskGroup,
|
|
7
|
+
TerminateTaskGroup,
|
|
8
|
+
force_terminate_task_group,
|
|
9
|
+
)
|
|
10
|
+
from .utils import checkpoint, heartbeat, sleep_forever
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"LimitedTaskGroup",
|
|
14
|
+
"TaskGroup",
|
|
15
|
+
"TerminateTaskGroup",
|
|
16
|
+
"asyncify",
|
|
17
|
+
"checkpoint",
|
|
18
|
+
"force_terminate_task_group",
|
|
19
|
+
"heartbeat",
|
|
20
|
+
"sleep_forever",
|
|
21
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import sys
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from typing import ParamSpec, TypeVar
|
|
5
|
+
|
|
6
|
+
if sys.version_info >= (3, 12):
|
|
7
|
+
from typing import override
|
|
8
|
+
else:
|
|
9
|
+
from typing_extensions import override
|
|
10
|
+
|
|
11
|
+
if sys.version_info >= (3, 13):
|
|
12
|
+
from typing import TypeIs
|
|
13
|
+
else:
|
|
14
|
+
from typing_extensions import TypeIs
|
|
15
|
+
|
|
16
|
+
_P = ParamSpec("_P")
|
|
17
|
+
_R = TypeVar("_R")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_awaitable(
|
|
21
|
+
func: Callable[_P, _R] | Callable[_P, Awaitable[_R]],
|
|
22
|
+
) -> TypeIs[Callable[_P, Awaitable[_R]]]:
|
|
23
|
+
return inspect.iscoroutinefunction(func)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"TypeIs",
|
|
28
|
+
"is_awaitable",
|
|
29
|
+
"override",
|
|
30
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Utilities for bridging synchronous and asynchronous code."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from typing import ParamSpec, TypeVar, overload
|
|
7
|
+
|
|
8
|
+
from ._compat import is_awaitable
|
|
9
|
+
|
|
10
|
+
_P = ParamSpec("_P")
|
|
11
|
+
_R = TypeVar("_R")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@overload
|
|
15
|
+
def asyncify(func: Callable[_P, Awaitable[_R]], /) -> Callable[_P, Awaitable[_R]]: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@overload
|
|
19
|
+
def asyncify(func: Callable[_P, _R], /) -> Callable[_P, Awaitable[_R]]: ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def asyncify(
|
|
23
|
+
func: Callable[_P, _R] | Callable[_P, Awaitable[_R]], /
|
|
24
|
+
) -> Callable[_P, Awaitable[_R]]:
|
|
25
|
+
"""Ensure that a callable can be awaited.
|
|
26
|
+
|
|
27
|
+
If ``func`` is already a coroutine function, it is returned as-is.
|
|
28
|
+
Otherwise, it is wrapped with :func:`asyncio.to_thread`, so that calls to
|
|
29
|
+
the returned callable will run ``func`` in a separate thread and return an
|
|
30
|
+
awaitable result.
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
func: A callable that is either synchronous or already a coroutine function.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
A callable with the same signature as ``func`` that returns an
|
|
38
|
+
:class:`~collections.abc.Awaitable`.
|
|
39
|
+
"""
|
|
40
|
+
if is_awaitable(func):
|
|
41
|
+
return func
|
|
42
|
+
|
|
43
|
+
@wraps(func)
|
|
44
|
+
async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
|
|
45
|
+
return await asyncio.to_thread(func, *args, **kwargs)
|
|
46
|
+
|
|
47
|
+
return wrapper
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Extensions to interact with TaskGroups."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Coroutine
|
|
6
|
+
from contextvars import Context
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
from typing import Any, Never, TypedDict, TypeVar, Unpack
|
|
9
|
+
|
|
10
|
+
from ._compat import override
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _CreateTaskParams(TypedDict, total=False):
|
|
16
|
+
"""Parameters for creating a task in a TaskGroup."""
|
|
17
|
+
|
|
18
|
+
name: str | None
|
|
19
|
+
context: Context | None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if sys.version_info >= (3, 14):
|
|
23
|
+
|
|
24
|
+
class CreateTaskParams(_CreateTaskParams):
|
|
25
|
+
"""Parameters for creating a task in a TaskGroup."""
|
|
26
|
+
|
|
27
|
+
eager_start: bool | None
|
|
28
|
+
else:
|
|
29
|
+
CreateTaskParams = _CreateTaskParams
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TerminateTaskGroup(BaseException):
|
|
33
|
+
"""Signal to terminate a task group.
|
|
34
|
+
|
|
35
|
+
Technically not an exception, so inherits from `BaseException`.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def force_terminate_task_group() -> Never:
|
|
40
|
+
"""Force termination of a task group."""
|
|
41
|
+
raise TerminateTaskGroup
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TaskGroup(asyncio.TaskGroup):
|
|
45
|
+
"""A version of asyncio.TaskGroup with a cancel method."""
|
|
46
|
+
|
|
47
|
+
async def __aexit__( # noqa: D105
|
|
48
|
+
self,
|
|
49
|
+
exc_type: type[BaseException] | None,
|
|
50
|
+
exc_value: BaseException | None,
|
|
51
|
+
traceback: TracebackType | None,
|
|
52
|
+
) -> None:
|
|
53
|
+
try: # noqa: SIM105
|
|
54
|
+
await super().__aexit__(exc_type, exc_value, traceback)
|
|
55
|
+
except* TerminateTaskGroup:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
def cancel(self) -> None:
|
|
59
|
+
"""Cancel any remaining task in the TaskGroup."""
|
|
60
|
+
self.create_task(force_terminate_task_group())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class LimitedTaskGroup(TaskGroup):
|
|
64
|
+
"""A TaskGroup that limits the number of concurrent tasks."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, max_concurrent: int) -> None:
|
|
67
|
+
"""Initialize the LimitedTaskGroup.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
max_concurrent: Maximum number of concurrent tasks.
|
|
71
|
+
"""
|
|
72
|
+
super().__init__()
|
|
73
|
+
self._semaphore = asyncio.Semaphore(max_concurrent)
|
|
74
|
+
|
|
75
|
+
async def _task_wrapper(self, coro: Coroutine[Any, Any, T]) -> T:
|
|
76
|
+
async with self._semaphore:
|
|
77
|
+
return await coro
|
|
78
|
+
|
|
79
|
+
@override
|
|
80
|
+
def create_task(
|
|
81
|
+
self,
|
|
82
|
+
coro: Coroutine[Any, Any, T],
|
|
83
|
+
**kwargs: Unpack[CreateTaskParams],
|
|
84
|
+
) -> asyncio.Task[T]:
|
|
85
|
+
"""Create a task within the limited concurrency context.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
coro: The coroutine to run as a task.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
The created asyncio Task.
|
|
92
|
+
"""
|
|
93
|
+
return super().create_task(self._task_wrapper(coro), **kwargs)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Utility functions."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Awaitable, Callable, Generator
|
|
5
|
+
from typing import Any, Never, ParamSpec, TypeVar
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
P = ParamSpec("P")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class YieldToEventLoop:
|
|
12
|
+
"""Helper class to give control back to the event loop."""
|
|
13
|
+
|
|
14
|
+
def __await__(self) -> Generator[None, Any, None]:
|
|
15
|
+
"""Yield control to the event loop."""
|
|
16
|
+
yield
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def checkpoint() -> None:
|
|
20
|
+
"""
|
|
21
|
+
Give control back to the eventloop.
|
|
22
|
+
|
|
23
|
+
This has the same effect of and asyncio.sleep(0), but it is more semantic
|
|
24
|
+
"""
|
|
25
|
+
return await YieldToEventLoop()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def sleep_forever() -> Never:
|
|
29
|
+
"""Sleeps forever."""
|
|
30
|
+
while True:
|
|
31
|
+
await YieldToEventLoop()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def noop(result: T) -> T:
|
|
35
|
+
"""Give control back to the event loop once, then returns the given argument."""
|
|
36
|
+
await YieldToEventLoop()
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def heartbeat(
|
|
41
|
+
interval: float,
|
|
42
|
+
fn: Callable[P, Awaitable[T]],
|
|
43
|
+
*args: P.args,
|
|
44
|
+
**kwargs: P.kwargs,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Run :arg:`fn` with the given args at regular interval."""
|
|
47
|
+
while True:
|
|
48
|
+
await asyncio.sleep(interval)
|
|
49
|
+
await fn(*args, **kwargs)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import threading
|
|
3
|
+
from collections.abc import Awaitable
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from asyncio_extensions.bridge import asyncify
|
|
8
|
+
|
|
9
|
+
pytestmark = pytest.mark.asyncio
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def test_asyncify_sync_returns_awaitable() -> None:
|
|
13
|
+
def add(a: int, b: int) -> int:
|
|
14
|
+
return a + b
|
|
15
|
+
|
|
16
|
+
result = asyncify(add)(1, 2)
|
|
17
|
+
|
|
18
|
+
assert isinstance(result, Awaitable)
|
|
19
|
+
assert await result == add(1, 2)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def test_asyncify_sync_runs_in_thread() -> None:
|
|
23
|
+
caller_thread = threading.current_thread()
|
|
24
|
+
func_thread: threading.Thread | None = None
|
|
25
|
+
|
|
26
|
+
def capture_thread() -> None:
|
|
27
|
+
nonlocal func_thread
|
|
28
|
+
func_thread = threading.current_thread()
|
|
29
|
+
|
|
30
|
+
await asyncify(capture_thread)()
|
|
31
|
+
|
|
32
|
+
assert func_thread is not None
|
|
33
|
+
assert func_thread is not caller_thread
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def test_asyncify_sync_preserves_wraps_metadata() -> None:
|
|
37
|
+
def my_func() -> None:
|
|
38
|
+
"""My docstring."""
|
|
39
|
+
|
|
40
|
+
wrapped = asyncify(my_func)
|
|
41
|
+
|
|
42
|
+
assert wrapped.__name__ == "my_func"
|
|
43
|
+
assert wrapped.__doc__ == "My docstring."
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def test_asyncify_async_returns_same_function() -> None:
|
|
47
|
+
async def coro() -> int:
|
|
48
|
+
return 42
|
|
49
|
+
|
|
50
|
+
assert asyncify(coro) is coro
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def test_asyncify_as_decorator_on_sync() -> None:
|
|
54
|
+
@asyncify
|
|
55
|
+
def multiply(a: int, b: int) -> int:
|
|
56
|
+
return a * b
|
|
57
|
+
|
|
58
|
+
result = await multiply(3, 4)
|
|
59
|
+
|
|
60
|
+
assert result == 12 # noqa: PLR2004
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def test_asyncify_as_decorator_on_async() -> None:
|
|
64
|
+
@asyncify
|
|
65
|
+
async def subtract(a: int, b: int) -> int:
|
|
66
|
+
return a - b
|
|
67
|
+
|
|
68
|
+
result = await subtract(10, 3)
|
|
69
|
+
|
|
70
|
+
assert result == 7 # noqa: PLR2004
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def test_asyncify_decorator_preserves_name() -> None:
|
|
74
|
+
@asyncify
|
|
75
|
+
def named_func() -> None:
|
|
76
|
+
"""Has a name."""
|
|
77
|
+
|
|
78
|
+
assert named_func.__name__ == "named_func"
|
|
79
|
+
assert named_func.__doc__ == "Has a name."
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def test_asyncify_decorator_runs_sync_in_thread() -> None:
|
|
83
|
+
caller_thread = threading.current_thread()
|
|
84
|
+
seen_thread: threading.Thread | None = None
|
|
85
|
+
|
|
86
|
+
@asyncify
|
|
87
|
+
def work() -> None:
|
|
88
|
+
nonlocal seen_thread
|
|
89
|
+
seen_thread = threading.current_thread()
|
|
90
|
+
|
|
91
|
+
await work()
|
|
92
|
+
|
|
93
|
+
assert seen_thread is not None
|
|
94
|
+
assert seen_thread is not caller_thread
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def test_asyncify_decorator_concurrent_calls() -> None:
|
|
98
|
+
results: list[int] = []
|
|
99
|
+
|
|
100
|
+
@asyncify
|
|
101
|
+
def append(value: int) -> None:
|
|
102
|
+
results.append(value)
|
|
103
|
+
|
|
104
|
+
await asyncio.gather(append(1), append(2), append(3))
|
|
105
|
+
|
|
106
|
+
assert sorted(results) == [1, 2, 3]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import sys
|
|
3
|
+
from contextvars import copy_context
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from asyncio_extensions import checkpoint
|
|
8
|
+
from asyncio_extensions.taskgroups import (
|
|
9
|
+
LimitedTaskGroup,
|
|
10
|
+
TaskGroup,
|
|
11
|
+
TerminateTaskGroup,
|
|
12
|
+
force_terminate_task_group,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from . import noop
|
|
16
|
+
|
|
17
|
+
pytestmark = pytest.mark.asyncio
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def test_force_terminate_task_group() -> None:
|
|
21
|
+
with pytest.RaisesGroup(TerminateTaskGroup):
|
|
22
|
+
async with asyncio.TaskGroup() as tg:
|
|
23
|
+
tg.create_task(asyncio.sleep(1000))
|
|
24
|
+
tg.create_task(force_terminate_task_group())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestTaskGroup:
|
|
28
|
+
async def test_runs_tasks(self) -> None:
|
|
29
|
+
async with TaskGroup() as tg:
|
|
30
|
+
tasks = [tg.create_task(noop()) for _ in range(10)]
|
|
31
|
+
|
|
32
|
+
assert all(t.done() for t in tasks)
|
|
33
|
+
assert not any(t.cancelled() for t in tasks)
|
|
34
|
+
|
|
35
|
+
async def test_cancel_with_running_task(self) -> None:
|
|
36
|
+
async with (
|
|
37
|
+
asyncio.timeout(1),
|
|
38
|
+
TaskGroup() as tg,
|
|
39
|
+
):
|
|
40
|
+
task = tg.create_task(asyncio.sleep(10))
|
|
41
|
+
tg.cancel()
|
|
42
|
+
assert task.cancelled() is True
|
|
43
|
+
|
|
44
|
+
async def test_cancel_without_running_task(self) -> None:
|
|
45
|
+
async with TaskGroup() as tg:
|
|
46
|
+
tg.cancel()
|
|
47
|
+
|
|
48
|
+
async def test_cancel_with_done_task(self) -> None:
|
|
49
|
+
async with (
|
|
50
|
+
asyncio.timeout(1),
|
|
51
|
+
TaskGroup() as tg,
|
|
52
|
+
):
|
|
53
|
+
task = tg.create_task(noop())
|
|
54
|
+
await task
|
|
55
|
+
tg.cancel()
|
|
56
|
+
|
|
57
|
+
assert task.done() is True
|
|
58
|
+
assert task.cancelled() is False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestLimitedTaskGroup:
|
|
62
|
+
async def test_limits_concurrency(self) -> None:
|
|
63
|
+
max_concurrent = 3
|
|
64
|
+
current_concurrent = 0
|
|
65
|
+
peak_concurrent = 0
|
|
66
|
+
|
|
67
|
+
async def task() -> None:
|
|
68
|
+
nonlocal current_concurrent, peak_concurrent
|
|
69
|
+
current_concurrent += 1
|
|
70
|
+
peak_concurrent = max(peak_concurrent, current_concurrent)
|
|
71
|
+
await checkpoint()
|
|
72
|
+
current_concurrent -= 1
|
|
73
|
+
|
|
74
|
+
async with LimitedTaskGroup(max_concurrent=max_concurrent) as tg:
|
|
75
|
+
for _ in range(100):
|
|
76
|
+
tg.create_task(task())
|
|
77
|
+
|
|
78
|
+
assert peak_concurrent <= max_concurrent
|
|
79
|
+
|
|
80
|
+
async def test_create_task_params(self) -> None:
|
|
81
|
+
ctx = copy_context()
|
|
82
|
+
async with LimitedTaskGroup(max_concurrent=2) as tg:
|
|
83
|
+
task = tg.create_task(noop(), name="test_task", context=ctx)
|
|
84
|
+
|
|
85
|
+
assert task.get_name() == "test_task"
|
|
86
|
+
|
|
87
|
+
if sys.version_info >= (3, 12):
|
|
88
|
+
assert task.get_context() is ctx
|
|
89
|
+
|
|
90
|
+
@pytest.mark.skipif(sys.version_info < (3, 14), reason="Requires Python 3.14+")
|
|
91
|
+
async def test_create_task_params_eager(self) -> None:
|
|
92
|
+
ctx = copy_context()
|
|
93
|
+
async with LimitedTaskGroup(max_concurrent=2) as tg:
|
|
94
|
+
task = tg.create_task(
|
|
95
|
+
noop(),
|
|
96
|
+
name="test_task",
|
|
97
|
+
context=ctx,
|
|
98
|
+
eager_start=True,
|
|
99
|
+
)
|
|
100
|
+
tg.cancel()
|
|
101
|
+
|
|
102
|
+
assert task.get_name() == "test_task"
|
|
103
|
+
assert task.get_context() is ctx
|
|
104
|
+
assert task.done() is True
|
|
105
|
+
|
|
106
|
+
async def test_can_cancel(self) -> None:
|
|
107
|
+
tg = LimitedTaskGroup(max_concurrent=2)
|
|
108
|
+
assert hasattr(tg, "cancel")
|
|
109
|
+
assert callable(tg.cancel)
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
from contextlib import suppress
|
|
3
|
+
from unittest import mock
|
|
2
4
|
|
|
3
5
|
import pytest
|
|
4
6
|
from faker import Faker
|
|
5
7
|
|
|
6
|
-
from asyncio_extensions.utils import checkpoint, sleep_forever
|
|
8
|
+
from asyncio_extensions.utils import checkpoint, heartbeat, sleep_forever
|
|
7
9
|
|
|
8
|
-
from .
|
|
10
|
+
from . import noop
|
|
9
11
|
|
|
10
12
|
pytestmark = pytest.mark.asyncio
|
|
11
13
|
|
|
@@ -36,3 +38,12 @@ async def test_sleep_forever_cycles_event_loop(faker: Faker) -> None:
|
|
|
36
38
|
|
|
37
39
|
assert all(t.done() for t in tasks)
|
|
38
40
|
assert not any(t.cancelled() for t in tasks)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def test_heartbeat() -> None:
|
|
44
|
+
mock_fn = mock.AsyncMock()
|
|
45
|
+
with suppress(TimeoutError):
|
|
46
|
+
async with asyncio.timeout(5):
|
|
47
|
+
await heartbeat(1, mock_fn, 1, 2, name="a")
|
|
48
|
+
|
|
49
|
+
assert mock_fn.await_args_list == [mock.call(1, 2, name="a")] * 4
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
name: Build & Release
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
release:
|
|
5
|
-
types: [published]
|
|
6
|
-
|
|
7
|
-
permissions:
|
|
8
|
-
contents: read
|
|
9
|
-
|
|
10
|
-
jobs:
|
|
11
|
-
deploy:
|
|
12
|
-
runs-on: ubuntu-latest
|
|
13
|
-
environment:
|
|
14
|
-
name: release
|
|
15
|
-
url: https://pypi.org/p/asyncio-extensions
|
|
16
|
-
permissions:
|
|
17
|
-
id-token: write
|
|
18
|
-
steps:
|
|
19
|
-
- uses: actions/checkout@v6
|
|
20
|
-
|
|
21
|
-
- uses: actions/setup-python@v6
|
|
22
|
-
with:
|
|
23
|
-
python-version: "3.x"
|
|
24
|
-
|
|
25
|
-
- name: Install dependencies
|
|
26
|
-
run: |
|
|
27
|
-
python -m pip install --upgrade pip build
|
|
28
|
-
|
|
29
|
-
- name: Build package
|
|
30
|
-
run: python -m build
|
|
31
|
-
|
|
32
|
-
- name: Publish package
|
|
33
|
-
uses: pypa/gh-action-pypi-publish@v1.13.0
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: asyncio-extensions
|
|
3
|
-
Version: 0.0.2
|
|
4
|
-
Summary: Some (maybe) useful extensions for asyncio
|
|
5
|
-
Project-URL: Documentation, https://github.com/hartungstenio/asyncio-extensions#readme
|
|
6
|
-
Project-URL: Issues, https://github.com/hartungstenio/asyncio-extensions/issues
|
|
7
|
-
Project-URL: Source, https://github.com/hartungstenio/asyncio-extensions
|
|
8
|
-
Author-email: Christian Hartung <6785871+hartungstenio@users.noreply.github.com>
|
|
9
|
-
License-Expression: MIT
|
|
10
|
-
License-File: LICENSE.txt
|
|
11
|
-
Keywords: asyncio,tasks
|
|
12
|
-
Classifier: Development Status :: 4 - Beta
|
|
13
|
-
Classifier: Framework :: AsyncIO
|
|
14
|
-
Classifier: Intended Audience :: Developers
|
|
15
|
-
Classifier: Programming Language :: Python
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
-
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
21
|
-
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
22
|
-
Classifier: Topic :: Software Development :: Libraries
|
|
23
|
-
Classifier: Typing :: Typed
|
|
24
|
-
Requires-Python: >=3.11
|
|
25
|
-
Description-Content-Type: text/markdown
|
|
26
|
-
|
|
27
|
-
# asyncio-extensions
|
|
28
|
-
|
|
29
|
-
[](https://pypi.org/project/asyncio-extensions)
|
|
30
|
-
[](https://pypi.org/project/asyncio-extensions)
|
|
31
|
-
|
|
32
|
-
-----
|
|
33
|
-
|
|
34
|
-
## Table of Contents
|
|
35
|
-
|
|
36
|
-
- [Installation](#installation)
|
|
37
|
-
- [License](#license)
|
|
38
|
-
|
|
39
|
-
## Installation
|
|
40
|
-
|
|
41
|
-
```console
|
|
42
|
-
pip install asyncio-extensions
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
## License
|
|
46
|
-
|
|
47
|
-
`asyncio-extensions` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# asyncio-extensions
|
|
2
|
-
|
|
3
|
-
[](https://pypi.org/project/asyncio-extensions)
|
|
4
|
-
[](https://pypi.org/project/asyncio-extensions)
|
|
5
|
-
|
|
6
|
-
-----
|
|
7
|
-
|
|
8
|
-
## Table of Contents
|
|
9
|
-
|
|
10
|
-
- [Installation](#installation)
|
|
11
|
-
- [License](#license)
|
|
12
|
-
|
|
13
|
-
## Installation
|
|
14
|
-
|
|
15
|
-
```console
|
|
16
|
-
pip install asyncio-extensions
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
## License
|
|
20
|
-
|
|
21
|
-
`asyncio-extensions` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
"""Utility functions and extensions for AsyncIO."""
|
|
2
|
-
|
|
3
|
-
from .taskgroups import TaskGroup, TerminateTaskGroup, force_terminate_task_group
|
|
4
|
-
from .utils import checkpoint, sleep_forever
|
|
5
|
-
|
|
6
|
-
__all__ = [
|
|
7
|
-
"TaskGroup",
|
|
8
|
-
"TerminateTaskGroup",
|
|
9
|
-
"checkpoint",
|
|
10
|
-
"force_terminate_task_group",
|
|
11
|
-
"sleep_forever",
|
|
12
|
-
]
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
"""Extensions to interact with TaskGroups."""
|
|
2
|
-
|
|
3
|
-
import asyncio
|
|
4
|
-
from types import TracebackType
|
|
5
|
-
from typing import Never, TypeVar
|
|
6
|
-
|
|
7
|
-
T = TypeVar("T")
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class TerminateTaskGroup(BaseException):
|
|
11
|
-
"""Signal to terminate a task group.
|
|
12
|
-
|
|
13
|
-
Technically not an exception, so inherits from `BaseException`.
|
|
14
|
-
"""
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
async def force_terminate_task_group() -> Never:
|
|
18
|
-
"""Force termination of a task group."""
|
|
19
|
-
raise TerminateTaskGroup
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
class TaskGroup(asyncio.TaskGroup):
|
|
23
|
-
"""A version of asyncio.TaskGroup with a cancel method."""
|
|
24
|
-
|
|
25
|
-
async def __aexit__( # noqa: D105
|
|
26
|
-
self,
|
|
27
|
-
exc_type: type[BaseException] | None,
|
|
28
|
-
exc_value: BaseException | None,
|
|
29
|
-
traceback: TracebackType | None,
|
|
30
|
-
) -> None:
|
|
31
|
-
try: # noqa: SIM105
|
|
32
|
-
await super().__aexit__(exc_type, exc_value, traceback)
|
|
33
|
-
except* TerminateTaskGroup:
|
|
34
|
-
pass
|
|
35
|
-
|
|
36
|
-
def cancel(self) -> None:
|
|
37
|
-
"""Cancel any remaining task in the TaskGroup."""
|
|
38
|
-
self.create_task(force_terminate_task_group())
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
"""Utility functions."""
|
|
2
|
-
|
|
3
|
-
from collections.abc import Generator
|
|
4
|
-
from typing import Any, Never
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class YieldToEventLoop:
|
|
8
|
-
"""Helper class to give control back to the event loop."""
|
|
9
|
-
|
|
10
|
-
def __await__(self) -> Generator[None, Any, None]:
|
|
11
|
-
"""Yield control to the event loop."""
|
|
12
|
-
yield
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
async def checkpoint() -> None:
|
|
16
|
-
"""
|
|
17
|
-
Give control back to the eventloop.
|
|
18
|
-
|
|
19
|
-
This has the same effect of and asyncio.sleep(0), but it is more semantic
|
|
20
|
-
"""
|
|
21
|
-
return await YieldToEventLoop()
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
async def sleep_forever() -> Never:
|
|
25
|
-
"""Sleeps forever."""
|
|
26
|
-
while True:
|
|
27
|
-
await YieldToEventLoop()
|
|
File without changes
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
|
|
5
|
-
from asyncio_extensions.taskgroups import (
|
|
6
|
-
TaskGroup,
|
|
7
|
-
TerminateTaskGroup,
|
|
8
|
-
force_terminate_task_group,
|
|
9
|
-
)
|
|
10
|
-
|
|
11
|
-
from .utils import noop
|
|
12
|
-
|
|
13
|
-
pytestmark = pytest.mark.asyncio
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
async def test_force_terminate_task_group() -> None:
|
|
17
|
-
with pytest.RaisesGroup(TerminateTaskGroup):
|
|
18
|
-
async with asyncio.TaskGroup() as tg:
|
|
19
|
-
tg.create_task(asyncio.sleep(1000))
|
|
20
|
-
tg.create_task(force_terminate_task_group())
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
class TestTaskGroup:
|
|
24
|
-
async def test_runs_tasks(self) -> None:
|
|
25
|
-
async with TaskGroup() as tg:
|
|
26
|
-
tasks = [tg.create_task(noop()) for _ in range(10)]
|
|
27
|
-
|
|
28
|
-
assert all(t.done() for t in tasks)
|
|
29
|
-
assert not any(t.cancelled() for t in tasks)
|
|
30
|
-
|
|
31
|
-
async def test_cancel_with_running_task(self) -> None:
|
|
32
|
-
async with (
|
|
33
|
-
asyncio.timeout(1),
|
|
34
|
-
TaskGroup() as tg,
|
|
35
|
-
):
|
|
36
|
-
task = tg.create_task(asyncio.sleep(10))
|
|
37
|
-
tg.cancel()
|
|
38
|
-
assert task.cancelled() is True
|
|
39
|
-
|
|
40
|
-
async def test_cancel_without_running_task(self) -> None:
|
|
41
|
-
async with TaskGroup() as tg:
|
|
42
|
-
tg.cancel()
|
|
43
|
-
|
|
44
|
-
async def test_cancel_with_done_task(self) -> None:
|
|
45
|
-
async with (
|
|
46
|
-
asyncio.timeout(1),
|
|
47
|
-
TaskGroup() as tg,
|
|
48
|
-
):
|
|
49
|
-
task = tg.create_task(noop())
|
|
50
|
-
await task
|
|
51
|
-
tg.cancel()
|
|
52
|
-
|
|
53
|
-
assert task.done() is True
|
|
54
|
-
assert task.cancelled() is False
|
|
File without changes
|
|
File without changes
|
|
File without changes
|