Skip to content

Commit

Permalink
fix[ux]: add missing filename to syntax exceptions (vyperlang#4343)
Browse files Browse the repository at this point in the history
this commit adds a filename to `SyntaxException`s. previously, they did
not include filename information because that is typically added from
the `Module` AST node fields. but, at the time a `SyntaxException`
is thrown, the AST is not yet available, so the normal handler does
not have the filename info. this commit adds the filename info in two
places where the path is known, one in natspec.py and one in parse.py.
  • Loading branch information
sandbubbles authored Dec 13, 2024
1 parent c4669d1 commit c951dea
Show file tree
Hide file tree
Showing 7 changed files with 111 additions and 5 deletions.
32 changes: 31 additions & 1 deletion tests/functional/syntax/exceptions/test_vyper_exception_pos.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from pytest import raises

from vyper.exceptions import VyperException
from vyper import compile_code
from vyper.exceptions import SyntaxException, VyperException


def test_type_exception_pos():
Expand Down Expand Up @@ -29,3 +30,32 @@ def __init__():
"""
assert_compile_failed(lambda: get_contract(code), VyperException)


def test_exception_contains_file(make_input_bundle):
code = """
def bar()>:
"""
input_bundle = make_input_bundle({"code.vy": code})
with raises(SyntaxException, match="contract"):
compile_code(code, input_bundle=input_bundle)


def test_exception_reports_correct_file(make_input_bundle, chdir_tmp_path):
code_a = "def bar()>:"
code_b = "import A"
input_bundle = make_input_bundle({"A.vy": code_a, "B.vy": code_b})

with raises(SyntaxException, match=r'contract "A\.vy:\d+"'):
compile_code(code_b, input_bundle=input_bundle)


def test_syntax_exception_reports_correct_offset(make_input_bundle):
code = """
def foo():
uint256 a = pass
"""
input_bundle = make_input_bundle({"code.vy": code})

with raises(SyntaxException, match=r"line \d+:12"):
compile_code(code, input_bundle=input_bundle)
16 changes: 16 additions & 0 deletions tests/unit/ast/test_natspec.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,3 +436,19 @@ def test_natspec_parsed_implicitly():
# anything beyond ast is blocked
with pytest.raises(NatSpecSyntaxException):
compile_code(code, output_formats=["annotated_ast_dict"])


def test_natspec_exception_contains_file_path():
code = """
@external
def foo() -> (int128,uint256):
'''
@return int128
@return uint256
@return this should fail
'''
return 1, 2
"""

with pytest.raises(NatSpecSyntaxException, match=r'contract "VyperContract\.vy:\d+"'):
parse_natspec(code)
20 changes: 20 additions & 0 deletions tests/unit/ast/test_pre_parser.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from pathlib import Path

import pytest

from vyper import compile_code
Expand Down Expand Up @@ -56,6 +58,24 @@ def test_invalid_version_pragma(file_version, mock_version):
validate_version_pragma(f"{file_version}", file_version, (SRC_LINE))


def test_invalid_version_contains_file(mock_version):
mock_version(COMPILER_VERSION)
with pytest.raises(VersionException, match=r'contract "mock\.vy:\d+"'):
compile_code("# pragma version ^0.3.10", resolved_path=Path("mock.vy"))


def test_imported_invalid_version_contains_correct_file(
mock_version, make_input_bundle, chdir_tmp_path
):
code_a = "# pragma version ^0.3.10"
code_b = "import A"
input_bundle = make_input_bundle({"A.vy": code_a, "B.vy": code_b})
mock_version(COMPILER_VERSION)

with pytest.raises(VersionException, match=r'contract "A\.vy:\d+"'):
compile_code(code_b, input_bundle=input_bundle)


prerelease_valid_versions = [
"<0.1.1-beta.9",
"<0.1.1b9",
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/cli/vyper_json/test_compile_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ def test_wrong_language():

def test_exc_handler_raises_syntax(input_json):
input_json["sources"]["badcode.vy"] = {"content": BAD_SYNTAX_CODE}
with pytest.raises(SyntaxException):
with pytest.raises(SyntaxException, match=r'contract "badcode\.vy:\d+"'):
compile_json(input_json)


Expand Down
8 changes: 8 additions & 0 deletions vyper/ast/natspec.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ class NatspecOutput:


def parse_natspec(annotated_vyper_module: vy_ast.Module) -> NatspecOutput:
try:
return _parse_natspec(annotated_vyper_module)
except NatSpecSyntaxException as e:
e.resolved_path = annotated_vyper_module.resolved_path
raise e


def _parse_natspec(annotated_vyper_module: vy_ast.Module) -> NatspecOutput:
"""
Parses NatSpec documentation from a contract.
Expand Down
23 changes: 22 additions & 1 deletion vyper/ast/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@ def parse_to_ast_with_settings(
module_path: Optional[str] = None,
resolved_path: Optional[str] = None,
add_fn_node: Optional[str] = None,
) -> tuple[Settings, vy_ast.Module]:
try:
return _parse_to_ast_with_settings(
vyper_source, source_id, module_path, resolved_path, add_fn_node
)
except SyntaxException as e:
e.resolved_path = resolved_path
raise e


def _parse_to_ast_with_settings(
vyper_source: str,
source_id: int = 0,
module_path: Optional[str] = None,
resolved_path: Optional[str] = None,
add_fn_node: Optional[str] = None,
) -> tuple[Settings, vy_ast.Module]:
"""
Parses a Vyper source string and generates basic Vyper AST nodes.
Expand Down Expand Up @@ -60,7 +76,12 @@ def parse_to_ast_with_settings(
py_ast = python_ast.parse(pre_parser.reformatted_code)
except SyntaxError as e:
# TODO: Ensure 1-to-1 match of source_code:reformatted_code SyntaxErrors
raise SyntaxException(str(e), vyper_source, e.lineno, e.offset) from None
offset = e.offset
if offset is not None:
# SyntaxError offset is 1-based, not 0-based (see:
# https://docs.python.org/3/library/exceptions.html#SyntaxError.offset)
offset -= 1
raise SyntaxException(str(e.msg), vyper_source, e.lineno, offset) from None

# Add dummy function node to ensure local variables are treated as `AnnAssign`
# instead of state variables (`VariableDecl`)
Expand Down
15 changes: 13 additions & 2 deletions vyper/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ def __init__(self, message="Error Message not found.", *items, hint=None, prev_d
self.lineno = None
self.col_offset = None
self.annotations = None
self.resolved_path = None

if len(items) == 1 and isinstance(items[0], tuple) and isinstance(items[0][0], int):
# support older exceptions that don't annotate - remove this in the future!
Expand Down Expand Up @@ -127,13 +128,18 @@ def format_annotation(self, value):
module_node = node.module_node

# TODO: handle cases where module is None or vy_ast.Module
if module_node.get("path") not in (None, "<unknown>"):
node_msg = f'{node_msg}contract "{module_node.path}:{node.lineno}", '
if module_node.get("resolved_path") not in (None, "<unknown>"):
node_msg = self._format_contract_details(
node_msg, module_node.resolved_path, node.lineno
)

fn_node = node.get_ancestor(vy_ast.FunctionDef)
if fn_node:
node_msg = f'{node_msg}function "{fn_node.name}", '

elif self.resolved_path is not None:
node_msg = self._format_contract_details(node_msg, self.resolved_path, node.lineno)

col_offset_str = "" if node.col_offset is None else str(node.col_offset)
node_msg = f"{node_msg}line {node.lineno}:{col_offset_str} \n{source_annotation}\n"

Expand All @@ -151,6 +157,11 @@ def _add_hint(self, msg):
return msg
return msg + f"\n (hint: {self.hint})"

def _format_contract_details(self, msg, path, lineno):
from vyper.utils import safe_relpath

return f'{msg}contract "{safe_relpath(path)}:{lineno}", '

def __str__(self):
return self._add_hint(self._str_helper())

Expand Down

0 comments on commit c951dea

Please sign in to comment.