Skip to content

Commit

Permalink
Display exception notes in tracebacks (#14039)
Browse files Browse the repository at this point in the history
[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 <module>
    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 <module>
    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 <module>
    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](python/cpython#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)

## 🐍 🤹‍♂️
  • Loading branch information
Carreau committed Jun 2, 2023
2 parents 3a567f0 + f7014a8 commit 1d4e184
Show file tree
Hide file tree
Showing 4 changed files with 93 additions and 32 deletions.
1 change: 1 addition & 0 deletions .gitignore
Expand Up @@ -27,6 +27,7 @@ __pycache__
*.swp
.pytest_cache
.python-version
.venv*/
venv*/
.mypy_cache/

Expand Down
49 changes: 36 additions & 13 deletions IPython/core/tests/test_ultratb.py
Expand Up @@ -51,24 +51,24 @@ def wrapper(*args, **kwargs):
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'):
Expand All @@ -92,27 +92,27 @@ def test_nonascii_path(self):
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('é')"
Expand Down Expand Up @@ -167,12 +167,12 @@ def test_indentationerror_shows_line(self):
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)
Expand Down Expand Up @@ -363,6 +363,29 @@ def test_recursion_three_frames(self):
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)
Expand Down
70 changes: 51 additions & 19 deletions IPython/core/ultratb.py
Expand Up @@ -89,6 +89,7 @@
#*****************************************************************************


from collections.abc import Sequence
import functools
import inspect
import linecache
Expand Down Expand Up @@ -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...
Expand Down Expand Up @@ -582,7 +591,7 @@ def _format_list(self, extracted_list):
"""

Colors = self.Colors
list = []
output_list = []
for ind, (filename, lineno, name, line) in enumerate(extracted_list):
normalCol, nameCol, fileCol, lineCol = (
# Emphasize the last entry
Expand All @@ -600,9 +609,9 @@ def _format_list(self, extracted_list):
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.
Expand All @@ -619,11 +628,11 @@ def _format_exception_only(self, etype, value):
"""
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
Expand All @@ -634,7 +643,7 @@ def _format_exception_only(self, etype, value):
else:
lineno = "unknown"
textline = ""
list.append(
output_list.append(
"%s %s%s\n"
% (
Colors.normalEm,
Expand All @@ -654,36 +663,41 @@ def _format_exception_only(self, etype, value):
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:
ipinst = get_ipython()
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.
Expand Down Expand Up @@ -999,9 +1013,27 @@ def format_exception(self, etype, evalue):
# 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,
Expand Down Expand Up @@ -1068,7 +1100,7 @@ def format_exception_as_a_whole(
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
Expand Down
5 changes: 5 additions & 0 deletions 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.

0 comments on commit 1d4e184

Please sign in to comment.