Skip to content

Commit 2d7b753

Browse files
committed
gh-159672: Preserve error state during keyword replay
1 parent 14f3574 commit 2d7b753

3 files changed

Lines changed: 136 additions & 35 deletions

File tree

‎Lib/test/test_traceback.py‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1912,6 +1912,74 @@ def test_parser_suggestions_need_no_formatting_work(self):
19121912
self.assertEqual((exc.lineno, exc.offset), (str(lineno), offset))
19131913
self.assertEqual(error.args, original)
19141914

1915+
@cpython_only
1916+
def test_public_fields_override_keyword_metadata(self):
1917+
changes = dict(filename="changed.py", msg="custom message", text="<redacted>\n",
1918+
lineno=101, offset=2, end_lineno=102, end_offset=3)
1919+
for field, value in changes.items():
1920+
with self.subTest(field=field):
1921+
with self.assertRaises(SyntaxError) as caught:
1922+
compile("impor math", "<test>", "exec")
1923+
error = caught.exception
1924+
setattr(error, field, value)
1925+
error.args = (error.msg, (error.filename, error.lineno, error.offset,
1926+
error.text, error.end_lineno, error.end_offset))
1927+
formatted = traceback.TracebackException.from_exception(error)
1928+
expected = str(value) if field in ("lineno", "end_lineno") else value
1929+
self.assertEqual(getattr(formatted, field), expected)
1930+
self.assertNotIn("Did you mean", ''.join(formatted.format_exception_only()))
1931+
1932+
@cpython_only
1933+
def test_keyword_metadata_avoids_custom_methods(self):
1934+
class CustomString(str):
1935+
def __eq__(self, other):
1936+
raise AssertionError("metadata called a custom method")
1937+
1938+
def __str__(self):
1939+
raise AssertionError("metadata called a custom method")
1940+
1941+
with self.assertRaises(SyntaxError) as caught:
1942+
compile("impor math", "<test>", "exec")
1943+
error = caught.exception
1944+
suggestion, location, original = error._metadata
1945+
for metadata in ((), (CustomString(suggestion), location, original),
1946+
(suggestion, (CustomString(location[0]), *location[1:]), original),
1947+
(suggestion, location, (CustomString(original[0]), *original[1:]))):
1948+
with self.subTest(metadata_type=type(metadata[0]) if metadata else None):
1949+
error._metadata = metadata
1950+
formatted = ''.join(traceback.format_exception_only(error))
1951+
self.assertNotIn("Did you mean", formatted)
1952+
1953+
def test_keyword_hint_in_interactive_interpreter(self):
1954+
import code
1955+
with captured_output("stderr") as output:
1956+
code.InteractiveInterpreter().runsource("impor math", filename="user.py")
1957+
self.assertIn("Did you mean 'import'", output.getvalue())
1958+
1959+
@requires_subprocess()
1960+
def test_keyword_hint_does_not_reopen_source(self):
1961+
with tempfile.TemporaryDirectory() as script_dir:
1962+
make_script(script_dir, "sitecustomize", textwrap.dedent('''
1963+
import sys
1964+
def audit(event, args):
1965+
if event == "open" and isinstance(args[0], str) and args[0].endswith("script.py"):
1966+
print("source-open")
1967+
sys.addaudithook(audit)
1968+
'''))
1969+
counts = []
1970+
cases = [("= math", None), ("impor math", "import"),
1971+
("result = (one\n adn two)", "and")]
1972+
for source, keyword in cases:
1973+
script = make_script(script_dir, "script", source)
1974+
_, output, error = assert_python_failure(script, PYTHONPATH=script_dir)
1975+
counts.append(output.count(b"source-open"))
1976+
if keyword is not None:
1977+
self.assertIn(f"Did you mean '{keyword}'".encode(), error)
1978+
if keyword == "and":
1979+
self.assertIn(b"line 1\n result = (one\n", error)
1980+
self.assertGreater(counts[0], 0)
1981+
self.assertEqual(counts, [counts[0]] * len(cases))
1982+
19151983
@cpython_only
19161984
def test_keyword_trials_do_not_repeat_warnings(self):
19171985
import warnings

‎Lib/traceback.py‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1086,6 +1086,27 @@ def emit(self, text_gen, margin_char=None):
10861086
yield textwrap.indent(text, indent_str, lambda line: True)
10871087

10881088

