Skip to content

Commit 1890dd2

Browse files
isidenticalammaraskarpablogsal
authored
bpo-43950: Specialize tracebacks for subscripts/binary ops (pythonGH-27037)
Co-authored-by: Ammar Askar <ammar@ammaraskar.com> Co-authored-by: Pablo Galindo <pablogsal@gmail.com>
1 parent da2e673 commit 1890dd2

File tree

4 files changed

+366
-46
lines changed

4 files changed

+366
-46
lines changed

Doc/library/traceback.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,7 @@ The output for the example would look similar to this:
473473
['Traceback (most recent call last):\n',
474474
' File "<doctest default[0]>", line 10, in <module>\n lumberjack()\n ^^^^^^^^^^^^\n',
475475
' File "<doctest default[0]>", line 4, in lumberjack\n bright_side_of_death()\n ^^^^^^^^^^^^^^^^^^^^^^\n',
476-
' File "<doctest default[0]>", line 7, in bright_side_of_death\n return tuple()[0]\n ^^^^^^^^^^\n',
476+
' File "<doctest default[0]>", line 7, in bright_side_of_death\n return tuple()[0]\n ~~~~~~~^^^\n',
477477
'IndexError: tuple index out of range\n']
478478
*** extract_tb:
479479
[<FrameSummary file <doctest...>, line 10 in <module>>,
@@ -482,7 +482,7 @@ The output for the example would look similar to this:
482482
*** format_tb:
483483
[' File "<doctest default[0]>", line 10, in <module>\n lumberjack()\n ^^^^^^^^^^^^\n',
484484
' File "<doctest default[0]>", line 4, in lumberjack\n bright_side_of_death()\n ^^^^^^^^^^^^^^^^^^^^^^\n',
485-
' File "<doctest default[0]>", line 7, in bright_side_of_death\n return tuple()[0]\n ^^^^^^^^^^\n']
485+
' File "<doctest default[0]>", line 7, in bright_side_of_death\n return tuple()[0]\n ~~~~~~~^^^\n']
486486
*** tb_lineno: 10
487487

488488

Lib/test/test_traceback.py

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
requires_debug_ranges, has_no_debug_ranges)
1313
from test.support.os_helper import TESTFN, unlink
1414
from test.support.script_helper import assert_python_ok, assert_python_failure
15-
import textwrap
1615

16+
import os
17+
import textwrap
1718
import traceback
19+
from functools import partial
1820

1921

2022
test_code = namedtuple('code', ['co_filename', 'co_name'])
@@ -406,6 +408,82 @@ def f_with_multiline():
406408
result_lines = self.get_exception(f_with_multiline)
407409
self.assertEqual(result_lines, expected_f.splitlines())
408410

411+
def test_caret_for_binary_operators(self):
412+
def f_with_binary_operator():
413+
divisor = 20
414+
return 10 + divisor / 0 + 30
415+
416+
lineno_f = f_with_binary_operator.__code__.co_firstlineno
417+
expected_error = (
418+
'Traceback (most recent call last):\n'
419+
f' File "{__file__}", line {self.callable_line}, in get_exception\n'
420+
' callable()\n'
421+
' ^^^^^^^^^^\n'
422+
f' File "{__file__}", line {lineno_f+2}, in f_with_binary_operator\n'
423+
' return 10 + divisor / 0 + 30\n'
424+
' ~~~~~~~~^~~\n'
425+
)
426+
result_lines = self.get_exception(f_with_binary_operator)
427+
self.assertEqual(result_lines, expected_error.splitlines())
428+
429+
def test_caret_for_binary_operators_two_char(self):
430+
def f_with_binary_operator():
431+
divisor = 20
432+
return 10 + divisor // 0 + 30
433+
434+
lineno_f = f_with_binary_operator.__code__.co_firstlineno
435+
expected_error = (
436+
'Traceback (most recent call last):\n'
437+
f' File "{__file__}", line {self.callable_line}, in get_exception\n'
438+
' callable()\n'
439+
' ^^^^^^^^^^\n'
440+
f' File "{__file__}", line {lineno_f+2}, in f_with_binary_operator\n'
441+
' return 10 + divisor // 0 + 30\n'
442+
' ~~~~~~~~^^~~\n'
443+
)
444+
result_lines = self.get_exception(f_with_binary_operator)
445+
self.assertEqual(result_lines, expected_error.splitlines())
446+
447+
def test_caret_for_subscript(self):
448+
def f_with_subscript():
449+
some_dict = {'x': {'y': None}}
450+
return some_dict['x']['y']['z']
451+
452+
lineno_f = f_with_subscript.__code__.co_firstlineno
453+
expected_error = (
454+
'Traceback (most recent call last):\n'
455+
f' File "{__file__}", line {self.callable_line}, in get_exception\n'
456+
' callable()\n'
457+
' ^^^^^^^^^^\n'
458+
f' File "{__file__}", line {lineno_f+2}, in f_with_subscript\n'
459+
" return some_dict['x']['y']['z']\n"
460+
' ~~~~~~~~~~~~~~~~~~~^^^^^\n'
461+
)
462+
result_lines = self.get_exception(f_with_subscript)
463+
self.assertEqual(result_lines, expected_error.splitlines())
464+
465+
def test_traceback_specialization_with_syntax_error(self):
466+
bytecode = compile("1 / 0 / 1 / 2\n", TESTFN, "exec")
467+
468+
with open(TESTFN, "w") as file:
469+
# make the file's contents invalid
470+
file.write("1 $ 0 / 1 / 2\n")
471+
self.addCleanup(unlink, TESTFN)
472+
473+
func = partial(exec, bytecode)
474+
result_lines = self.get_exception(func)
475+
476+
lineno_f = bytecode.co_firstlineno
477+
expected_error = (
478+
'Traceback (most recent call last):\n'
479+
f' File "{__file__}", line {self.callable_line}, in get_exception\n'
480+
' callable()\n'
481+
' ^^^^^^^^^^\n'
482+
f' File "{TESTFN}", line {lineno_f}, in <module>\n'
483+
" 1 $ 0 / 1 / 2\n"
484+
' ^^^^^\n'
485+
)
486+
self.assertEqual(result_lines, expected_error.splitlines())
409487

