Skip to content

Commit 79ac54f

Browse files
Merge pull request #160 from maxfischer2781/features/pyver3.13
Feature parity with Python 3.13
2 parents 0cdd850 + 0f447db commit 79ac54f

File tree

9 files changed

+69
-36
lines changed

9 files changed

+69
-36
lines changed

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2019 - 2024 Max Fischer
3+
Copyright (c) 2019 - 2024 Max Kühn
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

asyncstdlib/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
from .asynctools import borrow, scoped_iter, await_each, any_iter, apply, sync
4646
from .heapq import merge, nlargest, nsmallest
4747

48-
__version__ = "3.12.5"
48+
__version__ = "3.13.0"
4949

5050
__all__ = [
5151
"anext",

asyncstdlib/functools.py

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
Generic,
88
Generator,
99
Optional,
10-
Coroutine,
1110
AsyncContextManager,
1211
Type,
1312
cast,
@@ -66,25 +65,25 @@ def __repr__(self) -> str:
6665
return f"{self.__class__.__name__}({self.value!r})"
6766

6867

69-
class _FutureCachedValue(Generic[R, T]):
70-
"""A placeholder object to control concurrent access to a cached awaitable value.
68+
class _FutureCachedPropertyValue(Generic[R, T]):
69+
"""
70+
A placeholder object to control concurrent access to a cached awaitable value
7171
7272
When given a lock to coordinate access, only the first task to await on a
7373
cached property triggers the underlying coroutine. Once a value has been
7474
produced, all tasks are unblocked and given the same, single value.
75-
7675
"""
7776

78-
__slots__ = ("_get_attribute", "_instance", "_name", "_lock")
77+
__slots__ = ("_func", "_instance", "_name", "_lock")
7978

8079
def __init__(
8180
self,
82-
get_attribute: Callable[[T], Coroutine[Any, Any, R]],
81+
func: Callable[[T], Awaitable[R]],
8382
instance: T,
8483
name: str,
8584
lock: AsyncContextManager[Any],
8685
):
87-
self._get_attribute = get_attribute
86+
self._func = func
8887
self._instance = instance
8988
self._name = name
9089
self._lock = lock
@@ -98,7 +97,6 @@ def _instance_value(self) -> Awaitable[R]:
9897
9998
If the instance (no longer) has this attribute, it was deleted and the
10099
process is restarted by delegating to the descriptor.
101-
102100
"""
103101
try:
104102
return self._instance.__dict__[self._name]
@@ -116,12 +114,17 @@ async def _await_impl(self) -> R:
116114
# the instance attribute is still this placeholder, and we
117115
# hold the lock. Start the getter to store the value on the
118116
# instance and return the value.
119-
return await self._get_attribute(self._instance)
117+
return await self._get_attribute()
120118

121119
# another task produced a value, or the instance.__dict__ object was
122120
# deleted in the interim.
123121
return await stored
124122

123+
async def _get_attribute(self) -> R:
124+
value = await self._func(self._instance)
125+
self._instance.__dict__[self._name] = AwaitableValue(value)
126+
return value
127+
125128
def __repr__(self) -> str:
126129
return (
127130
f"<{type(self).__name__} for '{type(self._instance).__name__}."
@@ -135,9 +138,10 @@ def __init__(
135138
getter: Callable[[T], Awaitable[R]],
136139
asynccontextmanager_type: Type[AsyncContextManager[Any]] = nullcontext,
137140
):
138-
self.func = getter
141+
self.func = self.__wrapped__ = getter
139142
self.attrname = None
140143
self.__doc__ = getter.__doc__
144+
self.__module__ = getter.__module__
141145
self._asynccontextmanager_type = asynccontextmanager_type
142146

143147
def __set_name__(self, owner: Any, name: str) -> None:
@@ -175,19 +179,12 @@ def __get__(
175179
# on this instance. It takes care of coordinating between different
176180
# tasks awaiting on the placeholder until the cached value has been
177181
# produced.
178-
wrapper = _FutureCachedValue(
179-
self._get_attribute, instance, name, self._asynccontextmanager_type()
182+
wrapper = _FutureCachedPropertyValue(
183+
self.func, instance, name, self._asynccontextmanager_type()
180184
)
181185
cache[name] = wrapper
182186
return wrapper
183187

184-
async def _get_attribute(self, instance: T) -> R:
185-
value = await self.func(instance)
186-
name = self.attrname
187-
assert name is not None # enforced in __get__
188-
instance.__dict__[name] = AwaitableValue(value)
189-
return value
190-
191188

192189
def cached_property(
193190
type_or_getter: Union[Type[AsyncContextManager[Any]], Callable[[T], Awaitable[R]]],

asyncstdlib/itertools.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
zip,
3333
enumerate as aenumerate,
3434
iter as aiter,
35-
tuple as atuple,
3635
)
3736

3837
S = TypeVar("S")
@@ -122,17 +121,31 @@ async def accumulate(iterable, function, *, initial):
122121
yield value
123122

124123

125-
async def batched(iterable: AnyIterable[T], n: int) -> AsyncIterator[Tuple[T, ...]]:
124+
async def batched(
125+
iterable: AnyIterable[T], n: int, strict: bool = False
126+
) -> AsyncIterator[Tuple[T, ...]]:
126127
"""
127128
Batch the ``iterable`` to tuples of the length ``n``.
128129
129-
This lazily exhausts ``iterable`` and returns each batch as soon as it's ready.
130+
This lazily exhausts ``iterable`` and returns each batch as soon as it is ready.
131+
If ``strict`` is :py:data:`True` and the last batch is smaller than ``n``,
132+
:py:exc:`ValueError` is raised.
130133
"""
131134
if n < 1:
132135
raise ValueError("n must be at least one")
133136
async with ScopedIter(iterable) as item_iter:
134-
while batch := await atuple(islice(_borrow(item_iter), n)):
135-
yield batch
137+
batch: list[T] = []
138+
try:
139+
while True:
140+
batch.clear()
141+
for _ in range(n):
142+
batch.append(await anext(item_iter))
143+
yield tuple(batch)
144+
except StopAsyncIteration:
145+
if batch:
146+
if strict and len(batch) < n:
147+
raise ValueError("batched(): incomplete batch") from None
148+
yield tuple(batch)
136149

137150

138151
class chain(AsyncIterator[T]):

asyncstdlib/itertools.pyi

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,27 +32,33 @@ def accumulate(
3232
initial: T1,
3333
) -> AsyncIterator[T1]: ...
3434
@overload
35-
def batched(iterable: AnyIterable[T], n: Literal[1]) -> AsyncIterator[tuple[T]]: ...
35+
def batched(
36+
iterable: AnyIterable[T], n: Literal[1], strict: bool = ...
37+
) -> AsyncIterator[tuple[T]]: ...
3638
@overload
37-
def batched(iterable: AnyIterable[T], n: Literal[2]) -> AsyncIterator[tuple[T, T]]: ...
39+
def batched(
40+
iterable: AnyIterable[T], n: Literal[2], strict: bool = ...
41+
) -> AsyncIterator[tuple[T, T]]: ...
3842
@overload
3943
def batched(
40-
iterable: AnyIterable[T], n: Literal[3]
44+
iterable: AnyIterable[T], n: Literal[3], strict: bool = ...
4145
) -> AsyncIterator[tuple[T, T, T]]: ...
4246
@overload
4347
def batched(
44-
iterable: AnyIterable[T], n: Literal[4]
48+
iterable: AnyIterable[T], n: Literal[4], strict: bool = ...
4549
) -> AsyncIterator[tuple[T, T, T, T]]: ...
4650
@overload
4751
def batched(
48-
iterable: AnyIterable[T], n: Literal[5]
52+
iterable: AnyIterable[T], n: Literal[5], strict: bool = ...
4953
) -> AsyncIterator[tuple[T, T, T, T, T]]: ...
5054
@overload
5155
def batched(
52-
iterable: AnyIterable[T], n: Literal[6]
56+
iterable: AnyIterable[T], n: Literal[6], strict: bool = ...
5357
) -> AsyncIterator[tuple[T, T, T, T, T, T]]: ...
5458
@overload
55-
def batched(iterable: AnyIterable[T], n: int) -> AsyncIterator[tuple[T, ...]]: ...
59+
def batched(
60+
iterable: AnyIterable[T], n: int, strict: bool = ...
61+
) -> AsyncIterator[tuple[T, ...]]: ...
5662

5763
class chain(AsyncIterator[T]):
5864
__slots__: tuple[str, ...]

docs/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
# -- Project information -----------------------------------------------------
2626

2727
project = "asyncstdlib"
28-
author = "Max Fischer"
28+
author = "Max Kühn"
2929
copyright = f"2019-2024 {author}"
3030

3131
# The short X.Y version

docs/source/api/itertools.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,15 @@ Iterator splitting
8686

8787
.. versionadded:: 3.10.0
8888

89-
.. autofunction:: batched(iterable: (async) iter T, n: int)
89+
.. autofunction:: batched(iterable: (async) iter T, n: int, strict: bool = False)
9090
:async-for: :T
9191

9292
.. versionadded:: 3.11.0
9393

94+
.. versionadded:: 3.13.0
95+
96+
The ``strict`` parameter.
97+
9498
.. py:function:: groupby(iterable: (async) iter T)
9599
:async-for: :(T, async iter T)
96100
:noindex:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "flit_core.buildapi"
66
dynamic = ["version", "description"]
77
name = "asyncstdlib"
88
authors = [
9-
{name = "Max Fischer", email = "[email protected]"},
9+
{name = "Max Kühn", email = "[email protected]"},
1010
]
1111
readme = "README.rst"
1212
classifiers = [

unittests/test_itertools.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,19 @@ async def test_batched_invalid(length):
7979
await a.list(a.batched(range(10), length))
8080

8181

82+
@sync
83+
@pytest.mark.parametrize("values", ([1, 2, 3, 4], [1, 2, 3, 4, 5], [1]))
84+
async def test_batched_strict(values: "list[int]"):
85+
for n in range(1, len(values) + 1):
86+
batches = a.batched(values, n, strict=True)
87+
if len(values) % n == 0:
88+
assert values == list(await a.reduce(lambda a, b: a + b, batches))
89+
else:
90+
assert await a.anext(batches)
91+
with pytest.raises(ValueError):
92+
await a.list(batches)
93+
94+
8295
@sync
8396
async def test_cycle():
8497
async for _ in a.cycle([]):

0 commit comments

Comments
 (0)