Skip to content

Commit 2925139

Browse files
agronholmgpshead
andauthored
gh-125862: Keep ContextDecorator open across generator/coroutine execution (GH-136212)
ContextDecorator and AsyncContextDecorator (and therefore @contextmanager and @asynccontextmanager used as decorators) now detect generator, coroutine, and asynchronous generator functions and emit a wrapper of the matching kind, so the context manager spans iteration or await rather than just the call that constructs the lazy object. Wrapped generators are explicitly closed when iteration ends. For asynchronous generator wrappers, values passed via asend() and exceptions via athrow() are not forwarded to the wrapped generator. AsyncContextDecorator now also accepts synchronous functions and generators, returning an asynchronous wrapper; ContextDecorator remains the recommended choice for those. inspect.isgeneratorfunction(), iscoroutinefunction(), and isasyncgenfunction() now return True for the decorated result when the input is of that kind. --------- Co-authored-by: Gregory P. Smith <greg@krypto.org>
1 parent c8799f1 commit 2925139

6 files changed

Lines changed: 407 additions & 11 deletions

File tree

Doc/library/contextlib.rst

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -467,12 +467,40 @@ Functions and classes provided:
467467
statements. If this is not the case, then the original construct with the
468468
explicit :keyword:`!with` statement inside the function should be used.
469469

470+
When the decorated callable is a generator function, coroutine function, or
471+
asynchronous generator function, the returned wrapper is of the same kind
472+
and keeps the context manager open for the lifetime of the iteration or
473+
await rather than only for the call that creates the generator or coroutine
474+
object. Wrapped generators and asynchronous generators are explicitly
475+
closed when iteration ends, as if by :func:`closing` or :func:`aclosing`.
476+
477+
.. note::
478+
For asynchronous generators the wrapper re-yields each value with
479+
``async for``; values sent with :meth:`~agen.asend` and exceptions
480+
thrown with :meth:`~agen.athrow` are not forwarded to the wrapped
481+
generator.
482+
470483
.. versionadded:: 3.2
471484

485+
.. versionchanged:: next
486+
Decorating a generator function, coroutine function, or asynchronous
487+
generator function now keeps the context manager open across iteration
488+
or await. Previously the context manager exited as soon as the
489+
generator or coroutine object was created.
490+
472491

473492
.. class:: AsyncContextDecorator
474493

475-
Similar to :class:`ContextDecorator` but only for asynchronous functions.
494+
Similar to :class:`ContextDecorator`, but the context manager is entered
495+
and exited with :keyword:`async with`. Decorate coroutine functions and
496+
asynchronous generator functions with this class; the returned wrapper is
497+
of the same kind.
498+
499+
.. note::
500+
Synchronous functions and generators are accepted, but the wrapper is
501+
always asynchronous, so the decorated callable must then be awaited or
502+
iterated with ``async for``. If that change of calling convention is
503+
not intended, use :class:`ContextDecorator` instead.
476504

477505
Example of ``AsyncContextDecorator``::
478506

@@ -510,6 +538,13 @@ Functions and classes provided:
510538

511539
.. versionadded:: 3.10
512540

541+
.. versionchanged:: next
542+
Decorating an asynchronous generator function now keeps the context
543+
manager open across iteration. Previously the context manager exited
544+
as soon as the generator object was created. Synchronous functions
545+
and synchronous generator functions are also now accepted, with an
546+
asynchronous wrapper returned.
547+
513548

514549
.. class:: ExitStack()
515550

Doc/whatsnew/3.15.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -846,6 +846,15 @@ contextlib
846846
consistency with the :keyword:`with` and :keyword:`async with` statements.
847847
(Contributed by Serhiy Storchaka in :gh:`144386`.)
848848

849+
* :class:`~contextlib.ContextDecorator` and
850+
:class:`~contextlib.AsyncContextDecorator` (and therefore
851+
:func:`~contextlib.contextmanager` and :func:`~contextlib.asynccontextmanager`
852+
used as decorators) now detect generator functions, coroutine functions, and
853+
asynchronous generator functions and keep the context manager open across
854+
iteration or await. Previously the context manager exited as soon as the
855+
generator or coroutine object was created.
856+
(Contributed by Alex Grönholm & Gregory P. Smith in :gh:`125862`.)
857+
849858