1089+
def _syntax_error_metadata_matches(metadata, fields):
1090+
if type(metadata) is not tuple or len(metadata) != 3:
1091+
return False
1092+
suggestion, location, original = metadata
1093+
if type(suggestion) is not str or type(original) is not tuple:
1094+
return False
1095+
if len(original) != len(fields):
1096+
return False
1097+
if location is not None:
1098+
if type(location) is not tuple or len(location) != 5:
1099+
return False
1100+
if not all(type(value) is expected for value, expected in
1101+
zip(location, (str, int, int, int, int))):
1102+
return False
1103+
# Public fields can change independently of args. Exact built-in types
1104+
# keep this comparison from calling user-defined equality methods.
1105+
return (all(type(value) is str or type(value) is int or value is None
1106+
for value in fields + original)
1107+
and fields == original)
1108+
1109+
10891110
class TracebackException:
10901111
"""An exception ready for rendering.
10911112
@@ -1179,11 +1200,15 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None,
11791200
self.msg = exc_value.msg
11801201
self._is_syntax_error = True
11811202
metadata = getattr(exc_value, "_metadata", None)
1182-
if isinstance(metadata, tuple) and len(metadata) == 6:
1183-
(suggestion, self.text, lineno, self.offset,
1184-
end_lineno, self.end_offset) = metadata
1185-
self.lineno = str(lineno)
1186-
self.end_lineno = str(end_lineno)
1203+
fields = (self.filename, self.msg, self.text, lno, self.offset,
1204+
end_lno, self.end_offset)
1205+
if _syntax_error_metadata_matches(metadata, fields):
1206+
suggestion, location, original = metadata
1207+
if location is not None:
1208+
(self.text, lineno, self.offset,
1209+
end_lineno, self.end_offset) = location
1210+
self.lineno = str(lineno)
1211+
self.end_lineno = str(end_lineno)
11871212
self.msg = f"invalid syntax. Did you mean '{suggestion}'?"
11881213
elif exc_type and issubclass(exc_type, ImportError) and \
11891214
getattr(exc_value, "name_from", None) is not None:

‎Parser/pegen.c‎

Lines changed: 38 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1113,9 +1113,6 @@ add_keyword_typo(KeywordTypo *candidates, int *count, int mark,
11131113
if (word == NULL) {
11141114
return -1;
11151115
}
1116-
if (PyUnicode_GET_LENGTH(name) <= 2) {
1117-
max_cost = Py_MIN(max_cost, 2);
1118-
}
11191116
Py_ssize_t cost = _Py_UTF8_Edit_Cost(name, word, max_cost);
11201117
// Adjacent transpositions are one spelling error. The shared Levenshtein
11211118
// distance counts them as two substitutions.
@@ -1186,17 +1183,20 @@ keyword_trial_accepts(Parser *p, const KeywordTypo *candidate)
11861183
static int
11871184
try_keyword_typo(Parser *p, const KeywordTypo *candidate, KeywordTrial *state)
11881185
{
1186+
PyArena *arena = _PyArena_New();
1187+
if (arena == NULL) {
1188+
return -1;
1189+
}
11891190
// Token text belongs to the original arena. Token headers and memo entries
11901191
// must be private: even a failed trial can memoize nodes containing its edit.
1191-
Token *tokens = PyMem_Malloc(p->fill * sizeof(*tokens));
1192-
Token **index = PyMem_Malloc(p->fill * sizeof(*index));
1192+
Token *tokens = _PyArena_Malloc(arena, p->fill * sizeof(*tokens));
1193+
Token **index = _PyArena_Malloc(arena, p->fill * sizeof(*index));
11931194
Parser trial = *p;
11941195
trial.identifier_cache = p->identifier_cache == NULL ? NULL :
11951196
PyMem_Malloc(IDENTIFIER_CACHE_SIZE * sizeof(*trial.identifier_cache));
1196-
PyArena *arena = _PyArena_New();
11971197
PyObject *replacement = PyBytes_FromString(candidate->keyword);
11981198
int accepted = -1;
1199-
if (tokens == NULL || index == NULL || arena == NULL || replacement == NULL ||
1199+
if (tokens == NULL || index == NULL || replacement == NULL ||
12001200
(p->identifier_cache != NULL && trial.identifier_cache == NULL))
12011201
{
12021202
goto done;
@@ -1240,14 +1240,10 @@ try_keyword_typo(Parser *p, const KeywordTypo *candidate, KeywordTrial *state)
12401240
accepted = keyword_trial_accepts(&trial, candidate);
12411241
done:
12421242
// No Parser_Free: the trial borrows the tokenizer and type-ignore comments,
1243-
// and its token headers are a single allocation.
1243+
// and its token arrays belong to the arena.
12441244
PyMem_Free(trial.identifier_cache);
1245-
if (arena != NULL) {
1246-
_PyArena_Free(arena);
1247-
}
1245+
_PyArena_Free(arena);
12481246
Py_XDECREF(replacement);
1249-
PyMem_Free(index);
1250-
PyMem_Free(tokens);
12511247
return PyErr_Occurred() ? -1 : accepted;
12521248
}
12531249

@@ -1358,7 +1354,8 @@ collect_keyword_typos(Parser *p, PySyntaxErrorObject *error, KeywordTypo *candid
13581354
// Preserve missing-comma diagnostics and correctly spelled keywords
13591355
// unless one edit repairs the input. For keywords, only substitute a
13601356
// character: deleting one from "for" must not suggest "or".
1361-
Py_ssize_t max_cost = p->missing_comma || is_keyword ? 2 : 4;
1357+
Py_ssize_t max_cost =
1358+
p->missing_comma || is_keyword || PyUnicode_GET_LENGTH(name) <= 2 ? 2 : 4;
13621359
int result = 0;
13631360
for (int length = 0; length < p->n_keyword_lists && result == 0; length++) {
13641361
if (is_keyword && length != text_length) {
@@ -1385,32 +1382,43 @@ collect_keyword_typos(Parser *p, PySyntaxErrorObject *error, KeywordTypo *candid
13851382
}
13861383

13871384
static PyObject *
1388-
build_keyword_typo_metadata(Parser *p, const KeywordTypo *candidate)
1385+
build_keyword_typo_metadata(Parser *p, PySyntaxErrorObject *error,
1386+
const KeywordTypo *candidate)
13891387
{
13901388
Token *token = p->tokens[candidate->mark];
1391-
PyObject *line;
1389+
PyObject *line = NULL;
13921390
if (_PyTokenizer_RetainedSource(p->tok) != NULL) {
13931391
Py_ssize_t size;
13941392
const char *text = _PyTokenizer_LineView(p->tok, token->lineno, &size);
13951393
line = PyUnicode_DecodeUTF8(text, size, "replace");
13961394
}
1397-
else {
1398-
_PyTokenizer_Info info = _PyTokenizer_GetInfo(p->tok);
1399-
line = _PyErr_ProgramDecodedTextObject(
1400-
info.filename, token->lineno, info.encoding);
1395+
else if (PyLong_AsLong(error->lineno) == token->lineno &&
1396+
error->text != NULL && PyUnicode_CheckExact(error->text)) {
1397+
// Reuse the error's line without another file access or codec lookup.
1398+
line = Py_NewRef(error->text);
14011399
}
1402-
if (line == NULL) {
1400+
if (line == NULL && PyErr_Occurred()) {
14031401
return NULL;
14041402
}
1405-
Py_ssize_t start = _PyPegen_byte_offset_to_character_offset(line, token->col_offset);
1406-
Py_ssize_t end = _PyPegen_byte_offset_to_character_offset(line, token->end_col_offset);
1407-
PyObject *metadata = NULL;
1408-
if (start >= 0 && end >= 0) {
1409-
metadata = Py_BuildValue("(sOinin)", candidate->keyword, line,
1410-
token->lineno, start + 1,
1411-
token->end_lineno, end + 1);
1403+
PyObject *location = NULL;
1404+
if (line != NULL) {
1405+
Py_ssize_t start = _PyPegen_byte_offset_to_character_offset(line, token->col_offset);
1406+
Py_ssize_t end = _PyPegen_byte_offset_to_character_offset(line, token->end_col_offset);
1407+
if (start >= 0 && end >= 0) {
1408+
location = Py_BuildValue("(Oinin)", line, token->lineno, start + 1,
1409+
token->end_lineno, end + 1);
1410+
}
1411+
Py_DECREF(line);
1412+
if (location == NULL) {
1413+
return NULL;
1414+
}
14121415
}
1413-
Py_DECREF(line);
1416+
// Without the typo's source line, retain the original error location.
1417+
PyObject *metadata = Py_BuildValue(
1418+
"(sO(OOOOOOO))", candidate->keyword, location != NULL ? location : Py_None,
1419+
error->filename, error->msg, error->text, error->lineno, error->offset,
1420+
error->end_lineno, error->end_offset);
1421+
Py_XDECREF(location);
14141422
return metadata;
14151423
}
14161424

@@ -1451,7 +1459,7 @@ keyword_typo_metadata(Parser *p, PySyntaxErrorObject *error)
14511459
return NULL;
14521460
}
14531461
if (accepted) {
1454-
return build_keyword_typo_metadata(p, &candidates[i]);
1462+
return build_keyword_typo_metadata(p, error, &candidates[i]);
14551463
}
14561464
}
14571465
return NULL;

0 commit comments

Comments
 (0)