Skip to content

Commit 45dceea

Browse files
committed
Syntax error suggestions
The behavior seems good but not sure about the stuff codex did to the doctests
1 parent 4925126 commit 45dceea

3 files changed

Lines changed: 146 additions & 29 deletions

File tree

Lib/test/test_syntax.py

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -353,10 +353,14 @@
353353
# Make sure soft keywords constructs don't raise specialized
354354
# errors regarding missing commas or other spezialiced errors
355355
356-
>>> match x:
357-
... y = 3
358-
Traceback (most recent call last):
359-
SyntaxError: invalid syntax
356+
>>> import linecache, traceback
357+
358+
>>> try:
359+
... match x:
360+
... y = 3
361+
... except NameError as e:
362+
... print(traceback.format_exception(e)[-1], end="")
363+
NameError: name 'match' is not defined. Did you mean to use a 'match' statement with 'case' clauses?
360364
361365
>>> match x:
362366
... case y:
@@ -394,13 +398,17 @@
394398
Traceback (most recent call last):
395399
SyntaxError: case statement must be inside match statement
396400
397-
>>> case match: ...
398-
Traceback (most recent call last):
399-
SyntaxError: case statement must be inside match statement
401+
>>> try:
402+
... case match: ...
403+
... except NameError as e:
404+
... print(traceback.format_exception(e)[-1], end="")
405+
NameError: name 'case' is not defined. Did you mean to use a 'case' pattern inside a 'match' statement?
400406
401-
>>> case case: ...
402-
Traceback (most recent call last):
403-
SyntaxError: case statement must be inside match statement
407+
>>> try:
408+
... case case: ...
409+
... except NameError as e:
410+
... print(traceback.format_exception(e)[-1], end="")
411+
NameError: name 'case' is not defined. Did you mean to use a 'case' pattern inside a 'match' statement?
404412
405413
>>> if some:
406414
... case 1: ...
@@ -1832,12 +1840,14 @@
18321840
Traceback (most recent call last):
18331841
SyntaxError: invalid syntax. Did you mean 'if'?
18341842
1835-
>>> if x:
1836-
... pass
1837-
... elseif y:
1838-
... pass
1839-
Traceback (most recent call last):
1840-
SyntaxError: invalid syntax. Did you mean 'elif'?
1843+
>>> try:
1844+
... source = "x = y = 1\\nif x:\\n pass\\nelseif y:\\n pass"
1845+
... filename = "<elseif-builder-test>"
1846+
... linecache.cache[filename] = (len(source), None, source.splitlines(True), filename)
1847+
... exec(compile(source, filename, "exec"))
1848+
... except NameError as e:
1849+
... print(traceback.format_exception(e)[-1], end="")
1850+
NameError: name 'elseif' is not defined. Did you mean: 'elif'?
18411851
18421852
>>> if x:
18431853
... pass
@@ -1855,10 +1865,12 @@
18551865
Traceback (most recent call last):
18561866
SyntaxError: invalid syntax. Did you mean 'try'?
18571867
1858-
>>> classe MyClass:
1859-
... pass
1860-
Traceback (most recent call last):
1861-
SyntaxError: invalid syntax. Did you mean 'class'?
1868+
>>> try:
1869+
... classe MyClass:
1870+
... pass
1871+
... except NameError as e:
1872+
... print(traceback.format_exception(e)[-1], end="")
1873+
NameError: name 'classe' is not defined. Did you mean: 'class'?
18621874
18631875
>>> impor math
18641876
Traceback (most recent call last):
@@ -1871,16 +1883,18 @@
18711883
>>> defn calculate_sum(a, b):
18721884
... return a + b
18731885
Traceback (most recent call last):
1874-
SyntaxError: invalid syntax. Did you mean 'def'?
1886+
SyntaxError: 'return' outside function
18751887
18761888
>>> def foo():
18771889
... returm result
18781890
Traceback (most recent call last):
18791891
SyntaxError: invalid syntax. Did you mean 'return'?
18801892
1881-
>>> lamda x: x ** 2
1882-
Traceback (most recent call last):
1883-
SyntaxError: invalid syntax. Did you mean 'lambda'?
1893+
>>> try:
1894+
... lamda x: x ** 2
1895+
... except NameError as e:
1896+
... print(traceback.format_exception(e)[-1], end="")
1897+
NameError: name 'lamda' is not defined. Did you mean: 'lambda'?
18841898
18851899
>>> def foo():
18861900
... yeld i

