Skip to content

Commit 8a9c6c4

Browse files
authored
gh-128398: improve error messages when incorrectly using with and async with (#132218)
Improve the error message with a suggestion when an object supporting the synchronous (resp. asynchronous) context manager protocol is entered using `async with` (resp. `with`) instead of `with` (resp. `async with`).
1 parent 95800fe commit 8a9c6c4

File tree

10 files changed

+212
-48
lines changed

10 files changed

+212
-48
lines changed

Doc/whatsnew/3.14.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,12 @@ Other language changes
479479
:func:`textwrap.dedent`.
480480
(Contributed by Jon Crall and Steven Sun in :gh:`103998`.)
481481

482+
* Improve error message when an object supporting the synchronous (resp.
483+
asynchronous) context manager protocol is entered using :keyword:`async
484+
with` (resp. :keyword:`with`) instead of :keyword:`with` (resp.
485+
:keyword:`async with`).
486+
(Contributed by Bénédikt Tran in :gh:`128398`.)
487+
482488

483489
.. _whatsnew314-pep765:
484490

Include/internal/pycore_ceval.h

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ PyAPI_DATA(const conversion_func) _PyEval_ConversionFuncs[];
279279
typedef struct _special_method {
280280
PyObject *name;
281281
const char *error;
282+
const char *error_suggestion; // improved optional suggestion
282283
} _Py_SpecialMethod;
283284

284285
PyAPI_DATA(const _Py_SpecialMethod) _Py_SpecialMethods[];
@@ -309,6 +310,16 @@ PyAPI_FUNC(PyObject *) _PyEval_LoadName(PyThreadState *tstate, _PyInterpreterFra
309310
PyAPI_FUNC(int)
310311
_Py_Check_ArgsIterable(PyThreadState *tstate, PyObject *func, PyObject *args);
311312

313+
/*
314+
* Indicate whether a special method of given 'oparg' can use the (improved)
315+
* alternative error message instead. Only methods loaded by LOAD_SPECIAL
316+
* support alternative error messages.
317+
*
318+
* Symbol is exported for the JIT (see discussion on GH-132218).
319+
*/
320+
PyAPI_FUNC(int)
321+
_PyEval_SpecialMethodCanSuggest(PyObject *self, int oparg);
322+
312323
/* Bits that can be set in PyThreadState.eval_breaker */
313324
#define _PY_GIL_DROP_REQUEST_BIT (1U << 0)
314325
#define _PY_SIGNALS_PENDING_BIT (1U << 1)

Lib/test/test_with.py

Lines changed: 81 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,27 @@
1-
"""Unit tests for the with statement specified in PEP 343."""
1+
"""Unit tests for the 'with/async with' statements specified in PEP 343/492."""
22

33

44
__author__ = "Mike Bland"
55
__email__ = "mbland at acm dot org"
66

7+
import re
78
import sys
89
import traceback
910
import unittest
1011
from collections import deque
1112
from contextlib import _GeneratorContextManager, contextmanager, nullcontext
1213

1314

15+
def do_with(obj):
16+
with obj:
17+
pass
18+
19+
20+
async def do_async_with(obj):
21+
async with obj:
22+
pass
23+
24+
1425
class MockContextManager(_GeneratorContextManager):
1526
def __init__(self, *args):
1627
super().__init__(*args)
@@ -110,34 +121,77 @@ def fooNotDeclared():
110121
with foo: pass
111122
self.assertRaises(NameError, fooNotDeclared)
112123

113-
def testEnterAttributeError1(self):
114-
class LacksEnter(object):
115-
def __exit__(self, type, value, traceback):
116-
pass
117-
118-
def fooLacksEnter():
119-
foo = LacksEnter()
120-
with foo: pass
121-
self.assertRaisesRegex(TypeError, 'the context manager', fooLacksEnter)
122-
123-
def testEnterAttributeError2(self):
124-
class LacksEnterAndExit(object):
125-
pass
124+
def testEnterAttributeError(self):
125+
class LacksEnter:
126+
def __exit__(self, type, value, traceback): ...
126127

127-
def fooLacksEnterAndExit():
128-
foo = LacksEnterAndExit()
129-
with foo: pass
130-
self.assertRaisesRegex(TypeError, 'the context manager', fooLacksEnterAndExit)
128+
with self.assertRaisesRegex(TypeError, re.escape((
129+
"object does not support the context manager protocol "
130+
"(missed __enter__ method)"
131+
))):
132+
do_with(LacksEnter())
131133

132134
def testExitAttributeError(self):
133-
class LacksExit(object):
134-
def __enter__(self):
135-
pass
136-
137-
def fooLacksExit():
138-
foo = LacksExit()
139-
with foo: pass
140-
self.assertRaisesRegex(TypeError, 'the context manager.*__exit__', fooLacksExit)
135+
class LacksExit:
136+
def __enter__(self): ...
137+
138+
msg = re.escape((
139+
"object does not support the context manager protocol "
140+
"(missed __exit__ method)"
141+
))
142+
# a missing __exit__ is reported missing before a missing __enter__
143+
with self.assertRaisesRegex(TypeError, msg):
144+
do_with(object())
145+
with self.assertRaisesRegex(TypeError, msg):
146+
do_with(LacksExit())
147+
148+
def testWithForAsyncManager(self):
149+
class AsyncManager:
150+
async def __aenter__(self): ...
151+
async def __aexit__(self, type, value, traceback): ...
152+
153+
with self.assertRaisesRegex(TypeError, re.escape((
154+
"object does not support the context manager protocol "
155+
"(missed __exit__ method) but it supports the asynchronous "
156+
"context manager protocol. Did you mean to use 'async with'?"
157+
))):
158+
do_with(AsyncManager())
159+
160+
def testAsyncEnterAttributeError(self):
161+
class LacksAsyncEnter:
162+
async def __aexit__(self, type, value, traceback): ...
163+
164+
with self.assertRaisesRegex(TypeError, re.escape((
165+
"object does not support the asynchronous context manager protocol "
166+
"(missed __aenter__ method)"
167+
))):
168+
do_async_with(LacksAsyncEnter()).send(None)
169+
170+
def testAsyncExitAttributeError(self):
171+
class LacksAsyncExit:
172+
async def __aenter__(self): ...
173+
174+
msg = re.escape((
175+
"object does not support the asynchronous context manager protocol "
176+
"(missed __aexit__ method)"
177+
))
178+
# a missing __aexit__ is reported missing before a missing __aenter__
179+
with self.assertRaisesRegex(TypeError, msg):
180+
do_async_with(object()).send(None)
181+
with self.assertRaisesRegex(TypeError, msg):
182+
do_async_with(LacksAsyncExit()).send(None)
183+
184+
def testAsyncWithForSyncManager(self):
185+
class SyncManager:
186+
def __enter__(self): ...
187+
def __exit__(self, type, value, traceback): ...
188+
189+
with self.assertRaisesRegex(TypeError, re.escape((
190+
"object does not support the asynchronous context manager protocol "
191+
"(missed __aexit__ method) but it supports the context manager "
192+
"protocol. Did you mean to use 'with'?"
193+
))):
194+
do_async_with(SyncManager()).send(None)
141195

142196
def assertRaisesSyntaxError(self, codestr):
143197
def shouldRaiseSyntaxError(s):
@@ -190,6 +244,7 @@ def shouldThrow():
190244
pass
191245
self.assertRaises(RuntimeError, shouldThrow)
192246

247+
193248
class ContextmanagerAssertionMixin(object):
194249

195250
def setUp(self):

Lib/unittest/async_case.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,17 @@ async def enterAsyncContext(self, cm):
7575
enter = cls.__aenter__
7676
exit = cls.__aexit__
7777
except AttributeError:
78-
raise TypeError(f"'{cls.__module__}.{cls.__qualname__}' object does "
79-
f"not support the asynchronous context manager protocol"
80-
) from None
78+
msg = (f"'{cls.__module__}.{cls.__qualname__}' object does "
79+
"not support the asynchronous context manager protocol")
80+
try:
81+
cls.__enter__
82+
cls.__exit__
83+
except AttributeError:
84+
pass
85+
else:
86+
msg += (" but it supports the context manager protocol. "
87+
"Did you mean to use enterContext()?")
88+
raise TypeError(msg) from None
8189
result = await enter(cm)
8290
self.addAsyncCleanup(exit, cm, None, None, None)
8391
return result

Lib/unittest/case.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,17 @@ def _enter_context(cm, addcleanup):
111111
enter = cls.__enter__
112112
exit = cls.__exit__
113113
except AttributeError:
114-
raise TypeError(f"'{cls.__module__}.{cls.__qualname__}' object does "
115-
f"not support the context manager protocol") from None
114+
msg = (f"'{cls.__module__}.{cls.__qualname__}' object does "
115+
"not support the context manager protocol")
116+
try:
117+
cls.__aenter__
118+
cls.__aexit__
119+
except AttributeError:
120+
pass
121+
else:
122+
msg += (" but it supports the asynchronous context manager "
123+
"protocol. Did you mean to use enterAsyncContext()?")
124+
raise TypeError(msg) from None
116125
result = enter(cm)
117126
addcleanup(exit, cm, None, None, None)
118127
return result
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Improve error message when an object supporting the synchronous (resp.
2+
asynchronous) context manager protocol is entered using :keyword:`async
3+
with` (resp. :keyword:`with`) instead of :keyword:`with` (resp.
4+
:keyword:`async with`). Patch by Bénédikt Tran.

Python/bytecodes.c

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3425,9 +3425,12 @@ dummy_func(
34253425
PyObject *attr_o = _PyObject_LookupSpecialMethod(owner_o, name, &self_or_null_o);
34263426
if (attr_o == NULL) {
34273427
if (!_PyErr_Occurred(tstate)) {
3428-
_PyErr_Format(tstate, PyExc_TypeError,
3429-
_Py_SpecialMethods[oparg].error,
3430-
Py_TYPE(owner_o)->tp_name);
3428+
const char *errfmt = _PyEval_SpecialMethodCanSuggest(owner_o, oparg)
3429+
? _Py_SpecialMethods[oparg].error_suggestion
3430+
: _Py_SpecialMethods[oparg].error;
3431+
assert(!_PyErr_Occurred(tstate));
3432+
assert(errfmt != NULL);
3433+
_PyErr_Format(tstate, PyExc_TypeError, errfmt, owner_o);
34313434
}
34323435
ERROR_IF(true, error);
34333436
}

Python/ceval.c

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -545,23 +545,51 @@ const conversion_func _PyEval_ConversionFuncs[4] = {
545545
const _Py_SpecialMethod _Py_SpecialMethods[] = {
546546
[SPECIAL___ENTER__] = {
547547
.name = &_Py_ID(__enter__),
548-
.error = "'%.200s' object does not support the "
549-
"context manager protocol (missed __enter__ method)",
548+
.error = (
549+
"'%T' object does not support the context manager protocol "
550+
"(missed __enter__ method)"
551+
),
552+
.error_suggestion = (
553+
"'%T' object does not support the context manager protocol "
554+
"(missed __enter__ method) but it supports the asynchronous "
555+
"context manager protocol. Did you mean to use 'async with'?"
556+
)
550557
},
551558
[SPECIAL___EXIT__] = {
552559
.name = &_Py_ID(__exit__),
553-
.error = "'%.200s' object does not support the "
554-
"context manager protocol (missed __exit__ method)",
560+
.error = (
561+
"'%T' object does not support the context manager protocol "
562+
"(missed __exit__ method)"
563+
),
564+
.error_suggestion = (
565+
"'%T' object does not support the context manager protocol "
566+
"(missed __exit__ method) but it supports the asynchronous "
567+
"context manager protocol. Did you mean to use 'async with'?"
568+
)
555569
},
556570
[SPECIAL___AENTER__] = {
557571
.name = &_Py_ID(__aenter__),
558-
.error = "'%.200s' object does not support the asynchronous "
559-
"context manager protocol (missed __aenter__ method)",
572+
.error = (
573+
"'%T' object does not support the asynchronous "
574+
"context manager protocol (missed __aenter__ method)"
575+
),
576+
.error_suggestion = (
577+
"'%T' object does not support the asynchronous context manager "
578+
"protocol (missed __aenter__ method) but it supports the context "
579+
"manager protocol. Did you mean to use 'with'?"
580+
)
560581
},
561582
[SPECIAL___AEXIT__] = {
562583
.name = &_Py_ID(__aexit__),
563-
.error = "'%.200s' object does not support the asynchronous "
564-
"context manager protocol (missed __aexit__ method)",
584+
.error = (
585+
"'%T' object does not support the asynchronous "
586+
"context manager protocol (missed __aexit__ method)"
587+
),
588+
.error_suggestion = (
589+
"'%T' object does not support the asynchronous context manager "
590+
"protocol (missed __aexit__ method) but it supports the context "
591+
"manager protocol. Did you mean to use 'with'?"
592+
)
565593
}
566594
};
567595

@@ -3380,3 +3408,33 @@ _PyEval_LoadName(PyThreadState *tstate, _PyInterpreterFrame *frame, PyObject *na
33803408
}
33813409
return value;
33823410
}
3411+
3412+
/* Check if a 'cls' provides the given special method. */
3413+
static inline int
3414+
type_has_special_method(PyTypeObject *cls, PyObject *name)
3415+
{
3416+
// _PyType_Lookup() does not set an exception and returns a borrowed ref
3417+
assert(!PyErr_Occurred());
3418+
PyObject *r = _PyType_Lookup(cls, name);
3419+
return r != NULL && Py_TYPE(r)->tp_descr_get != NULL;
3420+
}
3421+
3422+
int
3423+
_PyEval_SpecialMethodCanSuggest(PyObject *self, int oparg)
3424+
{
3425+
PyTypeObject *type = Py_TYPE(self);
3426+
switch (oparg) {
3427+
case SPECIAL___ENTER__:
3428+
case SPECIAL___EXIT__: {
3429+
return type_has_special_method(type, &_Py_ID(__aenter__))
3430+
&& type_has_special_method(type, &_Py_ID(__aexit__));
3431+
}
3432+
case SPECIAL___AENTER__:
3433+
case SPECIAL___AEXIT__: {
3434+
return type_has_special_method(type, &_Py_ID(__enter__))
3435+
&& type_has_special_method(type, &_Py_ID(__exit__));
3436+
}
3437+
default:
3438+
Py_FatalError("unsupported special method");
3439+
}
3440+
}

Python/executor_cases.c.h

Lines changed: 8 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Python/generated_cases.c.h

Lines changed: 8 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)