gh-143055: Delegate to subiterators when unpacking in generator expressions#152550
gh-143055: Delegate to subiterators when unpacking in generator expressions#152550graingert wants to merge 3 commits into
Conversation
Documentation build overview
9 files changed ·
|
|
@1st1 has expressed the view that an asynchronous generator should never delegate to a synchronous one because of some nasty edge cases. See this section of PEP 828. |
The hazard in that section is And it's wrong independent of this PR anyway — yielding repeatedly across a structured-concurrency scope is never valid; the single yield in |
|
I don't appreciate you just taking my comment and pasting Claude's response -- please use your own words! The async def main():
async with asyncio.timeout(1):
# The synchronous subgenerator is not constructed to handle asynchronous exceptions
# (such as asyncio.CancelledError). If the timeout expires, the synchronous subgenerator
# may swallow them.
async for item in (*subgen() async for _ in whatever()):
await asyncio.sleep(0.5)This is only one of many potential problems. I had a long discussion with Yury about this topic at PyCon last month, and he eventually convinced me that delegating from an async to a sync generator is a bad idea. It's unlikely that people do this in practice, but it doesn't matter. This PR essentially adds a way to sneakily convert a synchronous generator into an asynchronous one, which opens the door to a bunch of weird bugs and increased maintenance burden. In fact, if PEP 828 is accepted, you could take this a step further to get the exact idea that Yury rejected: async def main():
ag = (*subgenerator() async for _ in single_element())
async yield from ag # Would delegate to a synchronous generator! |
|
Sorry for using claude I'll avoid using it in future comments on the
tracker.
If the timeout expires, the synchronous subgenerator
# may swallow them.
The synchronous generator will never receive a CancelledError because that
is only thrown out from Futures that are cancelled
Thomas Grainger
…On Mon, 29 Jun 2026, 22:31 Peter Bierma, ***@***.***> wrote:
*ZeroIntensity* left a comment (python/cpython#152550)
<#152550 (comment)>
I don't appreciate you just taking my comment and pasting Claude's
response -- please use your own words!
The async with asyncio.timeout thing is just there as an example;
fundamentally, this PR allows mixing async and sync generator frames, which
is not good. But anyway, asyncio.timeout is still a problem with this PR:
async def main():
async with asyncio.timeout(1):
# The synchronous subgenerator is not constructed to handle asynchronous exceptions
# (such as asyncio.CancelledError). If the timeout expires, the synchronous subgenerator
# may swallow them.
async for item in (*subgen() async for _ in whatever()):
await asyncio.sleep(0.5)
This is only one of many potential problems. I had a long discussion with
Yury about this topic at PyCon last month, and he eventually convinced me
that delegating from an async to a sync generator is a bad idea.
It's unlikely that people do this in practice, but it doesn't matter. This
PR essentially adds a way to sneakily convert a synchronous generator into
an asynchronous one, which opens the door to a bunch of weird bugs and
increased maintenance burden.
In fact, if PEP 828 is accepted, you could take this a step further to get
the exact idea that Yury rejected:
async def main():
ag = (*subgenerator() async for _ in single_element())
async yield from ag # Would delegate to a synchronous generator!
—
Reply to this email directly, view it on GitHub
<#152550?email_source=notifications&email_token=AADFATFCFBHOTZQVT64URN35CLN3NA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTIOBTG4ZDKMBSHA22M4TFMFZW63VGMF2XI2DPOKSWK5TFNZ2KYZTPN52GK4S7MNWGSY3L#issuecomment-4837250285>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AADFATFTI4QOVIEIL2HTNE35CLN3NAVCNFSNUABEKJSXA33TNF2G64TZHM4DCNJZHA4TMMJ3JFZXG5LFHM2DONRVHEYDENRXGOQXMAQ>
.
You are receiving this because you authored the thread.Message ID:
***@***.***>
|
|
With the correct code using aclosing it shows that CancelledError won't be thrown into the wrong place. Trace where the cancellation actually lands. A sync generator never awaits we can guarantee it doesn't yield a Future, i.e there is no suspension point inside subgen(). async def main():
async with asyncio.timeout(1):
# The synchronous subgenerator is not constructed to handle asynchronous exceptions
# (such as asyncio.CancelledError). If the timeout expires, the synchronous subgenerator
# will never recieve a CancelledError. Instead it will get a GeneratorExit when the agen is a aclosed()
agen = (*subgen() async for _ in whatever())
async with contextlib.aclosing(agen):
async for item in agen:
await asyncio.sleep(0.5)The CancelledError can only be thrown out of |
… expressions Unpacking a sub-iterable with `*` in a generator expression (PEP 798) now delegates to the sub-iterable using `yield from` semantics, so that values sent with send() and exceptions thrown with throw() are forwarded to the sub-iterator (and the sub-iterator's return value is discarded). This also works in asynchronous generator expressions. Since `*` unpacking is synchronous, the sub-iterable is a sync iterable, but it is delegated to from inside an async generator, so each produced value must be wrapped as an async-generator value -- including values produced in response to asend() and athrow(). A new internal `_PyAsyncGenUnpack` iterator (constructed via the new INTRINSIC_ASYNC_GEN_UNPACK intrinsic) wraps the sync iterable so that asend(), athrow() and aclose() are forwarded to the sub-iterator's send(), throw() and close(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- async_gen_unpack_throw: validate arity with _PyArg_CheckPositional so that calling throw() with no arguments on the internal wrapper (reachable via ag_await while suspended at the delegation) raises TypeError instead of crashing the interpreter via gen_set_exception(NULL, ...). - Correct the whatsnew/NEWS/reference wording: an async generator expression's aclose() throws GeneratorExit into the sub-iterator via its throw(), rather than calling close(). - Add a regression test for the throw() arity check. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nalyzer Add the new static type to Tools/c-analyzer/cpython/globals-to-fix.tsv alongside the other genobject static types, so the check-c-globals CI step passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2fdf024 to
dee268c
Compare
|
I think you're missing my overall point -- we have explicitly rejected the ability to delegate to synchronous generators from asynchronous ones. I'm not going to continue rehashing that discussion here, but since we rejected the idea for normal async generators, that should apply to generator expressions too. |
Unpacking a sub-iterable with
*in a generator expression (PEP 798) now delegates to the sub-iterable usingyield fromsemantics, rather than re-yielding each value with a manual loop.Sync generator expressions
(*sub for sub in subs)now forwardssend()andthrow()to the sub-iterator currently being unpacked, and discards the sub-iterator's return value:Asynchronous generator expressions
*unpacking is synchronous (per PEP 448), so the sub-iterable is always a sync iterable. But it is delegated to from inside an async generator, where each yielded value must be wrapped as an async-generator value — otherwise the async-generator machinery treats it as an awaited value. The throw path is the subtle case:_gen_throwforwards throws to the sub in the runtime and returns the result bypassing any bytecode-level wrap (and that same path is shared withawait/async forpassthrough, which must stay unwrapped).To handle this, a small internal iterator
_PyAsyncGenUnpackwraps the sync iterable so that every value it produces — via__next__,am_send, andthrow— is wrapped, while the return value and exceptions pass through unwrapped. It is constructed via a newINTRINSIC_ASYNC_GEN_UNPACKintrinsic emitted only in the async-generator case. As a resultasend(),athrow()andaclose()are forwarded to the sub-iterator'ssend(),throw()andclose().No
async yield from/ PEP 828 syntax is introduced; this is purely about delegating PEP 798 unpacking.Notes
PYC_MAGIC_NUMBERand the managed static type count are bumped (new helper type + changed codegen).Tests
New
unittest.TestCaseclasses inLib/test/test_unpack_ex.pycover syncsend/throw/closeforwarding and asyncasend/athrow/acloseforwarding. The async tests drive the generators by hand (noIsolatedAsyncioTestCase/asyncio), matching the convention intest_asyncgen.py.🤖 Generated with Claude Code