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

Commit fe01dcb

Browse files
committed
add options to customize hook/component patterns
1 parent 155c15a commit fe01dcb

17 files changed

+451
-252
lines changed

README.md

+38-3
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,6 @@ tox
2525

2626
# Errors
2727

28-
`ROH2**` errors can be enabled with the `--exhaustive-hook-deps` flag or setting
29-
`exhaustive_hook_deps = True` in your `flake8` config.
30-
3128
<table>
3229
<tr>
3330
<th>Code</th>
@@ -63,3 +60,41 @@ tox
6360
</td>
6461
</tr>
6562
</table>
63+
64+
# Options
65+
66+
All options my be used as CLI flags where `_` characters are replaced with `-`. For
67+
example, `exhaustive_hook_deps` would become `--exhaustive-hook-deps`.
68+
69+
<table>
70+
<tr>
71+
<th>Option</th>
72+
<th>Type</th>
73+
<th>Default</th>
74+
<th>Description</th>
75+
</tr>
76+
<tr>
77+
<td><code>exhaustive_hook_deps</code></td>
78+
<td>Boolean</td>
79+
<td><code>False</code></td>
80+
<td>Enable <code>ROH2**</code> errors (recommended)</td>
81+
</tr>
82+
<tr>
83+
<td><code>component_decorator_pattern</code></td>
84+
<td>Regex</td>
85+
<td><code>^(component|[\w\.]+\.component)$</code></td>
86+
<td>
87+
The pattern which should match the component decorators. Useful if
88+
you import the <code>@component</code> decorator under an alias.
89+
</td>
90+
</tr>
91+
<tr>
92+
<td><code>hook_function_pattern</code></td>
93+
<td>Regex</td>
94+
<td><code>^_*use_\w+$</code></td>
95+
<td>
96+
The pattern which should match the name of hook functions. Best used if you
97+
have existing functions with <code>use_*</code> names that are not hooks.
98+
</td>
99+
</tr>
100+
</table>

flake8_idom_hooks/__init__.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,6 @@
1010
from .flake8_plugin import Plugin
1111
from .run import run_checks
1212

13-
__all__ = ["Plugin", "run_checks"]
13+
plugin = Plugin()
14+
15+
__all__ = ["plugin", "run_checks"]

flake8_idom_hooks/common.py

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from __future__ import annotations
2+
3+
import ast
4+
import re
5+
from contextlib import contextmanager
6+
from typing import Any, Iterator
7+
8+
9+
@contextmanager
10+
def set_current(obj: Any, **attrs: Any) -> Iterator[None]:
11+
old_attrs = {k: getattr(obj, f"_current_{k}") for k in attrs}
12+
for k, v in attrs.items():
13+
setattr(obj, f"_current_{k}", v)
14+
try:
15+
yield
16+
finally:
17+
for k, v in old_attrs.items():
18+
setattr(obj, f"_current_{k}", v)
19+
20+
21+
class CheckContext:
22+
def __init__(
23+
self, component_decorator_pattern: str, hook_function_pattern: str
24+
) -> None:
25+
self.errors: list[tuple[int, int, str]] = []
26+
self._hook_function_pattern = re.compile(hook_function_pattern)
27+
self._component_decorator_pattern = re.compile(component_decorator_pattern)
28+
29+
def add_error(self, error_code: int, node: ast.AST, message: str) -> None:
30+
self.errors.append((node.lineno, node.col_offset, f"ROH{error_code} {message}"))
31+
32+
def is_hook_def(self, node: ast.FunctionDef) -> bool:
33+
return self.is_hook_name(node.name)
34+
35+
def is_hook_name(self, name: str) -> bool:
36+
return self._hook_function_pattern.match(name) is not None
37+
38+
def is_component_def(self, node: ast.FunctionDef) -> bool:
39+
return any(map(self.is_component_decorator, node.decorator_list))
40+
41+
def is_component_decorator(self, node: ast.AST) -> bool:
42+
deco_name_parts: list[str] = []
43+
while isinstance(node, ast.Attribute):
44+
deco_name_parts.insert(0, node.attr)
45+
node = node.value
46+
if isinstance(node, ast.Name):
47+
deco_name_parts.insert(0, node.id)
48+
return (
49+
self._component_decorator_pattern.match(".".join(deco_name_parts))
50+
is not None
51+
)

flake8_idom_hooks/exhaustive_deps.py

+14-15
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
11
import ast
22
from typing import Optional, Set, Union
33

4-
from .utils import ErrorVisitor, is_component_def, is_hook_def, set_current
4+
from .common import CheckContext, set_current
55

66
HOOKS_WITH_DEPS = ("use_effect", "use_callback", "use_memo")
77

88

