diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 58a32a8..0c53b1a 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -3,12 +3,24 @@ name: Test on: [push] jobs: - build: + coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Use Latest Python + uses: actions/setup-python@v2 + with: + python-version: "3.10" + - name: Install Python Dependencies + run: pip install -r requirements/nox-deps.txt + - name: Run Tests + run: nox -s test + + environments: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8, 3.9] - + python-version: ["3.7", "3.8", "3.9", "3.10"] steps: - uses: actions/checkout@v2 - name: Use Python ${{ matrix.python-version }} @@ -16,6 +28,6 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install Python Dependencies - run: pip install -r requirements/ci.txt + run: pip install -r requirements/nox-deps.txt - name: Run Tests - run: tox + run: nox -s test -- --no-cov diff --git a/README.md b/README.md index 4e68087..e358aa9 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ -# `flake8-idom-hooks` +# `reactpy-flake8` -A Flake8 plugin that enforces the ["rules of hooks"](https://reactjs.org/docs/hooks-rules.html) for [IDOM](https://github.com/idom-team/idom). +A Flake8 plugin that enforces the ["rules of hooks"](https://reactjs.org/docs/hooks-rules.html) for [ReactPy](https://github.com/reactive-python/reactpy). The implementation is based on React's own ESLint [plugin for hooks](https://github.com/facebook/react/tree/master/packages/eslint-plugin-react-hooks). # Install ```bash -pip install flake8-idom-hooks +pip install reactpy-flake8 ``` # Developer Installation @@ -20,46 +20,85 @@ pip install -e . Run the tests ```bash -tox +nox -s test ``` # Errors -`ROH2**` errors can be enabled with the `--exhaustive-hook-deps` flag or setting -`exhaustive_hook_deps = True` in your `flake8` config. - - + - + - + - + + + + + - + - +
Code Message
ROH100RPY100 Hook is defined as a closure
ROH101RPY101 Hook was used outside component or hook definition
ROH102RPY102 Hook was used inside a conditional or loop statement
ROH200RPY103Hook was used after an early return
RPY200 A hook's dependency is not destructured - dependencies should be refered to directly, not via an attribute or key of an object
ROH201RPY201 Hook dependency args should be a literal list, tuple or None
ROH202RPY202 Hook dependency is not specified
+ +# Options + +All options my be used as CLI flags where `_` characters are replaced with `-`. For +example, `exhaustive_hook_deps` would become `--exhaustive-hook-deps`. + + + + + + + + + + + + + + + + + + + + + + + + + + +
OptionTypeDefaultDescription
exhaustive_hook_depsBooleanFalseEnable REACTPY2** errors (recommended)
component_decorator_patternRegex^(component|[\w\.]+\.component)$ + The pattern which should match the component decorators. Useful if + you import the @component decorator under an alias. +
hook_function_patternRegex^_*use_\w+$ + The pattern which should match the name of hook functions. Best used if you + have existing functions with use_* names that are not hooks. +
diff --git a/flake8_idom_hooks/flake8_plugin.py b/flake8_idom_hooks/flake8_plugin.py deleted file mode 100644 index 8b5bc53..0000000 --- a/flake8_idom_hooks/flake8_plugin.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -import ast -from argparse import Namespace - -from flake8.options.manager import OptionManager - -from flake8_idom_hooks import __version__ -from flake8_idom_hooks.run import run_checks - - -class Plugin: - - name = __name__ - version = __version__ - - exhaustive_hook_deps: bool - - @classmethod - def add_options(cls, option_manager: OptionManager) -> None: - option_manager.add_option( - "--exhaustive-hook-deps", - action="store_true", - default=False, - dest="exhaustive_hook_deps", - parse_from_config=True, - ) - - @classmethod - def parse_options(cls, options: Namespace) -> None: - cls.exhaustive_hook_deps = getattr(options, "exhaustive_hook_deps", False) - - def __init__(self, tree: ast.Module) -> None: - self._tree = tree - - def run(self) -> list[tuple[int, int, str, type[Plugin]]]: - return [ - error + (self.__class__,) - for error in run_checks(self._tree, self.exhaustive_hook_deps) - ] diff --git a/flake8_idom_hooks/rules_of_hooks.py b/flake8_idom_hooks/rules_of_hooks.py deleted file mode 100644 index 5e54dc8..0000000 --- a/flake8_idom_hooks/rules_of_hooks.py +++ /dev/null @@ -1,102 +0,0 @@ -import ast -from typing import Union, Optional - -from .utils import ( - is_hook_def, - is_component_def, - ErrorVisitor, - is_hook_function_name, - set_current, -) - - -class RulesOfHooksVisitor(ErrorVisitor): - def __init__(self) -> None: - super().__init__() - self._current_hook: Optional[ast.FunctionDef] = None - self._current_component: Optional[ast.FunctionDef] = None - self._current_function: Optional[ast.FunctionDef] = None - self._current_call: Optional[ast.Call] = None - self._current_conditional: Union[None, ast.If, ast.IfExp, ast.Try] = None - self._current_loop: Union[None, ast.For, ast.While] = None - - def visit_FunctionDef(self, node: ast.FunctionDef) -> None: - if is_hook_def(node): - self._check_if_hook_defined_in_function(node) - with set_current( - self, - hook=node, - function=node, - # we need to reset these before enter new hook - conditional=None, - loop=None, - ): - self.generic_visit(node) - elif is_component_def(node): - with set_current( - self, - component=node, - function=node, - # we need to reset these before visiting a new component - conditional=None, - loop=None, - ): - self.generic_visit(node) - else: - with set_current(self, function=node): - self.generic_visit(node) - - def _visit_hook_usage(self, node: Union[ast.Name, ast.Attribute]) -> None: - self._check_if_propper_hook_usage(node) - - visit_Attribute = _visit_hook_usage - visit_Name = _visit_hook_usage - - def _visit_conditional(self, node: ast.AST) -> None: - with set_current(self, conditional=node): - self.generic_visit(node) - - visit_If = _visit_conditional - visit_IfExp = _visit_conditional - visit_Try = _visit_conditional - - def _visit_loop(self, node: ast.AST) -> None: - with set_current(self, loop=node): - self.generic_visit(node) - - visit_For = _visit_loop - visit_While = _visit_loop - - def _check_if_hook_defined_in_function(self, node: ast.FunctionDef) -> None: - if self._current_function is not None: - msg = f"hook {node.name!r} defined as closure in function {self._current_function.name!r}" - self._save_error(100, node, msg) - - def _check_if_propper_hook_usage( - self, node: Union[ast.Name, ast.Attribute] - ) -> None: - if isinstance(node, ast.Name): - name = node.id - else: - name = node.attr - - if not is_hook_function_name(name): - return None - - if self._current_hook is None and self._current_component is None: - msg = f"hook {name!r} used outside component or hook definition" - self._save_error(101, node, msg) - - loop_or_conditional = self._current_conditional or self._current_loop - if loop_or_conditional is not None: - node_type = type(loop_or_conditional) - node_type_to_name = { - ast.If: "if statement", - ast.IfExp: "inline if expression", - ast.Try: "try statement", - ast.For: "for loop", - ast.While: "while loop", - } - node_name = node_type_to_name[node_type] - msg = f"hook {name!r} used inside {node_name}" - self._save_error(102, node, msg) diff --git a/flake8_idom_hooks/run.py b/flake8_idom_hooks/run.py deleted file mode 100644 index 2760b6c..0000000 --- a/flake8_idom_hooks/run.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -import ast - -from .utils import ErrorVisitor -from .exhaustive_deps import ExhaustiveDepsVisitor -from .rules_of_hooks import RulesOfHooksVisitor - - -def run_checks( - tree: ast.Module, - exhaustive_hook_deps: bool, -) -> list[tuple[int, int, str]]: - visitor_types: list[type[ErrorVisitor]] = [RulesOfHooksVisitor] - if exhaustive_hook_deps: - visitor_types.append(ExhaustiveDepsVisitor) - - errors: list[tuple[int, int, str]] = [] - for vtype in visitor_types: - visitor = vtype() - visitor.visit(tree) - errors.extend(visitor.errors) - - return errors diff --git a/flake8_idom_hooks/utils.py b/flake8_idom_hooks/utils.py deleted file mode 100644 index ec2331a..0000000 --- a/flake8_idom_hooks/utils.py +++ /dev/null @@ -1,39 +0,0 @@ -import ast -from contextlib import contextmanager -from typing import List, Tuple, Iterator, Any - - -@contextmanager -def set_current(obj: Any, **attrs: Any) -> Iterator[None]: - old_attrs = {k: getattr(obj, f"_current_{k}") for k in attrs} - for k, v in attrs.items(): - setattr(obj, f"_current_{k}", v) - try: - yield - finally: - for k, v in old_attrs.items(): - setattr(obj, f"_current_{k}", v) - - -class ErrorVisitor(ast.NodeVisitor): - def __init__(self) -> None: - self.errors: List[Tuple[int, int, str]] = [] - - def _save_error(self, error_code: int, node: ast.AST, message: str) -> None: - self.errors.append((node.lineno, node.col_offset, f"ROH{error_code} {message}")) - - -def is_hook_def(node: ast.FunctionDef) -> bool: - return is_hook_function_name(node.name) - - -def is_component_def(node: ast.FunctionDef) -> bool: - return is_component_function_name(node.name) - - -def is_component_function_name(name: str) -> bool: - return name[0].upper() == name[0] and "_" not in name - - -def is_hook_function_name(name: str) -> bool: - return name.lstrip("_").startswith("use_") diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..6ca9385 --- /dev/null +++ b/noxfile.py @@ -0,0 +1,66 @@ +from pathlib import Path + +from nox import Session, parametrize, session + +ROOT = Path(".") +REQUIREMENTS_DIR = ROOT / "requirements" + + +@session +def format(session: Session) -> None: + install_requirements(session, "style") + session.run("black", ".") + session.run("isort", ".") + + +@session +def test(session: Session) -> None: + session.notify("test_style") + session.notify("test_types") + session.notify("test_coverage") + session.notify("test_suite") + + +@session +def test_style(session: Session) -> None: + install_requirements(session, "style") + session.run("black", "--check", ".") + session.run("isort", "--check", ".") + session.run("flake8", ".") + + +@session +def test_types(session: Session) -> None: + install_requirements(session, "types") + session.run("mypy", "--strict", "reactpy_flake8") + + +@session +@parametrize("flake8_version", ["3", "4", "5", "6"]) +def test_suite(session: Session, flake8_version: str) -> None: + install_requirements(session, "test-env") + session.install(f"flake8=={flake8_version}.*") + session.install(".") + session.run("pytest", "tests") + + +@session +def test_coverage(session: Session) -> None: + install_requirements(session, "test-env") + session.install("-e", ".") + + posargs = session.posargs[:] + + if "--no-cov" in session.posargs: + posargs.remove("--no-cov") + session.log("Coverage won't be checked") + session.install(".") + else: + posargs += ["--cov=reactpy_flake8", "--cov-report=term"] + session.install("-e", ".") + + session.run("pytest", "tests", *posargs) + + +def install_requirements(session: Session, name: str) -> None: + session.install("-r", str(REQUIREMENTS_DIR / f"{name}.txt")) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3c2aae9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.isort] +profile = "black" diff --git a/flake8_idom_hooks/__init__.py b/reactpy_flake8/__init__.py similarity index 55% rename from flake8_idom_hooks/__init__.py rename to reactpy_flake8/__init__.py index 5937afe..7f0500b 100644 --- a/flake8_idom_hooks/__init__.py +++ b/reactpy_flake8/__init__.py @@ -1,7 +1,5 @@ -from pkg_resources import ( - get_distribution as _get_distribution, - DistributionNotFound as _DistributionNotFound, -) +from pkg_resources import DistributionNotFound as _DistributionNotFound +from pkg_resources import get_distribution as _get_distribution try: __version__: str = _get_distribution(__name__).version @@ -12,4 +10,6 @@ from .flake8_plugin import Plugin from .run import run_checks -__all__ = ["Plugin", "run_checks"] +plugin = Plugin() + +__all__ = ["plugin", "run_checks"] diff --git a/reactpy_flake8/common.py b/reactpy_flake8/common.py new file mode 100644 index 0000000..e9d97ba --- /dev/null +++ b/reactpy_flake8/common.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import ast +import re +from contextlib import contextmanager +from typing import Any, Iterator + + +@contextmanager +def set_current(obj: Any, **attrs: Any) -> Iterator[None]: + old_attrs = {k: getattr(obj, f"_current_{k}") for k in attrs} + for k, v in attrs.items(): + setattr(obj, f"_current_{k}", v) + try: + yield + finally: + for k, v in old_attrs.items(): + setattr(obj, f"_current_{k}", v) + + +class CheckContext: + def __init__( + self, component_decorator_pattern: str, hook_function_pattern: str + ) -> None: + self.errors: list[tuple[int, int, str]] = [] + self._hook_function_pattern = re.compile(hook_function_pattern) + self._component_decorator_pattern = re.compile(component_decorator_pattern) + + def add_error(self, error_code: int, node: ast.AST, message: str) -> None: + self.errors.append( + (node.lineno, node.col_offset, f"REACTPY{error_code} {message}") + ) + + def is_hook_def(self, node: ast.FunctionDef) -> bool: + return self.is_hook_name(node.name) + + def is_hook_name(self, name: str) -> bool: + return self._hook_function_pattern.match(name) is not None + + def is_component_def(self, node: ast.FunctionDef) -> bool: + return any(map(self.is_component_decorator, node.decorator_list)) + + def is_component_decorator(self, node: ast.AST) -> bool: + deco_name_parts: list[str] = [] + while isinstance(node, ast.Attribute): + deco_name_parts.insert(0, node.attr) + node = node.value + if isinstance(node, ast.Name): + deco_name_parts.insert(0, node.id) + return ( + self._component_decorator_pattern.match(".".join(deco_name_parts)) + is not None + ) diff --git a/flake8_idom_hooks/exhaustive_deps.py b/reactpy_flake8/exhaustive_deps.py similarity index 92% rename from flake8_idom_hooks/exhaustive_deps.py rename to reactpy_flake8/exhaustive_deps.py index b828284..b745e8a 100644 --- a/flake8_idom_hooks/exhaustive_deps.py +++ b/reactpy_flake8/exhaustive_deps.py @@ -1,20 +1,18 @@ import ast -from typing import Optional, Union, Set - -from .utils import is_hook_def, is_component_def, ErrorVisitor, set_current +from typing import Optional, Set, Union +from .common import CheckContext, set_current HOOKS_WITH_DEPS = ("use_effect", "use_callback", "use_memo") -class ExhaustiveDepsVisitor(ErrorVisitor): - def __init__(self) -> None: - super().__init__() - self._current_function: Optional[ast.FunctionDef] = None +class ExhaustiveDepsVisitor(ast.NodeVisitor): + def __init__(self, context: CheckContext) -> None: + self._context = context self._current_hook_or_component: Optional[ast.FunctionDef] = None def visit_FunctionDef(self, node: ast.FunctionDef) -> None: - if is_hook_def(node) or is_component_def(node): + if self._context.is_hook_def(node) or self._context.is_component_def(node): with set_current(self, hook_or_component=node): self.generic_visit(node) elif self._current_hook_or_component is not None: @@ -54,10 +52,10 @@ def visit_Call(self, node: ast.Call) -> None: elif isinstance(called_func, ast.Attribute): called_func_name = called_func.attr else: # pragma: no cover - return None + return if called_func_name not in HOOKS_WITH_DEPS: - return None + return func: Optional[ast.expr] = None args: Optional[ast.expr] = None @@ -102,6 +100,7 @@ def _check_hook_dependency_list_is_exhaustive( variables_defined_in_scope = top_level_variable_finder.variable_names missing_name_finder = _MissingNameFinder( + self._context, hook_name=hook_name, func_name=func_name, dep_names=dep_names, @@ -114,8 +113,6 @@ def _check_hook_dependency_list_is_exhaustive( else: missing_name_finder.visit(func.body) - self.errors.extend(missing_name_finder.errors) - def _get_dependency_names_from_expression( self, hook_name: str, dependency_expr: Optional[ast.expr] ) -> Optional[Set[str]]: @@ -130,7 +127,7 @@ def _get_dependency_names_from_expression( # ideally we could deal with some common use cases, but since React's # own linter doesn't do this we'll just take the easy route for now: # https://github.com/facebook/react/issues/16265 - self._save_error( + self._context.add_error( 200, elt, ( @@ -144,7 +141,7 @@ def _get_dependency_names_from_expression( isinstance(dependency_expr, (ast.Constant, ast.NameConstant)) and dependency_expr.value is None ): - self._save_error( + self._context.add_error( 201, dependency_expr, ( @@ -157,16 +154,17 @@ def _get_dependency_names_from_expression( return set() -class _MissingNameFinder(ErrorVisitor): +class _MissingNameFinder(ast.NodeVisitor): def __init__( self, + context: CheckContext, hook_name: str, func_name: str, dep_names: Set[str], ignore_names: Set[str], names_in_scope: Set[str], ) -> None: - super().__init__() + self._context = context self._hook_name = hook_name self._func_name = func_name self._ignore_names = ignore_names @@ -180,7 +178,7 @@ def visit_Name(self, node: ast.Name) -> None: if node_id in self._dep_names: self.used_deps.add(node_id) else: - self._save_error( + self._context.add_error( 202, node, ( diff --git a/reactpy_flake8/flake8_plugin.py b/reactpy_flake8/flake8_plugin.py new file mode 100644 index 0000000..f1bb102 --- /dev/null +++ b/reactpy_flake8/flake8_plugin.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import ast +from argparse import Namespace + +from flake8.options.manager import OptionManager + +from reactpy_flake8 import __version__ +from reactpy_flake8.run import ( + DEFAULT_COMPONENT_DECORATOR_PATTERN, + DEFAULT_HOOK_FUNCTION_PATTERN, + run_checks, +) + +from .exhaustive_deps import HOOKS_WITH_DEPS + + +class Plugin: + name = __name__ + version = __version__ + + exhaustive_hook_deps: bool + component_decorator_pattern: str + hook_function_pattern: str + + def add_options(self, option_manager: OptionManager) -> None: + option_manager.add_option( + "--exhaustive-hook-deps", + action="store_true", + default=False, + help=f"Whether to check hook dependencies for {', '.join(HOOKS_WITH_DEPS)}", + dest="exhaustive_hook_deps", + parse_from_config=True, + ) + option_manager.add_option( + "--component-decorator-pattern", + nargs="?", + default=DEFAULT_COMPONENT_DECORATOR_PATTERN, + help=( + "The pattern which should match the component decorators. " + "Useful if you import the component decorator under an alias." + ), + dest="component_decorator_pattern", + parse_from_config=True, + ) + option_manager.add_option( + "--hook-function-pattern", + nargs="?", + default=DEFAULT_HOOK_FUNCTION_PATTERN, + help=( + "The pattern which should match the name of hook functions. Best used " + "if you have existing functions with 'use_*' names that are not hooks." + ), + dest="hook_function_pattern", + parse_from_config=True, + ) + + def parse_options(self, options: Namespace) -> None: + self.exhaustive_hook_deps = options.exhaustive_hook_deps + self.component_decorator_pattern = options.component_decorator_pattern + self.hook_function_pattern = options.hook_function_pattern + + def __call__(self, tree: ast.Module) -> list[tuple[int, int, str, type[Plugin]]]: + return [ + error + (self.__class__,) + for error in run_checks( + tree, + self.exhaustive_hook_deps, + self.component_decorator_pattern, + self.hook_function_pattern, + ) + ] + + def __init__(self) -> None: + # Hack to convince flake8 to accept plugins that are instances + # see: https://github.com/PyCQA/flake8/pull/1674 + self.__init__ = self.__call__ # type: ignore diff --git a/reactpy_flake8/rules_of_hooks.py b/reactpy_flake8/rules_of_hooks.py new file mode 100644 index 0000000..b216237 --- /dev/null +++ b/reactpy_flake8/rules_of_hooks.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import ast +import sys + +from .common import CheckContext, set_current + + +class RulesOfHooksVisitor(ast.NodeVisitor): + def __init__(self, context: CheckContext) -> None: + self._context = context + self._current_call: ast.Call | None = None + self._current_component: ast.FunctionDef | None = None + self._current_conditional: ast.If | ast.IfExp | ast.Try | None = None + self._current_early_return: ast.Return | None = None + self._current_function: ast.FunctionDef | None = None + self._current_hook: ast.FunctionDef | None = None + self._current_loop: ast.For | ast.While | None = None + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + if self._context.is_hook_def(node): + self._check_if_hook_defined_in_function(node) + with set_current( + self, + hook=node, + function=node, + # we need to reset these before enter new hook + conditional=None, + loop=None, + early_return=None, + ): + self.generic_visit(node) + elif self._context.is_component_def(node): + with set_current( + self, + component=node, + function=node, + # we need to reset these before visiting a new component + conditional=None, + loop=None, + early_return=None, + ): + self.generic_visit(node) + else: + with set_current( + self, + function=node, + early_return=None, + ): + self.generic_visit(node) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + with set_current( + self, + function=node, + early_return=None, + ): + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> None: + with set_current(self, call=node): + self.visit(node.func) + for a in node.args: + self.visit(a) + for kw in node.keywords: + self.visit(kw) + + def _visit_hook_usage(self, node: ast.Name | ast.Attribute) -> None: + if self._current_call is not None: + self._check_if_propper_hook_usage(node) + + visit_Attribute = _visit_hook_usage + visit_Name = _visit_hook_usage + + def _visit_conditional(self, node: ast.AST) -> None: + with set_current(self, conditional=node): + self.generic_visit(node) + + visit_If = _visit_conditional + visit_IfExp = _visit_conditional + visit_Try = _visit_conditional + visit_Match = _visit_conditional + + def _visit_loop(self, node: ast.AST) -> None: + with set_current(self, loop=node): + self.generic_visit(node) + + visit_For = _visit_loop + visit_While = _visit_loop + + def visit_Return(self, node: ast.Return) -> None: + if self._current_component is self._current_function: + self._current_early_return = node + + def _check_if_hook_defined_in_function(self, node: ast.FunctionDef) -> None: + if self._current_function is not None: + msg = f"hook {node.name!r} defined as closure in function {self._current_function.name!r}" + self._context.add_error(100, node, msg) + + def _check_if_propper_hook_usage(self, node: ast.Name | ast.Attribute) -> None: + if isinstance(node, ast.Name): + name = node.id + else: + name = node.attr + + if not self._context.is_hook_name(name): + return None + + if self._current_hook is None and self._current_component is None: + msg = f"hook {name!r} used outside component or hook definition" + self._context.add_error(101, node, msg) + + loop_or_conditional = self._current_conditional or self._current_loop + if loop_or_conditional is not None: + node_name = _NODE_TYPE_TO_NAME[type(loop_or_conditional)] + msg = f"hook {name!r} used inside {node_name}" + self._context.add_error(102, node, msg) + + if self._current_early_return: + self._context.add_error( + 103, + node, + f"hook {name!r} used after an early return on line {self._current_early_return.lineno}", + ) + + +_NODE_TYPE_TO_NAME = { + ast.If: "if statement", + ast.IfExp: "inline if expression", + ast.Try: "try statement", + ast.For: "for loop", + ast.While: "while loop", +} +if sys.version_info >= (3, 10): + _NODE_TYPE_TO_NAME[ast.Match] = "match statement" diff --git a/reactpy_flake8/run.py b/reactpy_flake8/run.py new file mode 100644 index 0000000..b06c65b --- /dev/null +++ b/reactpy_flake8/run.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import ast + +from .common import CheckContext +from .exhaustive_deps import ExhaustiveDepsVisitor +from .rules_of_hooks import RulesOfHooksVisitor + +DEFAULT_COMPONENT_DECORATOR_PATTERN = r"^(component|[\w\.]+\.component)$" +DEFAULT_HOOK_FUNCTION_PATTERN = r"^_*use_\w+$" + + +def run_checks( + tree: ast.Module, + exhaustive_hook_deps: bool, + component_decorator_pattern: str = DEFAULT_COMPONENT_DECORATOR_PATTERN, + hook_function_pattern: str = DEFAULT_HOOK_FUNCTION_PATTERN, +) -> list[tuple[int, int, str]]: + context = CheckContext(component_decorator_pattern, hook_function_pattern) + + visitors: list[ast.NodeVisitor] = [RulesOfHooksVisitor(context)] + if exhaustive_hook_deps: + visitors.append(ExhaustiveDepsVisitor(context)) + + for v in visitors: + v.visit(tree) + + return context.errors diff --git a/requirements.txt b/requirements.txt index eaed72c..6922360 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ --r requirements/dev.txt --r requirements/prod.txt --r requirements/test.txt --r requirements/lint.txt +-r requirements/nox-deps.txt +-r requirements/pkg-deps.txt +-r requirements/style.txt +-r requirements/test-env.txt +-r requirements/types.txt diff --git a/requirements/ci.txt b/requirements/ci.txt deleted file mode 100644 index bd5d03a..0000000 --- a/requirements/ci.txt +++ /dev/null @@ -1,3 +0,0 @@ -tox -tox-gh-actions -- dev.txt diff --git a/requirements/dev.txt b/requirements/dev.txt deleted file mode 100644 index 23932c6..0000000 --- a/requirements/dev.txt +++ /dev/null @@ -1,2 +0,0 @@ -tox -tox-wheel diff --git a/requirements/nox-deps.txt b/requirements/nox-deps.txt new file mode 100644 index 0000000..816817c --- /dev/null +++ b/requirements/nox-deps.txt @@ -0,0 +1 @@ +nox diff --git a/requirements/prod.txt b/requirements/pkg-deps.txt similarity index 68% rename from requirements/prod.txt rename to requirements/pkg-deps.txt index e4499d5..3edba2e 100644 --- a/requirements/prod.txt +++ b/requirements/pkg-deps.txt @@ -1 +1,2 @@ flake8 >=3.7 +black diff --git a/requirements/style.txt b/requirements/style.txt new file mode 100644 index 0000000..33afbed --- /dev/null +++ b/requirements/style.txt @@ -0,0 +1,4 @@ +flake8 >=3.7 +black +isort +flake8-tidy-imports diff --git a/requirements/test.txt b/requirements/test-env.txt similarity index 100% rename from requirements/test.txt rename to requirements/test-env.txt diff --git a/requirements/lint.txt b/requirements/types.txt similarity index 53% rename from requirements/lint.txt rename to requirements/types.txt index 6b8f0de..8d7fc1b 100644 --- a/requirements/lint.txt +++ b/requirements/types.txt @@ -1,4 +1,2 @@ -flake8 >=3.7 -black mypy types-setuptools diff --git a/setup.cfg b/setup.cfg index fc2e924..bbfaae1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -7,11 +7,11 @@ warn_unused_ignores = True [flake8] ignore = E203, E266, E501, W503, F811, N802 max-line-length = 88 -max-complexity = 18 -exclude = - .eggs/* - .tox/* - tests/hook_usage_test_cases.py +extend-exclude = + .nox + venv + .venv + tests/cases/* [coverage:report] fail_under = 100 @@ -26,4 +26,3 @@ exclude_lines = [tool:pytest] testpaths = tests xfail_strict = True -addopts = --cov=flake8_idom_hooks --cov-report term diff --git a/setup.py b/setup.py index 55f170d..c047239 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,9 @@ import os + import setuptools # the name of the project -name = "flake8_idom_hooks" +name = "reactpy_flake8" # basic paths used to gather files here = os.path.abspath(os.path.dirname(__file__)) @@ -17,12 +18,12 @@ package = { "name": name, "packages": setuptools.find_packages(exclude=["tests*"]), - "entry_points": {"flake8.extension": ["ROH=flake8_idom_hooks:Plugin"]}, - "python_requires": ">=3.6", - "description": "Flake8 plugin to enforce the rules of hooks for IDOM", + "entry_points": {"flake8.extension": ["RPY=reactpy_flake8:plugin"]}, + "python_requires": ">=3.7", + "description": "Flake8 plugin to enforce the rules of hooks for ReactPy", "author": "Ryan Morshead", "author_email": "ryan.morshead@gmail.com", - "url": "https://github.com/idom-team/flake8-idom-hooks", + "url": "https://github.com/reactive-python/reactpy-flake8", "license": "MIT", "platforms": "Linux, Mac OS X, Windows", "classifiers": [ @@ -46,7 +47,7 @@ requirements = [] -with open(os.path.join(here, "requirements", "prod.txt"), "r") as f: +with open(os.path.join(here, "requirements", "pkg-deps.txt"), "r") as f: for line in map(str.strip, f): if not line.startswith("#"): requirements.append(line) diff --git a/tests/cases/custom_component_decorator_pattern.py b/tests/cases/custom_component_decorator_pattern.py new file mode 100644 index 0000000..edebf42 --- /dev/null +++ b/tests/cases/custom_component_decorator_pattern.py @@ -0,0 +1,12 @@ +@component +def check_normal_pattern(): + if True: + # error: REACTPY102 hook 'use_state' used inside if statement + use_state() + + +@custom_component +def check_custom_pattern(): + if True: + # error: REACTPY102 hook 'use_state' used inside if statement + use_state() diff --git a/tests/cases/custom_hook_function_pattern.py b/tests/cases/custom_hook_function_pattern.py new file mode 100644 index 0000000..40d3b95 --- /dev/null +++ b/tests/cases/custom_hook_function_pattern.py @@ -0,0 +1,7 @@ +@component +def check(): + if True: + # this get's ignored because of custom pattern + use_ignore_this() + # error: REACTPY102 hook 'use_state' used inside if statement + use_state() diff --git a/tests/cases/exhaustive_deps.py b/tests/cases/exhaustive_deps.py new file mode 100644 index 0000000..0caa43e --- /dev/null +++ b/tests/cases/exhaustive_deps.py @@ -0,0 +1,114 @@ +# error: REACTPY101 hook 'use_effect' used outside component or hook definition +use_effect(lambda: x) # no need to check deps outside component/hook + + +@component +def check_effects(): + x = 1 + y = 2 + + # check that use_state is not treated as having dependencies. + use_state(lambda: x) + + use_effect( + lambda: ( + # error: REACTPY202 dependency 'x' of function 'lambda' is not specified in declaration of 'use_effect' + x + + y + ), + [y], + ) + + use_effect( + lambda: ( + # error: REACTPY202 dependency 'x' of function 'lambda' is not specified in declaration of 'use_effect' + x + ) + ) + + use_effect( + lambda: ( + # error: REACTPY202 dependency 'x' of function 'lambda' is not specified in declaration of 'use_effect' + x.y + ), + [ + # error: REACTPY200 dependency arg of 'use_effect' is not destructured - dependencies should be refered to directly, not via an attribute or key of an object + x.y + ], + ) + + module.use_effect( + lambda: ( + # error: REACTPY202 dependency 'x' of function 'lambda' is not specified in declaration of 'use_effect' + x + ), + [], + ) + + module.submodule.use_effect( + lambda: ( + # error: REACTPY202 dependency 'x' of function 'lambda' is not specified in declaration of 'use_effect' + x + ), + [], + ) + + use_effect( + lambda: ( + # error: REACTPY202 dependency 'x' of function 'lambda' is not specified in declaration of 'use_effect' + x + ), + args=[], + ) + + use_effect( + function=lambda: ( + # error: REACTPY202 dependency 'x' of function 'lambda' is not specified in declaration of 'use_effect' + x + ), + args=[], + ) + + @use_effect(args=[x]) + def my_effect(): + x + + @use_effect(args=[]) + def my_effect(): + # error: REACTPY202 dependency 'x' of function 'my_effect' is not specified in declaration of 'use_effect' + x + + @use_effect(args=[]) + @some_other_deco_that_adds_args_to_func_somehow + def my_effect(*args, **kwargs): + args + kwargs + + @module.use_effect(args=[]) + def my_effect(): + # error: REACTPY202 dependency 'x' of function 'my_effect' is not specified in declaration of 'use_effect' + x + + @not_a_decorator_we_care_about + def some_func(): + ... + + @not_a_decorator_we_care_about() + def some_func(): + ... + + @use_effect + def impropper_usage_of_effect_as_decorator(): + # ignored because bad useage + x + + use_effect( + lambda: None, + # error: REACTPY201 dependency args of 'use_effect' should be a literal list, tuple, or None - not expression type 'Name' + not_a_list_or_tuple, + ) + + use_effect( + lambda: None, + args=None, # Ok, to explicitely set None + ) diff --git a/tests/cases/hook_usage.py b/tests/cases/hook_usage.py new file mode 100644 index 0000000..a88fd95 --- /dev/null +++ b/tests/cases/hook_usage.py @@ -0,0 +1,226 @@ +import reactpy +from reactpy import component + + +@component +def HookInIfNoCall(): + if True: + # Ok, hook was not called + use_state + # Also ok, hook itself was not called + func(use_state) + + +@component +def HookInIf(): + if True: + # error: REACTPY102 hook 'use_state' used inside if statement + use_state() + + +@component +def HookInIfInExpression(): + if True: + ( + None + or + # error: REACTPY102 hook 'use_state' used inside if statement + use_state + )() + + +@component +def HookInElif(): + if False: + pass + elif True: + # error: REACTPY102 hook 'use_state' used inside if statement + use_state() + + +@component +def HookInElse(): + if False: + pass + else: + # error: REACTPY102 hook 'use_state' used inside if statement + use_state() + + +@component +def HookInIfExp(): + ( + # error: REACTPY102 hook 'use_state' used inside inline if expression + use_state() + if True + else None + ) + + +@component +def HookInElseOfIfExp(): + ( + None + if True + else + # error: REACTPY102 hook 'use_state' used inside inline if expression + use_state() + ) + + +@component +def HookInTry(): + try: + # error: REACTPY102 hook 'use_state' used inside try statement + use_state() + except: + pass + + +@component +def HookInExcept(): + try: + raise ValueError() + except: + # error: REACTPY102 hook 'use_state' used inside try statement + use_state() + + +@component +def HookInFinally(): + try: + pass + finally: + # error: REACTPY102 hook 'use_state' used inside try statement + use_state() + + +@component +def HookInForLoop(): + for i in range(3): + # error: REACTPY102 hook 'use_state' used inside for loop + use_state() + + +@component +def HookInWhileLoop(): + while True: + # error: REACTPY102 hook 'use_state' used inside while loop + use_state() + + +def outer_function(): + # error: REACTPY100 hook 'use_state' defined as closure in function 'outer_function' + def use_state(): + ... + + +def generic_function(): + # error: REACTPY101 hook 'use_state' used outside component or hook definition + use_state() + + +@component +def use_state(): + use_other() + + +@component +def Component(): + use_state() + + +@reactpy.component +def IdomLongImportComponent(): + use_state() + + +@component +def use_custom_hook(): + use_state() + + +# ok since 'use_state' is not the last attribute +module.use_state.other + +# ok since use state is not called +module.use_effect + +# error: REACTPY101 hook 'use_effect' used outside component or hook definition +module.use_effect() + + +def not_hook_or_component(): + # error: REACTPY101 hook 'use_state' used outside component or hook definition + use_state() + + +@component +def make_component(): + # nested component definitions are ok. + @component + def NestedComponent(): + use_state() + + +some_global_variable + + +@component +def Component(): + # referencing a global variable is OK + use_effect(lambda: some_global_variable, []) + + +if True: + + @component + def Component(): + # this is ok since the conditional is outside the component + use_state() + + @component + def use_other(): + use_state() + + +@component +def example(): + if True: + return None + # error: REACTPY103 hook 'use_state' used after an early return on line 190 + use_state() + + +@component +def example(): + def closure(): + # this return is ok since it's not in the same function + return None + + # Ok: no early return error + use_state() + + +@component +def example(): + @use_effect + def some_effect(): + # this return is ok since it's not in the same function + return None + + # Ok: no early return error + use_state() + + +@reactpy.component +def regression_check(): + @use_effect + def effect(): + # this return caused a false positive early return error in use_effect usage + return cleanup + + @use_effect + async def effect(): + # this return caused a false positive early return error in use_effect usage + return cleanup diff --git a/tests/cases/match_statement.py b/tests/cases/match_statement.py new file mode 100644 index 0000000..d0aaebd --- /dev/null +++ b/tests/cases/match_statement.py @@ -0,0 +1,6 @@ +@component +def example(): + match something: + case int: + # error: REACTPY102 hook 'use_state' used inside match statement + use_state() diff --git a/tests/cases/no_exhaustive_deps.py b/tests/cases/no_exhaustive_deps.py new file mode 100644 index 0000000..b129ef4 --- /dev/null +++ b/tests/cases/no_exhaustive_deps.py @@ -0,0 +1,68 @@ +# confirm that we're still checking for other errors +def generic_function(): + # error: REACTPY101 hook 'use_state' used outside component or hook definition + use_state() + + +@component +def check_dependency_checks_are_ignored(): + x = 1 + y = 2 + + use_effect( + lambda: x + y, + [y], + ) + + use_effect(lambda: x) + + use_effect( + lambda: x.y, + [x.y], + ) + + module.use_effect( + lambda: x, + [], + ) + + use_effect( + lambda: x, + args=[], + ) + + use_effect( + function=lambda: x, + args=[], + ) + + @use_effect(args=[x]) + def my_effect(): + x + + @use_effect(args=[]) + def my_effect(): + x + + @use_effect(args=[]) + @some_other_deco_that_adds_args_to_func_somehow + def my_effect(*args, **kwargs): + args + kwargs + + @module.use_effect(args=[]) + def my_effect(): + x + + @not_a_decorator_we_care_about + def some_func(): + ... + + @not_a_decorator_we_care_about() + def some_func(): + ... + + use_effect( + lambda: None, + not_a_list_or_tuple, + ) diff --git a/tests/hook_usage_test_cases.py b/tests/hook_usage_test_cases.py deleted file mode 100644 index 29df1fd..0000000 --- a/tests/hook_usage_test_cases.py +++ /dev/null @@ -1,234 +0,0 @@ -def HookInIf(): - if True: - # error: ROH102 hook 'use_state' used inside if statement - use_state - - -def HookInElif(): - if False: - pass - elif True: - # error: ROH102 hook 'use_state' used inside if statement - use_state - - -def HookInElse(): - if False: - pass - else: - # error: ROH102 hook 'use_state' used inside if statement - use_state - - -def HookInIfExp(): - ( - # error: ROH102 hook 'use_state' used inside inline if expression - use_state - if True - else None - ) - - -def HookInElseOfIfExp(): - ( - None - if True - else - # error: ROH102 hook 'use_state' used inside inline if expression - use_state - ) - - -def HookInTry(): - try: - # error: ROH102 hook 'use_state' used inside try statement - use_state - except: - pass - - -def HookInExcept(): - try: - raise ValueError() - except: - # error: ROH102 hook 'use_state' used inside try statement - use_state - - -def HookInFinally(): - try: - pass - finally: - # error: ROH102 hook 'use_state' used inside try statement - use_state - - -def HookInForLoop(): - for i in range(3): - # error: ROH102 hook 'use_state' used inside for loop - use_state - - -def HookInWhileLoop(): - while True: - # error: ROH102 hook 'use_state' used inside while loop - use_state - - -def outer_function(): - # error: ROH100 hook 'use_state' defined as closure in function 'outer_function' - def use_state(): - ... - - -def generic_function(): - # error: ROH101 hook 'use_state' used outside component or hook definition - use_state - - -def use_state(): - use_other - - -def Component(): - use_state - - -def use_custom_hook(): - use_state - - -# ok since 'use_state' is not the last attribute -module.use_state.other - -# error: ROH101 hook 'use_effect' used outside component or hook definition -module.use_effect() - - -def not_hook_or_component(): - # error: ROH101 hook 'use_state' used outside component or hook definition - use_state - - -def CheckEffects(): - x = 1 - y = 2 - - use_effect( - lambda: ( - # error: ROH202 dependency 'x' of function 'lambda' is not specified in declaration of 'use_effect' - x - + y - ), - [y], - ) - - use_effect( - lambda: ( - # error: ROH202 dependency 'x' of function 'lambda' is not specified in declaration of 'use_effect' - x - ) - ) - - use_effect( - lambda: ( - # error: ROH202 dependency 'x' of function 'lambda' is not specified in declaration of 'use_effect' - x.y - ), - [ - # error: ROH200 dependency arg of 'use_effect' is not destructured - dependencies should be refered to directly, not via an attribute or key of an object - x.y - ], - ) - - module.use_effect( - lambda: ( - # error: ROH202 dependency 'x' of function 'lambda' is not specified in declaration of 'use_effect' - x - ), - [], - ) - - use_effect( - lambda: ( - # error: ROH202 dependency 'x' of function 'lambda' is not specified in declaration of 'use_effect' - x - ), - args=[], - ) - - use_effect( - function=lambda: ( - # error: ROH202 dependency 'x' of function 'lambda' is not specified in declaration of 'use_effect' - x - ), - args=[], - ) - - @use_effect(args=[x]) - def my_effect(): - x - - @use_effect(args=[]) - def my_effect(): - # error: ROH202 dependency 'x' of function 'my_effect' is not specified in declaration of 'use_effect' - x - - @use_effect(args=[]) - @some_other_deco_that_adds_args_to_func_somehow - def my_effect(*args, **kwargs): - args - kwargs - - @module.use_effect(args=[]) - def my_effect(): - # error: ROH202 dependency 'x' of function 'my_effect' is not specified in declaration of 'use_effect' - x - - @not_a_decorator_we_care_about - def some_func(): - ... - - @not_a_decorator_we_care_about() - def some_func(): - ... - - @use_effect - def impropper_usage_of_effect_as_decorator(): - # ignored because bad useage - x - - use_effect( - lambda: None, - # error: ROH201 dependency args of 'use_effect' should be a literal list, tuple, or None - not expression type 'Name' - not_a_list_or_tuple, - ) - - use_effect( - lambda: None, - args=None, # Ok, to explicitely set None - ) - - -def make_component(): - # nested component definitions are ok. - def NestedComponent(): - use_state - - -some_global_variable - - -def Component(): - # referencing a global variable is OK - use_effect(lambda: some_global_variable, []) - - -if True: - - def Component(): - # this is ok since the conditional is outside the component - use_state - - def use_other(): - use_state diff --git a/tests/test_cases.py b/tests/test_cases.py new file mode 100644 index 0000000..b0d1c0e --- /dev/null +++ b/tests/test_cases.py @@ -0,0 +1,107 @@ +import ast +import sys +from pathlib import Path + +import flake8 +import pytest +from flake8.options.manager import OptionManager + +import reactpy_flake8 +from reactpy_flake8.flake8_plugin import Plugin + +HERE = Path(__file__).parent + + +def setup_plugin(args): + if flake8.__version_info__ >= (6,): + options_manager = OptionManager( + version="", + plugin_versions="", + parents=[], + formatter_names=[], + ) + elif flake8.__version_info__ >= (5,): + options_manager = OptionManager( + version="", + plugin_versions="", + parents=[], + ) + elif flake8.__version_info__ >= (4,): + options_manager = OptionManager( + version="", + parents=[], + prog="", + ) + elif flake8.__version_info__ >= (3, 7): + options_manager = OptionManager( + version="", + parents=[], + prog="", + ) + else: + raise RuntimeError("Unsupported flake8 version") + + plugin = Plugin() + plugin.add_options(options_manager) + + if flake8.__version_info__ >= (5,): + options = options_manager.parse_args(args) + else: + options, _ = options_manager.parse_args(args) + + plugin.parse_options(options) + + return plugin + + +@pytest.mark.parametrize( + "options_args, case_file_name", + [ + ( + "", + "hook_usage.py", + ), + ( + "", + "no_exhaustive_deps.py", + ), + pytest.param( + "", + "match_statement.py", + marks=pytest.mark.skipif( + sys.version_info < (3, 10), + reason="Match statement only in Python 3.10 and above", + ), + ), + ( + "--exhaustive-hook-deps", + "exhaustive_deps.py", + ), + ( + r"--component-decorator-pattern ^(component|custom_component)$", + "custom_component_decorator_pattern.py", + ), + ( + r"--hook-function-pattern ^_*use_(?!ignore_this)\w+$", + "custom_hook_function_pattern.py", + ), + ], +) +def test_reactpy_flake8(options_args, case_file_name): + case_file = Path(__file__).parent / "cases" / case_file_name + # save the file's AST + file_content = case_file.read_text() + + # find 'error' comments to construct expectations + expected_errors = set() + for index, line in enumerate(file_content.split("\n")): + lstrip_line = line.lstrip() + if lstrip_line.startswith("# error:"): + lineno = index + 2 # use 2 since error should be on next line + col_offset = len(line) - len(lstrip_line) + message = line.replace("# error:", "", 1).strip() + expected_errors.add((lineno, col_offset, message, reactpy_flake8.Plugin)) + + plugin = setup_plugin(options_args.split()) + actual_errors = plugin(ast.parse(file_content, case_file_name)) + assert set(actual_errors) == expected_errors diff --git a/tests/test_flake8_idom_hooks.py b/tests/test_flake8_idom_hooks.py deleted file mode 100644 index bcdda1d..0000000 --- a/tests/test_flake8_idom_hooks.py +++ /dev/null @@ -1,33 +0,0 @@ -import ast -from pathlib import Path - -from flake8.options.manager import OptionManager - -from flake8_idom_hooks import Plugin - - -options_manager = OptionManager("test", "0.0.0") -Plugin.add_options(options_manager) - - -def test_flake8_idom_hooks(): - path_to_case_file = Path(__file__).parent / "hook_usage_test_cases.py" - with path_to_case_file.open() as file: - # save the file's AST - file_content = file.read() - tree = ast.parse(file_content, path_to_case_file.name) - - # find 'error' comments to construct expectations - expected_errors = set() - for index, line in enumerate(file_content.split("\n")): - lstrip_line = line.lstrip() - if lstrip_line.startswith("# error:"): - lineno = index + 2 # use 2 since error should be on next line - col_offset = len(line) - len(lstrip_line) - message = line.replace("# error:", "", 1).strip() - expected_errors.add((lineno, col_offset, message, Plugin)) - - options, filenames = options_manager.parse_args(["--exhaustive-hook-deps"]) - Plugin.parse_options(options) - - assert set(Plugin(tree).run()) == expected_errors diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 3f3cae2..0000000 --- a/tox.ini +++ /dev/null @@ -1,32 +0,0 @@ - -[tox] -skip_missing_interpreters = True -envlist = - {py36,py37}-nocov, - py38-{cov,lint} - -[gh-actions] -python = - 3.6: py36-nocov - 3.7: py37-nocov - 3.8: py38-{cov,lint} - -[testenv] -wheel = true -deps = - nocov: -r requirements/test.txt - cov: -r requirements/test.txt -usedevelop = - nocov: false - cov: true -commands = - nocov: pytest tests --no-cov {posargs} -vv - cov: pytest tests {posargs} -vv - -[testenv:py38-lint] -skip_install = true -deps = -r requirements/lint.txt -commands = - black . --check - flake8 . - mypy --strict flake8_idom_hooks pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy