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.
Files changed (30) hide show
  1. {asyncio_extensions-0.0.2 → asyncio_extensions-0.0.4}/.github/dependabot.yml +0 -4
  2. asyncio_extensions-0.0.4/.github/workflows/release.yml +57 -0
  3. {asyncio_extensions-0.0.2 → asyncio_extensions-0.0.4}/.github/workflows/tests.yml +15 -3
  4. asyncio_extensions-0.0.4/.gitignore +5 -0
  5. asyncio_extensions-0.0.4/.pre-commit-config.yaml +36 -0
  6. asyncio_extensions-0.0.4/CHANGELOG.md +47 -0
  7. asyncio_extensions-0.0.4/PKG-INFO +163 -0
  8. asyncio_extensions-0.0.4/README.md +134 -0
  9. asyncio_extensions-0.0.4/codecov.yml +13 -0
  10. {asyncio_extensions-0.0.2 → asyncio_extensions-0.0.4}/pyproject.toml +12 -2
  11. asyncio_extensions-0.0.4/src/asyncio_extensions/__init__.py +21 -0
  12. asyncio_extensions-0.0.4/src/asyncio_extensions/_compat.py +30 -0
  13. asyncio_extensions-0.0.4/src/asyncio_extensions/bridge.py +47 -0
  14. asyncio_extensions-0.0.4/src/asyncio_extensions/taskgroups.py +93 -0
  15. asyncio_extensions-0.0.4/src/asyncio_extensions/utils.py +49 -0
  16. asyncio_extensions-0.0.4/tests/test_bridge.py +106 -0
  17. asyncio_extensions-0.0.4/tests/test_taskgroups.py +109 -0
  18. {asyncio_extensions-0.0.2 → asyncio_extensions-0.0.4}/tests/test_utils.py +13 -2
  19. asyncio_extensions-0.0.2/.github/workflows/release.yml +0 -33
  20. asyncio_extensions-0.0.2/.gitignore +0 -2
  21. asyncio_extensions-0.0.2/PKG-INFO +0 -47
  22. asyncio_extensions-0.0.2/README.md +0 -21
  23. asyncio_extensions-0.0.2/src/asyncio_extensions/__init__.py +0 -12
  24. asyncio_extensions-0.0.2/src/asyncio_extensions/taskgroups.py +0 -38
  25. asyncio_extensions-0.0.2/src/asyncio_extensions/utils.py +0 -27
  26. asyncio_extensions-0.0.2/tests/__init__.py +0 -0
  27. asyncio_extensions-0.0.2/tests/test_taskgroups.py +0 -54
  28. {asyncio_extensions-0.0.2 → asyncio_extensions-0.0.4}/LICENSE.txt +0 -0
  29. {asyncio_extensions-0.0.2 → asyncio_extensions-0.0.4}/src/asyncio_extensions/py.typed +0 -0
  30. /asyncio_extensions-0.0.2/tests/utils.py → /asyncio_extensions-0.0.4/tests/__init__.py +0 -0
@@ -6,10 +6,6 @@
6
6
 
7
7
  version: 2
8
8
  updates:
9
- - package-ecosystem: "devcontainers"
10
- directory: "/"
11
- schedule:
12
- interval: weekly
13
9
  - package-ecosystem: "github-actions"
14
10
  directory: "/"
15
11
  schedule:
@@ -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 install hatch
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 install hatch
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,5 @@
1
+ __pycache__/
2
+ dist/
3
+ .coverage*
4
+ coverage.xml
5
+ junit.xml
@@ -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
+ [![PyPI - Version](https://img.shields.io/pypi/v/asyncio-extensions.svg)](https://pypi.org/project/asyncio-extensions)
33
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/asyncio-extensions.svg)](https://pypi.org/project/asyncio-extensions)
34
+ [![codecov](https://codecov.io/github/hartungstenio/asyncio-extensions/graph/badge.svg?token=1MEZ4NBUJH)](https://codecov.io/github/hartungstenio/asyncio-extensions)
35
+ [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/hartungstenio/asyncio-extensions/main.svg)](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
+ [![PyPI - Version](https://img.shields.io/pypi/v/asyncio-extensions.svg)](https://pypi.org/project/asyncio-extensions)
4
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/asyncio-extensions.svg)](https://pypi.org/project/asyncio-extensions)
5
+ [![codecov](https://codecov.io/github/hartungstenio/asyncio-extensions/graph/badge.svg?token=1MEZ4NBUJH)](https://codecov.io/github/hartungstenio/asyncio-extensions)
6
+ [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/hartungstenio/asyncio-extensions/main.svg)](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.
@@ -0,0 +1,13 @@
1
+ coverage:
2
+ range: 80...95
3
+ round: down
4
+ precision: 2
5
+ status:
6
+ project:
7
+ default:
8
+ target: auto
9
+ threshold: 0%
10
+ patch:
11
+ default:
12
+ target: auto
13
+ threshold: 0%
@@ -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 .utils import noop
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,2 +0,0 @@
1
- __pycache__/
2
- dist/
@@ -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
- [![PyPI - Version](https://img.shields.io/pypi/v/asyncio-extensions.svg)](https://pypi.org/project/asyncio-extensions)
30
- [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/asyncio-extensions.svg)](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
- [![PyPI - Version](https://img.shields.io/pypi/v/asyncio-extensions.svg)](https://pypi.org/project/asyncio-extensions)
4
- [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/asyncio-extensions.svg)](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