Skip to content

Commit 30d8199

Browse files
committed
gh-159672: Generate keyword typo hints in the parser
1 parent 07539c8 commit 30d8199

8 files changed

Lines changed: 410 additions & 174 deletions

File tree

‎Lib/test/test_traceback.py‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1847,6 +1847,21 @@ def test_keyword_suggestions_from_command_string(self):
18471847
stderr_text = stderr.decode('utf-8')
18481848
self.assertIn(f"Did you mean '{expected_kw}'", stderr_text)
18491849

1850+
@cpython_only
1851+
def test_keyword_suggestions_from_single_input(self):
1852+
for source, keyword in self.TYPO_CASES:
1853+
for ending in ('', '\n'):
1854+
with self.subTest(source=source, ending=ending):
1855+
with self.assertRaises(SyntaxError) as caught:
1856+
compile(source + ending, "<test>", "single")
1857+
self.assertEqual(caught.exception._metadata[0], keyword)
1858+
with (unittest.mock.patch("builtins.compile") as compile_mock,
1859+
unittest.mock.patch("builtins.open") as open_mock):
1860+
text = ''.join(traceback.format_exception_only(caught.exception))
1861+
self.assertIn(f"Did you mean '{keyword}'", text)
1862+
compile_mock.assert_not_called()
1863+
open_mock.assert_not_called()
1864+
18501865
def test_no_keyword_suggestion_for_comma_errors(self):
18511866
# When the parser identifies a missing comma, don't suggest
18521867
# bogus keyword replacements like 'print' -> 'not'
@@ -1864,6 +1879,75 @@ def test_no_keyword_suggestion_for_comma_errors(self):
18641879
self.assertIn("Perhaps you forgot a comma", stderr_text)
18651880
self.assertNotIn("Did you mean", stderr_text)
18661881

1882+
@cpython_only
1883+
def test_parser_suggestions_need_no_formatting_work(self):
1884+
cases = [
1885+
("def f():\n returm value", "return", 2, 5),
1886+
("é = 1; impor math", "import", 1, 8),
1887+
("a = 1\nif True:\n pass\nelso:\n pass", "else", 4, 1),
1888+
("[123 fur x\nin range(3)\nif x]", "for", 1, 6),
1889+
("mach x:\n case 1: pass", "match", 1, 1),
1890+
("match x:\n cse 1: pass", "case", 2, 5),
1891+
("typ Alias = int", "type", 1, 1),
1892+
("lazi import math", "lazy", 1, 1),
1893+
("long_name = 1\n" * 85 + "whille True:\n pass", "while", 86, 1),
1894+
]
1895+
for source, keyword, lineno, offset in cases:
1896+
with self.subTest(source=source):
1897+
with self.assertRaises(SyntaxError) as caught:
1898+
compile(source, "<test>", "exec")
1899+
error = caught.exception
1900+
original = error.args
1901+
with (unittest.mock.patch("builtins.compile") as compile_mock,
1902+
unittest.mock.patch("builtins.open") as open_mock):
1903+
exc = traceback.TracebackException.from_exception(error)
1904+
first = ''.join(exc.format_exception_only())
1905+
self.assertEqual(first, ''.join(exc.format_exception_only()))
1906+
compile_mock.assert_not_called()
1907+
open_mock.assert_not_called()
1908+
self.assertIn(f"Did you mean '{keyword}'?", first)
1909+
self.assertEqual((exc.lineno, exc.offset), (str(lineno), offset))
1910+
self.assertEqual(error.args, original)
1911+
1912+
@cpython_only
1913+
def test_keyword_trials_do_not_repeat_warnings(self):
1914+
import warnings
1915+
for prefix in ("value = '\\q'\n", "from . lazy import x\n"):
1916+
with self.subTest(prefix=prefix), warnings.catch_warnings(record=True) as caught:
1917+
warnings.simplefilter("always")
1918+
with self.assertRaises(SyntaxError) as error:
1919+
compile(prefix + "impor math", "<test>", "exec")
1920+
self.assertEqual(len(caught), 1)
1921+
self.assertEqual(error.exception._metadata[0], "import")
1922+
1923+
@cpython_only
1924+
def test_keyword_replay_requires_complete_valid_input(self):
1925+
for source in ("dell 1", "impor math\nother =", "whille True:\npass",
1926+
"switch +:\n case:", "function f(=):\n pass", "(mat x)",
1927+
"impor math\n" + "pass\n" * 3000):
1928+
with self.subTest(source=source):
1929+
with self.assertRaises(SyntaxError) as caught:
1930+
compile(source, "<test>", "exec")
1931+
# Action errors and a cache which stopped before EOF must not
1932+
# count as successful repairs.
1933+
self.assertIsNone(caught.exception._metadata)
1934+
1935+
@cpython_only
1936+
def test_keyword_replay_respects_feature_version(self):
1937+
import ast
1938+
for source in ("mach x:\n case 1: pass", "mach x:", "switch x:\n case:"):
1939+
with self.subTest(source=source), self.assertRaises(SyntaxError) as caught:
1940+
ast.parse(source, feature_version=(3, 9))
1941+
self.assertIsNone(caught.exception._metadata)
1942+
1943+
@cpython_only
1944+
def test_keyword_trials_do_not_call_normalizer(self):
1945+
with unittest.mock.patch("unicodedata.normalize") as normalize:
1946+
with self.assertRaises(SyntaxError) as caught:
1947+
compile("whille True:\n café = 1", "<test>", "exec")
1948+
normalize.assert_not_called()
1949+
self.assertIsNone(caught.exception._metadata)
1950+
18671951
@requires_debug_ranges()
18681952
@force_not_colorized_test_class
18691953
class PurePythonTracebackErrorCaretTests(

‎Lib/traceback.py‎

Lines changed: 7 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,6 @@
1010
import textwrap
1111
import types
1212
import warnings
13-
import codeop
14-
import keyword
15-
import tokenize
16-
import io
1713
import importlib.util
1814
import pathlib
1915

@@ -1182,7 +1178,13 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None,
11821178
self.end_offset = exc_value.end_offset
11831179
self.msg = exc_value.msg
11841180
self._is_syntax_error = True
1185-
self._exc_metadata = getattr(exc_value, "_metadata", None)
1181+
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)
1187+
self.msg = f"invalid syntax. Did you mean '{suggestion}'?"
11861188
elif exc_type and issubclass(exc_type, ImportError) and \
11871189
getattr(exc_value, "name_from", None) is not None:
11881190
wrong_name = getattr(exc_value, "name_from", None)
@@ -1413,122 +1415,6 @@ def format_exception_only(self, *, show_group=False, _depth=0, **kwargs):
14131415
for ex in self.exceptions:
14141416
yield from ex.format_exception_only(show_group=show_group, _depth=_depth+1, colorize=colorize)
14151417

