Skip to content

Commit

Permalink
chore[ux]: compute natspec as part of standard pipeline (vyperlang#3946)
Browse files Browse the repository at this point in the history
prior to this commit, natspec had to be explicitly requested by the user
(`-f userdoc`, `-f devdoc` or `-f combined_json`). this would lead to
discrepancies between development time and verification time, where a
contract could compile locally (because no natspec was requested), but
not on the verifier's pipeline (because the verifier would request the
natspec via one of the above methods). this commit computes the natspec
as part of the dependencies of the analysed AST (the reasoning being
that, a consumer might expect semantic analysis to include natspec
validation). so, there is no way to produce bytecode for a contract
without validating natspec.
  • Loading branch information
charles-cooper authored Apr 14, 2024
1 parent 6f09e29 commit 074073f
Show file tree
Hide file tree
Showing 4 changed files with 61 additions and 24 deletions.
51 changes: 35 additions & 16 deletions tests/unit/ast/test_natspec.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import pytest

from vyper import ast as vy_ast
from vyper.compiler import compile_code
from vyper.compiler.phases import CompilerData
from vyper.exceptions import NatSpecSyntaxException

Expand Down Expand Up @@ -65,10 +66,10 @@ def parse_natspec(code):


def test_documentation_example_output():
userdoc, devdoc = parse_natspec(test_code)
natspec = parse_natspec(test_code)

assert userdoc == expected_userdoc
assert devdoc == expected_devdoc
assert natspec.userdoc == expected_userdoc
assert natspec.devdoc == expected_devdoc


def test_no_tags_implies_notice():
Expand All @@ -84,13 +85,13 @@ def foo():
pass
"""

userdoc, devdoc = parse_natspec(code)
natspec = parse_natspec(code)

assert userdoc == {
assert natspec.userdoc == {
"methods": {"foo()": {"notice": "This one too!"}},
"notice": "Because there is no tag, this docstring is handled as a notice.",
}
assert not devdoc
assert natspec.devdoc == {}


def test_whitespace():
Expand All @@ -111,9 +112,9 @@ def test_whitespace():
@author Mr No-linter
'''
"""
_, devdoc = parse_natspec(code)
natspec = parse_natspec(code)