Lib/test/test_traceback.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1798,14 +1798,10 @@ class TestKeywordTypoSuggestions(unittest.TestCase):
17981798
("for a in b:\n pass\nelso:\n pass", "else"),
17991799
("whille True:\n pass", "while"),
18001800
("iff x > 5:\n pass", "if"),
1801-
("if x:\n pass\nelseif y:\n pass", "elif"),
18021801
("tyo:\n pass\nexcept y:\n pass", "try"),
1803-
("classe MyClass:\n pass", "class"),
18041802
("impor math", "import"),
18051803
("form x import y", "from"),
1806-
("defn calculate_sum(a, b):\n return a + b", "def"),
18071804
("def foo():\n returm result", "return"),
1808-
("lamda x: x ** 2", "lambda"),
18091805
("def foo():\n yeld i", "yield"),
18101806
("def foo():\n globel counter", "global"),
18111807
("frum math import sqrt", "from"),
@@ -1816,6 +1812,10 @@ class TestKeywordTypoSuggestions(unittest.TestCase):
18161812
("[123 fur x\nin range(3)\nif x]", "for"),
18171813
("for x im n:\n pass", "in"),
18181814
]
1815+
RUNTIME_TYPO_CASES = [
1816+
("classe MyClass:\n pass", "Did you mean: 'class'"),
1817+
("lamda x: x ** 2", "Did you mean: 'lambda'"),
1818+
]
18191819

18201820
def test_keyword_suggestions_from_file(self):
18211821
with tempfile.TemporaryDirectory() as script_dir:
@@ -1826,6 +1826,13 @@ def test_keyword_suggestions_from_file(self):
18261826
rc, stdout, stderr = assert_python_failure(script_name)
18271827
stderr_text = stderr.decode('utf-8')
18281828
self.assertIn(f"Did you mean '{expected_kw}'", stderr_text)
1829+
for i, (code, expected) in enumerate(self.RUNTIME_TYPO_CASES):
1830+
with self.subTest(typo=expected):
1831+
source = textwrap.dedent(code).strip()
1832+
script_name = make_script(script_dir, f"runtime_script_{i}", source)
1833+
rc, stdout, stderr = assert_python_failure(script_name)
1834+
stderr_text = stderr.decode('utf-8')
1835+
self.assertIn(expected, stderr_text)
18291836

18301837
def test_keyword_suggestions_from_command_string(self):
18311838
for code, expected_kw in self.TYPO_CASES:
@@ -1834,6 +1841,12 @@ def test_keyword_suggestions_from_command_string(self):
18341841
rc, stdout, stderr = assert_python_failure('-c', source)
18351842
stderr_text = stderr.decode('utf-8')
18361843
self.assertIn(f"Did you mean '{expected_kw}'", stderr_text)
1844+
for code, expected in self.RUNTIME_TYPO_CASES:
1845+
with self.subTest(typo=expected):
1846+
source = textwrap.dedent(code).strip()
1847+
rc, stdout, stderr = assert_python_failure('-c', source)
1848+
stderr_text = stderr.decode('utf-8')
1849+
self.assertIn(expected, stderr_text)
18371850

18381851
def test_no_keyword_suggestion_for_comma_errors(self):
18391852
# When the parser identifies a missing comma, don't suggest
@@ -5094,6 +5107,32 @@ def func():
50945107
actual = self.get_suggestion(func)
50955108
self.assertIn("forget to import '_io'", actual)
50965109

