Skip to content

Commit 7f1d1cb

Browse files
committed
bpo-29302: Implement contextlib.AsyncExitStack.
1 parent a4afcdf commit 7f1d1cb

File tree

6 files changed

+451
-81
lines changed

6 files changed

+451
-81
lines changed

Doc/library/contextlib.rst

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,44 @@ Functions and classes provided:
435435
callbacks registered, the arguments passed in will indicate that no
436436
exception occurred.
437437

438+
.. class:: AsyncExitStack()
439+
440+
An :ref:`asynchronous context manager <async-context-managers>`, similar
441+
to :class:`ExitStack`, that supports combining both synchronous and
442+
asynchronous context managers, as well as having coroutines for
443+
cleanup logic.
444+
445+
The :meth:`close` method is not implemented, :meth:`aclose` must be used
446+
instead.
447+
448+
.. method:: enter_async_context(cm)
449+
450+
Similar to :meth:`enter_context` but expects an asynchronous context
451+
manager.
452+
453+
.. method:: push_async_exit(exit)
454+
455+
Similar to :meth:`push` but expects either an asynchronous context manager
456+
or a coroutine.
457+
458+
.. method:: push_async_callback(callback, *args, **kwds)
459+
460+
Similar to :meth:`callback` but expects a coroutine.
461+
462+
.. method:: aclose()
463+
464+
Similar to :meth:`close` but properly handles awaitables.
465+
466+
Continuing the example for :func:`asynccontextmanager`::
467+
468+
async with AsyncExitStack() as stack:
469+
connections = [await stack.enter_async_context(get_connection())
470+
for i in range(5)]
471+
# All opened connections will automatically be released at the end of
472+
# the async with statement, even if attempts to open a connection
473+
# later in the list raise an exception.
474+
475+
.. versionadded:: 3.7
438476

439477
Examples and Recipes
440478
--------------------

Doc/whatsnew/3.7.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,9 @@ contextlib
375375
:class:`~contextlib.AbstractAsyncContextManager` have been added. (Contributed
376376
by Jelle Zijlstra in :issue:`29679` and :issue:`30241`.)
377377

378+
:class:`contextlib.AsyncExitStack` has been added. (Contributed by
379+
Alexander Mohr and Ilya Kulakov in :issue:`29302`.)
380+
378381
cProfile
379382
--------
380383

Lib/contextlib.py

Lines changed: 205 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
__all__ = ["asynccontextmanager", "contextmanager", "closing", "nullcontext",
99
"AbstractContextManager", "AbstractAsyncContextManager",
10-
"ContextDecorator", "ExitStack",
10+
"AsyncExitStack", "ContextDecorator", "ExitStack",
1111
"redirect_stdout", "redirect_stderr", "suppress"]
1212

1313

@@ -365,85 +365,102 @@ def __exit__(self, exctype, excinst, exctb):
365365
return exctype is not None and issubclass(exctype, self._exceptions)
366366

367367

368-
# Inspired by discussions on http://bugs.python.org/issue13585
369-
class ExitStack(AbstractContextManager):
370-
"""Context manager for dynamic management of a stack of exit callbacks
368+
class _BaseExitStack:
369+
"""A base class for ExitStack and AsyncExitStack."""
371370

372-
For example:
371+
@staticmethod
372+
def _create_exit_wrapper(cm, cm_exit):
373+
def _exit_wrapper(exc_type, exc, tb):
374+
return cm_exit(cm, exc_type, exc, tb)
375+
return _exit_wrapper
373376

374-
with ExitStack() as stack:
375-
files = [stack.enter_context(open(fname)) for fname in filenames]
376-
# All opened files will automatically be closed at the end of
377-
# the with statement, even if attempts to open files later
378-
# in the list raise an exception
377+
@staticmethod
378+
def _create_cb_wrapper(callback, *args, **kwds):
379+
def _exit_wrapper(exc_type, exc, tb):
380+
callback(*args, **kwds)
381+
return _exit_wrapper
379382

380-
"""
381383
def __init__(self):
382384
self._exit_callbacks = deque()
383385

384386
def pop_all(self):
385-
"""Preserve the context stack by transferring it to a new instance"""
387+
"""Preserve the context stack by transferring it to a new instance."""
386388
new_stack = type(self)()
387389
new_stack._exit_callbacks = self._exit_callbacks
388390
self._exit_callbacks = deque()
389391
return new_stack
390392

