https://github.com/python/cpython/commit/f73d2e70037ea0c76d03487a71790734344aa080
commit: f73d2e70037ea0c76d03487a71790734344aa080
branch: main
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-02-04T13:20:18+02:00
summary:

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.

files:
A Misc/NEWS.d/next/Library/2026-02-03-14-16-49.gh-issue-144386.9Wa59r.rst
M Doc/library/contextlib.rst
M Doc/whatsnew/3.15.rst
M Lib/contextlib.py
M Lib/test/test_contextlib.py
M Lib/test/test_contextlib_async.py

diff --git a/Doc/library/contextlib.rst b/Doc/library/contextlib.rst
index d0fa645093a3e9..f2e3c836cec332 100644
--- a/Doc/library/contextlib.rst
+++ b/Doc/library/contextlib.rst
@@ -564,6 +564,10 @@ Functions and classes provided:
          Raises :exc:`TypeError` instead of :exc:`AttributeError` if *cm*
          is not a context manager.
 
+      .. versionchanged:: next
+         Added support for arbitrary descriptors :meth:`!__enter__` and
+         :meth:`!__exit__`.
+
    .. method:: push(exit)
 
       Adds a context manager's :meth:`~object.__exit__` method to the callback 
stack.
@@ -582,6 +586,9 @@ Functions and classes provided:
       The passed in object is returned from the function, allowing this
       method to be used as a function decorator.
 
+      .. versionchanged:: next
+         Added support for arbitrary descriptors :meth:`!__exit__`.
+
    .. method:: callback(callback, /, *args, **kwds)
 
       Accepts an arbitrary callback function and arguments and adds it to
@@ -639,11 +646,17 @@ Functions and classes provided:
          Raises :exc:`TypeError` instead of :exc:`AttributeError` if *cm*
          is not an asynchronous context manager.
 
+      .. versionchanged:: next
+         Added support for arbitrary descriptors :meth:`!__aenter__` and 
:meth:`!__aexit__`.
+
    .. method:: push_async_exit(exit)
 
       Similar to :meth:`ExitStack.push` but expects either an asynchronous 
context manager
       or a coroutine function.
 
+      .. versionchanged:: next
+         Added support for arbitrary descriptors :meth:`!__aexit__`.
+
    .. method:: push_async_callback(callback, /, *args, **kwds)
 
       Similar to :meth:`ExitStack.callback` but expects a coroutine function.
diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst
index 7979933d7e8e79..2291228e721ffd 100644
--- a/Doc/whatsnew/3.15.rst
+++ b/Doc/whatsnew/3.15.rst
@@ -548,6 +548,16 @@ concurrent.futures
   (Contributed by Jonathan Berg in :gh:`139486`.)
 
 
+contextlib
+----------
+
+* Added support for arbitrary descriptors :meth:`!__enter__`,
+  :meth:`!__exit__`, :meth:`!__aenter__`, and :meth:`!__aexit__` in
+  :class:`~contextlib.ExitStack` and :class:`contextlib.AsyncExitStack`, for
+  consistency with the :keyword:`with` and :keyword:`async with` statements.
+  (Contributed by Serhiy Storchaka in :gh:`144386`.)
+
+
 dataclasses
 -----------
 
diff --git a/Lib/contextlib.py b/Lib/contextlib.py
index 5b646fabca0225..cac3e39eba8b52 100644
--- a/Lib/contextlib.py
+++ b/Lib/contextlib.py
@@ -5,7 +5,7 @@
 import _collections_abc
 from collections import deque
 from functools import wraps
