asyncio-extensions 0.3.2__tar.gz → 0.4.0__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.3.2 → asyncio_extensions-0.4.0}/.github/workflows/tests.yml +7 -6
- {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/.pre-commit-config.yaml +1 -1
- {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/CHANGELOG.md +8 -1
- {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/PKG-INFO +15 -1
- {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/README.md +14 -0
- {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/pyproject.toml +22 -3
- {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/src/asyncio_extensions/__init__.py +2 -1
- {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/src/asyncio_extensions/_compat.py +8 -3
- {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/src/asyncio_extensions/_iterators.py +56 -19
- {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/src/asyncio_extensions/_task_groups.py +2 -3
- asyncio_extensions-0.4.0/src/asyncio_extensions/_types.py +19 -0
- asyncio_extensions-0.4.0/tests/__init__.py +2 -0
- {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/tests/test_iterators.py +87 -3
- asyncio_extensions-0.3.2/tests/__init__.py +0 -2
- {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/.github/dependabot.yml +0 -0
- {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/.github/workflows/release.yml +0 -0
- {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/.gitignore +0 -0
- {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/AGENTS.md +0 -0
- {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/CLAUDE.md +0 -0
- {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/LICENSE.txt +0 -0
- {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/codecov.yml +0 -0
- {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/src/asyncio_extensions/_scheduling.py +0 -0
- {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/src/asyncio_extensions/_sync.py +0 -0
- {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/src/asyncio_extensions/py.typed +0 -0
- {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/tests/test_scheduling.py +0 -0
- {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/tests/test_sync.py +0 -0
- {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/tests/test_task_groups.py +0 -0
|
@@ -27,16 +27,17 @@ jobs:
|
|
|
27
27
|
python-version: "3.13"
|
|
28
28
|
|
|
29
29
|
- name: Setup Hatch
|
|
30
|
-
run: pipx install
|
|
30
|
+
run: pipx install hatch
|
|
31
31
|
|
|
32
32
|
- name: Run static analysis
|
|
33
|
-
run: hatch
|
|
33
|
+
run: hatch check code
|
|
34
|
+
|
|
35
|
+
- name: Verify formatting
|
|
36
|
+
run: hatch check fmt
|
|
34
37
|
|
|
35
|
-
- name: Type checking with MyPy
|
|
36
|
-
run: hatch run mypy src/asyncio_extensions
|
|
37
38
|
|
|
38
|
-
- name:
|
|
39
|
-
run: hatch
|
|
39
|
+
- name: Run type checks
|
|
40
|
+
run: hatch check types
|
|
40
41
|
|
|
41
42
|
tests:
|
|
42
43
|
name: Run tests on ${{ matrix.python-version }}
|
|
@@ -8,6 +8,12 @@ This project adheres to both [Semantic Versioning](https://semver.org/spec/v2.0.
|
|
|
8
8
|
|
|
9
9
|
## [UNRELEASED]
|
|
10
10
|
|
|
11
|
+
## [0.4.0] - 2026-09-30
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `for_each_concurrent` coroutine to apply an async callback to each item in a sync or async iterable with bounded concurrency.
|
|
16
|
+
|
|
11
17
|
## [0.3.2] - 2026-09-24
|
|
12
18
|
|
|
13
19
|
### Fixed
|
|
@@ -104,7 +110,8 @@ This project adheres to both [Semantic Versioning](https://semver.org/spec/v2.0.
|
|
|
104
110
|
- `checkpoint` function to yield control to the event loop.
|
|
105
111
|
- `sleep_forever` function.
|
|
106
112
|
|
|
107
|
-
[unreleased]: https://github.com/hartungstenio/asyncio-extensions/compare/0.
|
|
113
|
+
[unreleased]: https://github.com/hartungstenio/asyncio-extensions/compare/0.4.0...HEAD
|
|
114
|
+
[0.4.0]: https://github.com/hartungstenio/asyncio-extensions/compare/0.3.2...0.4.0
|
|
108
115
|
[0.3.2]: https://github.com/hartungstenio/asyncio-extensions/compare/0.3.1...0.3.2
|
|
109
116
|
[0.3.1]: https://github.com/hartungstenio/asyncio-extensions/compare/0.3.0...0.3.1
|
|
110
117
|
[0.3.0]: https://github.com/hartungstenio/asyncio-extensions/compare/0.2.0...0.3.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: asyncio-extensions
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Some (maybe) useful extensions for asyncio
|
|
5
5
|
Project-URL: Changelog, https://github.com/hartungstenio/asyncio-extensions/blob/main/CHANGELOG.md
|
|
6
6
|
Project-URL: Documentation, https://github.com/hartungstenio/asyncio-extensions#readme
|
|
@@ -406,6 +406,20 @@ async with aclosing(flatten_stream(merge_iterables(source_a, source_b))) as stre
|
|
|
406
406
|
break
|
|
407
407
|
```
|
|
408
408
|
|
|
409
|
+
### for_each_concurrent
|
|
410
|
+
|
|
411
|
+
The `for_each_concurrent` coroutine applies an async callback to each item in a sync or async iterable, keeping at most `concurrency` calls running at the same time. Items are consumed in order, but callbacks may complete out of order.
|
|
412
|
+
|
|
413
|
+
```python
|
|
414
|
+
from asyncio_extensions import for_each_concurrent
|
|
415
|
+
|
|
416
|
+
async def fetch(url: str) -> None:
|
|
417
|
+
response = await client.get(url)
|
|
418
|
+
await save(response)
|
|
419
|
+
|
|
420
|
+
await for_each_concurrent(urls, fetch, concurrency=10)
|
|
421
|
+
```
|
|
422
|
+
|
|
409
423
|
### iscoroutinefunction, markcoroutinefunction, and is_awaitable
|
|
410
424
|
|
|
411
425
|
The `iscoroutinefunction` helper checks whether a callable is already a coroutine function. It is re-exported from `inspect` on newer Python versions and from `asyncio` on older versions, depending on the runtime.
|
|
@@ -376,6 +376,20 @@ async with aclosing(flatten_stream(merge_iterables(source_a, source_b))) as stre
|
|
|
376
376
|
break
|
|
377
377
|
```
|
|
378
378
|
|
|
379
|
+
### for_each_concurrent
|
|
380
|
+
|
|
381
|
+
The `for_each_concurrent` coroutine applies an async callback to each item in a sync or async iterable, keeping at most `concurrency` calls running at the same time. Items are consumed in order, but callbacks may complete out of order.
|
|
382
|
+
|
|
383
|
+
```python
|
|
384
|
+
from asyncio_extensions import for_each_concurrent
|
|
385
|
+
|
|
386
|
+
async def fetch(url: str) -> None:
|
|
387
|
+
response = await client.get(url)
|
|
388
|
+
await save(response)
|
|
389
|
+
|
|
390
|
+
await for_each_concurrent(urls, fetch, concurrency=10)
|
|
391
|
+
```
|
|
392
|
+
|
|
379
393
|
### iscoroutinefunction, markcoroutinefunction, and is_awaitable
|
|
380
394
|
|
|
381
395
|
The `iscoroutinefunction` helper checks whether a callable is already a coroutine function. It is re-exported from `inspect` on newer Python versions and from `asyncio` on older versions, depending on the runtime.
|
|
@@ -39,12 +39,15 @@ Source = "https://github.com/hartungstenio/asyncio-extensions"
|
|
|
39
39
|
[dependency-groups]
|
|
40
40
|
dev = [
|
|
41
41
|
"faker",
|
|
42
|
-
"mypy",
|
|
43
42
|
"pre-commit",
|
|
44
|
-
"pyrefly",
|
|
45
43
|
"pytest",
|
|
46
44
|
"pytest-asyncio",
|
|
47
45
|
"pytest-cov",
|
|
46
|
+
{ include-group = "types" },
|
|
47
|
+
]
|
|
48
|
+
types = [
|
|
49
|
+
"mypy (>= 2.3)",
|
|
50
|
+
"pyrefly (>= 1.3)",
|
|
48
51
|
]
|
|
49
52
|
|
|
50
53
|
[build-system]
|
|
@@ -71,6 +74,9 @@ exclude_also = [
|
|
|
71
74
|
'@(typing\.)?overload',
|
|
72
75
|
]
|
|
73
76
|
|
|
77
|
+
[tool.hatch]
|
|
78
|
+
requires-hatch = ">= 1.18"
|
|
79
|
+
|
|
74
80
|
[tool.hatch.version]
|
|
75
81
|
source = "vcs"
|
|
76
82
|
|
|
@@ -78,9 +84,19 @@ source = "vcs"
|
|
|
78
84
|
python = "3.13"
|
|
79
85
|
dependency-groups = ["dev"]
|
|
80
86
|
|
|
87
|
+
[tool.hatch.envs.hatch-check-types]
|
|
88
|
+
dependencies = []
|
|
89
|
+
dependency-groups = ["types"]
|
|
90
|
+
|
|
91
|
+
[tool.hatch.envs.hatch-check-types.scripts]
|
|
92
|
+
check = [
|
|
93
|
+
"pyrefly check{env:HATCH_CHECK_TYPES_ARGS:} {args}",
|
|
94
|
+
"mypy {args}",
|
|
95
|
+
]
|
|
96
|
+
|
|
81
97
|
[tool.hatch.envs.hatch-static-analysis]
|
|
82
98
|
config-path = "none"
|
|
83
|
-
dependencies = ["ruff (
|
|
99
|
+
dependencies = ["ruff (>= 0.16)"]
|
|
84
100
|
|
|
85
101
|
[tool.hatch.envs.hatch-test]
|
|
86
102
|
extra-dependencies = [
|
|
@@ -99,9 +115,11 @@ cov-report = "coverage xml"
|
|
|
99
115
|
|
|
100
116
|
[tool.mypy]
|
|
101
117
|
strict = true
|
|
118
|
+
files = ["src"]
|
|
102
119
|
enable_error_code = ["deprecated"]
|
|
103
120
|
|
|
104
121
|
[tool.pyrefly]
|
|
122
|
+
project-includes = ["src"]
|
|
105
123
|
preset = "strict"
|
|
106
124
|
min-severity = "ignore"
|
|
107
125
|
|
|
@@ -163,6 +181,7 @@ select = [
|
|
|
163
181
|
"UP",
|
|
164
182
|
"W",
|
|
165
183
|
]
|
|
184
|
+
ignore = ["SIM105"]
|
|
166
185
|
|
|
167
186
|
[tool.ruff.lint.per-file-ignores]
|
|
168
187
|
"tests/*.py" = ["D", "S101"]
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
from ._compat import CreateTaskParams as CreateTaskParams
|
|
4
4
|
from ._compat import iscoroutinefunction as iscoroutinefunction
|
|
5
5
|
from ._iterators import STOP as STOP
|
|
6
|
-
from ._iterators import ManagedStream as ManagedStream
|
|
7
6
|
from ._iterators import drain as drain
|
|
8
7
|
from ._iterators import fill_queue as fill_queue
|
|
9
8
|
from ._iterators import flatten_stream as flatten_stream
|
|
9
|
+
from ._iterators import for_each_concurrent as for_each_concurrent
|
|
10
10
|
from ._iterators import iterate_queue as iterate_queue
|
|
11
11
|
from ._iterators import merge_iterables as merge_iterables
|
|
12
12
|
from ._iterators import merge_streams as merge_streams
|
|
@@ -23,3 +23,4 @@ from ._task_groups import LimitedTaskGroup as LimitedTaskGroup
|
|
|
23
23
|
from ._task_groups import TaskGroup as TaskGroup
|
|
24
24
|
from ._task_groups import TerminateTaskGroup as TerminateTaskGroup
|
|
25
25
|
from ._task_groups import force_terminate_task_group as force_terminate_task_group
|
|
26
|
+
from ._types import ManagedStream as ManagedStream
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import sys
|
|
2
|
-
from collections.abc import Callable
|
|
3
2
|
from contextvars import Context
|
|
4
|
-
from typing import ParamSpec,
|
|
3
|
+
from typing import ParamSpec, TypeVar
|
|
5
4
|
|
|
6
5
|
_P = ParamSpec("_P")
|
|
7
6
|
_R = TypeVar("_R")
|
|
8
7
|
|
|
8
|
+
if sys.version_info >= (3, 15):
|
|
9
|
+
from typing import TypedDict
|
|
10
|
+
else:
|
|
11
|
+
from typing_extensions import TypedDict
|
|
12
|
+
|
|
9
13
|
if sys.version_info >= (3, 12):
|
|
10
14
|
from inspect import iscoroutinefunction, markcoroutinefunction
|
|
11
15
|
from typing import override
|
|
12
16
|
else:
|
|
13
17
|
from asyncio import coroutines, iscoroutinefunction
|
|
18
|
+
from collections.abc import Callable
|
|
14
19
|
|
|
15
20
|
from typing_extensions import override
|
|
16
21
|
|
|
@@ -29,7 +34,7 @@ else:
|
|
|
29
34
|
pass
|
|
30
35
|
|
|
31
36
|
|
|
32
|
-
class _CreateTaskParams(TypedDict, total=False):
|
|
37
|
+
class _CreateTaskParams(TypedDict, total=False, closed=True):
|
|
33
38
|
"""Parameters for creating a task in a TaskGroup."""
|
|
34
39
|
|
|
35
40
|
name: str | None
|
|
@@ -2,27 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import sys
|
|
5
|
-
from collections.abc import AsyncGenerator,
|
|
6
|
-
from contextlib import
|
|
5
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
6
|
+
from contextlib import AsyncExitStack, aclosing, asynccontextmanager
|
|
7
7
|
from functools import wraps
|
|
8
|
-
from typing import
|
|
8
|
+
from typing import Any, ParamSpec, TypeVar
|
|
9
9
|
|
|
10
10
|
from ._compat import QueueShutDown, sentinel
|
|
11
11
|
from ._sync import asyncify_iterable
|
|
12
12
|
from ._task_groups import TaskGroup
|
|
13
|
+
from ._types import AnyIterable, ManagedStream
|
|
13
14
|
|
|
14
15
|
T = TypeVar("T")
|
|
15
16
|
P = ParamSpec("P")
|
|
16
|
-
ManagedStream: TypeAlias = AbstractAsyncContextManager[AsyncIterator[T]]
|
|
17
|
-
"""An async context manager that yields an :class:`~collections.abc.AsyncIterator`.
|
|
18
|
-
|
|
19
|
-
This is the return type of :func:`safe_gen` and :func:`merge_iterables`, and the
|
|
20
|
-
accepted parameter type of :func:`flatten_stream`. Use it to annotate functions
|
|
21
|
-
that return a context-managed async stream::
|
|
22
|
-
|
|
23
|
-
def my_stream() -> ManagedStream[int]:
|
|
24
|
-
...
|
|
25
|
-
"""
|
|
26
17
|
|
|
27
18
|
if sys.version_info >= (3, 13):
|
|
28
19
|
STOP = sentinel("STOP")
|
|
@@ -78,7 +69,7 @@ async def iterate_queue(queue: asyncio.Queue[T]) -> AsyncGenerator[T]:
|
|
|
78
69
|
queue.task_done()
|
|
79
70
|
|
|
80
71
|
|
|
81
|
-
async def fill_queue(itr:
|
|
72
|
+
async def fill_queue(itr: AnyIterable[T], queue: asyncio.Queue[T]) -> None:
|
|
82
73
|
"""Fill *queue* with all items from *itr*.
|
|
83
74
|
|
|
84
75
|
Accepts both sync and async iterables and puts each item into *queue*,
|
|
@@ -96,7 +87,7 @@ async def fill_queue(itr: AsyncIterable[T] | Iterable[T], queue: asyncio.Queue[T
|
|
|
96
87
|
await queue.put(it)
|
|
97
88
|
|
|
98
89
|
|
|
99
|
-
async def drain(itr:
|
|
90
|
+
async def drain(itr: AnyIterable[T]) -> int:
|
|
100
91
|
"""Consume the remaining items from *itr* and return the count drained.
|
|
101
92
|
|
|
102
93
|
Accepts both sync and async iterables.
|
|
@@ -119,9 +110,7 @@ async def drain(itr: AsyncIterable[T] | Iterable[T]) -> int:
|
|
|
119
110
|
|
|
120
111
|
|
|
121
112
|
@asynccontextmanager
|
|
122
|
-
async def merge_iterables(
|
|
123
|
-
*itrs: AsyncIterable[T] | Iterable[T],
|
|
124
|
-
) -> AsyncGenerator[AsyncGenerator[T]]:
|
|
113
|
+
async def merge_iterables(*itrs: AnyIterable[T]) -> AsyncGenerator[AsyncGenerator[T]]:
|
|
125
114
|
"""Merge multiple iterables into a single async stream.
|
|
126
115
|
|
|
127
116
|
Feeds all *itrs* into a shared queue concurrently and yields a single
|
|
@@ -203,6 +192,7 @@ def safe_gen(fn: Callable[P, AsyncGenerator[T]]) -> Callable[P, ManagedStream[T]
|
|
|
203
192
|
await asyncio.sleep(10)
|
|
204
193
|
yield i
|
|
205
194
|
|
|
195
|
+
|
|
206
196
|
async with iterate_slowly(5) as stream:
|
|
207
197
|
async for item in stream:
|
|
208
198
|
print(item)
|
|
@@ -223,7 +213,7 @@ def safe_gen(fn: Callable[P, AsyncGenerator[T]]) -> Callable[P, ManagedStream[T]
|
|
|
223
213
|
return decorator
|
|
224
214
|
|
|
225
215
|
|
|
226
|
-
async def flatten_stream(ctx: ManagedStream[T]) ->
|
|
216
|
+
async def flatten_stream(ctx: ManagedStream[T]) -> AsyncGenerator[T]:
|
|
227
217
|
"""Iterate a context-managed stream without an explicit ``async with`` block.
|
|
228
218
|
|
|
229
219
|
Enters *ctx*, iterates the resulting async iterator, and yields each item
|
|
@@ -256,3 +246,50 @@ async def flatten_stream(ctx: ManagedStream[T]) -> AsyncIterator[T]:
|
|
|
256
246
|
yield it
|
|
257
247
|
except* GeneratorExit:
|
|
258
248
|
pass
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
async def for_each_concurrent(
|
|
252
|
+
source: AnyIterable[T],
|
|
253
|
+
callback: Callable[[T], Awaitable[Any]],
|
|
254
|
+
concurrency: int,
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Apply *callback* to each item in *source* with bounded concurrency.
|
|
257
|
+
|
|
258
|
+
Consumes *source* and calls ``await callback(item)`` for each item,
|
|
259
|
+
keeping at most *concurrency* calls running at the same time. Items are
|
|
260
|
+
processed in the order they arrive from *source*, but callbacks may
|
|
261
|
+
complete out of order.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
source: The source iterable (sync or async) to consume.
|
|
265
|
+
callback: An async callable invoked once per item.
|
|
266
|
+
concurrency: Maximum number of concurrent callback invocations.
|
|
267
|
+
Must be greater than 0.
|
|
268
|
+
|
|
269
|
+
Raises:
|
|
270
|
+
ValueError: If *concurrency* is not greater than 0.
|
|
271
|
+
|
|
272
|
+
Example::
|
|
273
|
+
|
|
274
|
+
async def fetch(url: str) -> None: ...
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
await for_each_concurrent(urls, fetch, concurrency=10)
|
|
278
|
+
"""
|
|
279
|
+
if concurrency <= 0:
|
|
280
|
+
msg = f"concurrency must be greater than 0, got {concurrency!r}"
|
|
281
|
+
raise ValueError(msg)
|
|
282
|
+
|
|
283
|
+
queue: asyncio.Queue[T] = asyncio.Queue(concurrency)
|
|
284
|
+
|
|
285
|
+
async def worker() -> None:
|
|
286
|
+
async for item in iterate_queue(queue):
|
|
287
|
+
await callback(item)
|
|
288
|
+
|
|
289
|
+
async with TaskGroup() as tg:
|
|
290
|
+
for _ in range(concurrency):
|
|
291
|
+
tg.create_task(worker())
|
|
292
|
+
|
|
293
|
+
await fill_queue(source, queue)
|
|
294
|
+
await queue.join()
|
|
295
|
+
tg.cancel()
|
{asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/src/asyncio_extensions/_task_groups.py
RENAMED
|
@@ -4,7 +4,7 @@ import asyncio
|
|
|
4
4
|
import sys
|
|
5
5
|
from collections.abc import Coroutine
|
|
6
6
|
from types import TracebackType
|
|
7
|
-
from typing import Never, TypeVar, Unpack
|
|
7
|
+
from typing import Any, Never, TypeVar, Unpack
|
|
8
8
|
|
|
9
9
|
from ._compat import CreateTaskParams, override
|
|
10
10
|
|
|
@@ -67,7 +67,7 @@ class LimitedTaskGroup(TaskGroup):
|
|
|
67
67
|
@override
|
|
68
68
|
def create_task(
|
|
69
69
|
self,
|
|
70
|
-
coro: Coroutine[
|
|
70
|
+
coro: Coroutine[Any, Any, T],
|
|
71
71
|
**kwargs: Unpack[CreateTaskParams],
|
|
72
72
|
) -> asyncio.Task[T]:
|
|
73
73
|
"""Schedule *coro* as a task, subject to the concurrency limit.
|
|
@@ -78,5 +78,4 @@ class LimitedTaskGroup(TaskGroup):
|
|
|
78
78
|
Returns:
|
|
79
79
|
The created :class:`asyncio.Task`.
|
|
80
80
|
"""
|
|
81
|
-
# pyrefly: ignore [open-unpacking]
|
|
82
81
|
return super().create_task(self._task_wrapper(coro), **kwargs)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from collections.abc import AsyncIterable, AsyncIterator, Iterable
|
|
2
|
+
from contextlib import AbstractAsyncContextManager
|
|
3
|
+
from typing import ParamSpec, TypeAlias, TypeVar
|
|
4
|
+
|
|
5
|
+
T = TypeVar("T")
|
|
6
|
+
P = ParamSpec("P")
|
|
7
|
+
|
|
8
|
+
ManagedStream: TypeAlias = AbstractAsyncContextManager[AsyncIterator[T]]
|
|
9
|
+
"""An async context manager that yields an :class:`~collections.abc.AsyncIterator`.
|
|
10
|
+
|
|
11
|
+
This is the return type of :func:`safe_gen` and :func:`merge_iterables`, and the
|
|
12
|
+
accepted parameter type of :func:`flatten_stream`. Use it to annotate functions
|
|
13
|
+
that return a context-managed async stream::
|
|
14
|
+
|
|
15
|
+
def my_stream() -> ManagedStream[int]:
|
|
16
|
+
...
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
AnyIterable: TypeAlias = AsyncIterable[T] | Iterable[T]
|
|
@@ -3,6 +3,7 @@ import sys
|
|
|
3
3
|
from collections.abc import AsyncGenerator
|
|
4
4
|
from contextlib import aclosing
|
|
5
5
|
from itertools import count
|
|
6
|
+
from typing import Any
|
|
6
7
|
|
|
7
8
|
import pytest
|
|
8
9
|
|
|
@@ -12,12 +13,15 @@ from asyncio_extensions._iterators import (
|
|
|
12
13
|
drain,
|
|
13
14
|
fill_queue,
|
|
14
15
|
flatten_stream,
|
|
16
|
+
for_each_concurrent,
|
|
15
17
|
iterate_queue,
|
|
16
18
|
merge_iterables,
|
|
17
19
|
merge_streams,
|
|
18
20
|
safe_gen,
|
|
19
21
|
)
|
|
20
22
|
|
|
23
|
+
from . import noop
|
|
24
|
+
|
|
21
25
|
pytestmark = pytest.mark.asyncio
|
|
22
26
|
|
|
23
27
|
|
|
@@ -200,14 +204,15 @@ async def test_merge_iterables_single_source_yields_all_items() -> None:
|
|
|
200
204
|
|
|
201
205
|
|
|
202
206
|
async def test_merge_iterables_empty_sources_yields_nothing() -> None:
|
|
203
|
-
|
|
207
|
+
given: list[Any] = []
|
|
208
|
+
async with merge_iterables(given, []) as stream:
|
|
204
209
|
results = [item async for item in stream]
|
|
205
210
|
|
|
206
211
|
assert results == []
|
|
207
212
|
|
|
208
213
|
|
|
209
214
|
async def test_merge_iterables_no_sources_yields_nothing() -> None:
|
|
210
|
-
async with merge_iterables() as stream:
|
|
215
|
+
async with merge_iterables() as stream: # type: ignore[var-annotated]
|
|
211
216
|
results = [item async for item in stream]
|
|
212
217
|
|
|
213
218
|
assert results == []
|
|
@@ -365,7 +370,8 @@ async def test_flatten_stream_yields_all_items() -> None:
|
|
|
365
370
|
|
|
366
371
|
|
|
367
372
|
async def test_flatten_stream_empty_stream_yields_nothing() -> None:
|
|
368
|
-
|
|
373
|
+
given: list[Any] = []
|
|
374
|
+
results = [item async for item in flatten_stream(merge_iterables(given))]
|
|
369
375
|
|
|
370
376
|
assert results == []
|
|
371
377
|
|
|
@@ -411,3 +417,81 @@ async def test_flatten_stream_safe_gen_closed_on_early_exit() -> None:
|
|
|
411
417
|
await stream.aclose()
|
|
412
418
|
|
|
413
419
|
assert closed
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# for_each_concurrent
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
async def test_for_each_concurrent_zero_concurrency_raises() -> None:
|
|
426
|
+
with pytest.raises(ValueError, match="concurrency"):
|
|
427
|
+
await for_each_concurrent([], noop, 0)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
async def test_for_each_concurrent_negative_concurrency_raises() -> None:
|
|
431
|
+
with pytest.raises(ValueError, match="concurrency"):
|
|
432
|
+
await for_each_concurrent([], noop, -1)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
async def test_for_each_concurrent_sync_iterable_calls_callback_for_each_item() -> None:
|
|
436
|
+
results: list[int] = []
|
|
437
|
+
|
|
438
|
+
async def callback(item: int) -> None:
|
|
439
|
+
results.append(item)
|
|
440
|
+
|
|
441
|
+
await for_each_concurrent([1, 2, 3], callback, concurrency=2)
|
|
442
|
+
|
|
443
|
+
assert sorted(results) == [1, 2, 3]
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
async def test_for_each_concurrent_async_iterable_calls_callback_for_each_item() -> None:
|
|
447
|
+
async def source() -> AsyncGenerator[int]:
|
|
448
|
+
for v in [1, 2, 3]:
|
|
449
|
+
yield v
|
|
450
|
+
|
|
451
|
+
results: list[int] = []
|
|
452
|
+
|
|
453
|
+
async def callback(item: int) -> None:
|
|
454
|
+
results.append(item)
|
|
455
|
+
|
|
456
|
+
await for_each_concurrent(source(), callback, concurrency=2)
|
|
457
|
+
|
|
458
|
+
assert sorted(results) == [1, 2, 3]
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
async def test_for_each_concurrent_empty_source_completes_without_calling_callback() -> None:
|
|
462
|
+
called = False
|
|
463
|
+
|
|
464
|
+
async def callback(_: int) -> None:
|
|
465
|
+
nonlocal called
|
|
466
|
+
called = True
|
|
467
|
+
|
|
468
|
+
await for_each_concurrent([], callback, concurrency=2)
|
|
469
|
+
|
|
470
|
+
assert not called
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
async def test_for_each_concurrent_respects_concurrency_limit() -> None:
|
|
474
|
+
active = 0
|
|
475
|
+
max_active = 0
|
|
476
|
+
|
|
477
|
+
async def callback(_: int) -> None:
|
|
478
|
+
nonlocal active, max_active
|
|
479
|
+
active += 1
|
|
480
|
+
max_active = max(max_active, active)
|
|
481
|
+
await checkpoint()
|
|
482
|
+
active -= 1
|
|
483
|
+
|
|
484
|
+
await for_each_concurrent(range(10), callback, concurrency=3)
|
|
485
|
+
|
|
486
|
+
assert max_active <= 3 # noqa: PLR2004
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
async def test_for_each_concurrent_concurrency_one_processes_serially() -> None:
|
|
490
|
+
order: list[int] = []
|
|
491
|
+
|
|
492
|
+
async def callback(item: int) -> None:
|
|
493
|
+
order.append(item)
|
|
494
|
+
|
|
495
|
+
await for_each_concurrent([1, 2, 3], callback, concurrency=1)
|
|
496
|
+
|
|
497
|
+
assert order == [1, 2, 3]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|