391-
def _push_cm_exit(self, cm, cm_exit):
392-
"""Helper to correctly register callbacks to __exit__ methods"""
393-
def _exit_wrapper(*exc_details):
394-
return cm_exit(cm, *exc_details)
395-
_exit_wrapper.__self__ = cm
396-
self.push(_exit_wrapper)
397-
398393
def push(self, exit):
399-
"""Registers a callback with the standard __exit__ method signature
400-
401-
Can suppress exceptions the same way __exit__ methods can.
394+
"""Registers a callback with the standard __exit__ method signature.
402395
396+
Can suppress exceptions the same way __exit__ method can.
403397
Also accepts any object with an __exit__ method (registering a call
404-
to the method instead of the object itself)
398+
to the method instead of the object itself).
405399
"""
406400
# We use an unbound method rather than a bound method to follow
407-
# the standard lookup behaviour for special methods
401+
# the standard lookup behaviour for special methods.
408402
_cb_type = type(exit)
403+
409404
try:
410405
exit_method = _cb_type.__exit__
411406
except AttributeError:
412-
# Not a context manager, so assume its a callable
413-
self._exit_callbacks.append(exit)
407+
# Not a context manager, so assume it's a callable.
408+
self._push_exit_callback(exit)
414409
else:
415410
self._push_cm_exit(exit, exit_method)
416-
return exit # Allow use as a decorator
417-
418-
def callback(self, callback, *args, **kwds):
419-
"""Registers an arbitrary callback and arguments.
420-
421-
Cannot suppress exceptions.
422-
"""
423-
def _exit_wrapper(exc_type, exc, tb):
424-
callback(*args, **kwds)
425-
# We changed the signature, so using @wraps is not appropriate, but
426-
# setting __wrapped__ may still help with introspection
427-
_exit_wrapper.__wrapped__ = callback
428-
self.push(_exit_wrapper)
429-
return callback # Allow use as a decorator
411+
return exit # Allow use as a decorator.
430412

431413
def enter_context(self, cm):
432-
"""Enters the supplied context manager
414+
"""Enters the supplied context manager.
433415
434416
If successful, also pushes its __exit__ method as a callback and
435417
returns the result of the __enter__ method.
436418
"""
437-
# We look up the special methods on the type to match the with statement
419+
# We look up the special methods on the type to match the with
420+
# statement.
438421
_cm_type = type(cm)
439422
_exit = _cm_type.__exit__
440423
result = _cm_type.__enter__(cm)
441424
self._push_cm_exit(cm, _exit)
442425
return result
443426

444-
def close(self):
445-
"""Immediately unwind the context stack"""
446-
self.__exit__(None, None, None)
427+
def callback(self, callback, *args, **kwds):
428+
"""Registers an arbitrary callback and arguments.
429+
430+
Cannot suppress exceptions.
431+
"""
432+
_exit_wrapper = self._create_cb_wrapper(callback, *args, **kwds)
433+
434+
# We changed the signature, so using @wraps is not appropriate, but
435+
# setting __wrapped__ may still help with introspection.
436+
_exit_wrapper.__wrapped__ = callback
437+
self._push_exit_callback(_exit_wrapper)
438+
return callback # Allow use as a decorator
439+
440+
def _push_cm_exit(self, cm, cm_exit):
441+
"""Helper to correctly register callbacks to __exit__ methods."""
442+
_exit_wrapper = self._create_exit_wrapper(cm, cm_exit)
443+
_exit_wrapper.__self__ = cm
444+
self._push_exit_callback(_exit_wrapper, True)
445+
446+
def _push_exit_callback(self, callback, is_sync=True):
447+
self._exit_callbacks.append((is_sync, callback))
448+
449+
450+
# Inspired by discussions on http://bugs.python.org/issue13585
451+
class ExitStack(_BaseExitStack, AbstractContextManager):
452+
"""Context manager for dynamic management of a stack of exit callbacks.
453+
454+
For example:
455+
with ExitStack() as stack:
456+
files = [stack.enter_context(open(fname)) for fname in filenames]
457+
# All opened files will automatically be closed at the end of
458+
# the with statement, even if attempts to open files later
459+
# in the list raise an exception.
460+
"""
461+
462+
def __enter__(self):
463+
return self
447464

448465
def __exit__(self, *exc_details):
449466
received_exc = exc_details[0] is not None
@@ -470,7 +487,8 @@ def _fix_exception_context(new_exc, old_exc):
470487
suppressed_exc = False
471488
pending_raise = False
472489
while self._exit_callbacks:
473-
cb = self._exit_callbacks.pop()
490+
is_sync, cb = self._exit_callbacks.pop()
491+
assert is_sync
474492
try:
475493
if cb(*exc_details):
476494
suppressed_exc = True
@@ -493,6 +511,147 @@ def _fix_exception_context(new_exc, old_exc):
493511
raise
494512
return received_exc and suppressed_exc
495513

