Skip to content

Commit 22b55c6

Browse files
feat: enhance project context and file handling in linter
1 parent d9f8462 commit 22b55c6

25 files changed

+516
-209
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,5 @@ site
1414
.ruff_cache
1515
.devcontainer
1616
.claude
17+
.mypy_cache
18+
.pytest_cache

README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ You can install Natrix using pip:
1010
pip install natrix
1111
```
1212

13-
Currently natrix requires [`uv`](https://docs.astral.sh/uv/) to be installed to function correctly.
14-
1513
### Vyper Version Compatibility
1614

1715
Natrix supports Vyper compiler versions 0.4.0 and above.

docs/api/index.md

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,29 @@ for access in func_node.memory_accesses:
147147
print(f"{access.type}: {access.var}") # "read: balance"
148148
```
149149

150+
## Project Context API
151+
152+
The `ProjectContext` class manages the dependency graph and compilation of all modules in a project:
153+
154+
```python
155+
from pathlib import Path
156+
from natrix.context import ProjectContext
157+
158+
# Create context with all project files
159+
vy_files = [Path("contract1.vy"), Path("contract2.vy")]
160+
context = ProjectContext(vy_files, extra_paths=(Path("lib"),))
161+
162+
# Get module information
163+
module = context.get_module(Path("contract1.vy"))
164+
if module:
165+
print(f"Dependencies: {module.dependencies}")
166+
print(f"Dependents: {module.dependents}")
167+
168+
# Get dependency relationships
169+
deps = context.get_dependencies_of(Path("contract1.vy"))
170+
dependents = context.get_dependents_of(Path("contract1.vy"))
171+
```
172+
150173
## Rule Development API
151174

152175
### Base Rule Class
@@ -226,15 +249,17 @@ class MyRule(BaseRule):
226249
Utilities for working with Vyper compilation:
227250

228251
```python
252+
from pathlib import Path
229253
from natrix.ast_tools import parse_file, vyper_compile, VyperASTVisitor
254+
from natrix.ast_node import Node
230255

231256
# Parse a Vyper file to AST
232-
ast_data = parse_file("contract.vy")
233-
root_node = Node(ast_data)
257+
ast_data = parse_file(Path("contract.vy"))
258+
root_node = Node(ast_data["ast"])
234259

235260
# Compile with specific format
236-
ast_only = vyper_compile("contract.vy", "annotated_ast")
237-
metadata = vyper_compile("contract.vy", "metadata")
261+
ast_only = vyper_compile(Path("contract.vy"), "annotated_ast")
262+
metadata = vyper_compile(Path("contract.vy"), "metadata")
238263
```
239264

240265
### VyperASTVisitor
@@ -321,6 +346,8 @@ class FunctionNamingRule(BaseRule):
321346

