Skip to content

Commit 7bb803d

Browse files
committed
don't autofix noqa'd errors, add --disable-noqa, add non-passing test that noqa always works
1 parent 35e94e0 commit 7bb803d

19 files changed

+435
-166
lines changed

flake8_trio/__init__.py

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -150,29 +150,26 @@ def from_source(cls, source: str) -> Plugin:
150150
return plugin
151151

152152
def run(self) -> Iterable[Error]:
153-
problems_ast = Flake8TrioRunner.run(self._tree, self.options)
153+
if not self.standalone:
154+
self.options.disable_noqa = True
154155

155156
cst_runner = Flake8TrioRunner_cst(self.options, self.module)
156-
problems_cst = cst_runner.run()
157+
yield from cst_runner.run()
158+
# update saved module so modified source code can be accessed when autofixing
159+
self.module = cst_runner.module
157160

158-
# when run as a flake8 plugin, flake8 handles suppressing errors from `noqa`.
159-
# it's therefore important we don't suppress any errors for compatibility with
160-
# flake8-noqa
161-
if not self.standalone:
161+
problems_ast = Flake8TrioRunner.run(self._tree, self.options)
162+
if self.options.disable_noqa:
162163
yield from problems_ast
163-
yield from problems_cst
164164
return
165165

166-
for problem in (*problems_ast, *problems_cst):
167-
noqa = cst_runner.state.noqas.get(problem.line)
166+
for problem in problems_ast:
167+
noqa = cst_runner.noqas.get(problem.line)
168168
# if there's a noqa comment, and it's bare or this code is listed in it
169169
if noqa is not None and (noqa == set() or problem.code in noqa):
170170
continue
171171
yield problem
172172

173-
# update saved module so modified source code can be accessed when autofixing
174-
self.module = cst_runner.module
175-
176173
@staticmethod
177174
def add_options(option_manager: OptionManager | ArgumentParser):
178175
if isinstance(option_manager, ArgumentParser):
@@ -184,6 +181,16 @@ def add_options(option_manager: OptionManager | ArgumentParser):
184181
dest="files",
185182
help="Files(s) to format, instead of autodetection.",
186183
)
184+
add_argument(
185+
"--disable-noqa",
186+
required=False,
187+
default=False,
188+
action="store_true",
189+
help=(
190+
'Disable the effect of "# noqa". This will report errors on '
191+
'lines with "# noqa" at the end.'
192+
),
193+
)
187194
else: # if run as a flake8 plugin
188195
Plugin.standalone = False
189196
# Disable TRIO9xx calls by default
@@ -326,6 +333,7 @@ def get_matching_codes(
326333
startable_in_context_manager=options.startable_in_context_manager,
327334
trio200_blocking_calls=options.trio200_blocking_calls,
328335
anyio=options.anyio,
336+
disable_noqa=options.disable_noqa,
329337
)
330338

331339

flake8_trio/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class Options:
2121
startable_in_context_manager: Collection[str]
2222
trio200_blocking_calls: dict[str, str]
2323
anyio: bool
24+
disable_noqa: bool
2425

2526

2627
class Statement(NamedTuple):

flake8_trio/runner.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
class SharedState:
3333
options: Options
3434
problems: list[Error] = field(default_factory=list)
35-
noqas: dict[int, set[str]] = field(default_factory=dict)
3635
library: tuple[str, ...] = ()
3736
typed_calls: dict[str, str] = field(default_factory=dict)
3837
variables: dict[str, str] = field(default_factory=dict)
@@ -113,22 +112,30 @@ class Flake8TrioRunner_cst(__CommonRunner):
113112
def __init__(self, options: Options, module: Module):
114113
super().__init__(options)
115114
self.options = options
115+
self.noqas: dict[int, set[str]] = {}
116116

117117
# Could possibly enable/disable utility visitors here, if visitors declared
118118
# dependencies
119119
self.utility_visitors: tuple[Flake8TrioVisitor_cst, ...] = tuple(
120120
v(self.state) for v in utility_visitors_cst
121121
)
122122