514+
def close(self):
515+
"""Immediately unwind the context stack."""
516+
self.__exit__(None, None, None)
517+
518+
519+
# Inspired by discussions on https://bugs.python.org/issue29302
520+
class AsyncExitStack(_BaseExitStack, AbstractAsyncContextManager):
521+
"""Async context manager for dynamic management of a stack of exit
522+
callbacks.
523+
524+
For example:
525+
async with AsyncExitStack() as stack:
526+
connections = [await stack.enter_async_context(get_connection())
527+
for i in range(5)]
528+
# All opened connections will automatically be released at the
529+
# end of the async with statement, even if attempts to open a
530+
# connection later in the list raise an exception.
531+
"""
532+
533+
@staticmethod
534+
def _create_async_exit_wrapper(cm, cm_exit):
535+
async def _exit_wrapper(exc_type, exc, tb):
536+
return await cm_exit(cm, exc_type, exc, tb)
537+
return _exit_wrapper
538+
539+
@staticmethod
540+
def _create_async_cb_wrapper(callback, *args, **kwds):
541+
async def _exit_wrapper(exc_type, exc, tb):
542+
await callback(*args, **kwds)
543+
return _exit_wrapper
544+
545+
async def enter_async_context(self, cm):
546+
"""Enters the supplied async context manager.
547+
548+
If successful, also pushes its __aexit__ method as a callback and
549+
returns the result of the __aenter__ method.
550+
"""
551+
_cm_type = type(cm)
552+
_exit = _cm_type.__aexit__
553+
result = await _cm_type.__aenter__(cm)
554+
self._push_async_cm_exit(cm, _exit)
555+
return result
556+
557+
def push_async_exit(self, exit):
558+
"""Registers a coroutine function with the standard __aexit__ method
559+
signature.
560+
561+
Can suppress exceptions the same way __aexit__ method can.
562+
Also accepts any object with an __aexit__ method (registering a call
563+
to the method instead of the object itself).
564+
"""
565+
_cb_type = type(exit)
566+
try:
567+
exit_method = _cb_type.__aexit__
568+
except AttributeError:
569+
# Not an async context manager, so assume it's a coroutine function
570+
self._push_exit_callback(exit, False)
571+
else:
572+
self._push_async_cm_exit(exit, exit_method)
573+
return exit # Allow use as a decorator
574+
575+
def push_async_callback(self, callback, *args, **kwds):
576+
"""Registers an arbitrary coroutine function and arguments.
577+
578+
Cannot suppress exceptions.
579+
"""
580+
_exit_wrapper = self._create_async_cb_wrapper(callback, *args, **kwds)
581+
582+
# We changed the signature, so using @wraps is not appropriate, but
583+
# setting __wrapped__ may still help with introspection.
584+
_exit_wrapper.__wrapped__ = callback
585+
self._push_exit_callback(_exit_wrapper, False)
586+
return callback # Allow use as a decorator
587+
588+
async def aclose(self):
589+
"""Immediately unwind the context stack."""
590+
await self.__aexit__(None, None, None)
591+
592+
def _push_async_cm_exit(self, cm, cm_exit):
593+
"""Helper to correctly register coroutine function to __aexit__
594+
method."""
595+
_exit_wrapper = self._create_async_exit_wrapper(cm, cm_exit)
596+
_exit_wrapper.__self__ = cm
597+
self._push_exit_callback(_exit_wrapper, False)
598+
599+
async def __aenter__(self):
600+
return self
601+
602+
async def __aexit__(self, *exc_details):
603+
received_exc = exc_details[0] is not None
604+
605+
# We manipulate the exception state so it behaves as though
606+
# we were actually nesting multiple with statements
607+
frame_exc = sys.exc_info()[1]
608+
def _fix_exception_context(new_exc, old_exc):
609+
# Context may not be correct, so find the end of the chain
610+
while 1:
611+
exc_context = new_exc.__context__
612+
if exc_context is old_exc:
613+
# Context is already set correctly (see issue 20317)
614+
return
615+
if exc_context is None or exc_context is frame_exc:
616+
break
617+
new_exc = exc_context
618+
# Change the end of the chain to point to the exception
619+
# we expect it to reference
620+
new_exc.__context__ = old_exc
621+
622+
# Callbacks are invoked in LIFO order to match the behaviour of
623+
# nested context managers
624+
suppressed_exc = False
625+
pending_raise = False
626+
while self._exit_callbacks:
627+
is_sync, cb = self._exit_callbacks.pop()
628+
try:
629+
if is_sync:
630+
cb_suppress = cb(*exc_details)
631+
else:
632+
cb_suppress = await cb(*exc_details)
633+
634+
if cb_suppress:
635+
suppressed_exc = True
636+
pending_raise = False
637+
exc_details = (None, None, None)
638+
except:
639+
new_exc_details = sys.exc_info()
640+
# simulate the stack of exceptions by setting the context
641+
_fix_exception_context(new_exc_details[1], exc_details[1])
642+
pending_raise = True
643+
exc_details = new_exc_details
644+
if pending_raise:
645+
try:
646+
# bare "raise exc_details[1]" replaces our carefully
647+
# set-up context
648+
fixed_ctx = exc_details[1].__context__
649+
raise exc_details[1]
650+
except BaseException:
651+
exc_details[1].__context__ = fixed_ctx
652+
raise
653+
return received_exc and suppressed_exc
654+
496655

497656
class nullcontext(AbstractContextManager):
498657
"""Context manager that does no additional processing.

0 commit comments

Comments
 (0)