410488
@cpython_only
411489
@requires_debug_ranges()
@@ -1615,7 +1693,7 @@ def f():
16151693
self.assertEqual(
16161694
output.getvalue().split('\n')[-5:],
16171695
[' x/0',
1618-
' ^^^',
1696+
' ~^~',
16191697
' x = 12',
16201698
'ZeroDivisionError: division by zero',
16211699
''])

Lib/traceback.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -494,9 +494,23 @@ def format(self):
494494
colno = _byte_offset_to_character_offset(frame._original_line, frame.colno)
495495
end_colno = _byte_offset_to_character_offset(frame._original_line, frame.end_colno)
496496

497+
try:
498+
anchors = _extract_caret_anchors_from_line_segment(
499+
frame._original_line[colno - 1:end_colno]
500+
)
501+
except Exception:
502+
anchors = None
503+
497504
row.append(' ')
498505
row.append(' ' * (colno - stripped_characters))
499-
row.append('^' * (end_colno - colno))
506+
507+
if anchors:
508+
row.append(anchors.primary_char * (anchors.left_end_offset))
509+
row.append(anchors.secondary_char * (anchors.right_start_offset - anchors.left_end_offset))
510+
row.append(anchors.primary_char * (end_colno - colno - anchors.right_start_offset))
511+
else:
512+
row.append('^' * (end_colno - colno))
513+
500514
row.append('\n')
501515

502516
if frame.locals:
@@ -520,6 +534,50 @@ def _byte_offset_to_character_offset(str, offset):
520534
return len(as_utf8[:offset + 1].decode("utf-8"))
521535

522536

537+
_Anchors = collections.namedtuple(
538+
"_Anchors",
539+
[
540+
"left_end_offset",
541+
"right_start_offset",
542+
"primary_char",
543+
"secondary_char",
544+
],
545+
defaults=["~", "^"]
546+
)
547+
548+
def _extract_caret_anchors_from_line_segment(segment):
549+
import ast
550+
551+
try:
552+
tree = ast.parse(segment)
553+
except SyntaxError:
554+
return None
555+
556+
if len(tree.body) != 1:
557+
return None
558+
559+
statement = tree.body[0]
560+
match statement:
561+
case ast.Expr(expr):
562+
match expr:
563+
case ast.BinOp():
564+
operator_str = segment[expr.left.end_col_offset:expr.right.col_offset]
565+
operator_offset = len(operator_str) - len(operator_str.lstrip())
566+
567+
left_anchor = expr.left.end_col_offset + operator_offset
568+
right_anchor = left_anchor + 1
569+
if (
570+
operator_offset + 1 < len(operator_str)
571+
and not operator_str[operator_offset + 1].isspace()
572+
):
573+
right_anchor += 1
574+
return _Anchors(left_anchor, right_anchor)
575+
case ast.Subscript():
576+
return _Anchors(expr.value.end_col_offset, expr.slice.end_col_offset + 1)
577+
578+
return None
579+
580+
523581
class TracebackException:
524582
"""An exception ready for rendering.
525583

0 commit comments

Comments
 (0)