assert devdoc == {
assert natspec.devdoc == {
"author": "Mr No-linter",
"details": "Whitespace gets cleaned up, people can use awful formatting. We don't mind!",
}
Expand All @@ -131,9 +132,9 @@ def foo(bar: int128, baz: uint256, potato: bytes32):
pass
"""

_, devdoc = parse_natspec(code)
natspec = parse_natspec(code)

assert devdoc == {
assert natspec.devdoc == {
"methods": {
"foo(int128,uint256,bytes32)": {
"details": "we didn't document potato, but that's ok",
Expand All @@ -154,9 +155,9 @@ def foo(bar: int128, baz: uint256) -> (int128, uint256):
return bar, baz
"""

_, devdoc = parse_natspec(code)
natspec = parse_natspec(code)

assert devdoc == {
assert natspec.devdoc == {
"methods": {
"foo(int128,uint256)": {"returns": {"_0": "value of bar", "_1": "value of baz"}}
}
Expand All @@ -176,9 +177,9 @@ def notfoo(bar: int128, baz: uint256):
pass
"""

_, devdoc = parse_natspec(code)
natspec = parse_natspec(code)

assert devdoc["methods"] == {"foo(int128,uint256)": {"details": "I will be parsed."}}
assert natspec.devdoc["methods"] == {"foo(int128,uint256)": {"details": "I will be parsed."}}


def test_partial_natspec():
Expand Down Expand Up @@ -276,9 +277,9 @@ def foo():
pass
"""

_, devdoc = parse_natspec(code)
natspec = parse_natspec(code)

assert devdoc == {"license": license}
assert natspec.devdoc == {"license": license}


fields = ["title", "author", "license", "notice", "dev"]
Expand Down Expand Up @@ -417,3 +418,21 @@ def foo() -> (int128,uint256):
NatSpecSyntaxException, match="Number of documented return values exceeds actual number"
):
parse_natspec(code)


def test_natspec_parsed_implicitly():
# test natspec is parsed even if not explicitly requested
code = """
'''
@noticee x
'''
"""
with pytest.raises(NatSpecSyntaxException):
parse_natspec(code)

# check we can get ast
compile_code(code, output_formats=["ast_dict"])

# anything beyond ast is blocked
with pytest.raises(NatSpecSyntaxException):
compile_code(code, output_formats=["annotated_ast_dict"])
11 changes: 9 additions & 2 deletions vyper/ast/natspec.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import re
from dataclasses import dataclass
from typing import Optional, Tuple

from asttokens import LineNumbers
Expand All @@ -11,7 +12,13 @@
USERDOCS_FIELDS = ("notice",)


def parse_natspec(annotated_vyper_module: vy_ast.Module) -> Tuple[dict, dict]:
@dataclass
class NatspecOutput:
userdoc: dict
devdoc: dict


def parse_natspec(annotated_vyper_module: vy_ast.Module) -> NatspecOutput:
"""
Parses NatSpec documentation from a contract.
Expand Down Expand Up @@ -63,7 +70,7 @@ def parse_natspec(annotated_vyper_module: vy_ast.Module) -> Tuple[dict, dict]:
if fn_natspec:
devdoc.setdefault("methods", {})[method_id] = fn_natspec

return userdoc, devdoc
return NatspecOutput(userdoc=userdoc, devdoc=devdoc)


def _parse_docstring(
Expand Down
8 changes: 3 additions & 5 deletions vyper/compiler/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from collections import deque
from pathlib import PurePath

from vyper.ast import ast_to_dict, parse_natspec
from vyper.ast import ast_to_dict
from vyper.codegen.ir_node import IRnode
from vyper.compiler.phases import CompilerData
from vyper.compiler.utils import build_gas_estimates
Expand Down Expand Up @@ -30,13 +30,11 @@ def build_annotated_ast_dict(compiler_data: CompilerData) -> dict:


def build_devdoc(compiler_data: CompilerData) -> dict:
userdoc, devdoc = parse_natspec(compiler_data.annotated_vyper_module)
return devdoc
return compiler_data.natspec.devdoc


def build_userdoc(compiler_data: CompilerData) -> dict:
userdoc, devdoc = parse_natspec(compiler_data.annotated_vyper_module)
return userdoc
return compiler_data.natspec.userdoc


def build_external_interface_output(compiler_data: CompilerData) -> str:
Expand Down
15 changes: 14 additions & 1 deletion vyper/compiler/phases.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import Optional

from vyper import ast as vy_ast
from vyper.ast import natspec
from vyper.codegen import module
from vyper.codegen.ir_node import IRnode
from vyper.compiler.input_bundle import FileInput, FilesystemInputBundle, InputBundle
Expand Down Expand Up @@ -150,9 +151,19 @@ def _generate_ast(self):
def vyper_module(self):
return self._generate_ast

@cached_property
def _annotate(self) -> tuple[natspec.NatspecOutput, vy_ast.Module]:
module = generate_annotated_ast(self.vyper_module, self.input_bundle)
nspec = natspec.parse_natspec(module)
return nspec, module

@cached_property
def natspec(self) -> natspec.NatspecOutput:
return self._annotate[0]

@cached_property
def annotated_vyper_module(self) -> vy_ast.Module:
return generate_annotated_ast(self.vyper_module, self.input_bundle)
return self._annotate[1]

@cached_property
def compilation_target(self):
Expand All @@ -173,6 +184,8 @@ def storage_layout(self) -> StorageLayout:
def global_ctx(self) -> ModuleT:
# ensure storage layout is computed
_ = self.storage_layout
# ensure natspec is computed
_ = self.natspec
return self.annotated_vyper_module._metadata["type"]

@cached_property
Expand Down

0 comments on commit 074073f

Please sign in to comment.