Repository: maxfischer2781/asyncstdlib

Issue:
Match Python 3.12 standard library
Python 3.12 is about to update some standard library features suitable for `asyncstdlib`:

* [ ] [`itertools`](https://docs.python.org/3.12/whatsnew/3.12.html#itertools)
  * [ ] [`itertools.batched`](https://docs.python.org/3.12/library/itertools.html#itertools.batched)

See [What’s New In Python 3.12](https://docs.python.org/3.12/whatsnew/3.12.html).

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/maxfischer2781/asyncstdlib/pull/115?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Comparison is base [(`83e06f8`)](https://app.codecov.io/gh/maxfischer2781/asyncstdlib/commit/83e06f8475fbcb4481c523394801fbd7d35268b1?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer) 99.08% compared to head [(`a32ee79`)](https://app.codecov.io/gh/maxfischer2781/asyncstdlib/pull/115?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer) 98.99%.


<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #115      +/-   ##
==========================================
- Coverage   99.08%   98.99%   -0.09%     
==========================================
  Files          20       20              
  Lines        2079     2095      +16     
  Branches      546      514      -32     
==========================================
+ Hits         2060     2074      +14     
- Misses         11       12       +1     
- Partials        8        9       +1     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/maxfischer2781/asyncstdlib/pull/115?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer).

ziima: Updated.

Patch:
diff --git a/asyncstdlib/__init__.py b/asyncstdlib/__init__.py
index 2bc35d9..2703d2b 100644
--- a/asyncstdlib/__init__.py
+++ b/asyncstdlib/__init__.py
@@ -21,6 +21,7 @@ from .functools import reduce, lru_cache, cache, cached_property
 from .contextlib import closing, contextmanager, nullcontext, ExitStack
 from .itertools import (
     accumulate,
+    batched,
     cycle,
     chain,
     compress,
@@ -68,6 +69,7 @@ __all__ = [
     "ExitStack",
     # itertools
     "accumulate",
+    "batched",
     "cycle",
     "chain",
     "compress",
diff --git a/asyncstdlib/itertools.py b/asyncstdlib/itertools.py
index ccd98e9..6b36cac 100644
--- a/asyncstdlib/itertools.py
+++ b/asyncstdlib/itertools.py
@@ -25,7 +25,13 @@ from ._core import (
     Sentinel,
     borrow as _borrow,
 )
-from .builtins import anext, zip, enumerate as aenumerate, iter as aiter
+from .builtins import (
+    anext,
+    zip,
+    enumerate as aenumerate,
+    iter as aiter,
+    tuple as atuple,
+)
 
 S = TypeVar("S")
 
@@ -141,6 +147,25 @@ async def accumulate(
             yield value
 
 
+async def batched(iterable: AnyIterable[T], n: int) -> AsyncIterator[Tuple[T, ...]]:
+    """
+    Batch the ``iterable`` to tuples of the length ``n``.
+
+    This lazily exhausts ``iterable`` and returns each batch as soon as it's ready.
+    """
+    if n < 1:
+        raise ValueError("n must be at least one")
+    async with ScopedIter(iterable) as item_iter:
+        # TODO: Use walrus when 3.8 is minimal version
+        # while batch := await atuple(islice(item_iter, n)):
+        while True:
+            batch = await atuple(islice(_borrow(item_iter), n))
+            if batch:
+                yield batch
+            else:
+                break
+
+
 class chain(AsyncIterator[T]):
     """
     An :term:`asynchronous iterator` flattening values from all ``iterables``
diff --git a/docs/source/api/itertools.rst b/docs/source/api/itertools.rst
index 5a91de7..4eb0ca3 100644
--- a/docs/source/api/itertools.rst
+++ b/docs/source/api/itertools.rst
@@ -86,6 +86,11 @@ Iterator splitting
 
     .. versionadded:: 3.10.0
 
+.. autofunction:: batched(iterable: (async) iter T, n: int)
+    :async-for: :T
+
+    .. versionadded:: 3.11.0
+
 .. py:function:: groupby(iterable: (async) iter T)
     :async-for: :(T, async iter T)
     :noindex:


Test patch:
diff --git a/unittests/test_itertools.py b/unittests/test_itertools.py
index ec4d415..d58b019 100644
--- a/unittests/test_itertools.py
+++ b/unittests/test_itertools.py
@@ -57,6 +57,28 @@ async def test_accumulate_misuse():
         assert await a.list(a.accumulate([]))
 
 
+batched_cases = [
+    (range(10), 2, [(0, 1), (2, 3), (4, 5), (6, 7), (8, 9)]),
+    (range(10), 3, [(0, 1, 2), (3, 4, 5), (6, 7, 8), (9,)]),
+    (range(10), 17, [tuple(range(10))]),
+    ([], 2, []),
+]
+
+
+@pytest.mark.parametrize("iterable, length, result", batched_cases)
+@sync
+async def test_batched(iterable, length, result):
+    assert await a.list(a.batched(iterable, length)) == result
+    assert await a.list(a.batched(asyncify(iterable), length)) == result
+
+
+@sync
+@pytest.mark.parametrize("length", (0, -1))
+async def test_batched_invalid(length):
+    with pytest.raises(ValueError):
+        await a.list(a.batched(range(10), length))
+
+
 @sync
 async def test_cycle():
     async for _ in a.cycle([]):