Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(test): test contract parsing #1

Open
wants to merge 39 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
cf7e91e
feat(test): test contract parsing
BobTheBuidler Nov 2, 2024
5ab9047
fix(test): fix tests
BobTheBuidler Nov 2, 2024
533f165
fix(mypy): fix type errs
BobTheBuidler Nov 2, 2024
ebbd631
feat(test): test more complex contract
BobTheBuidler Nov 2, 2024
7d6c216
feat: Tuple length
BobTheBuidler Nov 2, 2024
445da99
chore: black .
BobTheBuidler Nov 2, 2024
826625c
fix: broken imports
BobTheBuidler Nov 2, 2024
d42b450
chore: black .
BobTheBuidler Nov 2, 2024
bbdea85
fix: missing arg
BobTheBuidler Nov 2, 2024
2f8c0dc
fix(mypy): fix type errs
BobTheBuidler Nov 2, 2024
353a136
fix: unused var
BobTheBuidler Nov 2, 2024
71311bf
chore: ruff
BobTheBuidler Nov 2, 2024
105aa59
fix: struct formatting
BobTheBuidler Nov 2, 2024
b881e9d
fix(test): fix test_parser
BobTheBuidler Nov 2, 2024
ab4718a
fix(test): fix test_parser
BobTheBuidler Nov 2, 2024
a647185
fix: Structs section header
BobTheBuidler Nov 2, 2024
a64715e
chore: refactor regex
BobTheBuidler Nov 2, 2024
4c3a5b9
feat: use http.server instead of socketserver
BobTheBuidler Nov 2, 2024
bae1e35
feat: document enums and events
BobTheBuidler Nov 2, 2024
0e3e96a
feat: bifurcate internal and external functions
BobTheBuidler Nov 2, 2024
39c2359
fix: broken import
BobTheBuidler Nov 2, 2024
ec0a9b0
chore: cleanup
BobTheBuidler Nov 2, 2024
cf9a44b
chore: cleanup
BobTheBuidler Nov 2, 2024
d7b6b9d
fix: enum matcher
BobTheBuidler Nov 2, 2024
dfefb55
feat: document variables
BobTheBuidler Nov 2, 2024
19fb3a2
fix: variable parsing
BobTheBuidler Nov 2, 2024
450b5ce
fix: variable parsing
BobTheBuidler Nov 2, 2024
0c7a6a6
fix: temp disable variable docs
BobTheBuidler Nov 2, 2024
4dac5a5
feat(test): test new parsers
BobTheBuidler Nov 2, 2024
1a16c40
feat(test): test docs generationn for new objs
BobTheBuidler Nov 2, 2024
f6ada67
fix(test): various errs
BobTheBuidler Nov 2, 2024
6a9941a
feat(test): test indexed event param parsing
BobTheBuidler Nov 2, 2024
36c5774
fix: extract structs from source
BobTheBuidler Nov 2, 2024
0a57590
fix(test): fix tests
BobTheBuidler Nov 2, 2024
bce681e
fix: missing imports
BobTheBuidler Nov 2, 2024
f596041
chore: refactor
BobTheBuidler Nov 2, 2024
2caa7ba
fix: name err
BobTheBuidler Nov 2, 2024
72b2a9d
feat(test): increase test verbosity
BobTheBuidler Nov 2, 2024
620b22b
fix: name err
BobTheBuidler Nov 2, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -74,5 +74,5 @@ ignore_missing_imports = true

[tool.pytest.ini_options]
minversion = "7.0"
addopts = "-ra -q --cov=sphinx_autodoc_vyper"
addopts = "-ra -q --cov=sphinx_autodoc_vyper -vv"
testpaths = ["tests"]
44 changes: 38 additions & 6 deletions sphinx_autodoc_vyper/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,41 @@ def _generate_contract_docs(self, contracts: List[Contract]) -> None:
if contract.docstring:
content += f"{contract.docstring}\n\n"

