diff --git a/README.md b/README.md index 4e68087..f3230fe 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,6 @@ tox # Errors -`ROH2**` errors can be enabled with the `--exhaustive-hook-deps` flag or setting -`exhaustive_hook_deps = True` in your `flake8` config. - @@ -63,3 +60,41 @@ tox
Code
+ +# 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 ROH2** 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/__init__.py b/flake8_idom_hooks/__init__.py index 689fb5d..7f0500b 100644 --- a/flake8_idom_hooks/__init__.py +++ b/flake8_idom_hooks/__init__.py @@ -10,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/flake8_idom_hooks/common.py b/flake8_idom_hooks/common.py new file mode 100644 index 0000000..41c0cdc --- /dev/null +++ b/flake8_idom_hooks/common.py @@ -0,0 +1,51 @@ +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"ROH{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/flake8_idom_hooks/exhaustive_deps.py index 7ce6620..b745e8a 100644 --- a/flake8_idom_hooks/exhaustive_deps.py +++ b/flake8_idom_hooks/exhaustive_deps.py @@ -1,19 +1,18 @@ import ast from typing import Optional, Set, Union -from .utils import ErrorVisitor, is_component_def, is_hook_def, set_current +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: @@ -53,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 @@ -101,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, @@ -113,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]]: @@ -129,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, ( @@ -143,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, ( @@ -156,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 @@ -179,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/flake8_idom_hooks/flake8_plugin.py b/flake8_idom_hooks/flake8_plugin.py index 8b5bc53..8b44170 100644 --- a/flake8_idom_hooks/flake8_plugin.py +++ b/flake8_idom_hooks/flake8_plugin.py @@ -6,7 +6,13 @@ from flake8.options.manager import OptionManager from flake8_idom_hooks import __version__ -from flake8_idom_hooks.run import run_checks +from flake8_idom_hooks.run import ( + DEFAULT_COMPONENT_DECORATOR_PATTERN, + DEFAULT_HOOK_FUNCTION_PATTERN, + run_checks, +) + +from .exhaustive_deps import HOOKS_WITH_DEPS class Plugin: @@ -15,26 +21,58 @@ class Plugin: version = __version__ exhaustive_hook_deps: bool + component_decorator_pattern: str + hook_function_pattern: str - @classmethod - def add_options(cls, option_manager: OptionManager) -> None: + 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, + ) - @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 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 run(self) -> list[tuple[int, int, str, type[Plugin]]]: + def __call__(self, tree: ast.Module) -> list[tuple[int, int, str, type[Plugin]]]: return [ error + (self.__class__,) - for error in run_checks(self._tree, self.exhaustive_hook_deps) + 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/flake8_idom_hooks/rules_of_hooks.py b/flake8_idom_hooks/rules_of_hooks.py index ead80ba..2b99a53 100644 --- a/flake8_idom_hooks/rules_of_hooks.py +++ b/flake8_idom_hooks/rules_of_hooks.py @@ -1,18 +1,12 @@ import ast from typing import Optional, Union -from .utils import ( - ErrorVisitor, - is_component_def, - is_hook_def, - is_hook_function_name, - set_current, -) +from .common import CheckContext, set_current -class RulesOfHooksVisitor(ErrorVisitor): - def __init__(self) -> None: - super().__init__() +class RulesOfHooksVisitor(ast.NodeVisitor): + def __init__(self, context: CheckContext) -> None: + self._context = context self._current_hook: Optional[ast.FunctionDef] = None self._current_component: Optional[ast.FunctionDef] = None self._current_function: Optional[ast.FunctionDef] = None @@ -21,7 +15,7 @@ def __init__(self) -> None: self._current_loop: Union[None, ast.For, ast.While] = None def visit_FunctionDef(self, node: ast.FunctionDef) -> None: - if is_hook_def(node): + if self._context.is_hook_def(node): self._check_if_hook_defined_in_function(node) with set_current( self, @@ -32,7 +26,7 @@ def visit_FunctionDef(self, node: ast.FunctionDef) -> None: loop=None, ): self.generic_visit(node) - elif is_component_def(node): + elif self._context.is_component_def(node): with set_current( self, component=node, @@ -70,7 +64,7 @@ def _visit_loop(self, node: ast.AST) -> None: 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) + self._context.add_error(100, node, msg) def _check_if_propper_hook_usage( self, node: Union[ast.Name, ast.Attribute] @@ -80,12 +74,12 @@ def _check_if_propper_hook_usage( else: name = node.attr - if not is_hook_function_name(name): + 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._save_error(101, node, msg) + self._context.add_error(101, node, msg) loop_or_conditional = self._current_conditional or self._current_loop if loop_or_conditional is not None: @@ -99,4 +93,4 @@ def _check_if_propper_hook_usage( } node_name = node_type_to_name[node_type] msg = f"hook {name!r} used inside {node_name}" - self._save_error(102, node, msg) + self._context.add_error(102, node, msg) diff --git a/flake8_idom_hooks/run.py b/flake8_idom_hooks/run.py index f734bac..b06c65b 100644 --- a/flake8_idom_hooks/run.py +++ b/flake8_idom_hooks/run.py @@ -2,23 +2,27 @@ import ast +from .common import CheckContext from .exhaustive_deps import ExhaustiveDepsVisitor from .rules_of_hooks import RulesOfHooksVisitor -from .utils import ErrorVisitor + +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]]: - visitor_types: list[type[ErrorVisitor]] = [RulesOfHooksVisitor] + context = CheckContext(component_decorator_pattern, hook_function_pattern) + + visitors: list[ast.NodeVisitor] = [RulesOfHooksVisitor(context)] if exhaustive_hook_deps: - visitor_types.append(ExhaustiveDepsVisitor) + visitors.append(ExhaustiveDepsVisitor(context)) - errors: list[tuple[int, int, str]] = [] - for vtype in visitor_types: - visitor = vtype() - visitor.visit(tree) - errors.extend(visitor.errors) + for v in visitors: + v.visit(tree) - return errors + return context.errors diff --git a/flake8_idom_hooks/utils.py b/flake8_idom_hooks/utils.py deleted file mode 100644 index 14e501e..0000000 --- a/flake8_idom_hooks/utils.py +++ /dev/null @@ -1,39 +0,0 @@ -import ast -from contextlib import contextmanager -from typing import Any, Iterator, List, Tuple - - -@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 index 77dfee3..9c83403 100644 --- a/noxfile.py +++ b/noxfile.py @@ -6,6 +6,13 @@ 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") @@ -17,6 +24,7 @@ def test(session: Session) -> None: @session def test_style(session: Session) -> None: install_requirements(session, "style") + session.run("black", "--check", ".") session.run("isort", "--check", ".") session.run("flake8", ".") diff --git a/setup.cfg b/setup.cfg index fc385a0..bbfaae1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,7 +11,7 @@ extend-exclude = .nox venv .venv - tests/hook_usage_test_cases.py + tests/cases/* [coverage:report] fail_under = 100 diff --git a/setup.py b/setup.py index 24d7e78..b3a2e46 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ package = { "name": name, "packages": setuptools.find_packages(exclude=["tests*"]), - "entry_points": {"flake8.extension": ["ROH=flake8_idom_hooks:Plugin"]}, + "entry_points": {"flake8.extension": ["ROH=flake8_idom_hooks:plugin"]}, "python_requires": ">=3.6", "description": "Flake8 plugin to enforce the rules of hooks for IDOM", "author": "Ryan Morshead", diff --git a/tests/cases/custom_component_decorator_pattern.py b/tests/cases/custom_component_decorator_pattern.py new file mode 100644 index 0000000..54e546f --- /dev/null +++ b/tests/cases/custom_component_decorator_pattern.py @@ -0,0 +1,12 @@ +@component +def check_normal_pattern(): + if True: + # error: ROH102 hook 'use_state' used inside if statement + use_state + + +@custom_component +def check_custom_pattern(): + if True: + # error: ROH102 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..c1b5b35 --- /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: ROH102 hook 'use_state' used inside if statement + use_state diff --git a/tests/hook_usage_test_cases.py b/tests/cases/exhaustive_deps.py similarity index 51% rename from tests/hook_usage_test_cases.py rename to tests/cases/exhaustive_deps.py index 29df1fd..ee9abfe 100644 --- a/tests/hook_usage_test_cases.py +++ b/tests/cases/exhaustive_deps.py @@ -1,119 +1,15 @@ -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 +use_effect(lambda: x) # no need to check deps outside component/hook -def CheckEffects(): +@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: ROH202 dependency 'x' of function 'lambda' is not specified in declaration of 'use_effect' @@ -149,6 +45,14 @@ def CheckEffects(): [], ) + module.submodule.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' @@ -208,27 +112,3 @@ def impropper_usage_of_effect_as_decorator(): 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/cases/hook_usage.py b/tests/cases/hook_usage.py new file mode 100644 index 0000000..c873618 --- /dev/null +++ b/tests/cases/hook_usage.py @@ -0,0 +1,161 @@ +import idom +from idom import component + + +@component +def HookInIf(): + if True: + # error: ROH102 hook 'use_state' used inside if statement + use_state + + +@component +def HookInElif(): + if False: + pass + elif True: + # error: ROH102 hook 'use_state' used inside if statement + use_state + + +@component +def HookInElse(): + if False: + pass + else: + # error: ROH102 hook 'use_state' used inside if statement + use_state + + +@component +def HookInIfExp(): + ( + # error: ROH102 hook 'use_state' used inside inline if expression + use_state + if True + else None + ) + + +@component +def HookInElseOfIfExp(): + ( + None + if True + else + # error: ROH102 hook 'use_state' used inside inline if expression + use_state + ) + + +@component +def HookInTry(): + try: + # error: ROH102 hook 'use_state' used inside try statement + use_state + except: + pass + + +@component +def HookInExcept(): + try: + raise ValueError() + except: + # error: ROH102 hook 'use_state' used inside try statement + use_state + + +@component +def HookInFinally(): + try: + pass + finally: + # error: ROH102 hook 'use_state' used inside try statement + use_state + + +@component +def HookInForLoop(): + for i in range(3): + # error: ROH102 hook 'use_state' used inside for loop + use_state + + +@component +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 + + +@component +def use_state(): + use_other + + +@component +def Component(): + use_state + + +@idom.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 + +# 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 + + +@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 diff --git a/tests/cases/no_exhaustive_deps.py b/tests/cases/no_exhaustive_deps.py new file mode 100644 index 0000000..fb1d6fa --- /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: ROH101 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/test_cases.py b/tests/test_cases.py new file mode 100644 index 0000000..2f1d008 --- /dev/null +++ b/tests/test_cases.py @@ -0,0 +1,70 @@ +import ast +from pathlib import Path + +import pytest +from flake8.options.manager import OptionManager + +import flake8_idom_hooks +from flake8_idom_hooks.flake8_plugin import Plugin + +HERE = Path(__file__).parent + + +def setup_plugin(args): + options_manager = OptionManager( + version="0.0.0", + plugin_versions=flake8_idom_hooks.__version__, + parents=[], + ) + + plugin = Plugin() + plugin.add_options(options_manager) + options = options_manager.parse_args(args) + plugin.parse_options(options) + + return plugin + + +@pytest.mark.parametrize( + "options_args, case_file_name", + [ + ( + "", + "hook_usage.py", + ), + ( + "--exhaustive-hook-deps", + "exhaustive_deps.py", + ), + ( + "", + "no_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_flake8_idom_hooks(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, flake8_idom_hooks.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 524b0ac..0000000 --- a/tests/test_flake8_idom_hooks.py +++ /dev/null @@ -1,38 +0,0 @@ -import ast -from pathlib import Path - -from flake8.options.manager import OptionManager - -import flake8_idom_hooks - -options_manager = OptionManager( - version="0.0.0", - plugin_versions=flake8_idom_hooks.__version__, - parents=[], -) -flake8_idom_hooks.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, flake8_idom_hooks.Plugin) - ) - - options = options_manager.parse_args(["--exhaustive-hook-deps"]) - flake8_idom_hooks.Plugin.parse_options(options) - - assert set(flake8_idom_hooks.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