Skip to content

Commit 2960244

Browse files
gh-64862: Add the stop_exception parameter in iter() and aiter()
The created iterator stops when the callable raises the specified exception. The second parameter of iter() is now named stop_value and can be passed as a keyword argument. aiter() now accepts the same stop_value and stop_exception parameters, calling an asynchronous callable and awaiting the result. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c1fc445 commit 2960244

22 files changed

Lines changed: 1046 additions & 63 deletions

Doc/library/functions.rst

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,46 @@ are always available. They are listed here in alphabetical order.
6565

6666

6767
.. function:: aiter(async_iterable, /)
68+
aiter(callable, /, stop_value, *, stop_exception=StopAsyncIteration)
69+
aiter(callable, /, *, stop_exception)
6870
6971
Return an :term:`asynchronous iterator` for an :term:`asynchronous iterable`.
7072
Equivalent to calling ``x.__aiter__()``.
7173

72-
Note: Unlike :func:`iter`, :func:`aiter` has no 2-argument variant.
74+
If *stop_value* or *stop_exception* is given,
75+
then the first argument must be a callable object.
76+
The asynchronous iterator created in this case
77+
calls *callable* with no arguments and awaits the result
78+
for each call to its :meth:`~object.__anext__` method;
79+
if the awaited value is equal to *stop_value*,
80+
or if the call raises :exc:`StopAsyncIteration` or an exception
81+
matching *stop_exception*, :exc:`StopAsyncIteration` will be raised,
82+
otherwise the value will be returned.
83+
The callable is only called when the result of :meth:`~object.__anext__`
84+
is awaited.
85+
86+
*stop_exception* is an exception class or a tuple of exception classes.
87+
If *stop_value* is not specified,
88+
the iteration stops only when the callable raises an exception.
89+
90+
For example, reading fixed-size chunks from an asynchronous stream
91+
until the end of file is reached::
92+
93+
from functools import partial
94+
async for chunk in aiter(partial(reader.read, 1024), b''):
95+
process_chunk(chunk)
96+
97+
Or consuming an :class:`asyncio.Queue` until it is shut down::
98+
99+
from asyncio import QueueShutDown
100+
async for item in aiter(queue.get, stop_exception=QueueShutDown):
101+
process_item(item)
73102

74103
.. versionadded:: 3.10
75104

105+
.. versionchanged:: next
106+
Added the *stop_value* and *stop_exception* parameters.
107+
76108
.. function:: all(iterable, /)
77109

