]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-112730: Use color to highlight error locations (gh-112732)
authorPablo Galindo Salgado <Pablogsal@gmail.com>
Wed, 6 Dec 2023 22:29:54 +0000 (22:29 +0000)
committerGitHub <noreply@github.com>
Wed, 6 Dec 2023 22:29:54 +0000 (23:29 +0100)
Signed-off-by: Pablo Galindo <pablogsal@gmail.com>
Co-authored-by: Łukasz Langa <lukasz@langa.pl>
Doc/using/cmdline.rst
Doc/whatsnew/3.13.rst
Lib/test/test_traceback.py
Lib/traceback.py
Misc/NEWS.d/next/Core and Builtins/2023-12-04-23-09-07.gh-issue-112730.BXHlFa.rst [new file with mode: 0644]
Modules/clinic/posixmodule.c.h
Modules/posixmodule.c
Python/initconfig.c

index 39c8d114f1e2c5e77bfeb9f32dac87896a12e71e..56235bf4c28c7c29ae1838b7747c6f5af5722a05 100644 (file)
@@ -612,6 +612,27 @@ Miscellaneous options
    .. versionadded:: 3.13
       The ``-X presite`` option.
 
+Controlling Color
+~~~~~~~~~~~~~~~~~
+
+The Python interpreter is configured by default to use colors to highlight
+output in certain situations such as when displaying tracebacks. This
+behavior can be controlled by setting different environment variables.
+
+Setting the environment variable ``TERM`` to ``dumb`` will disable color.
+
+If the environment variable ``FORCE_COLOR`` is set, then color will be
+enabled regardless of the value of TERM. This is useful on CI systems which
+aren’t terminals but can none-the-less display ANSI escape sequences.
+
+If the environment variable ``NO_COLOR`` is set, Python will disable all color
+in the output. This takes precedence over ``FORCE_COLOR``.
+
+All these environment variables are used also by other tools to control color
+output. To control the color output only in the Python interpreter, the
+:envvar:`PYTHON_COLORS` environment variable can be used. This variable takes
+precedence over ``NO_COLOR``, which in turn takes precedence over
+``FORCE_COLOR``.
 
 Options you shouldn't use
 ~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -1110,6 +1131,12 @@ conflict.
 
    .. versionadded:: 3.13
 
+.. envvar:: PYTHON_COLORS
+
+   If this variable is set to ``1``, the interpreter will colorize various kinds
+   of output. Setting it to ``0`` deactivates this behavior.
+
+   .. versionadded:: 3.13
 
 Debug-mode variables
 ~~~~~~~~~~~~~~~~~~~~
index f5723e050a2762e49489d2c8a82c4bf572129a75..9adf7a3893bd7081b6818c2db154d66a0a93eb07 100644 (file)
@@ -85,7 +85,13 @@ Important deprecations, removals or restrictions:
 New Features
 ============
 
+Improved Error Messages
+-----------------------
 
+* The interpreter now colorizes error messages when displaying tracebacks by default.
+  This feature can be controlled via the new :envvar:`PYTHON_COLORS` environment
+  variable as well as the canonical ``NO_COLOR`` and ``FORCE_COLOR`` environment
+  variables. (Contributed by Pablo Galindo Salgado in :gh:`112730`.)
 
 Other Language Changes
 ======================
index b60e06ff37f494a7ece5fb139b8c69ba87a2172b..a6708119b8119114aa0543cc88fed8876f1aeb82 100644 (file)
@@ -8,6 +8,7 @@ import types
 import inspect
 import builtins
 import unittest
+import unittest.mock
 import re
 import tempfile
 import random
@@ -24,6 +25,7 @@ from test.support.import_helper import forget
 import json
 import textwrap
 import traceback
+import contextlib
 from functools import partial
 from pathlib import Path
 
@@ -41,6 +43,14 @@ LEVENSHTEIN_DATA_FILE = Path(__file__).parent / 'levenshtein_examples.json'
 class TracebackCases(unittest.TestCase):
     # For now, a very minimal set of tests.  I want to be sure that
     # formatting of SyntaxErrors works based on changes for 2.1.
