From 17103aed1ef21263fa2e11402df8bbf647c1683b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 18 Sep 2022 23:44:08 +0300 Subject: [PATCH] Restored the IPython custom error handler Now it works with BaseExceptionGroup too. --- setup.py | 2 +- test-requirements.in | 2 +- test-requirements.txt | 22 +++- trio/_core/_multierror.py | 28 ++++- trio/_core/tests/test_multierror.py | 108 ++++++++++++++++++ .../tests/test_multierror_scripts/__init__.py | 2 + .../tests/test_multierror_scripts/_common.py | 7 ++ .../ipython_custom_exc.py | 36 ++++++ .../simple_excepthook.py | 21 ++++ .../simple_excepthook_IPython.py | 7 ++ 10 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 trio/_core/tests/test_multierror_scripts/__init__.py create mode 100644 trio/_core/tests/test_multierror_scripts/_common.py create mode 100644 trio/_core/tests/test_multierror_scripts/ipython_custom_exc.py create mode 100644 trio/_core/tests/test_multierror_scripts/simple_excepthook.py create mode 100644 trio/_core/tests/test_multierror_scripts/simple_excepthook_IPython.py diff --git a/setup.py b/setup.py index d2f09d5055..00ba270764 100644 --- a/setup.py +++ b/setup.py @@ -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: diff --git a/test-requirements.in b/test-requirements.in index ae04d7bc70..cb9db8f894 100644 --- a/test-requirements.in +++ b/test-requirements.in @@ -30,5 +30,5 @@ async_generator >= 1.9 idna outcome sniffio -exceptiongroup; python_version < "3.11" +exceptiongroup >= 1.0.0rc9; python_version < "3.11" diff --git a/test-requirements.txt b/test-requirements.txt index 34768d31df..88123b0d69 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -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 @@ -34,7 +34,7 @@ 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 @@ -42,6 +42,12 @@ 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 @@ -130,6 +136,12 @@ 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" @@ -137,11 +149,17 @@ types-pyopenssl==22.0.9 ; implementation_name == "cpython" 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 diff --git a/trio/_core/_multierror.py b/trio/_core/_multierror.py index 4a4a39ab90..ddeb768788 100644 --- a/trio/_core/_multierror.py +++ b/trio/_core/_multierror.py @@ -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 @@ -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) diff --git a/trio/_core/tests/test_multierror.py b/trio/_core/tests/test_multierror.py index 503d85adc1..12f8606109 100644 --- a/trio/_core/tests/test_multierror.py +++ b/trio/_core/tests/test_multierror.py @@ -1,5 +1,9 @@ import gc import logging +import os +import subprocess +from pathlib import Path + import pytest from traceback import ( @@ -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 @@ -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 ", + "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") diff --git a/trio/_core/tests/test_multierror_scripts/__init__.py b/trio/_core/tests/test_multierror_scripts/__init__.py new file mode 100644 index 0000000000..a1f6cb598d --- /dev/null +++ b/trio/_core/tests/test_multierror_scripts/__init__.py @@ -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. diff --git a/trio/_core/tests/test_multierror_scripts/_common.py b/trio/_core/tests/test_multierror_scripts/_common.py new file mode 100644 index 0000000000..0c70df1840 --- /dev/null +++ b/trio/_core/tests/test_multierror_scripts/_common.py @@ -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() diff --git a/trio/_core/tests/test_multierror_scripts/ipython_custom_exc.py b/trio/_core/tests/test_multierror_scripts/ipython_custom_exc.py new file mode 100644 index 0000000000..b3fd110e50 --- /dev/null +++ b/trio/_core/tests/test_multierror_scripts/ipython_custom_exc.py @@ -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()]) diff --git a/trio/_core/tests/test_multierror_scripts/simple_excepthook.py b/trio/_core/tests/test_multierror_scripts/simple_excepthook.py new file mode 100644 index 0000000000..94004525db --- /dev/null +++ b/trio/_core/tests/test_multierror_scripts/simple_excepthook.py @@ -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()]) diff --git a/trio/_core/tests/test_multierror_scripts/simple_excepthook_IPython.py b/trio/_core/tests/test_multierror_scripts/simple_excepthook_IPython.py new file mode 100644 index 0000000000..6aa12493b0 --- /dev/null +++ b/trio/_core/tests/test_multierror_scripts/simple_excepthook_IPython.py @@ -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