if contract.enums:
content += _insert_content_section("Enums")
for enum in contract.enums:
content += enum.generate_docs()

if contract.structs:
content += "Structs\n---------\n\n"
content += _insert_content_section("Structs")
for struct in contract.structs:
content += self._generate_struct_docs(struct)

if contract.functions:
content += "Functions\n---------\n\n"
for func in contract.functions:
if contract.events:
content += _insert_content_section("Events")
for event in contract.events:
content += event.generate_docs()

if contract.constants:
content += _insert_content_section("Constants")
for constant in contract.constants:
content += constant.generate_docs()

# TODO: fix this
# if contract.variables:
# content += _insert_content_section("Variables")
# for variable in contract.variables:
# content += f".. py:attribute:: {variable.name}\n\n"
# content += f" {f'public({variable.type})' if variable.visibility == 'public' else variable.type}"

if contract.external_functions:
content += _insert_content_section("External Functions")
for func in contract.external_functions:
content += self._generate_function_docs(func)

if contract.internal_functions:
content += _insert_content_section("Internal Functions")
for func in contract.internal_functions:
content += self._generate_function_docs(func)

with open(
Expand All @@ -100,8 +127,13 @@ def _generate_function_docs(func: Function) -> str:

@staticmethod
def _generate_struct_docs(struct: Struct) -> str:
content = f".. py:class:: {func.name}\n\n"
content = f".. py:class:: {struct.name}\n\n"
for field in struct.fields:
content += f" .. py:attribute:: {struct.name}.{field.name}\n\n"
content += f" {field.type}"
content += f" {field.type}\n\n"
return content


def _insert_content_section(name: str) -> str:
"""Insert a hyperlinked content section accessible from the docs index."""
return f"{name}\n{'-' * len(name)}\n\n"
222 changes: 191 additions & 31 deletions sphinx_autodoc_vyper/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import logging
import os
import re
import typing
from dataclasses import dataclass
from pathlib import Path
from typing import Any, List, Optional, Union
Expand All @@ -13,9 +14,32 @@
valid_uints = {f"uint{8 * (i+1)}" for i in range(32)}
VALID_VYPER_TYPES = {*valid_ints, *valid_uints, "address", "bool", "Bytes", "String"}

ENUM_PATTERN = r"enum\s+(\w+)\s*:\s*([\w\s\n]+)"
CONSTANT_PATTERN = r"(\w+):\s*constant\((\w+)\)\s*=\s*(.*?)$"
VARIABLE_PATTERN = r"(\w+):\s*(public\((\w+)\)|(\w+))"
STRUCT_PATTERN = r"struct\s+(\w+)\s*{([^}]*)}"
EVENT_PATTERN = r"event\s+(\w+)\((.*?)\)"
FUNCTION_PATTERN = r'@(external|internal)\s+def\s+([^(]+)\(([^)]*)\)(\s*->\s*[^:]+)?:\s*("""[\s\S]*?""")?'
PARAM_PATTERN = r"(\w+:\s*DynArray\[[^\]]+\]|\w+:\s*\w+)"


@dataclass
class Enum:
"""Vyper enum representation."""

name: str
values: List[str]

def generate_docs(self) -> str:
content = f".. py:class:: {self.name}\n\n"
for value in self.values:
content += f" .. py:attribute:: {value}\n\n"
return content


@dataclass
class Constant:
"""Vyper constant representation."""

name: str
type: str
Expand All @@ -25,9 +49,27 @@ def __post_init__(self) -> None:
if self.type is not None and self.type not in VALID_VYPER_TYPES:
logger.warning(f"{self} is not a valid Vyper type")

def generate_docs(self) -> str:
return f".. py:data:: {self.name}\n\n {self.type}: {self.value}\n\n"


@dataclass
class Variable:
"""Vyper variable representation."""

name: str
type: str
visibility: str

def __post_init__(self) -> None:
if self.type not in VALID_VYPER_TYPES:
logger.warning(f"{self} is not a valid Vyper type")


@dataclass
class Tuple:
"""Vyper tuple representation."""

types: List[str]

def __post_init__(self) -> None:
Expand All @@ -39,6 +81,9 @@ def __post_init__(self) -> None:
if type not in VALID_VYPER_TYPES:
logger.warning(f"{self} is not a valid Vyper type")

def __len__(self) -> int:
return len(self.types)


@dataclass
class DynArray:
Expand Down Expand Up @@ -84,6 +129,35 @@ class Struct:
fields: List[Parameter]


@dataclass
class EventParameter:
"""Vyper event parameter representation."""

name: str
type: str
indexed: bool

def __post_init__(self) -> None:
if self.type not in VALID_VYPER_TYPES:
logger.warning(f"{self} is not a valid Vyper type")


@dataclass
class Event:
"""Vyper event representation."""

name: str
params: List[EventParameter]

def generate_docs(self) -> str:
content = f".. py:class:: {self.name}\n\n"
for param in self.params:
type_str = f"indexed({param.type})" if param.indexed else param.type
content += f" .. py:attribute:: {param.name}\n\n"
content += f" {type_str}\n\n"
return content


@dataclass
class Function:
"""Vyper function representation."""
Expand All @@ -108,8 +182,16 @@ class Contract:
name: str
path: str
docstring: Optional[str]
enums: List[Enum]
structs: List[Struct]
functions: List[Function]
events: List[Event]
constants: List[Constant]
variables: List[Variable]
external_functions: List[Function]
internal_functions: List[Function]


Functions = typing.Tuple[List[Function], List[Function]]


class VyperParser:
Expand Down Expand Up @@ -139,48 +221,115 @@ def _parse_contract(self, file_path: str) -> Contract:
name = os.path.basename(file_path).replace(".vy", "")
rel_path = os.path.relpath(file_path, self.contracts_dir)

# Extract contract docstring
docstring = self._extract_contract_docstring(content)

# Extract structs
structs = self._extract_structs(content)

# Extract functions
functions = self._extract_functions(content)
external_functions, internal_functions = self._extract_functions(content)

return Contract(
name=name,
path=rel_path,
docstring=docstring,
structs=structs,
functions=functions,
docstring=self._extract_contract_docstring(content),
enums=self._extract_enums(content),
structs=self._extract_structs(content),
events=self._extract_events(content),
constants=self._extract_constants(content),
variables=self._extract_variables(content),
external_functions=external_functions,
internal_functions=internal_functions,
)

def _extract_contract_docstring(self, content: str) -> Optional[str]:
"""Extract the contract's main docstring."""
match = re.search(r'^"""(.*?)"""', content, re.DOTALL | re.MULTILINE)
return match.group(1).strip() if match else None

def _extract_structs(self, content: str) -> List[Struct]:
"""Extract all structs from the contract."""
structs = []
struct_pattern = r"struct\s+(\w+)\s*{([^}]*)}"
@staticmethod
def _extract_enums(content: str) -> List[Enum]:
"""Extract all enums from the contract."""
return [
Enum(
name=match.group(1).strip(),
values=[
value.strip()
for value in match.group(2).split("\n")
if value.strip()
],
)
for match in re.finditer(ENUM_PATTERN, content)
]

def _extract_constants(self, content: str) -> List[Constant]:
"""Extract constants from the contract."""
return [
Constant(
name=match.group(1).strip(),
type=match.group(2).strip(),
value=match.group(3).strip(),
)
for match in re.finditer(CONSTANT_PATTERN, content, re.MULTILINE)
]

for match in re.finditer(struct_pattern, content):
name = match.group(1).strip()
fields_str = match.group(2).strip()
fields = self._parse_params(fields_str)
structs.append(Struct(name=name, fields=fields))
@staticmethod
def _extract_variables(content: str) -> List[Variable]:
"""Extract all contract-level variables from the contract."""
# Split the content into lines and filter out lines that are likely inside functions
lines = content.splitlines()
contract_level_lines = []
inside_function = False

for line in lines:
stripped_line = line.strip()
# Detect function definitions to avoid parsing their contents
if stripped_line.startswith("@") or stripped_line.startswith("def "):
inside_function = True
elif stripped_line == "":
inside_function = False

# Only consider lines outside of functions
if not inside_function and stripped_line:
contract_level_lines.append(stripped_line)

# Join the filtered lines back into a single string for regex processing
filtered_content = "\n".join(contract_level_lines)

return [
Variable(
name=match.group(1).strip(),
type=match.group(3).strip()
if match.group(3)
else match.group(4).strip(),
visibility="public" if match.group(2) else "private",
)
for match in re.finditer(VARIABLE_PATTERN, filtered_content)
]

return structs
@classmethod
def _extract_structs(cls, content: str) -> List[Struct]:
"""Extract all structs from the contract."""
return [
Struct(
name=match.group(1).strip(),
fields=cls._parse_params(match.group(2).strip()),
)
for match in re.finditer(STRUCT_PATTERN, content)
]

def _extract_functions(self, content: str) -> List[Function]:
@staticmethod
def _extract_events(content: str) -> List[Event]:
"""Extract all events from the contract."""
return [
Event(
name=match.group(1).strip(),
params=self._parse_event_params(match.group(2).strip()),
)
for match in re.finditer(EVENT_PATTERN, content)
]

@classmethod
def _extract_functions(cls, content: str) -> Functions:
"""Extract all functions from the contract, with @external functions listed first."""
external_functions = []
internal_functions = []
function_pattern = r'@(external|internal)\s+def\s+([^(]+)\(([^)]*)\)(\s*->\s*[^:]+)?:\s*("""[\s\S]*?""")?'

for match in re.finditer(function_pattern, content):
for match in re.finditer(FUNCTION_PATTERN, content):
decorator = match.group(1).strip()
name = match.group(2).strip()
params_str = match.group(3).strip()
Expand All @@ -189,10 +338,9 @@ def _extract_functions(self, content: str) -> List[Function]:
)
docstring = match.group(5)[3:-3].strip() if match.group(5) else None