123+
# sort the error classes to get predictable behaviour when multiple autofixers
124+
# are enabled
125+
sorted_error_classes_cst = sorted(ERROR_CLASSES_CST, key=lambda x: x.__name__)
123126
self.visitors: tuple[Flake8TrioVisitor_cst, ...] = tuple(
124-
v(self.state) for v in ERROR_CLASSES_CST if self.selected(v.error_codes)
127+
v(self.state)
128+
for v in sorted_error_classes_cst
129+
if self.selected(v.error_codes)
125130
)
126131
self.module = module
127132

128133
def run(self) -> Iterable[Error]:
129-
if not self.visitors:
130-
return
131134
for v in (*self.utility_visitors, *self.visitors):
132135
self.module = cst.MetadataWrapper(self.module).visit(v)
133136

134137
yield from self.state.problems
138+
139+
# expose the noqa's parsed by the last visitor, so they can be used to filter
140+
# ast problems
141+
self.noqas = v.noqas # type: ignore[reportUnboundVariable]

flake8_trio/visitors/flake8triovisitor.py

Lines changed: 118 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from __future__ import annotations
44

55
import ast
6+
import functools
7+
import re
68
from abc import ABC
79
from typing import TYPE_CHECKING, Any, Union
810

@@ -13,6 +15,7 @@
1315

1416
if TYPE_CHECKING:
1517
from collections.abc import Iterable
18+
from re import Match
1619

1720
from ..runner import SharedState
1821

@@ -167,7 +170,7 @@ def __init__(self, shared_state: SharedState):
167170
self.__state = shared_state
168171

169172
self.options = self.__state.options
170-
self.noqas = self.__state.noqas
173+
self.noqas: dict[int, set[str]] = {}
171174

172175
def get_state(self, *attrs: str, copy: bool = False) -> dict[str, Any]:
173176
# require attrs, since we inherit a *ton* of stuff which we don't want to copy
@@ -197,12 +200,19 @@ def save_state(self, node: cst.CSTNode, *attrs: str, copy: bool = False):
197200
def restore_state(self, node: cst.CSTNode):
198201
self.set_state(self.outer.pop(node, {}))
199202

