Skip to content

Commit 5877437

Browse files
Replace a leaking StopIteration with RuntimeError
If the callable raises StopIteration (StopAsyncIteration in aiter()) which does not match stop_exception, the consumer would mistake it for the end of the iteration, or, in the asynchronous case, for the result of the await. Replace it with RuntimeError, as PEP 479 and PEP 525 do for generators. StopIteration is therefore no longer special: it stops the iteration only because it is the default stop_exception. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d85e8ce commit 5877437

6 files changed

Lines changed: 96 additions & 63 deletions

File tree

Doc/library/functions.rst

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,15 +77,18 @@ are always available. They are listed here in alphabetical order.
7777
calls *callable* with no arguments and awaits the result
7878
for each call to its :meth:`~object.__anext__` method;
7979
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,
80+
or if the call raises an exception matching *stop_exception*,
81+
:exc:`StopAsyncIteration` will be raised,
8282
otherwise the value will be returned.
8383
The callable is only called when the result of :meth:`~object.__anext__`
8484
is awaited.
8585

8686
*stop_exception* is an exception class or a tuple of exception classes.
8787
If *stop_value* is not specified,
8888
the iteration stops only when the callable raises an exception.
89+
If the callable raises :exc:`StopAsyncIteration` which does not match
90+
*stop_exception*, it is replaced with a :exc:`RuntimeError`,
91+
as for asynchronous generators (see :pep:`525`).
8992

9093
For example, reading fixed-size chunks from an asynchronous stream
9194
until the end of file is reached::
@@ -1191,13 +1194,15 @@ are always available. They are listed here in alphabetical order.
11911194
then the first argument must be a callable object. The iterator created in this case
11921195
will call *callable* with no arguments for each call to its
11931196
:meth:`~iterator.__next__` method; if the value returned is equal to
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+
*stop_value*, or if the call raises an exception matching *stop_exception*,
1198+
:exc:`StopIteration` will be raised, otherwise the value will be returned.
11971199

11981200
*stop_exception* is an exception class or a tuple of exception classes.
11991201
If *stop_value* is not specified,
12001202
the iteration stops only when the callable raises an exception.
1203+
If the callable raises :exc:`StopIteration` which does not match
1204+
*stop_exception*, it is replaced with a :exc:`RuntimeError`,
1205+
as for generators (see :pep:`479`).
12011206

12021207
See also :ref:`typeiter`.
12031208

Lib/test/test_asyncgen.py

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -835,9 +835,8 @@ async def spam():
835835
self.collect(aiter(spam, 100, stop_exception=LookupError)),
836836
[1, 2, 3, 4, 5])
837837

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
838+
def test_aiter_callable_stop_async_iteration(self):
839+
# StopAsyncIteration is the default stop exception
841840
counter = self.make_counter()
842841
async def spam():
843842
value = await counter()
@@ -847,21 +846,38 @@ async def spam():
847846
self.assertEqual(
848847
self.collect(aiter(spam, stop_exception=StopAsyncIteration)),
849848
[1, 2, 3])
850-
counter = self.make_counter()
851-
self.assertEqual(self.collect(aiter(spam, stop_exception=())),
852-
[1, 2, 3])
853849

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()
850+
def test_aiter_callable_leak_from_await(self):
851+
# A StopAsyncIteration leaking from the await is replaced with
852+
# RuntimeError (see PEP 525)
858853
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])
854+
raise StopAsyncIteration
855+
it = aiter(spam, 10, stop_exception=LookupError)
856+
with self.assertRaisesRegex(RuntimeError,
857+
'callable raised StopAsyncIteration') as cm:
858+
self.loop.run_until_complete(anext(it))
859+
self.assertIsInstance(cm.exception.__cause__, StopAsyncIteration)
860+
# but if it matches stop_exception, it stops the iteration
861+
it = aiter(spam, 10, stop_exception=(LookupError, StopAsyncIteration))
862+
with self.assertRaises(StopAsyncIteration):
863+
self.loop.run_until_complete(anext(it))
864+
865+
def test_aiter_callable_leak_from_call(self):
866+
# StopIteration and StopAsyncIteration leaking from the call are
867+
# replaced with RuntimeError (see PEP 525)
868+
for exc in StopIteration, StopAsyncIteration:
869+
with self.subTest(exc=exc):
870+
def spam():
871+
raise exc
872+
it = aiter(spam, 10, stop_exception=LookupError)
873+
with self.assertRaisesRegex(
874+
RuntimeError, f'callable raised {exc.__name__}') as cm:
875+
self.loop.run_until_complete(anext(it))
876+
self.assertIsInstance(cm.exception.__cause__, exc)
877+
# but if it matches stop_exception, it stops the iteration
878+
it = aiter(spam, 10, stop_exception=(LookupError, exc))
879+
with self.assertRaises(StopAsyncIteration):
880+
self.loop.run_until_complete(anext(it))
865881

