diff --git a/Include/internal/pycore_suggestions.h b/Include/internal/pycore_suggestions.h new file mode 100644 index 00000000000000..a28d85c9ecb5fc --- /dev/null +++ b/Include/internal/pycore_suggestions.h @@ -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 */ \ No newline at end of file diff --git a/Lib/test/test_class.py b/Lib/test/test_class.py index 456f1be30be046..c6672ff7f9859a 100644 --- a/Lib/test/test_class.py +++ b/Lib/test/test_class.py @@ -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: @@ -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 diff --git a/Lib/test/test_descr.py b/Lib/test/test_descr.py index 796e60a7704795..73bd87f62c81d1 100644 --- a/Lib/test/test_descr.py +++ b/Lib/test/test_descr.py @@ -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 diff --git a/Lib/unittest/mock.py b/Lib/unittest/mock.py index a48132c5b1cb5b..c5808d3e96df92 100644 --- a/Lib/unittest/mock.py +++ b/Lib/unittest/mock.py @@ -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 = [ diff --git a/Makefile.pre.in b/Makefile.pre.in index 1c0958ec974b25..036062e615c76a 100644 --- a/Makefile.pre.in +++ b/Makefile.pre.in @@ -368,6 +368,7 @@ PYTHON_OBJS= \ Python/dtoa.o \ Python/formatter_unicode.o \ Python/fileutils.o \ + Python/suggestions.o \ Python/$(DYNLOADFILE) \ $(LIBOBJS) \ $(MACHDEP_OBJS) \ @@ -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 \ diff --git a/Objects/object.c b/Objects/object.c index 2c8e823f05ee94..3f9fd3f622f712 100644 --- a/Objects/object.c +++ b/Objects/object.c @@ -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" @@ -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, @@ -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 diff --git a/PCbuild/pythoncore.vcxproj b/PCbuild/pythoncore.vcxproj index 1c055b6a334304..696b7c8d3a7ca4 100644 --- a/PCbuild/pythoncore.vcxproj +++ b/PCbuild/pythoncore.vcxproj @@ -179,6 +179,7 @@ + @@ -462,6 +463,7 @@ + diff --git a/Python/suggestions.c b/Python/suggestions.c new file mode 100644 index 00000000000000..897125cc077744 --- /dev/null +++ b/Python/suggestions.c @@ -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; +} + +