+    def setUp(self):
+        super().setUp()
+        self.colorize = traceback._COLORIZE
+        traceback._COLORIZE = False
+
+    def tearDown(self):
+        super().tearDown()
+        traceback._COLORIZE = self.colorize
 
     def get_exception_format(self, func, exc):
         try:
@@ -521,7 +531,7 @@ class TracebackCases(unittest.TestCase):
         self.assertEqual(
             str(inspect.signature(traceback.print_exception)),
             ('(exc, /, value=<implicit>, tb=<implicit>, '
-             'limit=None, file=None, chain=True)'))
+             'limit=None, file=None, chain=True, **kwargs)'))
 
         self.assertEqual(
             str(inspect.signature(traceback.format_exception)),
@@ -3031,7 +3041,7 @@ class TestStack(unittest.TestCase):
 
     def test_custom_format_frame(self):
         class CustomStackSummary(traceback.StackSummary):
-            def format_frame_summary(self, frame_summary):
+            def format_frame_summary(self, frame_summary, colorize=False):
                 return f'{frame_summary.filename}:{frame_summary.lineno}'
 
         def some_inner():
@@ -3056,7 +3066,7 @@ class TestStack(unittest.TestCase):
         tb = g()
 
         class Skip_G(traceback.StackSummary):
-            def format_frame_summary(self, frame_summary):
+            def format_frame_summary(self, frame_summary, colorize=False):
                 if frame_summary.name == 'g':
                     return None
                 return super().format_frame_summary(frame_summary)
@@ -3076,7 +3086,6 @@ class Unrepresentable:
         raise Exception("Unrepresentable")
 
 class TestTracebackException(unittest.TestCase):
-
     def do_test_smoke(self, exc, expected_type_str):
         try:
             raise exc
@@ -4245,6 +4254,115 @@ class MiscTest(unittest.TestCase):
                 res3 = traceback._levenshtein_distance(a, b, threshold)
                 self.assertGreater(res3, threshold, msg=(a, b, threshold))
 
+class TestColorizedTraceback(unittest.TestCase):
+    def test_colorized_traceback(self):
+        def foo(*args):
+            x = {'a':{'b': None}}
+            y = x['a']['b']['c']
+
+        def baz(*args):
+            return foo(1,2,3,4)
+
+        def bar():
+            return baz(1,
+                    2,3
+                    ,4)
+        try:
+            bar()
+        except Exception as e:
+            exc = traceback.TracebackException.from_exception(
+                e, capture_locals=True
+            )
+        lines = "".join(exc.format(colorize=True))
+        red = traceback._ANSIColors.RED
+        boldr = traceback._ANSIColors.BOLD_RED
+        reset = traceback._ANSIColors.RESET
+        self.assertIn("y = " + red + "x['a']['b']" + reset + boldr + "['c']" + reset, lines)
+        self.assertIn("return " + red + "foo" + reset + boldr + "(1,2,3,4)" + reset, lines)
+        self.assertIn("return " + red + "baz" + reset + boldr + "(1," + reset, lines)
+        self.assertIn(boldr + "2,3" + reset, lines)
+        self.assertIn(boldr + ",4)" + reset, lines)
+        self.assertIn(red + "bar" + reset + boldr + "()" + reset, lines)
+
+    def test_colorized_syntax_error(self):
+        try:
+            compile("a $ b", "<string>", "exec")
+        except SyntaxError as e:
+            exc = traceback.TracebackException.from_exception(
+                e, capture_locals=True
+            )
+        actual = "".join(exc.format(colorize=True))
+        red = traceback._ANSIColors.RED
+        magenta = traceback._ANSIColors.MAGENTA
+        boldm = traceback._ANSIColors.BOLD_MAGENTA
+        boldr = traceback._ANSIColors.BOLD_RED
+        reset = traceback._ANSIColors.RESET
+        expected = "".join([
+        f'  File {magenta}"<string>"{reset}, line {magenta}1{reset}\n',
+        f'    a {boldr}${reset} b\n',
+        f'      {boldr}^{reset}\n',
+        f'{boldm}SyntaxError{reset}: {magenta}invalid syntax{reset}\n']
+        )
+        self.assertIn(expected, actual)
+
+    def test_colorized_traceback_is_the_default(self):
+        def foo():
+            1/0
+
+        from _testcapi import exception_print
+        try:
+            foo()
+            self.fail("No exception thrown.")
+        except Exception as e:
+            with captured_output("stderr") as tbstderr:
+                with unittest.mock.patch('traceback._can_colorize', return_value=True):
+                    exception_print(e)
+            actual = tbstderr.getvalue().splitlines()
+
+        red = traceback._ANSIColors.RED
+        boldr = traceback._ANSIColors.BOLD_RED
+        magenta = traceback._ANSIColors.MAGENTA
+        boldm = traceback._ANSIColors.BOLD_MAGENTA
+        reset = traceback._ANSIColors.RESET
+        lno_foo = foo.__code__.co_firstlineno
+        expected = ['Traceback (most recent call last):',
+            f'  File {magenta}"{__file__}"{reset}, '
+            f'line {magenta}{lno_foo+5}{reset}, in {magenta}test_colorized_traceback_is_the_default{reset}',
+            f'    {red}foo{reset+boldr}(){reset}',
+            f'    {red}~~~{reset+boldr}^^{reset}',
+            f'  File {magenta}"{__file__}"{reset}, '
+            f'line {magenta}{lno_foo+1}{reset}, in {magenta}foo{reset}',
+            f'    {red}1{reset+boldr}/{reset+red}0{reset}',
+            f'    {red}~{reset+boldr}^{reset+red}~{reset}',
+            f'{boldm}ZeroDivisionError{reset}: {magenta}division by zero{reset}']
+        self.assertEqual(actual, expected)
+
+    def test_colorized_detection_checks_for_environment_variables(self):
+        if sys.platform == "win32":
+            virtual_patching = unittest.mock.patch("nt._supports_virtual_terminal", return_value=True)
+        else:
+            virtual_patching = contextlib.nullcontext()
+        with virtual_patching:
+            with unittest.mock.patch("os.isatty") as isatty_mock:
+                isatty_mock.return_value = True
+                with unittest.mock.patch("os.environ", {'TERM': 'dumb'}):
+                    self.assertEqual(traceback._can_colorize(), False)
+                with unittest.mock.patch("os.environ", {'PYTHON_COLORS': '1'}):
+                    self.assertEqual(traceback._can_colorize(), True)
+                with unittest.mock.patch("os.environ", {'PYTHON_COLORS': '0'}):
+                    self.assertEqual(traceback._can_colorize(), False)
+                with unittest.mock.patch("os.environ", {'NO_COLOR': '1'}):
+                    self.assertEqual(traceback._can_colorize(), False)
+                with unittest.mock.patch("os.environ", {'NO_COLOR': '1', "PYTHON_COLORS": '1'}):
+                    self.assertEqual(traceback._can_colorize(), True)
+                with unittest.mock.patch("os.environ", {'FORCE_COLOR': '1'}):
+                    self.assertEqual(traceback._can_colorize(), True)
+                with unittest.mock.patch("os.environ", {'FORCE_COLOR': '1', 'NO_COLOR': '1'}):
+                    self.assertEqual(traceback._can_colorize(), False)
+                with unittest.mock.patch("os.environ", {'FORCE_COLOR': '1', "PYTHON_COLORS": '0'}):
+                    self.assertEqual(traceback._can_colorize(), False)
+                isatty_mock.return_value = False
+                self.assertEqual(traceback._can_colorize(), False)
 
 if __name__ == "__main__":
     unittest.main()
index a0485a7023d07d794a722da41f0593595a8fa718..1cf008c7e9da9796b1388ef67840861ea1cdb327 100644 (file)
@@ -1,5 +1,7 @@
 """Extract, format and print information about Python stack traces."""
 
+import os
+import io
 import collections.abc
 import itertools
 import linecache
@@ -19,6 +21,8 @@ __all__ = ['extract_stack', 'extract_tb', 'format_exception',
 # Formatting and printing lists of traceback lines.
 #
 
+_COLORIZE = True
+
 def print_list(extracted_list, file=None):
     """Print the list of tuples as returned by extract_tb() or
     extract_stack() as a formatted stack trace to the given file."""
@@ -110,7 +114,7 @@ def _parse_value_tb(exc, value, tb):
 
 
 def print_exception(exc, /, value=_sentinel, tb=_sentinel, limit=None, \
-                    file=None, chain=True):
+                    file=None, chain=True, **kwargs):
     """Print exception up to 'limit' stack trace entries from 'tb' to 'file'.
 
     This differs from print_tb() in the following ways: (1) if
@@ -121,17 +125,44 @@ def print_exception(exc, /, value=_sentinel, tb=_sentinel, limit=None, \
     occurred with a caret on the next line indicating the approximate
     position of the error.
     """
+    colorize = kwargs.get("colorize", False)
     value, tb = _parse_value_tb(exc, value, tb)
     te = TracebackException(type(value), value, tb, limit=limit, compact=True)
-    te.print(file=file, chain=chain)
+    te.print(file=file, chain=chain, colorize=colorize)
 
 
 BUILTIN_EXCEPTION_LIMIT = object()
 
+def _can_colorize():
+    if sys.platform == "win32":
+        try:
+            import nt
+            if not nt._supports_virtual_terminal():
+                return False
+        except (ImportError, AttributeError):
+            return False
+
+    if os.environ.get("PYTHON_COLORS") == "0":
+        return False
+    if os.environ.get("PYTHON_COLORS") == "1":
+        return True
+    if "NO_COLOR" in os.environ:
+        return False
+    if not _COLORIZE:
+        return False
+    if "FORCE_COLOR" in os.environ:
+        return True
+    if os.environ.get("TERM") == "dumb":
+        return False
+    try:
+        return os.isatty(sys.stderr.fileno())
+    except io.UnsupportedOperation:
+        return sys.stderr.isatty()
 
 def _print_exception_bltin(exc, /):
     file = sys.stderr if sys.stderr is not None else sys.__stderr__
-    return print_exception(exc, limit=BUILTIN_EXCEPTION_LIMIT, file=file)
+    colorize = _can_colorize()
+    return print_exception(exc, limit=BUILTIN_EXCEPTION_LIMIT, file=file, colorize=colorize)
 
 
 def format_exception(exc, /, value=_sentinel, tb=_sentinel, limit=None, \
@@ -172,13 +203,19 @@ def format_exception_only(exc, /, value=_sentinel, *, show_group=False):
 
 # -- not official API but folk probably use these two functions.
 
-def _format_final_exc_line(etype, value, *, insert_final_newline=True):
+def _format_final_exc_line(etype, value, *, insert_final_newline=True, colorize=False):
     valuestr = _safe_string(value, 'exception')
     end_char = "\n" if insert_final_newline else ""
-    if value is None or not valuestr:
-        line = f"{etype}{end_char}"
+    if colorize:
+        if value is None or not valuestr:
+            line = f"{_ANSIColors.BOLD_MAGENTA}{etype}{_ANSIColors.RESET}{end_char}"
+        else:
+            line = f"{_ANSIColors.BOLD_MAGENTA}{etype}{_ANSIColors.RESET}: {_ANSIColors.MAGENTA}{valuestr}{_ANSIColors.RESET}{end_char}"
     else:
-        line = f"{etype}: {valuestr}{end_char}"
+        if value is None or not valuestr:
+            line = f"{etype}{end_char}"
+        else:
+            line = f"{etype}: {valuestr}{end_char}"
     return line
 
 def _safe_string(value, what, func=str):
@@ -406,6 +443,14 @@ def _get_code_position(code, instruction_index):
 
 _RECURSIVE_CUTOFF = 3 # Also hardcoded in traceback.c.
 
+class _ANSIColors:
+    RED = '\x1b[31m'
+    BOLD_RED = '\x1b[1;31m'
+    MAGENTA = '\x1b[35m'
+    BOLD_MAGENTA = '\x1b[1;35m'
+    GREY = '\x1b[90m'
+    RESET = '\x1b[0m'
+
 class StackSummary(list):
     """A list of FrameSummary objects, representing a stack of frames."""
 
@@ -496,18 +541,33 @@ class StackSummary(list):
                 result.append(FrameSummary(filename, lineno, name, line=line))
         return result
 
-    def format_frame_summary(self, frame_summary):
+    def format_frame_summary(self, frame_summary, **kwargs):
         """Format the lines for a single FrameSummary.
 
         Returns a string representing one frame involved in the stack. This
         gets called for every frame to be printed in the stack summary.
         """
+        colorize = kwargs.get("colorize", False)
         row = []
         filename = frame_summary.filename
         if frame_summary.filename.startswith("<stdin>-"):
             filename = "<stdin>"
-        row.append('  File "{}", line {}, in {}\n'.format(
-            filename, frame_summary.lineno, frame_summary.name))
+        if colorize:
+            row.append('  File {}"{}"{}, line {}{}{}, in {}{}{}\n'.format(
+                    _ANSIColors.MAGENTA,
+                    filename,
+                    _ANSIColors.RESET,
+                    _ANSIColors.MAGENTA,
+                    frame_summary.lineno,
+                    _ANSIColors.RESET,
+                    _ANSIColors.MAGENTA,
+                    frame_summary.name,
+                    _ANSIColors.RESET,
+                    )
+            )
+        else:
+            row.append('  File "{}", line {}, in {}\n'.format(
+                filename, frame_summary.lineno, frame_summary.name))
         if frame_summary._dedented_lines and frame_summary._dedented_lines.strip():
             if (
                 frame_summary.colno is None or
@@ -619,7 +679,31 @@ class StackSummary(list):
                             carets.append(secondary_char)
                         else:
                             carets.append(primary_char)
-                    result.append("".join(carets) + "\n")
+                    if colorize:
+                        # Replace the previous line with a red version of it only in the parts covered
+                        # by the carets.
+                        line = result[-1]
+                        colorized_line_parts = []
+                        colorized_carets_parts = []
+
+                        for color, group in itertools.groupby(itertools.zip_longest(line, carets, fillvalue=""), key=lambda x: x[1]):
+                            caret_group = list(group)
+                            if color == "^":
+                                colorized_line_parts.append(_ANSIColors.BOLD_RED + "".join(char for char, _ in caret_group) + _ANSIColors.RESET)
+                                colorized_carets_parts.append(_ANSIColors.BOLD_RED + "".join(caret for _, caret in caret_group) + _ANSIColors.RESET)
+                            elif color == "~":
+                                colorized_line_parts.append(_ANSIColors.RED + "".join(char for char, _ in caret_group) + _ANSIColors.RESET)
+                                colorized_carets_parts.append(_ANSIColors.RED + "".join(caret for _, caret in caret_group) + _ANSIColors.RESET)
+                            else:
+                                colorized_line_parts.append("".join(char for char, _ in caret_group))
+                                colorized_carets_parts.append("".join(caret for _, caret in caret_group))
+
+                        colorized_line = "".join(colorized_line_parts)
+                        colorized_carets = "".join(colorized_carets_parts)
+                        result[-1] = colorized_line
+                        result.append(colorized_carets + "\n")
+                    else:
+                        result.append("".join(carets) + "\n")
 
                 # display significant lines
                 sig_lines_list = sorted(significant_lines)
@@ -643,7 +727,7 @@ class StackSummary(list):
 
         return ''.join(row)
 
-    def format(self):
+    def format(self, **kwargs):
         """Format the stack ready for printing.
 
         Returns a list of strings ready for printing.  Each string in the
@@ -655,13 +739,14 @@ class StackSummary(list):
         repetitions are shown, followed by a summary line stating the exact
         number of further repetitions.
         """
+        colorize = kwargs.get("colorize", False)
         result = []
         last_file = None
         last_line = None
         last_name = None
         count = 0
         for frame_summary in self:
-            formatted_frame = self.format_frame_summary(frame_summary)
+            formatted_frame = self.format_frame_summary(frame_summary, colorize=colorize)
             if formatted_frame is None:
                 continue
             if (last_file is None or last_file != frame_summary.filename or
@@ -1118,7 +1203,7 @@ class TracebackException:
     def __str__(self):
         return self._str
 
-    def format_exception_only(self, *, show_group=False, _depth=0):
+    def format_exception_only(self, *, show_group=False, _depth=0, **kwargs):
         """Format the exception part of the traceback.
 
         The return value is a generator of strings, each ending in a newline.
@@ -1135,10 +1220,11 @@ class TracebackException:
         :exc:`BaseExceptionGroup`, the nested exceptions are included as
         well, recursively, with indentation relative to their nesting depth.
         """
+        colorize = kwargs.get("colorize", False)
 
         indent = 3 * _depth * ' '
         if not self._have_exc_type:
-            yield indent + _format_final_exc_line(None, self._str)
+            yield indent + _format_final_exc_line(None, self._str, colorize=colorize)
             return
 
         stype = self.exc_type_str
@@ -1146,16 +1232,16 @@ class TracebackException:
             if _depth > 0:
                 # Nested exceptions needs correct handling of multiline messages.
                 formatted = _format_final_exc_line(
-                    stype, self._str, insert_final_newline=False,
+                    stype, self._str, insert_final_newline=False, colorize=colorize
                 ).split('\n')
                 yield from [
                     indent + l + '\n'
                     for l in formatted
                 ]
             else:
-                yield _format_final_exc_line(stype, self._str)
+                yield _format_final_exc_line(stype, self._str, colorize=colorize)
         else:
-            yield from [indent + l for l in self._format_syntax_error(stype)]
+            yield from [indent + l for l in self._format_syntax_error(stype, colorize=colorize)]
 
         if (
             isinstance(self.__notes__, collections.abc.Sequence)
@@ -1169,15 +1255,26 @@ class TracebackException:
 
         if self.exceptions and show_group:
             for ex in self.exceptions:
-                yield from ex.format_exception_only(show_group=show_group, _depth=_depth+1)
+                yield from ex.format_exception_only(show_group=show_group, _depth=_depth+1, colorize=colorize)
 
-    def _format_syntax_error(self, stype):
+    def _format_syntax_error(self, stype, **kwargs):
         """Format SyntaxError exceptions (internal helper)."""
         # Show exactly where the problem was found.
+        colorize = kwargs.get("colorize", False)
         filename_suffix = ''
         if self.lineno is not None:
-            yield '  File "{}", line {}\n'.format(
-                self.filename or "<string>", self.lineno)
+            if colorize:
+                yield '  File {}"{}"{}, line {}{}{}\n'.format(
+                    _ANSIColors.MAGENTA,
+                    self.filename or "<string>",
+                    _ANSIColors.RESET,
+                    _ANSIColors.MAGENTA,
+                    self.lineno,
+                    _ANSIColors.RESET,
+                    )
+            else:
+                yield '  File "{}", line {}\n'.format(
+                    self.filename or "<string>", self.lineno)
         elif self.filename is not None:
             filename_suffix = ' ({})'.format(self.filename)
 
@@ -1189,9 +1286,9 @@ class TracebackException:
             rtext = text.rstrip('\n')
             ltext = rtext.lstrip(' \n\f')
             spaces = len(rtext) - len(ltext)
-            yield '    {}\n'.format(ltext)
-
-            if self.offset is not None:
+            if self.offset is None:
+                yield '    {}\n'.format(ltext)
+            else:
                 offset = self.offset
                 end_offset = self.end_offset if self.end_offset not in {None, 0} else offset
                 if self.text and offset > len(self.text):
@@ -1204,14 +1301,43 @@ class TracebackException:
                 # Convert 1-based column offset to 0-based index into stripped text
                 colno = offset - 1 - spaces
                 end_colno = end_offset - 1 - spaces
+                caretspace = ' '
                 if colno >= 0:
                     # non-space whitespace (likes tabs) must be kept for alignment
                     caretspace = ((c if c.isspace() else ' ') for c in ltext[:colno])
-                    yield '    {}{}'.format("".join(caretspace), ('^' * (end_colno - colno) + "\n"))
+                    start_color = end_color = ""
+                    if colorize:
+                        # colorize from colno to end_colno
+                        ltext = (
+                            ltext[:colno] +
+                            _ANSIColors.BOLD_RED + ltext[colno:end_colno] + _ANSIColors.RESET +
+                            ltext[end_colno:]
+                        )
+                        start_color = _ANSIColors.BOLD_RED
+                        end_color = _ANSIColors.RESET
+                    yield '    {}\n'.format(ltext)
+                    yield '    {}{}{}{}\n'.format(
+                        "".join(caretspace),
+                        start_color,
+                        ('^' * (end_colno - colno)),
+                        end_color,
+                    )
+                else:
+                    yield '    {}\n'.format(ltext)
         msg = self.msg or "<no detail available>"
-        yield "{}: {}{}\n".format(stype, msg, filename_suffix)
+        if colorize:
+            yield "{}{}{}: {}{}{}{}\n".format(
+                _ANSIColors.BOLD_MAGENTA,
+                stype,
+                _ANSIColors.RESET,
+                _ANSIColors.MAGENTA,
+                msg,
+                _ANSIColors.RESET,
+                filename_suffix)
+        else:
+            yield "{}: {}{}\n".format(stype, msg, filename_suffix)
 
-    def format(self, *, chain=True, _ctx=None):
+    def format(self, *, chain=True, _ctx=None, **kwargs):
         """Format the exception.
 
         If chain is not *True*, *__cause__* and *__context__* will not be formatted.
@@ -1223,7 +1349,7 @@ class TracebackException:
         The message indicating which exception occurred is always the last
         string in the output.
         """
-
+        colorize = kwargs.get("colorize", False)
         if _ctx is None:
             _ctx = _ExceptionPrintContext()
 
@@ -1253,8 +1379,8 @@ class TracebackException:
             if exc.exceptions is None:
                 if exc.stack:
                     yield from _ctx.emit('Traceback (most recent call last):\n')
-                    yield from _ctx.emit(exc.stack.format())
-                yield from _ctx.emit(exc.format_exception_only())
+                    yield from _ctx.emit(exc.stack.format(colorize=colorize))
+                yield from _ctx.emit(exc.format_exception_only(colorize=colorize))
             elif _ctx.exception_group_depth > self.max_group_depth:
                 # exception group, but depth exceeds limit
                 yield from _ctx.emit(
@@ -1269,9 +1395,9 @@ class TracebackException:
                     yield from _ctx.emit(
                         'Exception Group Traceback (most recent call last):\n',
                         margin_char = '+' if is_toplevel else None)
-                    yield from _ctx.emit(exc.stack.format())
+                    yield from _ctx.emit(exc.stack.format(colorize=colorize))
 
-                yield from _ctx.emit(exc.format_exception_only())
+                yield from _ctx.emit(exc.format_exception_only(colorize=colorize))
                 num_excs = len(exc.exceptions)
                 if num_excs <= self.max_group_width:
                     n = num_excs
@@ -1312,11 +1438,12 @@ class TracebackException:
                     _ctx.exception_group_depth = 0
 
 
-    def print(self, *, file=None, chain=True):
+    def print(self, *, file=None, chain=True, **kwargs):
         """Print the result of self.format(chain=chain) to 'file'."""
+        colorize = kwargs.get("colorize", False)
         if file is None:
             file = sys.stderr
-        for line in self.format(chain=chain):
+        for line in self.format(chain=chain, colorize=colorize):
             print(line, file=file, end="")
 
 
diff --git a/Misc/NEWS.d/next/Core and Builtins/2023-12-04-23-09-07.gh-issue-112730.BXHlFa.rst b/Misc/NEWS.d/next/Core and Builtins/2023-12-04-23-09-07.gh-issue-112730.BXHlFa.rst
new file mode 100644 (file)
index 0000000..51758dd
--- /dev/null
@@ -0,0 +1 @@
+Use color to highlight error locations in tracebacks. Patch by Pablo Galindo
index a6c76370f241be1eb5b6b8fadd2159daa1280aa7..9d6cd337f4a2f4f4299dd8fef0aff619716c160c 100644 (file)
@@ -11756,6 +11756,28 @@ exit:
 
 #endif /* (defined(WIFEXITED) || defined(MS_WINDOWS)) */
 
+#if defined(MS_WINDOWS)
+
+PyDoc_STRVAR(os__supports_virtual_terminal__doc__,
+"_supports_virtual_terminal($module, /)\n"
+"--\n"
+"\n"
+"Checks if virtual terminal is supported in windows");
+
+#define OS__SUPPORTS_VIRTUAL_TERMINAL_METHODDEF    \
+    {"_supports_virtual_terminal", (PyCFunction)os__supports_virtual_terminal, METH_NOARGS, os__supports_virtual_terminal__doc__},
+
+static PyObject *
+os__supports_virtual_terminal_impl(PyObject *module);
+
+static PyObject *
+os__supports_virtual_terminal(PyObject *module, PyObject *Py_UNUSED(ignored))
+{
+    return os__supports_virtual_terminal_impl(module);
+}
+
+#endif /* defined(MS_WINDOWS) */
+
 #ifndef OS_TTYNAME_METHODDEF
     #define OS_TTYNAME_METHODDEF
 #endif /* !defined(OS_TTYNAME_METHODDEF) */
@@ -12395,4 +12417,8 @@ exit:
 #ifndef OS_WAITSTATUS_TO_EXITCODE_METHODDEF
     #define OS_WAITSTATUS_TO_EXITCODE_METHODDEF
 #endif /* !defined(OS_WAITSTATUS_TO_EXITCODE_METHODDEF) */
-/*[clinic end generated code: output=2900675ac5219924 input=a9049054013a1b77]*/
+
+#ifndef OS__SUPPORTS_VIRTUAL_TERMINAL_METHODDEF
+    #define OS__SUPPORTS_VIRTUAL_TERMINAL_METHODDEF
+#endif /* !defined(OS__SUPPORTS_VIRTUAL_TERMINAL_METHODDEF) */
+/*[clinic end generated code: output=ff0ec3371de19904 input=a9049054013a1b77]*/
index 70d107a297f315d8df7194343c223a34b9b8ec0d..ddbb4cd43babfcf197e467cf7cdb9c08999012e3 100644 (file)
@@ -16073,6 +16073,26 @@ os_waitstatus_to_exitcode_impl(PyObject *module, PyObject *status_obj)
 }
 #endif
 
+#if defined(MS_WINDOWS)
+/*[clinic input]
+os._supports_virtual_terminal
+
+Checks if virtual terminal is supported in windows
+[clinic start generated code]*/
+
+static PyObject *
+os__supports_virtual_terminal_impl(PyObject *module)
+/*[clinic end generated code: output=bd0556a6d9d99fe6 input=0752c98e5d321542]*/
+{
+    DWORD mode = 0;
+    HANDLE handle = GetStdHandle(STD_ERROR_HANDLE);
+    if (!GetConsoleMode(handle, &mode)) {
+        Py_RETURN_FALSE;
+    }
+    return PyBool_FromLong(mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING);
+}
+#endif
+
 
 static PyMethodDef posix_methods[] = {
 
@@ -16277,6 +16297,8 @@ static PyMethodDef posix_methods[] = {
     OS__PATH_ISFILE_METHODDEF
     OS__PATH_ISLINK_METHODDEF
     OS__PATH_EXISTS_METHODDEF
+
+    OS__SUPPORTS_VIRTUAL_TERMINAL_METHODDEF
     {NULL,              NULL}            /* Sentinel */
 };
 
index d7f3195ed5fcf0f4ee9e17b704b0e3af69adb1d5..06e317907b8ec960ab2243c8e83c2d2f0b1aa647 100644 (file)
@@ -293,6 +293,8 @@ static const char usage_envvars[] =
 "PYTHON_FROZEN_MODULES   : if this variable is set, it determines whether or not \n"
 "   frozen modules should be used. The default is \"on\" (or \"off\" if you are \n"
 "   running a local build).\n"
+"PYTHON_COLORS           : If this variable is set to 1, the interpreter will"
+"   colorize various kinds of output. Setting it to 0 deactivates this behavior.\n"
 "These variables have equivalent command-line parameters (see --help for details):\n"
 "PYTHONDEBUG             : enable parser debug mode (-d)\n"
 "PYTHONDONTWRITEBYTECODE : don't write .pyc files (-B)\n"