Skip to content
This repository was archived by the owner on Jan 15, 2025. It is now read-only.

Commit e4a884a

Browse files
committed
add exhaustive dep checking
1 parent dbef98b commit e4a884a

11 files changed

+549
-106
lines changed

flake8_idom_hooks/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .flake8_plugin import Plugin
2+
3+
__all__ = ["Plugin"]

flake8_idom_hooks/exhaustive_deps.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import ast
2+
from typing import Optional, List, Union, Set
3+
4+
from .utils import is_hook_or_element_def, ErrorVisitor
5+
6+
7+
HOOKS_WITH_DEPS = ("use_effect", "use_callback", "use_memo")
8+
9+
10+
class ExhaustiveDepsVisitor(ErrorVisitor):
11+
def __init__(self) -> None:
12+
super().__init__()
13+
self._current_hook_or_element: Optional[ast.FunctionDef] = None
14+
15+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
16+
if is_hook_or_element_def(node):
17+
self._current_hook_or_element = node
18+
self.generic_visit(node)
19+
self._current_hook_or_element = None
20+
elif self._current_hook_or_element is not None:
21+
for deco in node.decorator_list:
22+
if not isinstance(deco, ast.Call):
23+
continue
24+
25+
called_func = deco.func
26+
if isinstance(called_func, ast.Name):
27+
called_func_name = called_func.id
28+
elif isinstance(called_func, ast.Attribute):
29+
called_func_name = called_func.attr
30+
else: # pragma: no cover
31+
continue
32+
33+
if called_func_name not in HOOKS_WITH_DEPS:
34+
continue
35+
36+
for kw in deco.keywords:
37+
if kw.arg == "args":
38+
self._check_hook_dependency_list_is_exhaustive(
39+
called_func_name,
40+
node,
41+
kw.value,
42+
)
43+
break
44+
45+
def visit_Call(self, node: ast.Call) -> None:
46+
if self._current_hook_or_element is None:
47+
return
48+
49+
called_func = node.func
50+
51+
if isinstance(called_func, ast.Name):
52+
called_func_name = called_func.id
53+
elif isinstance(called_func, ast.Attribute):
54+
called_func_name = called_func.attr
55+
else: # pragma: no cover
56+
return None
57+
58+
if called_func_name not in HOOKS_WITH_DEPS:
59+
return None
60+
61+
func: Optional[ast.expr] = None
62+
args: Optional[ast.expr] = None
63+
64+
if len(node.args) == 2:
65+
func, args = node.args
66+
else:
67+
if len(node.args) == 1:
68+
func = node.args[0]
69+
for kw in node.keywords:
70+
if kw.arg == "function":
71+
func = kw.value
72+
elif kw.arg == "args":
73+
args = kw.value
74+
75+
if isinstance(func, ast.Lambda):
76+
self._check_hook_dependency_list_is_exhaustive(
77+
called_func_name,
78+
func,
79+
args,
80+
)
81+
82+
def _check_hook_dependency_list_is_exhaustive(
83+
self,
84+
hook_name: str,
85+
func: Union[ast.FunctionDef, ast.Lambda],
86+
dependency_expr: Optional[ast.expr],
87+
) -> None:
88+
dep_names = self._get_dependency_names_from_expression(
89+
hook_name, dependency_expr
90+
)
91+
92+
if dep_names is None:
93+
return None
94+
95+
func_name = "lambda" if isinstance(func, ast.Lambda) else func.name
96+
97+
visitor = _MissingNameOrAttrVisitor(
98+
hook_name,
99+
func_name,
100+
_param_names_of_function_def(func),
101+
dep_names,
102+
)
103+
if isinstance(func.body, list):
104+
for b in func.body:
105+
visitor.visit(b)
106+
else:
107+
visitor.visit(func.body)
108+
109+
self.errors.extend(visitor.errors)
110+
111+
def _get_dependency_names_from_expression(
112+
self, hook_name: str, dependency_expr: Optional[ast.expr]
113+
) -> Optional[List[str]]:
114+
if dependency_expr is None:
115+
return []
116+
elif isinstance(dependency_expr, (ast.List, ast.Tuple)):
117+
dep_names: List[str] = []
118+
for elt in dependency_expr.elts:
119+
if isinstance(elt, ast.Name):
120+
dep_names.append(elt.id)
121+
else:
122+
# ideally we could deal with some common use cases, but since React's
123+
# own linter doesn't do this we'll just take the easy route for now:
124+
# https://github.com/facebook/react/issues/16265
125+
self._save_error(
126+
201,
127+
elt,
128+
(
129+
f"dependency arg of {hook_name!r} is not destructured - "
130+
"dependencies should be refered to directly, not via an "
131+
"attribute or key of an object"
132+
),
133+
)
134+
return dep_names
135+
else:
136+
self._save_error(
137+
202,
138+
dependency_expr,
139+
(
140+
f"dependency args of {hook_name!r} should be a literal list or "
141+
f"tuple - not expression type {type(dependency_expr).__name__!r}"
142+
),
143+
)
144+
return None
145+
146+
147+
class _MissingNameOrAttrVisitor(ErrorVisitor):
148+
def __init__(
149+
self,
150+
hook_name: str,
151+
func_name: str,
152+
ignore_names: List[str],
153+
dep_names: List[str],
154+
) -> None:
155+
super().__init__()
156+
self._hook_name = hook_name
157+
self._func_name = func_name
158+
self._ignore_names = ignore_names
159+
self._dep_names = dep_names
160+
self.used_deps: Set[str] = set()
161+
162+
def visit_Name(self, node: ast.Name) -> None:
163+
node_id = node.id
164+
if node_id not in self._ignore_names:
165+
if node_id in self._dep_names:
166+
self.used_deps.add(node_id)
167+
else:
168+
self._save_error(
169+
203,
170+
node,
171+
(
172+
f"dependency {node_id!r} of function {self._func_name!r} "
173+
f"is not specified in declaration of {self._hook_name!r}"
174+
),
175+
)
176+
177+
178+
def _param_names_of_function_def(func: Union[ast.FunctionDef, ast.Lambda]) -> List[str]:
179+
names: List[str] = []
180+
names.extend(a.arg for a in func.args.args)
181+
names.extend(kw.arg for kw in func.args.kwonlyargs)
182+
if func.args.vararg is not None:
183+
names.append(func.args.vararg.arg)
184+
if func.args.kwarg is not None:
185+
names.append(func.args.kwarg.arg)
186+
return names

