Skip to content

Commit 37e5f1e

Browse files
pablogsalambv
authored andcommitted
pythongh-112730: Use color to highlight error locations (pythongh-112732)
Signed-off-by: Pablo Galindo <[email protected]> Co-authored-by: Łukasz Langa <[email protected]>
1 parent d9deae3 commit 37e5f1e

File tree

8 files changed

+369
-40
lines changed

8 files changed

+369
-40
lines changed

Diff for: Doc/using/cmdline.rst

+27
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,27 @@ Miscellaneous options
612612
.. versionadded:: 3.13
613613
The ``-X presite`` option.
614614

615+
Controlling Color
616+
~~~~~~~~~~~~~~~~~
617+
618+
The Python interpreter is configured by default to use colors to highlight
619+
output in certain situations such as when displaying tracebacks. This
620+
behavior can be controlled by setting different environment variables.
621+
622+
Setting the environment variable ``TERM`` to ``dumb`` will disable color.
623+
624+
If the environment variable ``FORCE_COLOR`` is set, then color will be
625+
enabled regardless of the value of TERM. This is useful on CI systems which
626+
aren’t terminals but can none-the-less display ANSI escape sequences.
627+
628+
If the environment variable ``NO_COLOR`` is set, Python will disable all color
629+
in the output. This takes precedence over ``FORCE_COLOR``.
630+
631+
All these environment variables are used also by other tools to control color
632+
output. To control the color output only in the Python interpreter, the
633+
:envvar:`PYTHON_COLORS` environment variable can be used. This variable takes
634+
precedence over ``NO_COLOR``, which in turn takes precedence over
635+
``FORCE_COLOR``.
615636

616637
Options you shouldn't use
617638
~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -1110,6 +1131,12 @@ conflict.
11101131

11111132
.. versionadded:: 3.13
11121133

1134+
.. envvar:: PYTHON_COLORS
1135+
1136+
If this variable is set to ``1``, the interpreter will colorize various kinds
1137+
of output. Setting it to ``0`` deactivates this behavior.
1138+
1139+
.. versionadded:: 3.13
11131140

11141141
Debug-mode variables
11151142
~~~~~~~~~~~~~~~~~~~~

Diff for: Doc/whatsnew/3.13.rst

+6
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,13 @@ Important deprecations, removals or restrictions:
8585
New Features
8686
============
8787

88+
Improved Error Messages
89+
-----------------------
8890

91+
* The interpreter now colorizes error messages when displaying tracebacks by default.
92+
This feature can be controlled via the new :envvar:`PYTHON_COLORS` environment
93+
variable as well as the canonical ``NO_COLOR`` and ``FORCE_COLOR`` environment
94+
variables. (Contributed by Pablo Galindo Salgado in :gh:`112730`.)
8995

9096
Other Language Changes
9197
======================

Diff for: Lib/test/test_traceback.py

+122-4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import inspect
99
import builtins
1010
import unittest
11+
import unittest.mock
1112
import re
1213
import tempfile
1314
import random
@@ -24,6 +25,7 @@
2425
import json
2526
import textwrap
2627
import traceback
28+
import contextlib
2729
from functools import partial
2830
from pathlib import Path
2931

@@ -41,6 +43,14 @@
4143
class TracebackCases(unittest.TestCase):
4244
# For now, a very minimal set of tests. I want to be sure that
4345
# formatting of SyntaxErrors works based on changes for 2.1.
46+
def setUp(self):
47+
super().setUp()
48+
self.colorize = traceback._COLORIZE
49+
traceback._COLORIZE = False
50+
51+
def tearDown(self):
52+
super().tearDown()
53+
traceback._COLORIZE = self.colorize
4454

4555
def get_exception_format(self, func, exc):
4656
try:
@@ -521,7 +531,7 @@ def test_signatures(self):
521531
self.assertEqual(
522532
str(inspect.signature(traceback.print_exception)),
523533
('(exc, /, value=<implicit>, tb=<implicit>, '
524-
'limit=None, file=None, chain=True)'))
534+
'limit=None, file=None, chain=True, **kwargs)'))
525535

