Skip to content

Commit 574638f

Browse files
committed
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.
1 parent 963d4df commit 574638f

5 files changed

Lines changed: 112 additions & 0 deletions

File tree

docs/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ 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>`_
1213

1314
25.7.1
1415
======

docs/rules.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,14 @@ _`ASYNC125`: constant-absolute-deadline
123123
:func:`anyio.move_on_after`, or the ``relative_deadline`` parameter to
124124
:class:`trio.CancelScope`.
125125

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

flake8_async/visitors/visitors.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,41 @@ 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")
556+
for b in node.bases
557+
):
558+
return
559+
560+
for item in node.body:
561+
if (
562+
isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
563+
and item.name == "derive"
564+
):
565+
return
566+
567+
self.error(node, node.name, node.name)
568+
569+
535570
@error_class_cst
536571
class Visitor300(Flake8AsyncVisitor_cst):
537572
error_codes: Mapping[str, str] = {

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 NoDeriveBase(BaseExceptionGroup): # error: 0, "NoDeriveBase", "NoDeriveBase"
12+
pass
13+
14+
15+
class NoDeriveGeneric(ExceptionGroup[Exception]): # error: 0, "NoDeriveGeneric", "NoDeriveGeneric"
16+
pass
17+
18+
19+
import exceptiongroup
20+
21+
22+
class NoDeriveQualified(exceptiongroup.ExceptionGroup): # error: 0, "NoDeriveQualified", "NoDeriveQualified"
23+
pass
24+
25+
26+
class SomeMixin: ...
27+
28+
29+
class MultipleBases(SomeMixin, ExceptionGroup): # error: 0, "MultipleBases", "MultipleBases"
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 HasDeriveBase(BaseExceptionGroup):
40+
def derive(self, excs):
41+
return HasDeriveBase(self.message, excs)
42+
43+
44+
class HasDeriveGeneric(ExceptionGroup[Exception]):
45+
def derive(self, excs):
46+
return HasDeriveGeneric(self.message, excs)
47+
48+
49+
# async derive is weird but counts
50+
class AsyncDerive(ExceptionGroup):
51+
async def derive(self, excs): # type: ignore
52+
return AsyncDerive(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 InnerNoDerive(ExceptionGroup): # error: 4, "InnerNoDerive", "InnerNoDerive"
63+
pass
64+
65+
class InnerHasDerive(ExceptionGroup):
66+
def derive(self, excs):
67+
return Outer.InnerHasDerive(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)