🌐 AI搜索 & 代理 主页
Skip to content

Commit 1d4e184

Browse files
authored
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 <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) ## 🐍 🤹‍♂️
2 parents 3a567f0 + f7014a8 commit 1d4e184

File tree

4 files changed

+93
-32
lines changed

4 files changed

+93
-32
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ __pycache__
2727
*.swp
2828
.pytest_cache
2929
.python-version
30+
.venv*/
3031
venv*/
3132
.mypy_cache/
3233

IPython/core/tests/test_ultratb.py

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -51,24 +51,24 @@ def wrapper(*args, **kwargs):
5151
class ChangedPyFileTest(unittest.TestCase):
5252
def test_changing_py_file(self):
5353
"""Traceback produced if the line where the error occurred is missing?
54-
54+
5555
https://github.com/ipython/ipython/issues/1456
5656
"""
5757
with TemporaryDirectory() as td:
5858
fname = os.path.join(td, "foo.py")
5959
with open(fname, "w", encoding="utf-8") as f:
6060
f.write(file_1)
61-
61+
6262
with prepended_to_syspath(td):
6363
ip.run_cell("import foo")
64-
64+
6565
with tt.AssertPrints("ZeroDivisionError"):
6666
ip.run_cell("foo.f()")
67-
67+
6868
# Make the file shorter, so the line of the error is missing.
6969
with open(fname, "w", encoding="utf-8") as f:
7070
f.write(file_2)
71-
71+
7272
# For some reason, this was failing on the *second* call after
7373
# changing the file, so we call f() twice.
7474
with tt.AssertNotPrints("Internal Python error", channel='stderr'):
@@ -92,27 +92,27 @@ def test_nonascii_path(self):
9292
fname = os.path.join(td, u"fooé.py")
9393
with open(fname, "w", encoding="utf-8") as f:
9494
f.write(file_1)
95-
95+
9696
with prepended_to_syspath(td):
9797
ip.run_cell("import foo")
98-
98+
9999
with tt.AssertPrints("ZeroDivisionError"):
100100
ip.run_cell("foo.f()")
101-
101+
102102
def test_iso8859_5(self):
103103
with TemporaryDirectory() as td:
104104
fname = os.path.join(td, 'dfghjkl.py')
105105

106106
with io.open(fname, 'w', encoding='iso-8859-5') as f:
107107
f.write(iso_8859_5_file)
108-
108+
109109
with prepended_to_syspath(td):
110110
ip.run_cell("from dfghjkl import fail")
111-
111+
112112
with tt.AssertPrints("ZeroDivisionError"):
113113
with tt.AssertPrints(u'дбИЖ', suppress=False):
114114
ip.run_cell('fail()')
115-
115+
116116
def test_nonascii_msg(self):
117117
cell = u"raise Exception('é')"
118118
expected = u"Exception('é')"
@@ -167,12 +167,12 @@ def test_indentationerror_shows_line(self):
167167
with tt.AssertPrints("IndentationError"):
168168
with tt.AssertPrints("zoon()", suppress=False):
169169
ip.run_cell(indentationerror_file)
170-
170+
171171
with TemporaryDirectory() as td:
172172
fname = os.path.join(td, "foo.py")
173173
with open(fname, "w", encoding="utf-8") as f:
174174
f.write(indentationerror_file)
175-
175+
176176
with tt.AssertPrints("IndentationError"):
177177
with tt.AssertPrints("zoon()", suppress=False):
178178
ip.magic('run %s' % fname)
@@ -363,6 +363,29 @@ def test_recursion_three_frames(self):
363363
ip.run_cell("r3o2()")
364364

365365

366+
class PEP678NotesReportingTest(unittest.TestCase):
367+
ERROR_WITH_NOTE = """
368+
try:
369+
raise AssertionError("Message")
370+
except Exception as e:
371+
try:
372+
e.add_note("This is a PEP-678 note.")
373+
except AttributeError: # Python <= 3.10
374+
e.__notes__ = ("This is a PEP-678 note.",)
375+
raise
376+
"""
377+
378+
def test_verbose_reports_notes(self):
379+
with tt.AssertPrints(["AssertionError", "Message", "This is a PEP-678 note."]):
380+
ip.run_cell(self.ERROR_WITH_NOTE)
381+
382+
def test_plain_reports_notes(self):
383+
with tt.AssertPrints(["AssertionError", "Message", "This is a PEP-678 note."]):
384+
ip.run_cell("%xmode Plain")
385+
ip.run_cell(self.ERROR_WITH_NOTE)
386+
ip.run_cell("%xmode Verbose")
387+
388+
366389
#----------------------------------------------------------------------------
367390

368391
# module testing (minimal)

IPython/core/ultratb.py

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
#*****************************************************************************
9090

9191

92+
from collections.abc import Sequence
9293
import functools
9394
import inspect
9495
import linecache
@@ -183,6 +184,14 @@ def get_line_number_of_frame(frame: types.FrameType) -> int:
183184
return count_lines_in_py_file(filename)
184185

185186

187+
def _safe_string(value, what, func=str):
188+
# Copied from cpython/Lib/traceback.py
189+
try:
190+
return func(value)
191+
except:
192+
return f"<{what} {func.__name__}() failed>"
193+
194+
186195
def _format_traceback_lines(lines, Colors, has_colors: bool, lvals):
187196
"""
188197
Format tracebacks lines with pointing arrow, leading numbers...
@@ -582,7 +591,7 @@ def _format_list(self, extracted_list):
582591
"""
583592