1416-
def _find_keyword_typos(self):
1417-
assert self._is_syntax_error
1418-
try:
1419-
import _suggestions
1420-
except ImportError:
1421-
_suggestions = None
1422-
1423-
# Only try to find keyword typos if there is no custom message
1424-
if self.msg != "invalid syntax" and "Perhaps you forgot a comma" not in self.msg:
1425-
return
1426-
1427-
if not self._exc_metadata:
1428-
return
1429-
1430-
line, offset, source = self._exc_metadata
1431-
end_line = int(self.lineno) if self.lineno is not None else 0
1432-
lines = None
1433-
from_filename = False
1434-
1435-
if source is None:
1436-
if self.filename:
1437-
try:
1438-
with open(self.filename) as f:
1439-
lines = f.read().splitlines()
1440-
except Exception:
1441-
line, end_line, offset = 0,1,0
1442-
else:
1443-
from_filename = True
1444-
lines = lines if lines is not None else self.text.splitlines()
1445-
else:
1446-
lines = source.splitlines()
1447-
1448-
error_code = lines[line -1 if line > 0 else 0:end_line]
1449-
error_code = textwrap.dedent('\n'.join(error_code))
1450-
1451-
# Do not continue if the source is too large
1452-
if len(error_code) > 1024:
1453-
return
1454-
1455-
# If the original code doesn't raise SyntaxError, we can't validate
1456-
# that a keyword replacement actually fixes anything
1457-
try:
1458-
codeop.compile_command(error_code, symbol="exec", flags=codeop.PyCF_ONLY_AST)
1459-
except SyntaxError:
1460-
pass # Good - the original code has a syntax error we might fix
1461-
else:
1462-
return # Original code compiles or is incomplete - can't validate fixes
1463-
1464-
error_lines = error_code.splitlines()
1465-
tokens = tokenize.generate_tokens(io.StringIO(error_code).readline)
1466-
tokens_left_to_process = 10
1467-
import difflib
1468-
for token in tokens:
1469-
start, end = token.start, token.end
1470-
if token.type != tokenize.NAME:
1471-
continue
1472-
# Only consider NAME tokens on the same line as the error
1473-
the_end = end_line if line == 0 else end_line + 1
1474-
if from_filename and token.start[0]+line != the_end:
1475-
continue
1476-
wrong_name = token.string
1477-
if wrong_name in keyword.kwlist:
1478-
continue
1479-
1480-
# Limit the number of valid tokens to consider to not spend
1481-
# to much time in this function
1482-
tokens_left_to_process -= 1
1483-
if tokens_left_to_process < 0:
1484-
break
1485-
# Limit the number of possible matches to try
1486-
max_matches = 3
1487-
matches = []
1488-
1489-
hint = _get_cross_language_keyword_hint(wrong_name)
1490-
if hint:
1491-
matches.append(hint)
1492-
if _suggestions is not None:
1493-
suggestion = _suggestions._generate_suggestions(keyword.kwlist + keyword.softkwlist, wrong_name)
1494-
if suggestion:
1495-
matches.append(suggestion)
1496-
matches.extend(
1497-
difflib.get_close_matches(
1498-
wrong_name,
1499-
keyword.kwlist + keyword.softkwlist,
1500-
n=max_matches,
1501-
cutoff=0.5
1502-
)
1503-
)
1504-
matches = matches[:max_matches]
1505-
for suggestion in matches:
1506-
if not suggestion or suggestion == wrong_name:
1507-
continue
1508-
# Try to replace the token with the keyword
1509-
the_lines = error_lines.copy()
1510-
the_line = the_lines[start[0] - 1][:]
1511-
chars = list(the_line)
1512-
chars[token.start[1]:token.end[1]] = suggestion
1513-
the_lines[start[0] - 1] = ''.join(chars)
1514-
code = '\n'.join(the_lines)
1515-
1516-
# Check if it works
1517-
try:
1518-
codeop.compile_command(code, symbol="exec", flags=codeop.PyCF_ONLY_AST)
1519-
except SyntaxError:
1520-
continue
1521-
1522-
# Keep token.line but handle offsets correctly
1523-
self.text = token.line
1524-
self.offset = token.start[1] + 1
1525-
self.end_offset = token.end[1] + 1
1526-
self.lineno = start[0]
1527-
self.end_lineno = end[0]
1528-
self.msg = f"invalid syntax. Did you mean '{suggestion}'?"
1529-
return
1530-
1531-
15321418
def _format_syntax_error(self, stype, **kwargs):
15331419
"""Format SyntaxError exceptions (internal helper)."""
15341420
# Show exactly where the problem was found.
@@ -1555,9 +1441,6 @@ def _format_syntax_error(self, stype, **kwargs):
15551441
# text = " foo\n"
15561442
# rtext = " foo"
15571443
# ltext = "foo"
1558-
with suppress(Exception):
1559-
self._find_keyword_typos()
1560-
text = self.text
15611444
rtext = text.rstrip('\n')
15621445
ltext = rtext.lstrip(' \n\f')
15631446
spaces = len(rtext) - len(ltext)
@@ -1798,17 +1681,6 @@ def print(self, *, file=None, chain=True, **kwargs):
17981681
})
17991682

