Skip to content

Commit b40f815

Browse files
danceratopzSamWilsn
authored andcommitted
feat(spec-tool): enforce @final on leaf dataclasses
Add a `FinalDecoratorHygiene` lint rule that flags any leaf dataclass (a `@dataclass` or `@slotted_freezable` class never used as a base) that is missing `@final`. Marking leaf dataclasses `@final` lets `mypyc` bypass the vtable for method calls and property accessors. The rule scans the whole specification once at the first fork position: every fork's modules plus the shared modules such as `ethereum.state` and `ethereum.trace`, including each package's `__init__.py`. Register the rule in `vulture_whitelist.py` since lints are discovered dynamically.
1 parent 9c2813e commit b40f815

2 files changed

Lines changed: 153 additions & 0 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""
2+
Final Decorator Lint.
3+
4+
Ensures that leaf dataclasses are decorated with `@final`.
5+
"""
6+
7+
import ast
8+
import importlib
9+
import inspect
10+
import pkgutil
11+
from typing import Generator, List, Optional, Sequence, Set, Tuple
12+
13+
from ethereum_spec_tools.forks import Hardfork
14+
from ethereum_spec_tools.lint import Diagnostic, Lint
15+
16+
DATACLASS_DECORATORS = {"dataclass", "slotted_freezable"}
17+
FINAL_DECORATOR = "final"
18+
19+
20+
def _spec_sources(
21+
forks: List[Hardfork],
22+
) -> Generator[Tuple[str, str], None, None]:
23+
"""
24+
Yield the name and source of every module in the specification.
25+
26+
This spans each fork's modules as well as the shared, fork-independent
27+
modules such as `ethereum.state` and `ethereum.trace`. The fork packages
28+
are walked individually because `ethereum.forks` is a namespace package
29+
that `walk_packages` does not descend into from the `ethereum` root. The
30+
package roots are listed explicitly because `walk_packages` never yields
31+
the package it is walking, so their `__init__.py` files would otherwise
32+
be skipped.
33+
"""
34+
names: List[str] = ["ethereum"]
35+
for fork in forks:
36+
names.append(fork.name)
37+
names += [mod_info.name for mod_info in fork.walk_packages()]
38+
39+
root = importlib.import_module("ethereum")
40+
names += [
41+
mod_info.name
42+
for mod_info in pkgutil.walk_packages(root.__path__, "ethereum.")
43+
]
44+
45+
for name in names:
46+
mod = importlib.import_module(name)
47+
yield mod.__name__, inspect.getsource(mod)
48+
49+
50+
class FinalDecoratorHygiene(Lint):
51+
"""
52+
Ensure that every leaf dataclass is decorated with `@final`.
53+
54+
A *leaf* class is one that is never used as a base class anywhere in the
55+
specification. Marking such classes `@final` lets `mypyc` bypass the
56+
vtable for method calls and property accessors. Classes that are
57+
subclassed are skipped (they are not leaves), as are non-dataclass types
58+
such as enums, protocols, exceptions, and constant namespaces.
59+
"""
60+
61+
def lint(
62+
self, forks: List[Hardfork], position: int
63+
) -> Sequence[Diagnostic]:
64+
"""
65+
Flag leaf dataclasses that are missing `@final`.
66+
67+
The check spans every fork and the shared modules at once, so it only
68+
does work at the first position.
69+
"""
70+
if position != 0:
71+
return []
72+
73+
bases: Set[str] = set()
74+
candidates: List[Tuple[str, int, str]] = []
75+
for name, source in _spec_sources(forks):
76+
visitor = self._parse(source, _Visitor())
77+
bases |= visitor.bases
78+
for lineno, class_name in visitor.undecorated:
79+
candidates.append((name, lineno, class_name))
80+
81+
diagnostics: List[Diagnostic] = []
82+
for name, lineno, class_name in candidates:
83+
if class_name in bases:
84+
# The class is subclassed somewhere, so it is not a leaf.
85+
continue
86+
diagnostics.append(
87+
Diagnostic(
88+
message=(
89+
f"`{class_name}` at line {lineno} in `{name}` is a "
90+
"leaf dataclass and should be decorated with `@final`"
91+
)
92+
)
93+
)
94+
95+
return diagnostics
96+
97+
98+
def _name_of(node: ast.expr) -> Optional[str]:
99+
"""
100+
Return the bare name of a decorator or base class expression.
101+
102+
Handles plain names (`final`), dotted names (`typing.final`), and calls
103+
(`dataclass(frozen=True)`). Returns `None` for anything else, such as a
104+
subscripted generic base.
105+
"""
106+
target = node.func if isinstance(node, ast.Call) else node
107+
if isinstance(target, ast.Name):
108+
return target.id
109+
if isinstance(target, ast.Attribute):
110+
return target.attr
111+
return None
112+
113+
114+
class _Visitor(ast.NodeVisitor):
115+
"""
116+
Collect base class names and dataclasses that lack `@final`.
117+
"""
118+
119+
bases: Set[str]
120+
undecorated: List[Tuple[int, str]]
121+
122+
def __init__(self) -> None:
123+
self.bases = set()
124+
self.undecorated = []
125+
126+
def visit_ClassDef(self, klass: ast.ClassDef) -> None:
127+
"""
128+
Visit a class definition.
129+
"""
130+
for base in klass.bases:
131+
base_name = _name_of(base)
132+
if base_name is not None:
133+
self.bases.add(base_name)
134+
135+
decorators: Set[str] = set()
136+
for decorator in klass.decorator_list:
137+
decorator_name = _name_of(decorator)
138+
if decorator_name is not None:
139+
decorators.add(decorator_name)
140+
141+
if (
142+
decorators & DATACLASS_DECORATORS
143+
and FINAL_DECORATOR not in decorators
144+
):
145+
self.undecorated.append((klass.lineno, klass.name))
146+
147+
self.generic_visit(klass)

vulture_whitelist.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
FinalTrace,
2525
Trace,
2626
)
27+
from ethereum_spec_tools.lint.lints.final_decorator import (
28+
FinalDecoratorHygiene,
29+
)
2730
from ethereum_spec_tools.lint.lints.glacier_forks_hygiene import (
2831
GlacierForksHygiene,
2932
)
@@ -127,6 +130,9 @@
127130
Trace.opName
128131
FinalTrace.gasUsed
129132

133+
# src/ethereum_spec_tools/lint/lints/final_decorator.py
134+
FinalDecoratorHygiene
135+
130136
# src/ethereum_spec_tools/lint/lints/uint_len.py
131137
UintLenHygiene
132138

0 commit comments

Comments
 (0)