Skip to content

bpo-38530: Offer suggestions on AttributeError #16850

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Include/internal/pycore_suggestions.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#include "Python.h"

#ifndef Py_INTERNAL_SUGGESTIONS_H
#define Py_INTERNAL_SUGGESTIONS_H

#ifndef Py_BUILD_CORE
# error "this header requires Py_BUILD_CORE define"
#endif

int _Py_offer_suggestions(PyObject* v, PyObject* name);

#endif /* !Py_INTERNAL_SUGGESTIONS_H */
26 changes: 25 additions & 1 deletion Lib/test/test_class.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,7 @@ class A:
try:
A().a # Raised AttributeError: A instance has no attribute 'a'
except AttributeError as x:
if str(x) != "booh":
if not str(x).startswith("booh"):
self.fail("attribute error for A().a got masked: %s" % x)

class E:
Expand All @@ -534,6 +534,30 @@ class I:
else:
self.fail("attribute error for I.__init__ got masked")

def test_getattr_suggestions(self):
class A:
blech = None

try:
A().bluch
except AttributeError as x:
self.assertIn("blech", str(x))

try:
A().somethingverywrong
except AttributeError as x:
self.assertNotIn("blech", str(x))

# A class with a very big __dict__ will not be consider
# for suggestions.
for index in range(101):
setattr(A, f"index_{index}", None)

try:
A().bluch
except AttributeError as x:
self.assertNotIn("blech", str(x))

def assertNotOrderable(self, a, b):
with self.assertRaises(TypeError):
a < b
Expand Down
6 changes: 3 additions & 3 deletions Lib/test/test_descr.py
Original file line number Diff line number Diff line change
Expand Up @@ -4582,11 +4582,11 @@ class C(object):
__getattr__ = descr

self.assertRaises(AttributeError, getattr, A(), "attr")
self.assertEqual(descr.counter, 1)
self.assertEqual(descr.counter, 3)
self.assertRaises(AttributeError, getattr, B(), "attr")
self.assertEqual(descr.counter, 2)
self.assertRaises(AttributeError, getattr, C(), "attr")
self.assertEqual(descr.counter, 4)
self.assertRaises(AttributeError, getattr, C(), "attr")
self.assertEqual(descr.counter, 10)

class EvilGetattribute(object):
# This used to segfault
Expand Down
1 change: 1 addition & 0 deletions Lib/unittest/mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,7 @@ def __dir__(self):
return object.__dir__(self)