203+
def is_noqa(self, node: cst.CSTNode, code: str):
204+
if self.options.disable_noqa:
205+
return False
206+
pos = self.get_metadata(PositionProvider, node).start
207+
noqas = self.noqas.get(pos.line)
208+
return noqas is not None and (noqas == set() or code in noqas)
209+
200210
def error(
201211
self,
202212
node: cst.CSTNode,
203213
*args: str | Statement | int,
204214
error_code: str | None = None,
205-
):
215+
) -> bool:
206216
if error_code is None:
207217
assert (
208218
len(self.error_codes) == 1
@@ -211,9 +221,12 @@ def error(
211221
# don't emit an error if this code is disabled in a multi-code visitor
212222
# TODO: write test for only one of 910/911 enabled/autofixed
213223
elif error_code[:7] not in self.options.enabled_codes:
214-
return # pragma: no cover
215-
pos = self.get_metadata(PositionProvider, node).start
224+
return False # pragma: no cover
216225

226+
if self.is_noqa(node, error_code):
227+
return False
228+
229+
pos = self.get_metadata(PositionProvider, node).start
217230
self.__state.problems.append(
218231
Error(
219232
# 7 == len('TRIO...'), so alt messages raise the original code
@@ -224,13 +237,60 @@ def error(
224237
*args,
225238
)
226239
)
240+
return True
227241

228-
def should_autofix(self, code: str | None = None):
242+
def should_autofix(self, node: cst.CSTNode, code: str | None = None) -> bool:
229243
if code is None:
230244
assert len(self.error_codes) == 1
231245
code = next(iter(self.error_codes))
246+
if self.is_noqa(node, code):
247+
return False
232248
return code in self.options.autofix_codes
233249

250+
# These must not be overridden without calling super() on them.
251+
# TODO: find a good way of enforcing that statically, or add a test param that
252+
# all errors work with noqa, or do fancy metaclass stuff
253+
# https://stackoverflow.com/questions/75033760/require-overriding-method-to-call-super
254+
# or just #YOLO
255+
256+
def visit_SimpleStatementLine(self, node: cst.SimpleStatementLine):
257+
super().visit_SimpleStatementLine(node)
258+
self.save_state(node, "noqas")
259+
self.parse_noqa(node.trailing_whitespace.comment)
260+
261+
def leave_SimpleStatementLine(
262+
self,
263+
original_node: cst.SimpleStatementLine,
264+
updated_node: cst.SimpleStatementLine,
265+
):
266+
super_updated_node = super().leave_SimpleStatementLine(
267+
original_node, updated_node
268+
)
269+
self.restore_state(original_node)
270+
return super_updated_node # noqa: R504
271+
272+
def visit_SimpleStatementSuite(self, node: cst.SimpleStatementSuite):
273+
self.save_state(node, "noqas")
274+
self.parse_noqa(node.trailing_whitespace.comment)
275+
276+
def leave_SimpleStatementSuite(
277+
self,
278+
original_node: cst.SimpleStatementSuite,
279+
updated_node: cst.SimpleStatementSuite,
280+
):
281+
self.restore_state(original_node)
282+
return updated_node
283+
284+
def visit_IndentedBlock(self, node: cst.IndentedBlock):
285+
self.save_state(node, "noqas")
286+
self.parse_noqa(node.header.comment)
287+
288+
def leave_IndentedBlock(
289+
self, original_node: cst.IndentedBlock, updated_node: cst.IndentedBlock
290+
):
291+
self.restore_state(original_node)
292+
return updated_node
293+
234294
@property
235295
def library(self) -> tuple[str, ...]:
236296
return self.__state.library if self.__state.library else ("trio",)
@@ -240,3 +300,56 @@ def library(self) -> tuple[str, ...]:
240300
def add_library(self, name: str) -> None:
241301
if name not in self.__state.library:
242302
self.__state.library = self.__state.library + (name,)
303+
304+
def parse_noqa(self, node: cst.Comment | None):
305+
if not node:
306+
return
307+
noqa_match = _find_noqa(node.value)
308+
if noqa_match is None:
309+
return
310+
311+
codes_str = noqa_match.groupdict()["codes"]
312+
313+
line = self.get_metadata(PositionProvider, node).start.line
314+
315+
assert (
316+
line not in self.noqas
317+
), "it should not be possible to have two [noqa] comments on the same line"
318+
319+
# blanket noqa
320+
if codes_str is None:
321+
# this also includes a non-blanket noqa with a list of invalid codes
322+
# so one should maybe instead specifically look for no `:`
323+
self.noqas[line] = set()
324+
return
325+
# split string on ",", strip of whitespace, and save in set if non-empty
326+
# TODO: Check that code exists
327+
self.noqas[line] = {
328+
item_strip for item in codes_str.split(",") if (item_strip := item.strip())
329+
}
330+
331+
332+
# taken from
333+
# https://github.com/PyCQA/flake8/blob/d016204366a22d382b5b56dc14b6cbff28ce929e/src/flake8/defaults.py#L27
334+
NOQA_INLINE_REGEXP = re.compile(
335+
# We're looking for items that look like this:
336+
# ``# noqa``
337+
# ``# noqa: E123``
338+
# ``# noqa: E123,W451,F921``
339+
# ``# noqa:E123,W451,F921``
340+
# ``# NoQA: E123,W451,F921``
341+
# ``# NOQA: E123,W451,F921``
342+
# ``# NOQA:E123,W451,F921``
343+
# We do not want to capture the ``: `` that follows ``noqa``
344+
# We do not care about the casing of ``noqa``
345+
# We want a comma-separated list of errors
346+
# upstream links to an old version on regex101
347+
# https://regex101.com/r/4XUuax/5 full explanation of the regex
348+
r"# noqa(?::[\s]?(?P<codes>([A-Z]+[0-9]+(?:[,\s]+)?)+))?",
349+
re.IGNORECASE,
350+
)
351+
352+
353+
@functools.lru_cache(maxsize=512)
354+
def _find_noqa(physical_line: str) -> Match[str] | None:
355+
return NOQA_INLINE_REGEXP.search(physical_line)

flake8_trio/visitors/visitor100.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def leave_With(
5858
for res in self.node_dict[original_node]:
5959
self.error(res.node, res.base, res.function)
6060

61-
if self.should_autofix() and len(updated_node.items) == 1:
61+
if self.should_autofix(original_node) and len(updated_node.items) == 1:
6262
return flatten_preserving_comments(updated_node)
6363

6464
return updated_node

0 commit comments

Comments
 (0)