From 049fdb001577f08f991d211c537fcac18c07a10a Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Mon, 21 Jun 2021 10:48:48 +0300 Subject: [PATCH 1/2] bpo-44471: Change error type for bad objects in ExitStack.enter_context() A TypeError is now raised instead of an AttributeError in ExitStack.enter_context() and AsyncExitStack.enter_async_context() for objects which do not support the context manager or asynchronous context manager protocols correspondingly. --- Doc/library/contextlib.rst | 8 +++++ Doc/whatsnew/3.11.rst | 6 ++++ Lib/contextlib.py | 23 +++++++++---- Lib/test/test_contextlib.py | 23 ++++++++++++- Lib/test/test_contextlib_async.py | 34 ++++++++++++++++++- .../2021-06-21-10-46-58.bpo-44471.2QjXv_.rst | 5 +++ 6 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2021-06-21-10-46-58.bpo-44471.2QjXv_.rst diff --git a/Doc/library/contextlib.rst b/Doc/library/contextlib.rst index c9065be32e6386..7ac3856819593c 100644 --- a/Doc/library/contextlib.rst +++ b/Doc/library/contextlib.rst @@ -515,6 +515,10 @@ Functions and classes provided: These context managers may suppress exceptions just as they normally would if used directly as part of a :keyword:`with` statement. + ... versionchanged:: 3.11 + Raises :exc:`TypeError` instead of :exc:`AttributeError` if *cm* + is not a context manager. + .. method:: push(exit) Adds a context manager's :meth:`__exit__` method to the callback stack. @@ -585,6 +589,10 @@ Functions and classes provided: Similar to :meth:`enter_context` but expects an asynchronous context manager. + ... versionchanged:: 3.11 + Raises :exc:`TypeError` instead of :exc:`AttributeError` if *cm* + is not an asynchronous context manager. + .. method:: push_async_exit(exit) Similar to :meth:`push` but expects either an asynchronous context manager diff --git a/Doc/whatsnew/3.11.rst b/Doc/whatsnew/3.11.rst index 50d91a0adc141b..a5a9129bce6886 100644 --- a/Doc/whatsnew/3.11.rst +++ b/Doc/whatsnew/3.11.rst @@ -75,6 +75,12 @@ New Features Other Language Changes ====================== +A :exc:`TypeError` is now raised instead of an :exc:`AttributeError` in +:meth:`contextlib.ExitStack.enter_context` and +:meth:`contextlib.AsyncExitStack.enter_async_context` for objects which do not +support the :term:`context manager` or :term:`asynchronous context manager` +protocols correspondingly. +(Contributed by Serhiy Storchaka in :issue:`44471`.) New Modules diff --git a/Lib/contextlib.py b/Lib/contextlib.py index 1a8ef6122c8d47..004d1037b78a47 100644 --- a/Lib/contextlib.py +++ b/Lib/contextlib.py @@ -473,9 +473,14 @@ def enter_context(self, cm): """ # We look up the special methods on the type to match the with # statement. - _cm_type = type(cm) - _exit = _cm_type.__exit__ - result = _cm_type.__enter__(cm) + cls = type(cm) + try: + _enter = cls.__enter__ + _exit = cls.__exit__ + except AttributeError: + 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) return result @@ -600,9 +605,15 @@ 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. """ - _cm_type = type(cm) - _exit = _cm_type.__aexit__ - result = await _cm_type.__aenter__(cm) + cls = type(cm) + try: + _enter = cls.__aenter__ + _exit = cls.__aexit__ + except AttributeError: + 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) return result diff --git a/Lib/test/test_contextlib.py b/Lib/test/test_contextlib.py index 453ef6c9f0832f..b9a29071b7aa04 100644 --- a/Lib/test/test_contextlib.py +++ b/Lib/test/test_contextlib.py @@ -661,6 +661,25 @@ def _exit(): result.append(2) self.assertEqual(result, [1, 2, 3, 4]) + def test_enter_context_errors(self): + class LacksEnterAndExit: + pass + class LacksEnter: + def __exit__(self, *exc_info): + pass + class LacksExit: + def __enter__(self): + pass + + with self.exit_stack() as stack: + with self.assertRaisesRegex(TypeError, 'context manager'): + stack.enter_context(LacksEnterAndExit()) + with self.assertRaisesRegex(TypeError, 'context manager'): + stack.enter_context(LacksEnter()) + with self.assertRaisesRegex(TypeError, 'context manager'): + stack.enter_context(LacksExit()) + self.assertFalse(stack._exit_callbacks) + def test_close(self): result = [] with self.exit_stack() as stack: @@ -886,9 +905,11 @@ def test_excessive_nesting(self): def test_instance_bypass(self): class Example(object): pass cm = Example() + cm.__enter__ = object() cm.__exit__ = object() stack = self.exit_stack() - self.assertRaises(AttributeError, stack.enter_context, cm) + with self.assertRaisesRegex(TypeError, "context manager"): + stack.enter_context(cm) stack.push(cm) self.assertIs(stack._exit_callbacks[-1][1], cm) diff --git a/Lib/test/test_contextlib_async.py b/Lib/test/test_contextlib_async.py index cbc82dfd8f8d07..f5d0fb04f0f36a 100644 --- a/Lib/test/test_contextlib_async.py +++ b/Lib/test/test_contextlib_async.py @@ -483,7 +483,7 @@ async def __aexit__(self, *exc_details): 1/0 @_async_test - async def test_async_enter_context(self): + async def test_enter_async_context(self): class TestCM(object): async def __aenter__(self): result.append(1) @@ -504,6 +504,26 @@ async def _exit(): self.assertEqual(result, [1, 2, 3, 4]) + @_async_test + async def test_enter_async_context_errors(self): + class LacksEnterAndExit: + pass + class LacksEnter: + async def __aexit__(self, *exc_info): + pass + class LacksExit: + async def __aenter__(self): + pass + + async with self.exit_stack() as stack: + with self.assertRaisesRegex(TypeError, 'asynchronous context manager'): + await stack.enter_async_context(LacksEnterAndExit()) + with self.assertRaisesRegex(TypeError, 'asynchronous context manager'): + await stack.enter_async_context(LacksEnter()) + with self.assertRaisesRegex(TypeError, 'asynchronous context manager'): + await stack.enter_async_context(LacksExit()) + self.assertFalse(stack._exit_callbacks) + @_async_test async def test_async_exit_exception_chaining(self): # Ensure exception chaining matches the reference behaviour @@ -536,6 +556,18 @@ async def suppress_exc(*exc_details): self.assertIsInstance(inner_exc, ValueError) self.assertIsInstance(inner_exc.__context__, ZeroDivisionError) + @_async_test + async def test_instance_bypass_async(self): + class Example(object): pass + cm = Example() + cm.__aenter__ = object() + cm.__aexit__ = object() + stack = self.exit_stack() + with self.assertRaisesRegex(TypeError, "asynchronous context manager"): + await stack.enter_async_context(cm) + stack.push_async_exit(cm) + self.assertIs(stack._exit_callbacks[-1][1], cm) + class TestAsyncNullcontext(unittest.TestCase): @_async_test diff --git a/Misc/NEWS.d/next/Library/2021-06-21-10-46-58.bpo-44471.2QjXv_.rst b/Misc/NEWS.d/next/Library/2021-06-21-10-46-58.bpo-44471.2QjXv_.rst new file mode 100644 index 00000000000000..0675ef3262a090 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2021-06-21-10-46-58.bpo-44471.2QjXv_.rst @@ -0,0 +1,5 @@ +A :exc:`TypeError` is now raised instead of an :exc:`AttributeError` in +:meth:`contextlib.ExitStack.enter_context` and +:meth:`contextlib.AsyncExitStack.enter_async_context` for objects which do +not support the :term:`context manager` or :term:`asynchronous context +manager` protocols correspondingly. From 62370db066a3e1700ae3d8e4ba93e72e93d95ffb Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Mon, 21 Jun 2021 21:13:00 +0300 Subject: [PATCH 2/2] More specific tests. --- Lib/test/test_contextlib.py | 8 ++++---- Lib/test/test_contextlib_async.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_contextlib.py b/Lib/test/test_contextlib.py index b9a29071b7aa04..9202328dbf0087 100644 --- a/Lib/test/test_contextlib.py +++ b/Lib/test/test_contextlib.py @@ -672,11 +672,11 @@ def __enter__(self): pass with self.exit_stack() as stack: - with self.assertRaisesRegex(TypeError, 'context manager'): + with self.assertRaisesRegex(TypeError, 'the context manager'): stack.enter_context(LacksEnterAndExit()) - with self.assertRaisesRegex(TypeError, 'context manager'): + with self.assertRaisesRegex(TypeError, 'the context manager'): stack.enter_context(LacksEnter()) - with self.assertRaisesRegex(TypeError, 'context manager'): + with self.assertRaisesRegex(TypeError, 'the context manager'): stack.enter_context(LacksExit()) self.assertFalse(stack._exit_callbacks) @@ -908,7 +908,7 @@ class Example(object): pass cm.__enter__ = object() cm.__exit__ = object() stack = self.exit_stack() - with self.assertRaisesRegex(TypeError, "context manager"): + with self.assertRaisesRegex(TypeError, 'the context manager'): stack.enter_context(cm) stack.push(cm) self.assertIs(stack._exit_callbacks[-1][1], cm) diff --git a/Lib/test/test_contextlib_async.py b/Lib/test/test_contextlib_async.py index f5d0fb04f0f36a..7904abff7d1aa5 100644 --- a/Lib/test/test_contextlib_async.py +++ b/Lib/test/test_contextlib_async.py @@ -563,7 +563,7 @@ class Example(object): pass cm.__aenter__ = object() cm.__aexit__ = object() stack = self.exit_stack() - with self.assertRaisesRegex(TypeError, "asynchronous context manager"): + with self.assertRaisesRegex(TypeError, 'asynchronous context manager'): await stack.enter_async_context(cm) stack.push_async_exit(cm) self.assertIs(stack._exit_callbacks[-1][1], cm)