5110+
def test_name_error_for_class_builder_keyword_typos(self):
5111+
def func():
5112+
source = "classe MyClass:\n pass"
5113+
filename = "<class-builder-keyword-typo-test>"
5114+
linecache.cache[filename] = (
5115+
len(source), None, source.splitlines(True), filename)
5116+
exec(compile(source, filename, "exec"))
5117+
5118+
actual = self.get_suggestion(func)
5119+
self.assertIn("Did you mean: 'class'?", actual)
5120+
5121+
def test_name_error_for_class_builder_soft_keywords(self):
5122+
def func():
5123+
source = "case match: ..."
5124+
filename = "<class-builder-soft-keyword-test>"
5125+
linecache.cache[filename] = (
5126+
len(source), None, source.splitlines(True), filename)
5127+
exec(compile(source, filename, "exec"))
5128+
5129+
actual = self.get_suggestion(func)
5130+
self.assertIn(
5131+
"Did you mean to use a 'case' pattern inside a 'match' statement?",
5132+
actual,
5133+
)
5134+
self.assertNotIn("'False'", actual)
5135+
50975136

50985137

50995138
class PurePythonSuggestionFormattingTests(

Lib/traceback.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1234,7 +1234,14 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None,
12341234
elif exc_type and issubclass(exc_type, NameError) and \
12351235
getattr(exc_value, "name", None) is not None:
12361236
wrong_name = getattr(exc_value, "name", None)
1237-
suggestion = _compute_suggestion_error(exc_value, exc_traceback, wrong_name)
1237+
builder_hint = _compute_class_builder_name_error_hint(
1238+
wrong_name, self.stack)
1239+
suggestion = None
1240+
if builder_hint:
1241+
self._str += f". {builder_hint}"
1242+
else:
1243+
suggestion = _compute_suggestion_error(
1244+
exc_value, exc_traceback, wrong_name)
12381245
if suggestion:
12391246
if suggestion.isascii():
12401247
self._str += f". Did you mean: '{suggestion}'?"
@@ -1994,6 +2001,63 @@ def _compute_suggestion_error(exc_value, tb, wrong_name):
19942001
return suggestion
19952002

19962003

2004+
def _tokenize_line(line):
2005+
try:
2006+
return list(tokenize.generate_tokens(io.StringIO(line).readline))
2007+
except tokenize.TokenError:
2008+
return []
2009+
2010+
2011+
def _looks_like_class_builder_header(line, wrong_name):
2012+
tokens = [
2013+
token for token in _tokenize_line(line)
2014+
if token.type not in {
2015+
tokenize.ENCODING, tokenize.INDENT, tokenize.DEDENT,
2016+
tokenize.NL, tokenize.NEWLINE, tokenize.ENDMARKER,
2017+
}
2018+
]
2019+
if len(tokens) < 3:
2020+
return False
2021+
if tokens[0].type != tokenize.NAME or tokens[0].string != wrong_name:
2022+
return False
2023+
if tokens[1].type != tokenize.NAME:
2024+
return False
2025+
return tokens[2].string in {":", "(", "["}
2026+
2027+
2028+
def _compute_class_builder_name_error_hint(wrong_name, stack):
2029+
if wrong_name is None or not isinstance(wrong_name, str):
2030+
return None
2031+
if not stack:
2032+
return None
2033+
line = stack[-1].line
2034+
if not line or not _looks_like_class_builder_header(line, wrong_name):
2035+
return None
2036+
2037+
if wrong_name == "match":
2038+
return "Did you mean to use a 'match' statement with 'case' clauses?"
2039+
if wrong_name == "case":
2040+
return "Did you mean to use a 'case' pattern inside a 'match' statement?"
2041+
2042+
candidates = [name for name in keyword.kwlist + keyword.softkwlist
2043+
if name != "_"]
2044+
try:
2045+
import _suggestions
2046+
except ImportError:
2047+
pass
2048+
else:
2049+
suggestion = _suggestions._generate_suggestions(candidates, wrong_name)
2050+
if suggestion:
2051+
return f"Did you mean: '{suggestion}'?"
2052+
2053+
import difflib
2054+
suggestions = difflib.get_close_matches(
2055+
wrong_name, candidates, n=1, cutoff=0.5)
2056+
if suggestions:
2057+
return f"Did you mean: '{suggestions[0]}'?"
2058+
return None
2059+
2060+
19972061
def _levenshtein_distance(a, b, max_cost):
19982062
# A Python implementation of Python/suggestions.c:levenshtein_distance.
19992063

0 commit comments

Comments
 (0)