Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
bd00fcb
gh-125862: Improve context decorator support for generators and async…
agronholm Jul 2, 2025
1a0c100
Merge branch 'main' into fix-issue-125862
agronholm Jul 2, 2025
9b3ba13
📜🤖 Added by blurb_it.
blurb-it[bot] Jul 2, 2025
8535a21
Manually iterate coroutines to avoid asyncio use
agronholm Jul 2, 2025
6a68ffb
Merge branch 'main' into fix-issue-125862
agronholm Jul 2, 2025
bbaee0c
Merge branch 'main' into fix-issue-125862
agronholm Dec 8, 2025
93b30f4
Merge branch 'main' into fix-issue-125862
agronholm Dec 9, 2025
1fd52d5
Make sure we at least try to close the generators
agronholm Dec 9, 2025
e32191e
Merge remote-tracking branch 'fork/fix-issue-125862' into fix-issue-1…
agronholm Dec 9, 2025
65c8f50
Use (a)closing
agronholm Dec 9, 2025
8762048
Merge branch 'main' into fix-issue-125862
agronholm Dec 15, 2025
6fb5bcb
Merge remote-tracking branch 'origin/main' into fix-issue-125862
gpshead Apr 27, 2026
1433534
Use _private imports
gpshead Apr 27, 2026
a8ee60d
performance: only define inner funcs on the branch that uses them, la…
gpshead Apr 27, 2026
46ffa37
do not delete that newline (small diff)
gpshead Apr 27, 2026
ed6d9fa
reword reST news entry
gpshead Apr 27, 2026
876704a
Preserve generator return value; expand decorator test coverage
gpshead Apr 27, 2026
3b30193
Document ContextDecorator generator/coroutine handling
gpshead Apr 27, 2026
72d4a8e
Add What's New entry for ContextDecorator generator support
gpshead Apr 27, 2026
719c55c
simplify lazy import syntax
gpshead Apr 27, 2026
59422a3
Reword AsyncContextDecorator docs to lead with intended async use
gpshead Apr 28, 2026
7026b14
Reword test comments to state the contract, not the mechanism
gpshead Apr 28, 2026
c6eda4a
Test send/throw forwarding for the sync wrapper; pin async limitation
gpshead Apr 28, 2026
a3cfb1a
also add my name in whatsnew
gpshead Apr 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion Doc/library/contextlib.rst
Original file line number Diff line number Diff line change
Expand Up @@ -467,12 +467,40 @@ Functions and classes provided:
statements. If this is not the case, then the original construct with the
explicit :keyword:`!with` statement inside the function should be used.

When the decorated callable is a generator function, coroutine function, or
asynchronous generator function, the returned wrapper is of the same kind
and keeps the context manager open for the lifetime of the iteration or
await rather than only for the call that creates the generator or coroutine
object. Wrapped generators and asynchronous generators are explicitly
closed when iteration ends, as if by :func:`closing` or :func:`aclosing`.

.. note::
For asynchronous generators the wrapper re-yields each value with
``async for``; values sent with :meth:`~agen.asend` and exceptions
thrown with :meth:`~agen.athrow` are not forwarded to the wrapped
generator.

.. versionadded:: 3.2

.. versionchanged:: next
Decorating a generator function, coroutine function, or asynchronous
generator function now keeps the context manager open across iteration
or await. Previously the context manager exited as soon as the
generator or coroutine object was created.


.. class:: AsyncContextDecorator

Similar to :class:`ContextDecorator` but only for asynchronous functions.
Similar to :class:`ContextDecorator`, but the context manager is entered
and exited with :keyword:`async with`. Decorate coroutine functions and
asynchronous generator functions with this class; the returned wrapper is
of the same kind.

.. note::
Synchronous functions and generators are accepted, but the wrapper is
always asynchronous, so the decorated callable must then be awaited or
iterated with ``async for``. If that change of calling convention is
not intended, use :class:`ContextDecorator` instead.

Example of ``AsyncContextDecorator``::

Expand Down Expand Up @@ -510,6 +538,13 @@ Functions and classes provided:

.. versionadded:: 3.10

.. versionchanged:: next
Decorating an asynchronous generator function now keeps the context
manager open across iteration. Previously the context manager exited
as soon as the generator object was created. Synchronous functions
and synchronous generator functions are also now accepted, with an
asynchronous wrapper returned.


.. class:: ExitStack()

Expand Down
9 changes: 9 additions & 0 deletions Doc/whatsnew/3.15.rst
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,15 @@ contextlib
consistency with the :keyword:`with` and :keyword:`async with` statements.
(Contributed by Serhiy Storchaka in :gh:`144386`.)

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


dataclasses
-----------
Expand Down
82 changes: 72 additions & 10 deletions Lib/contextlib.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
"""Utilities for with-statement contexts. See PEP 343."""

import abc
import os
import sys
import _collections_abc
from collections import deque
from functools import wraps
lazy from inspect import (
isasyncgenfunction as _isasyncgenfunction,
iscoroutinefunction as _iscoroutinefunction,
isgeneratorfunction as _isgeneratorfunction,
)
from types import GenericAlias