584593
Colors = self.Colors
585-
list = []
594+
output_list = []
586595
for ind, (filename, lineno, name, line) in enumerate(extracted_list):
587596
normalCol, nameCol, fileCol, lineCol = (
588597
# Emphasize the last entry
@@ -600,9 +609,9 @@ def _format_list(self, extracted_list):
600609
item += "\n"
601610
if line:
602611
item += f"{lineCol} {line.strip()}{normalCol}\n"
603-
list.append(item)
612+
output_list.append(item)
604613

605-
return list
614+
return output_list
606615

607616
def _format_exception_only(self, etype, value):
608617
"""Format the exception part of a traceback.
@@ -619,11 +628,11 @@ def _format_exception_only(self, etype, value):
619628
"""
620629
have_filedata = False
621630
Colors = self.Colors
622-
list = []
631+
output_list = []
623632
stype = py3compat.cast_unicode(Colors.excName + etype.__name__ + Colors.Normal)
624633
if value is None:
625634
# Not sure if this can still happen in Python 2.6 and above
626-
list.append(stype + '\n')
635+
output_list.append(stype + "\n")
627636
else:
628637
if issubclass(etype, SyntaxError):
629638
have_filedata = True
@@ -634,7 +643,7 @@ def _format_exception_only(self, etype, value):
634643
else:
635644
lineno = "unknown"
636645
textline = ""
637-
list.append(
646+
output_list.append(
638647
"%s %s%s\n"
639648
% (
640649
Colors.normalEm,
@@ -654,36 +663,41 @@ def _format_exception_only(self, etype, value):
654663
i = 0
655664
while i < len(textline) and textline[i].isspace():
656665
i += 1
657-
list.append('%s %s%s\n' % (Colors.line,
658-
textline.strip(),
659-
Colors.Normal))
666+
output_list.append(
667+
"%s %s%s\n" % (Colors.line, textline.strip(), Colors.Normal)
668+
)
660669
if value.offset is not None:
661670
s = ' '
662671
for c in textline[i:value.offset - 1]:
663672
if c.isspace():
664673
s += c
665674
else:
666-
s += ' '
667-
list.append('%s%s^%s\n' % (Colors.caret, s,
668-
Colors.Normal))
675+
s += " "
676+
output_list.append(
677+
"%s%s^%s\n" % (Colors.caret, s, Colors.Normal)
678+
)
669679

670680
try:
671681
s = value.msg
672682
except Exception:
673683
s = self._some_str(value)
674684
if s:
675-
list.append('%s%s:%s %s\n' % (stype, Colors.excName,
676-
Colors.Normal, s))
685+
output_list.append(
686+
"%s%s:%s %s\n" % (stype, Colors.excName, Colors.Normal, s)
687+
)
677688
else:
678-
list.append('%s\n' % stype)
689+
output_list.append("%s\n" % stype)
690+
691+
# PEP-678 notes
692+
output_list.extend(f"{x}\n" for x in getattr(value, "__notes__", []))
679693

680694
# sync with user hooks
681695
if have_filedata:
682696
ipinst = get_ipython()
683697
if ipinst is not None:
684698
ipinst.hooks.synchronize_with_editor(value.filename, value.lineno, 0)
685699

686-
return list
700+
return output_list
687701

688702
def get_exception_only(self, etype, value):
689703
"""Only print the exception type and message, without a traceback.
@@ -999,9 +1013,27 @@ def format_exception(self, etype, evalue):
9991013
# User exception is improperly defined.
10001014
etype, evalue = str, sys.exc_info()[:2]
10011015
etype_str, evalue_str = map(str, (etype, evalue))
1016+
1017+
# PEP-678 notes
1018+
notes = getattr(evalue, "__notes__", [])
1019+
if not isinstance(notes, Sequence) or isinstance(notes, (str, bytes)):
1020+
notes = [_safe_string(notes, "__notes__", func=repr)]
1021+
10021022
# ... and format it
1003-
return ['%s%s%s: %s' % (colors.excName, etype_str,
1004-
colorsnormal, py3compat.cast_unicode(evalue_str))]
1023+
return [
1024+
"{}{}{}: {}".format(
1025+
colors.excName,
1026+
etype_str,
1027+
colorsnormal,
1028+
py3compat.cast_unicode(evalue_str),
1029+
),
1030+
*(
1031+
"{}{}".format(
1032+
colorsnormal, _safe_string(py3compat.cast_unicode(n), "note")
1033+
)
1034+
for n in notes
1035+
),
1036+
]
10051037

10061038
def format_exception_as_a_whole(
10071039
self,
@@ -1068,7 +1100,7 @@ def format_exception_as_a_whole(
10681100
if ipinst is not None:
10691101
ipinst.hooks.synchronize_with_editor(frame_info.filename, frame_info.lineno, 0)
10701102

1071-
return [[head] + frames + [''.join(formatted_exception[0])]]
1103+
return [[head] + frames + formatted_exception]
10721104

10731105
def get_records(
10741106
self, etb: TracebackType, number_of_lines_of_context: int, tb_offset: int
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Support for PEP-678 Exception Notes
2+
-----------------------------------
3+
4+
Ultratb now shows :pep:`678` notes, improving your debugging experience on
5+
Python 3.11+ or with libraries such as Pytest and Hypothesis.

0 commit comments

Comments
 (0)