From 3feea1a9fa76a301bd2e95566219035e19b3d83a Mon Sep 17 00:00:00 2001 From: jakkdl Date: Sat, 18 Feb 2023 14:32:25 +0100 Subject: [PATCH] reimplement trio100 & trio101 with libcst --- flake8_trio/__init__.py | 40 +++++++++-- flake8_trio/runner.py | 32 ++++++++- flake8_trio/visitors/__init__.py | 5 +- flake8_trio/visitors/flake8triovisitor.py | 73 +++++++++++++++++++ flake8_trio/visitors/helpers.py | 86 +++++++++++++++++++++-- flake8_trio/visitors/visitor100.py | 67 ++++++++++++++++++ flake8_trio/visitors/visitor101.py | 64 +++++++++++++++++ flake8_trio/visitors/visitors.py | 79 +-------------------- tests/eval_files/trio100.py | 31 +++++++- tests/test_flake8_trio.py | 22 +++--- 10 files changed, 397 insertions(+), 102 deletions(-) create mode 100644 flake8_trio/visitors/visitor100.py create mode 100644 flake8_trio/visitors/visitor101.py diff --git a/flake8_trio/__init__.py b/flake8_trio/__init__.py index 17fede08..0d3277f0 100644 --- a/flake8_trio/__init__.py +++ b/flake8_trio/__init__.py @@ -13,16 +13,19 @@ import ast import keyword +import os import re import tokenize from argparse import ArgumentTypeError, Namespace from typing import TYPE_CHECKING -from .runner import Flake8TrioRunner +import libcst as cst + +from .runner import Flake8TrioRunner, Flake8TrioRunner_cst from .visitors import default_disabled_error_codes if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Iterable, Sequence from os import PathLike from flake8.options.manager import OptionManager @@ -34,24 +37,53 @@ __version__ = "23.2.5" +# Enable support in libcst for new grammar +# See e.g. https://github.com/Instagram/LibCST/issues/862 +# wrapping the call and restoring old values in case there's other libcst parsers +# in the same environment, which we don't wanna mess up. +def cst_parse_module_native(source: str) -> cst.Module: + var = os.environ.get("LIBCST_PARSER_TYPE") + try: + os.environ["LIBCST_PARSER_TYPE"] = "native" + mod = cst.parse_module(source) + finally: + del os.environ["LIBCST_PARSER_TYPE"] + if var is not None: # pragma: no cover + os.environ["LIBCST_PARSER_TYPE"] = var + return mod + + class Plugin: name = __name__ version = __version__ options: Namespace = Namespace() - def __init__(self, tree: ast.AST): + def __init__(self, tree: ast.AST, lines: Sequence[str]): super().__init__() self._tree = tree + source = "".join(lines) + + self._module: cst.Module = cst_parse_module_native(source) @classmethod def from_filename(cls, filename: str | PathLike[str]) -> Plugin: # pragma: no cover # only used with --runslow with tokenize.open(filename) as f: source = f.read() - return cls(ast.parse(source)) + return cls.from_source(source) + + # alternative `__init__` to avoid re-splitting and/or re-joining lines + @classmethod + def from_source(cls, source: str) -> Plugin: + plugin = Plugin.__new__(cls) + super(Plugin, plugin).__init__() + plugin._tree = ast.parse(source) + plugin._module = cst_parse_module_native(source) + return plugin def run(self) -> Iterable[Error]: yield from Flake8TrioRunner.run(self._tree, self.options) + yield from Flake8TrioRunner_cst(self.options).run(self._module) @staticmethod def add_options(option_manager: OptionManager): diff --git a/flake8_trio/runner.py b/flake8_trio/runner.py index 7b408e09..ddc907ed 100644 --- a/flake8_trio/runner.py +++ b/flake8_trio/runner.py @@ -11,14 +11,18 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING -from .visitors import ERROR_CLASSES, utility_visitors +import libcst as cst + +from .visitors import ERROR_CLASSES, ERROR_CLASSES_CST, utility_visitors if TYPE_CHECKING: from argparse import Namespace from collections.abc import Iterable + from libcst import Module + from .base import Error - from .visitors.flake8triovisitor import Flake8TrioVisitor + from .visitors.flake8triovisitor import Flake8TrioVisitor, Flake8TrioVisitor_cst @dataclass @@ -93,3 +97,27 @@ def visit(self, node: ast.AST): # restore any outer state that was saved in the visitor method for subclass in *self.utility_visitors, *self.visitors: subclass.set_state(subclass.outer.pop(node, {})) + + +class Flake8TrioRunner_cst: + def __init__(self, options: Namespace): + super().__init__() + self.state = SharedState(options) + self.options = options + self.visitors: tuple[Flake8TrioVisitor_cst, ...] = tuple( + v(self.state) for v in ERROR_CLASSES_CST if self.selected(v.error_codes) + ) + + def run(self, module: Module) -> Iterable[Error]: + if not self.visitors: + return + wrapper = cst.MetadataWrapper(module) + for v in self.visitors: + _ = wrapper.visit(v) + yield from self.state.problems + + def selected(self, error_codes: dict[str, str]) -> bool: + return any( + re.match(self.state.options.enable_visitor_codes_regex, code) + for code in error_codes + ) diff --git a/flake8_trio/visitors/__init__.py b/flake8_trio/visitors/__init__.py index c4ab9933..c22fc40b 100644 --- a/flake8_trio/visitors/__init__.py +++ b/flake8_trio/visitors/__init__.py @@ -10,16 +10,19 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from .flake8triovisitor import Flake8TrioVisitor + from .flake8triovisitor import Flake8TrioVisitor, Flake8TrioVisitor_cst __all__ = ["ERROR_CLASSES", "default_disabled_error_codes", "utility_visitors"] ERROR_CLASSES: set[type[Flake8TrioVisitor]] = set() +ERROR_CLASSES_CST: set[type[Flake8TrioVisitor_cst]] = set() utility_visitors: set[type[Flake8TrioVisitor]] = set() default_disabled_error_codes: list[str] = [] # Import all visitors so their decorators run, filling the above containers # This has to be done at the end to avoid circular imports from . import visitor_utility # isort: skip +from . import visitor100 # isort: skip +from . import visitor101 # isort: skip from . import visitor102 # isort: skip from . import visitor103_104 # isort: skip from . import visitor105 # isort: skip diff --git a/flake8_trio/visitors/flake8triovisitor.py b/flake8_trio/visitors/flake8triovisitor.py index 8065dfc6..45808fcd 100644 --- a/flake8_trio/visitors/flake8triovisitor.py +++ b/flake8_trio/visitors/flake8triovisitor.py @@ -7,6 +7,10 @@ from abc import ABC from typing import TYPE_CHECKING, Any, Union +import libcst as cst +import libcst.matchers as m +from libcst.metadata import PositionProvider + from ..base import Error, Statement if TYPE_CHECKING: @@ -157,3 +161,72 @@ def library_str(self) -> str: def add_library(self, name: str) -> None: if name not in self.__state.library: self.__state.library = self.__state.library + (name,) + + +class Flake8TrioVisitor_cst(m.MatcherDecoratableTransformer, ABC): + # abstract attribute by not providing a value + error_codes: dict[str, str] # pyright: reportUninitializedInstanceVariable=false + METADATA_DEPENDENCIES = (PositionProvider,) + + def __init__(self, shared_state: SharedState): + super().__init__() + self.outer: dict[cst.BaseStatement, dict[str, Any]] = {} + self.__state = shared_state + + self.options = self.__state.options + + def get_state(self, *attrs: str, copy: bool = False) -> dict[str, Any]: + # require attrs, since we inherit a *ton* of stuff which we don't want to copy + assert attrs + res: dict[str, Any] = {} + for attr in attrs: + value = getattr(self, attr) + if copy and hasattr(value, "copy"): + value = value.copy() + res[attr] = value + return res + + def set_state(self, attrs: dict[str, Any], copy: bool = False): + for attr, value in attrs.items(): + if copy and hasattr(value, "copy"): # pragma: no cover + # not used by visitors yet + value = value.copy() + setattr(self, attr, value) + + def save_state(self, node: cst.BaseStatement, *attrs: str, copy: bool = False): + state = self.get_state(*attrs, copy=copy) + if node in self.outer: + # not currently used, and not gonna bother adding dedicated test + # visitors atm + self.outer[node].update(state) # pragma: no cover + else: + self.outer[node] = state + + def error( + self, + node: cst.CSTNode, + *args: str | Statement | int, + error_code: str | None = None, + ): + if error_code is None: + assert ( + len(self.error_codes) == 1 + ), "No error code defined, but class has multiple codes" + error_code = next(iter(self.error_codes)) + # don't emit an error if this code is disabled in a multi-code visitor + elif not re.match( + self.options.enable_visitor_codes_regex, error_code + ): # pragma: no cover + return + pos = self.get_metadata(PositionProvider, node).start + + self.__state.problems.append( + Error( + # 7 == len('TRIO...'), so alt messages raise the original code + error_code[:7], + pos.line, + pos.column, + self.error_codes[error_code], + *args, + ) + ) diff --git a/flake8_trio/visitors/helpers.py b/flake8_trio/visitors/helpers.py index 5bf407b4..58a22ad5 100644 --- a/flake8_trio/visitors/helpers.py +++ b/flake8_trio/visitors/helpers.py @@ -7,17 +7,26 @@ import ast from fnmatch import fnmatch -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING, NamedTuple, TypeVar + +import libcst as cst +import libcst.matchers as m from ..base import Statement -from . import ERROR_CLASSES, default_disabled_error_codes, utility_visitors +from . import ( + ERROR_CLASSES, + ERROR_CLASSES_CST, + default_disabled_error_codes, + utility_visitors, +) if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Iterable, Iterator, Sequence - from .flake8triovisitor import Flake8TrioVisitor, HasLineCol + from .flake8triovisitor import Flake8TrioVisitor, Flake8TrioVisitor_cst, HasLineCol T = TypeVar("T", bound=Flake8TrioVisitor) + T_CST = TypeVar("T_CST", bound=Flake8TrioVisitor_cst) def error_class(error_class: type[T]) -> type[T]: @@ -26,6 +35,12 @@ def error_class(error_class: type[T]) -> type[T]: return error_class +def error_class_cst(error_class: type[T_CST]) -> type[T_CST]: + assert error_class.error_codes + ERROR_CLASSES_CST.add(error_class) + return error_class + + def disabled_by_default(error_class: type[T]) -> type[T]: assert error_class.error_codes default_disabled_error_codes.extend(error_class.error_codes) @@ -174,3 +189,66 @@ def get_matching_call( ): return node, node.func.attr, node.func.value.id return None + + +# ___ CST helpers ___ +def oneof_names(*names: str): + return m.OneOf(*map(m.Name, names)) + + +CSTNode_T = TypeVar("CSTNode_T", bound=cst.CSTNode) + + +def list_contains( + seq: Sequence[CSTNode_T], matcher: m.BaseMatcherNode +) -> Iterator[CSTNode_T]: + yield from (item for item in seq if m.matches(item, matcher)) + + +class AttributeCall(NamedTuple): + node: cst.Call + base: str + function: str + + +def with_has_call( + node: cst.With, *names: str, base: Iterable[str] = ("trio", "anyio") +) -> list[AttributeCall]: + res_list: list[AttributeCall] = [] + for item in node.items: + if res := m.extract( + item.item, + m.Call( + func=m.Attribute( + value=m.SaveMatchedNode(m.Name(), name="library"), + attr=m.SaveMatchedNode( + oneof_names(*names), + name="function", + ), + ) + ), + ): + assert isinstance(item.item, cst.Call) + assert isinstance(res["library"], cst.Name) + assert isinstance(res["function"], cst.Name) + if res["library"].value not in base: + continue + res_list.append( + AttributeCall(item.item, res["library"].value, res["function"].value) + ) + return res_list + + +def func_has_decorator(func: cst.FunctionDef, *names: str) -> bool: + return any( + list_contains( + func.decorators, + m.Decorator( + decorator=m.OneOf( + oneof_names(*names), + m.Attribute(attr=oneof_names(*names)), + m.Call(func=m.Attribute(attr=oneof_names(*names))), + ) + ), + ) + ) diff --git a/flake8_trio/visitors/visitor100.py b/flake8_trio/visitors/visitor100.py new file mode 100644 index 00000000..b5bf4cbc --- /dev/null +++ b/flake8_trio/visitors/visitor100.py @@ -0,0 +1,67 @@ +"""Contains visitor for TRIO100. + +A `with trio.fail_after(...):` or `with trio.move_on_after(...):` +context does not contain any `await` statements. This makes it pointless, as +the timeout can only be triggered by a checkpoint. +Checkpoints on Await, Async For and Async With +""" +# if future annotations are imported then shed will reformat away the Union use +from typing import Any, Union + +import libcst as cst +import libcst.matchers as m + +from .flake8triovisitor import Flake8TrioVisitor_cst +from .helpers import AttributeCall, error_class_cst, with_has_call + + +@error_class_cst +class Visitor100_libcst(Flake8TrioVisitor_cst): + error_codes = { + "TRIO100": ( + "{0}.{1} context contains no checkpoints, remove the context or add" + " `await {0}.lowlevel.checkpoint()`." + ), + } + + def __init__(self, *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + self.has_checkpoint_stack: list[bool] = [] + self.node_dict: dict[cst.With, list[AttributeCall]] = {} + + def visit_With(self, node: cst.With) -> None: + if m.matches(node, m.With(asynchronous=m.Asynchronous())): + self.checkpoint_node(node) + if res := with_has_call( + node, "fail_after", "fail_at", "move_on_after", "move_on_at", "CancelScope" + ): + self.node_dict[node] = res + + self.has_checkpoint_stack.append(False) + else: + self.has_checkpoint_stack.append(True) + + def leave_With(self, original_node: cst.With, updated_node: cst.With) -> cst.With: + if not self.has_checkpoint_stack.pop(): + for res in self.node_dict[original_node]: + self.error(res.node, res.base, res.function) + # if: autofixing is enabled for this code + # then: remove the with and pop out it's body + return updated_node + + @m.visit(m.Await() | m.For(asynchronous=m.Asynchronous())) + # can't use m.call_if_inside(m.With), since it matches parents *or* the node itself + # need to use Union due to https://github.com/Instagram/LibCST/issues/870 + def checkpoint_node(self, node: Union[cst.Await, cst.For, cst.With]): + if self.has_checkpoint_stack: + self.has_checkpoint_stack[-1] = True + + def visit_FunctionDef(self, node: cst.FunctionDef): + self.save_state(node, "has_checkpoint_stack", copy=True) + self.has_checkpoint_stack = [] + + def leave_FunctionDef( + self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef + ) -> cst.FunctionDef: + self.set_state(self.outer.pop(original_node, {})) + return updated_node diff --git a/flake8_trio/visitors/visitor101.py b/flake8_trio/visitors/visitor101.py new file mode 100644 index 00000000..31ad0382 --- /dev/null +++ b/flake8_trio/visitors/visitor101.py @@ -0,0 +1,64 @@ +"""Contains visitor for TRIO101. + +`yield` inside a nursery or cancel scope is only safe when implementing a context manager +- otherwise, it breaks exception handling. +""" +from __future__ import annotations + +from typing import Any + +import libcst as cst +import libcst.matchers as m + +from .flake8triovisitor import Flake8TrioVisitor_cst +from .helpers import ( + cancel_scope_names, + error_class_cst, + func_has_decorator, + with_has_call, +) + + +@error_class_cst +class Visitor101(Flake8TrioVisitor_cst): + error_codes = { + "TRIO101": ( + "`yield` inside a nursery or cancel scope is only safe when implementing " + "a context manager - otherwise, it breaks exception handling." + ), + } + + def __init__(self, *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + self._yield_is_error = False + self._safe_decorator = False + + def visit_With(self, node: cst.With): + self.save_state(node, "_yield_is_error", copy=True) + # if there's no safe decorator, + # and it's not yet been determined that yield is error + # and this withitem opens a cancelscope: + # then yielding is unsafe + self._yield_is_error = ( + not self._safe_decorator + and not self._yield_is_error + and bool(with_has_call(node, "open_nursery", *cancel_scope_names)) + ) + + @m.leave(m.OneOf(m.With(), m.FunctionDef())) + def restore_state( + self, original_node: cst.BaseStatement, updated_node: cst.BaseStatement + ) -> cst.BaseStatement: + self.set_state(self.outer.pop(original_node, {})) + return updated_node + + def visit_FunctionDef(self, node: cst.FunctionDef): + self.save_state(node, "_yield_is_error", "_safe_decorator") + self._yield_is_error = False + self._safe_decorator = func_has_decorator( + node, "contextmanager", "asynccontextmanager", "fixture" + ) + + def visit_Yield(self, node: cst.Yield): + if self._yield_is_error: + self.error(node) diff --git a/flake8_trio/visitors/visitors.py b/flake8_trio/visitors/visitors.py index 28981dc4..4fe292d0 100644 --- a/flake8_trio/visitors/visitors.py +++ b/flake8_trio/visitors/visitors.py @@ -6,84 +6,7 @@ from typing import Any, cast from .flake8triovisitor import Flake8TrioVisitor -from .helpers import ( - cancel_scope_names, - disabled_by_default, - error_class, - get_matching_call, - has_decorator, -) - -# used in 100 -checkpoint_node_types = (ast.Await, ast.AsyncFor, ast.AsyncWith) - - -@error_class -class Visitor100(Flake8TrioVisitor): - error_codes = { - "TRIO100": ( - "{0}.{1} context contains no checkpoints, remove the context or add" - " `await {0}.lowlevel.checkpoint()`." - ), - } - - def visit_With(self, node: ast.With | ast.AsyncWith): - for item in (i.context_expr for i in node.items): - call = get_matching_call(item, *cancel_scope_names) - if call and not any( - isinstance(x, checkpoint_node_types) and x != node - for x in ast.walk(node) - ): - # type checking has been done inside get_matching_call - self.error(item, call[2], call[1]) - - visit_AsyncWith = visit_With - - -@error_class -class Visitor101(Flake8TrioVisitor): - error_codes = { - "TRIO101": ( - "`yield` inside a nursery or cancel scope is only safe when implementing " - "a context manager - otherwise, it breaks exception handling." - ), - } - - def __init__(self, *args: Any, **kwargs: Any): - super().__init__(*args, **kwargs) - self._yield_is_error = False - self._safe_decorator = False - - def visit_With(self, node: ast.With | ast.AsyncWith): - self.save_state(node, "_yield_is_error", copy=True) - for item in node.items: - # if there's no safe decorator, - # and it's not yet been determined that yield is error - # and this withitem opens a cancelscope: - # then yielding is unsafe - if ( - not self._safe_decorator - and not self._yield_is_error - and get_matching_call( - item.context_expr, "open_nursery", *cancel_scope_names - ) - is not None - ): - self._yield_is_error = True - - def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef): - self.save_state(node) - self._yield_is_error = False - self._safe_decorator = has_decorator( - node, "contextmanager", "asynccontextmanager", "fixture" - ) - - visit_AsyncWith = visit_With - visit_AsyncFunctionDef = visit_FunctionDef - - def visit_Yield(self, node: ast.Yield): - if self._yield_is_error: - self.error(node) +from .helpers import disabled_by_default, error_class, get_matching_call, has_decorator @error_class diff --git a/tests/eval_files/trio100.py b/tests/eval_files/trio100.py index fcb3174d..525be7e8 100644 --- a/tests/eval_files/trio100.py +++ b/tests/eval_files/trio100.py @@ -1,12 +1,21 @@ # type: ignore + import trio -with trio.move_on_after(10): # error: 5,"trio", "move_on_after" +with trio.move_on_after(10): # error: 5, "trio", "move_on_after" pass async def function_name(): - async with trio.fail_after(10): # error: 15,"trio", "fail_after" + async with trio.fail_after(10): # error: 15, "trio", "fail_after" + pass + async with trio.fail_at(10): # error: 15, "trio", "fail_at" + pass + async with trio.move_on_after(10): # error: 15, "trio", "move_on_after" + pass + async with trio.move_on_at(10): # error: 15, "trio", "move_on_at" + pass + async with trio.CancelScope(...): # error: 15, "trio", "CancelScope" pass with trio.move_on_after(10): @@ -24,7 +33,7 @@ async def function_name(): with open("filename") as _: pass - with trio.fail_after(10): # error: 9,"trio", "fail_after" + with trio.fail_after(10): # error: 9, "trio", "fail_after" pass send_channel, receive_channel = trio.open_memory_channel(0) @@ -35,6 +44,22 @@ async def function_name(): async for _ in receive_channel: pass + # fix missed alarm when function is defined inside the with scope + with trio.move_on_after(10): # error: 9, "trio", "move_on_after" + + async def foo(): + await trio.sleep(1) + + with trio.move_on_after(10): # error: 9, "trio", "move_on_after" + if ...: + + async def foo(): + if ...: + await trio.sleep(1) + + async with random_ignored_library.fail_after(10): + pass + async def function_name2(): with ( diff --git a/tests/test_flake8_trio.py b/tests/test_flake8_trio.py index dbecc45f..3ec9172e 100644 --- a/tests/test_flake8_trio.py +++ b/tests/test_flake8_trio.py @@ -24,7 +24,7 @@ from flake8_trio import Plugin from flake8_trio.base import Error, Statement -from flake8_trio.visitors import ERROR_CLASSES +from flake8_trio.visitors import ERROR_CLASSES, ERROR_CLASSES_CST if TYPE_CHECKING: from collections.abc import Iterable, Sequence @@ -60,7 +60,7 @@ def check_version(test: str): ERROR_CODES = { err_code: err_class - for err_class in ERROR_CLASSES + for err_class in (*ERROR_CLASSES, *ERROR_CLASSES_CST) for err_code in err_class.error_codes.keys() } @@ -75,7 +75,7 @@ def test_eval(test: str, path: Path): if "# TRIO_NO_ERROR" in content: expected = [] - plugin = Plugin(ast.parse(content)) + plugin = Plugin.from_source(content) _ = assert_expected_errors(plugin, *expected, args=parsed_args) @@ -105,7 +105,7 @@ def test_eval_anyio(test: str, path: Path): # initialize plugin and check errors, ignoring columns since they occasionally are # wrong due to len("anyio") > len("trio") - plugin = Plugin(ast.parse(content)) + plugin = Plugin.from_source(content) if "# ANYIO_NO_ERROR" in content: expected = [] @@ -200,7 +200,9 @@ def _parse_eval_file(test: str, content: str) -> tuple[list[Error], list[str]]: raise ParseError(msg) from e for error in expected: - assert re.match(visitor_codes_regex, error.code) + assert re.match( + visitor_codes_regex, error.code + ), "Expected error code not enabled" return expected, parsed_args @@ -259,7 +261,7 @@ def test_noerror_on_sync_code(test: str, path: Path): + "))" ) _ = assert_expected_errors( - Plugin(tree), + Plugin(tree, [ast.unparse(tree)]), args=[f"--enable-visitor-codes-regex={ignored_codes_regex}"], ) @@ -443,7 +445,7 @@ async def foo(): finally: await foo() | ... | return | None """ - plugin = Plugin(ast.AST()) + plugin = Plugin(ast.AST(), []) initialize_options(plugin, args=["--enable-visitor-codes-regex=TRIO910"]) check = "await foo()" @@ -502,7 +504,7 @@ async def foo(): def test_114_raises_on_invalid_parameter(capsys: pytest.CaptureFixture[str]): - plugin = Plugin(ast.AST()) + plugin = Plugin(ast.AST(), []) # flake8 will reraise ArgumentError as SystemExit for arg in "blah.foo", "foo*", "*": with pytest.raises(SystemExit): @@ -513,7 +515,7 @@ def test_114_raises_on_invalid_parameter(capsys: pytest.CaptureFixture[str]): def test_200_options(capsys: pytest.CaptureFixture[str]): - plugin = Plugin(ast.AST()) + plugin = Plugin(ast.AST(), []) for i, arg in (0, "foo"), (2, "foo->->bar"), (2, "foo->bar->fee"): with pytest.raises(SystemExit): initialize_options(plugin, args=[f"--trio200-blocking-calls={arg}"]) @@ -646,7 +648,7 @@ def test_does_not_crash_on_any_valid_code(self, syntax_tree: ast.AST): # Given any syntatically-valid source code, the checker should # not crash. This tests doesn't check that we do the *right* thing, # just that we don't crash on valid-if-poorly-styled code! - plugin = Plugin(syntax_tree) + plugin = Plugin(syntax_tree, [ast.unparse(syntax_tree)]) initialize_options( plugin, [f"--enable-visitor-codes-regex={enable_visitor_codes_regex}"] )