9-
class ExhaustiveDepsVisitor(ErrorVisitor):
10-
def __init__(self) -> None:
11-
super().__init__()
12-
self._current_function: Optional[ast.FunctionDef] = None
9+
class ExhaustiveDepsVisitor(ast.NodeVisitor):
10+
def __init__(self, context: CheckContext) -> None:
11+
self._context = context
1312
self._current_hook_or_component: Optional[ast.FunctionDef] = None
1413

1514
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
16-
if is_hook_def(node) or is_component_def(node):
15+
if self._context.is_hook_def(node) or self._context.is_component_def(node):
1716
with set_current(self, hook_or_component=node):
1817
self.generic_visit(node)
1918
elif self._current_hook_or_component is not None:
@@ -53,10 +52,10 @@ def visit_Call(self, node: ast.Call) -> None:
5352
elif isinstance(called_func, ast.Attribute):
5453
called_func_name = called_func.attr
5554
else: # pragma: no cover
56-
return None
55+
return
5756

5857
if called_func_name not in HOOKS_WITH_DEPS:
59-
return None
58+
return
6059

6160
func: Optional[ast.expr] = None
6261
args: Optional[ast.expr] = None
@@ -101,6 +100,7 @@ def _check_hook_dependency_list_is_exhaustive(
101100
variables_defined_in_scope = top_level_variable_finder.variable_names
102101

103102
missing_name_finder = _MissingNameFinder(
103+
self._context,
104104
hook_name=hook_name,
105105
func_name=func_name,
106106
dep_names=dep_names,
@@ -113,8 +113,6 @@ def _check_hook_dependency_list_is_exhaustive(
113113
else:
114114
missing_name_finder.visit(func.body)
115115

116-
self.errors.extend(missing_name_finder.errors)
117-
118116
def _get_dependency_names_from_expression(
119117
self, hook_name: str, dependency_expr: Optional[ast.expr]
120118
) -> Optional[Set[str]]:
@@ -129,7 +127,7 @@ def _get_dependency_names_from_expression(
129127
# ideally we could deal with some common use cases, but since React's
130128
# own linter doesn't do this we'll just take the easy route for now:
131129
# https://github.com/facebook/react/issues/16265
132-
self._save_error(
130+
self._context.add_error(
133131
200,
134132
elt,
135133
(
@@ -143,7 +141,7 @@ def _get_dependency_names_from_expression(
143141
isinstance(dependency_expr, (ast.Constant, ast.NameConstant))
144142
and dependency_expr.value is None
145143
):
146-
self._save_error(
144+
self._context.add_error(
147145
201,
148146
dependency_expr,
149147
(
@@ -156,16 +154,17 @@ def _get_dependency_names_from_expression(
156154
return set()
157155

158156

159-
class _MissingNameFinder(ErrorVisitor):
157+
class _MissingNameFinder(ast.NodeVisitor):
160158
def __init__(
161159
self,
160+
context: CheckContext,
162161
hook_name: str,
163162
func_name: str,
164163
dep_names: Set[str],
165164
ignore_names: Set[str],
166165
names_in_scope: Set[str],
167166
) -> None:
168-
super().__init__()
167+
self._context = context
169168
self._hook_name = hook_name
170169
self._func_name = func_name
171170
self._ignore_names = ignore_names
@@ -179,7 +178,7 @@ def visit_Name(self, node: ast.Name) -> None:
179178
if node_id in self._dep_names:
180179
self.used_deps.add(node_id)
181180
else:
182-
self._save_error(
181+
self._context.add_error(
183182
202,
184183
node,
185184
(

flake8_idom_hooks/flake8_plugin.py

+49-11
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66
from flake8.options.manager import OptionManager
77

88
from flake8_idom_hooks import __version__
9-
from flake8_idom_hooks.run import run_checks
9+
from flake8_idom_hooks.run import (
10+
DEFAULT_COMPONENT_DECORATOR_PATTERN,
11+
DEFAULT_HOOK_FUNCTION_PATTERN,
12+
run_checks,
13+
)
14+
15+
from .exhaustive_deps import HOOKS_WITH_DEPS
1016

1117

1218
class Plugin:
@@ -15,26 +21,58 @@ class Plugin:
1521
version = __version__
1622

1723
exhaustive_hook_deps: bool
24+
component_decorator_pattern: str
25+
hook_function_pattern: str
1826

19-
@classmethod
20-
def add_options(cls, option_manager: OptionManager) -> None:
27+
def add_options(self, option_manager: OptionManager) -> None:
2128
option_manager.add_option(
2229
"--exhaustive-hook-deps",
2330
action="store_true",
2431
default=False,
32+
help=f"Whether to check hook dependencies for {', '.join(HOOKS_WITH_DEPS)}",
2533
dest="exhaustive_hook_deps",
2634
parse_from_config=True,
2735
)
36+
option_manager.add_option(
37+
"--component-decorator-pattern",
38+
nargs="?",
39+
default=DEFAULT_COMPONENT_DECORATOR_PATTERN,
40+
help=(
41+
"The pattern which should match the component decorators. "
42+
"Useful if you import the component decorator under an alias."
43+
),
44+
dest="component_decorator_pattern",
45+
parse_from_config=True,
46+
)
47+
option_manager.add_option(
48+
"--hook-function-pattern",
49+
nargs="?",
50+
default=DEFAULT_HOOK_FUNCTION_PATTERN,
51+
help=(
52+
"The pattern which should match the name of hook functions. Best used "
53+
"if you have existing functions with 'use_*' names that are not hooks."
54+
),
55+
dest="hook_function_pattern",
56+
parse_from_config=True,
57+
)
2858

29-
@classmethod
30-
def parse_options(cls, options: Namespace) -> None:
31-
cls.exhaustive_hook_deps = getattr(options, "exhaustive_hook_deps", False)
32-
33-
def __init__(self, tree: ast.Module) -> None:
34-
self._tree = tree
59+
def parse_options(self, options: Namespace) -> None:
60+
self.exhaustive_hook_deps = options.exhaustive_hook_deps
61+
self.component_decorator_pattern = options.component_decorator_pattern
62+
self.hook_function_pattern = options.hook_function_pattern
3563

36-
def run(self) -> list[tuple[int, int, str, type[Plugin]]]:
64+
def __call__(self, tree: ast.Module) -> list[tuple[int, int, str, type[Plugin]]]:
3765
return [
3866
error + (self.__class__,)
39-
for error in run_checks(self._tree, self.exhaustive_hook_deps)
67+
for error in run_checks(
68+
tree,
69+
self.exhaustive_hook_deps,
70+
self.component_decorator_pattern,
71+
self.hook_function_pattern,
72+
)
4073
]
74+
75+
def __init__(self) -> None:
76+
# Hack to convince flake8 to accept plugins that are instances
77+
# see: https://github.com/PyCQA/flake8/pull/1674
78+
self.__init__ = self.__call__ # type: ignore

flake8_idom_hooks/rules_of_hooks.py

+10-16
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,12 @@
11
import ast
22
from typing import Optional, Union
33

4-
from .utils import (
5-
ErrorVisitor,
6-
is_component_def,
7-
is_hook_def,
8-
is_hook_function_name,
9-
set_current,
10-
)
4+
from .common import CheckContext, set_current
115

126

13-
class RulesOfHooksVisitor(ErrorVisitor):
14-
def __init__(self) -> None:
15-
super().__init__()
7+
class RulesOfHooksVisitor(ast.NodeVisitor):
8+
def __init__(self, context: CheckContext) -> None:
9+
self._context = context
1610
self._current_hook: Optional[ast.FunctionDef] = None
1711
self._current_component: Optional[ast.FunctionDef] = None
1812
self._current_function: Optional[ast.FunctionDef] = None
@@ -21,7 +15,7 @@ def __init__(self) -> None:
2115
self._current_loop: Union[None, ast.For, ast.While] = None
2216

2317
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
24-
if is_hook_def(node):
18+
if self._context.is_hook_def(node):
2519
self._check_if_hook_defined_in_function(node)
2620
with set_current(
2721
self,
@@ -32,7 +26,7 @@ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
3226
loop=None,
3327
):
3428
self.generic_visit(node)
35-
elif is_component_def(node):
29+
elif self._context.is_component_def(node):
3630
with set_current(
3731
self,
3832
component=node,
@@ -70,7 +64,7 @@ def _visit_loop(self, node: ast.AST) -> None:
7064
def _check_if_hook_defined_in_function(self, node: ast.FunctionDef) -> None:
7165
if self._current_function is not None:
7266
msg = f"hook {node.name!r} defined as closure in function {self._current_function.name!r}"
73-
self._save_error(100, node, msg)
67+
self._context.add_error(100, node, msg)
7468

7569
def _check_if_propper_hook_usage(
7670
self, node: Union[ast.Name, ast.Attribute]
@@ -80,12 +74,12 @@ def _check_if_propper_hook_usage(
8074
else:
8175
name = node.attr
8276

83-
if not is_hook_function_name(name):
77+
if not self._context.is_hook_name(name):
8478
return None
8579

8680
if self._current_hook is None and self._current_component is None:
8781
msg = f"hook {name!r} used outside component or hook definition"
88-
self._save_error(101, node, msg)
82+
self._context.add_error(101, node, msg)
8983

9084
loop_or_conditional = self._current_conditional or self._current_loop
9185
if loop_or_conditional is not None:
@@ -99,4 +93,4 @@ def _check_if_propper_hook_usage(
9993
}
10094
node_name = node_type_to_name[node_type]
10195
msg = f"hook {name!r} used inside {node_name}"
102-
self._save_error(102, node, msg)
96+
self._context.add_error(102, node, msg)

0 commit comments

Comments
 (0)