diff --git a/development.rst b/development.rst new file mode 100644 index 000000000..33c847167 --- /dev/null +++ b/development.rst @@ -0,0 +1,7 @@ +Regenerating the Enaml parser +============================= + +To regenerate the parser: + + python -m enaml.core.parser.generate_enaml_parser + black enaml/core/parser/enaml_parser.py diff --git a/enaml/core/parser/base_python_parser.py b/enaml/core/parser/base_python_parser.py index 010e22ff3..2da29e543 100644 --- a/enaml/core/parser/base_python_parser.py +++ b/enaml/core/parser/base_python_parser.py @@ -16,6 +16,8 @@ from pegen.parser import Parser from pegen.tokenizer import Tokenizer +from enaml.compat import PY310 + # Singleton ast nodes, created once for efficiency Load = ast.Load() Store = ast.Store() @@ -76,7 +78,13 @@ def parse(self, rule: str) -> Optional[ast.AST]: raise self._exception else: token = self._tokenizer.diagnose() - raise SyntaxError("invalid syntax", (self.filename, token.start, 0, token.line)) + lineno, offset = token.start + end_lineno, end_offset = token.end + if PY310: + args = (self.filename, lineno, offset, token.line, end_lineno, end_offset) + else: + args = (self.filename, lineno, offset, token.line) + raise SyntaxError("invalid syntax", args) return res @@ -93,7 +101,9 @@ def check_version(self, min_version: Tuple[int, ...], error_msg: str, node: Node def raise_indentation_error(self, msg) -> None: """Raise an indentation error.""" - raise IndentationError(msg) + node = self._tokenizer.peek() + self.store_syntax_error_known_location(msg, node, IndentationError) + raise self._exception def get_expr_name(self, node) -> str: """Get a descriptive name for an expression.""" @@ -245,7 +255,8 @@ def _store_syntax_error( self, message: str, start: Optional[Tuple[int, int]] = None, - end: Optional[Tuple[int, int]] = None + end: Optional[Tuple[int, int]] = None, + exc_type: type = SyntaxError ) -> None: line_from_token = start is None and end is None if start is None or end is None: @@ -264,7 +275,7 @@ def _store_syntax_error( args = (self.filename, start[0], start[1], line) if sys.version_info >= (3, 10): args += (end[0], end[1]) - self._exception = SyntaxError(message, args) + self._exception = exc_type(message, args) def store_syntax_error(self, message: str) -> None: self._store_syntax_error(message) @@ -273,7 +284,12 @@ def make_syntax_error(self, message: str) -> None: self._store_syntax_error(message) return self._exception - def store_syntax_error_known_location(self, message: str, node) -> None: + def store_syntax_error_known_location( + self, + message: str, + node, + exc_type: type = SyntaxError + ) -> None: """Store a syntax error that occured at a given AST node.""" if isinstance(node, tokenize.TokenInfo): start = node.start @@ -282,7 +298,7 @@ def store_syntax_error_known_location(self, message: str, node) -> None: start = node.lineno, node.col_offset end = node.end_lineno, node.end_col_offset - self._store_syntax_error(message, start, end) + self._store_syntax_error(message, start, end, exc_type) def store_syntax_error_known_range( self, diff --git a/enaml/core/parser/enaml_parser.py b/enaml/core/parser/enaml_parser.py index 064ba2ffe..1bdd691c4 100644 --- a/enaml/core/parser/enaml_parser.py +++ b/enaml/core/parser/enaml_parser.py @@ -8,13 +8,12 @@ # NOTE This file was generated using enaml/core/parser/generate_enaml_parser.py # DO NOT EDIT DIRECTLY import ast -import itertools import sys +import itertools from typing import Any, List, NoReturn, Optional, Tuple, TypeVar, Union -from pegen.parser import Parser, logger, memoize, memoize_left_rec - from enaml.core import enaml_ast +from pegen.parser import Parser, logger, memoize, memoize_left_rec from .base_enaml_parser import BaseEnamlParser as Parser @@ -22,6 +21,8 @@ Load = ast.Load() Store = ast.Store() Del = ast.Del() + + # Keywords and soft keywords are listed at the end of the parser definition. class EnamlParser(Parser): @memoize @@ -9697,54 +9698,54 @@ def _tmp_241(self) -> Optional[Any]: return None KEYWORDS = ( - "nonlocal", - "global", - "True", - "while", - "for", - "elif", - "del", - "from", - "pass", - "def", - "except", - "None", - "with", + "class", "or", - "return", + "del", "not", - "and", - "await", - "finally", "in", + "await", + "except", + "return", + "try", "import", - "yield", - "raise", - "assert", + "break", "else", - "False", - "lambda", - "class", + "for", + "with", "async", - "break", + "None", + "assert", + "global", "if", - "try", "as", - "continue", + "True", + "yield", + "raise", + "and", + "finally", "is", + "while", + "pass", + "from", + "False", + "elif", + "lambda", + "nonlocal", + "def", + "continue", ) SOFT_KEYWORDS = ( - "event", + "case", "template", + "match", + "event", + "alias", "attr", + "enamldef", "const", - "func", - "pragma", - "case", - "alias", "_", - "enamldef", - "match", + "pragma", + "func", ) diff --git a/pyproject.toml b/pyproject.toml index fd1005e3c..d6306c6ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "atom>=0.9.0", "kiwisolver>=1.2.0", "bytecode>=0.14.2", - "pegen>=0.1.0", + "pegen>=0.1.0,<0.2.0", ] dynamic=["version"] diff --git a/releasenotes.rst b/releasenotes.rst index 5ca4ade96..190a91086 100644 --- a/releasenotes.rst +++ b/releasenotes.rst @@ -3,6 +3,11 @@ Enaml Release Notes Dates are written as DD/MM/YYYY +XXXXX +----- +- fix bug in Enaml parser that was not showing proper location of syntax and + indentation errors in tracebacks when the error was in an Enaml file. + 0.16.1 - 05/05/2023 ------------------- - fix typo causing a crash in dock area PR #523 diff --git a/tests/conftest.py b/tests/conftest.py index 4617d797d..c69ade14e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -85,7 +85,7 @@ def validate_parser_is_up_to_date(): last_source_modif ), ( "Generated parser appears outdated compared to its sources, " - "re-generate it using enaml/core/parser/generate_enaml_parser.enaml" + "re-generate it using `python -m enaml.core.parser.generate_enaml_parser`" ) diff --git a/tests/core/parser/test_parser.py b/tests/core/parser/test_parser.py index 971c93f4d..113311f1a 100644 --- a/tests/core/parser/test_parser.py +++ b/tests/core/parser/test_parser.py @@ -13,7 +13,7 @@ import traceback from textwrap import dedent -from enaml.compat import PY39 +from enaml.compat import PY310, PY311 def validate_ast(py_node, enaml_node, dump_ast=False, offset=0): @@ -81,7 +81,186 @@ def test_syntax_error_traceback_correct_path(tmpdir): tb = traceback.format_exc() print(tb) lines = tb.strip().split("\n") - assert ('File "{}", line (5, 35)'.format(test_module_path) in - (lines[-3] if PY39 else lines[-4])) + line = '\n'.join(lines[-4:]) + expected = 'File "{}", line 5'.format(test_module_path) + assert expected in line + finally: + sys.path.remove(tmpdir.strpath) + + +def test_syntax_error_traceback_show_line(tmpdir): + """ Test that a syntax error retains the path to the file + + """ + test_module_path = os.path.join(tmpdir.strpath, 'test_syntax.enaml') + + with open(test_module_path, 'w') as f: + f.write(dedent(""" + from enaml.widgets.api import Container, Label + + enamldef CustomLabel(Container): + Label # : missing intentionally + text = "Hello world" + """)) + + try: + sys.path.append(tmpdir.strpath) + with enaml.imports(): + from test_syntax import CustomLabel + assert False, "Should raise a syntax error" + except Exception as e: + tb = traceback.format_exc() + print(tb) + lines = tb.strip().split("\n") + line = '\n'.join(lines[-4:]) + + expected = 'Label # : missing intentionally' + assert expected in line + finally: + sys.path.remove(tmpdir.strpath) + + +INDENTATION_TESTS = { + "enamldef-block": ( + """ + from enaml.widgets.api import Window, Container, Label + + enamldef MainWindow(Window): + attr x = 1 + """, + "attr x = 1", + ), + "childdef-block": ( + """ + from enaml.widgets.api import Window, Container, Label + + enamldef MainWindow(Window): + Container: + Label: # no indent + text = "Hello world" + """, + "Label: # no indent", + ), + "childdef-indent-mismatch": ( + """ + from enaml.widgets.api import Window, Container, Label + + enamldef MainWindow(Window): + Container: + Label: + text = "Hello world" + Label: # indent mismatch + text = "Hello world" + """, + "Label: # indent mismatch", + ), + "childdef-attr": ( + """ + from enaml.widgets.api import Window, Container, Label + + enamldef MainWindow(Window): + Container: + Label: + text = 'Hello world' + """, + "text = 'Hello world'" + ), + "if-block": ( + """ + from enaml.widgets.api import Window + + enamldef MainWindow(Window): + func go(): + if True: + x = 1 + else: + x = 0 + """, + "x = 1", + ), + "for-block": ( + """ + from enaml.widgets.api import Window + + enamldef MainWindow(Window): + func go(): + x = 0 + for i in range(4): + x += 1 + """, + "x += 1" + ), + "try-block": ( + """ + from enaml.widgets.api import Window + enamldef MainWindow(Window): + func go(): + try: + x = 1/0 + except Exception as e: + print(e) + """, + "x = 1/0" + ), + "except-block": ( + """ + from enaml.widgets.api import Window + + enamldef MainWindow(Window): + func go(): + try: + x = 0 + except Exception as e: + print(e) + """, + "print(e)" + ), + "finally-block": ( + """ + from enaml.widgets.api import Window + + enamldef MainWindow(Window): + func go(): + try: + x = 0 + finally: + x = 2 + return 3 + """, + "x = 2" + ), + "class": ( + """ + from enaml.widgets.api import Window, Container, Label + + class Foo: + x = 1 + def add(): + self.x += 1 + """, + "def add()", + ), +} + +@pytest.mark.parametrize("label", INDENTATION_TESTS.keys()) +def test_indent_error_traceback_show_line(tmpdir, label): + """ Test that a syntax error retains the path to the file + + """ + test_module_path = os.path.join(tmpdir.strpath, f'test_indent_{label}.enaml') + source, expected= INDENTATION_TESTS[label] + with open(test_module_path, 'w') as f: + f.write(dedent(source.lstrip("\n"))) + try: + sys.path.append(tmpdir.strpath) + with enaml.imports(): + __import__(f"test_indent_{label}") + assert False, "Should raise a identation error" + except IndentationError as e: + tb = traceback.format_exc() + print(tb) + lines = tb.strip().split("\n") + line = '\n'.join(lines[-4:]) + assert expected in line finally: sys.path.remove(tmpdir.strpath)