-from types import MethodType, GenericAlias
+from types import GenericAlias
 
 __all__ = ["asynccontextmanager", "contextmanager", "closing", "nullcontext",
            "AbstractContextManager", "AbstractAsyncContextManager",
@@ -469,13 +469,23 @@ def __exit__(self, exctype, excinst, exctb):
         return False
 
 
+def _lookup_special(obj, name, default):
+    # Follow the standard lookup behaviour for special methods.
+    from inspect import getattr_static, _descriptor_get
+    cls = type(obj)
+    try:
+        descr = getattr_static(cls, name)
+    except AttributeError:
+        return default
+    return _descriptor_get(descr, obj)
+
+
+_sentinel = ['SENTINEL']
+
+
 class _BaseExitStack:
     """A base class for ExitStack and AsyncExitStack."""
 
-    @staticmethod
-    def _create_exit_wrapper(cm, cm_exit):
-        return MethodType(cm_exit, cm)
-
     @staticmethod
     def _create_cb_wrapper(callback, /, *args, **kwds):
         def _exit_wrapper(exc_type, exc, tb):
@@ -499,17 +509,8 @@ def push(self, exit):
         Also accepts any object with an __exit__ method (registering a call
         to the method instead of the object itself).
         """
-        # We use an unbound method rather than a bound method to follow
-        # the standard lookup behaviour for special methods.
-        _cb_type = type(exit)
-
-        try:
-            exit_method = _cb_type.__exit__
-        except AttributeError:
-            # Not a context manager, so assume it's a callable.
-            self._push_exit_callback(exit)
-        else:
-            self._push_cm_exit(exit, exit_method)
+        exit_method = _lookup_special(exit, '__exit__', exit)
+        self._push_exit_callback(exit_method)
         return exit  # Allow use as a decorator.
 
     def enter_context(self, cm):
@@ -518,17 +519,18 @@ def enter_context(self, cm):
         If successful, also pushes its __exit__ method as a callback and
         returns the result of the __enter__ method.
         """
-        # We look up the special methods on the type to match the with
-        # statement.
-        cls = type(cm)
-        try:
-            _enter = cls.__enter__
-            _exit = cls.__exit__
-        except AttributeError:
+        _enter = _lookup_special(cm, '__enter__', _sentinel)
+        if _enter is _sentinel:
+            cls = type(cm)
             raise TypeError(f"'{cls.__module__}.{cls.__qualname__}' object 
does "
-                            f"not support the context manager protocol") from 
None
-        result = _enter(cm)
-        self._push_cm_exit(cm, _exit)
+                            f"not support the context manager protocol")
+        _exit = _lookup_special(cm, '__exit__', _sentinel)
+        if _exit is _sentinel:
+            cls = type(cm)
+            raise TypeError(f"'{cls.__module__}.{cls.__qualname__}' object 
does "
+                            f"not support the context manager protocol")
+        result = _enter()
+        self._push_exit_callback(_exit)
         return result
 
     def callback(self, callback, /, *args, **kwds):
@@ -544,11 +546,6 @@ def callback(self, callback, /, *args, **kwds):
         self._push_exit_callback(_exit_wrapper)
         return callback  # Allow use as a decorator
 
-    def _push_cm_exit(self, cm, cm_exit):
-        """Helper to correctly register callbacks to __exit__ methods."""
-        _exit_wrapper = self._create_exit_wrapper(cm, cm_exit)
-        self._push_exit_callback(_exit_wrapper, True)
-
     def _push_exit_callback(self, callback, is_sync=True):
         self._exit_callbacks.append((is_sync, callback))
 
@@ -641,10 +638,6 @@ class AsyncExitStack(_BaseExitStack, 
AbstractAsyncContextManager):
             # connection later in the list raise an exception.
     """
 
-    @staticmethod
-    def _create_async_exit_wrapper(cm, cm_exit):
-        return MethodType(cm_exit, cm)
-
     @staticmethod
     def _create_async_cb_wrapper(callback, /, *args, **kwds):
         async def _exit_wrapper(exc_type, exc, tb):
@@ -657,16 +650,18 @@ async def enter_async_context(self, cm):
         If successful, also pushes its __aexit__ method as a callback and
         returns the result of the __aenter__ method.
         """
