Skip to content

Commit cdef8e4

Browse files
committed
gh-159672: Preserve error state during keyword replay
1 parent 80cbec8 commit cdef8e4

4 files changed

Lines changed: 157 additions & 67 deletions

File tree

‎Lib/test/test_traceback.py‎

Lines changed: 87 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1833,6 +1833,11 @@ class TestKeywordTypoSuggestions(unittest.TestCase):
18331833
("void f():", "def"),
18341834
]
18351835

1836+
def get_syntax_error(self, source, mode="exec"):
1837+
with self.assertRaises(SyntaxError) as caught:
1838+
compile(source, "<test>", mode)
1839+
return caught.exception
1840+
18361841
def test_keyword_suggestions_from_file(self):
18371842
with tempfile.TemporaryDirectory() as script_dir:
18381843
for i, (code, expected_kw) in enumerate(self.TYPO_CASES):
@@ -1856,9 +1861,8 @@ def test_keyword_suggestions_from_single_input(self):
18561861
for source, keyword in self.TYPO_CASES:
18571862
for ending in ('', '\n'):
18581863
with self.subTest(source=source, ending=ending):
1859-
with self.assertRaises(SyntaxError) as caught:
1860-
compile(source + ending, "<test>", "single")
1861-
self.assertEqual(caught.exception._metadata[0], keyword)
1864+
error = self.get_syntax_error(source + ending, "single")
1865+
self.assertEqual(error._metadata[0], keyword)
18621866

18631867
def test_no_keyword_suggestion_for_comma_errors(self):
18641868
# When the parser identifies a missing comma, don't suggest
@@ -1897,9 +1901,7 @@ def test_parser_suggestions_need_no_formatting_work(self):
18971901
]
18981902
for source, keyword, lineno, offset in cases:
18991903
with self.subTest(source=source):
1900-
with self.assertRaises(SyntaxError) as caught:
1901-
compile(source, "<test>", "exec")
1902-
error = caught.exception
1904+
error = self.get_syntax_error(source)
19031905
original = error.args
19041906
with (unittest.mock.patch("builtins.compile") as compile_mock,
19051907
unittest.mock.patch("builtins.open") as open_mock):
@@ -1912,28 +1914,88 @@ def test_parser_suggestions_need_no_formatting_work(self):
19121914
self.assertEqual((exc.lineno, exc.offset), (str(lineno), offset))
19131915
self.assertEqual(error.args, original)
19141916

1917+
@cpython_only
1918+
def test_public_fields_override_keyword_metadata(self):
1919+
changes = dict(filename="changed.py", msg="custom message", text="<redacted>\n",
1920+
lineno=101, offset=2, end_lineno=102, end_offset=3)
1921+
for field, value in changes.items():
1922+
with self.subTest(field=field):
1923+
error = self.get_syntax_error("impor math")
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+
error = self.get_syntax_error("impor math")
1942+
suggestion, location, original = error._metadata
1943+
for metadata in ((), (CustomString(suggestion), location, original),
1944+
(suggestion, (CustomString(location[0]), *location[1:]), original),
1945+
(suggestion, location, (CustomString(original[0]), *original[1:]))):
1946+
with self.subTest(metadata_type=type(metadata[0]) if metadata else None):
1947+
error._metadata = metadata
1948+
formatted = ''.join(traceback.format_exception_only(error))
1949+
self.assertNotIn("Did you mean", formatted)
1950+
1951+
def test_keyword_hint_in_interactive_interpreter(self):
1952+
import code
1953+
with captured_output("stderr") as output:
1954+
code.InteractiveInterpreter().runsource("impor math", filename="user.py")
1955+
self.assertIn("Did you mean 'import'", output.getvalue())
1956+
1957+
@requires_subprocess()
1958+
def test_keyword_hint_does_not_reopen_source(self):
1959+
with tempfile.TemporaryDirectory() as script_dir:
1960+
make_script(script_dir, "sitecustomize", textwrap.dedent('''
1961+
import sys
1962+
def audit(event, args):
1963+
if event == "open" and isinstance(args[0], str) and args[0].endswith("script.py"):
1964+
print("source-open")
1965+
sys.addaudithook(audit)
1966+
'''))
1967+
counts = []
1968+
cases = [("= math", None), ("impor math", "import"),
1969+
("result = (one\n adn two)", "and")]
1970+
for source, keyword in cases:
1971+
script = make_script(script_dir, "script", source)
1972+
_, output, error = assert_python_failure(script, PYTHONPATH=script_dir)
1973+
counts.append(output.count(b"source-open"))
1974+
if keyword is not None:
1975+
self.assertIn(f"Did you mean '{keyword}'".encode(), error)
1976+
if keyword == "and":
1977+
self.assertIn(b"line 1\n result = (one\n", error)
1978+
self.assertGreater(counts[0], 0)
1979+
self.assertEqual(counts, [counts[0]] * len(cases))
1980+
19151981
@cpython_only
19161982
def test_keyword_trials_do_not_repeat_warnings(self):
19171983
import warnings
19181984
for prefix in ("value = '\\q'\n", "from . lazy import x\n"):
19191985
with self.subTest(prefix=prefix), warnings.catch_warnings(record=True) as caught:
19201986
warnings.simplefilter("always")
1921-
with self.assertRaises(SyntaxError) as error:
1922-
compile(prefix + "impor math", "<test>", "exec")
1987+
error = self.get_syntax_error(prefix + "impor math")
19231988
self.assertEqual(len(caught), 1)
1924-
self.assertEqual(error.exception._metadata[0], "import")
1989+
self.assertEqual(error._metadata[0], "import")
19251990