params = self._parse_params(params_str)
function = Function(
name=name,
params=params,
params=cls._parse_params(params_str),
return_type=return_type,
docstring=docstring,
)
Expand All @@ -202,8 +350,7 @@ def _extract_functions(self, content: str) -> List[Function]:
else:
internal_functions.append(function)

# Combine external and internal functions, with external functions first
return external_functions + internal_functions
return external_functions, internal_functions

@staticmethod
def _parse_params(params_str: str) -> List[Parameter]:
Expand All @@ -213,10 +360,23 @@ def _parse_params(params_str: str) -> List[Parameter]:

params = []
# Use regex to split by commas that are not within brackets
param_pattern = r"(\w+:\s*DynArray\[[^\]]+\]|\w+:\s*\w+)"
for param in re.finditer(param_pattern, params_str):
for param in re.finditer(PARAM_PATTERN, params_str):
name, type_str = param.group().split(":")
type_str = type_str.strip()
typ = Tuple(type_str[1:-1].split(",")) if type_str[1] == "(" else type_str
params.append(Parameter(name=name.strip(), type=typ))
return params

@staticmethod
def _parse_event_params(params_str: str) -> List[EventParameter]:
"""Parse event parameters."""
params = []
for param_str in params_str.split("\n"):
name, type = param_str.strip().split(":")
type = type.strip()
if indexed := "indexed" in type:
assert type.startswith("indexed(") and type.endswith(")"), type
type = type[8:-1]
param = EventParameter(name=name.strip(), type=type, indexed=indexed)
params.append(param)
return params
Loading
Loading