flake8_idom_hooks/flake8_plugin.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import ast
2+
from pkg_resources import (
3+
get_distribution as _get_distribution,
4+
DistributionNotFound as _DistributionNotFound,
5+
)
6+
from typing import List, Tuple, Type
7+
8+
from .utils import ErrorVisitor
9+
from .rules_of_hooks import RulesOfHooksVisitor
10+
from .exhaustive_deps import ExhaustiveDepsVisitor
11+
12+
try:
13+
__version__ = _get_distribution(__name__).version
14+
except _DistributionNotFound: # pragma: no cover
15+
# package is not installed
16+
__version__ = "0.0.0"
17+
18+
19+
class Plugin:
20+
21+
name = __name__
22+
version = __version__
23+
options = None
24+
25+
_visitor_types: List[Type[ErrorVisitor]] = [
26+
RulesOfHooksVisitor,
27+
ExhaustiveDepsVisitor,
28+
]
29+
30+
def __init__(self, tree: ast.Module) -> None:
31+
self._tree = tree
32+
33+
def run(self) -> List[Tuple[int, int, str, Type["Plugin"]]]:
34+
errors = []
35+
for vtype in self._visitor_types:
36+
visitor = vtype()
37+
visitor.visit(self._tree)
38+
errors.extend(visitor.errors)
39+
return [(line, col, msg, self.__class__) for line, col, msg in errors]

