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 |
- ROH100 |
+ RPY100 |
Hook is defined as a closure |
- ROH101 |
+ RPY101 |
Hook was used outside component or hook definition |
- ROH102 |
+ RPY102 |
Hook was used inside a conditional or loop statement |
- ROH200 |
+ RPY103 |
+ Hook 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
|
- ROH201 |
+ RPY201 |
Hook dependency args should be a literal list, tuple or None |
- ROH202 |
+ RPY202 |
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`.
+
+
+
+ Option |
+ Type |
+ Default |
+ Description |
+
+
+ exhaustive_hook_deps |
+ Boolean |
+ False |
+ Enable REACTPY2** errors (recommended) |
+
+
+ component_decorator_pattern |
+ Regex |
+ ^(component|[\w\.]+\.component)$ |
+
+ The pattern which should match the component decorators. Useful if
+ you import the @component decorator under an alias.
+ |
+
+
+ hook_function_pattern |
+ Regex |
+ ^_*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