]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-91058: Add error suggestions to 'import from' import errors (#98305)
authorPablo Galindo Salgado <Pablogsal@gmail.com>
Tue, 25 Oct 2022 22:56:59 +0000 (23:56 +0100)
committerGitHub <noreply@github.com>
Tue, 25 Oct 2022 22:56:59 +0000 (23:56 +0100)
Include/cpython/pyerrors.h
Include/internal/pycore_global_strings.h
Include/internal/pycore_runtime_init_generated.h
Lib/test/test_call.py
Lib/test/test_traceback.py
Lib/traceback.py
Misc/NEWS.d/next/Core and Builtins/2022-10-15-22-25-20.gh-issue-91058.Uo2kW-.rst [new file with mode: 0644]
Objects/exceptions.c
Python/ceval.c
Python/errors.c
Python/suggestions.c

index f33d3caaa2082e2a4dd5ca18cb5dd2d81cc9c455..141341667795e8fadcd1bdc7b8650393d8fae53c 100644 (file)
@@ -37,6 +37,7 @@ typedef struct {
     PyObject *msg;
     PyObject *name;
     PyObject *path;
+    PyObject *name_from;
 } PyImportErrorObject;
 
 typedef struct {
@@ -176,4 +177,11 @@ PyAPI_FUNC(void) _Py_NO_RETURN _Py_FatalErrorFormat(
     const char *format,
     ...);
 
+extern PyObject *_PyErr_SetImportErrorWithNameFrom(
+        PyObject *,
+        PyObject *,
+        PyObject *,
+        PyObject *);
+
+
 #define Py_FatalError(message) _Py_FatalErrorFunc(__func__, (message))
index 9c716a3012f496d0670c1c52016276ebad22160c..43f4dac8938677b621101ac09d9ccc470e487b85 100644 (file)
@@ -478,6 +478,7 @@ struct _Py_global_strings {
         STRUCT_FOR_ID(n_sequence_fields)
         STRUCT_FOR_ID(n_unnamed_fields)
         STRUCT_FOR_ID(name)
+        STRUCT_FOR_ID(name_from)
         STRUCT_FOR_ID(namespace_separator)
         STRUCT_FOR_ID(namespaces)
         STRUCT_FOR_ID(narg)
index 55c7c9e3194c7cb59d60c4cfa4412669b27da596..f7823d36ef819cbc2acefb49cab4335611465822 100644 (file)
@@ -987,6 +987,7 @@ extern "C" {
                 INIT_ID(n_sequence_fields), \
                 INIT_ID(n_unnamed_fields), \
                 INIT_ID(name), \
+                INIT_ID(name_from), \
                 INIT_ID(namespace_separator), \
                 INIT_ID(namespaces), \
                 INIT_ID(narg), \
@@ -2286,6 +2287,8 @@ _PyUnicode_InitStaticStrings(void) {
     PyUnicode_InternInPlace(&string);
     string = &_Py_ID(name);
     PyUnicode_InternInPlace(&string);
+    string = &_Py_ID(name_from);
+    PyUnicode_InternInPlace(&string);
     string = &_Py_ID(namespace_separator);
     PyUnicode_InternInPlace(&string);
     string = &_Py_ID(namespaces);
@@ -6505,6 +6508,10 @@ _PyStaticObjects_CheckRefcnt(void) {
         _PyObject_Dump((PyObject *)&_Py_ID(name));
         Py_FatalError("immortal object has less refcnt than expected _PyObject_IMMORTAL_REFCNT");
     };
+    if (Py_REFCNT((PyObject *)&_Py_ID(name_from)) < _PyObject_IMMORTAL_REFCNT) {
+        _PyObject_Dump((PyObject *)&_Py_ID(name_from));
+        Py_FatalError("immortal object has less refcnt than expected _PyObject_IMMORTAL_REFCNT");
+    };
     if (Py_REFCNT((PyObject *)&_Py_ID(namespace_separator)) < _PyObject_IMMORTAL_REFCNT) {
         _PyObject_Dump((PyObject *)&_Py_ID(namespace_separator));
         Py_FatalError("immortal object has less refcnt than expected _PyObject_IMMORTAL_REFCNT");
index 1f3307f822a5fc73c38e16a3611bec072590c1d4..0b37116cd682396c6c77b93457ea6014d44e7532 100644 (file)
@@ -140,9 +140,9 @@ class CFunctionCallsErrorMessages(unittest.TestCase):
                                itertools.product, 0, repeat=1, foo=2)
 
     def test_varargs15_kw(self):
-        msg = r"^ImportError\(\) takes at most 2 keyword arguments \(3 given\)$"
+        msg = r"^ImportError\(\) takes at most 3 keyword arguments \(4 given\)$"
         self.assertRaisesRegex(TypeError, msg,
-                               ImportError, 0, name=1, path=2, foo=3)
+                               ImportError, 0, name=1, path=2, name_from=3, foo=3)
 
     def test_varargs16_kw(self):
         msg = r"^min\(\) takes at most 2 keyword arguments \(3 given\)$"
index 2d17e0600650755c82b769663f473a1e8485a391..cf52d17ff8fa97ae1dd14bfd192163a4cc4fac93 100644 (file)
@@ -6,14 +6,20 @@ import linecache
 import sys
 import types
 import inspect
+import importlib
 import unittest
 import re
+import tempfile
+import random
+import string
 from test import support
+import shutil
 from test.support import (Error, captured_output, cpython_only, ALWAYS_EQ,
                           requires_debug_ranges, has_no_debug_ranges,
                           requires_subprocess)
 from test.support.os_helper import TESTFN, unlink
 from test.support.script_helper import assert_python_ok, assert_python_failure
+from test.support.import_helper import forget
 
 import json
 import textwrap
@@ -2985,6 +2991,122 @@ class SuggestionFormattingTestBase:
         self.assertIn("Did you mean", actual)
         self.assertIn("bluch", actual)
 
+    def make_module(self, code):
+        tmpdir = Path(tempfile.mkdtemp())
+        self.addCleanup(shutil.rmtree, tmpdir)
+
+        sys.path.append(str(tmpdir))
+        self.addCleanup(sys.path.pop)
+
+        mod_name = ''.join(random.choices(string.ascii_letters, k=16))
+        module = tmpdir / (mod_name + ".py")
+        module.write_text(code)
+
+        return mod_name
+
+    def get_import_from_suggestion(self, mod_dict, name):
+        modname = self.make_module(mod_dict)
+
+        def callable():
+            try:
+                exec(f"from {modname} import {name}")
+            except ImportError as e:
+                raise e from None
+            except Exception as e:
+                self.fail(f"Expected ImportError but got {type(e)}")
+        self.addCleanup(forget, modname)
+
+        result_lines = self.get_exception(
+            callable, slice_start=-1, slice_end=None
+        )
+        return result_lines[0]
+
+    def test_import_from_suggestions(self):
+        substitution = textwrap.dedent("""\
+            noise = more_noise = a = bc = None
+            blech = None
+        """)
+
+        elimination = textwrap.dedent("""
+            noise = more_noise = a = bc = None
+            blch = None
+        """)
+
+        addition = textwrap.dedent("""
+            noise = more_noise = a = bc = None
+            bluchin = None
+        """)
+
+        substitutionOverElimination = textwrap.dedent("""
+            blach = None
+            bluc = None
+        """)
+
+        substitutionOverAddition = textwrap.dedent("""
+            blach = None
+            bluchi = None
+        """)
+
+        eliminationOverAddition = textwrap.dedent("""
+            blucha = None
+            bluc = None
+        """)
+
+        caseChangeOverSubstitution = textwrap.dedent("""
+            Luch = None
+            fluch = None
+            BLuch = None
+        """)
+
+        for code, suggestion in [
+            (addition, "'bluchin'?"),
+            (substitution, "'blech'?"),
+            (elimination, "'blch'?"),
+            (addition, "'bluchin'?"),
+            (substitutionOverElimination, "'blach'?"),
+            (substitutionOverAddition, "'blach'?"),
+            (eliminationOverAddition, "'bluc'?"),
+            (caseChangeOverSubstitution, "'BLuch'?"),
+        ]:
+            actual = self.get_import_from_suggestion(code, 'bluch')
+            self.assertIn(suggestion, actual)
+
+    def test_import_from_suggestions_do_not_trigger_for_long_attributes(self):
+        code = "blech = None"
+
+        actual = self.get_suggestion(code, 'somethingverywrong')
+        self.assertNotIn("blech", actual)
+
+    def test_import_from_error_bad_suggestions_do_not_trigger_for_small_names(self):
+        code = "vvv = mom = w = id = pytho = None"
+
+        for name in ("b", "v", "m", "py"):
+            with self.subTest(name=name):
+                actual = self.get_import_from_suggestion(code, name)
+                self.assertNotIn("you mean", actual)
+                self.assertNotIn("vvv", actual)
+                self.assertNotIn("mom", actual)
+                self.assertNotIn("'id'", actual)
+                self.assertNotIn("'w'", actual)
+                self.assertNotIn("'pytho'", actual)
+
+    def test_import_from_suggestions_do_not_trigger_for_big_namespaces(self):
+        # A module with lots of names will not be considered for suggestions.
+        chunks = [f"index_{index} = " for index in range(200)]
+        chunks.append(" None")
+        code = " ".join(chunks)
+        actual = self.get_import_from_suggestion(code, 'bluch')
+        self.assertNotIn("blech", actual)
+
+    def test_import_from_error_with_bad_name(self):
+        def raise_attribute_error_with_bad_name():
+            raise ImportError(name=12, obj=23, name_from=11)
+
+        result_lines = self.get_exception(
+            raise_attribute_error_with_bad_name, slice_start=-1, slice_end=None
+        )
+        self.assertNotIn("?", result_lines[-1])
+
     def test_name_error_suggestions(self):
         def Substitution():
             noise = more_noise = a = bc = None
index bb7856a5142e0395b63edafdc7dcb6665170c040..6270100348a6a21098aa3d34c360b3b2e1957d33 100644 (file)
@@ -707,9 +707,16 @@ class TracebackException:
             self.offset = exc_value.offset
             self.end_offset = exc_value.end_offset
             self.msg = exc_value.msg
+        elif exc_type and issubclass(exc_type, ImportError) and \
+                getattr(exc_value, "name_from", None) is not None:
+            wrong_name = getattr(exc_value, "name_from", None)
+            suggestion = _compute_suggestion_error(exc_value, exc_traceback, wrong_name)
+            if suggestion:
+                self._str += f". Did you mean: '{suggestion}'?"
         elif exc_type and issubclass(exc_type, (NameError, AttributeError)) and \
                 getattr(exc_value, "name", None) is not None:
-            suggestion = _compute_suggestion_error(exc_value, exc_traceback)
+            wrong_name = getattr(exc_value, "name", None)
+            suggestion = _compute_suggestion_error(exc_value, exc_traceback, wrong_name)
             if suggestion:
                 self._str += f". Did you mean: '{suggestion}'?"
             if issubclass(exc_type, NameError):
@@ -1005,8 +1012,7 @@ def _substitution_cost(ch_a, ch_b):
     return _MOVE_COST
 
 
-def _compute_suggestion_error(exc_value, tb):
-    wrong_name = getattr(exc_value, "name", None)
+def _compute_suggestion_error(exc_value, tb, wrong_name):
     if wrong_name is None or not isinstance(wrong_name, str):
         return None
     if isinstance(exc_value, AttributeError):
@@ -1015,6 +1021,12 @@ def _compute_suggestion_error(exc_value, tb):
             d = dir(obj)
         except Exception:
             return None
+    elif isinstance(exc_value, ImportError):
+        try:
+            mod = __import__(exc_value.name)
+            d = dir(mod)
+        except Exception:
+            return None
     else:
         assert isinstance(exc_value, NameError)
         # find most recent frame
diff --git a/Misc/NEWS.d/next/Core and Builtins/2022-10-15-22-25-20.gh-issue-91058.Uo2kW-.rst b/Misc/NEWS.d/next/Core and Builtins/2022-10-15-22-25-20.gh-issue-91058.Uo2kW-.rst
new file mode 100644 (file)
index 0000000..042c7ce
--- /dev/null
@@ -0,0 +1,3 @@
+:exc:`ImportError` raised from failed ``from <module> import <name>`` now
+include suggestions for the value of ``<name>`` based on the available names
+in ``<module>``. Patch by Pablo Galindo
index 80e98bb4ffa43ada98d3b12b128a84abe42a053a..4b4f31a209b6690e14339943be5a4180fe4e9ee3 100644 (file)
@@ -1464,11 +1464,12 @@ SimpleExtendsException(PyExc_BaseException, KeyboardInterrupt,
 static int
 ImportError_init(PyImportErrorObject *self, PyObject *args, PyObject *kwds)
 {
-    static char *kwlist[] = {"name", "path", 0};
+    static char *kwlist[] = {"name", "path", "name_from", 0};
     PyObject *empty_tuple;
     PyObject *msg = NULL;
     PyObject *name = NULL;
     PyObject *path = NULL;
+    PyObject *name_from = NULL;
 
     if (BaseException_init((PyBaseExceptionObject *)self, args, NULL) == -1)
         return -1;
@@ -1476,8 +1477,8 @@ ImportError_init(PyImportErrorObject *self, PyObject *args, PyObject *kwds)
     empty_tuple = PyTuple_New(0);
     if (!empty_tuple)
         return -1;
-    if (!PyArg_ParseTupleAndKeywords(empty_tuple, kwds, "|$OO:ImportError", kwlist,
-                                     &name, &path)) {
+    if (!PyArg_ParseTupleAndKeywords(empty_tuple, kwds, "|$OOO:ImportError", kwlist,
+                                     &name, &path, &name_from)) {
         Py_DECREF(empty_tuple);
         return -1;
     }
@@ -1489,6 +1490,9 @@ ImportError_init(PyImportErrorObject *self, PyObject *args, PyObject *kwds)
     Py_XINCREF(path);
     Py_XSETREF(self->path, path);
 
+    Py_XINCREF(name_from);
+    Py_XSETREF(self->name_from, name_from);
+
     if (PyTuple_GET_SIZE(args) == 1) {
         msg = PyTuple_GET_ITEM(args, 0);
         Py_INCREF(msg);
@@ -1504,6 +1508,7 @@ ImportError_clear(PyImportErrorObject *self)
     Py_CLEAR(self->msg);
     Py_CLEAR(self->name);
     Py_CLEAR(self->path);
+    Py_CLEAR(self->name_from);
     return BaseException_clear((PyBaseExceptionObject *)self);
 }
 
@@ -1521,6 +1526,7 @@ ImportError_traverse(PyImportErrorObject *self, visitproc visit, void *arg)
     Py_VISIT(self->msg);
     Py_VISIT(self->name);
     Py_VISIT(self->path);
+    Py_VISIT(self->name_from);
     return BaseException_traverse((PyBaseExceptionObject *)self, visit, arg);
 }
 
@@ -1540,7 +1546,7 @@ static PyObject *
 ImportError_getstate(PyImportErrorObject *self)
 {
     PyObject *dict = ((PyBaseExceptionObject *)self)->dict;
-    if (self->name || self->path) {
+    if (self->name || self->path || self->name_from) {
         dict = dict ? PyDict_Copy(dict) : PyDict_New();
         if (dict == NULL)
             return NULL;
@@ -1552,6 +1558,10 @@ ImportError_getstate(PyImportErrorObject *self)
             Py_DECREF(dict);
             return NULL;
         }
+        if (self->name_from && PyDict_SetItem(dict, &_Py_ID(name_from), self->name_from) < 0) {
+            Py_DECREF(dict);
+            return NULL;
+        }
         return dict;
     }
     else if (dict) {
@@ -1588,6 +1598,8 @@ static PyMemberDef ImportError_members[] = {
         PyDoc_STR("module name")},
     {"path", T_OBJECT, offsetof(PyImportErrorObject, path), 0,
         PyDoc_STR("module path")},
+    {"name_from", T_OBJECT, offsetof(PyImportErrorObject, name_from), 0,
+        PyDoc_STR("name imported from module")},
     {NULL}  /* Sentinel */
 };
 
index fb8dd4811628d11c5e33da510569c1c291453b5f..35ce767710e146ebfd833cc6d0e87abe5518f797 100644 (file)
@@ -6900,7 +6900,7 @@ import_from(PyThreadState *tstate, PyObject *v, PyObject *name)
             name, pkgname_or_unknown
         );
         /* NULL checks for errmsg and pkgname done by PyErr_SetImportError. */
-        PyErr_SetImportError(errmsg, pkgname, NULL);
+        _PyErr_SetImportErrorWithNameFrom(errmsg, pkgname, NULL, name);
     }
     else {
         PyObject *spec = PyObject_GetAttr(v, &_Py_ID(__spec__));
@@ -6913,7 +6913,7 @@ import_from(PyThreadState *tstate, PyObject *v, PyObject *name)
 
         errmsg = PyUnicode_FromFormat(fmt, name, pkgname_or_unknown, pkgpath);
         /* NULL checks for errmsg and pkgname done by PyErr_SetImportError. */
-        PyErr_SetImportError(errmsg, pkgname, pkgpath);
+        _PyErr_SetImportErrorWithNameFrom(errmsg, pkgname, pkgpath, name);
     }
 
     Py_XDECREF(errmsg);
index 2aa748c60c370478a40ddf60d9bdc943b5db8e58..fc3e4689209456cd0da232feb53b7c6b5f8c537f 100644 (file)
@@ -975,9 +975,10 @@ PyObject *PyErr_SetFromWindowsErrWithFilename(
 
 #endif /* MS_WINDOWS */
 
-PyObject *
-PyErr_SetImportErrorSubclass(PyObject *exception, PyObject *msg,
-    PyObject *name, PyObject *path)
+static PyObject *
+_PyErr_SetImportErrorSubclassWithNameFrom(
+    PyObject *exception, PyObject *msg,
+    PyObject *name, PyObject *path, PyObject* from_name)
 {
     PyThreadState *tstate = _PyThreadState_GET();
     int issubclass;
@@ -1005,6 +1006,10 @@ PyErr_SetImportErrorSubclass(PyObject *exception, PyObject *msg,
     if (path == NULL) {
         path = Py_None;
     }
+    if (from_name == NULL) {
+        from_name = Py_None;
+    }
+
 
     kwargs = PyDict_New();
     if (kwargs == NULL) {
@@ -1016,6 +1021,9 @@ PyErr_SetImportErrorSubclass(PyObject *exception, PyObject *msg,
     if (PyDict_SetItemString(kwargs, "path", path) < 0) {
         goto done;
     }
+    if (PyDict_SetItemString(kwargs, "name_from", from_name) < 0) {
+        goto done;
+    }
 
     error = PyObject_VectorcallDict(exception, &msg, 1, kwargs);
     if (error != NULL) {
@@ -1028,6 +1036,20 @@ done:
     return NULL;
 }
 
+
+PyObject *
+PyErr_SetImportErrorSubclass(PyObject *exception, PyObject *msg,
+    PyObject *name, PyObject *path)
+{
+    return _PyErr_SetImportErrorSubclassWithNameFrom(exception, msg, name, path, NULL);
+}
+
+PyObject *
+_PyErr_SetImportErrorWithNameFrom(PyObject *msg, PyObject *name, PyObject *path, PyObject* from_name)
+{
+    return _PyErr_SetImportErrorSubclassWithNameFrom(PyExc_ImportError, msg, name, path, from_name);
+}
+
 PyObject *
 PyErr_SetImportError(PyObject *msg, PyObject *name, PyObject *path)
 {
index 89b86f78bc7ac85defeedc7d92864544995db80b..82376b6cd985a3fe6c53ced1fcb1138629d5cd4e 100644 (file)
@@ -312,6 +312,38 @@ offer_suggestions_for_name_error(PyNameErrorObject *exc)
     return result;
 }
 
+static PyObject *
+offer_suggestions_for_import_error(PyImportErrorObject *exc)
+{
+    PyObject *mod_name = exc->name; // borrowed reference
+    PyObject *name = exc->name_from; // borrowed reference
+    if (name == NULL || mod_name == NULL || name == Py_None ||
+        !PyUnicode_CheckExact(name) || !PyUnicode_CheckExact(mod_name)) {
+        return NULL;
+    }
+
+    PyObject* mod = PyImport_GetModule(mod_name);
+    if (mod == NULL) {
+        return NULL;
+    }
+
+    PyObject *dir = PyObject_Dir(mod);
+    Py_DECREF(mod);
+    if (dir == NULL) {
+        return NULL;
+    }
+
+    PyObject *suggestion = calculate_suggestions(dir, name);
+    Py_DECREF(dir);
+    if (!suggestion) {
+        return NULL;
+    }
+
+    PyObject* result = PyUnicode_FromFormat(". Did you mean: %R?", suggestion);
+    Py_DECREF(suggestion);
+    return result;
+}
+
 // Offer suggestions for a given exception. Returns a python string object containing the
 // suggestions. This function returns NULL if no suggestion was found or if an exception happened,
 // users must call PyErr_Occurred() to disambiguate.
@@ -324,6 +356,8 @@ _Py_Offer_Suggestions(PyObject *exception)
         result = offer_suggestions_for_attribute_error((PyAttributeErrorObject *) exception);
     } else if (Py_IS_TYPE(exception, (PyTypeObject*)PyExc_NameError)) {
         result = offer_suggestions_for_name_error((PyNameErrorObject *) exception);
+    } else if (Py_IS_TYPE(exception, (PyTypeObject*)PyExc_ImportError)) {
+        result = offer_suggestions_for_import_error((PyImportErrorObject *) exception);
     }
     return result;
 }