19261991
@cpython_only
19271992
def test_keyword_replay_requires_complete_valid_input(self):
19281993
for source in ("dell 1", "impor math\nother =", "whille True:\npass",
19291994
"switch +:\n case:", "function f(=):\n pass", "(mat x)",
19301995
"impor math\n" + "pass\n" * 20000):
19311996
with self.subTest(source=source):
1932-
with self.assertRaises(SyntaxError) as caught:
1933-
compile(source, "<test>", "exec")
1934-
# Action errors and a cache which stopped before EOF must not
1935-
# count as successful repairs.
1936-
self.assertIsNone(caught.exception._metadata)
1997+
error = self.get_syntax_error(source)
1998+
self.assertIsNone(error._metadata)
19371999

19382000
@cpython_only
19392001
def test_keyword_replay_respects_feature_version(self):
@@ -1949,9 +2011,8 @@ def test_keyword_trials_reject_invalid_literals(self):
19492011
for mode in ("exec", "eval", "single"):
19502012
source = "npt " + literal
19512013
with self.subTest(source=source, mode=mode):
1952-
with self.assertRaises(SyntaxError) as caught:
1953-
compile(source, "<test>", mode)
1954-
self.assertIsNone(caught.exception._metadata)
2014+
error = self.get_syntax_error(source, mode)
2015+
self.assertIsNone(error._metadata)
19552016

19562017
@cpython_only
19572018
@requires_subprocess()
@@ -2008,17 +2069,15 @@ def test_keyword_trials_numeric_limit(self):
20082069
with self.subTest(digits=digits):
20092070
source = "x = " + "1" * digits
20102071
compile(source, "<test>", "exec")
2011-
with self.assertRaises(SyntaxError) as caught:
2012-
compile("impor math\n" + source, "<test>", "exec")
2013-
self.assertEqual(caught.exception._metadata is not None, expected)
2072+
error = self.get_syntax_error("impor math\n" + source)
2073+
self.assertEqual(error._metadata is not None, expected)
20142074

20152075
@cpython_only
20162076
def test_keyword_trials_do_not_call_normalizer(self):
20172077
with unittest.mock.patch("unicodedata.normalize") as normalize:
2018-
with self.assertRaises(SyntaxError) as caught:
2019-
compile("whille True:\n café = 1", "<test>", "exec")
2078+
error = self.get_syntax_error("whille True:\n café = 1")
20202079
normalize.assert_not_called()
2021-
self.assertIsNone(caught.exception._metadata)
2080+
self.assertIsNone(error._metadata)
20222081

20232082
@cpython_only
20242083
@requires_subprocess()
@@ -2051,10 +2110,9 @@ def test_keyword_replay_source_limit(self):
20512110
literal = "value = " + repr("a" * size) + "\n"
20522111
for source in (literal + "impor math", "impor math\n" + literal):
20532112
with self.subTest(size=size, typo_first=source.startswith("impor")):
2054-
with self.assertRaises(SyntaxError) as caught:
2055-
compile(source, "<test>", "exec")
2113+
error = self.get_syntax_error(source)
20562114
expected = size < 256 * 1024
2057-
self.assertEqual(caught.exception._metadata is not None, expected)
2115+
self.assertEqual(error._metadata is not None, expected)
20582116
script = make_script(script_dir, "large_keyword_typo", source)
20592117
_, _, stderr = assert_python_failure(script)
20602118
self.assertEqual(b"Did you mean 'import'" in stderr, expected)
@@ -2066,10 +2124,9 @@ def test_keyword_replay_work_limit(self):
20662124
# Several candidates must share the budget, including failed passes.
20672125
for count, expected in ((100, "lambda"), (2000, None)):
20682126
source = "x = 1\n" * count + "f(" + "fron, " * 8 + "lamda x: x)"
2069-
with self.subTest(count=count), self.assertRaises(SyntaxError) as caught:
2070-
compile(source, "<test>", "exec")
2071-
metadata = caught.exception._metadata
2072-
self.assertEqual(metadata[0] if metadata else None, expected)
2127+
with self.subTest(count=count):
2128+
metadata = self.get_syntax_error(source)._metadata
2129+
self.assertEqual(metadata[0] if metadata else None, expected)
20732130