78110
Return ``True`` if all elements of the *iterable* are true (or if the iterable
@@ -1143,21 +1175,29 @@ are always available. They are listed here in alphabetical order.
11431175

11441176

11451177
.. function:: iter(iterable, /)
1146-
iter(callable, sentinel, /)
1178+
iter(callable, /, stop_value, *, stop_exception=StopIteration)
1179+
iter(callable, /, *, stop_exception)
11471180
11481181
Return an :term:`iterator` object. The first argument is interpreted very
1149-
differently depending on the presence of the second argument. Without a
1150-
second argument, the single argument must be a collection object which supports the
1182+
differently depending on the presence of the other arguments. Without other
1183+
arguments, the single argument must be a collection object which supports the
11511184
:term:`iterable` protocol (the :meth:`~object.__iter__` method),
11521185
or it must support
11531186
the sequence protocol (the :meth:`~object.__getitem__` method with integer arguments
11541187
starting at ``0``). If it does not support either of those protocols,
1155-
:exc:`TypeError` is raised. If the second argument, *sentinel*, is given,
1188+
:exc:`TypeError` is raised.
1189+
1190+
If *stop_value* or *stop_exception* is given,
11561191
then the first argument must be a callable object. The iterator created in this case
11571192
will call *callable* with no arguments for each call to its
11581193
:meth:`~iterator.__next__` method; if the value returned is equal to
1159-
*sentinel*, :exc:`StopIteration` will be raised, otherwise the value will
1160-
be returned.
1194+
*stop_value*, or if the call raises :exc:`StopIteration` or an exception
1195+
matching *stop_exception*, :exc:`StopIteration` will be raised, otherwise the
1196+
value will be returned.
1197+
1198+
*stop_exception* is an exception class or a tuple of exception classes.
1199+
If *stop_value* is not specified,
1200+
the iteration stops only when the callable raises an exception.
11611201

11621202
See also :ref:`typeiter`.
11631203

@@ -1170,6 +1210,17 @@ are always available. They are listed here in alphabetical order.
11701210
for block in iter(partial(f.read, 64), b''):
11711211
process_block(block)
11721212

1213+
*stop_exception* is useful for callables which report exhaustion by raising an
1214+
exception instead of returning a special value.
1215+
For example, draining a queue::
1216+
1217+
from queue import Empty
1218+
for item in iter(queue.get_nowait, stop_exception=Empty):
1219+
process_item(item)
1220+
1221+
.. versionchanged:: next
1222+
Added the *stop_exception* parameter and allowed passing *stop_value* by keyword.
1223+
11731224

11741225
.. function:: len(object, /)
11751226

Doc/whatsnew/3.16.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,13 @@ New features
7575
Other language changes
7676
======================
7777

78+
* The :func:`iter` function now accepts the *stop_exception* parameter.
79+
The created iterator stops when the callable raises the specified exception.
80+
The second parameter is now named *stop_value* and can be passed by keyword.
81+
:func:`aiter` now accepts the same *stop_value* and *stop_exception*
82+
parameters, calling an asynchronous callable and awaiting the result.
83+
(Contributed by Serhiy Storchaka in :gh:`64862`.)
84+
7885
* :meth:`memoryview.cast` now allows casting a multidimensional
7986
F-contiguous view to a one-dimensional view.
8087
(Contributed by Jaemin Park in :gh:`91484`.)

Include/internal/pycore_genobject.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ PyAPI_FUNC(int) _PyGen_SetStopIterationValue(PyObject *);
2929

3030
// Export for '_asyncio' shared extension
3131
PyAPI_FUNC(int) _PyGen_FetchStopIterationValue(PyObject **);
32+
// Set the exception passed to throw(typ[, val[, tb]]).
33+
// Return 0 on success, -1 on failure.
34+
extern int _PyGen_SetException(PyObject *typ, PyObject *val, PyObject *tb);
3235

3336
PyAPI_FUNC(PyObject *)_PyCoro_GetAwaitableIter(PyObject *o);
3437
PyAPI_FUNC(PyObject *)_PyAsyncGenValueWrapperNew(PyThreadState *state, PyObject *);

Include/internal/pycore_global_objects_fini_generated.h

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Include/internal/pycore_global_strings.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,8 @@ struct _Py_global_strings {
827827
STRUCT_FOR_ID(stdout)
828828
STRUCT_FOR_ID(step)
829829
STRUCT_FOR_ID(steps)
830+
STRUCT_FOR_ID(stop_exception)
831+
STRUCT_FOR_ID(stop_value)
830832
STRUCT_FOR_ID(store_name)
831833
STRUCT_FOR_ID(strategy)
832834
STRUCT_FOR_ID(strftime)

Include/internal/pycore_interp_structs.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -538,7 +538,7 @@ struct _py_func_state {
538538
If you add a new static type to the standard library, you may have to
539539
update one of these numbers.
540540
*/
541-
#define _Py_NUM_MANAGED_PREINITIALIZED_TYPES 120
541+
#define _Py_NUM_MANAGED_PREINITIALIZED_TYPES 122
542542
#define _Py_MAX_MANAGED_STATIC_BUILTIN_TYPES \
543543
(_Py_NUM_MANAGED_PREINITIALIZED_TYPES + 83)
544544
#define _Py_MAX_MANAGED_STATIC_EXT_TYPES 10
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#ifndef Py_INTERNAL_ITEROBJECT_H
2+
#define Py_INTERNAL_ITEROBJECT_H
3+
#ifdef __cplusplus
4+
extern "C" {
5+
#endif
6+
7+
#ifndef Py_BUILD_CORE
8+
# error "this header requires Py_BUILD_CORE define"
9+
#endif
10+
11+
extern PyTypeObject _PyACallIter_Type;
12+
extern PyTypeObject _PyACallIterAwaitable_Type;
13+
14+
// Like PyCallIter_New(), but the iteration also stops when *callable* raises
15+
// an exception matching *stop_exc* (an exception class or a tuple of exception
16+
// classes). Both *sentinel* and *stop_exc* can be NULL.
17+
extern PyObject *_PyCallIter_NewEx(PyObject *callable, PyObject *sentinel,
18+
PyObject *stop_exc);
19+
20+
// The asynchronous counterpart of _PyCallIter_NewEx(): the result of
21+
// *callable* is awaited, and StopAsyncIteration stops the iteration.
22+
extern PyObject *_PyACallIter_New(PyObject *callable, PyObject *sentinel,
23+
PyObject *stop_exc);
24+
25+
// Return NULL if *stop_exc* has no effect: *implied_exc* stops the iteration
26+
// in any case, and an empty tuple never matches a raised exception.
27+
static inline PyObject *
28+
_PyIter_NormalizeStopException(PyObject *stop_exc, PyObject *implied_exc)
29+
{
30+
if (stop_exc == implied_exc ||
31+
(PyTuple_Check(stop_exc) && PyTuple_GET_SIZE(stop_exc) == 0))
32+
{
33+
return NULL;
34+
}
35+
return stop_exc;
36+
}
37+
38+
#ifdef __cplusplus
39+
}
40+
#endif
41+
#endif /* !Py_INTERNAL_ITEROBJECT_H */

Include/internal/pycore_runtime_init_generated.h

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Include/internal/pycore_unicodeobject_generated.h

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Lib/test/test_asyncgen.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,148 @@ async def gen():
789789
applied_twice = aiter(applied_once)
790790
self.assertIs(applied_once, applied_twice)
791791

792+
def make_counter(self):
793+
state = {'n': 0}
794+
async def counter():
795+
state['n'] += 1
796+
return state['n']
797+
return counter
798+
799+
def collect(self, ait):
800+
async def consume():
801+
return [i async for i in ait]
802+
return self.loop.run_until_complete(consume())
803+
804+
def test_aiter_callable_stop(self):
805+
self.assertEqual(self.collect(aiter(self.make_counter(), 4)), [1, 2, 3])
806+
self.assertEqual(self.collect(aiter(self.make_counter(), stop_value=4)),
807+
[1, 2, 3])
808+
809+
def test_aiter_callable_stop_exception(self):
810+
counter = self.make_counter()
811+
async def spam():
812+
value = await counter()
813+
if value > 3:
814+
raise LookupError
815+
return value
816+
self.assertEqual(self.collect(aiter(spam, stop_exception=LookupError)),
817+
[1, 2, 3])
818+
counter = self.make_counter()
819+
self.assertEqual(
820+
self.collect(aiter(spam, stop_exception=(ZeroDivisionError,
821+
LookupError))),
822+
[1, 2, 3])
823+
824+
def test_aiter_callable_stop_and_exception(self):
825+
counter = self.make_counter()
826+
async def spam():
827+
value = await counter()
828+
if value > 5:
829+
raise LookupError
830+
return value
831+
self.assertEqual(
832+
self.collect(aiter(spam, 3, stop_exception=LookupError)), [1, 2])
833+
counter = self.make_counter()
834+
self.assertEqual(
835+
self.collect(aiter(spam, 100, stop_exception=LookupError)),
836+
[1, 2, 3, 4, 5])
837+
838+
def test_aiter_callable_stop_exception_redundant(self):
839+
# StopAsyncIteration and an empty tuple stop the iteration in any
840+
# case, so they are the same as no exception argument
841+
counter = self.make_counter()
842+
async def spam():
843+
value = await counter()
844+
if value > 3:
845+
raise StopAsyncIteration
846+
return value
847+
self.assertEqual(
848+
self.collect(aiter(spam, stop_exception=StopAsyncIteration)),
849+
[1, 2, 3])
850+
counter = self.make_counter()
851+
self.assertEqual(self.collect(aiter(spam, stop_exception=())),
852+
[1, 2, 3])
853+
854+
def test_aiter_callable_stop_async_iteration(self):
855+
# StopAsyncIteration stops the iteration even if other exception
856+
# is specified
857+
counter = self.make_counter()
858+
async def spam():
859+
value = await counter()
860+
if value > 3:
861+
raise StopAsyncIteration
862+
return value
863+
self.assertEqual(self.collect(aiter(spam, stop_exception=LookupError)),
864+
[1, 2, 3])
865+
866+
def test_aiter_callable_other_exception(self):
867+
async def spam():
868+
raise ZeroDivisionError
869+
it = aiter(spam, stop_exception=LookupError)
870+
with self.assertRaises(ZeroDivisionError):
871+
self.loop.run_until_complete(anext(it))
872+
873+
def test_aiter_callable_exhausted(self):
874+
it = aiter(self.make_counter(), 3)
875+
self.assertEqual(self.collect(it), [1, 2])
876+
self.assertEqual(self.loop.run_until_complete(anext(it, 'default')),
877+
'default')
878+
with self.assertRaises(StopAsyncIteration):
879+
self.loop.run_until_complete(anext(it))
880+
881+
def test_aiter_callable_lazy(self):
882+
# The callable is only called when the awaitable is awaited
883+
calls = []
884+
async def spam():
885+
calls.append(1)
886+
return len(calls)
887+
it = aiter(spam, 10)
888+
awaitable = it.__anext__()
889+
self.assertEqual(calls, [])
890+
self.assertEqual(self.loop.run_until_complete(awaitable), 1)
891+
self.assertEqual(calls, [1])
892+
893+
def test_aiter_callable_awaitable(self):
894+
it = aiter(self.make_counter(), 10)
895+
awaitable = it.__anext__()
896+
self.assertIsNone(awaitable.close())
897+
with self.assertRaises(RuntimeError):
898+
self.loop.run_until_complete(awaitable)
899+
awaitable = it.__anext__()
900+
with self.assertRaises(KeyError):
901+
awaitable.throw(KeyError('injected'))
902+
903+
def test_aiter_callable_cancel(self):
904+
# Cancellation is delivered to the awaited callable result
905+
cancelled = []
906+
async def spam():
907+
try:
908+
await asyncio.sleep(10)
909+
except asyncio.CancelledError:
910+
cancelled.append(1)
911+
raise
912+
async def consume():
913+
async for _ in aiter(spam, None):
914+
pass
915+
async def main():
916+
task = asyncio.ensure_future(consume())
917+
await asyncio.sleep(0)
918+
task.cancel()
919+
with self.assertRaises(asyncio.CancelledError):
920+
await task
921+
self.loop.run_until_complete(main())
922+
self.assertEqual(cancelled, [1])
923+
924+
def test_aiter_callable_errors(self):
925+
async def gen():
926+
yield 1
927+
self.assertRaises(TypeError, aiter, gen(), 1)
928+
self.assertRaises(TypeError, aiter, [1, 2], stop_exception=LookupError)
929+
self.assertRaises(TypeError, aiter, len, stop_exception=42)
930+
self.assertRaises(TypeError, aiter, len,
931+
stop_exception=(LookupError, 42))
932+
self.assertRaises(TypeError, aiter, len, stop_exception=LookupError())
933+
792934
def test_anext_bad_args(self):
793935
async def gen():
794936
yield 1

0 commit comments

Comments
 (0)