Skip to content

Commit c1e0905

Browse files
graingertclaude
andcommitted
gh-143055: Delegate to subiterators when unpacking in generator 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>
1 parent bc3fa17 commit c1e0905

12 files changed

Lines changed: 441 additions & 27 deletions

File tree

Doc/reference/expressions.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -891,11 +891,24 @@ For example::
891891
>>> {**d for d in configuration_sets}
892892
{'color': 'yellow', 'count': 5}
893893

894+
In a generator expression, a starred expression is delegated to using
895+
:keyword:`yield from <yield>` semantics: values sent into the generator with
896+
:meth:`~generator.send` and exceptions thrown in with :meth:`~generator.throw`
897+
are forwarded to the sub-iterator currently being unpacked. The same applies
898+
to asynchronous generator expressions, where :meth:`~agen.asend`,
899+
:meth:`~agen.athrow` and :meth:`~agen.aclose` are forwarded to the
900+
(synchronous) sub-iterator.
901+
894902
.. versionadded:: 3.15
895903

896904
Unpacking in comprehensions using the ``*`` and ``**`` operators
897905
was introduced in :pep:`798`.
898906

907+
.. versionchanged:: 3.16
908+
909+
Unpacking a starred expression in a generator expression delegates to the
910+
sub-iterator using :keyword:`yield from <yield>` semantics.
911+
899912

900913
.. index::
901914
single: async for; in comprehensions

Doc/whatsnew/3.16.rst

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

78+
* Unpacking a sub-iterable with ``*`` in a generator expression
79+
(:pep:`798`) now delegates to the sub-iterable using
80+
:keyword:`yield from <yield>` semantics. Values sent with
81+
:meth:`~generator.send` and exceptions thrown with
82+
:meth:`~generator.throw` are forwarded to the sub-iterator, and the same
83+
applies to asynchronous generator expressions, where
84+
:meth:`~agen.asend`, :meth:`~agen.athrow` and :meth:`~agen.aclose`
85+
are forwarded to the (synchronous) sub-iterator's
86+
:meth:`~generator.send`, :meth:`~generator.throw` and
87+
:meth:`~generator.close`.
88+
(Contributed in :gh:`143055`.)
89+
7890

7991

8092
New modules

Include/internal/pycore_genobject.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,15 @@ PyAPI_FUNC(int) _PyGen_FetchStopIterationValue(PyObject **);
3232

3333
PyAPI_FUNC(PyObject *)_PyCoro_GetAwaitableIter(PyObject *o);
3434
PyAPI_FUNC(PyObject *)_PyAsyncGenValueWrapperNew(PyThreadState *state, PyObject *);
35+
PyAPI_FUNC(PyObject *)_PyAsyncGenUnpack_New(PyThreadState *state, PyObject *);
3536

3637
// Exported for external JIT support
3738
PyAPI_FUNC(PyObject *) _PyCoro_ComputeOrigin(int origin_depth, _PyInterpreterFrame *current_frame);
3839

3940
extern PyTypeObject _PyCoroWrapper_Type;
4041
extern PyTypeObject _PyAsyncGenWrappedValue_Type;
4142
extern PyTypeObject _PyAsyncGenAThrow_Type;
43+
extern PyTypeObject _PyAsyncGenUnpack_Type;
4244

4345
PyAPI_FUNC(PySendResult) _PyAsyncGenASend_Send(PyObject *iter, PyObject *arg, PyObject **result);
4446

