Skip to content

Commit f73d2e7

Browse files
gh-144386: Add support for descriptors in ExitStack and AsyncExitStack (#144420)
__enter__(), __exit__(), __aenter__(), and __aexit__() can now be arbitrary descriptors, not only normal methods, for consistency with the "with" and "async with" statements.
1 parent 34e5a63 commit f73d2e7

File tree

6 files changed

+209
-58
lines changed

6 files changed

+209
-58
lines changed

Doc/library/contextlib.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,10 @@ Functions and classes provided:
564564
Raises :exc:`TypeError` instead of :exc:`AttributeError` if *cm*
565565
is not a context manager.
566566

567+
.. versionchanged:: next
568+
Added support for arbitrary descriptors :meth:`!__enter__` and
569+
:meth:`!__exit__`.
570+
567571
.. method:: push(exit)
568572

569573
Adds a context manager's :meth:`~object.__exit__` method to the callback stack.
@@ -582,6 +586,9 @@ Functions and classes provided:
582586
The passed in object is returned from the function, allowing this
583587
method to be used as a function decorator.
584588

589+
.. versionchanged:: next
590+
Added support for arbitrary descriptors :meth:`!__exit__`.
591+
585592
.. method:: callback(callback, /, *args, **kwds)
586593

587594
Accepts an arbitrary callback function and arguments and adds it to
@@ -639,11 +646,17 @@ Functions and classes provided:
639646
Raises :exc:`TypeError` instead of :exc:`AttributeError` if *cm*
640647
is not an asynchronous context manager.
641648

649+
.. versionchanged:: next
650+
Added support for arbitrary descriptors :meth:`!__aenter__` and :meth:`!__aexit__`.
651+
642652
.. method:: push_async_exit(exit)
643653

644654
Similar to :meth:`ExitStack.push` but expects either an asynchronous context manager
645655
or a coroutine function.
646656

657+
.. versionchanged:: next
658+
Added support for arbitrary descriptors :meth:`!__aexit__`.
659+
647660
.. method:: push_async_callback(callback, /, *args, **kwds)
648661

649662
Similar to :meth:`ExitStack.callback` but expects a coroutine function.

Doc/whatsnew/3.15.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,16 @@ concurrent.futures
548548
(Contributed by Jonathan Berg in :gh:`139486`.)
549549

550550

551+
contextlib
552+
----------
553+
554+
* Added support for arbitrary descriptors :meth:`!__enter__`,
555+
:meth:`!__exit__`, :meth:`!__aenter__`, and :meth:`!__aexit__` in
556+
:class:`~contextlib.ExitStack` and :class:`contextlib.AsyncExitStack`, for
557+
consistency with the :keyword:`with` and :keyword:`async with` statements.
558+
(Contributed by Serhiy Storchaka in :gh:`144386`.)
559+
560+
551561
dataclasses
552562
-----------
553563

Lib/contextlib.py

Lines changed: 41 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import _collections_abc
66
from collections import deque
77
from functools import wraps
8-
from types import MethodType, GenericAlias
8+
from types import GenericAlias
99

1010
__all__ = ["asynccontextmanager", "contextmanager", "closing", "nullcontext",
1111
"AbstractContextManager", "AbstractAsyncContextManager",
@@ -469,13 +469,23 @@ def __exit__(self, exctype, excinst, exctb):
469469
return False
470470

471471

472+
def _lookup_special(obj, name, default):
473+
# Follow the standard lookup behaviour for special methods.
474+
from inspect import getattr_static, _descriptor_get
475+
cls = type(obj)
476+
try:
477+
descr = getattr_static(cls, name)
478+
except AttributeError:
479+
return default
480+
return _descriptor_get(descr, obj)
481+
482+
483+
_sentinel = ['SENTINEL']
484+
485+
472486
class _BaseExitStack:
473487
"""A base class for ExitStack and AsyncExitStack."""
474488

475-
@staticmethod
476-
def _create_exit_wrapper(cm, cm_exit):
477-
return MethodType(cm_exit, cm)
478-
479489
@staticmethod
480490
def _create_cb_wrapper(callback, /, *args, **kwds):
481491
def _exit_wrapper(exc_type, exc, tb):
@@ -499,17 +509,8 @@ def push(self, exit):
499509
Also accepts any object with an __exit__ method (registering a call
500510
to the method instead of the object itself).
501511
"""
502-
# We use an unbound method rather than a bound method to follow
503-
# the standard lookup behaviour for special methods.
504-
_cb_type = type(exit)
505-
506-
try:
507-
exit_method = _cb_type.__exit__
508-
except AttributeError:
509-
# Not a context manager, so assume it's a callable.
510-
self._push_exit_callback(exit)
511-
else:
512-
self._push_cm_exit(exit, exit_method)
512+
exit_method = _lookup_special(exit, '__exit__', exit)
513+
self._push_exit_callback(exit_method)
513514
return exit # Allow use as a decorator.
514515

515516
def enter_context(self, cm):
@@ -518,17 +519,18 @@ def enter_context(self, cm):
518519
If successful, also pushes its __exit__ method as a callback and
519520
returns the result of the __enter__ method.
520521
"""
521-
# We look up the special methods on the type to match the with
522-
# statement.
523-
cls = type(cm)
524-
try:
525-
_enter = cls.__enter__
526-
_exit = cls.__exit__
527-
except AttributeError:
522+
_enter = _lookup_special(cm, '__enter__', _sentinel)
523+
if _enter is _sentinel:
524+
cls = type(cm)
528525
raise TypeError(f"'{cls.__module__}.{cls.__qualname__}' object does "
529-
f"not support the context manager protocol") from None
530-
result = _enter(cm)
531-
self._push_cm_exit(cm, _exit)
526+
f"not support the context manager protocol")
527+
_exit = _lookup_special(cm, '__exit__', _sentinel)
528+
if _exit is _sentinel:
529+
cls = type(cm)
530+
raise TypeError(f"'{cls.__module__}.{cls.__qualname__}' object does "
531+
f"not support the context manager protocol")
532+
result = _enter()
533+
self._push_exit_callback(_exit)
532534
return result
533535

534536
def callback(self, callback, /, *args, **kwds):
@@ -544,11 +546,6 @@ def callback(self, callback, /, *args, **kwds):
544546
self._push_exit_callback(_exit_wrapper)
545547
return callback # Allow use as a decorator
546548

547-
def _push_cm_exit(self, cm, cm_exit):
548-
"""Helper to correctly register callbacks to __exit__ methods."""
549-
_exit_wrapper = self._create_exit_wrapper(cm, cm_exit)
550-
self._push_exit_callback(_exit_wrapper, True)
551-
552549
def _push_exit_callback(self, callback, is_sync=True):
553550
self._exit_callbacks.append((is_sync, callback))
554551

@@ -641,10 +638,6 @@ class AsyncExitStack(_BaseExitStack, AbstractAsyncContextManager):
641638
# connection later in the list raise an exception.
642639
"""
643640

644-
@staticmethod
645-
def _create_async_exit_wrapper(cm, cm_exit):
646-
return MethodType(cm_exit, cm)
647-
648641
@staticmethod
649642
def _create_async_cb_wrapper(callback, /, *args, **kwds):
650643
async def _exit_wrapper(exc_type, exc, tb):
@@ -657,16 +650,18 @@ async def enter_async_context(self, cm):
657650
If successful, also pushes its __aexit__ method as a callback and
658651
returns the result of the __aenter__ method.
659652
"""
660-
cls = type(cm)
661-
try:
662-
_enter = cls.__aenter__
663-
_exit = cls.__aexit__
664-
except AttributeError:
653+
_enter = _lookup_special(cm, '__aenter__', _sentinel)
654+
if _enter is _sentinel:
655+
cls = type(cm)
665656
raise TypeError(f"'{cls.__module__}.{cls.__qualname__}' object does "
666-
f"not support the asynchronous context manager protocol"
667-
) from None
668-
result = await _enter(cm)
669-
self._push_async_cm_exit(cm, _exit)
657+
f"not support the asynchronous context manager protocol")
658+
_exit = _lookup_special(cm, '__aexit__', _sentinel)
659+
if _exit is _sentinel:
660+
cls = type(cm)
661+
raise TypeError(f"'{cls.__module__}.{cls.__qualname__}' object does "
662+
f"not support the asynchronous context manager protocol")
663+
result = await _enter()
664+
self._push_exit_callback(_exit, False)
670665
return result
671666