flake8_idom_hooks/rules_of_hooks.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import ast
2+
from contextlib import contextmanager
3+
from typing import Iterator, Union, Optional, Any
4+
5+
from .utils import is_hook_or_element_def, ErrorVisitor, is_hook_function_name
6+
7+
8+
class RulesOfHooksVisitor(ErrorVisitor):
9+
def __init__(self) -> None:
10+
super().__init__()
11+
self._current_hook_or_element: Optional[ast.FunctionDef] = None
12+
self._current_function: Optional[ast.FunctionDef] = None
13+
self._current_call: Optional[ast.Call] = None
14+
self._current_conditional: Union[None, ast.If, ast.IfExp, ast.Try] = None
15+
self._current_loop: Union[None, ast.For, ast.While] = None
16+
17+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
18+
if is_hook_or_element_def(node):
19+
self._check_if_hook_defined_in_function(node)
20+
with self._set_current(hook_or_element=node, function=node):
21+
self.generic_visit(node)
22+
else:
23+
with self._set_current(function=node):
24+
self.generic_visit(node)
25+
26+
def _visit_hook_usage(self, node: Union[ast.Name, ast.Attribute]) -> None:
27+
self._check_if_propper_hook_usage(node)
28+
29+
visit_Attribute = _visit_hook_usage
30+
visit_Name = _visit_hook_usage
31+
32+
def _visit_conditional(self, node: ast.AST) -> None:
33+
with self._set_current(conditional=node):
34+
self.generic_visit(node)
35+
36+
visit_If = _visit_conditional
37+
visit_IfExp = _visit_conditional
38+
visit_Try = _visit_conditional
39+
40+
def _visit_loop(self, node: ast.AST) -> None:
41+
with self._set_current(loop=node):
42+
self.generic_visit(node)
43+
44+
visit_For = _visit_loop
45+
visit_While = _visit_loop
46+
47+
def _check_if_hook_defined_in_function(self, node: ast.FunctionDef) -> None:
48+
if self._current_function is not None:
49+
msg = f"hook {node.name!r} defined as closure in function {self._current_function.name!r}"
50+
self._save_error(100, node, msg)
51+
52+
def _check_if_propper_hook_usage(
53+
self, node: Union[ast.Name, ast.Attribute]
54+
) -> None:
55+
if isinstance(node, ast.Name):
56+
name = node.id
57+
else:
58+
name = node.attr
59+
60+
if not is_hook_function_name(name):
61+
return None
62+
63+
if self._current_hook_or_element is None:
64+
msg = f"hook {name!r} used outside element or hook definition"
65+
self._save_error(101, node, msg)
66+
67+
_loop_or_conditional = self._current_conditional or self._current_loop
68+
if _loop_or_conditional is not None:
69+
node_type = type(_loop_or_conditional)
70+
node_type_to_name = {
71+
ast.If: "if statement",
72+
ast.IfExp: "inline if expression",
73+
ast.Try: "try statement",
74+
ast.For: "for loop",
75+
ast.While: "while loop",
76+
}
77+
node_name = node_type_to_name[node_type]
78+
msg = f"hook {name!r} used inside {node_name}"
79+
self._save_error(102, node, msg)
80+
81+
@contextmanager
82+
def _set_current(self, **attrs: Any) -> Iterator[None]:
83+
old_attrs = {k: getattr(self, f"_current_{k}") for k in attrs}
84+
for k, v in attrs.items():
85+
setattr(self, f"_current_{k}", v)
86+
try:
87+
yield
88+
finally:
89+
for k, v in old_attrs.items():
90+
setattr(self, f"_current_{k}", v)

flake8_idom_hooks/utils.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import ast
2+
from typing import List, Tuple
3+
4+
5+
class ErrorVisitor(ast.NodeVisitor):
6+
def __init__(self) -> None:
7+
self.errors: List[Tuple[int, int, str]] = []
8+
9+
def _save_error(self, error_code: int, node: ast.AST, message: str) -> None:
10+
self.errors.append((node.lineno, node.col_offset, f"ROH{error_code} {message}"))
11+
12+
13+
def is_hook_or_element_def(node: ast.FunctionDef) -> bool:
14+
return is_element_function_name(node.name) or is_hook_function_name(node.name)
15+
16+
17+
def is_element_function_name(name: str) -> bool:
18+
return name[0].upper() == name[0] and "_" not in name
19+
20+
21+
def is_hook_function_name(name: str) -> bool:
22+
return name.lstrip("_").startswith("use_")

requirements/lint.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
flake8 >=3.7
22
black
3+
mypy

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
package = {
1818
"name": name,
19-
"py_modules": ["flake8_idom_hooks"],
19+
"py_modules": setuptools.find_packages(exclude=["tests"]),
2020
"entry_points": {"flake8.extension": ["ROH=flake8_idom_hooks:Plugin"]},
2121
"python_requires": ">=3.6",
2222
"description": "Flake8 plugin to enforce the rules of hooks for IDOM",

0 commit comments

Comments
 (0)