322347
```python
323348
import pytest
349+
from pathlib import Path
350+
from natrix.context import ProjectContext
324351
from natrix.ast_tools import parse_file
325352
from natrix.ast_node import Node
326353

@@ -337,12 +364,14 @@ def good_function_name(): # Should pass
337364
"""
338365

339366
# Parse and test
340-
with open("test.vy", "w") as f:
367+
test_path = Path("test.vy")
368+
with open(test_path, "w") as f:
341369
f.write(test_contract)
342370

343-
ast_data = parse_file("test.vy")
371+
# Create context and run rule
372+
context = ProjectContext([test_path])
344373
rule = FunctionNamingRule()
345-
issues = rule.run(ast_data)
374+
issues = rule.run(context, test_path)
346375

347376
assert len(issues) == 1
348377
assert "badFunctionName" in issues[0].message

natrix/__init__.py

Lines changed: 39 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,13 @@
1616
import tomli as tomllib
1717

1818
from natrix.__version__ import __version__
19-
from natrix.ast_tools import parse_file
2019
from natrix.codegen import generate_exports
20+
from natrix.context import ProjectContext
2121
from natrix.rules.common import Issue, RuleRegistry
2222

23+
# Vyper file extensions
24+
VYPER_EXTENSIONS = (".vy", ".vyi")
25+
2326

2427
class OutputFormatter:
2528
"""Handles output formatting for both CLI and JSON modes."""
@@ -67,14 +70,12 @@ def print_summary(self, has_issues: bool) -> None:
6770

6871

6972
def lint_file(
70-
file_path: str,
73+
file_path: Path,
74+
project_context: ProjectContext,
7175
formatter: OutputFormatter,
7276
disabled_rules: set[str] | None = None,
73-
extra_paths: tuple[str, ...] = (),
7477
) -> list[Issue]:
75-
"""Lint a single Vyper file with the given rules configuration."""
76-
ast = parse_file(file_path, extra_paths=extra_paths)
77-
78+
"""Lint a single Vyper file using the pre-built project context."""
7879
if disabled_rules is None:
7980
disabled_rules = set()
8081

@@ -85,7 +86,7 @@ def lint_file(
8586
issues = []
8687
for rule in rules:
8788
try:
88-
rule_issues = rule.run(ast)
89+
rule_issues = rule.run(project_context, file_path)
8990
issues.extend(
9091
[issue for issue in rule_issues if issue.code not in disabled_rules]
9192
)
@@ -99,30 +100,31 @@ def lint_file(
99100
return issues
100101

101102

102-
def find_vy_files(directory: str) -> list[str]:
103-
# Recursively find all .vy files in the given directory,
103+
def find_vy_files(directory: Path) -> list[Path]:
104+
# Recursively find all Vyper files (.vy and .vyi) in the given directory,
104105
# excluding specified directories
105106
vy_files = []
106107
for root, _, files in os.walk(directory):
107-
# Collect all .vy files
108+
# Collect all Vyper files
108109
for file in files:
109-
if file.endswith(".vy"):
110-
vy_files.append(str(Path(root) / file))
110+
file_path = Path(root) / file
111+
if file_path.suffix in VYPER_EXTENSIONS:
112+
vy_files.append(file_path)
111113

112114
return vy_files
113115

114116

115-
def get_project_root() -> str:
117+
def get_project_root() -> Path:
116118
"""Get the project root directory, which contains the pyproject.toml file."""
117119
# First try to find it from the current working directory upwards
118120
current_path = Path.cwd().resolve()
119121

120122
for parent in [current_path, *current_path.parents]:
121123
if (parent / "pyproject.toml").exists():
122-
return str(parent)
124+
return parent
123125

124126
# If not found, default to the current directory
125-
return str(Path.cwd())
127+
return Path.cwd()
126128

127129

128130
def read_pyproject_config() -> dict[str, Any]:
@@ -137,7 +139,7 @@ def read_pyproject_config() -> dict[str, Any]:
137139
try:
138140
# Find the project root directory
139141
project_root = get_project_root()
140-
pyproject_path = Path(project_root) / "pyproject.toml"
142+
pyproject_path = project_root / "pyproject.toml"
141143

142144
if pyproject_path.exists():
143145
with pyproject_path.open("rb") as f:
@@ -149,7 +151,7 @@ def read_pyproject_config() -> dict[str, Any]:
149151
):
150152
# Make paths relative to pyproject.toml location
151153
config["files"] = [
152-
str((Path(project_root) / path).resolve())
154+
(project_root / path).resolve()
153155
for path in natrix_config["files"]
154156
]
155157
if "disabled_rules" in natrix_config and isinstance(
@@ -167,7 +169,7 @@ def read_pyproject_config() -> dict[str, Any]:
167169
):
168170
# Make paths relative to pyproject.toml location
169171
config["path"] = [
170-
str((Path(project_root) / path).resolve())
172+
(project_root / path).resolve()
171173
for path in natrix_config["path"]
172174
]
173175
except Exception as e:
@@ -280,9 +282,9 @@ def main() -> None:
280282
if args.command == "codegen":
281283
if args.codegen_command == "exports":
282284
# Get extra paths if provided
283-
extra_paths = tuple(args.path) if args.path else ()
285+
extra_paths = tuple(Path(p) for p in args.path) if args.path else ()
284286
# Generate and print exports
285-
exports = generate_exports(args.file_path, extra_paths)
287+
exports = generate_exports(Path(args.file_path), extra_paths)
286288
print(exports)
287289
sys.exit(0)
288290
else:
@@ -361,36 +363,43 @@ def main() -> None:
361363
if args.files:
362364
all_vy_files = []
363365
for path in args.files:
364-
path_obj = Path(path)
365-
if path_obj.is_file() and path.endswith(".vy"):
366+
path = Path(path)
367+
if path.is_file() and path.suffix in VYPER_EXTENSIONS:
366368
all_vy_files.append(path)
367-
elif path_obj.is_dir():
369+
elif path.is_dir():
368370
dir_vy_files = find_vy_files(path)
369371
if not dir_vy_files:
370-
formatter.print(f"No .vy files found in the directory: {path}")
372+
formatter.print(f"No Vyper files found in the directory: {path}")
371373
all_vy_files.extend(dir_vy_files)
372374
else:
373375
formatter.print(
374-
f"Provided path is not a valid .vy file or directory: {path}"
376+
f"Provided path is not a valid Vyper file or directory: {path}"
375377
)
376378

377379
if not all_vy_files:
378-
formatter.print("No valid .vy files to lint.")
380+
formatter.print("No valid Vyper files to lint.")
379381
sys.exit(1)
380382
else:
381-
# If no paths are provided, search for .vy files in the current
383+
# If no paths are provided, search for Vyper files in the current
382384
# directory recursively
383-
all_vy_files = find_vy_files(".")
385+
all_vy_files = find_vy_files(Path())
384386

385387
if not all_vy_files:
386-
formatter.print("No .vy files found in the current directory.")
388+
formatter.print("No Vyper files found in the current directory.")
387389
sys.exit(1)
388390

391+
# Create ProjectContext with all files
392+
formatter.print("Building project dependency graph...")
393+
project_context = ProjectContext(
394+
all_vy_files,
395+
extra_paths=tuple(Path(p) if isinstance(p, str) else p for p in extra_paths),
396+
)
397+
389398
# Collect all issues from all files
390399
all_issues: list[Issue] = []
391400
for file in all_vy_files:
392401
file_issues = lint_file(
393-
file, formatter, disabled_rules, extra_paths=extra_paths
402+
Path(file).resolve(), project_context, formatter, disabled_rules
394403
)
395404
all_issues.extend(file_issues)
396405

natrix/ast_tools.py

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def _check_vyper_version() -> None:
3838
) from e
3939

4040

41-
def _obtain_sys_path() -> list[str]:
41+
def _obtain_sys_path() -> list[Path]:
4242
"""
4343
Obtain all the system paths in which the compiler would
4444
normally look for modules. This allows to lint vyper files
@@ -55,45 +55,37 @@ def _obtain_sys_path() -> list[str]:
5555
# Remove the first element of the list which is an empty string.
5656
paths = ast.literal_eval(stdout)[1:]
5757

58-
# Remove paths that do not exist as the vyper compiler will throw an error.
59-
valid_paths = [path for path in paths if Path(path).exists()]
58+
return [Path(path) for path in paths]
6059

61-
return valid_paths
6260

63-
64-
def _obtain_default_paths() -> list[str]:
61+
def _obtain_default_paths() -> list[Path]:
6562
"""
6663
Obtain default paths for Vyper imports.
67-
Only returns paths that actually exist on the system.
6864
"""
6965
# List of default paths to check
7066
default_paths = [
71-
"lib/pypi", # Default dependency folder for moccasin
67+
Path("lib/pypi"), # Default dependency folder for moccasin
7268
# Add more paths here in the future
7369
]
7470

75-
# Return only existing paths
76-
existing_paths = []
77-
for path in default_paths:
78-
path_obj = Path(path)
79-
if path_obj.exists() and path_obj.is_dir():
80-
existing_paths.append(path)
81-
82-
return existing_paths
71+
return default_paths
8372

8473

8574
def vyper_compile(
86-
filename: str, formatting: str, extra_paths: tuple[str, ...] = ()
75+
filename: Path, formatting: str, extra_paths: tuple[Path, ...] = ()
8776
) -> dict[str, Any] | list[dict[str, Any]]:
8877
_check_vyper_version()
8978

9079
# Combine all paths
9180
all_paths = _obtain_sys_path() + _obtain_default_paths() + list(extra_paths)
9281

93-
# Convert to compiler flags
94-
path_flags = [item for p in all_paths for item in ["-p", p]]
82+
# Filter out non-existent paths as the vyper compiler will throw an error
83+
valid_paths = [p for p in all_paths if p.exists()]
9584

96-
command = ["vyper", "-f", formatting, filename, *path_flags]
85+
# Convert to compiler flags (vyper expects strings)
86+
path_flags = [item for p in valid_paths for item in ["-p", str(p)]]
87+
88+
command = ["vyper", "-f", formatting, str(filename), *path_flags]
9789

9890
process = subprocess.Popen(
9991
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
@@ -113,11 +105,15 @@ def vyper_compile(
113105
) from e
114106

115107

116-
def parse_file(file_path: str, extra_paths: tuple[str, ...] = ()) -> dict[str, Any]:
108+
def parse_file(file_path: Path, extra_paths: tuple[Path, ...] = ()) -> dict[str, Any]:
117109
ast = vyper_compile(file_path, "annotated_ast", extra_paths=extra_paths)
118110
# For annotated_ast, vyper_compile returns a dict
119111
assert isinstance(ast, dict)
120112

113+
# For interface files (.vyi), we only compile to AST, not metadata
114+
if file_path.suffix == ".vyi":
115+
return ast
116+
121117
# Try to compile metadata, but handle InitializerException gracefully
122118
# This happens when a module uses deferred initialization (uses: module_name)
123119
try:
@@ -160,7 +156,7 @@ def parse_source(source_code: str) -> dict[str, Any]:
160156

161157
try:
162158
# Parse the temporary file
163-
result = parse_file(temp_file_path)
159+
result = parse_file(Path(temp_file_path))
164160
return result
165161
finally:
166162
# Clean up the temporary file

natrix/codegen.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from natrix.ast_tools import vyper_compile
66

77

8-
def generate_exports(file_path: str, extra_paths: tuple[str, ...]) -> str:
8+
def generate_exports(file_path: Path, extra_paths: tuple[Path, ...]) -> str:
99
"""Generate explicit exports for a Vyper contract.
1010
1111
Args:
@@ -16,7 +16,7 @@ def generate_exports(file_path: str, extra_paths: tuple[str, ...]) -> str:
1616
A string containing the exports declaration
1717
"""
1818
# Extract module name from file path
19-
module_name = Path(file_path).stem
19+
module_name = file_path.stem
2020

2121
# Get the ABI from vyper
2222
abi = vyper_compile(file_path, "abi", extra_paths=extra_paths)

0 commit comments

Comments
 (0)