672667
def push_async_exit(self, exit):
@@ -677,14 +672,8 @@ def push_async_exit(self, exit):
677672
Also accepts any object with an __aexit__ method (registering a call
678673
to the method instead of the object itself).
679674
"""
680-
_cb_type = type(exit)
681-
try:
682-
exit_method = _cb_type.__aexit__
683-
except AttributeError:
684-
# Not an async context manager, so assume it's a coroutine function
685-
self._push_exit_callback(exit, False)
686-
else:
687-
self._push_async_cm_exit(exit, exit_method)
675+
exit_method = _lookup_special(exit, '__aexit__', exit)
676+
self._push_exit_callback(exit_method, False)
688677
return exit # Allow use as a decorator
689678

690679
def push_async_callback(self, callback, /, *args, **kwds):
@@ -704,12 +693,6 @@ async def aclose(self):
704693
"""Immediately unwind the context stack."""
705694
await self.__aexit__(None, None, None)
706695

707-
def _push_async_cm_exit(self, cm, cm_exit):
708-
"""Helper to correctly register coroutine function to __aexit__
709-
method."""
710-
_exit_wrapper = self._create_async_exit_wrapper(cm, cm_exit)
711-
self._push_exit_callback(_exit_wrapper, False)
712-
713696
async def __aenter__(self):
714697
return self
715698

Lib/test/test_contextlib.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,75 @@ def _exit():
788788
result.append(2)
789789
self.assertEqual(result, [1, 2, 3, 4])
790790

791+
def test_enter_context_classmethod(self):
792+
class TestCM:
793+
@classmethod
794+
def __enter__(cls):
795+
result.append(('enter', cls))
796+
@classmethod
797+
def __exit__(cls, *exc_details):
798+
result.append(('exit', cls, *exc_details))
799+
800+
cm = TestCM()
801+
result = []
802+
with self.exit_stack() as stack:
803+
stack.enter_context(cm)
804+
self.assertEqual(result, [('enter', TestCM)])
805+
self.assertEqual(result, [('enter', TestCM),
806+
('exit', TestCM, None, None, None)])
807+
808+
result = []
809+
with self.exit_stack() as stack:
810+
stack.push(cm)
811+
self.assertEqual(result, [])
812+
self.assertEqual(result, [('exit', TestCM, None, None, None)])
813+
814+
def test_enter_context_staticmethod(self):
815+
class TestCM:
816+
@staticmethod
817+
def __enter__():
818+
result.append('enter')
819+
@staticmethod
820+
def __exit__(*exc_details):
821+
result.append(('exit', *exc_details))
822+
823+
cm = TestCM()
824+
result = []
825+
with self.exit_stack() as stack:
826+
stack.enter_context(cm)
827+
self.assertEqual(result, ['enter'])
828+
self.assertEqual(result, ['enter', ('exit', None, None, None)])
829+
830+
result = []
831+
with self.exit_stack() as stack:
832+
stack.push(cm)
833+
self.assertEqual(result, [])
834+
self.assertEqual(result, [('exit', None, None, None)])
835+
836+
def test_enter_context_slots(self):
837+
class TestCM:
838+
__slots__ = ('__enter__', '__exit__')
839+
def __init__(self):
840+
def enter():
841+
result.append('enter')
842+
def exit(*exc_details):
843+
result.append(('exit', *exc_details))
844+
self.__enter__ = enter
845+
self.__exit__ = exit
846+
847+
cm = TestCM()
848+
result = []
849+
with self.exit_stack() as stack:
850+
stack.enter_context(cm)
851+
self.assertEqual(result, ['enter'])
852+
self.assertEqual(result, ['enter', ('exit', None, None, None)])
853+
854+
result = []
855+
with self.exit_stack() as stack:
856+
stack.push(cm)
857+
self.assertEqual(result, [])
858+
self.assertEqual(result, [('exit', None, None, None)])
859+
791860
def test_enter_context_errors(self):
792861
class LacksEnterAndExit:
793862
pass

Lib/test/test_contextlib_async.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,78 @@ async def _exit():
641641

642642
self.assertEqual(result, [1, 2, 3, 4])
643643

644+
@_async_test
645+
async def test_enter_async_context_classmethod(self):
646+
class TestCM:
647+
@classmethod
648+
async def __aenter__(cls):
649+
result.append(('enter', cls))
650+
@classmethod
651+
async def __aexit__(cls, *exc_details):
652+
result.append(('exit', cls, *exc_details))
653+
654+
cm = TestCM()
655+
result = []
656+
async with self.exit_stack() as stack:
657+
await stack.enter_async_context(cm)
658+
self.assertEqual(result, [('enter', TestCM)])
659+
self.assertEqual(result, [('enter', TestCM),
660+
('exit', TestCM, None, None, None)])
661+
662+
result = []
663+
async with self.exit_stack() as stack:
664+
stack.push_async_exit(cm)
665+
self.assertEqual(result, [])
666+
self.assertEqual(result, [('exit', TestCM, None, None, None)])
667+
668+
@_async_test
669+
async def test_enter_async_context_staticmethod(self):
670+
class TestCM:
671+
@staticmethod
672+
async def __aenter__():
673+
result.append('enter')
674+
@staticmethod
675+
async def __aexit__(*exc_details):
676+
result.append(('exit', *exc_details))
677+
678+
cm = TestCM()
679+
result = []
680+
async with self.exit_stack() as stack:
681+
await stack.enter_async_context(cm)
682+
self.assertEqual(result, ['enter'])
683+
self.assertEqual(result, ['enter', ('exit', None, None, None)])
684+
685+
result = []
686+
async with self.exit_stack() as stack:
687+
stack.push_async_exit(cm)
688+
self.assertEqual(result, [])
689+
self.assertEqual(result, [('exit', None, None, None)])
690+
691+
@_async_test
692+
async def test_enter_async_context_slots(self):
693+
class TestCM:
694+
__slots__ = ('__aenter__', '__aexit__')
695+
def __init__(self):
696+
async def enter():
697+
result.append('enter')
698+
async def exit(*exc_details):
699+
result.append(('exit', *exc_details))
700+
self.__aenter__ = enter
701+
self.__aexit__ = exit
702+
703+
cm = TestCM()
704+
result = []
705+
async with self.exit_stack() as stack:
706+
await stack.enter_async_context(cm)
707+
self.assertEqual(result, ['enter'])
708+
self.assertEqual(result, ['enter', ('exit', None, None, None)])
709+
710+
result = []
711+
async with self.exit_stack() as stack:
712+
stack.push_async_exit(cm)
713+
self.assertEqual(result, [])
714+
self.assertEqual(result, [('exit', None, None, None)])
715+
644716
@_async_test
645717
async def test_enter_async_context_errors(self):
646718
class LacksEnterAndExit:
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Add support for arbitrary descriptors :meth:`!__enter__`, :meth:`!__exit__`,
2+
:meth:`!__aenter__`, and :meth:`!__aexit__` in :class:`contextlib.ExitStack`
3+
and :class:`contextlib.AsyncExitStack`, for consistency with the
4+
:keyword:`with` and :keyword:`async with` statements.

0 commit comments

Comments
 (0)