|
| 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) |
0 commit comments