Skip to content

Commit

Permalink
Restored the IPython custom error handler
Browse files Browse the repository at this point in the history
Now it works with BaseExceptionGroup too.
  • Loading branch information
agronholm committed Sep 18, 2022
1 parent dc35213 commit 17103ae
Show file tree
Hide file tree
Showing 10 changed files with 230 additions and 5 deletions.
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
# cffi 1.14 fixes memory leak inside ffi.getwinerror()
# cffi is required on Windows, except on PyPy where it is built-in
"cffi>=1.14; os_name == 'nt' and implementation_name != 'pypy'",
"exceptiongroup; python_version < '3.11'",
"exceptiongroup >= 1.0.0rc9; python_version < '3.11'",
],
# This means, just install *everything* you see under trio/, even if it
# doesn't look like a source file, so long as it appears in MANIFEST.in:
Expand Down
2 changes: 1 addition & 1 deletion test-requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,5 @@ async_generator >= 1.9
idna
outcome
sniffio
exceptiongroup; python_version < "3.11"
exceptiongroup >= 1.0.0rc9; python_version < "3.11"

22 changes: 20 additions & 2 deletions test-requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#
# This file is autogenerated by pip-compile with python 3.10
# This file is autogenerated by pip-compile with python 3.7
# To update, run:
#
# pip-compile test-requirements.in
Expand Down Expand Up @@ -34,14 +34,20 @@ decorator==5.1.1
# via ipython
dill==0.3.5.1
# via pylint
exceptiongroup==1.0.0rc8 ; python_version < "3.11"
exceptiongroup==1.0.0rc9 ; python_version < "3.11"
# via -r test-requirements.in
flake8==4.0.1
# via -r test-requirements.in
idna==3.4
# via
# -r test-requirements.in
# trustme
importlib-metadata==4.2.0
# via
# click
# flake8
# pluggy
# pytest
iniconfig==1.1.1
# via pytest
ipython==7.31.1
Expand Down Expand Up @@ -130,18 +136,30 @@ traitlets==5.4.0
# matplotlib-inline
trustme==0.9.0
# via -r test-requirements.in
typed-ast==1.5.4 ; implementation_name == "cpython" and python_version < "3.8"
# via
# -r test-requirements.in
# astroid
# black
# mypy
types-cryptography==3.3.22
# via types-pyopenssl
types-pyopenssl==22.0.9 ; implementation_name == "cpython"
# via -r test-requirements.in
typing-extensions==4.3.0 ; implementation_name == "cpython"
# via
# -r test-requirements.in
# astroid
# black
# importlib-metadata
# mypy
# pylint
wcwidth==0.2.5
# via prompt-toolkit
wrapt==1.14.1
# via astroid
zipp==3.8.1
# via importlib-metadata

# The following packages are considered to be unsafe in a requirements file:
# setuptools
28 changes: 27 additions & 1 deletion trio/_core/_multierror.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
from trio._deprecate import warn_deprecated

if sys.version_info < (3, 11):
from exceptiongroup import BaseExceptionGroup, ExceptionGroup
from exceptiongroup import BaseExceptionGroup, ExceptionGroup, print_exception
else:
from traceback import print_exception

################################################################
# MultiError
Expand Down Expand Up @@ -387,3 +389,27 @@ def concat_tb(head, tail):
for head_tb in reversed(head_tbs):
current_head = copy_tb(head_tb, tb_next=current_head)
return current_head


# Remove when IPython gains support for exception groups
if "IPython" in sys.modules:
import IPython

ip = IPython.get_ipython()
if ip is not None:
if ip.custom_exceptions != ():
warnings.warn(
"IPython detected, but you already have a custom exception "
"handler installed. I'll skip installing Trio's custom "
"handler, but this means exception groups will not show full "
"tracebacks.",
category=RuntimeWarning,
)
else:

def trio_show_traceback(self, etype, value, tb, tb_offset=None):
# XX it would be better to integrate with IPython's fancy
# exception formatting stuff (and not ignore tb_offset)
print_exception(value)

ip.set_custom_exc((BaseExceptionGroup,), trio_show_traceback)
108 changes: 108 additions & 0 deletions trio/_core/tests/test_multierror.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import gc
import logging
import os
import subprocess
from pathlib import Path

import pytest

