Skip to content

Commit 37494b4

Browse files
authored
bpo-38530: Offer suggestions on AttributeError (#16856)
When printing AttributeError, PyErr_Display will offer suggestions of similar attribute names in the object that the exception was raised from: >>> collections.namedtoplo Traceback (most recent call last): File "<stdin>", line 1, in <module> AttributeError: module 'collections' has no attribute 'namedtoplo'. Did you mean: namedtuple?
1 parent 3bc694d commit 37494b4

File tree

12 files changed

+470
-15
lines changed

12 files changed

+470
-15
lines changed

Doc/library/exceptions.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,13 @@ The following exceptions are the exceptions that are usually raised.
149149
assignment fails. (When an object does not support attribute references or
150150
attribute assignments at all, :exc:`TypeError` is raised.)
151151

152+
The :attr:`name` and :attr:`obj` attributes can be set using keyword-only
153+
arguments to the constructor. When set they represent the name of the attribute
154+
that was attempted to be accessed and the object that was accessed for said
155+
attribute, respectively.
156+
157+
.. versionchanged:: 3.10
158+
Added the :attr:`name` and :attr:`obj` attributes.
152159

153160
.. exception:: EOFError
154161

Doc/whatsnew/3.10.rst

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,11 @@ Check :pep:`617` for more details.
125125
in :issue:`12782` and :issue:`40334`.)
126126

127127

128-
Better error messages in the parser
129-
-----------------------------------
128+
Better error messages
129+
---------------------
130+
131+
SyntaxErrors
132+
~~~~~~~~~~~~
130133

131134
When parsing code that contains unclosed parentheses or brackets the interpreter
132135
now includes the location of the unclosed bracket of parentheses instead of displaying
@@ -167,6 +170,23 @@ These improvements are inspired by previous work in the PyPy interpreter.
167170
(Contributed by Pablo Galindo in :issue:`42864` and Batuhan Taskaya in
168171
:issue:`40176`.)
169172
173+
174+
AttributeErrors
175+
~~~~~~~~~~~~~~~
176+
177+
When printing :exc:`AttributeError`, :c:func:`PyErr_Display` will offer
178+
suggestions of simmilar attribute names in the object that the exception was
179+
raised from:
180+
181+
.. code-block:: python
182+
183+
>>> collections.namedtoplo
184+
Traceback (most recent call last):
185+
File "<stdin>", line 1, in <module>
186+
AttributeError: module 'collections' has no attribute 'namedtoplo'. Did you mean: namedtuple?
187+
188+
(Contributed by Pablo Galindo in :issue:`38530`.)
189+
170190
PEP 626: Precise line numbers for debugging and other tools
171191
-----------------------------------------------------------
172192

Include/cpython/pyerrors.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ typedef struct {
6262
PyObject *value;
6363
} PyStopIterationObject;
6464

65+
typedef struct {
66+
PyException_HEAD
67+
PyObject *obj;
68+
PyObject *name;
69+
} PyAttributeErrorObject;
70+
6571
/* Compatibility typedefs */
6672
typedef PyOSErrorObject PyEnvironmentErrorObject;
6773
#ifdef MS_WINDOWS

Include/internal/pycore_pyerrors.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ PyAPI_FUNC(int) _PyErr_CheckSignalsTstate(PyThreadState *tstate);
8686

8787
PyAPI_FUNC(void) _Py_DumpExtensionModules(int fd, PyInterpreterState *interp);
8888

89+
extern PyObject* _Py_Offer_Suggestions(PyObject* exception);
90+
8991
#ifdef __cplusplus
9092
}
9193
#endif

Lib/test/test_exceptions.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,6 +1414,165 @@ class TestException(MemoryError):
14141414
gc_collect()
14151415

14161416

