diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index fa1fbc5..b9fe3fb 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -40,6 +40,8 @@ repos:
rev: v1.11.2
hooks:
- id: mypy
+ # uses py311 syntax, mypy configured for py39
+ exclude: tests/eval_files/.*_py311.py
- repo: https://github.com/RobertCraigie/pyright-python
rev: v1.1.384
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 9a4e78f..d81bb8a 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -4,6 +4,10 @@ Changelog
`CalVer, YY.month.patch `_
+24.10.1
+=======
+- Add :ref:`ASYNC123 ` bad-exception-group-flattening
+
24.9.5
======
- Fix crash when analyzing code with infinite loop inside context manager.
diff --git a/docs/rules.rst b/docs/rules.rst
index a181536..c3c8edb 100644
--- a/docs/rules.rst
+++ b/docs/rules.rst
@@ -89,6 +89,11 @@ _`ASYNC121`: control-flow-in-taskgroup
_`ASYNC122`: delayed-entry-of-relative-cancelscope
:func:`trio.move_on_after`, :func:`trio.fail_after`, :func:`anyio.move_on_after` and :func:`anyio.fail_after` behaves unintuitively if initialization and entry are separated, with the timeout starting on initialization. Trio>=0.27 changes this behaviour, so if you don't support older versions you should disable this check. See `Trio issue #2512 `_.
+_`ASYNC123`: bad-exception-group-flattening
+ Raising one of the exceptions contained in an exception group will mutate it, replacing the original ``.__context__`` with the group, and erasing the ``.__traceback__``.
+ Dropping this information makes diagnosing errors much more difficult.
+ We recommend ``raise SomeNewError(...) from group`` if possible; or consider using `copy.copy` to shallow-copy the exception before re-raising (for copyable types), or re-raising the error from outside the `except` block.
+
Blocking sync calls in async functions
======================================
diff --git a/flake8_async/__init__.py b/flake8_async/__init__.py
index 82fdf43..b9bbaf9 100644
--- a/flake8_async/__init__.py
+++ b/flake8_async/__init__.py
@@ -38,7 +38,7 @@
# CalVer: YY.month.patch, e.g. first release of July 2022 == "22.7.1"
-__version__ = "24.9.5"
+__version__ = "24.10.1"
# taken from https://github.com/Zac-HD/shed
diff --git a/flake8_async/visitors/__init__.py b/flake8_async/visitors/__init__.py
index 0b05011..f1b6199 100644
--- a/flake8_async/visitors/__init__.py
+++ b/flake8_async/visitors/__init__.py
@@ -36,6 +36,7 @@
visitor105,
visitor111,
visitor118,
+ visitor123,
visitor_utility,
visitors,
)
diff --git a/flake8_async/visitors/visitor123.py b/flake8_async/visitors/visitor123.py
new file mode 100644
index 0000000..58237c1
--- /dev/null
+++ b/flake8_async/visitors/visitor123.py
@@ -0,0 +1,111 @@
+"""foo."""
+
+from __future__ import annotations
+
+import ast
+from typing import TYPE_CHECKING, Any
+
+from .flake8asyncvisitor import Flake8AsyncVisitor
+from .helpers import error_class
+
+if TYPE_CHECKING:
+ from collections.abc import Mapping
+
+
+@error_class
+class Visitor123(Flake8AsyncVisitor):
+ error_codes: Mapping[str, str] = {
+ "ASYNC123": (
+ "Raising a child exception of an exception group loses"
+ " context, cause, and/or traceback of the exception inside the group."
+ )
+ }
+
+ def __init__(self, *args: Any, **kwargs: Any):
+ super().__init__(*args, **kwargs)
+ self.try_star = False
+ self.exception_group_names: set[str] = set()
+ self.child_exception_list_names: set[str] = set()
+ self.child_exception_names: set[str] = set()
+
+ def _is_exception_group(self, node: ast.expr) -> bool:
+ return (
+ (isinstance(node, ast.Name) and node.id in self.exception_group_names)
+ or (
+ # a child exception might be an ExceptionGroup
+ self._is_child_exception(node)
+ )
+ or (
+ isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Attribute)
+ and self._is_exception_group(node.func.value)
+ and node.func.attr in ("subgroup", "split")
+ )
+ )
+
+ def _is_exception_list(self, node: ast.expr | None) -> bool:
+ return (
+ isinstance(node, ast.Name) and node.id in self.child_exception_list_names
+ ) or (
+ isinstance(node, ast.Attribute)
+ and node.attr == "exceptions"
+ and self._is_exception_group(node.value)
+ )
+
+ def _is_child_exception(self, node: ast.expr | None) -> bool:
+ return (
+ isinstance(node, ast.Name) and node.id in self.child_exception_names
+ ) or (isinstance(node, ast.Subscript) and self._is_exception_list(node.value))
+
+ def visit_Raise(self, node: ast.Raise):
+ if self._is_child_exception(node.exc):
+ self.error(node)
+
+ def visit_ExceptHandler(self, node: ast.ExceptHandler):
+ self.save_state(
+ node,
+ "exception_group_names",
+ "child_exception_list_names",
+ "child_exception_names",
+ copy=True,
+ )
+ if node.name is None or (
+ not self.try_star
+ and (node.type is None or "ExceptionGroup" not in ast.unparse(node.type))
+ ):
+ self.novisit = True
+ return
+ self.exception_group_names = {node.name}
+
+ # ast.TryStar added in py311
+ # we run strict codecov on all python versions, this one doesn't run on bool:
+ return True
+
+
+def any_fun(arg: Exception) -> Exception:
+ return arg
+
+
+try:
+ ...
+except ExceptionGroup as e:
+ if condition():
+ raise e.exceptions[0] # error: 8
+ elif condition():
+ raise copy.copy(e.exceptions[0]) # safe
+ elif condition():
+ raise copy.deepcopy(e.exceptions[0]) # safe
+ else:
+ raise any_fun(e.exceptions[0]) # safe
+try:
+ ...
+except BaseExceptionGroup as e:
+ raise e.exceptions[0] # error: 4
+try:
+ ...
+except ExceptionGroup as e:
+ my_e = e.exceptions[0]
+ raise my_e # error: 4
+try:
+ ...
+except ExceptionGroup as e:
+ excs = e.exceptions
+ my_e = excs[0]
+ raise my_e # error: 4
+try:
+ ...
+except ExceptionGroup as e:
+ excs_2 = e.subgroup(bool)
+ if excs_2:
+ raise excs_2.exceptions[0] # error: 8
+try:
+ ...
+except ExceptionGroup as e:
+ excs_1, excs_2 = e.split(bool)
+ if excs_1:
+ raise excs_1.exceptions[0] # error: 8
+ if excs_2:
+ raise excs_2.exceptions[0] # error: 8
+
+try:
+ ...
+except ExceptionGroup as e:
+ f = e
+ raise f.exceptions[0] # error: 4
+try:
+ ...
+except ExceptionGroup as e:
+ excs = e.exceptions
+ excs2 = excs
+ raise excs2[0] # error: 4
+try:
+ ...
+except ExceptionGroup as e:
+ my_exc = e.exceptions[0]
+ my_exc2 = my_exc
+ raise my_exc2 # error: 4
+
+try:
+ ...
+except ExceptionGroup as e:
+ raise e.exceptions[0].exceptions[0] # error: 4
+try:
+ ...
+except ExceptionGroup as e:
+ excs = e.exceptions
+ for exc in excs:
+ if ...:
+ raise exc # error: 12
+ raise
+try:
+ ...
+except ExceptionGroup as e:
+ ff: ExceptionGroup[Exception] = e
+ raise ff.exceptions[0] # error: 4
+try:
+ ...
+except ExceptionGroup as e:
+ raise e.subgroup(bool).exceptions[0] # type: ignore # error: 4
+
+# not implemented
+try:
+ ...
+except ExceptionGroup as e:
+ a, *b = e.exceptions
+ raise a
+
+# not implemented
+try:
+ ...
+except ExceptionGroup as e:
+ x: Any = object()
+ x.y = e
+ raise x.y.exceptions[0]
+
+# coverage
+try:
+ ...
+except ExceptionGroup:
+ ...
+
+# not implemented
+try:
+ ...
+except ExceptionGroup as e:
+ (a, *b), (c, *d) = e.split(bool)
+ if condition():
+ raise a
+ if condition():
+ raise b[0]
+ if condition():
+ raise c
+ if condition():
+ raise d[0]
+
+# coverage (skip irrelevant assignments)
+x = 0
+
+# coverage (ignore multiple targets when assign target is child exception)
+try:
+ ...
+except ExceptionGroup as e:
+ exc = e.exceptions[0]
+ b, c = exc
+ if condition():
+ raise b # not handled, and probably shouldn't raise
+ else:
+ raise c # same
+
+# coverage (skip irrelevant loop)
+for x in range(5):
+ ...
diff --git a/tests/eval_files/async123_py311.py b/tests/eval_files/async123_py311.py
new file mode 100644
index 0000000..4410dab
--- /dev/null
+++ b/tests/eval_files/async123_py311.py
@@ -0,0 +1,4 @@
+try:
+ ...
+except* Exception as e:
+ raise e.exceptions[0] # error: 4
diff --git a/tests/test_flake8_async.py b/tests/test_flake8_async.py
index 5219f27..9676bbc 100644
--- a/tests/test_flake8_async.py
+++ b/tests/test_flake8_async.py
@@ -482,6 +482,7 @@ def _parse_eval_file(
# doesn't check for it
"ASYNC121",
"ASYNC122",
+ "ASYNC123",
"ASYNC300",
"ASYNC912",
}