20742131
@requires_debug_ranges()
20752132
@force_not_colorized_test_class

‎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
@@ -1108,9 +1108,6 @@ add_keyword_typo(KeywordTypo *candidates, int *count, int mark,
11081108
if (word == NULL) {
11091109
return -1;
11101110
}
1111-
if (PyUnicode_GET_LENGTH(name) <= 2) {
1112-
max_cost = Py_MIN(max_cost, 2);
1113-
}
11141111
Py_ssize_t cost = _Py_UTF8_Edit_Cost(name, word, max_cost);
11151112
// Adjacent transpositions are one spelling error. The shared Levenshtein
11161113
// distance counts them as two substitutions.
@@ -1181,17 +1178,20 @@ keyword_trial_accepts(Parser *p, const KeywordTypo *candidate)
11811178
static int
11821179
try_keyword_typo(Parser *p, const KeywordTypo *candidate, KeywordTrial *state)
11831180
{
1181+
PyArena *arena = _PyArena_New();
1182+
if (arena == NULL) {
1183+
return -1;
1184+
}
11841185
// Token text belongs to the original arena. Token headers and memo entries
11851186
// must be private: even a failed trial can memoize nodes containing its edit.
1186-
Token *tokens = PyMem_Malloc(p->fill * sizeof(*tokens));
1187-
Token **index = PyMem_Malloc(p->fill * sizeof(*index));
1187+
Token *tokens = _PyArena_Malloc(arena, p->fill * sizeof(*tokens));
1188+
Token **index = _PyArena_Malloc(arena, p->fill * sizeof(*index));
11881189
Parser trial = *p;
11891190
trial.identifier_cache = p->identifier_cache == NULL ? NULL :
11901191
PyMem_Malloc(IDENTIFIER_CACHE_SIZE * sizeof(*trial.identifier_cache));
1191-
PyArena *arena = _PyArena_New();
11921192
PyObject *replacement = PyBytes_FromString(candidate->keyword);
11931193
int accepted = -1;
1194-
if (tokens == NULL || index == NULL || arena == NULL || replacement == NULL ||
1194+
if (tokens == NULL || index == NULL || replacement == NULL ||
11951195
(p->identifier_cache != NULL && trial.identifier_cache == NULL))
11961196
{
11971197
goto done;
@@ -1235,14 +1235,10 @@ try_keyword_typo(Parser *p, const KeywordTypo *candidate, KeywordTrial *state)
12351235
accepted = keyword_trial_accepts(&trial, candidate);
12361236
done:
12371237
// No Parser_Free: the trial borrows the tokenizer and type-ignore comments,
1238-
// and its token headers are a single allocation.
1238+
// and its token arrays belong to the arena.
12391239
PyMem_Free(trial.identifier_cache);
1240-
if (arena != NULL) {
1241-
_PyArena_Free(arena);
1242-
}
1240+
_PyArena_Free(arena);
12431241
Py_XDECREF(replacement);
1244-
PyMem_Free(index);
1245-
PyMem_Free(tokens);
12461242
return PyErr_Occurred() ? -1 : accepted;
12471243
}
12481244

@@ -1356,7 +1352,8 @@ collect_keyword_typos(Parser *p, PySyntaxErrorObject *error, KeywordTypo *candid
13561352
// Preserve missing-comma diagnostics and correctly spelled keywords
13571353
// unless one edit repairs the input. For keywords, only substitute a
13581354
// character: deleting one from "for" must not suggest "or".
1359-
Py_ssize_t max_cost = p->missing_comma || is_keyword ? 2 : 4;
1355+
Py_ssize_t max_cost =
1356+
p->missing_comma || is_keyword || PyUnicode_GET_LENGTH(name) <= 2 ? 2 : 4;
13601357
int result = 0;
13611358
for (int length = 0; length < p->n_keyword_lists && result == 0; length++) {
13621359
if (is_keyword && length != text_length) {
@@ -1383,32 +1380,43 @@ collect_keyword_typos(Parser *p, PySyntaxErrorObject *error, KeywordTypo *candid
13831380
}
13841381

13851382
static PyObject *
1386-
build_keyword_typo_metadata(Parser *p, const KeywordTypo *candidate)
1383+
build_keyword_typo_metadata(Parser *p, PySyntaxErrorObject *error,
1384+
const KeywordTypo *candidate)
13871385
{
13881386
Token *token = p->tokens[candidate->mark];
1389-
PyObject *line;
1387+
PyObject *line = NULL;
13901388
if (_PyTokenizer_RetainedSource(p->tok) != NULL) {
13911389
Py_ssize_t size;
13921390
const char *text = _PyTokenizer_LineView(p->tok, token->lineno, &size);
13931391
line = PyUnicode_DecodeUTF8(text, size, "replace");
13941392
}
1395-
else {
1396-
_PyTokenizer_Info info = _PyTokenizer_GetInfo(p->tok);
1397-
line = _PyErr_ProgramDecodedTextObject(
1398-
info.filename, token->lineno, info.encoding);
1393+
else if (PyLong_AsLong(error->lineno) == token->lineno &&
1394+
error->text != NULL && PyUnicode_CheckExact(error->text)) {
1395+
// Reuse the error's line without another file access or codec lookup.
1396+
line = Py_NewRef(error->text);
13991397
}
1400-
if (line == NULL) {
1398+
if (line == NULL && PyErr_Occurred()) {
14011399
return NULL;
14021400
}
1403-
Py_ssize_t start = _PyPegen_byte_offset_to_character_offset(line, token->col_offset);
1404-
Py_ssize_t end = _PyPegen_byte_offset_to_character_offset(line, token->end_col_offset);
1405-
PyObject *metadata = NULL;
1406-
if (start >= 0 && end >= 0) {
1407-
metadata = Py_BuildValue("(sOinin)", candidate->keyword, line,
1408-
token->lineno, start + 1,
1409-
token->end_lineno, end + 1);
1401+
PyObject *location = NULL;
1402+
if (line != NULL) {
1403+
Py_ssize_t start = _PyPegen_byte_offset_to_character_offset(line, token->col_offset);
1404+
Py_ssize_t end = _PyPegen_byte_offset_to_character_offset(line, token->end_col_offset);
1405+
if (start >= 0 && end >= 0) {
1406+
location = Py_BuildValue("(Oinin)", line, token->lineno, start + 1,
1407+
token->end_lineno, end + 1);
1408+
}
1409+
Py_DECREF(line);
1410+
if (location == NULL) {
1411+
return NULL;
1412+
}
14101413
}
1411-
Py_DECREF(line);
1414+
// Without the typo's source line, retain the original error location.
1415+
PyObject *metadata = Py_BuildValue(
1416+
"(sO(OOOOOOO))", candidate->keyword, location != NULL ? location : Py_None,
1417+
error->filename, error->msg, error->text, error->lineno, error->offset,
1418+
error->end_lineno, error->end_offset);
1419+
Py_XDECREF(location);
14121420
return metadata;
14131421
}
14141422

@@ -1449,7 +1457,7 @@ keyword_typo_metadata(Parser *p, PySyntaxErrorObject *error)
14491457
return NULL;
14501458
}
14511459
if (accepted) {
1452-
return build_keyword_typo_metadata(p, &candidates[i]);
1460+
return build_keyword_typo_metadata(p, error, &candidates[i]);
14531461
}
14541462
}
14551463
return NULL;

‎Parser/pegen_errors.c‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ _PyPegen_tokenize_full_source_to_check_for_errors(Parser *p) {
175175
// Retain the tokens this error check already reads for bounded typo
176176
// trials. Never treat a truncated cache as complete input.
177177
if (cache_tokens && p->fill < MAX_KEYWORD_TYPO_TOKENS &&
178-
(p->fill == 0 || p->tokens[p->fill - 1]->type != ENDMARKER) &&
178+
p->tokens[p->fill - 1]->type != ENDMARKER &&
179179
new_token.type != ERRORTOKEN && new_token.type != TYPE_IGNORE &&
180180
_PyPegen_append_token(p, &new_token) < 0)
181181
{
@@ -203,7 +203,7 @@ _PyPegen_tokenize_full_source_to_check_for_errors(Parser *p) {
203203
}
204204
case ENDMARKER:
205205
if (cache_tokens && p->fill < MAX_KEYWORD_TYPO_TOKENS &&
206-
p->start_rule == Py_single_input && p->fill > 0 &&
206+
p->start_rule == Py_single_input &&
207207
p->tokens[p->fill - 1]->type == NEWLINE)
208208
{
209209
// append_token inserted single input's implicit newline;

0 commit comments

Comments
 (0)