1417+
class AttributeErrorTests(unittest.TestCase):
1418+
def test_attributes(self):
1419+
# Setting 'attr' should not be a problem.
1420+
exc = AttributeError('Ouch!')
1421+
self.assertIsNone(exc.name)
1422+
self.assertIsNone(exc.obj)
1423+
1424+
sentinel = object()
1425+
exc = AttributeError('Ouch', name='carry', obj=sentinel)
1426+
self.assertEqual(exc.name, 'carry')
1427+
self.assertIs(exc.obj, sentinel)
1428+
1429+
def test_getattr_has_name_and_obj(self):
1430+
class A:
1431+
blech = None
1432+
1433+
obj = A()
1434+
try:
1435+
obj.bluch
1436+
except AttributeError as exc:
1437+
self.assertEqual("bluch", exc.name)
1438+
self.assertEqual(obj, exc.obj)
1439+
1440+
def test_getattr_has_name_and_obj_for_method(self):
1441+
class A:
1442+
def blech(self):
1443+
return
1444+
1445+
obj = A()
1446+
try:
1447+
obj.bluch()
1448+
except AttributeError as exc:
1449+
self.assertEqual("bluch", exc.name)
1450+
self.assertEqual(obj, exc.obj)
1451+
1452+
def test_getattr_suggestions(self):
1453+
class Substitution:
1454+
noise = more_noise = a = bc = None
1455+
blech = None
1456+
1457+
class Elimination:
1458+
noise = more_noise = a = bc = None
1459+
blch = None
1460+
1461+
class Addition:
1462+
noise = more_noise = a = bc = None
1463+
bluchin = None
1464+
1465+
class SubstitutionOverElimination:
1466+
blach = None
1467+
bluc = None
1468+
1469+
class SubstitutionOverAddition:
1470+
blach = None
1471+
bluchi = None
1472+
1473+
class EliminationOverAddition:
1474+
blucha = None
1475+
bluc = None
1476+
1477+
for cls, suggestion in [(Substitution, "blech?"),
1478+
(Elimination, "blch?"),
1479+
(Addition, "bluchin?"),
1480+
(EliminationOverAddition, "bluc?"),
1481+
(SubstitutionOverElimination, "blach?"),
1482+
(SubstitutionOverAddition, "blach?")]:
1483+
try:
1484+
cls().bluch
1485+
except AttributeError as exc:
1486+
with support.captured_stderr() as err:
1487+
sys.__excepthook__(*sys.exc_info())
1488+
1489+
self.assertIn(suggestion, err.getvalue())
1490+
1491+
def test_getattr_suggestions_do_not_trigger_for_long_attributes(self):
1492+
class A:
1493+
blech = None
1494+
1495+
try:
1496+
A().somethingverywrong
1497+
except AttributeError as exc:
1498+
with support.captured_stderr() as err:
1499+
sys.__excepthook__(*sys.exc_info())
1500+
1501+
self.assertNotIn("blech", err.getvalue())
1502+
1503+
def test_getattr_suggestions_do_not_trigger_for_big_dicts(self):
1504+
class A:
1505+
blech = None
1506+
# A class with a very big __dict__ will not be consider
1507+
# for suggestions.
1508+
for index in range(101):
1509+
setattr(A, f"index_{index}", None)
1510+
1511+
try:
1512+
A().bluch
1513+
except AttributeError as exc:
1514+
with support.captured_stderr() as err:
1515+
sys.__excepthook__(*sys.exc_info())
1516+
1517+
self.assertNotIn("blech", err.getvalue())
1518+
1519+
def test_getattr_suggestions_no_args(self):
1520+
class A:
1521+
blech = None
1522+
def __getattr__(self, attr):
1523+
raise AttributeError()
1524+
1525+
try:
1526+
A().bluch
1527+
except AttributeError as exc:
1528+
with support.captured_stderr() as err:
1529+
sys.__excepthook__(*sys.exc_info())
1530+
1531+
self.assertIn("blech", err.getvalue())
1532+
1533+
class A:
1534+
blech = None
1535+
def __getattr__(self, attr):
1536+
raise AttributeError
1537+
1538+
try:
1539+
A().bluch
1540+
except AttributeError as exc:
1541+
with support.captured_stderr() as err:
1542+
sys.__excepthook__(*sys.exc_info())
1543+
1544+
self.assertIn("blech", err.getvalue())
1545+
1546+
def test_getattr_suggestions_invalid_args(self):
1547+
class NonStringifyClass:
1548+
__str__ = None
1549+
__repr__ = None
1550+
1551+
class A:
1552+
blech = None
1553+
def __getattr__(self, attr):
1554+
raise AttributeError(NonStringifyClass())
1555+
1556+
class B:
1557+
blech = None
1558+
def __getattr__(self, attr):
1559+
raise AttributeError("Error", 23)
1560+
1561+
class C:
1562+
blech = None
1563+
def __getattr__(self, attr):
1564+
raise AttributeError(23)
1565+
1566+
for cls in [A, B, C]:
1567+
try:
1568+
cls().bluch
1569+
except AttributeError as exc:
1570+
with support.captured_stderr() as err:
1571+
sys.__excepthook__(*sys.exc_info())
1572+
1573+
self.assertIn("blech", err.getvalue())
1574+
1575+
14171576
class ImportErrorTests(unittest.TestCase):
14181577

