8000 gh-91058: Add error suggestions to 'import from' import errors (#98305) · python/cpython@7cfbb49 · GitHub
[go: up one dir, main page]

Skip to content

Commit 7cfbb49

Browse files
authored
gh-91058: Add error suggestions to 'import from' import errors (#98305)
1 parent 1f737ed commit 7cfbb49

File tree

11 files changed

+235
-14
lines changed

11 files changed

+235
-14
lines changed

Include/cpython/pyerrors.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ typedef struct {
3737
PyObject *msg;
3838
PyObject *name;
3939
PyObject *path;
40+
PyObject *name_from;
4041
} PyImportErrorObject;
4142

4243
typedef struct {
@@ -176,4 +177,11 @@ PyAPI_FUNC(void) _Py_NO_RETURN _Py_FatalErrorFormat(
176177
const char *format,
177178
...);
178179

180+
extern PyObject *_PyErr_SetImportErrorWithNameFrom(
181+
PyObject *,
182+
PyObject *,
183+
PyObject *,
184+
PyObject *);
185+
186+
179187
#define Py_FatalError(message) _Py_FatalErrorFunc(__func__, (message))

Include/internal/pycore_global_strings.h

Lines changed: 1 addition & 0 deletions
< 67E6 tbody>
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,7 @@ struct _Py_global_strings {
478478
STRUCT_FOR_ID(n_sequence_fields)
479479
STRUCT_FOR_ID(n_unnamed_fields)
480480
STRUCT_FOR_ID(name)
481+
STRUCT_FOR_ID(name_from)
481482
STRUCT_FOR_ID(namespace_separator)
482483
STRUCT_FOR_ID(namespaces)
483484
STRUCT_FOR_ID(narg)

Include/internal/pycore_runtime_init_generated.h

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

Lib/test/test_call.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,9 @@ def test_varargs14_kw(self):
140140
itertools.product, 0, repeat=1, foo=2)
141141

142142
def test_varargs15_kw(self):
143-
msg = r"^ImportError\(\) takes at most 2 keyword arguments \(3 given\)$"
143+
msg = r"^ImportError\(\) takes at most 3 keyword arguments \(4 given\)$"
144144
self.assertRaisesRegex(TypeError, msg,
145-
ImportError, 0, name=1, path=2, foo=3)
145+
ImportError, 0, name=1, path=2, name_from=3, foo=3)
146146

147147
def test_varargs16_kw(self):
148148
msg = r"^min\(\) takes at most 2 keyword arguments \(3 given\)$"

Lib/test/test_traceback.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,20 @@
66
import sys
77
import types
88
import inspect
9+
import importlib
910
import unittest
1011
import re
12+
import tempfile
13+
import random
14+
import string
1115
from test import support
16+
import shutil
1217
from test.support import (Error, captured_output, cpython_only, ALWAYS_EQ,
1318
requires_debug_ranges, has_no_debug_ranges,
1419
requires_subprocess)
1520
from test.support.os_helper import TESTFN, unlink
1621
from test.support.script_helper import assert_python_ok, assert_python_failure
22+
from test.support.import_helper import forget
1723

1824
import json
1925
import textwrap
@@ -2985,6 +2991,122 @@ def __getattribute__(self, attr):
29852991
self.assertIn("Did you mean", actual)
29862992
self.assertIn("bluch", actual)
29872993

2994+
def make_module(self, code):
2995+
tmpdir = Path(tempfile.mkdtemp())
2996+
self.addCleanup(shutil.rmtree, tmpdir)
2997+
2998+
sys.path.append(str(tmpdir))
2999+
self.addCleanup(sys.path.pop)
3000+
3001+
mod_name = ''.join(random.choices(string.ascii_letters, k=16))
3002+
module = tmpdir / (mod_name + ".py")
3003+
module.write_text(code)
3004+
3005+
return mod_name
3006+
3007+
def get_import_from_suggestion(self, mod_dict, name):
3008+
modname = self.make_module(mod_dict)
3009+
3010+
def callable():
3011+
try:
3012+
exec(f"from {modname} import {name}")
3013+
except ImportError as e:
3014+
raise e from None
3015+
except Exception as e:
3016+
self.fail(f"Expected ImportError but got {type(e)}")
3017+
self.addCleanup(forget, modname)
3018+
3019+
result_lines = self.get_exception(
3020+
callable, slice_start=-1, slice_end=None
3021+
)
3022+
return result_lines[0]
3023+
3024+
def test_import_from_suggestions(self):
3025+
substitution = textwrap.dedent("""\
3026+
noise = more_noise = a = bc = None
3027+
blech = None
3028+
""")
3029+
3030+
elimination = textwrap.dedent("""
3031+
noise = more_noise = a = bc = None
3032+
blch = None
3033+
""")
3034+
3035+
addition = textwrap.dedent("""
3036+
noise = more_noise = a = bc = None
3037+
bluchin = None
3038+
""")
3039+
3040+
substitutionOverElimination = textwrap.dedent("""
3041+
blach = None
3042+
bluc = None
3043+
""")
3044+
3045+
substitutionOverAddition = textwrap.dedent("""
3046+
blach = None
3047+
bluchi = None
3048+
""")
3049+
3050+
eliminationOverAddition = textwrap.dedent("""
3051+
blucha = None
3052+
bluc = None
3053+
""")
3054+
3055+
caseChangeOverSubstitution = textwrap.dedent("""
3056+
Luch = None
3057+
fluch = None
3058+
BLuch = None
3059+
""")
3060+
3061+
for code, suggestion in [
3062+
(addition, "'bluchin'?"),
3063+
(substitution, "'blech'?"),
3064+
(elimination, "'blch'?"),
3065+
(addition, "'bluchin'?"),
3066+
(substitutionOverElimination, "'blach'?"),
3067+
(substitutionOverAddition, "'blach'?"),
3068+
(eliminationOverAddition, "'bluc'?"),
3069+
(caseChangeOverSubstitution, "'BLuch'?"),
3070+
]:
3071+
actual = self.get_import_from_suggestion(code, 'bluch')
3072+
self.assertIn(suggestion, actual)
3073+
3074+
def test_import_from_suggestions_do_not_trigger_for_long_attributes(self):
3075+
code = "blech = None"
3076+
3077+
actual = self.get_suggestion(code, 'somethingverywrong')
3078+
self.assertNotIn("blech", actual)
3079+
3080+
def test_import_from_error_bad_suggestions_do_not_trigger_for_small_names(self):
3081+
code = "vvv = mom = w = id = pytho = None"
3082+
3083+
for name in ("b", "v", "m", "py"):
3084+
with self.subTest(name=name):
3085+
actual = self.get_import_from_sugg F438 estion(code, name)
3086+
self.assertNotIn("you mean", actual)
3087+
self.assertNotIn("vvv", actual)
3088+
self.assertNotIn("mom", actual)
3089+
self.assertNotIn("'id'", actual)
3090+
self.assertNotIn("'w'", actual)
3091+
self.assertNotIn("'pytho'", actual)
3092+
3093+
def test_import_from_suggestions_do_not_trigger_for_big_namespaces(self):
3094+
# A module with lots of names will not be considered for suggestions.
3095+
chunks = [f"index_{index} = " for index in range(200)]
3096+
chunks.append(" None")
3097+
code = " ".join(chunks)
3098+
actual = self.get_import_from_suggestion(code, 'bluch')
3099+
self.assertNotIn("blech", actual)
3100+
3101+
def test_import_from_error_with_bad_name(self):
3102+
def raise_attribute_error_with_bad_name():
3103+
raise ImportError(name=12, obj=23, name_from=11)
3104+
3105+
result_lines = self.get_exception(
3106+
raise_attribute_error_with_bad_name, slice_start=-1, slice_end=None
3107+
)
3108+
self.assertNotIn("?", result_lines[-1])
3109+
29883110
def test_name_error_suggestions(self):
29893111
def Substitution():
29903112
noise = more_noise = a = bc = None

Lib/traceback.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -707,9 +707,16 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None,
707707
self.offset = exc_value.offset
708708
self.end_offset = exc_value.end_offset
709709
self.msg = exc_value.msg
710+
elif exc_type and issubclass(exc_type, ImportError) and \
711+
getattr(exc_value, "name_from", None) is not None:
712+
wrong_name = getattr(exc_value, "name_from", None)
713+
suggestion = _compute_suggestion_error(exc_value, exc_traceback, wrong_name)
714+
if suggestion:
715+
self._str += f". Did you mean: '{suggestion}'?"
710716
elif exc_type and issubclass(exc_type, (NameError, AttributeError)) and \
711717
getattr(exc_value, "name", None) is not None:
712-
suggestion = _compute_suggestion_error(exc_value, exc_traceback)
718+
wrong_name = getattr(exc_value, "name", None)
719+
suggestion = _compute_suggestion_error(exc_value, exc_traceback, wrong_name)
713720
if suggestion:
714721
self._str += f". Did you mean: '{suggestion}'?"
715722
if issubclass(exc_type, NameError):
@@ -1005,8 +1012,7 @@ def _substitution_cost(ch_a, ch_b):
10051012
return _MOVE_COST
10061013

10071014

1008-
def _compute_suggestion_error(exc_value, tb):
1009-
wrong_name = getattr(exc_value, "name", None)
1015+
def _compute_suggestion_error(exc_value, tb, wrong_name):
10101016
if wrong_name is None or not isinstance(wrong_name, str):
10111017
return None
10121018
if isinstance(exc_value, AttributeError):
@@ -1015,6 +1021,12 @@ def _compute_suggestion_error(exc_value, tb):
10151021
d = dir(obj)
10161022
except Exception:
10171023
return None
1024+
elif isinstance(exc_value, ImportError):
1025+
try:
1026+
mod = __import__(exc_value.name)
1027+
d = dir(mod)
1028+
except Exception:
1029+
return None
10181030
else:
10191031
assert isinstance(exc_value, NameError)
10201032
# find most recent frame
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
:exc:`ImportError` raised from failed ``from <module> import <name>`` now
2+
include suggestions for the value of ``<name>`` based on the available names
3+
in ``<module>``. Patch by Pablo Galindo

Objects/exceptions.c

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1464,20 +1464,21 @@ SimpleExtendsException(PyExc_BaseException, KeyboardInterrupt,
14641464
static int
14651465
ImportError_init(PyImportErrorObject *self, PyObject *args, PyObject *kwds)
14661466
{
1467-
static char *kwlist[] = {"name", "path", 0};
1467+
static char *kwlist[] = {"name", "path", "name_from", 0};
14681468
PyObject *empty_tuple;
14691469
PyObject *msg = NULL;
14701470
PyObject *name = NULL;
14711471
PyObject *path = NULL;
1472+
PyObject *name_from = NULL;
14721473

14731474
if (BaseException_init((PyBaseExceptionObject *)self, args, NULL) == -1)
14741475
return -1;
14751476

14761477
empty_tuple = PyTuple_New(0);
14771478
if (!empty_tuple)
14781479
return -1;
1479-
if (!PyArg_ParseTupleAndKeywords(empty_tuple, kwds, "|$OO:ImportError", kwlist,
1480-
&name, &path)) {
1480+
if (!PyArg_ParseTupleAndKeywords(empty_tuple, kwds, "|$OOO:ImportError", kwlist,
1481+
&name, &path, &name_from)) {
14811482
Py_DECREF(empty_tuple);
14821483
return -1;
14831484
}
@@ -1489,6 +1490,9 @@ ImportError_init(PyImportErrorObject *self, PyObject *args, PyObject *kwds)
14891490
Py_XINCREF(path);
14901491
Py_XSETREF(self->path, path);
14911492

1493+
Py_XINCREF(name_from);
1494+
Py_XSETREF(self->name_from, name_from);
1495+
14921496
if (PyTuple_GET_SIZE(args) == 1) {
14931497
msg = PyTuple_GET_ITEM(args, 0);
14941498
Py_INCREF(msg);
@@ -1504,6 +1508,7 @@ ImportError_clear(PyImportErrorObject *self)
15041508
Py_CLEAR(self->msg);
15051509
Py_CLEAR(self->name);
15061510
Py_CLEAR(self->path);
1511+
Py_CLEAR(self->name_from);
15071512
return BaseException_clear((PyBaseExceptionObject *)self);
15081513
}
15091514

@@ -1521,6 +1526,7 @@ ImportError_traverse(PyImportErrorObject *self, visitproc visit, void *arg)
15211526
Py_VISIT(self->msg);
15221527
Py_VISIT(self->name);
15231528
Py_VISIT(self->path);
1529+
Py_VISIT(self->name_from);
15241530
return BaseException_traverse((PyBaseExceptionObject *)self, visit, arg);
15251531
}
15261532

@@ -1540,7 +1546,7 @@ static PyObject *
15401546
ImportError_getstate(PyImportErrorObject *self)
15411547
{
15421548
PyObject *dict = ((PyBaseExceptionObject *)self)->dict;
1543-
if (self->name || self->path) {
1549+
if (self->name || self->path || self->name_from) {
15441550
dict = dict ? PyDict_Copy(dict) : PyDict_New();
15451551
if (dict == NULL)
15461552
return NULL;
@@ -1552,6 +1558,10 @@ ImportError_getstate(PyImportErrorObject *self)
15521558
Py_DECREF(dict);
15531559
return NULL;
15541560
}
1561+
if (self->name_from && PyDict_SetItem(dict, &_Py_ID(name_from), self->name_from) < 0) {
1562+
Py_DECREF(dict);
1563+
return NULL;
1564+
}
15551565
return dict;
15561566
}
15571567
else if (dict) {
@@ -1588,6 +1598,8 @@ static PyMemberDef ImportError_members[] = {
15881598
PyDoc_STR("module name")},
15891599
{"path", T_OBJECT, offsetof(PyImportErrorObject, path), 0,
15901600
PyDoc_STR("module path")},
1601+
{"name_from", T_OBJECT, offsetof(PyImportErrorObject, name_from), 0,
1602+
PyDoc_STR("name imported from module")},
15911603
{NULL} /* Sentinel */
15921604
};
15931605

Python/ceval.c

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6900,7 +6900,7 @@ import_from(PyThreadState *tstate, PyObject *v, PyObject *name)
69006900
name, pkgname_or_unknown
69016901
);
69026902
/* NULL checks for errmsg and pkgname done by PyErr_SetImportError. */
6903-
PyErr_SetImportError(errmsg, pkgname, NULL);
6903+
_PyErr_SetImportErrorWithNameFrom(errmsg, pkgname, NULL, name);
69046904
}
69056905
else {
69066906
PyObject *spec = PyObject_GetAttr(v, &_Py_ID(__spec__));
@@ -6913,7 +6913,7 @@ import_from(PyThreadState *tstate, PyObject *v, PyObject *name)
69136913

69146914
errmsg = PyUnicode_FromFormat(fmt, name, pkgname_or_unknown, pkgpath);
69156915
/* NULL checks for errmsg and pkgname done by PyErr_SetImportError. */
6916-
PyErr_SetImportError(errmsg, pkgname, pkgpath);
6916+
_PyErr_SetImportErrorWithNameFrom(errmsg, pkgname, pkgpath, name);
69176917
}
69186918

69196919
Py_XDECREF(errmsg);

0 commit comments

Comments
 (0)
0