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

Commit 40502cb

Browse files
committed
initial implementation and testing
1 parent 678c2c8 commit 40502cb

File tree

5 files changed

+221
-29
lines changed

5 files changed

+221
-29
lines changed

flake8_idom_hooks.py

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import ast
2+
from contextlib import contextmanager
13
from pkg_resources import (
24
get_distribution as _get_distribution,
35
DistributionNotFound as _DistributionNotFound,
46
)
7+
from typing import List, Tuple, Iterator, Union, Optional
58

69

710
try:
@@ -17,8 +20,106 @@ class Plugin:
1720
version = __version__
1821
options = None
1922

20-
def __init__(self, tree):
23+
def __init__(self, tree: ast.Module):
2124
self._tree = tree
2225

2326
def run(self):
24-
return [(7, 39, "hooks cannot be used in conditionals", type(self))]
27+
visitor = HookRulesVisitor()
28+
visitor.visit(self._tree)
29+
cls = type(self)
30+
return [(line, col, msg, cls) for line, col, msg in visitor.errors]
31+
32+
33+
class HookRulesVisitor(ast.NodeVisitor):
34+
def __init__(self):
35+
self.errors: List[Tuple[int, int, str]] = []
36+
self._current_function: Optional[ast.FunctionDef] = None
37+
self._current_call: Optional[ast.Call] = None
38+
self._current_conditional: Union[None, ast.If, ast.IfExp, ast.Try] = None
39+
self._current_loop: Union[None, ast.For, ast.While] = None
40+
41+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
42+
self._check_if_hook_defined_in_function(node)
43+
with self._set_current(function=node):
44+
self.generic_visit(node)
45+
46+
def _visit_hook_usage(self, node: ast.AST) -> None:
47+
self._check_if_propper_hook_usage(node)
48+
49+
visit_Attribute = _visit_hook_usage
50+
visit_Name = _visit_hook_usage
51+
52+
def _visit_conditional(self, node: ast.AST) -> None:
53+
with self._set_current(conditional=node):
54+
self.generic_visit(node)
55+
56+
visit_If = _visit_conditional
57+
visit_IfExp = _visit_conditional
58+
visit_Try = _visit_conditional
59+
60+
def _visit_loop(self, node: ast.AST) -> None:
61+
with self._set_current(loop=node):
62+
self.generic_visit(node)
63+
64+
visit_For = _visit_loop
65+
visit_While = _visit_loop
66+
67+
def _check_if_hook_defined_in_function(self, node: ast.FunctionDef) -> None:
68+
if self._current_function is not None and _is_hook_or_element_def(node):
69+
msg = f"Hook {node.name!r} defined inside another function."
70+
self.errors.append((node.lineno, node.col_offset, msg))
71+
72+
def _check_if_propper_hook_usage(self, node: Union[ast.Name, ast.Attribute]):
73+
if isinstance(node, ast.Name):
74+
name = node.id
75+
else:
76+
name = node.attr
77+
78+
if not _is_hook_function_name(name):
79+
return
80+
81+
if not _is_hook_or_element_def(self._current_function):
82+
msg = f"Hook {name!r} used outside element or hook definition."
83+
self.errors.append((node.lineno, node.col_offset, msg))
84+
return
85+
86+
_loop_or_conditional = self._current_conditional or self._current_loop
87+
if _loop_or_conditional is not None:
88+
node_type = type(_loop_or_conditional)
89+
node_type_to_name = {
90+
ast.If: "if statement",
91+
ast.IfExp: "inline if expression",
92+
ast.Try: "try statement",
93+
ast.For: "for loop",
94+
ast.While: "while loop",
95+
}
96+
node_name = node_type_to_name[node_type]
97+
msg = f"Hook {name!r} used inside {node_name}."
98+
self.errors.append((node.lineno, node.col_offset, msg))
99+
return
100+
101+
@contextmanager
102+
def _set_current(self, **attrs) -> Iterator[None]:
103+
old_attrs = {k: getattr(self, f"_current_{k}") for k in attrs}
104+
for k, v in attrs.items():
105+
setattr(self, f"_current_{k}", v)
106+
try:
107+
yield
108+
finally:
109+
for k, v in old_attrs.items():
110+
setattr(self, f"_current_{k}", v)
111+
112+
113+
def _is_hook_or_element_def(node: Optional[ast.FunctionDef]) -> bool:
114+
if node is None:
115+
return False
116+
else:
117+
return _is_element_function_name(node.name) or _is_hook_function_name(node.name)
118+
119+
120+
def _is_element_function_name(name: str) -> bool:
121+
return name[0].upper() == name[0] and "_" not in name
122+
123+
124+
def _is_hook_function_name(name: str) -> bool:
125+
return name.lstrip("_").startswith("use_")

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ select = B,C,E,F,W,T4,B9,N
66
exclude =
77
.eggs/*
88
.tox/*
9+
tests/hook_usage_cases.py
910

1011
[coverage:report]
1112
fail_under = 100

tests/cases/hook_in_conditional.py

Lines changed: 0 additions & 8 deletions
This file was deleted.

tests/hook_usage_cases.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
def HookInIf():
2+
if True:
3+
# error: Hook 'use_state' used inside if statement.
4+
use_state
5+
6+
7+
def HookInElif():
8+
if False:
9+
pass
10+
elif True:
11+
# error: Hook 'use_state' used inside if statement.
12+
use_state
13+
14+
15+
def HookInElse():
16+
if False:
17+
pass
18+
else:
19+
# error: Hook 'use_state' used inside if statement.
20+
use_state
21+
22+
23+
def HookInIfExp():
24+
(
25+
# error: Hook 'use_state' used inside inline if expression.
26+
use_state
27+
if True
28+
else None
29+
)
30+
31+
32+
def HookInElseOfIfExp():
33+
(
34+
None
35+
if True
36+
else
37+
# error: Hook 'use_state' used inside inline if expression.
38+
use_state
39+
)
40+
41+
42+
def HookInTry():
43+
try:
44+
# error: Hook 'use_state' used inside try statement.
45+
use_state
46+
except:
47+
pass
48+
49+
50+
def HookInExcept():
51+
try:
52+
raise ValueError()
53+
except:
54+
# error: Hook 'use_state' used inside try statement.
55+
use_state
56+
57+
58+
def HookInFinally():
59+
try:
60+
pass
61+
finally:
62+
# error: Hook 'use_state' used inside try statement.
63+
use_state
64+
65+
66+
def HookInForLoop():
67+
for i in range(3):
68+
# error: Hook 'use_state' used inside for loop.
69+
use_state
70+
71+
72+
def HookInWhileLoop():
73+
while True:
74+
# error: Hook 'use_state' used inside while loop.
75+
use_state
76+
77+
78+
def outer_function():
79+
# error: Hook 'use_state' defined inside another function.
80+
def use_state():
81+
...
82+
83+
84+
def generic_function():
85+
# error: Hook 'use_state' used outside element or hook definition.
86+
use_state
87+
88+
89+
def use_state():
90+
use_other
91+
92+
93+
def Element():
94+
use_state
95+
96+
97+
# ok since 'use_state' is not the last attribute
98+
module.use_state.other
99+
100+
# error: Hook 'use_state' used outside element or hook definition.
101+
module.use_state

tests/test_flake8_idom_hooks.py

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,24 @@
11
import ast
22
from pathlib import Path
33

4-
import pytest
5-
64
from flake8_idom_hooks import Plugin
75

86

9-
case_file_asts = {}
10-
for path in (Path(__file__).parent / "cases").rglob("*.py"):
11-
with path.open() as f:
12-
tree = ast.parse(f.read(), path.name)
13-
case_file_asts[path.name[:-3]] = tree
14-
15-
16-
expectations = {
17-
"hook_in_conditional": [(7, 39, "hooks cannot be used in conditionals")]
18-
}
19-
20-
21-
@pytest.mark.parametrize(
22-
"tree, expected_errors",
23-
[(case_file_asts[name], expectations[name]) for name in case_file_asts],
24-
)
25-
def test_flake8_idom_hooks(tree, expected_errors):
7+
def test_flake8_idom_hooks():
8+
path_to_case_file = Path(__file__).parent / "hook_usage_cases.py"
9+
with path_to_case_file.open() as file:
10+
# save the file's AST
11+
file_content = file.read()
12+
tree = ast.parse(file_content, path_to_case_file.name)
13+
14+
# find 'error' comments to construct expectations
15+
expected_errors = []
16+
for index, line in enumerate(file_content.split("\n")):
17+
lstrip_line = line.lstrip()
18+
if lstrip_line.startswith("# error:"):
19+
lineno = index + 2 # use 2 since error should be on next line
20+
col_offset = len(line) - len(lstrip_line)
21+
message = line.replace("# error:", "", 1).strip()
22+
expected_errors.append((lineno, col_offset, message))
2623
actual_errors = Plugin(tree).run()
2724
assert [(ln, col, msg) for ln, col, msg, p_type in actual_errors] == expected_errors

0 commit comments

Comments
 (0)