Skip to content

Commit 17103ae

Browse files
committed
Restored the IPython custom error handler
Now it works with BaseExceptionGroup too.
1 parent dc35213 commit 17103ae

File tree

10 files changed

+230
-5
lines changed

10 files changed

+230
-5
lines changed

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@
8989
# cffi 1.14 fixes memory leak inside ffi.getwinerror()
9090
# cffi is required on Windows, except on PyPy where it is built-in
9191
"cffi>=1.14; os_name == 'nt' and implementation_name != 'pypy'",
92-
"exceptiongroup; python_version < '3.11'",
92+
"exceptiongroup >= 1.0.0rc9; python_version < '3.11'",
9393
],
9494
# This means, just install *everything* you see under trio/, even if it
9595
# doesn't look like a source file, so long as it appears in MANIFEST.in:

test-requirements.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,5 @@ async_generator >= 1.9
3030
idna
3131
outcome
3232
sniffio
33-
exceptiongroup; python_version < "3.11"
33+
exceptiongroup >= 1.0.0rc9; python_version < "3.11"
3434

test-requirements.txt

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#
2-
# This file is autogenerated by pip-compile with python 3.10
2+
# This file is autogenerated by pip-compile with python 3.7
33
# To update, run:
44
#
55
# pip-compile test-requirements.in
@@ -34,14 +34,20 @@ decorator==5.1.1
3434
# via ipython
3535
dill==0.3.5.1
3636
# via pylint
37-
exceptiongroup==1.0.0rc8 ; python_version < "3.11"
37+
exceptiongroup==1.0.0rc9 ; python_version < "3.11"
3838
# via -r test-requirements.in
3939
flake8==4.0.1
4040
# via -r test-requirements.in
4141
idna==3.4
4242
# via
4343
# -r test-requirements.in
4444
# trustme
45+
importlib-metadata==4.2.0
46+
# via
47+
# click
48+
# flake8
49+
# pluggy
50+
# pytest
4551
iniconfig==1.1.1
4652
# via pytest
4753
ipython==7.31.1
@@ -130,18 +136,30 @@ traitlets==5.4.0
130136
# matplotlib-inline
131137
trustme==0.9.0
132138
# via -r test-requirements.in
139+
typed-ast==1.5.4 ; implementation_name == "cpython" and python_version < "3.8"
140+
# via
141+
# -r test-requirements.in
142+
# astroid
143+
# black
144+
# mypy
133145
types-cryptography==3.3.22
134146
# via types-pyopenssl
135147
types-pyopenssl==22.0.9 ; implementation_name == "cpython"
136148
# via -r test-requirements.in
137149
typing-extensions==4.3.0 ; implementation_name == "cpython"
138150
# via
139151
# -r test-requirements.in
152+
# astroid
153+
# black
154+
# importlib-metadata
140155
# mypy
156+
# pylint
141157
wcwidth==0.2.5
142158
# via prompt-toolkit
143159
wrapt==1.14.1
144160
# via astroid
161+
zipp==3.8.1
162+
# via importlib-metadata
145163

146164
# The following packages are considered to be unsafe in a requirements file:
147165
# setuptools

trio/_core/_multierror.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
from trio._deprecate import warn_deprecated
88

99
if sys.version_info < (3, 11):
10-
from exceptiongroup import BaseExceptionGroup, ExceptionGroup
10+
from exceptiongroup import BaseExceptionGroup, ExceptionGroup, print_exception
11+
else:
12+
from traceback import print_exception
1113

1214
################################################################
1315
# MultiError
@@ -387,3 +389,27 @@ def concat_tb(head, tail):
387389
for head_tb in reversed(head_tbs):
388390
current_head = copy_tb(head_tb, tb_next=current_head)
389391
return current_head
392+
393+
394+
# Remove when IPython gains support for exception groups
395+
if "IPython" in sys.modules:
396+
import IPython
397+
398+
ip = IPython.get_ipython()
399+
if ip is not None:
400+
if ip.custom_exceptions != ():
401+
warnings.warn(
402+
"IPython detected, but you already have a custom exception "
403+
"handler installed. I'll skip installing Trio's custom "
404+
"handler, but this means exception groups will not show full "
405+
"tracebacks.",
406+
category=RuntimeWarning,
407+
)
408+
else:
409+
410+
def trio_show_traceback(self, etype, value, tb, tb_offset=None):
411+
# XX it would be better to integrate with IPython's fancy
412+
# exception formatting stuff (and not ignore tb_offset)
413+
print_exception(value)
414+
415+
ip.set_custom_exc((BaseExceptionGroup,), trio_show_traceback)

trio/_core/tests/test_multierror.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import gc
22
import logging
3+
import os
4+
import subprocess
5+
from pathlib import Path
6+
37
import pytest
48