extras = self._mock_methods or []
extras = list(extras)
from_type = dir(type(self))
from_dict = list(self.__dict__)
from_child_mocks = [
Expand Down
2 changes: 2 additions & 0 deletions Makefile.pre.in
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ PYTHON_OBJS= \
Python/dtoa.o \
Python/formatter_unicode.o \
Python/fileutils.o \
Python/suggestions.o \
Python/$(DYNLOADFILE) \
$(LIBOBJS) \
$(MACHDEP_OBJS) \
Expand Down Expand Up @@ -1086,6 +1087,7 @@ PYTHON_HEADERS= \
$(srcdir)/Include/internal/pycore_import.h \
$(srcdir)/Include/internal/pycore_initconfig.h \
$(srcdir)/Include/internal/pycore_object.h \
$(srcdir)/Include/internal/pycore_suggestions.h \
$(srcdir)/Include/internal/pycore_pathconfig.h \
$(srcdir)/Include/internal/pycore_pyerrors.h \
$(srcdir)/Include/internal/pycore_pyhash.h \
Expand Down
29 changes: 22 additions & 7 deletions Objects/object.c
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#include "pycore_object.h"
#include "pycore_pystate.h"
#include "pycore_context.h"
#include "pycore_suggestions.h"
#include "frameobject.h"
#include "interpreteridobject.h"

Expand Down Expand Up @@ -930,6 +931,7 @@ PyObject *
PyObject_GetAttr(PyObject *v, PyObject *name)
{
PyTypeObject *tp = Py_TYPE(v);
PyObject* result = NULL;

if (!PyUnicode_Check(name)) {
PyErr_Format(PyExc_TypeError,
Expand All @@ -938,17 +940,30 @@ PyObject_GetAttr(PyObject *v, PyObject *name)
return NULL;
}
if (tp->tp_getattro != NULL)
return (*tp->tp_getattro)(v, name);
if (tp->tp_getattr != NULL) {
result = (*tp->tp_getattro)(v, name);
else if (tp->tp_getattr != NULL) {
const char *name_str = PyUnicode_AsUTF8(name);
if (name_str == NULL)
return NULL;
return (*tp->tp_getattr)(v, (char *)name_str);
result = (*tp->tp_getattr)(v, (char *)name_str);
} else {
PyErr_Format(PyExc_AttributeError,
"'%.50s' object has no attribute '%U'",
tp->tp_name, name);
}
PyErr_Format(PyExc_AttributeError,
"'%.50s' object has no attribute '%U'",
tp->tp_name, name);
return NULL;

// xxx use thread local storage for this thing
static int should_offer_suggestions = 1;
if (!result && should_offer_suggestions && PyErr_ExceptionMatches(PyExc_AttributeError)) {
should_offer_suggestions = 0;
int ret = _Py_offer_suggestions(v, name);
should_offer_suggestions = 1;
if (ret == -1) {
return NULL;
}
}

return result;
}

int
Expand Down
2 changes: 2 additions & 0 deletions PCbuild/pythoncore.vcxproj
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@
<ClInclude Include="..\Include\internal\pycore_traceback.h" />
<ClInclude Include="..\Include\internal\pycore_tupleobject.h" />
<ClInclude Include="..\Include\internal\pycore_warnings.h" />
<ClInclude Include="..\Include\internal\pycore_suggestions.h" />
<ClInclude Include="..\Include\interpreteridobject.h" />
<ClInclude Include="..\Include\intrcheck.h" />
<ClInclude Include="..\Include\iterobject.h" />
Expand Down Expand Up @@ -462,6 +463,7 @@
<ClCompile Include="..\Python\dtoa.c" />
<ClCompile Include="..\Python\Python-ast.c" />
<ClCompile Include="..\Python\pythonrun.c" />
<ClCompile Include="..\Python\suggestions.c" />
<ClCompile Include="..\Python\structmember.c" />
<ClCompile Include="..\Python\symtable.c" />
<ClCompile Include="..\Python\sysmodule.c" />
Expand Down
176 changes: 176 additions & 0 deletions Python/suggestions.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
#include "Python.h"

#include "pycore_suggestions.h"

#define MAX_GETATTR_PREDICT_DIST 3
#define MAX_GETATTR_PREDICT_ITEMS 100
#define MAX_GETATTR_STRING_SIZE 20

static size_t
distance(const char *string1, const char *string2)
{
Py_ssize_t len1 = strlen(string1);
Py_ssize_t len2 = strlen(string2);
Py_ssize_t i;
Py_ssize_t half;
size_t *row;
size_t *end;

/* Get rid of the common prefix */
while (len1 > 0 && len2 > 0 && *string1 == *string2) {
len1--;
len2--;
string1++;
string2++;
}

/* strip common suffix */
while (len1 > 0 && len2 > 0 && string1[len1-1] == string2[len2-1]) {
len1--;
len2--;
}

/* catch trivial cases */
if (len1 == 0) {
return len2;
}
if (len2 == 0) {
return len1;
}

/* make the inner cycle (i.e. string2) the longer one */
if (len1 > len2) {
size_t nx = len1;
const char *sx = string1;
len1 = len2;
len2 = nx;
string1 = string2;
string2 = sx;
}
/* check len1 == 1 separately */
if (len1 == 1) {
return len2 - (memchr(string2, *string1, len2) != NULL);
}
len1++;
len2++;
half = len1 >> 1;

/* initalize first row */
row = (size_t*)PyMem_Malloc(len2*sizeof(size_t));
if (!row) {
return (size_t)(-1);
}
end = row + len2 - 1;
for (i = 0; i < len2 - half; i++) {
row[i] = i;
}

/* We don't have to scan two corner triangles (of size len1/2)
* in the matrix because no best path can go throught them. This is
* not true when len1 == len2 == 2 so the memchr() special case above is
* necessary */
row[0] = len1 - half - 1;
for (i = 1; i < len1; i++) {
size_t *p;
const char char1 = string1[i - 1];
const char *char2p;
size_t D, x;
/* skip the upper triangle */
if (i >= len1 - half) {
size_t offset = i - (len1 - half);
size_t c3;

char2p = string2 + offset;
p = row + offset;
c3 = *(p++) + (char1 != *(char2p++));
x = *p;
x++;
D = x;
if (x > c3)
x = c3;
*(p++) = x;
}
else {
p = row + 1;
char2p = string2;
D = x = i;
}
/* skip the lower triangle */
if (i <= half + 1) {
end = row + len2 + i - half - 2;
}
/* main */
while (p <= end) {
size_t c3 = --D + (char1 != *(char2p++));
x++;
if (x > c3)
x = c3;
D = *p;
D++;
if (x > D)
x = D;
*(p++) = x;
}
/* lower triangle sentinel */
if (i <= half) {
size_t c3 = --D + (char1 != *char2p);
x++;
if (x > c3)
x = c3;
*p = x;
}
}
i = *end;
PyMem_Free(row);
return i;
}

int _Py_offer_suggestions(PyObject* v, PyObject* name){
PyObject *oldexceptiontype, *oldexceptionvalue, *oldtraceback;
PyErr_Fetch(&oldexceptiontype, &oldexceptionvalue, &oldtraceback);
if (Py_EnterRecursiveCall(" while getting the __dir__ of an object")) {
return -1;
}
PyObject* dir = PyObject_Dir(v);
Py_LeaveRecursiveCall();
PyObject* newexceptionvalue = oldexceptionvalue;
if (dir) {
if (!PyList_CheckExact(dir)) {
return -1;
}
PyObject* suggestion = NULL;
Py_ssize_t dir_size = PyList_GET_SIZE(dir);
if (dir_size <= MAX_GETATTR_PREDICT_ITEMS) {
int suggestion_distance = PyUnicode_GetLength(name);
for(int i = 0; i < dir_size; ++i) {
PyObject *item = PyList_GET_ITEM(dir, i);
const char *name_str = PyUnicode_AsUTF8(name);
if (name_str == NULL) {
PyErr_Clear();
continue;
}
int current_distance = distance(PyUnicode_AsUTF8(name),
PyUnicode_AsUTF8(item));
if (current_distance > MAX_GETATTR_PREDICT_DIST){
continue;
}
if (!suggestion || current_distance < suggestion_distance) {
suggestion = item;
suggestion_distance = current_distance;
}
}
}
if (suggestion) {
newexceptionvalue = PyUnicode_FromFormat("%S\n\nDid you mean: %U?",
oldexceptionvalue,
suggestion);
Py_DECREF(oldexceptionvalue);
}
Py_DECREF(dir);
}
PyErr_Clear();
PyErr_Restore(oldexceptiontype, newexceptionvalue, oldtraceback);
return 0;
}