diff --git a/Lib/test/test_asyncgen.py b/Lib/test/test_asyncgen.py index e9eecbc8341551..1ac8bcfa8392e3 100644 --- a/Lib/test/test_asyncgen.py +++ b/Lib/test/test_asyncgen.py @@ -266,6 +266,22 @@ async def gen(): 'async generator.*StopIteration'): to_list(gen()) + def test_async_gen_athrow_stopiteration_not_started(self): + # gh-152685: StopIteration and StopAsyncIteration thrown into an + # async generator that hasn't started yet are wrapped in a + # RuntimeError, like for a started one. + async def gen(): + yield 123 + + for exc, msg in [ + (StopIteration, 'async generator raised StopIteration'), + (StopAsyncIteration, 'async generator raised StopAsyncIteration'), + ]: + with self.subTest(exc=exc): + agen = gen() + with self.assertRaisesRegex(RuntimeError, msg): + agen.athrow(exc).send(None) + def test_async_gen_exception_07(self): def sync_gen(): try: diff --git a/Lib/test/test_coroutines.py b/Lib/test/test_coroutines.py index 9d415238876c8f..74ccaa2fe009f9 100644 --- a/Lib/test/test_coroutines.py +++ b/Lib/test/test_coroutines.py @@ -555,6 +555,17 @@ async def foo(): run_async(foo()) + def test_throw_stopiteration_not_started(self): + # gh-152685: StopIteration thrown into a coroutine that hasn't + # started yet is wrapped in a RuntimeError, like for a started one. + async def foo(): + return 1 + + coro = foo() + with self.assertRaisesRegex( + RuntimeError, "coroutine raised StopIteration"): + coro.throw(StopIteration) + def test_func_3(self): async def foo(): raise StopIteration diff --git a/Lib/test/test_generators.py b/Lib/test/test_generators.py index 6eb25960101a9e..dc4f5c32e1b2db 100644 --- a/Lib/test/test_generators.py +++ b/Lib/test/test_generators.py @@ -898,6 +898,51 @@ def g(): with self.assertRaises(RuntimeError) as cm: gen.throw(ValueError) + def test_throw_stopiteration_not_started(self): + # Throwing StopIteration into a not-yet-started generator must be + # wrapped in a RuntimeError, the same as for a started one + # (PEP 479, gh-152685). + def g(): + yield + + for started in (False, True): + with self.subTest(started=started): + gen = g() + if started: + next(gen) + with self.assertRaisesRegex(RuntimeError, + 'generator raised StopIteration'): + gen.throw(StopIteration) + # The generator is finished afterwards. + self.assertRaises(StopIteration, next, gen) + + # A StopIteration subclass is wrapped too. + class MyStop(StopIteration): + pass + with self.assertRaises(RuntimeError): + g().throw(MyStop) + + # The original StopIteration is kept as cause and context. + try: + g().throw(StopIteration) + except RuntimeError as exc: + self.assertIs(type(exc.__cause__), StopIteration) + self.assertIs(type(exc.__context__), StopIteration) + self.assertTrue(exc.__suppress_context__) + else: + self.fail('RuntimeError not raised') + + # A non-StopIteration exception is still propagated unchanged. + with self.assertRaises(ValueError): + g().throw(ValueError) + + def test_throw_stopiteration_not_started_genexpr(self): + # Same as test_throw_stopiteration_not_started, for a generator + # expression (gh-152685). + with self.assertRaisesRegex(RuntimeError, + 'generator raised StopIteration'): + (x for x in ()).throw(StopIteration) + class GeneratorStackTraceTest(unittest.TestCase): diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-07-22-11-04-33.gh-issue-152685.ixWsPB.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-07-22-11-04-33.gh-issue-152685.ixWsPB.rst new file mode 100644 index 00000000000000..ce5fc2f7e512fd --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-07-22-11-04-33.gh-issue-152685.ixWsPB.rst @@ -0,0 +1,5 @@ +Calling ``throw(StopIteration)`` on a generator, generator expression, +coroutine, or asynchronous generator that has not started running now raises +:exc:`RuntimeError`, consistent with :pep:`479` and with throwing into an +already-started generator. Previously the :exc:`StopIteration` escaped +unchanged. Patch by Joel Walker. diff --git a/Objects/genobject.c b/Objects/genobject.c index 3cdc06733363d3..a7b17b97eb2809 100644 --- a/Objects/genobject.c +++ b/Objects/genobject.c @@ -315,9 +315,30 @@ gen_send_ex2(PyGenObject *gen, PyObject *arg, PyObject **presult, int exc) } } else { - assert(!PyErr_ExceptionMatches(PyExc_StopIteration)); - assert(!PyAsyncGen_CheckExact(gen) || - !PyErr_ExceptionMatches(PyExc_StopAsyncIteration)); + /* The frame propagated an exception. A StopIteration (or, in an async + * generator, a StopAsyncIteration) raised inside the frame is normally + * turned into a RuntimeError by the frame's own PEP 479 handler. That + * handler does not cover the prologue where a not-yet-started generator + * resumes, so throwing such an exception into one reaches here + * unconverted (gh-152685). Apply the conversion so PEP 479 still + * holds. */ + const char *msg = NULL; + if (_PyErr_ExceptionMatches(tstate, PyExc_StopIteration)) { + msg = "generator raised StopIteration"; + if (PyAsyncGen_CheckExact(gen)) { + msg = "async generator raised StopIteration"; + } + else if (PyCoro_CheckExact(gen)) { + msg = "coroutine raised StopIteration"; + } + } + else if (PyAsyncGen_CheckExact(gen) && + _PyErr_ExceptionMatches(tstate, PyExc_StopAsyncIteration)) { + msg = "async generator raised StopAsyncIteration"; + } + if (msg != NULL) { + _PyErr_FormatFromCauseTstate(tstate, PyExc_RuntimeError, "%s", msg); + } } *presult = result;