asyncio-extensions 0.0.2__tar.gz → 0.0.3__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 (28) hide show
  1. {asyncio_extensions-0.0.2 → asyncio_extensions-0.0.3}/.github/dependabot.yml +0 -4
  2. asyncio_extensions-0.0.3/.github/workflows/release.yml +57 -0
  3. {asyncio_extensions-0.0.2 → asyncio_extensions-0.0.3}/.github/workflows/tests.yml +15 -3
  4. asyncio_extensions-0.0.3/.gitignore +5 -0
  5. asyncio_extensions-0.0.3/.pre-commit-config.yaml +36 -0
  6. asyncio_extensions-0.0.3/CHANGELOG.md +43 -0
  7. asyncio_extensions-0.0.3/PKG-INFO +133 -0
  8. asyncio_extensions-0.0.3/README.md +104 -0
  9. asyncio_extensions-0.0.3/codecov.yml +13 -0
  10. {asyncio_extensions-0.0.2 → asyncio_extensions-0.0.3}/pyproject.toml +12 -2
  11. asyncio_extensions-0.0.3/src/asyncio_extensions/__init__.py +19 -0
  12. asyncio_extensions-0.0.3/src/asyncio_extensions/_compat.py +10 -0
  13. asyncio_extensions-0.0.3/src/asyncio_extensions/taskgroups.py +93 -0
  14. asyncio_extensions-0.0.3/src/asyncio_extensions/utils.py +49 -0
  15. asyncio_extensions-0.0.3/tests/test_taskgroups.py +109 -0
  16. {asyncio_extensions-0.0.2 → asyncio_extensions-0.0.3}/tests/test_utils.py +13 -2
  17. asyncio_extensions-0.0.2/.github/workflows/release.yml +0 -33
  18. asyncio_extensions-0.0.2/.gitignore +0 -2
  19. asyncio_extensions-0.0.2/PKG-INFO +0 -47
  20. asyncio_extensions-0.0.2/README.md +0 -21
  21. asyncio_extensions-0.0.2/src/asyncio_extensions/__init__.py +0 -12
  22. asyncio_extensions-0.0.2/src/asyncio_extensions/taskgroups.py +0 -38
  23. asyncio_extensions-0.0.2/src/asyncio_extensions/utils.py +0 -27
  24. asyncio_extensions-0.0.2/tests/__init__.py +0 -0
  25. asyncio_extensions-0.0.2/tests/test_taskgroups.py +0 -54
  26. {asyncio_extensions-0.0.2 → asyncio_extensions-0.0.3}/LICENSE.txt +0 -0
  27. {asyncio_extensions-0.0.2 → asyncio_extensions-0.0.3}/src/asyncio_extensions/py.typed +0 -0
  28. /asyncio_extensions-0.0.2/tests/utils.py → /asyncio_extensions-0.0.3/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@v6
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@v7
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.8
33
+ hooks:
34
+ - id: commitizen
35
+ - id: commitizen-branch
36
+ stages: [pre-push]
@@ -0,0 +1,43 @@
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
+ ## [0.0.3] - 2026-02-26
12
+
13
+ ### Added
14
+
15
+ - This CHANGELOG file.
16
+ - pre-commit config.
17
+ - test coverage using Codecov
18
+ - LimitedTaskGroup to execute tasks limiting concurrency
19
+ - heartbeat function to run something at regular interval
20
+
21
+ ### Changed
22
+
23
+ - Package building and publishing are run in separate jobs to avoid credential sharing.
24
+ - Improved README
25
+
26
+ ## [0.0.2] - 2025-12-23
27
+
28
+ ### Changed
29
+
30
+ - `TaskGroup` now inherits from `asyncio.TaskGroup`.
31
+
32
+ ## [0.0.1] - 2025-12-23
33
+
34
+ ### Added
35
+
36
+ - `TaskGroup` subclass with a cancel method.
37
+ - `checkpoint` function to yield control to the event loop.
38
+ - `sleep_forever` function.
39
+
40
+ [unreleased]: https://github.com/hartungstenio/asyncio-extensions/compare/0.0.3...HEAD
41
+ [0.0.3]: https://github.com/hartungstenio/asyncio-extensions/compare/0.0.2...0.0.3
42
+ [0.0.2]: https://github.com/hartungstenio/asyncio-extensions/compare/0.0.1...0.0.2
43
+ [0.0.1]: https://github.com/hartungstenio/asyncio-extensions/releases/tag/0.0.1
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: asyncio-extensions
3
+ Version: 0.0.3
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
+ `asyncio-extensions` provide a cancellable version of AsyncIO's `TaskGroup`.
49
+
50
+ ```python
51
+ import asyncio
52
+
53
+ from asyncio_extensions import TaskGroup
54
+
55
+ queue = asyncio.Queue()
56
+ async with TaskGroup() as tg:
57
+ for _ in range(10):
58
+ tg.create_task(consume_from_queue(queue))
59
+
60
+ await add_to_queue(queue)
61
+ await queue.join()
62
+ tg.cancel()
63
+ ```
64
+
65
+ #### LimitedTaskGroup
66
+ A version of `TaskGroup` which limits the number of running tasks.
67
+
68
+ ```python
69
+ import asyncio
70
+
71
+ from asyncio_extensions import LimitedTaskGroup
72
+
73
+ queue = asyncio.Queue()
74
+ async with LimitedTaskGroup(3) as tg:
75
+ for _ in range(50):
76
+ tg.create_task(some_expensive_operation(queue))
77
+
78
+ await add_to_queue(queue)
79
+ await queue.join()
80
+ tg.cancel()
81
+ ```
82
+
83
+
84
+ ### checkpoint
85
+ The `checkpoint` function yields control do the event loop. It is a more elegant approach to do-nothing tasks since they give a chance for other tasks to run.
86
+
87
+ ```python
88
+ from asyncio_extensions import checkpoint
89
+
90
+ class DummyChannel:
91
+ async def send_message(self, message):
92
+ await checkpoint()
93
+ ```
94
+
95
+ ### sleep_forever
96
+ The `sleep_forever` function never returns. It simply keeps yielding control do the event loop.
97
+
98
+ ```python
99
+ from asyncio_extensions import sleep_forever
100
+
101
+ class DummyChannel:
102
+ async def receive_message(self):
103
+ await sleep_forever()
104
+ ```
105
+
106
+ ### heartbeat
107
+ The `heartbeat` function runs a given function at a regular interval.
108
+
109
+ ```python
110
+ from asyncio_extensions import heartbeat
111
+
112
+ interval = 5
113
+
114
+ async def ping():
115
+ pass
116
+
117
+ async with TaskGroup() as tg:
118
+ tg.create_task(heartbeat(5, ping))
119
+
120
+ await some_long_running_process()
121
+ ```
122
+
123
+ ## Contributing
124
+
125
+ Pull requests are welcome. For major changes, please open an issue first
126
+ to discuss what you would like to change.
127
+
128
+ Please make sure to update tests as appropriate.
129
+
130
+
131
+ ## License
132
+
133
+ `asyncio-extensions` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
@@ -0,0 +1,104 @@
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
+ `asyncio-extensions` provide a cancellable version of AsyncIO's `TaskGroup`.
20
+
21
+ ```python
22
+ import asyncio
23
+
24
+ from asyncio_extensions import TaskGroup
25
+
26
+ queue = asyncio.Queue()
27
+ async with TaskGroup() as tg:
28
+ for _ in range(10):
29
+ tg.create_task(consume_from_queue(queue))
30
+
31
+ await add_to_queue(queue)
32
+ await queue.join()
33
+ tg.cancel()
34
+ ```
35
+
36
+ #### LimitedTaskGroup
37
+ A version of `TaskGroup` which limits the number of running tasks.
38
+
39
+ ```python
40
+ import asyncio
41
+
42
+ from asyncio_extensions import LimitedTaskGroup
43
+
44
+ queue = asyncio.Queue()
45
+ async with LimitedTaskGroup(3) as tg:
46
+ for _ in range(50):
47
+ tg.create_task(some_expensive_operation(queue))
48
+
49
+ await add_to_queue(queue)
50
+ await queue.join()
51
+ tg.cancel()
52
+ ```
53
+
54
+
55
+ ### checkpoint
56
+ The `checkpoint` function yields control do the event loop. It is a more elegant approach to do-nothing tasks since they give a chance for other tasks to run.
57
+
58
+ ```python
59
+ from asyncio_extensions import checkpoint
60
+
61
+ class DummyChannel:
62
+ async def send_message(self, message):
63
+ await checkpoint()
64
+ ```
65
+
66
+ ### sleep_forever
67
+ The `sleep_forever` function never returns. It simply keeps yielding control do the event loop.
68
+
69
+ ```python
70
+ from asyncio_extensions import sleep_forever
71
+
72
+ class DummyChannel:
73
+ async def receive_message(self):
74
+ await sleep_forever()
75
+ ```
76
+
77
+ ### heartbeat
78
+ The `heartbeat` function runs a given function at a regular interval.
79
+
80
+ ```python
81
+ from asyncio_extensions import heartbeat
82
+
83
+ interval = 5
84
+
85
+ async def ping():
86
+ pass
87
+
88
+ async with TaskGroup() as tg:
89
+ tg.create_task(heartbeat(5, ping))
90
+
91
+ await some_long_running_process()
92
+ ```
93
+
94
+ ## Contributing
95
+
96
+ Pull requests are welcome. For major changes, please open an issue first
97
+ to discuss what you would like to change.
98
+
99
+ Please make sure to update tests as appropriate.
100
+
101
+
102
+ ## License
103
+
104
+ `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,19 @@
1
+ """Utility functions and extensions for AsyncIO."""
2
+
3
+ from .taskgroups import (
4
+ LimitedTaskGroup,
5
+ TaskGroup,
6
+ TerminateTaskGroup,
7
+ force_terminate_task_group,
8
+ )
9
+ from .utils import checkpoint, heartbeat, sleep_forever
10
+
11
+ __all__ = [
12
+ "LimitedTaskGroup",
13
+ "TaskGroup",
14
+ "TerminateTaskGroup",
15
+ "checkpoint",
16
+ "force_terminate_task_group",
17
+ "heartbeat",
18
+ "sleep_forever",
19
+ ]
@@ -0,0 +1,10 @@
1
+ import sys
2
+
3
+ if sys.version_info >= (3, 12):
4
+ from typing import override
5
+ else:
6
+ from typing_extensions import override
7
+
8
+ __all__ = [
9
+ "override",
10
+ ]
@@ -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,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