from traceback import (
Expand All @@ -11,6 +15,7 @@
import sys
import re

from .tutil import slow
from .._multierror import MultiError, concat_tb, NonBaseMultiError
from ... import TrioDeprecationWarning
from ..._core import open_nursery
Expand Down Expand Up @@ -427,3 +432,106 @@ def test_non_base_multierror():
exc = MultiError([ZeroDivisionError(), ValueError()])
assert type(exc) is NonBaseMultiError
assert isinstance(exc, ExceptionGroup)


def run_script(name, use_ipython=False):
import trio

trio_path = Path(trio.__file__).parent.parent
script_path = Path(__file__).parent / "test_multierror_scripts" / 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(trio_path))
pp.insert(0, str(script_path.parent))
env["PYTHONPATH"] = os.pathsep.join(pp)
print("subprocess PYTHONPATH:", env.get("PYTHONPATH"))

if use_ipython:
lines = [script_path.read_text(), "exit()"]

cmd = [
sys.executable,
"-u",
"-m",
"IPython",
# no startup files
"--quick",
"--TerminalIPythonApp.code_to_run=" + "\n".join(lines),
]
else:
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


def check_simple_excepthook(completed):
assert_match_in_seq(
[
"in <module>",
"MultiError",
"--- 1 ---",
"in exc1_fn",
"ValueError",
"--- 2 ---",
"in exc2_fn",
"KeyError",
],
completed.stdout.decode("utf-8"),
)


try:
import IPython
except ImportError: # pragma: no cover
have_ipython = False
else:
have_ipython = True

need_ipython = pytest.mark.skipif(not have_ipython, reason="need IPython")


@slow
@need_ipython
def test_ipython_exc_handler():
completed = run_script("simple_excepthook.py", use_ipython=True)
check_simple_excepthook(completed)


@slow
@need_ipython
def test_ipython_imported_but_unused():
completed = run_script("simple_excepthook_IPython.py")
check_simple_excepthook(completed)


@slow
@need_ipython
def test_ipython_custom_exc_handler():
# Check we get a nice warning (but only one!) if the user is using IPython
# and already has some other set_custom_exc handler installed.
completed = run_script("ipython_custom_exc.py", use_ipython=True)
assert_match_in_seq(
[
# The warning
"RuntimeWarning",
"IPython detected",
"skip installing Trio",
# The MultiError
"MultiError",
"ValueError",
"KeyError",
],
completed.stdout.decode("utf-8"),
)
# Make sure our other warning doesn't show up
assert "custom sys.excepthook" not in completed.stdout.decode("utf-8")
2 changes: 2 additions & 0 deletions trio/_core/tests/test_multierror_scripts/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# This isn't really a package, everything in here is a standalone script. This
# __init__.py is just to fool setup.py into actually installing the things.
7 changes: 7 additions & 0 deletions trio/_core/tests/test_multierror_scripts/_common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# https://coverage.readthedocs.io/en/latest/subprocess.html
try:
import coverage
except ImportError: # pragma: no cover
pass
else:
coverage.process_startup()
36 changes: 36 additions & 0 deletions trio/_core/tests/test_multierror_scripts/ipython_custom_exc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import _common

# Override the regular excepthook too -- it doesn't change anything either way
# because ipython doesn't use it, but we want to make sure Trio doesn't warn
# about it.
import sys


def custom_excepthook(*args):
print("custom running!")
return sys.__excepthook__(*args)


sys.excepthook = custom_excepthook

import IPython

ip = IPython.get_ipython()


# Set this to some random nonsense
class SomeError(Exception):
pass


def custom_exc_hook(etype, value, tb, tb_offset=None):
ip.showtraceback()


ip.set_custom_exc((SomeError,), custom_exc_hook)

import trio

# The custom excepthook should run, because Trio was polite and didn't
# override it
raise trio.MultiError([ValueError(), KeyError()])
21 changes: 21 additions & 0 deletions trio/_core/tests/test_multierror_scripts/simple_excepthook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import _common

import trio


def exc1_fn():
try:
raise ValueError
except Exception as exc:
return exc


def exc2_fn():
try:
raise KeyError
except Exception as exc:
return exc


# This should be printed nicely, because Trio overrode sys.excepthook
raise trio.MultiError([exc1_fn(), exc2_fn()])
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import _common

# To tickle the "is IPython loaded?" logic, make sure that Trio tolerates
# IPython loaded but not actually in use
import IPython

import simple_excepthook

0 comments on commit 17103ae

Please sign in to comment.