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.
Files changed (27) hide show
  1. {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/.github/workflows/tests.yml +7 -6
  2. {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/.pre-commit-config.yaml +1 -1
  3. {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/CHANGELOG.md +8 -1
  4. {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/PKG-INFO +15 -1
  5. {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/README.md +14 -0
  6. {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/pyproject.toml +22 -3
  7. {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/src/asyncio_extensions/__init__.py +2 -1
  8. {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/src/asyncio_extensions/_compat.py +8 -3
  9. {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/src/asyncio_extensions/_iterators.py +56 -19
  10. {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/src/asyncio_extensions/_task_groups.py +2 -3
  11. asyncio_extensions-0.4.0/src/asyncio_extensions/_types.py +19 -0
  12. asyncio_extensions-0.4.0/tests/__init__.py +2 -0
  13. {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/tests/test_iterators.py +87 -3
  14. asyncio_extensions-0.3.2/tests/__init__.py +0 -2
  15. {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/.github/dependabot.yml +0 -0
  16. {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/.github/workflows/release.yml +0 -0
  17. {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/.gitignore +0 -0
  18. {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/AGENTS.md +0 -0
  19. {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/CLAUDE.md +0 -0
  20. {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/LICENSE.txt +0 -0
  21. {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/codecov.yml +0 -0
  22. {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/src/asyncio_extensions/_scheduling.py +0 -0
  23. {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/src/asyncio_extensions/_sync.py +0 -0
  24. {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/src/asyncio_extensions/py.typed +0 -0
  25. {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/tests/test_scheduling.py +0 -0
  26. {asyncio_extensions-0.3.2 → asyncio_extensions-0.4.0}/tests/test_sync.py +0 -0
  27. {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 --preinstall "virtualenv<21" hatch
30
+ run: pipx install hatch
31
31
 
32
32
  - name: Run static analysis
33
- run: hatch fmt --check
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: Type checking with Pyrefly
39
- run: hatch run pyrefly check src/asyncio_extensions
39
+ - name: Run type checks
40
+ run: hatch check types
40
41
 
41
42
  tests:
42
43
  name: Run tests on ${{ matrix.python-version }}
@@ -29,7 +29,7 @@ repos:
29
29
  - id: trailing-whitespace
30
30
 
31
31
  - repo: https://github.com/commitizen-tools/commitizen
32
- rev: v4.18.1
32
+ rev: v4.19.0
33
33
  hooks:
34
34
  - id: commitizen
35
35
  - id: commitizen-branch
@@ -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.3.2...HEAD
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.2
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 (==0.15.*)"]
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, TypedDict, TypeVar
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, AsyncIterable, AsyncIterator, Callable, Iterable
6
- from contextlib import AbstractAsyncContextManager, AsyncExitStack, aclosing, asynccontextmanager
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 ParamSpec, TypeAlias, TypeVar
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: AsyncIterable[T] | Iterable[T], queue: asyncio.Queue[T]) -> None:
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: AsyncIterable[T] | Iterable[T]) -> int:
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]) -> AsyncIterator[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()
@@ -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[None, None, T],
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]
@@ -0,0 +1,2 @@
1
+ async def noop(*_: object) -> None:
2
+ """Do nothing."""
@@ -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
- async with merge_iterables([], []) as stream:
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
- results = [item async for item in flatten_stream(merge_iterables([]))]
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]
@@ -1,2 +0,0 @@
1
- async def noop() -> None:
2
- """Do nothing."""