__all__ = ["asynccontextmanager", "contextmanager", "closing", "nullcontext",
Expand Down Expand Up @@ -79,11 +85,37 @@ def _recreate_cm(self):
return self

def __call__(self, func):
@wraps(func)
def inner(*args, **kwds):
with self._recreate_cm():
return func(*args, **kwds)
return inner
wrapper = wraps(func)
if _isasyncgenfunction(func):

async def asyncgen_inner(*args, **kwds):
with self._recreate_cm():
async with aclosing(func(*args, **kwds)) as gen:
async for value in gen:
yield value

return wrapper(asyncgen_inner)
elif _iscoroutinefunction(func):

async def async_inner(*args, **kwds):
with self._recreate_cm():
return await func(*args, **kwds)

return wrapper(async_inner)
elif _isgeneratorfunction(func):

def gen_inner(*args, **kwds):
with self._recreate_cm(), closing(func(*args, **kwds)) as gen:
return (yield from gen)

return wrapper(gen_inner)
else:

def inner(*args, **kwds):
with self._recreate_cm():
return func(*args, **kwds)

return wrapper(inner)


class AsyncContextDecorator(object):
Expand All @@ -95,11 +127,41 @@ def _recreate_cm(self):
return self

def __call__(self, func):
@wraps(func)
async def inner(*args, **kwds):
async with self._recreate_cm():
return await func(*args, **kwds)
return inner
wrapper = wraps(func)
if _isasyncgenfunction(func):

async def asyncgen_inner(*args, **kwds):
async with (
self._recreate_cm(),
aclosing(func(*args, **kwds)) as gen
):
async for value in gen:
yield value

return wrapper(asyncgen_inner)
elif _iscoroutinefunction(func):

async def async_inner(*args, **kwds):
async with self._recreate_cm():
return await func(*args, **kwds)

return wrapper(async_inner)
elif _isgeneratorfunction(func):

async def gen_inner(*args, **kwds):
async with self._recreate_cm():
with closing(func(*args, **kwds)) as gen:
for value in gen:
yield value

return wrapper(gen_inner)
else:

async def inner(*args, **kwds):
async with self._recreate_cm():
return func(*args, **kwds)

return wrapper(inner)


class _GeneratorContextManagerBase:
Expand Down
148 changes: 148 additions & 0 deletions Lib/test/test_contextlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,154 @@ def test(x):
self.assertEqual(state, [1, 'something else', 999])


def test_contextmanager_decorate_generator_function(self):
@contextmanager
def woohoo(y):
state.append(y)
yield
state.append(999)

state = []
@woohoo(1)
def test(x):
self.assertEqual(state, [1])
state.append(x)
yield
state.append("second item")
return "result"

gen = test("something")
for _ in gen:
self.assertEqual(state, [1, "something"])
self.assertEqual(state, [1, "something", "second item", 999])

# The wrapped generator's return value is preserved.
state = []
gen = test("something")
with self.assertRaises(StopIteration) as cm:
while True:
next(gen)
self.assertEqual(cm.exception.value, "result")


def test_contextmanager_decorate_generator_function_exception(self):
@contextmanager
def woohoo():
state.append("enter")
try:
yield
finally:
state.append("exit")

state = []
@woohoo()
def test():
state.append("body")
yield
raise ZeroDivisionError

with self.assertRaises(ZeroDivisionError):
for _ in test():
pass
self.assertEqual(state, ["enter", "body", "exit"])


def test_contextmanager_decorate_generator_function_early_stop(self):
@contextmanager
def woohoo():
state.append("enter")
try:
yield
finally:
state.append("exit")

state = []
@woohoo()
def test():
try:
yield 1
yield 2
finally:
state.append("inner closed")

gen = test()
self.assertEqual(next(gen), 1)
gen.close()
# The inner generator is closed before the context manager exits.
self.assertEqual(state, ["enter", "inner closed", "exit"])


def test_contextmanager_decorate_generator_function_send_throw(self):
@contextmanager
def woohoo():
yield

@woohoo()
def test():
received = yield "first"
state.append(("received", received))
try:
yield "second"
except ValueError as exc:
state.append(("caught", type(exc)))
yield "after throw"

# .send() and .throw() are forwarded to the wrapped generator.
state = []
gen = test()
self.assertEqual(next(gen), "first")
self.assertEqual(gen.send("VALUE"), "second")
self.assertEqual(gen.throw(ValueError), "after throw")
gen.close()
self.assertEqual(
state, [("received", "VALUE"), ("caught", ValueError)]
)


def test_contextmanager_decorate_coroutine_function(self):
@contextmanager
def woohoo(y):
state.append(y)
yield
state.append(999)

state = []
@woohoo(1)
async def test(x):
self.assertEqual(state, [1])
state.append(x)

coro = test("something")
with self.assertRaises(StopIteration):
coro.send(None)

self.assertEqual(state, [1, "something", 999])


def test_contextmanager_decorate_asyncgen_function(self):
@contextmanager
def woohoo(y):
state.append(y)
yield
state.append(999)

state = []
@woohoo(1)
async def test(x):
self.assertEqual(state, [1])
state.append(x)
yield
state.append("second item")

agen = test("something")
with self.assertRaises(StopIteration):
agen.asend(None).send(None)
with self.assertRaises(StopAsyncIteration):
agen.asend(None).send(None)

self.assertEqual(state, [1, "something", "second item", 999])


class TestBaseExitStack:
exit_stack = None

Expand Down
Loading
Loading