850859
dataclasses
851860
-----------

Lib/contextlib.py

Lines changed: 72 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
"""Utilities for with-statement contexts. See PEP 343."""
2+
23
import abc
34
import os
45
import sys
56
import _collections_abc
67
from collections import deque
78
from functools import wraps
9+
lazy from inspect import (
10+
isasyncgenfunction as _isasyncgenfunction,
11+
iscoroutinefunction as _iscoroutinefunction,
12+
isgeneratorfunction as _isgeneratorfunction,
13+
)
814
from types import GenericAlias
915

1016
__all__ = ["asynccontextmanager", "contextmanager", "closing", "nullcontext",
@@ -79,11 +85,37 @@ def _recreate_cm(self):
7985
return self
8086

8187
def __call__(self, func):
82-
@wraps(func)
83-
def inner(*args, **kwds):
84-
with self._recreate_cm():
85-
return func(*args, **kwds)
86-
return inner
88+
wrapper = wraps(func)
89+
if _isasyncgenfunction(func):
90+
91+
async def asyncgen_inner(*args, **kwds):
92+
with self._recreate_cm():
93+
async with aclosing(func(*args, **kwds)) as gen:
94+
async for value in gen:
95+
yield value
96+
97+
return wrapper(asyncgen_inner)
98+
elif _iscoroutinefunction(func):
99+
100+
async def async_inner(*args, **kwds):
101+
with self._recreate_cm():
102+
return await func(*args, **kwds)
103+
104+
return wrapper(async_inner)
105+
elif _isgeneratorfunction(func):
106+
107+
def gen_inner(*args, **kwds):
108+
with self._recreate_cm(), closing(func(*args, **kwds)) as gen:
109+
return (yield from gen)
110+
111+
return wrapper(gen_inner)
112+
else:
113+
114+
def inner(*args, **kwds):
115+
with self._recreate_cm():
116+
return func(*args, **kwds)
117+
118+
return wrapper(inner)
87119

88120

89121
class AsyncContextDecorator(object):
@@ -95,11 +127,41 @@ def _recreate_cm(self):
95127
return self
96128

97129
def __call__(self, func):
98-
@wraps(func)
99-
async def inner(*args, **kwds):
100-
async with self._recreate_cm():
101-
return await func(*args, **kwds)
102-
return inner
130+
wrapper = wraps(func)
131+
if _isasyncgenfunction(func):
132+
133+
async def asyncgen_inner(*args, **kwds):
134+
async with (
135+
self._recreate_cm(),
136+
aclosing(func(*args, **kwds)) as gen
137+
):
138+
async for value in gen:
139+
yield value
140+
141+
return wrapper(asyncgen_inner)
142+
elif _iscoroutinefunction(func):
143+
144+
async def async_inner(*args, **kwds):
145+
async with self._recreate_cm():
146+
return await func(*args, **kwds)
147+
148+
return wrapper(async_inner)
149+
elif _isgeneratorfunction(func):
150+
151+
async def gen_inner(*args, **kwds):
152+
async with self._recreate_cm():
153+
with closing(func(*args, **kwds)) as gen:
154+
for value in gen:
155+
yield value
156+
157+
return wrapper(gen_inner)
158+
else:
159+
160+
async def inner(*args, **kwds):
161+
async with self._recreate_cm():
162+
return func(*args, **kwds)
163+
164+
return wrapper(inner)
103165

104166

105167
class _GeneratorContextManagerBase:

Lib/test/test_contextlib.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,154 @@ def test(x):
680680
self.assertEqual(state, [1, 'something else', 999])
681681

682682

683+
def test_contextmanager_decorate_generator_function(self):
684+
@contextmanager
685+
def woohoo(y):
686+
state.append(y)
687+
yield
688+
state.append(999)
689+
690+
state = []
691+
@woohoo(1)
692+
def test(x):
693+
self.assertEqual(state, [1])
694+
state.append(x)
695+
yield
696+
state.append("second item")
697+
return "result"
698+
699+
gen = test("something")
700+
for _ in gen:
701+
self.assertEqual(state, [1, "something"])
702+
self.assertEqual(state, [1, "something", "second item", 999])
703+
704+
# The wrapped generator's return value is preserved.
705+
state = []
706+
gen = test("something")
707+
with self.assertRaises(StopIteration) as cm:
708+
while True:
709+
next(gen)
710+
self.assertEqual(cm.exception.value, "result")
711+
712+
713+
def test_contextmanager_decorate_generator_function_exception(self):
714+
@contextmanager
715+
def woohoo():
716+
state.append("enter")
717+
try:
718+
yield
719+
finally:
720+
state.append("exit")
721+
722+
state = []
723+
@woohoo()
724+
def test():
725+
state.append("body")
726+
yield
727+
raise ZeroDivisionError
728+
729+
with self.assertRaises(ZeroDivisionError):
730+
for _ in test():
731+
pass
732+
self.assertEqual(state, ["enter", "body", "exit"])
733+
734+
735+
def test_contextmanager_decorate_generator_function_early_stop(self):
736+
@contextmanager
737+
def woohoo():
738+
state.append("enter")
739+
try:
740+
yield
741+
finally:
742+
state.append("exit")
743+
744+
state = []
745+
@woohoo()
746+
def test():
747+
try:
748+
yield 1
749+
yield 2
750+
finally:
751+
state.append("inner closed")
752+
753+
gen = test()
754+
self.assertEqual(next(gen), 1)
755+
gen.close()
756+
# The inner generator is closed before the context manager exits.
757+
self.assertEqual(state, ["enter", "inner closed", "exit"])
758+
759+
760+
def test_contextmanager_decorate_generator_function_send_throw(self):
761+
@contextmanager
762+
def woohoo():
763+
yield
764+
765+
@woohoo()
766+
def test():
767+
received = yield "first"
768+
state.append(("received", received))
769+
try:
770+
yield "second"
771+
except ValueError as exc:
772+
state.append(("caught", type(exc)))
773+
yield "after throw"
774+
775+
# .send() and .throw() are forwarded to the wrapped generator.
776+
state = []
777+
gen = test()
778+
self.assertEqual(next(gen), "first")
779+
self.assertEqual(gen.send("VALUE"), "second")
780+
self.assertEqual(gen.throw(ValueError), "after throw")
781+
gen.close()
782+
self.assertEqual(
783+
state, [("received", "VALUE"), ("caught", ValueError)]
784+
)
785+
786+
787+
def test_contextmanager_decorate_coroutine_function(self):
788+
@contextmanager
789+
def woohoo(y):
790+
state.append(y)
791+
yield
792+
state.append(999)
793+
794+
state = []
795+
@woohoo(1)
796+
async def test(x):
797+
self.assertEqual(state, [1])
798+
state.append(x)
799+
800+
coro = test("something")
801+
with self.assertRaises(StopIteration):
802+
coro.send(None)
803+
804+
self.assertEqual(state, [1, "something", 999])
805+
806+
807+
def test_contextmanager_decorate_asyncgen_function(self):
808+
@contextmanager
809+
def woohoo(y):
810+
state.append(y)
811+
yield
812+
state.append(999)
813+
814+
state = []
815+
@woohoo(1)
816+
async def test(x):
817+
self.assertEqual(state, [1])
818+
state.append(x)
819+
yield
820+
state.append("second item")
821+
822+
agen = test("something")
823+
with self.assertRaises(StopIteration):
824+
agen.asend(None).send(None)
825+
with self.assertRaises(StopAsyncIteration):
826+
agen.asend(None).send(None)
827+
828+
self.assertEqual(state, [1, "something", "second item", 999])
829+
830+
683831
class TestBaseExitStack:
684832
exit_stack = None
685833

0 commit comments

Comments
 (0)