59
from traceback import (
@@ -11,6 +15,7 @@
1115
import sys
1216
import re
1317

18+
from .tutil import slow
1419
from .._multierror import MultiError, concat_tb, NonBaseMultiError
1520
from ... import TrioDeprecationWarning
1621
from ..._core import open_nursery
@@ -427,3 +432,106 @@ def test_non_base_multierror():
427432
exc = MultiError([ZeroDivisionError(), ValueError()])
428433
assert type(exc) is NonBaseMultiError
429434
assert isinstance(exc, ExceptionGroup)
435+
436+
437+
def run_script(name, use_ipython=False):
438+
import trio
439+
440+
trio_path = Path(trio.__file__).parent.parent
441+
script_path = Path(__file__).parent / "test_multierror_scripts" / name
442+
443+
env = dict(os.environ)
444+
print("parent PYTHONPATH:", env.get("PYTHONPATH"))
445+
if "PYTHONPATH" in env: # pragma: no cover
446+
pp = env["PYTHONPATH"].split(os.pathsep)
447+
else:
448+
pp = []
449+
pp.insert(0, str(trio_path))
450+
pp.insert(0, str(script_path.parent))
451+
env["PYTHONPATH"] = os.pathsep.join(pp)
452+
print("subprocess PYTHONPATH:", env.get("PYTHONPATH"))
453+
454+
if use_ipython:
455+
lines = [script_path.read_text(), "exit()"]
456+
457+
cmd = [
458+
sys.executable,
459+
"-u",
460+
"-m",
461+
"IPython",
462+
# no startup files
463+
"--quick",
464+
"--TerminalIPythonApp.code_to_run=" + "\n".join(lines),
465+
]
466+
else:
467+
cmd = [sys.executable, "-u", str(script_path)]
468+
print("running:", cmd)
469+
completed = subprocess.run(
470+
cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
471+
)
472+
print("process output:")
473+
print(completed.stdout.decode("utf-8"))
474+
return completed
475+
476+
477+
def check_simple_excepthook(completed):
478+
assert_match_in_seq(
479+
[
480+
"in <module>",
481+
"MultiError",
482+
"--- 1 ---",
483+
"in exc1_fn",
484+
"ValueError",
485+
"--- 2 ---",
486+
"in exc2_fn",
487+
"KeyError",
488+
],
489+
completed.stdout.decode("utf-8"),
490+
)
491+
492+
493+
try:
494+
import IPython
495+
except ImportError: # pragma: no cover
496+
have_ipython = False
497+
else:
498+
have_ipython = True
499+
500+
need_ipython = pytest.mark.skipif(not have_ipython, reason="need IPython")
501+
502+
503+
@slow
504+
@need_ipython
505+
def test_ipython_exc_handler():
506+
completed = run_script("simple_excepthook.py", use_ipython=True)
507+
check_simple_excepthook(completed)
508+
509+
510+
@slow
511+
@need_ipython
512+
def test_ipython_imported_but_unused():
513+
completed = run_script("simple_excepthook_IPython.py")
514+
check_simple_excepthook(completed)
515+
516+
517+
@slow
518+
@need_ipython
519+
def test_ipython_custom_exc_handler():
520+
# Check we get a nice warning (but only one!) if the user is using IPython
521+
# and already has some other set_custom_exc handler installed.
522+
completed = run_script("ipython_custom_exc.py", use_ipython=True)
523+
assert_match_in_seq(
524+
[
525+
# The warning
526+
"RuntimeWarning",
527+
"IPython detected",
528+
"skip installing Trio",
529+
# The MultiError
530+
"MultiError",
531+
"ValueError",
532+
"KeyError",
533+
],
534+
completed.stdout.decode("utf-8"),
535+
)
536+
# Make sure our other warning doesn't show up
537+
assert "custom sys.excepthook" not in completed.stdout.decode("utf-8")
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# This isn't really a package, everything in here is a standalone script. This
2+
# __init__.py is just to fool setup.py into actually installing the things.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# https://coverage.readthedocs.io/en/latest/subprocess.html
2+
try:
3+
import coverage
4+
except ImportError: # pragma: no cover
5+
pass
6+
else:
7+
coverage.process_startup()
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import _common
2+
3+
# Override the regular excepthook too -- it doesn't change anything either way
4+
# because ipython doesn't use it, but we want to make sure Trio doesn't warn
5+
# about it.
6+
import sys
7+
8+
9+
def custom_excepthook(*args):
10+
print("custom running!")
11+
return sys.__excepthook__(*args)
12+
13+
14+
sys.excepthook = custom_excepthook
15+
16+
import IPython
17+
18+
ip = IPython.get_ipython()
19+
20+
21+
# Set this to some random nonsense
22+
class SomeError(Exception):
23+
pass
24+
25+
26+
def custom_exc_hook(etype, value, tb, tb_offset=None):
27+
ip.showtraceback()
28+
29+
30+
ip.set_custom_exc((SomeError,), custom_exc_hook)
31+
32+
import trio
33+
34+
# The custom excepthook should run, because Trio was polite and didn't
35+
# override it
36+
raise trio.MultiError([ValueError(), KeyError()])
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import _common
2+
3+
import trio
4+
5+
6+
def exc1_fn():
7+
try:
8+
raise ValueError
9+
except Exception as exc:
10+
return exc
11+
12+
13+
def exc2_fn():
14+
try:
15+
raise KeyError
16+
except Exception as exc:
17+
return exc
18+
19+
20+
# This should be printed nicely, because Trio overrode sys.excepthook
21+
raise trio.MultiError([exc1_fn(), exc2_fn()])
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import _common
2+
3+
# To tickle the "is IPython loaded?" logic, make sure that Trio tolerates
4+
# IPython loaded but not actually in use
5+
import IPython
6+
7+
import simple_excepthook

0 commit comments

Comments
 (0)