18001683

1801-
# Cross-language keyword suggestions.
1802-
_CROSS_LANGUAGE_KEYWORD_HINTS = frozendict({
1803-
# C/C++ equivalents
1804-
'switch': 'match',
1805-
'delete': 'del',
1806-
# function define equivalents
1807-
'function': 'def',
1808-
'func': 'def',
1809-
'void': 'def',
1810-
})
1811-
18121684
def _substitution_cost(ch_a, ch_b):
18131685
if ch_a == ch_b:
18141686
return 0
@@ -1888,12 +1760,6 @@ def _get_cross_language_hint(obj, wrong_name):
18881760
return None
18891761

18901762

1891-
def _get_cross_language_keyword_hint(wrong_name):
1892-
"""Check if wrong_name is a common keyword from another language
1893-
"""
1894-
return _CROSS_LANGUAGE_KEYWORD_HINTS.get(wrong_name)
1895-
1896-
18971763
def _get_safe___dir__(obj):
18981764
# Use obj.__dir__() to avoid a TypeError when calling dir(obj).
18991765
# See gh-131001 and gh-139933.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
The parser now generates keyword typo suggestions from cached tokens. The
2+
:mod:`traceback` formatter displays these suggestions without source
3+
recompilation.

‎Parser/action_helpers.c‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2072,7 +2072,7 @@ _warn_relative_import_of_lazy(Parser *p, asdl_seq *dots, expr_ty module)
20722072
// the module name is insignificant, so this is parsed exactly like
20732073
// `from .lazy import x` (an import of the relative module "lazy"), but it
20742074
// is most likely a transposition of `lazy from . import x` (PEP 810).
2075-
if (p->call_invalid_rules) {
2075+
if (p->call_invalid_rules || p->keyword_trial != NULL) {
20762076
return 0;
20772077
}
20782078

@@ -2134,7 +2134,9 @@ _PyPegen_checked_from_import(Parser *p, asdl_seq *dots, expr_ty module_name,
21342134
alias_ty alias = asdl_seq_GET(names, i);
21352135
if (PyUnicode_CompareWithASCIIString(alias->name, "barry_as_FLUFL") == 0) {
21362136
p->flags |= PyPARSE_BARRY_AS_BDFL;
2137-
p->tok->barry_as_bdfl = 1;
2137+
if (p->keyword_trial == NULL) {
2138+
p->tok->barry_as_bdfl = 1;
2139+
}
21382140
}
21392141
}
21402142
}

0 commit comments

Comments
 (0)