Include/internal/pycore_interp_structs.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -532,7 +532,7 @@ struct _py_func_state {
532532
If you add a new static type to the standard library, you may have to
533533
update one of these numbers.
534534
*/
535-
#define _Py_NUM_MANAGED_PREINITIALIZED_TYPES 120
535+
#define _Py_NUM_MANAGED_PREINITIALIZED_TYPES 121
536536
#define _Py_MAX_MANAGED_STATIC_BUILTIN_TYPES \
537537
(_Py_NUM_MANAGED_PREINITIALIZED_TYPES + 83)
538538
#define _Py_MAX_MANAGED_STATIC_EXT_TYPES 10

Include/internal/pycore_intrinsics.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@
1919
#define INTRINSIC_SUBSCRIPT_GENERIC 10
2020
#define INTRINSIC_TYPEALIAS 11
2121
#define INTRINSIC_BUILD_FROZENSET 12
22+
#define INTRINSIC_ASYNC_GEN_UNPACK 13
2223

23-
#define MAX_INTRINSIC_1 12
24+
#define MAX_INTRINSIC_1 13
2425

2526

2627
/* Binary Functions: */

Include/internal/pycore_magic_number.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ Known values:
299299
Python 3.15b1 3666 (Add SEND_VIRTUAL and SEND_ASYNC_GEN specializations)
300300
Python 3.16a0 3700 (Initial version)
301301
Python 3.16a0 3701 (Add CONSTANT_EMPTY_TUPLE to LOAD_COMMON_CONSTANT)
302+
Python 3.16a0 3702 (Delegate to subiterators when unpacking in generator expressions)
302303
303304
304305
Python 3.17 will start with 3750
@@ -312,7 +313,7 @@ PC/launcher.c must also be updated.
312313
313314
*/
314315

315-
#define PYC_MAGIC_NUMBER 3701
316+
#define PYC_MAGIC_NUMBER 3702
316317
/* This is equivalent to converting PYC_MAGIC_NUMBER to 2 bytes
317318
(little-endian) and then appending b'\r\n'. */
318319
#define PYC_MAGIC_NUMBER_TOKEN \

Lib/test/test_unpack_ex.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,169 @@ def test_errors_in_getitem():
748748

749749
__test__ = {'doctests' : doctests}
750750

751+
752+
class TestGeneratorExpressionDelegation(unittest.TestCase):
753+
# Unpacking a sub-iterable with ``*`` in a generator expression delegates
754+
# to the sub-iterable using ``yield from`` semantics, so that values sent
755+
# to (and exceptions thrown into) the generator are forwarded.
756+
757+
def test_flatten(self):
758+
lists = [[1, 2], [3], [], [4, 5]]
759+
self.assertEqual(list((*sub for sub in lists)), [1, 2, 3, 4, 5])
760+
761+
def test_yields_from_multiple_iterables(self):
762+
gen = (*(0, 1) for i in range(3))
763+
self.assertEqual(list(gen), [0, 1, 0, 1, 0, 1])
764+
765+
def test_send_is_forwarded(self):
766+
received = []
767+
768+
def sub():
769+
while True:
770+
received.append((yield 'value'))
771+
772+
gen = (*sub() for _ in range(1))
773+
self.assertEqual(next(gen), 'value')
774+
self.assertEqual(gen.send(42), 'value')
775+
self.assertEqual(gen.send(7), 'value')
776+
self.assertEqual(received, [42, 7])
777+
778+
def test_throw_is_forwarded(self):
779+
caught = []
780+
781+
def sub():
782+
try:
783+
yield 1
784+
yield 2
785+
except ValueError as exc:
786+
caught.append(str(exc))
787+
yield 'after-catch'
788+
789+
gen = (*sub() for _ in range(1))
790+
self.assertEqual(next(gen), 1)
791+
self.assertEqual(gen.throw(ValueError('boom')), 'after-catch')
792+
self.assertEqual(caught, ['boom'])
793+
794+
def test_close_is_forwarded(self):
795+
closed = []
796+
797+
def sub():
798+
try:
799+
yield 1
800+
yield 2
801+
except GeneratorExit:
802+
closed.append(True)
803+
raise
804+
805+
gen = (*sub() for _ in range(1))
806+
self.assertEqual(next(gen), 1)
807+
gen.close()
808+
self.assertEqual(closed, [True])
809+
810+
def test_subiterator_return_value_is_discarded(self):
811+
def sub(n):
812+
yield n
813+
return 'ignored'
814+
815+
self.assertEqual(list((*sub(i) for i in range(3))), [0, 1, 2])
816+
817+
818+
class TestAsyncGeneratorExpressionDelegation(unittest.TestCase):
819+
# Unpacking a (synchronous) sub-iterable with ``*`` in an asynchronous
820+
# generator expression also delegates with ``yield from`` semantics:
821+
# asend() forwards to the sub-iterator's send(), athrow() to throw() and
822+
# aclose() to close().
823+
#
824+
# These are low-level language tests, so (like test_asyncgen) the async
825+
# generators are driven by hand rather than through an event loop.
826+
827+
@staticmethod
828+
async def _aiter(seq):
829+
for item in seq:
830+
yield item
831+
832+
@staticmethod
833+
def _run(coro):
834+
# Drive a coroutine that is not expected to await anything real, and
835+
# return its result. Any StopAsyncIteration (e.g. an exhausted
836+
# asend()) is allowed to propagate.
837+
try:
838+
coro.send(None)
839+
except StopIteration as exc:
840+
return exc.value
841+
coro.close()
842+
raise AssertionError("coroutine awaited unexpectedly")
843+
844+
def _collect(self, agen):
845+
result = []
846+
while True:
847+
try:
848+
result.append(self._run(agen.asend(None)))
849+
except StopAsyncIteration:
850+
break
851+
return result
852+
853+
def test_flatten(self):
854+
lists = [[1, 2], [3], [], [4, 5]]
855+
agen = (*sub async for sub in self._aiter(lists))
856+
self.assertEqual(self._collect(agen), [1, 2, 3, 4, 5])
857+
858+
def test_asend_forwards_to_send(self):
859+
received = []
860+
861+
def sub():
862+
while True:
863+
received.append((yield 'value'))
864+
865+
agen = (*sub() async for _ in self._aiter([0]))
866+
self.assertEqual(self._run(agen.asend(None)), 'value')
867+
self.assertEqual(self._run(agen.asend(99)), 'value')
868+
self.assertEqual(self._run(agen.asend(123)), 'value')
869+
self.assertEqual(received, [99, 123])
870+
871+
def test_athrow_forwards_to_throw(self):
872+
caught = []
873+
874+
def sub():
875+
try:
876+
yield 1
877+
yield 2
878+
except ValueError as exc:
879+
caught.append(str(exc))
880+
yield 'after-catch'
881+
882+
agen = (*sub() async for _ in self._aiter([0]))
883+
self.assertEqual(self._run(agen.asend(None)), 1)
884+
self.assertEqual(self._run(agen.athrow(ValueError('boom'))),
885+
'after-catch')
886+
self.assertEqual(caught, ['boom'])
887+
888+
def test_aclose_forwards_to_close(self):
889+
closed = []
890+
891+
def sub():
892+
try:
893+
yield 1
894+
yield 2
895+
except GeneratorExit:
896+
closed.append(True)
897+
raise
898+
899+
agen = (*sub() async for _ in self._aiter([0]))
900+
self.assertEqual(self._run(agen.asend(None)), 1)
901+
self._run(agen.aclose())
902+
self.assertEqual(closed, [True])
903+
904+
def test_unpacking_async_iterable_is_a_type_error(self):
905+
# ``*`` unpacking is synchronous; async iterables cannot be unpacked.
906+
async def agen_fn():
907+
yield 1
908+
909+
agen = (*agen_fn() async for _ in self._aiter([0]))
910+
with self.assertRaises(TypeError):
911+
self._run(agen.asend(None))
912+
913+
751914
def load_tests(loader, tests, pattern):
752915
tests.addTest(doctest.DocTestSuite())
753916
return tests
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Unpacking a sub-iterable with ``*`` in a generator expression (:pep:`798`)
2+
now delegates to the sub-iterable using :keyword:`yield from <yield>`
3+
semantics, so that values sent with :meth:`~generator.send` and exceptions
4+
thrown with :meth:`~generator.throw` are forwarded to the sub-iterator. This
5+
also works in asynchronous generator expressions, where
6+
:meth:`~agen.asend`, :meth:`~agen.athrow` and :meth:`~agen.aclose`
7+
are forwarded to the (synchronous) sub-iterator's
8+
:meth:`~generator.send`, :meth:`~generator.throw` and
9+
:meth:`~generator.close`.

0 commit comments

Comments
 (0)