diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6263930
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,17 @@
+*.egg-info
+*.dist-info
+*.pyc
+build
+dist
+__pycache__
+.coverage
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+.eggs/
+.tox
+.idea
+.cache
+.local
+venv*/
+/src/exceptiongroup/_version.py
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..8f9f3c5
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,24 @@
+repos:
+- repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v4.6.0
+ hooks:
+ - id: check-added-large-files
+ - id: check-case-conflict
+ - id: check-merge-conflict
+ - id: check-symlinks
+ - id: check-toml
+ - id: check-yaml
+ - id: debug-statements
+ exclude: "tests/test_catch_py311.py"
+ - id: end-of-file-fixer
+ - id: mixed-line-ending
+ args: ["--fix=lf"]
+ - id: trailing-whitespace
+
+- repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.5.0
+ hooks:
+ - id: ruff
+ args: [--fix, --show-fixes]
+ exclude: "tests/test_catch_py311.py"
+ - id: ruff-format
diff --git a/CHANGES.rst b/CHANGES.rst
index 040c6f9..a9c19c5 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -3,6 +3,42 @@ Version history
This library adheres to `Semantic Versioning 2.0 `_.
+**1.2.2**
+
+- Removed an ``assert`` in ``exceptiongroup._formatting`` that caused compatibility
+ issues with Sentry (`#123 `_)
+
+**1.2.1**
+
+- Updated the copying of ``__notes__`` to match CPython behavior (PR by CF Bolz-Tereick)
+- Corrected the type annotation of the exception handler callback to accept a
+ ``BaseExceptionGroup`` instead of ``BaseException``
+- Fixed type errors on Python < 3.10 and the type annotation of ``suppress()``
+ (PR by John Litborn)
+
+**1.2.0**
+
+- Added special monkeypatching if `Apport `_ has
+ overridden ``sys.excepthook`` so it will format exception groups correctly
+ (PR by John Litborn)
+- Added a backport of ``contextlib.suppress()`` from Python 3.12.1 which also handles
+ suppressing exceptions inside exception groups
+- Fixed bare ``raise`` in a handler reraising the original naked exception rather than
+ an exception group which is what is raised when you do a ``raise`` in an ``except*``
+ handler
+
+**1.1.3**
+
+- ``catch()`` now raises a ``TypeError`` if passed an async exception handler instead of
+ just giving a ``RuntimeWarning`` about the coroutine never being awaited. (#66, PR by
+ John Litborn)
+- Fixed plain ``raise`` statement in an exception handler callback to work like a
+ ``raise`` in an ``except*`` block
+- Fixed new exception group not being chained to the original exception when raising an
+ exception group from exceptions raised in handler callbacks
+- Fixed type annotations of the ``derive()``, ``subgroup()`` and ``split()`` methods to
+ match the ones in typeshed
+
**1.1.2**
- Changed handling of exceptions in exception group handler callbacks to not wrap a
diff --git a/README.rst b/README.rst
index 48178d9..d34937d 100644
--- a/README.rst
+++ b/README.rst
@@ -26,6 +26,8 @@ It contains the following:
* ``traceback.format_exception_only()``
* ``traceback.print_exception()``
* ``traceback.print_exc()``
+* A backported version of ``contextlib.suppress()`` from Python 3.12.1 which also
+ handles suppressing exceptions inside exception groups
If this package is imported on Python 3.11 or later, the built-in implementations of the
exception group classes are used instead, ``TracebackException`` is not monkey patched
@@ -52,7 +54,7 @@ containing more matching exceptions.
Thus, the following Python 3.11+ code:
-.. code-block:: python3
+.. code-block:: python
try:
...
@@ -64,15 +66,15 @@ Thus, the following Python 3.11+ code:
would be written with this backport like this:
-.. code-block:: python3
+.. code-block:: python
- from exceptiongroup import ExceptionGroup, catch
+ from exceptiongroup import BaseExceptionGroup, catch
- def value_key_err_handler(excgroup: ExceptionGroup) -> None:
+ def value_key_err_handler(excgroup: BaseExceptionGroup) -> None:
for exc in excgroup.exceptions:
print('Caught exception:', type(exc))
- def runtime_err_handler(exc: ExceptionGroup) -> None:
+ def runtime_err_handler(exc: BaseExceptionGroup) -> None:
print('Caught runtime error')
with catch({
@@ -84,6 +86,20 @@ would be written with this backport like this:
**NOTE**: Just like with ``except*``, you cannot handle ``BaseExceptionGroup`` or
``ExceptionGroup`` with ``catch()``.
+Suppressing exceptions
+======================
+
+This library contains a backport of the ``contextlib.suppress()`` context manager from
+Python 3.12.1. It allows you to selectively ignore certain exceptions, even when they're
+inside exception groups:
+
+.. code-block:: python
+
+ from exceptiongroup import suppress
+
+ with suppress(RuntimeError):
+ raise ExceptionGroup("", [RuntimeError("boo")])
+
Notes on monkey patching
========================
diff --git a/debian/changelog b/debian/changelog
index 5cc589f..e71cebb 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,33 @@
+python-exceptiongroup (1.2.2-1) unstable; urgency=medium
+
+ * Team upload.
+ * New upstream release.
+ * Standards-Version: 4.7.0 (no changes required).
+
+ -- Colin Watson Mon, 15 Jul 2024 11:14:53 +0100
+
+python-exceptiongroup (1.2.1-1) unstable; urgency=medium
+
+ * Team upload.
+ * New upstream release:
+ - Fix test failure on Python 3.12.3 (closes: #1069817).
+
+ -- Colin Watson Fri, 26 Apr 2024 13:42:11 +0100
+
+python-exceptiongroup (1.2.0-1) unstable; urgency=medium
+
+ * Team upload.
+ * New upstream version 1.2.0
+
+ -- Timo Röhling Tue, 28 Nov 2023 22:14:41 +0100
+
+python-exceptiongroup (1.1.3-1) unstable; urgency=medium
+
+ * Team upload.
+ * New upstream version 1.1.3
+
+ -- Timo Röhling Mon, 21 Aug 2023 11:33:24 +0200
+
python-exceptiongroup (1.1.2-1) unstable; urgency=medium
* Team upload.
diff --git a/debian/control b/debian/control
index 7d6132b..d676b63 100644
--- a/debian/control
+++ b/debian/control
@@ -12,7 +12,7 @@ Build-Depends:
python3-all,
python3-flit-scm,
python3-pytest ,
-Standards-Version: 4.6.2
+Standards-Version: 4.7.0
Testsuite: autopkgtest-pkg-pybuild
Vcs-Git: https://salsa.debian.org/python-team/packages/python-exceptiongroup.git
Vcs-Browser: https://salsa.debian.org/python-team/packages/python-exceptiongroup
diff --git a/pyproject.toml b/pyproject.toml
index 4b21e4c..8416650 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,6 +31,7 @@ test = [
[tool.flit.sdist]
include = [
+ "CHANGES.rst",
"tests",
]
exclude = [
@@ -44,18 +45,21 @@ version_scheme = "post-release"
local_scheme = "dirty-tag"
write_to = "src/exceptiongroup/_version.py"
-[tool.ruff]
-line-length = 88
-select = [
- "E", "F", "W", # default flake-8
+[tool.ruff.lint]
+extend-select = [
"I", # isort
"ISC", # flake8-implicit-str-concat
"PGH", # pygrep-hooks
"RUF100", # unused noqa (yesqa)
+ "UP", # pyupgrade
+ "W", # pycodestyle warnings
]
-target-version = "py37"
-[tool.ruff.isort]
+[tool.ruff.lint.pyupgrade]
+# Preserve types, even if a file imports `from __future__ import annotations`.
+keep-runtime-typing = true
+
+[tool.ruff.lint.isort]
known-first-party = ["exceptiongroup"]
[tool.pytest.ini_options]
@@ -67,25 +71,41 @@ source = ["exceptiongroup"]
relative_files = true
[tool.coverage.report]
-exclude_lines = [
- "pragma: no cover",
- "if TYPE_CHECKING:"
+exclude_also = [
+ "if TYPE_CHECKING:",
+ "@overload",
]
+[tool.pyright]
+# for type tests, the code itself isn't type checked in CI
+reportUnnecessaryTypeIgnoreComment = true
+
+[tool.mypy]
+# for type tests, the code itself isn't type checked in CI
+warn_unused_ignores = true
+
[tool.tox]
legacy_tox_ini = """
[tox]
-envlist = py37, py38, py39, py310, py311, py312, pypy3
+envlist = py37, py38, py39, py310, py311, py312, py313, pypy3
+labels =
+ typing = py{310,311,312}-typing
skip_missing_interpreters = true
minversion = 4.0
[testenv]
extras = test
commands = python -m pytest {posargs}
+package = editable
usedevelop = true
-[testenv:pyright]
-deps = pyright
-commands = pyright --verifytypes exceptiongroup
+[testenv:{py37-,py38-,py39-,py310-,py311-,py312-,}typing]
+deps =
+ pyright
+ mypy
+commands =
+ pyright --verifytypes exceptiongroup
+ pyright tests/check_types.py
+ mypy tests/check_types.py
usedevelop = true
"""
diff --git a/src/exceptiongroup/__init__.py b/src/exceptiongroup/__init__.py
index 0e7e02b..d8e36b2 100644
--- a/src/exceptiongroup/__init__.py
+++ b/src/exceptiongroup/__init__.py
@@ -6,6 +6,7 @@
"format_exception_only",
"print_exception",
"print_exc",
+ "suppress",
]
import os
@@ -38,3 +39,8 @@
BaseExceptionGroup = BaseExceptionGroup
ExceptionGroup = ExceptionGroup
+
+if sys.version_info < (3, 12, 1):
+ from ._suppress import suppress
+else:
+ from contextlib import suppress
diff --git a/src/exceptiongroup/_catch.py b/src/exceptiongroup/_catch.py
index 0be39b4..0246568 100644
--- a/src/exceptiongroup/_catch.py
+++ b/src/exceptiongroup/_catch.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import inspect
import sys
from collections.abc import Callable, Iterable, Mapping
from contextlib import AbstractContextManager
@@ -10,7 +11,7 @@
from ._exceptions import BaseExceptionGroup
if TYPE_CHECKING:
- _Handler = Callable[[BaseException], Any]
+ _Handler = Callable[[BaseExceptionGroup[Any]], Any]
class _Catcher:
@@ -33,7 +34,16 @@ def __exit__(
elif unhandled is None:
return True
else:
- raise unhandled from None
+ if isinstance(exc, BaseExceptionGroup):
+ try:
+ raise unhandled from exc.__cause__
+ except BaseExceptionGroup:
+ # Change __context__ to __cause__ because Python 3.11 does this
+ # too
+ unhandled.__context__ = exc.__cause__
+ raise
+
+ raise unhandled from exc
return False
@@ -49,9 +59,23 @@ def handle_exception(self, exc: BaseException) -> BaseException | None:
matched, excgroup = excgroup.split(exc_types)
if matched:
try:
- handler(matched)
+ try:
+ raise matched
+ except BaseExceptionGroup:
+ result = handler(matched)
+ except BaseExceptionGroup as new_exc:
+ if new_exc is matched:
+ new_exceptions.append(new_exc)
+ else:
+ new_exceptions.extend(new_exc.exceptions)
except BaseException as new_exc:
new_exceptions.append(new_exc)
+ else:
+ if inspect.iscoroutine(result):
+ raise TypeError(
+ f"Error trying to handle {matched!r} with {handler!r}. "
+ "Exception handler must be a sync function."
+ ) from exc
if not excgroup:
break
@@ -60,9 +84,6 @@ def handle_exception(self, exc: BaseException) -> BaseException | None:
if len(new_exceptions) == 1:
return new_exceptions[0]
- if excgroup:
- new_exceptions.append(excgroup)
-
return BaseExceptionGroup("", new_exceptions)
elif (
excgroup and len(excgroup.exceptions) == 1 and excgroup.exceptions[0] is exc
@@ -73,7 +94,7 @@ def handle_exception(self, exc: BaseException) -> BaseException | None:
def catch(
- __handlers: Mapping[type[BaseException] | Iterable[type[BaseException]], _Handler]
+ __handlers: Mapping[type[BaseException] | Iterable[type[BaseException]], _Handler],
) -> AbstractContextManager[None]:
if not isinstance(__handlers, Mapping):
raise TypeError("the argument must be a mapping")
diff --git a/src/exceptiongroup/_exceptions.py b/src/exceptiongroup/_exceptions.py
index 84e2b37..a4a7ace 100644
--- a/src/exceptiongroup/_exceptions.py
+++ b/src/exceptiongroup/_exceptions.py
@@ -5,13 +5,13 @@
from inspect import getmro, isclass
from typing import TYPE_CHECKING, Generic, Type, TypeVar, cast, overload
-if TYPE_CHECKING:
- from typing import Self
-
_BaseExceptionT_co = TypeVar("_BaseExceptionT_co", bound=BaseException, covariant=True)
_BaseExceptionT = TypeVar("_BaseExceptionT", bound=BaseException)
_ExceptionT_co = TypeVar("_ExceptionT_co", bound=Exception, covariant=True)
_ExceptionT = TypeVar("_ExceptionT", bound=Exception)
+# using typing.Self would require a typing_extensions dependency on py<3.11
+_ExceptionGroupSelf = TypeVar("_ExceptionGroupSelf", bound="ExceptionGroup")
+_BaseExceptionGroupSelf = TypeVar("_BaseExceptionGroupSelf", bound="BaseExceptionGroup")
def check_direct_subclass(
@@ -27,7 +27,7 @@ def check_direct_subclass(
def get_condition_filter(
condition: type[_BaseExceptionT]
| tuple[type[_BaseExceptionT], ...]
- | Callable[[_BaseExceptionT_co], bool]
+ | Callable[[_BaseExceptionT_co], bool],
) -> Callable[[_BaseExceptionT_co], bool]:
if isclass(condition) and issubclass(
cast(Type[BaseException], condition), BaseException
@@ -42,12 +42,25 @@ def get_condition_filter(
raise TypeError("expected a function, exception type or tuple of exception types")
+def _derive_and_copy_attributes(self, excs):
+ eg = self.derive(excs)
+ eg.__cause__ = self.__cause__
+ eg.__context__ = self.__context__
+ eg.__traceback__ = self.__traceback__
+ if hasattr(self, "__notes__"):
+ # Create a new list so that add_note() only affects one exceptiongroup
+ eg.__notes__ = list(self.__notes__)
+ return eg
+
+
class BaseExceptionGroup(BaseException, Generic[_BaseExceptionT_co]):
"""A combination of multiple unrelated exceptions."""
def __new__(
- cls, __message: str, __exceptions: Sequence[_BaseExceptionT_co]
- ) -> Self:
+ cls: type[_BaseExceptionGroupSelf],
+ __message: str,
+ __exceptions: Sequence[_BaseExceptionT_co],
+ ) -> _BaseExceptionGroupSelf:
if not isinstance(__message, str):
raise TypeError(f"argument 1 must be str, not {type(__message)}")
if not isinstance(__exceptions, Sequence):
@@ -105,24 +118,28 @@ def exceptions(
) -> tuple[_BaseExceptionT_co | BaseExceptionGroup[_BaseExceptionT_co], ...]:
return tuple(self._exceptions)
+ @overload
+ def subgroup(
+ self, __condition: type[_ExceptionT] | tuple[type[_ExceptionT], ...]
+ ) -> ExceptionGroup[_ExceptionT] | None: ...
+
@overload
def subgroup(
self, __condition: type[_BaseExceptionT] | tuple[type[_BaseExceptionT], ...]
- ) -> BaseExceptionGroup[_BaseExceptionT] | None:
- ...
+ ) -> BaseExceptionGroup[_BaseExceptionT] | None: ...
@overload
def subgroup(
- self: Self, __condition: Callable[[_BaseExceptionT_co], bool]
- ) -> Self | None:
- ...
+ self,
+ __condition: Callable[[_BaseExceptionT_co | _BaseExceptionGroupSelf], bool],
+ ) -> BaseExceptionGroup[_BaseExceptionT_co] | None: ...
def subgroup(
- self: Self,
+ self,
__condition: type[_BaseExceptionT]
| tuple[type[_BaseExceptionT], ...]
- | Callable[[_BaseExceptionT_co], bool],
- ) -> BaseExceptionGroup[_BaseExceptionT] | Self | None:
+ | Callable[[_BaseExceptionT_co | _BaseExceptionGroupSelf], bool],
+ ) -> BaseExceptionGroup[_BaseExceptionT] | None:
condition = get_condition_filter(__condition)
modified = False
if condition(self):
@@ -145,35 +162,54 @@ def subgroup(
if not modified:
return self
elif exceptions:
- group = self.derive(exceptions)
- group.__cause__ = self.__cause__
- group.__context__ = self.__context__
- group.__traceback__ = self.__traceback__
+ group = _derive_and_copy_attributes(self, exceptions)
return group
else:
return None
@overload
def split(
- self: Self,
- __condition: type[_BaseExceptionT] | tuple[type[_BaseExceptionT], ...],
- ) -> tuple[BaseExceptionGroup[_BaseExceptionT] | None, Self | None]:
- ...
+ self, __condition: type[_ExceptionT] | tuple[type[_ExceptionT], ...]
+ ) -> tuple[
+ ExceptionGroup[_ExceptionT] | None,
+ BaseExceptionGroup[_BaseExceptionT_co] | None,
+ ]: ...
+
+ @overload
+ def split(
+ self, __condition: type[_BaseExceptionT] | tuple[type[_BaseExceptionT], ...]
+ ) -> tuple[
+ BaseExceptionGroup[_BaseExceptionT] | None,
+ BaseExceptionGroup[_BaseExceptionT_co] | None,
+ ]: ...
@overload
def split(
- self: Self, __condition: Callable[[_BaseExceptionT_co], bool]
- ) -> tuple[Self | None, Self | None]:
- ...
+ self,
+ __condition: Callable[[_BaseExceptionT_co | _BaseExceptionGroupSelf], bool],
+ ) -> tuple[
+ BaseExceptionGroup[_BaseExceptionT_co] | None,
+ BaseExceptionGroup[_BaseExceptionT_co] | None,
+ ]: ...
def split(
- self: Self,
+ self,
__condition: type[_BaseExceptionT]
| tuple[type[_BaseExceptionT], ...]
| Callable[[_BaseExceptionT_co], bool],
) -> (
- tuple[BaseExceptionGroup[_BaseExceptionT] | None, Self | None]
- | tuple[Self | None, Self | None]
+ tuple[
+ ExceptionGroup[_ExceptionT] | None,
+ BaseExceptionGroup[_BaseExceptionT_co] | None,
+ ]
+ | tuple[
+ BaseExceptionGroup[_BaseExceptionT] | None,
+ BaseExceptionGroup[_BaseExceptionT_co] | None,
+ ]
+ | tuple[
+ BaseExceptionGroup[_BaseExceptionT_co] | None,
+ BaseExceptionGroup[_BaseExceptionT_co] | None,
+ ]
):
condition = get_condition_filter(__condition)
if condition(self):
@@ -194,29 +230,30 @@ def split(
else:
nonmatching_exceptions.append(exc)
- matching_group: Self | None = None
+ matching_group: _BaseExceptionGroupSelf | None = None
if matching_exceptions:
- matching_group = self.derive(matching_exceptions)
- matching_group.__cause__ = self.__cause__
- matching_group.__context__ = self.__context__
- matching_group.__traceback__ = self.__traceback__
+ matching_group = _derive_and_copy_attributes(self, matching_exceptions)
- nonmatching_group: Self | None = None
+ nonmatching_group: _BaseExceptionGroupSelf | None = None
if nonmatching_exceptions:
- nonmatching_group = self.derive(nonmatching_exceptions)
- nonmatching_group.__cause__ = self.__cause__
- nonmatching_group.__context__ = self.__context__
- nonmatching_group.__traceback__ = self.__traceback__
+ nonmatching_group = _derive_and_copy_attributes(
+ self, nonmatching_exceptions
+ )
return matching_group, nonmatching_group
- def derive(self: Self, __excs: Sequence[_BaseExceptionT_co]) -> Self:
- eg = BaseExceptionGroup(self.message, __excs)
- if hasattr(self, "__notes__"):
- # Create a new list so that add_note() only affects one exceptiongroup
- eg.__notes__ = list(self.__notes__)
+ @overload
+ def derive(self, __excs: Sequence[_ExceptionT]) -> ExceptionGroup[_ExceptionT]: ...
+
+ @overload
+ def derive(
+ self, __excs: Sequence[_BaseExceptionT]
+ ) -> BaseExceptionGroup[_BaseExceptionT]: ...
- return eg
+ def derive(
+ self, __excs: Sequence[_BaseExceptionT]
+ ) -> BaseExceptionGroup[_BaseExceptionT]:
+ return BaseExceptionGroup(self.message, __excs)
def __str__(self) -> str:
suffix = "" if len(self._exceptions) == 1 else "s"
@@ -227,7 +264,11 @@ def __repr__(self) -> str:
class ExceptionGroup(BaseExceptionGroup[_ExceptionT_co], Exception):
- def __new__(cls, __message: str, __exceptions: Sequence[_ExceptionT_co]) -> Self:
+ def __new__(
+ cls: type[_ExceptionGroupSelf],
+ __message: str,
+ __exceptions: Sequence[_ExceptionT_co],
+ ) -> _ExceptionGroupSelf:
return super().__new__(cls, __message, __exceptions)
if TYPE_CHECKING:
@@ -235,48 +276,46 @@ def __new__(cls, __message: str, __exceptions: Sequence[_ExceptionT_co]) -> Self
@property
def exceptions(
self,
- ) -> tuple[_ExceptionT_co | ExceptionGroup[_ExceptionT_co], ...]:
- ...
+ ) -> tuple[_ExceptionT_co | ExceptionGroup[_ExceptionT_co], ...]: ...
@overload # type: ignore[override]
def subgroup(
self, __condition: type[_ExceptionT] | tuple[type[_ExceptionT], ...]
- ) -> ExceptionGroup[_ExceptionT] | None:
- ...
+ ) -> ExceptionGroup[_ExceptionT] | None: ...
@overload
def subgroup(
- self: Self, __condition: Callable[[_ExceptionT_co], bool]
- ) -> Self | None:
- ...
+ self, __condition: Callable[[_ExceptionT_co | _ExceptionGroupSelf], bool]
+ ) -> ExceptionGroup[_ExceptionT_co] | None: ...
def subgroup(
- self: Self,
+ self,
__condition: type[_ExceptionT]
| tuple[type[_ExceptionT], ...]
| Callable[[_ExceptionT_co], bool],
- ) -> ExceptionGroup[_ExceptionT] | Self | None:
+ ) -> ExceptionGroup[_ExceptionT] | None:
return super().subgroup(__condition)
- @overload # type: ignore[override]
+ @overload
def split(
- self: Self, __condition: type[_ExceptionT] | tuple[type[_ExceptionT], ...]
- ) -> tuple[ExceptionGroup[_ExceptionT] | None, Self | None]:
- ...
+ self, __condition: type[_ExceptionT] | tuple[type[_ExceptionT], ...]
+ ) -> tuple[
+ ExceptionGroup[_ExceptionT] | None, ExceptionGroup[_ExceptionT_co] | None
+ ]: ...
@overload
def split(
- self: Self, __condition: Callable[[_ExceptionT_co], bool]
- ) -> tuple[Self | None, Self | None]:
- ...
+ self, __condition: Callable[[_ExceptionT_co | _ExceptionGroupSelf], bool]
+ ) -> tuple[
+ ExceptionGroup[_ExceptionT_co] | None, ExceptionGroup[_ExceptionT_co] | None
+ ]: ...
def split(
- self: Self,
+ self: _ExceptionGroupSelf,
__condition: type[_ExceptionT]
| tuple[type[_ExceptionT], ...]
| Callable[[_ExceptionT_co], bool],
- ) -> (
- tuple[ExceptionGroup[_ExceptionT] | None, Self | None]
- | tuple[Self | None, Self | None]
- ):
+ ) -> tuple[
+ ExceptionGroup[_ExceptionT_co] | None, ExceptionGroup[_ExceptionT_co] | None
+ ]:
return super().split(__condition)
diff --git a/src/exceptiongroup/_formatting.py b/src/exceptiongroup/_formatting.py
index c0402ba..4c52d77 100644
--- a/src/exceptiongroup/_formatting.py
+++ b/src/exceptiongroup/_formatting.py
@@ -359,6 +359,44 @@ def format_exception_only(self):
)
sys.excepthook = exceptiongroup_excepthook
+# Ubuntu's system Python has a sitecustomize.py file that imports
+# apport_python_hook and replaces sys.excepthook.
+#
+# The custom hook captures the error for crash reporting, and then calls
+# sys.__excepthook__ to actually print the error.
+#
+# We don't mind it capturing the error for crash reporting, but we want to
+# take over printing the error. So we monkeypatch the apport_python_hook
+# module so that instead of calling sys.__excepthook__, it calls our custom
+# hook.
+#
+# More details: https://github.com/python-trio/trio/issues/1065
+if getattr(sys.excepthook, "__name__", None) in (
+ "apport_excepthook",
+ # on ubuntu 22.10 the hook was renamed to partial_apport_excepthook
+ "partial_apport_excepthook",
+):
+ # patch traceback like above
+ traceback.TracebackException.__init__ = ( # type: ignore[assignment]
+ PatchedTracebackException.__init__
+ )
+ traceback.TracebackException.format = ( # type: ignore[assignment]
+ PatchedTracebackException.format
+ )
+ traceback.TracebackException.format_exception_only = ( # type: ignore[assignment]
+ PatchedTracebackException.format_exception_only
+ )
+
+ from types import ModuleType
+
+ import apport_python_hook
+
+ # monkeypatch the sys module that apport has imported
+ fake_sys = ModuleType("exceptiongroup_fake_sys")
+ fake_sys.__dict__.update(sys.__dict__)
+ fake_sys.__excepthook__ = exceptiongroup_excepthook
+ apport_python_hook.sys = fake_sys
+
@singledispatch
def format_exception_only(__exc: BaseException) -> List[str]:
diff --git a/src/exceptiongroup/_suppress.py b/src/exceptiongroup/_suppress.py
new file mode 100644
index 0000000..11467ee
--- /dev/null
+++ b/src/exceptiongroup/_suppress.py
@@ -0,0 +1,55 @@
+from __future__ import annotations
+
+import sys
+from contextlib import AbstractContextManager
+from types import TracebackType
+from typing import TYPE_CHECKING, Optional, Type, cast
+
+if sys.version_info < (3, 11):
+ from ._exceptions import BaseExceptionGroup
+
+if TYPE_CHECKING:
+ # requires python 3.9
+ BaseClass = AbstractContextManager[None]
+else:
+ BaseClass = AbstractContextManager
+
+
+class suppress(BaseClass):
+ """Backport of :class:`contextlib.suppress` from Python 3.12.1."""
+
+ def __init__(self, *exceptions: type[BaseException]):
+ self._exceptions = exceptions
+
+ def __enter__(self) -> None:
+ pass
+
+ def __exit__(
+ self,
+ exctype: Optional[Type[BaseException]],
+ excinst: Optional[BaseException],
+ exctb: Optional[TracebackType],
+ ) -> bool:
+ # Unlike isinstance and issubclass, CPython exception handling
+ # currently only looks at the concrete type hierarchy (ignoring
+ # the instance and subclass checking hooks). While Guido considers
+ # that a bug rather than a feature, it's a fairly hard one to fix
+ # due to various internal implementation details. suppress provides
+ # the simpler issubclass based semantics, rather than trying to
+ # exactly reproduce the limitations of the CPython interpreter.
+ #
+ # See http://bugs.python.org/issue12029 for more details
+ if exctype is None:
+ return False
+
+ if issubclass(exctype, self._exceptions):
+ return True
+
+ if issubclass(exctype, BaseExceptionGroup):
+ match, rest = cast(BaseExceptionGroup, excinst).split(self._exceptions)
+ if rest is None:
+ return True
+
+ raise rest
+
+ return False
diff --git a/tests/apport_excepthook.py b/tests/apport_excepthook.py
new file mode 100644
index 0000000..1e4a8f3
--- /dev/null
+++ b/tests/apport_excepthook.py
@@ -0,0 +1,13 @@
+# The apport_python_hook package is only installed as part of Ubuntu's system
+# python, and not available in venvs. So before we can import it we have to
+# make sure it's on sys.path.
+import sys
+
+sys.path.append("/usr/lib/python3/dist-packages")
+import apport_python_hook # unsorted import
+
+apport_python_hook.install()
+
+from exceptiongroup import ExceptionGroup # noqa: E402 # unsorted import
+
+raise ExceptionGroup("msg1", [KeyError("msg2"), ValueError("msg3")])
diff --git a/tests/check_types.py b/tests/check_types.py
new file mode 100644
index 0000000..f7a102d
--- /dev/null
+++ b/tests/check_types.py
@@ -0,0 +1,36 @@
+from typing_extensions import assert_type
+
+from exceptiongroup import BaseExceptionGroup, ExceptionGroup, catch, suppress
+
+# issue 117
+a = BaseExceptionGroup("", (KeyboardInterrupt(),))
+assert_type(a, BaseExceptionGroup[KeyboardInterrupt])
+b = BaseExceptionGroup("", (ValueError(),))
+assert_type(b, BaseExceptionGroup[ValueError])
+c = ExceptionGroup("", (ValueError(),))
+assert_type(c, ExceptionGroup[ValueError])
+
+# expected type error when passing a BaseException to ExceptionGroup
+ExceptionGroup("", (KeyboardInterrupt(),)) # type: ignore[type-var]
+
+
+# code snippets from the README
+
+
+def value_key_err_handler(excgroup: BaseExceptionGroup) -> None:
+ for exc in excgroup.exceptions:
+ print("Caught exception:", type(exc))
+
+
+def runtime_err_handler(exc: BaseExceptionGroup) -> None:
+ print("Caught runtime error")
+
+
+with catch(
+ {(ValueError, KeyError): value_key_err_handler, RuntimeError: runtime_err_handler}
+):
+ ...
+
+
+with suppress(RuntimeError):
+ raise ExceptionGroup("", [RuntimeError("boo")])
diff --git a/tests/test_apport_monkeypatching.py b/tests/test_apport_monkeypatching.py
new file mode 100644
index 0000000..998554f
--- /dev/null
+++ b/tests/test_apport_monkeypatching.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+import exceptiongroup
+
+
+def run_script(name: str) -> subprocess.CompletedProcess[bytes]:
+ exceptiongroup_path = Path(exceptiongroup.__file__).parent.parent
+ script_path = Path(__file__).parent / name
+
+ env = dict(os.environ)
+ print("parent PYTHONPATH:", env.get("PYTHONPATH"))
+ if "PYTHONPATH" in env: # pragma: no cover
+ pp = env["PYTHONPATH"].split(os.pathsep)
+ else:
+ pp = []
+
+ pp.insert(0, str(exceptiongroup_path))
+ pp.insert(0, str(script_path.parent))
+ env["PYTHONPATH"] = os.pathsep.join(pp)
+ print("subprocess PYTHONPATH:", env.get("PYTHONPATH"))
+
+ cmd = [sys.executable, "-u", str(script_path)]
+ print("running:", cmd)
+ completed = subprocess.run(
+ cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
+ )
+ print("process output:")
+ print(completed.stdout.decode("utf-8"))
+ return completed
+
+
+@pytest.mark.skipif(
+ sys.version_info > (3, 11),
+ reason="No patching is done on Python >= 3.11",
+)
+@pytest.mark.skipif(
+ not Path("/usr/lib/python3/dist-packages/apport_python_hook.py").exists(),
+ reason="need Ubuntu with python3-apport installed",
+)
+def test_apport_excepthook_monkeypatch_interaction():
+ completed = run_script("apport_excepthook.py")
+ stdout = completed.stdout.decode("utf-8")
+ file = Path(__file__).parent / "apport_excepthook.py"
+ assert stdout == (
+ f"""\
+ + Exception Group Traceback (most recent call last):
+ | File "{file}", line 13, in
+ | raise ExceptionGroup("msg1", [KeyError("msg2"), ValueError("msg3")])
+ | exceptiongroup.ExceptionGroup: msg1 (2 sub-exceptions)
+ +-+---------------- 1 ----------------
+ | KeyError: 'msg2'
+ +---------------- 2 ----------------
+ | ValueError: msg3
+ +------------------------------------
+"""
+ )
diff --git a/tests/test_catch.py b/tests/test_catch.py
index 0af2fa0..1da749e 100644
--- a/tests/test_catch.py
+++ b/tests/test_catch.py
@@ -148,9 +148,41 @@ def test_catch_handler_raises():
def handler(exc):
raise RuntimeError("new")
- with pytest.raises(RuntimeError, match="new"):
+ with pytest.raises(RuntimeError, match="new") as exc:
with catch({(ValueError, ValueError): handler}):
- raise ExceptionGroup("booboo", [ValueError("bar")])
+ excgrp = ExceptionGroup("booboo", [ValueError("bar")])
+ raise excgrp
+
+ context = exc.value.__context__
+ assert isinstance(context, ExceptionGroup)
+ assert str(context) == "booboo (1 sub-exception)"
+ assert len(context.exceptions) == 1
+ assert isinstance(context.exceptions[0], ValueError)
+ assert exc.value.__cause__ is None
+
+
+def test_bare_raise_in_handler():
+ """Test that a bare "raise" "middle" ecxeption group gets discarded."""
+
+ def handler(exc):
+ raise
+
+ with pytest.raises(ExceptionGroup) as excgrp:
+ with catch({(ValueError,): handler, (RuntimeError,): lambda eg: None}):
+ try:
+ first_exc = RuntimeError("first")
+ raise first_exc
+ except RuntimeError as exc:
+ middle_exc = ExceptionGroup(
+ "bad", [ValueError(), ValueError(), TypeError()]
+ )
+ raise middle_exc from exc
+
+ assert len(excgrp.value.exceptions) == 2
+ assert all(isinstance(exc, ValueError) for exc in excgrp.value.exceptions)
+ assert excgrp.value is not middle_exc
+ assert excgrp.value.__cause__ is first_exc
+ assert excgrp.value.__context__ is first_exc
def test_catch_subclass():
@@ -162,3 +194,29 @@ def test_catch_subclass():
assert isinstance(lookup_errors[0], ExceptionGroup)
exceptions = lookup_errors[0].exceptions
assert isinstance(exceptions[0], KeyError)
+
+
+def test_async_handler(request):
+ async def handler(eg):
+ pass
+
+ def delegate(eg):
+ coro = handler(eg)
+ request.addfinalizer(coro.close)
+ return coro
+
+ with pytest.raises(TypeError, match="Exception handler must be a sync function."):
+ with catch({TypeError: delegate}):
+ raise ExceptionGroup("message", [TypeError("uh-oh")])
+
+
+def test_bare_reraise_from_naked_exception():
+ def handler(eg):
+ raise
+
+ with pytest.raises(ExceptionGroup) as excgrp, catch({Exception: handler}):
+ raise KeyError("foo")
+
+ assert len(excgrp.value.exceptions) == 1
+ assert isinstance(excgrp.value.exceptions[0], KeyError)
+ assert str(excgrp.value.exceptions[0]) == "'foo'"
diff --git a/tests/test_catch_py311.py b/tests/test_catch_py311.py
index 4351be8..2f12ac3 100644
--- a/tests/test_catch_py311.py
+++ b/tests/test_catch_py311.py
@@ -1,3 +1,5 @@
+import sys
+
import pytest
from exceptiongroup import ExceptionGroup
@@ -121,13 +123,25 @@ def test_catch_full_match():
pass
+@pytest.mark.skipif(
+ sys.version_info < (3, 11, 4),
+ reason="Behavior was changed in 3.11.4",
+)
def test_catch_handler_raises():
- with pytest.raises(RuntimeError, match="new"):
+ with pytest.raises(RuntimeError, match="new") as exc:
try:
- raise ExceptionGroup("booboo", [ValueError("bar")])
+ excgrp = ExceptionGroup("booboo", [ValueError("bar")])
+ raise excgrp
except* ValueError:
raise RuntimeError("new")
+ context = exc.value.__context__
+ assert isinstance(context, ExceptionGroup)
+ assert str(context) == "booboo (1 sub-exception)"
+ assert len(context.exceptions) == 1
+ assert isinstance(context.exceptions[0], ValueError)
+ assert exc.value.__cause__ is None
+
def test_catch_subclass():
lookup_errors = []
@@ -140,3 +154,37 @@ def test_catch_subclass():
assert isinstance(lookup_errors[0], ExceptionGroup)
exceptions = lookup_errors[0].exceptions
assert isinstance(exceptions[0], KeyError)
+
+
+def test_bare_raise_in_handler():
+ """Test that the "middle" ecxeption group gets discarded."""
+ with pytest.raises(ExceptionGroup) as excgrp:
+ try:
+ try:
+ first_exc = RuntimeError("first")
+ raise first_exc
+ except RuntimeError as exc:
+ middle_exc = ExceptionGroup(
+ "bad", [ValueError(), ValueError(), TypeError()]
+ )
+ raise middle_exc from exc
+ except* ValueError:
+ raise
+ except* TypeError:
+ pass
+
+ assert excgrp.value is not middle_exc
+ assert excgrp.value.__cause__ is first_exc
+ assert excgrp.value.__context__ is first_exc
+
+
+def test_bare_reraise_from_naked_exception():
+ with pytest.raises(ExceptionGroup) as excgrp:
+ try:
+ raise KeyError("foo")
+ except* KeyError:
+ raise
+
+ assert len(excgrp.value.exceptions) == 1
+ assert isinstance(excgrp.value.exceptions[0], KeyError)
+ assert str(excgrp.value.exceptions[0]) == "'foo'"
diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py
index bd20f15..f77511a 100644
--- a/tests/test_exceptions.py
+++ b/tests/test_exceptions.py
@@ -205,6 +205,13 @@ def test_notes_is_list_of_strings_if_it_exists(self):
eg.add_note(note)
self.assertEqual(eg.__notes__, [note])
+ def test_derive_doesn_copy_notes(self):
+ eg = create_simple_eg()
+ eg.add_note("hello")
+ assert eg.__notes__ == ["hello"]
+ eg2 = eg.derive([ValueError()])
+ assert not hasattr(eg2, "__notes__")
+
class ExceptionGroupTestBase(unittest.TestCase):
def assertMatchesTemplate(self, exc, exc_type, template):
@@ -366,7 +373,7 @@ def test_basics_split_by_predicate__match(self):
class DeepRecursionInSplitAndSubgroup(unittest.TestCase):
def make_deep_eg(self):
e = TypeError(1)
- for _ in range(2000):
+ for _ in range(10000):
e = ExceptionGroup("eg", [e])
return e
@@ -786,6 +793,7 @@ def derive(self, excs):
except ValueError as ve:
raise EG("eg", [ve, nested], 42)
except EG as e:
+ e.add_note("hello")
eg = e
self.assertMatchesTemplate(eg, EG, [ValueError(1), [TypeError(2)]])
@@ -796,29 +804,35 @@ def derive(self, excs):
self.assertMatchesTemplate(rest, EG, [ValueError(1), [TypeError(2)]])
self.assertEqual(rest.code, 42)
self.assertEqual(rest.exceptions[1].code, 101)
+ self.assertEqual(rest.__notes__, ["hello"])
# Match Everything
match, rest = self.split_exception_group(eg, (ValueError, TypeError))
self.assertMatchesTemplate(match, EG, [ValueError(1), [TypeError(2)]])
self.assertEqual(match.code, 42)
self.assertEqual(match.exceptions[1].code, 101)
+ self.assertEqual(match.__notes__, ["hello"])
self.assertIsNone(rest)
# Match ValueErrors
match, rest = self.split_exception_group(eg, ValueError)
self.assertMatchesTemplate(match, EG, [ValueError(1)])
self.assertEqual(match.code, 42)
+ self.assertEqual(match.__notes__, ["hello"])
self.assertMatchesTemplate(rest, EG, [[TypeError(2)]])
self.assertEqual(rest.code, 42)
self.assertEqual(rest.exceptions[0].code, 101)
+ self.assertEqual(rest.__notes__, ["hello"])
# Match TypeErrors
match, rest = self.split_exception_group(eg, TypeError)
self.assertMatchesTemplate(match, EG, [[TypeError(2)]])
self.assertEqual(match.code, 42)
self.assertEqual(match.exceptions[0].code, 101)
+ self.assertEqual(match.__notes__, ["hello"])
self.assertMatchesTemplate(rest, EG, [ValueError(1)])
self.assertEqual(rest.code, 42)
+ self.assertEqual(rest.__notes__, ["hello"])
def test_repr():
diff --git a/tests/test_formatting.py b/tests/test_formatting.py
index f6b9bc2..557fcc8 100644
--- a/tests/test_formatting.py
+++ b/tests/test_formatting.py
@@ -64,12 +64,15 @@ def test_exceptionhook(capsys: CaptureFixture) -> None:
local_lineno = test_exceptionhook.__code__.co_firstlineno
lineno = raise_excgroup.__code__.co_firstlineno
module_prefix = "" if sys.version_info >= (3, 11) else "exceptiongroup."
+ underline_suffix = (
+ "" if sys.version_info < (3, 13) else "\n | ~~~~~~~~~~~~~~^^"
+ )
output = capsys.readouterr().err
assert output == (
f"""\
+ Exception Group Traceback (most recent call last):
| File "{__file__}", line {local_lineno + 2}, in test_exceptionhook
- | raise_excgroup()
+ | raise_excgroup(){underline_suffix}
| File "{__file__}", line {lineno + 15}, in raise_excgroup
| raise exc
| {module_prefix}ExceptionGroup: test message (2 sub-exceptions)
@@ -163,13 +166,16 @@ def test_exceptionhook_format_exception_only(capsys: CaptureFixture) -> None:
local_lineno = test_exceptionhook_format_exception_only.__code__.co_firstlineno
lineno = raise_excgroup.__code__.co_firstlineno
module_prefix = "" if sys.version_info >= (3, 11) else "exceptiongroup."
+ underline_suffix = (
+ "" if sys.version_info < (3, 13) else "\n | ~~~~~~~~~~~~~~^^"
+ )
output = capsys.readouterr().err
assert output == (
f"""\
+ Exception Group Traceback (most recent call last):
| File "{__file__}", line {local_lineno + 2}, in \
test_exceptionhook_format_exception_only
- | raise_excgroup()
+ | raise_excgroup(){underline_suffix}
| File "{__file__}", line {lineno + 15}, in raise_excgroup
| raise exc
| {module_prefix}ExceptionGroup: test message (2 sub-exceptions)
@@ -204,13 +210,14 @@ def test_formatting_syntax_error(capsys: CaptureFixture) -> None:
underline = "\n ^"
lineno = test_formatting_syntax_error.__code__.co_firstlineno
+ underline_suffix = "" if sys.version_info < (3, 13) else "\n ~~~~^^^^^^^^^^^^"
output = capsys.readouterr().err
assert output == (
f"""\
Traceback (most recent call last):
File "{__file__}", line {lineno + 2}, \
in test_formatting_syntax_error
- exec("//serser")
+ exec("//serser"){underline_suffix}
File "", line 1
//serser{underline}
SyntaxError: invalid syntax
@@ -254,11 +261,14 @@ def test_format_exception(
lineno = raise_excgroup.__code__.co_firstlineno
assert isinstance(lines, list)
module_prefix = "" if sys.version_info >= (3, 11) else "exceptiongroup."
+ underline_suffix = (
+ "" if sys.version_info < (3, 13) else "\n | ~~~~~~~~~~~~~~^^"
+ )
assert "".join(lines) == (
f"""\
+ Exception Group Traceback (most recent call last):
| File "{__file__}", line {local_lineno + 25}, in test_format_exception
- | raise_excgroup()
+ | raise_excgroup(){underline_suffix}
| File "{__file__}", line {lineno + 15}, in raise_excgroup
| raise exc
| {module_prefix}ExceptionGroup: test message (2 sub-exceptions)
@@ -303,6 +313,10 @@ def raise_exc(max_level: int, level: int = 1) -> NoReturn:
except Exception as exc:
lines = format_exception(type(exc), exc, exc.__traceback__)
+ underline_suffix1 = (
+ "" if sys.version_info < (3, 13) else "\n ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^"
+ )
+ underline_suffix2 = "" if sys.version_info < (3, 13) else "\n ~~~~~~~~~^^^"
local_lineno = test_format_nested.__code__.co_firstlineno + 20
raise_exc_lineno1 = raise_exc.__code__.co_firstlineno + 2
raise_exc_lineno2 = raise_exc.__code__.co_firstlineno + 5
@@ -312,7 +326,7 @@ def raise_exc(max_level: int, level: int = 1) -> NoReturn:
f"""\
Traceback (most recent call last):
File "{__file__}", line {raise_exc_lineno2}, in raise_exc
- raise_exc(max_level, level + 1)
+ raise_exc(max_level, level + 1){underline_suffix1}
File "{__file__}", line {raise_exc_lineno1}, in raise_exc
raise Exception(f"LEVEL_{{level}}")
Exception: LEVEL_3
@@ -321,7 +335,7 @@ def raise_exc(max_level: int, level: int = 1) -> NoReturn:
Traceback (most recent call last):
File "{__file__}", line {raise_exc_lineno2}, in raise_exc
- raise_exc(max_level, level + 1)
+ raise_exc(max_level, level + 1){underline_suffix1}
File "{__file__}", line {raise_exc_lineno3}, in raise_exc
raise Exception(f"LEVEL_{{level}}")
Exception: LEVEL_2
@@ -330,7 +344,7 @@ def raise_exc(max_level: int, level: int = 1) -> NoReturn:
Traceback (most recent call last):
File "{__file__}", line {local_lineno}, in test_format_nested
- raise_exc(3)
+ raise_exc(3){underline_suffix2}
File "{__file__}", line {raise_exc_lineno3}, in raise_exc
raise Exception(f"LEVEL_{{level}}")
Exception: LEVEL_1
@@ -388,12 +402,15 @@ def test_print_exception(
local_lineno = test_print_exception.__code__.co_firstlineno
lineno = raise_excgroup.__code__.co_firstlineno
module_prefix = "" if sys.version_info >= (3, 11) else "exceptiongroup."
+ underline_suffix = (
+ "" if sys.version_info < (3, 13) else "\n | ~~~~~~~~~~~~~~^^"
+ )
output = capsys.readouterr().err
assert output == (
f"""\
+ Exception Group Traceback (most recent call last):
| File "{__file__}", line {local_lineno + 13}, in test_print_exception
- | raise_excgroup()
+ | raise_excgroup(){underline_suffix}
| File "{__file__}", line {lineno + 15}, in raise_excgroup
| raise exc
| {module_prefix}ExceptionGroup: test message (2 sub-exceptions)
@@ -433,12 +450,15 @@ def test_print_exc(
local_lineno = test_print_exc.__code__.co_firstlineno
lineno = raise_excgroup.__code__.co_firstlineno
module_prefix = "" if sys.version_info >= (3, 11) else "exceptiongroup."
+ underline_suffix = (
+ "" if sys.version_info < (3, 13) else "\n | ~~~~~~~~~~~~~~^^"
+ )
output = capsys.readouterr().err
assert output == (
f"""\
+ Exception Group Traceback (most recent call last):
| File "{__file__}", line {local_lineno + 13}, in test_print_exc
- | raise_excgroup()
+ | raise_excgroup(){underline_suffix}
| File "{__file__}", line {lineno + 15}, in raise_excgroup
| raise exc
| {module_prefix}ExceptionGroup: test message (2 sub-exceptions)
diff --git a/tests/test_suppress.py b/tests/test_suppress.py
new file mode 100644
index 0000000..289bb33
--- /dev/null
+++ b/tests/test_suppress.py
@@ -0,0 +1,16 @@
+import sys
+
+import pytest
+
+from exceptiongroup import suppress
+
+if sys.version_info < (3, 11):
+ from exceptiongroup import BaseExceptionGroup, ExceptionGroup
+
+
+def test_suppress_exception():
+ with pytest.raises(ExceptionGroup) as exc, suppress(SystemExit):
+ raise BaseExceptionGroup("", [SystemExit(1), RuntimeError("boo")])
+
+ assert len(exc.value.exceptions) == 1
+ assert isinstance(exc.value.exceptions[0], RuntimeError)