Skip to content

Commit d2e1afb

Browse files
authored
ASYNC912: timeout/cancelscope with only conditional checkpoints (#242)
* Add ASYNC912: timeout/cancelscope with only conditional checkpoints * ASYNC100 is now part of Visitor91x * rewrite `with_has_call` to add support for nested attributes
1 parent 0d6646f commit d2e1afb

26 files changed

+592
-155
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
# Changelog
22
*[CalVer, YY.month.patch](https://calver.org/)*
33

4+
## 24.5.1
5+
- Add ASYNC912: no checkpoints in with statement are guaranteed to run.
6+
- ASYNC100 now properly treats async for comprehensions as checkpoints.
7+
- ASYNC100 now supports autofixing on asyncio.
8+
49
## 24.4.2
510
- Add ASYNC119: yield in contextmanager in async generator.
611

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ Note: 22X, 23X and 24X has not had asyncio-specific suggestions written.
6464
- **ASYNC910**: Exit or `return` from async function with no guaranteed checkpoint or exception since function definition. You might want to enable this on a codebase to make it easier to reason about checkpoints, and make the logic of ASYNC911 correct.
6565
- **ASYNC911**: Exit, `yield` or `return` from async iterable with no guaranteed checkpoint since possible function entry (yield or function definition)
6666
Checkpoints are `await`, `async for`, and `async with` (on one of enter/exit).
67+
- **ASYNC912**: Timeout/Cancelscope has no awaits that are guaranteed to run. If the scope has no checkpoints at all, then `ASYNC100` will be raised instead.
6768

6869
### Removed Warnings
6970
- **TRIOxxx**: All error codes are now renamed ASYNCxxx

docs/rules.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Optional rules disabled by default
5555
- **ASYNC910**: Exit or ``return`` from async function with no guaranteed checkpoint or exception since function definition. You might want to enable this on a codebase to make it easier to reason about checkpoints, and make the logic of ASYNC911 correct.
5656
- **ASYNC911**: Exit, ``yield`` or ``return`` from async iterable with no guaranteed checkpoint since possible function entry (yield or function definition)
5757
Checkpoints are ``await``, ``async for``, and ``async with`` (on one of enter/exit).
58+
-- **ASYNC912**: A timeout/cancelscope has checkpoints, but they're not guaranteed to run. Similar to ASYNC100, but it does not warn on trivial cases where there is no checkpoint at all. It instead shares logic with ASYNC910 and ASYNC911 for parsing conditionals and branches.
5859

5960
Removed rules
6061
================

flake8_async/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737

3838

3939
# CalVer: YY.month.patch, e.g. first release of July 2022 == "22.7.1"
40-
__version__ = "24.4.2"
40+
__version__ = "24.5.1"
4141

4242

4343
# taken from https://github.com/Zac-HD/shed

flake8_async/visitors/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
from . import (
3131
visitor2xx,
3232
visitor91x,
33-
visitor100,
3433
visitor101,
3534
visitor102,
3635
visitor103_104,

flake8_async/visitors/flake8asyncvisitor.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,11 @@ def error(
9898
), "No error code defined, but class has multiple codes"
9999
error_code = next(iter(self.error_codes))
100100
# don't emit an error if this code is disabled in a multi-code visitor
101-
elif strip_error_subidentifier(error_code) not in self.options.enabled_codes:
101+
elif (
102+
(ec_no_sub := strip_error_subidentifier(error_code))
103+
not in self.options.enabled_codes
104+
and ec_no_sub not in self.options.autofix_codes
105+
):
102106
return
103107

104108
self.__state.problems.append(
@@ -217,7 +221,11 @@ def error(
217221
error_code = next(iter(self.error_codes))
218222
# don't emit an error if this code is disabled in a multi-code visitor
219223
# TODO: write test for only one of 910/911 enabled/autofixed
220-
elif strip_error_subidentifier(error_code) not in self.options.enabled_codes:
224+
elif (
225+
(ec_no_sub := strip_error_subidentifier(error_code))
226+
not in self.options.enabled_codes
227+
and ec_no_sub not in self.options.autofix_codes
228+
):
221229
return False # pragma: no cover
222230

223231
if self.is_noqa(node, error_code):
@@ -237,7 +245,7 @@ def error(
237245
return True
238246

239247
def should_autofix(self, node: cst.CSTNode, code: str | None = None) -> bool:
240-
if code is None:
248+
if code is None: # pragma: no cover
241249
assert len(self.error_codes) == 1
242250
code = next(iter(self.error_codes))
243251
# this does not currently need to check for `noqa`s, as error() does that

flake8_async/visitors/helpers.py

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,19 @@ def error_class_cst(error_class: type[T_CST]) -> type[T_CST]:
5151

5252

5353
def disabled_by_default(error_class: type[T_EITHER]) -> type[T_EITHER]:
54+
"""Default-disables all error codes in a class."""
5455
assert error_class.error_codes # type: ignore[attr-defined]
5556
default_disabled_error_codes.extend(
5657
error_class.error_codes # type: ignore[attr-defined]
5758
)
5859
return error_class
5960

6061

62+
def disable_codes_by_default(*codes: str) -> None:
63+
"""Default-disables only specified codes."""
64+
default_disabled_error_codes.extend(codes)
65+
66+
6167
def utility_visitor(c: type[T]) -> type[T]:
6268
assert not hasattr(c, "error_codes")
6369
c.error_codes = {}
@@ -317,30 +323,68 @@ class AttributeCall(NamedTuple):
317323
function: str
318324

319325

326+
# the custom __or__ in libcst breaks pyright type checking. It's possible to use
327+
# `Union` as a workaround ... except pyupgrade will automatically replace that.
328+
# So we have to resort to specifying one of the base classes.
329+
# See https://github.com/Instagram/LibCST/issues/1143
330+
def build_cst_matcher(attr: str) -> m.BaseExpression:
331+
"""Build a cst matcher structure with attributes&names matching a string `a.b.c`."""
332+
if "." not in attr:
333+
return m.Name(value=attr)
334+
body, tail = attr.rsplit(".")
335+
return m.Attribute(value=build_cst_matcher(body), attr=m.Name(value=tail))
336+
337+
338+
def identifier_to_string(attr: cst.Name | cst.Attribute) -> str:
339+
if isinstance(attr, cst.Name):
340+
return attr.value
341+
assert isinstance(attr.value, (cst.Attribute, cst.Name))
342+
return identifier_to_string(attr.value) + "." + attr.attr.value
343+
344+
320345
def with_has_call(
321346
node: cst.With, *names: str, base: Iterable[str] = ("trio", "anyio")
322347
) -> list[AttributeCall]:
348+
"""Check if a with statement has a matching call, returning a list with matches.
349+
350+
`names` specify the names of functions to match, `base` specifies the
351+
library/module(s) the function must be in.
352+
The list elements in the return value are named tuples with the matched node,
353+
base and function.
354+
355+
Examples_
356+
357+
`with_has_call(node, "bar", base="foo")` matches foo.bar.
358+
`with_has_call(node, "bar", "bee", base=("foo", "a.b.c")` matches
359+
`foo.bar`, `foo.bee`, `a.b.c.bar`, and `a.b.c.bee`.
360+
361+
"""
362+
if isinstance(base, str):
363+
base = (base,) # pragma: no cover
364+
365+
# build matcher, using SaveMatchedNode to save the base and the function name.
366+
matcher = m.Call(
367+
func=m.Attribute(
368+
value=m.SaveMatchedNode(
369+
m.OneOf(*(build_cst_matcher(b) for b in base)), name="base"
370+
),
371+
attr=m.SaveMatchedNode(
372+
oneof_names(*names),
373+
name="function",
374+
),
375+
)
376+
)
377+
323378
res_list: list[AttributeCall] = []
324379
for item in node.items:
325-
if res := m.extract(
326-
item.item,
327-
m.Call(
328-
func=m.Attribute(
329-
value=m.SaveMatchedNode(m.Name(), name="library"),
330-
attr=m.SaveMatchedNode(
331-
oneof_names(*names),
332-
name="function",
333-
),
334-
)
335-
),
336-
):
380+
if res := m.extract(item.item, matcher):
337381
assert isinstance(item.item, cst.Call)
338-
assert isinstance(res["library"], cst.Name)
382+
assert isinstance(res["base"], (cst.Name, cst.Attribute))
339383
assert isinstance(res["function"], cst.Name)
340-
if res["library"].value not in base:
341-
continue
342384
res_list.append(
343-
AttributeCall(item.item, res["library"].value, res["function"].value)
385+
AttributeCall(
386+
item.item, identifier_to_string(res["base"]), res["function"].value
387+
)
344388
)
345389
return res_list
346390

flake8_async/visitors/visitor100.py

Lines changed: 0 additions & 90 deletions
This file was deleted.

0 commit comments

Comments
 (0)