-        cls = type(cm)
-        try:
-            _enter = cls.__aenter__
-            _exit = cls.__aexit__
-        except AttributeError:
+        _enter = _lookup_special(cm, '__aenter__', _sentinel)
+        if _enter is _sentinel:
+            cls = type(cm)
             raise TypeError(f"'{cls.__module__}.{cls.__qualname__}' object 
does "
-                            f"not support the asynchronous context manager 
protocol"
-                           ) from None
-        result = await _enter(cm)
-        self._push_async_cm_exit(cm, _exit)
+                            f"not support the asynchronous context manager 
protocol")
+        _exit = _lookup_special(cm, '__aexit__', _sentinel)
+        if _exit is _sentinel:
+            cls = type(cm)
+            raise TypeError(f"'{cls.__module__}.{cls.__qualname__}' object 
does "
+                            f"not support the asynchronous context manager 
protocol")
+        result = await _enter()
+        self._push_exit_callback(_exit, False)
         return result
 
     def push_async_exit(self, exit):
@@ -677,14 +672,8 @@ def push_async_exit(self, exit):
         Also accepts any object with an __aexit__ method (registering a call
         to the method instead of the object itself).
         """
-        _cb_type = type(exit)
-        try:
-            exit_method = _cb_type.__aexit__
-        except AttributeError:
-            # Not an async context manager, so assume it's a coroutine function
-            self._push_exit_callback(exit, False)
-        else:
-            self._push_async_cm_exit(exit, exit_method)
+        exit_method = _lookup_special(exit, '__aexit__', exit)
+        self._push_exit_callback(exit_method, False)
         return exit  # Allow use as a decorator
 
     def push_async_callback(self, callback, /, *args, **kwds):
@@ -704,12 +693,6 @@ async def aclose(self):
         """Immediately unwind the context stack."""
         await self.__aexit__(None, None, None)
 
-    def _push_async_cm_exit(self, cm, cm_exit):
-        """Helper to correctly register coroutine function to __aexit__
-        method."""
-        _exit_wrapper = self._create_async_exit_wrapper(cm, cm_exit)
-        self._push_exit_callback(_exit_wrapper, False)
-
     async def __aenter__(self):
         return self
 
diff --git a/Lib/test/test_contextlib.py b/Lib/test/test_contextlib.py
index 6a3329fa5aaace..1fd8b3cb18c2d4 100644
--- a/Lib/test/test_contextlib.py
+++ b/Lib/test/test_contextlib.py
@@ -788,6 +788,75 @@ def _exit():
             result.append(2)
         self.assertEqual(result, [1, 2, 3, 4])
 
+    def test_enter_context_classmethod(self):
+        class TestCM:
+            @classmethod
+            def __enter__(cls):
+                result.append(('enter', cls))
+            @classmethod
+            def __exit__(cls, *exc_details):
+                result.append(('exit', cls, *exc_details))
+
+        cm = TestCM()
+        result = []
+        with self.exit_stack() as stack:
+            stack.enter_context(cm)
+            self.assertEqual(result, [('enter', TestCM)])
+        self.assertEqual(result, [('enter', TestCM),
+                                  ('exit', TestCM, None, None, None)])
+
+        result = []
+        with self.exit_stack() as stack:
+            stack.push(cm)
+            self.assertEqual(result, [])
+        self.assertEqual(result, [('exit', TestCM, None, None, None)])
+
+    def test_enter_context_staticmethod(self):
+        class TestCM:
+            @staticmethod
+            def __enter__():
+                result.append('enter')
+            @staticmethod
+            def __exit__(*exc_details):
+                result.append(('exit', *exc_details))
+
+        cm = TestCM()
+        result = []
+        with self.exit_stack() as stack:
+            stack.enter_context(cm)
+            self.assertEqual(result, ['enter'])
+        self.assertEqual(result, ['enter', ('exit', None, None, None)])
+
+        result = []
+        with self.exit_stack() as stack:
+            stack.push(cm)
+            self.assertEqual(result, [])
+        self.assertEqual(result, [('exit', None, None, None)])
+
+    def test_enter_context_slots(self):
+        class TestCM:
+            __slots__ = ('__enter__', '__exit__')
+            def __init__(self):
+                def enter():
+                    result.append('enter')
+                def exit(*exc_details):
+                    result.append(('exit', *exc_details))
+                self.__enter__ = enter
+                self.__exit__ = exit
+
+        cm = TestCM()
+        result = []
+        with self.exit_stack() as stack:
+            stack.enter_context(cm)
+            self.assertEqual(result, ['enter'])
+        self.assertEqual(result, ['enter', ('exit', None, None, None)])
+
+        result = []
+        with self.exit_stack() as stack:
+            stack.push(cm)
+            self.assertEqual(result, [])
+        self.assertEqual(result, [('exit', None, None, None)])
+
     def test_enter_context_errors(self):
         class LacksEnterAndExit:
             pass
diff --git a/Lib/test/test_contextlib_async.py 
b/Lib/test/test_contextlib_async.py
index dcd0072037950e..248d32d615225d 100644
--- a/Lib/test/test_contextlib_async.py
+++ b/Lib/test/test_contextlib_async.py
@@ -641,6 +641,78 @@ async def _exit():
 
         self.assertEqual(result, [1, 2, 3, 4])
 
+    @_async_test
+    async def test_enter_async_context_classmethod(self):
+        class TestCM:
+            @classmethod
+            async def __aenter__(cls):
+                result.append(('enter', cls))
+            @classmethod
+            async def __aexit__(cls, *exc_details):
+                result.append(('exit', cls, *exc_details))
+
+        cm = TestCM()
+        result = []
+        async with self.exit_stack() as stack:
+            await stack.enter_async_context(cm)
+            self.assertEqual(result, [('enter', TestCM)])
+        self.assertEqual(result, [('enter', TestCM),
+                                  ('exit', TestCM, None, None, None)])
+
+        result = []
+        async with self.exit_stack() as stack:
+            stack.push_async_exit(cm)
+            self.assertEqual(result, [])
+        self.assertEqual(result, [('exit', TestCM, None, None, None)])
+
+    @_async_test
+    async def test_enter_async_context_staticmethod(self):
+        class TestCM:
+            @staticmethod
+            async def __aenter__():
+                result.append('enter')
+            @staticmethod
+            async def __aexit__(*exc_details):
+                result.append(('exit', *exc_details))
+
+        cm = TestCM()
+        result = []
+        async with self.exit_stack() as stack:
+            await stack.enter_async_context(cm)
+            self.assertEqual(result, ['enter'])
+        self.assertEqual(result, ['enter', ('exit', None, None, None)])
+
+        result = []
+        async with self.exit_stack() as stack:
+            stack.push_async_exit(cm)
+            self.assertEqual(result, [])
+        self.assertEqual(result, [('exit', None, None, None)])
+
+    @_async_test
+    async def test_enter_async_context_slots(self):
+        class TestCM:
+            __slots__ = ('__aenter__', '__aexit__')
+            def __init__(self):
+                async def enter():
+                    result.append('enter')
+                async def exit(*exc_details):
+                    result.append(('exit', *exc_details))
+                self.__aenter__ = enter
+                self.__aexit__ = exit
+
+        cm = TestCM()
+        result = []
+        async with self.exit_stack() as stack:
+            await stack.enter_async_context(cm)
+            self.assertEqual(result, ['enter'])
+        self.assertEqual(result, ['enter', ('exit', None, None, None)])
+
+        result = []
+        async with self.exit_stack() as stack:
+            stack.push_async_exit(cm)
+            self.assertEqual(result, [])
+        self.assertEqual(result, [('exit', None, None, None)])
+
     @_async_test
     async def test_enter_async_context_errors(self):
         class LacksEnterAndExit:
diff --git 
a/Misc/NEWS.d/next/Library/2026-02-03-14-16-49.gh-issue-144386.9Wa59r.rst 
b/Misc/NEWS.d/next/Library/2026-02-03-14-16-49.gh-issue-144386.9Wa59r.rst
new file mode 100644
index 00000000000000..6e60eeba208ffd
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-02-03-14-16-49.gh-issue-144386.9Wa59r.rst
@@ -0,0 +1,4 @@
+Add support for arbitrary descriptors :meth:`!__enter__`, :meth:`!__exit__`,
+:meth:`!__aenter__`, and :meth:`!__aexit__` in :class:`contextlib.ExitStack`
+and :class:`contextlib.AsyncExitStack`, for consistency with the
+:keyword:`with` and :keyword:`async with` statements.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to