526536
self.assertEqual(
527537
str(inspect.signature(traceback.format_exception)),
@@ -3031,7 +3041,7 @@ def some_inner(k, v):
30313041

30323042
def test_custom_format_frame(self):
30333043
class CustomStackSummary(traceback.StackSummary):
3034-
def format_frame_summary(self, frame_summary):
3044+
def format_frame_summary(self, frame_summary, colorize=False):
30353045
return f'{frame_summary.filename}:{frame_summary.lineno}'
30363046

30373047
def some_inner():
@@ -3056,7 +3066,7 @@ def g():
30563066
tb = g()
30573067

30583068
class Skip_G(traceback.StackSummary):
3059-
def format_frame_summary(self, frame_summary):
3069+
def format_frame_summary(self, frame_summary, colorize=False):
30603070
if frame_summary.name == 'g':
30613071
return None
30623072
return super().format_frame_summary(frame_summary)
@@ -3076,7 +3086,6 @@ def __repr__(self) -> str:
30763086
raise Exception("Unrepresentable")
30773087

30783088
class TestTracebackException(unittest.TestCase):
3079-
30803089
def do_test_smoke(self, exc, expected_type_str):
30813090
try:
30823091
raise exc
@@ -4245,6 +4254,115 @@ def test_levenshtein_distance_short_circuit(self):
42454254
res3 = traceback._levenshtein_distance(a, b, threshold)
42464255
self.assertGreater(res3, threshold, msg=(a, b, threshold))
42474256

4257+
class TestColorizedTraceback(unittest.TestCase):
4258+
def test_colorized_traceback(self):
4259+
def foo(*args):
4260+
x = {'a':{'b': None}}
4261+
y = x['a']['b']['c']
4262+
4263+
def baz(*args):
4264+
return foo(1,2,3,4)
4265+
4266+
def bar():
4267+
return baz(1,
4268+
2,3
4269+
,4)
4270+
try:
4271+
bar()
4272+
except Exception as e:
4273+
exc = traceback.TracebackException.from_exception(
4274+
e, capture_locals=True
4275+
)
4276+
lines = "".join(exc.format(colorize=True))
4277+
red = traceback._ANSIColors.RED
4278+
boldr = traceback._ANSIColors.BOLD_RED
4279+
reset = traceback._ANSIColors.RESET
4280+
self.assertIn("y = " + red + "x['a']['b']" + reset + boldr + "['c']" + reset, lines)
4281+
self.assertIn("return " + red + "foo" + reset + boldr + "(1,2,3,4)" + reset, lines)
4282+
self.assertIn("return " + red + "baz" + reset + boldr + "(1," + reset, lines)
4283+
self.assertIn(boldr + "2,3" + reset, lines)
4284+
self.assertIn(boldr + ",4)" + reset, lines)
4285+
self.assertIn(red + "bar" + reset + boldr + "()" + reset, lines)
4286+
4287+
def test_colorized_syntax_error(self):
4288+
try:
4289+
compile("a $ b", "<string>", "exec")
4290+
except SyntaxError as e:
4291+
exc = traceback.TracebackException.from_exception(
4292+
e, capture_locals=True
4293+
)
4294+
actual = "".join(exc.format(colorize=True))
4295+
red = traceback._ANSIColors.RED
4296+
magenta = traceback._ANSIColors.MAGENTA
4297+
boldm = traceback._ANSIColors.BOLD_MAGENTA
4298+
boldr = traceback._ANSIColors.BOLD_RED
4299+
reset = traceback._ANSIColors.RESET
4300+
expected = "".join([
4301+
f' File {magenta}"<string>"{reset}, line {magenta}1{reset}\n',
4302+
f' a {boldr}${reset} b\n',
4303+
f' {boldr}^{reset}\n',
4304+
f'{boldm}SyntaxError{reset}: {magenta}invalid syntax{reset}\n']
4305+
)
4306+
self.assertIn(expected, actual)
4307+
4308+
def test_colorized_traceback_is_the_default(self):
4309+
def foo():
4310+
1/0
4311+
4312+
from _testcapi import exception_print
4313+
try:
4314+
foo()
4315+
self.fail("No exception thrown.")
4316+
except Exception as e:
4317+
with captured_output("stderr") as tbstderr:
4318+
with unittest.mock.patch('traceback._can_colorize', return_value=True):
4319+
exception_print(e)
4320+
actual = tbstderr.getvalue().splitlines()
4321+
4322+
red = traceback._ANSIColors.RED
4323+
boldr = traceback._ANSIColors.BOLD_RED
4324+
magenta = traceback._ANSIColors.MAGENTA
4325+
boldm = traceback._ANSIColors.BOLD_MAGENTA
4326+
reset = traceback._ANSIColors.RESET
4327+
lno_foo = foo.__code__.co_firstlineno
4328+
expected = ['Traceback (most recent call last):',
4329+
f' File {magenta}"{__file__}"{reset}, '
4330+
f'line {magenta}{lno_foo+5}{reset}, in {magenta}test_colorized_traceback_is_the_default{reset}',
4331+
f' {red}foo{reset+boldr}(){reset}',
4332+
f' {red}~~~{reset+boldr}^^{reset}',
4333+
f' File {magenta}"{__file__}"{reset}, '
4334+
f'line {magenta}{lno_foo+1}{reset}, in {magenta}foo{reset}',
4335+
f' {red}1{reset+boldr}/{reset+red}0{reset}',
4336+
f' {red}~{reset+boldr}^{reset+red}~{reset}',
4337+
f'{boldm}ZeroDivisionError{reset}: {magenta}division by zero{reset}']
4338+
self.assertEqual(actual, expected)
4339+
4340+
def test_colorized_detection_checks_for_environment_variables(self):
4341+
if sys.platform == "win32":
4342+
virtual_patching = unittest.mock.patch("nt._supports_virtual_terminal", return_value=True)
4343+
else:
4344+
virtual_patching = contextlib.nullcontext()
4345+
with virtual_patching:
4346+
with unittest.mock.patch("os.isatty") as isatty_mock:
4347+
isatty_mock.return_value = True
4348+
with unittest.mock.patch("os.environ", {'TERM': 'dumb'}):
4349+
self.assertEqual(traceback._can_colorize(), False)
4350+
with unittest.mock.patch("os.environ", {'PYTHON_COLORS': '1'}):
4351+
self.assertEqual(traceback._can_colorize(), True)
4352+
with unittest.mock.patch("os.environ", {'PYTHON_COLORS': '0'}):
4353+
self.assertEqual(traceback._can_colorize(), False)
4354+
with unittest.mock.patch("os.environ", {'NO_COLOR': '1'}):
4355+
self.assertEqual(traceback._can_colorize(), False)
4356+
with unittest.mock.patch("os.environ", {'NO_COLOR': '1', "PYTHON_COLORS": '1'}):
4357+
self.assertEqual(traceback._can_colorize(), True)
4358+
with unittest.mock.patch("os.environ", {'FORCE_COLOR': '1'}):
4359+
self.assertEqual(traceback._can_colorize(), True)
4360+
with unittest.mock.patch("os.environ", {'FORCE_COLOR': '1', 'NO_COLOR': '1'}):
4361+
self.assertEqual(traceback._can_colorize(), False)
4362+
with unittest.mock.patch("os.environ", {'FORCE_COLOR': '1', "PYTHON_COLORS": '0'}):
4363+
self.assertEqual(traceback._can_colorize(), False)
4364+
isatty_mock.return_value = False
4365+
self.assertEqual(traceback._can_colorize(), False)
42484366

42494367
if __name__ == "__main__":
42504368
unittest.main()

0 commit comments

Comments
 (0)