14191578
def test_attributes(self):

Makefile.pre.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ PYTHON_OBJS= \
387387
Python/dtoa.o \
388388
Python/formatter_unicode.o \
389389
Python/fileutils.o \
390+
Python/suggestions.o \
390391
Python/$(DYNLOADFILE) \
391392
$(LIBOBJS) \
392393
$(MACHDEP_OBJS) \
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
When printing :exc:`AttributeError`, :c:func:`PyErr_Display` will offer
2+
suggestions of simmilar attribute names in the object that the exception was
3+
raised from. Patch by Pablo Galindo

Objects/exceptions.c

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1338,9 +1338,76 @@ SimpleExtendsException(PyExc_NameError, UnboundLocalError,
13381338
/*
13391339
* AttributeError extends Exception
13401340
*/
1341-
SimpleExtendsException(PyExc_Exception, AttributeError,
1342-
"Attribute not found.");
13431341

1342+
static int
1343+
AttributeError_init(PyAttributeErrorObject *self, PyObject *args, PyObject *kwds)
1344+
{
1345+
static char *kwlist[] = {"name", "obj", NULL};
1346+
PyObject *name = NULL;
1347+
PyObject *obj = NULL;
1348+
1349+
if (BaseException_init((PyBaseExceptionObject *)self, args, NULL) == -1) {
1350+
return -1;
1351+
}
1352+
1353+
PyObject *empty_tuple = PyTuple_New(0);
1354+
if (!empty_tuple) {
1355+
return -1;
1356+
}
1357+
if (!PyArg_ParseTupleAndKeywords(empty_tuple, kwds, "|$OO:AttributeError", kwlist,
1358+
&name, &obj)) {
1359+
Py_DECREF(empty_tuple);
1360+
return -1;
1361+
}
1362+
Py_DECREF(empty_tuple);
1363+
1364+
Py_XINCREF(name);
1365+
Py_XSETREF(self->name, name);
1366+
1367+
Py_XINCREF(obj);
1368+
Py_XSETREF(self->obj, obj);
1369+
1370+
return 0;
1371+
}
1372+
1373+
static int
1374+
AttributeError_clear(PyAttributeErrorObject *self)
1375+
{
1376+
Py_CLEAR(self->obj);
1377+
Py_CLEAR(self->name);
1378+
return BaseException_clear((PyBaseExceptionObject *)self);
1379+
}
1380+
1381+
static void
1382+
AttributeError_dealloc(PyAttributeErrorObject *self)
1383+
{
1384+
_PyObject_GC_UNTRACK(self);
1385+
AttributeError_clear(self);
1386+
Py_TYPE(self)->tp_free((PyObject *)self);
1387+
}
1388+
1389+
static int
1390+
AttributeError_traverse(PyAttributeErrorObject *self, visitproc visit, void *arg)
1391+
{
1392+
Py_VISIT(self->obj);
1393+
Py_VISIT(self->name);
1394+
return BaseException_traverse((PyBaseExceptionObject *)self, visit, arg);
1395+
}
1396+
1397+
static PyMemberDef AttributeError_members[] = {
1398+
{"name", T_OBJECT, offsetof(PyAttributeErrorObject, name), 0, PyDoc_STR("attribute name")},
1399+
{"obj", T_OBJECT, offsetof(PyAttributeErrorObject, obj), 0, PyDoc_STR("object")},
1400+
{NULL} /* Sentinel */
1401+
};
1402+
1403+
static PyMethodDef AttributeError_methods[] = {
1404+
{NULL} /* Sentinel */
1405+
};
1406+
1407+
ComplexExtendsException(PyExc_Exception, AttributeError,
1408+
AttributeError, 0,
1409+
AttributeError_methods, AttributeError_members,
1410+
0, BaseException_str, "Attribute not found.");
13441411

13451412
/*
13461413
* SyntaxError extends Exception

0 commit comments

Comments
 (0)