866882
def test_aiter_callable_other_exception(self):
867883
async def spam():

Lib/test/test_iter.py

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -371,16 +371,18 @@ def test_iter_exception_and_stop(self):
371371
self.check_iterator(iter(CallableIterClass(), 200, stop_exception=IndexError),
372372
list(range(101)))
373373

374-
# StopIteration stops the iteration even if other exception is specified
375-
def test_iter_exception_stop_iteration(self):
376-
def spam(state=[0]):
377-
i = state[0]
378-
if i == 10:
379-
raise StopIteration
380-
state[0] = i+1
381-
return i
382-
self.check_iterator(iter(spam, stop_exception=IndexError), list(range(10)),
383-
pickle=False)
374+
# A leaking StopIteration is replaced with RuntimeError (see PEP 479)
375+
def test_iter_exception_stop_iteration_leak(self):
376+
def spam():
377+
raise StopIteration
378+
it = iter(spam, stop_exception=IndexError)
379+
with self.assertRaisesRegex(RuntimeError,
380+
'callable raised StopIteration') as cm:
381+
next(it)
382+
self.assertIsInstance(cm.exception.__cause__, StopIteration)
383+
# but if it matches stop_exception, it stops the iteration
384+
it = iter(spam, stop_exception=(IndexError, StopIteration))
385+
self.assertRaises(StopIteration, next, it)
384386

385387
# Other exceptions are propagated
386388
def test_iter_exception_not_matching(self):
@@ -395,22 +397,16 @@ def test_iter_exception_errors(self):
395397
self.assertRaises(TypeError, iter, len, stop_exception=(IndexError, 42))
396398
self.assertRaises(TypeError, iter, len, stop_exception=IndexError())
397399

398-
# StopIteration and an empty tuple stop the iteration in any case,
399-
# so they are the same as no exception argument
400-
def test_iter_exception_redundant(self):
401-
def make_spam():
402-
state = [0]
403-
def spam():
404-
if state[0] == 10:
405-
raise StopIteration
406-
state[0] += 1
407-
return state[0] - 1
408-
return spam
409-
for stop_exception in StopIteration, ():
410-
with self.subTest(stop_exception=stop_exception):
411-
self.check_iterator(
412-
iter(make_spam(), stop_exception=stop_exception),
413-
list(range(10)), pickle=False)
400+
# StopIteration is the default stop exception
401+
def test_iter_exception_stop_iteration(self):
402+
def spam(state=[0]):
403+
i = state[0]
404+
if i == 10:
405+
raise StopIteration
406+
state[0] = i+1
407+
return i
408+
self.check_iterator(iter(spam, stop_exception=StopIteration),
409+
list(range(10)), pickle=False)
414410

415411
def test_calliter_reduce(self):
416412
c = CallableIterClass()

Objects/iterobject.c

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#include "pycore_genobject.h" // _PyCoro_GetAwaitableIter()
88
#include "pycore_iterobject.h" // _PyCallIter_NewEx()
99
#include "pycore_object.h" // _PyObject_GC_TRACK()
10+
#include "pycore_pyerrors.h" // _PyErr_FormatFromCause()
1011
#include "pycore_pystate.h" // _PyThreadState_GET()
1112

1213

@@ -187,10 +188,10 @@ PyTypeObject PySeqIter_Type = {
187188

188189
typedef struct {
189190
PyObject_HEAD
190-
/* All are set to NULL when the iterator is exhausted */
191+
/* Both are set to NULL when the iterator is exhausted */
191192
PyObject *it_callable;
192193
PyObject *it_sentinel; /* can be NULL */
193-
PyObject *it_stop_exc; /* not NULL if it_callable is not NULL */
194+
PyObject *it_stop_exc; /* never NULL */
194195
} calliterobject;
195196

196197
PyObject *
@@ -264,14 +265,17 @@ calliter_iternext(PyObject *op)
264265
if (ok > 0) {
265266
Py_CLEAR(it->it_callable);
266267
Py_CLEAR(it->it_sentinel);
267-
Py_CLEAR(it->it_stop_exc);
268268
}
269269
}
270270
else if (PyErr_ExceptionMatches(it->it_stop_exc)) {
271271
PyErr_Clear();
272272
Py_CLEAR(it->it_callable);
273273
Py_CLEAR(it->it_sentinel);
274-
Py_CLEAR(it->it_stop_exc);
274+
}
275+
else if (PyErr_ExceptionMatches(PyExc_StopIteration)) {
276+
/* It would be mistaken for the end of the iteration (see PEP 479). */
277+
_PyErr_FormatFromCause(PyExc_RuntimeError,
278+
"callable raised StopIteration");
275279
}
276280
Py_XDECREF(result);
277281
return NULL;
@@ -616,10 +620,10 @@ PyAnextAwaitable_New(PyObject *awaitable, PyObject *default_value)
616620

