Skip to content

Commit a5a0268

Browse files
authored
add --exception-suppress-context-manager (#254)
* add --exception-suppress-context-manager * add support for `from contextlib import suppress [as <xxx>]` and `from contextlib import *`
1 parent b9b0869 commit a5a0268

15 files changed

+577
-5
lines changed

docs/usage.rst

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ install and run as standalone
5656

5757
If inside a git repository, running without arguments will run it against all ``*.py`` files in the repository.
5858

59+
Note that this does not currently support reading config files, and does not respect ``# noqa`` comments.
60+
5961
.. code-block:: sh
6062
6163
pip install flake8-async
@@ -230,6 +232,32 @@ Example
230232
ign*,
231233
*.ignore,
232234
235+
``exception-suppress-context-managers``
236+
---------------------------------------
237+
238+
239+
Comma-separated list of contextmanagers which may suppress exceptions
240+
without reraising. For ASYNC91x, these will be parsed in the worst-case scenario,
241+
where any checkpoints inside the contextmanager are not executed, and all
242+
exceptions are suppressed.
243+
``contextlib.suppress`` will be added to the list after parsing, and some basic parsing
244+
of ``from contextlib import suppress`` is supported.
245+
Decorators can be dotted or not, as well as support * as a wildcard.
246+
247+
If you want to be extremely pessimistic, you can specify ``*`` as the context manager.
248+
We may add a whitelist option in the future to support this use-case better.
249+
250+
Example
251+
^^^^^^^
252+
253+
.. code-block:: none
254+
255+
exception-suppress-context-managers =
256+
mysuppressor,
257+
dangerouslibrary.*,
258+
*.suppress,
259+
260+
233261
.. _--startable-in-context-manager:
234262

235263
``startable-in-context-manager``

flake8_async/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,18 @@ def add_options(option_manager: OptionManager | ArgumentParser):
261261
"mydecorator,mypackage.mydecorators.*``"
262262
),
263263
)
264+
add_argument(
265+
"--exception-suppress-context-managers",
266+
default="",
267+
required=False,
268+
type=comma_separated_list,
269+
help=(
270+
"Comma-separated list of contextmanagers which may suppress exceptions "
271+
"without reraising, breaking checkpoint guarantees of ASYNC91x. "
272+
"``contextlib.suppress`` will be added to the list. "
273+
"Decorators can be dotted or not, as well as support * as a wildcard. "
274+
),
275+
)
264276
add_argument(
265277
"--startable-in-context-manager",
266278
type=parse_async114_identifiers,
@@ -379,6 +391,7 @@ def get_matching_codes(
379391
autofix_codes=autofix_codes,
380392
error_on_autofix=options.error_on_autofix,
381393
no_checkpoint_warning_decorators=options.no_checkpoint_warning_decorators,
394+
exception_suppress_context_managers=options.exception_suppress_context_managers,
382395
startable_in_context_manager=options.startable_in_context_manager,
383396
async200_blocking_calls=options.async200_blocking_calls,
384397
anyio=options.anyio,

flake8_async/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class Options:
2929
# whether to print an error message even when autofixed
3030
error_on_autofix: bool
3131
no_checkpoint_warning_decorators: Collection[str]
32+
exception_suppress_context_managers: Collection[str]
3233
startable_in_context_manager: Collection[str]
3334
async200_blocking_calls: dict[str, str]
3435
anyio: bool

flake8_async/visitors/helpers.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,8 @@ def fnmatch_qualified_name(name_list: list[ast.expr], *patterns: str) -> str | N
112112

113113

114114
def fnmatch_qualified_name_cst(
115-
name_list: Iterable[cst.Decorator], *patterns: str
115+
name_list: Iterable[cst.Decorator | cst.Call | cst.Attribute | cst.Name],
116+
*patterns: str,
116117
) -> str | None:
117118
for name in name_list:
118119
qualified_name = get_full_name_for_node_or_raise(name)

flake8_async/visitors/visitor91x.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,9 @@ def __init__(self, *args: Any, **kwargs: Any):
283283
self.has_checkpoint_stack: list[bool] = []
284284
self.node_dict: dict[cst.With, list[AttributeCall]] = {}
285285

286+
# --exception-suppress-context-manager
287+
self.suppress_imported_as: list[str] = []
288+
286289
def should_autofix(self, node: cst.CSTNode, code: str | None = None) -> bool:
287290
if code is None: # pragma: no branch
288291
code = "ASYNC911" if self.has_yield else "ASYNC910"
@@ -300,6 +303,28 @@ def checkpoint(self) -> None:
300303
def checkpoint_statement(self) -> cst.SimpleStatementLine:
301304
return checkpoint_statement(self.library[0])
302305

306+
def visit_ImportFrom(self, node: cst.ImportFrom) -> None:
307+
# Semi-crude approach to handle `from contextlib import suppress`.
308+
# It does not handle the identifier being overridden, or assigned
309+
# to other idefintifers. Function scoping is handled though.
310+
# The "proper" way would be to add a cst version of
311+
# visitor_utility.VisitorTypeTracker, and expand that to handle imports.
312+
if isinstance(node.module, cst.Name) and node.module.value == "contextlib":
313+
# handle `from contextlib import *`
314+
if isinstance(node.names, cst.ImportStar):
315+
self.suppress_imported_as.append("suppress")
316+
return
317+
for alias in node.names:
318+
if alias.name.value == "suppress":
319+
if alias.asname is not None:
320+
# `libcst.AsName` is incorrectly typed
321+
# https://github.com/Instagram/LibCST/issues/503
322+
assert isinstance(alias.asname.name, cst.Name)
323+
self.suppress_imported_as.append(alias.asname.name.value)
324+
else:
325+
self.suppress_imported_as.append("suppress")
326+
return
327+
303328
def visit_FunctionDef(self, node: cst.FunctionDef) -> bool:
304329
# don't lint functions whose bodies solely consist of pass or ellipsis
305330
# @overload functions are also guaranteed to be empty
@@ -316,6 +341,7 @@ def visit_FunctionDef(self, node: cst.FunctionDef) -> bool:
316341
"loop_state",
317342
"try_state",
318343
"has_checkpoint_stack",
344+
"suppress_imported_as",
319345
copy=True,
320346
)
321347
self.uncheckpointed_statements = set()
@@ -449,12 +475,29 @@ def leave_Await(
449475
# can't use TypeVar due to libcst's built-in type checking not supporting it
450476
leave_Raise = leave_Await # type: ignore
451477

478+
def _is_exception_suppressing_context_manager(self, node: cst.With) -> bool:
479+
return (
480+
fnmatch_qualified_name_cst(
481+
(x.item for x in node.items if isinstance(x.item, cst.Call)),
482+
"contextlib.suppress",
483+
*self.suppress_imported_as,
484+
*self.options.exception_suppress_context_managers,
485+
)
486+
is not None
487+
)
488+
452489
# Async context managers can reasonably checkpoint on either or both of entry and
453490
# exit. Given that we can't tell which, we assume "both" to avoid raising a
454491
# missing-checkpoint warning when there might in fact be one (i.e. a false alarm).
455492
def visit_With_body(self, node: cst.With):
456493
if getattr(node, "asynchronous", None):
457494
self.checkpoint()
495+
496+
# if this might suppress exceptions, we cannot treat anything inside it as
497+
# checkpointing.
498+
if self._is_exception_suppressing_context_manager(node):
499+
self.save_state(node, "uncheckpointed_statements", copy=True)
500+
458501
if res := (
459502
with_has_call(node, *cancel_scope_names)
460503
or with_has_call(
@@ -499,6 +542,14 @@ def leave_With(self, original_node: cst.With, updated_node: cst.With):
499542
self.uncheckpointed_statements.remove(s)
500543
for res in self.node_dict[original_node]:
501544
self.error(res.node, error_code="ASYNC912")
545+
546+
# if exception-suppressing, restore all uncheckpointed statements from
547+
# before the `with`.
548+
if self._is_exception_suppressing_context_manager(original_node):
549+
prev_checkpoints = self.uncheckpointed_statements
550+
self.restore_state(original_node)
551+
self.uncheckpointed_statements.update(prev_checkpoints)
552+
502553
if getattr(original_node, "asynchronous", None):
503554
self.checkpoint()
504555
return updated_node

tests/autofix_files/async910.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# mypy: disable-error-code="unreachable"
44
from __future__ import annotations
55

6+
import contextlib
67
import typing
78
from typing import Any, overload
89

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# ARG --exception-suppress-context-managers=mysuppress,*.dangerousname,dangerouslibrary.*
2+
# ARG --enable=ASYNC910,ASYNC911
3+
4+
# AUTOFIX
5+
# ASYNCIO_NO_AUTOFIX
6+
7+
# 912 is tested in eval_files/async912.py to avoid problems with autofix/asyncio
8+
9+
import contextlib
10+
from typing import Any
11+
12+
import trio
13+
14+
mysuppress: Any
15+
anything: Any
16+
dangerouslibrary: Any
17+
18+
19+
async def foo() -> Any:
20+
await foo()
21+
22+
23+
async def foo_suppress(): # ASYNC910: 0, "exit", Statement('function definition', lineno)
24+
with contextlib.suppress():
25+
await foo()
26+
await trio.lowlevel.checkpoint()
27+
28+
29+
async def foo_suppress_1(): # ASYNC910: 0, "exit", Statement('function definition', lineno)
30+
with mysuppress():
31+
await foo()
32+
await trio.lowlevel.checkpoint()
33+
34+
35+
async def foo_suppress_2(): # ASYNC910: 0, "exit", Statement('function definition', lineno)
36+
with anything.dangerousname():
37+
await foo()
38+
await trio.lowlevel.checkpoint()
39+
40+
41+
async def foo_suppress_3(): # ASYNC910: 0, "exit", Statement('function definition', lineno)
42+
with dangerouslibrary.anything():
43+
await foo()
44+
await trio.lowlevel.checkpoint()
45+
46+
47+
async def foo_suppress_async911(): # ASYNC911: 0, "exit", Statement("function definition", lineno)
48+
with contextlib.suppress():
49+
await foo()
50+
yield
51+
await foo()
52+
await trio.lowlevel.checkpoint()
53+
54+
55+
# the `async with` checkpoints, so there's no error
56+
async def foo_suppress_async():
57+
async with mysuppress:
58+
await foo()
59+
60+
61+
async def foo_multiple(): # ASYNC910: 0, "exit", Statement('function definition', lineno)
62+
with anything, contextlib.suppress():
63+
await foo()
64+
await trio.lowlevel.checkpoint()
65+
66+
67+
# we only match on *calls*
68+
async def foo_no_call():
69+
with contextlib.suppress: # type: ignore[attr-defined]
70+
await foo()
71+
72+
73+
# doesn't work on namedexpr, but those should use `as` anyway
74+
async def foo_namedexpr():
75+
with (ref := contextlib.suppress()):
76+
await foo()
77+
78+
79+
async def foo_suppress_as(): # ASYNC910: 0, "exit", Statement('function definition', lineno)
80+
with contextlib.suppress() as my_suppressor:
81+
await foo()
82+
await trio.lowlevel.checkpoint()
83+
84+
85+
# ###############################
86+
# from contextlib import suppress
87+
# ###############################
88+
89+
90+
# not enabled unless it's imported from contextlib
91+
async def foo_suppress_directly_imported_1():
92+
with suppress():
93+
await foo()
94+
95+
96+
from contextlib import suppress
97+
98+
99+
# now it's enabled
100+
async def foo_suppress_directly_imported_2(): # ASYNC910: 0, "exit", Statement('function definition', lineno)
101+
with suppress():
102+
await foo()
103+
await trio.lowlevel.checkpoint()
104+
105+
106+
# it also supports importing with an alias
107+
from contextlib import suppress as adifferentname
108+
109+
110+
async def foo_suppress_directly_imported_3(): # ASYNC910: 0, "exit", Statement('function definition', lineno)
111+
with adifferentname():
112+
await foo()
113+
await trio.lowlevel.checkpoint()
114+
115+
116+
# and will keep track of all identifiers it's been assigned as
117+
async def foo_suppress_directly_imported_4(): # ASYNC910: 0, "exit", Statement('function definition', lineno)
118+
with suppress():
119+
await foo()
120+
await trio.lowlevel.checkpoint()
121+
122+
123+
# basic function scoping is supported
124+
async def function_that_contains_the_import():
125+
from contextlib import suppress as bar
126+
127+
with adifferentname():
128+
await foo()
129+
await trio.lowlevel.checkpoint()
130+
yield # ASYNC911: 4, "yield", Stmt("function definition", lineno-5)
131+
with bar():
132+
await foo()
133+
await trio.lowlevel.checkpoint()
134+
yield # ASYNC911: 4, "yield", Stmt("yield", lineno-3)
135+
await foo()
136+
137+
138+
# bar is not suppressing
139+
async def foo_suppress_directly_imported_scoped():
140+
with bar(): # type: ignore[name-defined]
141+
await foo()
142+
143+
144+
# adifferentname is still suppressing
145+
async def foo_suppress_directly_imported_restored_after_scope(): # ASYNC910: 0, "exit", Statement('function definition', lineno)
146+
with adifferentname():
147+
await foo()
148+
await trio.lowlevel.checkpoint()
149+
150+
151+
# We don't track the identifier being overridden though.
152+
adifferentname = None # type: ignore[assignment]
153+
154+
155+
# shouldn't give an error
156+
async def foo_suppress_directly_imported_5(): # ASYNC910: 0, "exit", Statement('function definition', lineno)
157+
with adifferentname():
158+
await foo()
159+
await trio.lowlevel.checkpoint()
160+
161+
162+
# or assignments to different identifiers
163+
from contextlib import suppress
164+
165+
my_second_suppress = suppress
166+
167+
168+
# should give an error
169+
async def foo_suppress_directly_imported_assignment():
170+
with my_second_suppress():
171+
await foo()

0 commit comments

Comments
 (0)