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)