617621
typedef struct {
618622
PyObject_HEAD
619-
/* All are set to NULL when the iterator is exhausted */
623+
/* Both are set to NULL when the iterator is exhausted */
620624
PyObject *it_callable;
621625
PyObject *it_sentinel; /* can be NULL */
622-
PyObject *it_stop_exc; /* not NULL if it_callable is not NULL */
626+
PyObject *it_stop_exc; /* never NULL */
623627
} acalliterobject;
624628

625629
#define acalliterobject_CAST(op) ((acalliterobject *)(op))
@@ -660,7 +664,6 @@ acalliter_exhaust(acalliterobject *it)
660664
{
661665
Py_CLEAR(it->it_callable);
662666
Py_CLEAR(it->it_sentinel);
663-
Py_CLEAR(it->it_stop_exc);
664667
}
665668

666669
static void
@@ -783,6 +786,16 @@ acallawaitable_start(acallawaitableobject *aw)
783786
acalliter_exhaust(it);
784787
PyErr_SetNone(PyExc_StopAsyncIteration);
785788
}
789+
else if (PyErr_ExceptionMatches(PyExc_StopIteration)) {
790+
/* It would be mistaken for the result of the await (PEP 525). */
791+
_PyErr_FormatFromCause(PyExc_RuntimeError,
792+
"callable raised StopIteration");
793+
}
794+
else if (PyErr_ExceptionMatches(PyExc_StopAsyncIteration)) {
795+
/* It would be mistaken for the end of the iteration (PEP 525). */
796+
_PyErr_FormatFromCause(PyExc_RuntimeError,
797+
"callable raised StopAsyncIteration");
798+
}
786799
return -1;
787800
}
788801
aw->aw_wrapped = awaitable;
@@ -820,6 +833,11 @@ acallawaitable_handle_error(acallawaitableobject *aw)
820833
acalliter_exhaust(it);
821834
PyErr_SetNone(PyExc_StopAsyncIteration);
822835
}
836+
else if (PyErr_ExceptionMatches(PyExc_StopAsyncIteration)) {
837+
/* It would be mistaken for the end of the iteration (see PEP 525). */
838+
_PyErr_FormatFromCause(PyExc_RuntimeError,
839+
"callable raised StopAsyncIteration");
840+
}
823841
return NULL;
824842
}
825843

Python/bltinmodule.c

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1908,13 +1908,13 @@ Get an iterator from an object.
19081908
19091909
In the first form, the argument must supply its own iterator, or be a
19101910
sequence. In the second form, the callable is called until it returns
1911-
the stop value or raises StopIteration or the specified exception.
1911+
the stop value or raises the specified exception.
19121912
[clinic start generated code]*/
19131913

19141914
static PyObject *
19151915
builtin_iter_impl(PyObject *module, PyObject *object, PyObject *stop_value,
19161916
PyObject *stop_exception)
1917-
/*[clinic end generated code: output=eb9c9ae8f77bf400 input=d4eb3d19c8942790]*/
1917+
/*[clinic end generated code: output=eb9c9ae8f77bf400 input=d3a2f767f29d9ae6]*/
19181918
{
19191919
if (stop_value == NULL && stop_exception == NULL) {
19201920
return PyObject_GetIter(object);
@@ -1941,14 +1941,13 @@ aiter as builtin_aiter
19411941
Return an AsyncIterator for an AsyncIterable object.
19421942
19431943
In the second form, the callable is called and its result is awaited
1944-
until it returns the stop value or raises StopAsyncIteration or the
1945-
specified exception.
1944+
until it returns the stop value or raises the specified exception.
19461945
[clinic start generated code]*/
19471946

19481947
static PyObject *
19491948
builtin_aiter_impl(PyObject *module, PyObject *object, PyObject *stop_value,
19501949
PyObject *stop_exception)
1951-
/*[clinic end generated code: output=2865edb3fbc45693 input=3eec4f0424a7ebac]*/
1950+
/*[clinic end generated code: output=2865edb3fbc45693 input=2adb37d12adafd0c]*/
19521951
{
19531952
if (stop_value == NULL && stop_exception == NULL) {
19541953
return PyObject_GetAIter(object);

Python/clinic/bltinmodule.c.h

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

0 commit comments

Comments
 (0)