From 1d4e18474ed5734c4cfddd912f38c9170fffba4e 2023-06-02 09:25:41 From: Matthias Bussonnier Date: 2023-06-02 09:25:41 Subject: [PATCH] Display exception notes in tracebacks (#14039) [PEP 678](https://peps.python.org/pep-0678/) introduced the ability to add notes to exception objects. This has been [released in Python 3.11](https://docs.python.org/3/library/exceptions.html#BaseException.add_note) and is currently not implemented in IPython. These changes are fully compatible with older Python versions that don't include PEP 678. Here's a sample test that shows the consistency in Python's stdlib traceback module (test 1) and the difference between Python and IPython's runtimes (test 2): ```python import traceback print('--- test 1 ---') try: raise Exception('Testing notes') except Exception as e: e.add_note('Does this work?') e.add_note('Yes!') traceback.print_exc() print('\n--- test 2 ---') try: raise Exception('Testing notes') except Exception as e: e.add_note('Does this work?') e.add_note('No!') raise ``` When executed with Python 3.11, both notes are displayed in both tracebacks: ``` $ python test.py --- test 1 --- Traceback (most recent call last): File "/app/test.py", line 5, in raise Exception('Testing notes') Exception: Testing notes Does this work? Yes! --- test 2 --- Traceback (most recent call last): File "/app/test.py", line 13, in raise Exception('Testing notes') Exception: Testing notes Does this work? No! ``` In IPython's VerboseTB does not yet handle exception notes: ``` $ ipython test.py --- test 1 --- Traceback (most recent call last): File "/app/test.py", line 5, in raise Exception('Testing notes') Exception: Testing notes Does this work? Yes! --- test 2 --- --------------------------------------------------------------------------- Exception Traceback (most recent call last) File /app/test.py:13 11 print('\n--- test 2 ---') 12 try: ---> 13 raise Exception('Testing notes') 14 except Exception as e: 15 e.add_note('Does this work?') Exception: Testing notes ``` The changes I am suggesting are inspired from implementation of [Lib/traceback.py](https://github.com/python/cpython/blob/main/Lib/traceback.py) (search for `__notes__`) and improvements for dealing with edge cases more nicely in [cpython#103897](https://github.com/python/cpython/pull/103897). Although notes are meant to be strings only, I kept some inspiration from the existing exception handling to ensure that the notes are uncolored and bytes decoded, if there are any. I am definitely open to using a different color if deemed better. For context, `bpython` keeps the notes uncolored, and [Python's tutorial](https://docs.python.org/3/tutorial/errors.html#enriching-exceptions-with-notes) puts them in light gray, like the line numbers. Here's how the test 2 looks like after these changes: ![image](https://user-images.githubusercontent.com/16963011/234723689-6bbfe0ff-94d4-4a90-9da6-acfe1c8e5edf.png) ## :snake: :man_juggling: --- diff --git a/.gitignore b/.gitignore index 06087b7..270eb98 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ __pycache__ *.swp .pytest_cache .python-version +.venv*/ venv*/ .mypy_cache/ diff --git a/IPython/core/tests/test_ultratb.py b/IPython/core/tests/test_ultratb.py index d8c969a..c4de95d 100644 --- a/IPython/core/tests/test_ultratb.py +++ b/IPython/core/tests/test_ultratb.py @@ -51,24 +51,24 @@ def recursionlimit(frames): class ChangedPyFileTest(unittest.TestCase): def test_changing_py_file(self): """Traceback produced if the line where the error occurred is missing? - + https://github.com/ipython/ipython/issues/1456 """ with TemporaryDirectory() as td: fname = os.path.join(td, "foo.py") with open(fname, "w", encoding="utf-8") as f: f.write(file_1) - + with prepended_to_syspath(td): ip.run_cell("import foo") - + with tt.AssertPrints("ZeroDivisionError"): ip.run_cell("foo.f()") - + # Make the file shorter, so the line of the error is missing. with open(fname, "w", encoding="utf-8") as f: f.write(file_2) - + # For some reason, this was failing on the *second* call after # changing the file, so we call f() twice. with tt.AssertNotPrints("Internal Python error", channel='stderr'): @@ -92,27 +92,27 @@ class NonAsciiTest(unittest.TestCase): fname = os.path.join(td, u"fooé.py") with open(fname, "w", encoding="utf-8") as f: f.write(file_1) - + with prepended_to_syspath(td): ip.run_cell("import foo") - + with tt.AssertPrints("ZeroDivisionError"): ip.run_cell("foo.f()") - + def test_iso8859_5(self): with TemporaryDirectory() as td: fname = os.path.join(td, 'dfghjkl.py') with io.open(fname, 'w', encoding='iso-8859-5') as f: f.write(iso_8859_5_file) - + with prepended_to_syspath(td): ip.run_cell("from dfghjkl import fail") - + with tt.AssertPrints("ZeroDivisionError"): with tt.AssertPrints(u'дбИЖ', suppress=False): ip.run_cell('fail()') - + def test_nonascii_msg(self): cell = u"raise Exception('é')" expected = u"Exception('é')" @@ -167,12 +167,12 @@ class IndentationErrorTest(unittest.TestCase): with tt.AssertPrints("IndentationError"): with tt.AssertPrints("zoon()", suppress=False): ip.run_cell(indentationerror_file) - + with TemporaryDirectory() as td: fname = os.path.join(td, "foo.py") with open(fname, "w", encoding="utf-8") as f: f.write(indentationerror_file) - + with tt.AssertPrints("IndentationError"): with tt.AssertPrints("zoon()", suppress=False): ip.magic('run %s' % fname) @@ -363,6 +363,29 @@ def r3o2(): ip.run_cell("r3o2()") +class PEP678NotesReportingTest(unittest.TestCase): + ERROR_WITH_NOTE = """ +try: + raise AssertionError("Message") +except Exception as e: + try: + e.add_note("This is a PEP-678 note.") + except AttributeError: # Python <= 3.10 + e.__notes__ = ("This is a PEP-678 note.",) + raise + """ + + def test_verbose_reports_notes(self): + with tt.AssertPrints(["AssertionError", "Message", "This is a PEP-678 note."]): + ip.run_cell(self.ERROR_WITH_NOTE) + + def test_plain_reports_notes(self): + with tt.AssertPrints(["AssertionError", "Message", "This is a PEP-678 note."]): + ip.run_cell("%xmode Plain") + ip.run_cell(self.ERROR_WITH_NOTE) + ip.run_cell("%xmode Verbose") + + #---------------------------------------------------------------------------- # module testing (minimal) diff --git a/IPython/core/ultratb.py b/IPython/core/ultratb.py index 6ae64d7..e0d9ddd 100644 --- a/IPython/core/ultratb.py +++ b/IPython/core/ultratb.py @@ -89,6 +89,7 @@ Inheritance diagram: #***************************************************************************** +from collections.abc import Sequence import functools import inspect import linecache @@ -183,6 +184,14 @@ def get_line_number_of_frame(frame: types.FrameType) -> int: return count_lines_in_py_file(filename) +def _safe_string(value, what, func=str): + # Copied from cpython/Lib/traceback.py + try: + return func(value) + except: + return f"<{what} {func.__name__}() failed>" + + def _format_traceback_lines(lines, Colors, has_colors: bool, lvals): """ Format tracebacks lines with pointing arrow, leading numbers... @@ -582,7 +591,7 @@ class ListTB(TBTools): """ Colors = self.Colors - list = [] + output_list = [] for ind, (filename, lineno, name, line) in enumerate(extracted_list): normalCol, nameCol, fileCol, lineCol = ( # Emphasize the last entry @@ -600,9 +609,9 @@ class ListTB(TBTools): item += "\n" if line: item += f"{lineCol} {line.strip()}{normalCol}\n" - list.append(item) + output_list.append(item) - return list + return output_list def _format_exception_only(self, etype, value): """Format the exception part of a traceback. @@ -619,11 +628,11 @@ class ListTB(TBTools): """ have_filedata = False Colors = self.Colors - list = [] + output_list = [] stype = py3compat.cast_unicode(Colors.excName + etype.__name__ + Colors.Normal) if value is None: # Not sure if this can still happen in Python 2.6 and above - list.append(stype + '\n') + output_list.append(stype + "\n") else: if issubclass(etype, SyntaxError): have_filedata = True @@ -634,7 +643,7 @@ class ListTB(TBTools): else: lineno = "unknown" textline = "" - list.append( + output_list.append( "%s %s%s\n" % ( Colors.normalEm, @@ -654,28 +663,33 @@ class ListTB(TBTools): i = 0 while i < len(textline) and textline[i].isspace(): i += 1 - list.append('%s %s%s\n' % (Colors.line, - textline.strip(), - Colors.Normal)) + output_list.append( + "%s %s%s\n" % (Colors.line, textline.strip(), Colors.Normal) + ) if value.offset is not None: s = ' ' for c in textline[i:value.offset - 1]: if c.isspace(): s += c else: - s += ' ' - list.append('%s%s^%s\n' % (Colors.caret, s, - Colors.Normal)) + s += " " + output_list.append( + "%s%s^%s\n" % (Colors.caret, s, Colors.Normal) + ) try: s = value.msg except Exception: s = self._some_str(value) if s: - list.append('%s%s:%s %s\n' % (stype, Colors.excName, - Colors.Normal, s)) + output_list.append( + "%s%s:%s %s\n" % (stype, Colors.excName, Colors.Normal, s) + ) else: - list.append('%s\n' % stype) + output_list.append("%s\n" % stype) + + # PEP-678 notes + output_list.extend(f"{x}\n" for x in getattr(value, "__notes__", [])) # sync with user hooks if have_filedata: @@ -683,7 +697,7 @@ class ListTB(TBTools): if ipinst is not None: ipinst.hooks.synchronize_with_editor(value.filename, value.lineno, 0) - return list + return output_list def get_exception_only(self, etype, value): """Only print the exception type and message, without a traceback. @@ -999,9 +1013,27 @@ class VerboseTB(TBTools): # User exception is improperly defined. etype, evalue = str, sys.exc_info()[:2] etype_str, evalue_str = map(str, (etype, evalue)) + + # PEP-678 notes + notes = getattr(evalue, "__notes__", []) + if not isinstance(notes, Sequence) or isinstance(notes, (str, bytes)): + notes = [_safe_string(notes, "__notes__", func=repr)] + # ... and format it - return ['%s%s%s: %s' % (colors.excName, etype_str, - colorsnormal, py3compat.cast_unicode(evalue_str))] + return [ + "{}{}{}: {}".format( + colors.excName, + etype_str, + colorsnormal, + py3compat.cast_unicode(evalue_str), + ), + *( + "{}{}".format( + colorsnormal, _safe_string(py3compat.cast_unicode(n), "note") + ) + for n in notes + ), + ] def format_exception_as_a_whole( self, @@ -1068,7 +1100,7 @@ class VerboseTB(TBTools): if ipinst is not None: ipinst.hooks.synchronize_with_editor(frame_info.filename, frame_info.lineno, 0) - return [[head] + frames + [''.join(formatted_exception[0])]] + return [[head] + frames + formatted_exception] def get_records( self, etb: TracebackType, number_of_lines_of_context: int, tb_offset: int diff --git a/docs/source/whatsnew/pr/pep678-notes.rst b/docs/source/whatsnew/pr/pep678-notes.rst new file mode 100644 index 0000000..7d564d6 --- /dev/null +++ b/docs/source/whatsnew/pr/pep678-notes.rst @@ -0,0 +1,5 @@ +Support for PEP-678 Exception Notes +----------------------------------- + +Ultratb now shows :pep:`678` notes, improving your debugging experience on +Python 3.11+ or with libraries such as Pytest and Hypothesis.