Skip to content

Commit 129f952

Browse files
Zac-HDclaudepre-commit-ci[bot]
authored
Warn on ExceptionGroup subclasses without a .derive() method (#448)
* Add ASYNC126 exceptiongroup-subclass-missing-derive Warn when a class inherits from `ExceptionGroup` / `BaseExceptionGroup` but doesn't override `derive`. Without that override, `split`/`subgroup` (as used by nursery and TaskGroup implementations) silently produce plain `ExceptionGroup` instances instead of the custom subclass. Fixes #334. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Exempt aclose_forcefully from ASYNC102 `trio.aclose_forcefully` (and `anyio.aclose_forcefully`) are designed specifically for cleanup and cancel immediately by design, so calling them in a `finally:` / critical `except:` does not introduce the cancellation-replacing hazard that ASYNC102 targets. Treat them as safe, same as `.aclose()` with no arguments. Fixes #446. * Fix ASYNC126 eval file after auto-formatter wrapping The pre-commit ruff/black formatter wrapped some `class Foo(Bar):` lines across multiple lines, which moved the trailing `# error:` annotation off the line where the visitor actually emits the error (the `class` keyword line). Use shorter class names so the formatter leaves them on a single line. --------- Co-authored-by: Claude <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 963d4df commit 129f952

7 files changed

Lines changed: 144 additions & 7 deletions

File tree

docs/changelog.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ Unreleased
99
- Autofix for :ref:`ASYNC910 <async910>` / :ref:`ASYNC911 <async911>` no longer inserts checkpoints inside ``except`` clauses (which would trigger :ref:`ASYNC120 <async120>`); instead the checkpoint is added at the top of the function or of the enclosing loop. `(issue #403) <https://github.com/python-trio/flake8-async/issues/403>`_
1010
- :ref:`ASYNC910 <async910>` and :ref:`ASYNC911 <async911>` now accept ``__aenter__`` / ``__aexit__`` methods when the partner method provides the checkpoint, or when only one of the two is defined on a class that inherits from another class (charitably assuming the partner is inherited and contains a checkpoint). `(issue #441) <https://github.com/python-trio/flake8-async/issues/441>`_
1111
- :ref:`ASYNC300 <async300>` no longer triggers when the result of ``asyncio.create_task()`` is returned from a function. `(issue #398) <https://github.com/python-trio/flake8-async/issues/398>`_
12+
- Add :ref:`ASYNC126 <async126>` exceptiongroup-subclass-missing-derive. `(issue #334) <https://github.com/python-trio/flake8-async/issues/334>`_
13+
- :ref:`ASYNC102 <async102>` no longer warns on ``await trio.aclose_forcefully(...)`` / ``await anyio.aclose_forcefully(...)``, which are designed for cleanup and cancel immediately by design. `(issue #446) <https://github.com/python-trio/flake8-async/issues/446>`_
1214

1315
25.7.1
1416
======

docs/rules.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ _`ASYNC102` : await-in-finally-or-cancelled
2525
``await`` inside ``finally``, :ref:`cancelled-catching <cancelled>` ``except:``, or ``__aexit__`` must have shielded :ref:`cancel scope <cancel_scope>` with timeout.
2626
If not, the async call will immediately raise a new cancellation, suppressing any cancellation that was caught.
2727
Not applicable to asyncio due to edge-based cancellation semantics it uses as opposed to level-based used by trio and anyio.
28+
Calls to ``.aclose()`` (with no arguments) and to :func:`trio.aclose_forcefully` / :func:`anyio.aclose_forcefully` are exempt, as they are intended for use in cleanup.
2829
See :ref:`ASYNC120 <async120>` for the general case where other exceptions might get suppressed.
2930

3031
ASYNC103 : no-reraise-cancelled
@@ -123,6 +124,14 @@ _`ASYNC125`: constant-absolute-deadline
123124
:func:`anyio.move_on_after`, or the ``relative_deadline`` parameter to
124125
:class:`trio.CancelScope`.
125126

127+
_`ASYNC126`: exceptiongroup-subclass-missing-derive
128+
A subclass of :class:`ExceptionGroup` or :class:`BaseExceptionGroup` must override
129+
:meth:`~BaseExceptionGroup.derive` to return an instance of itself, otherwise
130+
:meth:`~BaseExceptionGroup.split` and :meth:`~BaseExceptionGroup.subgroup` - which
131+
are used by e.g. :ref:`taskgroup_nursery` implementations - will silently produce
132+
plain ``ExceptionGroup`` instances and lose the custom subclass.
133+
See `trio#3175 <https://github.com/python-trio/trio/issues/3175>`_ for motivation.
134+
126135
Blocking sync calls in async functions
127136
======================================
128137

flake8_async/visitors/visitor102_120.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,17 +84,22 @@ def visit_Raise(self, node: ast.Raise):
8484
self._potential_120.clear()
8585

8686
def is_safe_aclose_call(self, node: ast.Await) -> bool:
87-
return (
88-
isinstance(node.value, ast.Call)
89-
# only known safe if no arguments
87+
if not isinstance(node.value, ast.Call):
88+
return False
89+
# allow `<x>.aclose()` with no arguments
90+
if (
91+
isinstance(node.value.func, ast.Attribute)
92+
and node.value.func.attr == "aclose"
9093
and not node.value.args
9194
and not node.value.keywords
92-
and isinstance(node.value.func, ast.Attribute)
93-
and node.value.func.attr == "aclose"
94-
)
95+
):
96+
return True
97+
# allow `trio.aclose_forcefully(<x>)` / `anyio.aclose_forcefully(<x>)`,
98+
# which are specifically designed for cleanup and cancel immediately by design
99+
return get_matching_call(node.value, "aclose_forcefully") is not None
95100

96101
def visit_Await(self, node: ast.Await):
97-
# allow calls to `.aclose()`
102+
# allow calls to `.aclose()` and `[trio/anyio].aclose_forcefully(...)`
98103
if not (self.is_safe_aclose_call(node)):
99104
self.async_call_checker(node)
100105

flake8_async/visitors/visitors.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,40 @@ def is_constant(value: ast.expr) -> bool:
532532
)
533533

534534

535+
@error_class
536+
class Visitor126(Flake8AsyncVisitor):
537+
error_codes: Mapping[str, str] = {
538+
"ASYNC126": (
539+
"ExceptionGroup subclass {} should override `derive`, otherwise"
540+
" `split`/`subgroup` (used by e.g. nursery/TaskGroup"
541+
" implementations) will silently produce plain `ExceptionGroup`"
542+
" instances instead of `{}`."
543+
)
544+
}
545+
546+
def visit_ClassDef(self, node: ast.ClassDef):
547+
def base_name(base: ast.expr) -> str:
548+
# strip generic subscripts like `ExceptionGroup[Foo]`
549+
if isinstance(base, ast.Subscript):
550+
base = base.value
551+
unparsed = ast.unparse(base)
552+
return unparsed.rsplit(".", 1)[-1]
553+
554+
if not any(
555+
base_name(b) in ("ExceptionGroup", "BaseExceptionGroup") for b in node.bases
556+
):
557+
return
558+
559+
for item in node.body:
560+
if (
561+
isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
562+
and item.name == "derive"
563+
):
564+
return
565+
566+
self.error(node, node.name, node.name)
567+
568+
535569
@error_class_cst
536570
class Visitor300(Flake8AsyncVisitor_cst):
537571
error_codes: Mapping[str, str] = {

tests/eval_files/async102.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,3 +345,22 @@ async def foo():
345345
await x.aclose(bar=foo) # ASYNC102: 8, Statement("try/finally", lineno-9)
346346
await x.aclose(*foo) # ASYNC102: 8, Statement("try/finally", lineno-10)
347347
await x.aclose(None) # ASYNC102: 8, Statement("try/finally", lineno-11)
348+
349+
350+
# aclose_forcefully is designed for cleanup and is safe in finally/except
351+
# see https://github.com/python-trio/flake8-async/issues/446
352+
async def foo_aclose_forcefully():
353+
x = None
354+
355+
try:
356+
...
357+
except BaseException:
358+
await trio.aclose_forcefully(x)
359+
finally:
360+
await trio.aclose_forcefully(x)
361+
362+
# unqualified or unknown-base call is still treated as unsafe
363+
try:
364+
...
365+
finally:
366+
await aclose_forcefully(x) # ASYNC102: 8, Statement("try/finally", lineno-3)

tests/eval_files/async126.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import sys
2+
3+
if sys.version_info < (3, 11):
4+
from exceptiongroup import BaseExceptionGroup, ExceptionGroup
5+
6+
7+
class NoDerive(ExceptionGroup): # error: 0, "NoDerive", "NoDerive"
8+
pass
9+
10+
11+
class NoDeriveBE(BaseExceptionGroup): # error: 0, "NoDeriveBE", "NoDeriveBE"
12+
pass
13+
14+
15+
class NoDeriveG(ExceptionGroup[Exception]): # error: 0, "NoDeriveG", "NoDeriveG"
16+
pass
17+
18+
19+
import exceptiongroup as eg
20+
21+
22+
class NoDeriveQ(eg.ExceptionGroup): # error: 0, "NoDeriveQ", "NoDeriveQ"
23+
pass
24+
25+
26+
class _Mixin: ...
27+
28+
29+
class MultiBase(_Mixin, ExceptionGroup): # error: 0, "MultiBase", "MultiBase"
30+
pass
31+
32+
33+
# safe - overrides derive
34+
class HasDerive(ExceptionGroup):
35+
def derive(self, excs):
36+
return HasDerive(self.message, excs)
37+
38+
39+
class HasDeriveBE(BaseExceptionGroup):
40+
def derive(self, excs):
41+
return HasDeriveBE(self.message, excs)
42+
43+
44+
class HasDeriveG(ExceptionGroup[Exception]):
45+
def derive(self, excs):
46+
return HasDeriveG(self.message, excs)
47+
48+
49+
# async derive is weird but counts
50+
class AsyncDer(ExceptionGroup):
51+
async def derive(self, excs): # type: ignore
52+
return AsyncDer(self.message, excs)
53+
54+
55+
# not an ExceptionGroup subclass
56+
class NotAnEG(Exception):
57+
pass
58+
59+
60+
# nested class
61+
class Outer:
62+
class InnerNo(ExceptionGroup): # error: 4, "InnerNo", "InnerNo"
63+
pass
64+
65+
class InnerHas(ExceptionGroup):
66+
def derive(self, excs):
67+
return Outer.InnerHas(self.message, excs)

tests/test_flake8_async.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,7 @@ def _parse_eval_file(
521521
"ASYNC122",
522522
"ASYNC123",
523523
"ASYNC125",
524+
"ASYNC126",
524525
"ASYNC300",
525526
"ASYNC400",
526527
"ASYNC912",

0 commit comments

Comments
 (0)