From 9bc06edacb8c55b1cc8c096ea2d61f01596e6a99 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Date: Mon, 4 Dec 2023 23:08:33 +0000 Subject: [PATCH 1/9] gh-112730: Use color to highlight error locations Signed-off-by: Pablo Galindo --- Doc/using/cmdline.rst | 6 + Doc/whatsnew/3.13.rst | 6 + Lib/test/test_traceback.py | 95 +++++++++++++- Lib/traceback.py | 118 ++++++++++++++---- ...-12-04-23-09-07.gh-issue-112730.BXHlFa.rst | 1 + Python/initconfig.c | 2 + 6 files changed, 201 insertions(+), 27 deletions(-) create mode 100644 Misc/NEWS.d/next/Core and Builtins/2023-12-04-23-09-07.gh-issue-112730.BXHlFa.rst diff --git a/Doc/using/cmdline.rst b/Doc/using/cmdline.rst index 39c8d114f1e2c5..519a031667f2a3 100644 --- a/Doc/using/cmdline.rst +++ b/Doc/using/cmdline.rst @@ -1110,6 +1110,12 @@ conflict. .. versionadded:: 3.13 +.. envvar:: PY_COLORS + + If this variable is set to ``1``, the interpreter will colorize different kinds + of output. Setting it to ``0`` deactivates this behavior. + + .. versionadded:: 3.13 Debug-mode variables ~~~~~~~~~~~~~~~~~~~~ diff --git a/Doc/whatsnew/3.13.rst b/Doc/whatsnew/3.13.rst index 372e4a45468e68..d458830de5e2e2 100644 --- a/Doc/whatsnew/3.13.rst +++ b/Doc/whatsnew/3.13.rst @@ -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:`PY_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 ====================== diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index b60e06ff37f494..ba282e4958c5c6 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -8,6 +8,7 @@ import inspect import builtins import unittest +import unittest.mock import re import tempfile import random @@ -41,6 +42,14 @@ 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 +530,7 @@ def test_signatures(self): self.assertEqual( str(inspect.signature(traceback.print_exception)), ('(exc, /, value=, tb=, ' - 'limit=None, file=None, chain=True)')) + 'limit=None, file=None, chain=True, **kwargs)')) self.assertEqual( str(inspect.signature(traceback.format_exception)), @@ -3031,7 +3040,7 @@ def some_inner(k, v): 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 +3065,7 @@ def g(): 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 +3085,6 @@ def __repr__(self) -> str: raise Exception("Unrepresentable") class TestTracebackException(unittest.TestCase): - def do_test_smoke(self, exc, expected_type_str): try: raise exc @@ -4245,6 +4253,85 @@ def test_levenshtein_distance_short_circuit(self): 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_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 + reset = traceback._ANSIColors.RESET + lno_foo = foo.__code__.co_firstlineno + expected = ['Traceback (most recent call last):', + f' File "{__file__}", ' + f'line {lno_foo+5}, in test_colorized_traceback_is_the_default', + f' {red}foo{reset+boldr}(){reset}', + f' {red}~~~{reset+boldr}^^{reset}', + f' File "{__file__}", ' + f'line {lno_foo+1}, in foo', + f' {red}1{reset+boldr}/{reset+red}0{reset}', + f' {red}~{reset+boldr}^{reset+red}~{reset}', + 'ZeroDivisionError: division by zero'] + self.assertEqual(actual, expected) + + def test_colorized_detection_checks_for_environment_variables(self): + with unittest.mock.patch("sys.stderr") as stderr_mock: + stderr_mock.isatty.return_value = True + with unittest.mock.patch("os.environ", {'TERM': 'dumb'}): + self.assertEqual(traceback._can_colorize(), False) + with unittest.mock.patch("os.environ", {'PY_COLORS': '1'}): + self.assertEqual(traceback._can_colorize(), True) + with unittest.mock.patch("os.environ", {'PY_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', "PY_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) + stderr_mock.isatty.return_value = False + self.assertEqual(traceback._can_colorize(), False) if __name__ == "__main__": unittest.main() diff --git a/Lib/traceback.py b/Lib/traceback.py index a0485a7023d07d..167b58f2470d33 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1,5 +1,6 @@ """Extract, format and print information about Python stack traces.""" +import os import collections.abc import itertools import linecache @@ -19,6 +20,8 @@ # 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 +113,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 +124,33 @@ 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 not _COLORIZE: + return False + if os.environ.get("PY_COLORS") == "1": + return True + if os.environ.get("PY_COLORS") == "0": + return False + if "NO_COLOR" in os.environ: + return False + if "FORCE_COLOR" in os.environ: + return True + return ( + hasattr(sys.stderr, "isatty") and sys.stderr.isatty() and os.environ.get("TERM") != "dumb" + ) 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, \ @@ -406,6 +425,11 @@ 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' + RESET = '\x1b[0m' + class StackSummary(list): """A list of FrameSummary objects, representing a stack of frames.""" @@ -496,12 +520,13 @@ def from_list(klass, a_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("-"): @@ -619,7 +644,31 @@ def output_line(lineno): 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(zip(line, carets), 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 + "\n" + result.append(colorized_carets + "\n") + else: + result.append("".join(carets) + "\n") # display significant lines sig_lines_list = sorted(significant_lines) @@ -643,7 +692,7 @@ def output_line(lineno): 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 +704,14 @@ def format(self): 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 +1168,7 @@ def __eq__(self, other): 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,6 +1185,7 @@ def format_exception_only(self, *, show_group=False, _depth=0): :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: @@ -1155,7 +1206,7 @@ def format_exception_only(self, *, show_group=False, _depth=0): else: yield _format_final_exc_line(stype, self._str) 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,11 +1220,12 @@ def format_exception_only(self, *, show_group=False, _depth=0): 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( @@ -1189,9 +1241,9 @@ def _format_syntax_error(self, stype): 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 +1256,33 @@ def _format_syntax_error(self, stype): # 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.RED + ltext[colno:end_colno] + _ANSIColors.RESET + + ltext[end_colno:] + ) + start_color = _ANSIColors.RED + end_color = _ANSIColors.RESET + yield ' {}\n'.format(ltext) + yield ' {}{}{}{}'.format( + "".join(caretspace), + start_color, + ('^' * (end_colno - colno) + "\n"), + end_color, + ) + else: + yield ' {}\n'.format(ltext) msg = self.msg or "" 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 +1294,7 @@ def format(self, *, chain=True, _ctx=None): 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 +1324,8 @@ def format(self, *, chain=True, _ctx=None): 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 +1340,9 @@ def format(self, *, chain=True, _ctx=None): 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 +1383,12 @@ def format(self, *, chain=True, _ctx=None): _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 index 00000000000000..51758dd5f4c318 --- /dev/null +++ b/Misc/NEWS.d/next/Core and Builtins/2023-12-04-23-09-07.gh-issue-112730.BXHlFa.rst @@ -0,0 +1 @@ +Use color to highlight error locations in tracebacks. Patch by Pablo Galindo diff --git a/Python/initconfig.c b/Python/initconfig.c index d7f3195ed5fcf0..f823c61eaf1b11 100644 --- a/Python/initconfig.c +++ b/Python/initconfig.c @@ -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" +"PY_COLORS: If this variable is set to 1, the interpreter will colorize different \n" +" 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" From 7671e2dcbc4bea0a84c002a2734dbc358b7c8bbb Mon Sep 17 00:00:00 2001 From: Pablo Galindo Date: Tue, 5 Dec 2023 12:04:29 +0000 Subject: [PATCH 2/9] Make it work on windows --- Lib/traceback.py | 8 ++++++++ Modules/clinic/posixmodule.c.h | 28 +++++++++++++++++++++++++++- Modules/posixmodule.c | 22 ++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/Lib/traceback.py b/Lib/traceback.py index 167b58f2470d33..d9d8c240f12dee 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -133,6 +133,14 @@ def print_exception(exc, /, value=_sentinel, tb=_sentinel, limit=None, \ 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 not _COLORIZE: return False if os.environ.get("PY_COLORS") == "1": diff --git a/Modules/clinic/posixmodule.c.h b/Modules/clinic/posixmodule.c.h index a6c76370f241be..9d6cd337f4a2f4 100644 --- a/Modules/clinic/posixmodule.c.h +++ b/Modules/clinic/posixmodule.c.h @@ -11756,6 +11756,28 @@ os_waitstatus_to_exitcode(PyObject *module, PyObject *const *args, Py_ssize_t na #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 @@ os_waitstatus_to_exitcode(PyObject *module, PyObject *const *args, Py_ssize_t na #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]*/ diff --git a/Modules/posixmodule.c b/Modules/posixmodule.c index 70d107a297f315..ddbb4cd43babfc 100644 --- a/Modules/posixmodule.c +++ b/Modules/posixmodule.c @@ -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 */ }; From ff19eecfcfe0d9d9d5a7aeb54c8645659c8c7979 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Date: Tue, 5 Dec 2023 12:31:03 +0000 Subject: [PATCH 3/9] Reorder env variable checks --- Lib/test/test_traceback.py | 8 ++++---- Lib/traceback.py | 18 +++++++++++------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index ba282e4958c5c6..f25eb4a405c82c 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -4314,8 +4314,8 @@ def foo(): self.assertEqual(actual, expected) def test_colorized_detection_checks_for_environment_variables(self): - with unittest.mock.patch("sys.stderr") as stderr_mock: - stderr_mock.isatty.return_value = True + 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", {'PY_COLORS': '1'}): @@ -4325,12 +4325,12 @@ def test_colorized_detection_checks_for_environment_variables(self): with unittest.mock.patch("os.environ", {'NO_COLOR': '1'}): self.assertEqual(traceback._can_colorize(), False) with unittest.mock.patch("os.environ", {'NO_COLOR': '1', "PY_COLORS": '1'}): - self.assertEqual(traceback._can_colorize(), True) + self.assertEqual(traceback._can_colorize(), False) 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) - stderr_mock.isatty.return_value = False + isatty_mock.return_value = False self.assertEqual(traceback._can_colorize(), False) if __name__ == "__main__": diff --git a/Lib/traceback.py b/Lib/traceback.py index d9d8c240f12dee..a782a85e1e07fd 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1,6 +1,7 @@ """Extract, format and print information about Python stack traces.""" import os +import io import collections.abc import itertools import linecache @@ -141,19 +142,22 @@ def _can_colorize(): except (ImportError, AttributeError): return False - if not _COLORIZE: + if "NO_COLOR" in os.environ: return False - if os.environ.get("PY_COLORS") == "1": - return True if os.environ.get("PY_COLORS") == "0": return False - if "NO_COLOR" in os.environ: + if not _COLORIZE: return False if "FORCE_COLOR" in os.environ: return True - return ( - hasattr(sys.stderr, "isatty") and sys.stderr.isatty() and os.environ.get("TERM") != "dumb" - ) + if os.environ.get("PY_COLORS") == "1": + 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__ From 9ae4c7735c2df2cdb7039b18461685d4584d2dad Mon Sep 17 00:00:00 2001 From: Pablo Galindo Date: Tue, 5 Dec 2023 12:38:02 +0000 Subject: [PATCH 4/9] Add more docs Signed-off-by: Pablo Galindo --- Doc/using/cmdline.rst | 20 +++++++++++++++++++ Lib/test/test_traceback.py | 40 ++++++++++++++++++++++---------------- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/Doc/using/cmdline.rst b/Doc/using/cmdline.rst index 519a031667f2a3..1396135b9db381 100644 --- a/Doc/using/cmdline.rst +++ b/Doc/using/cmdline.rst @@ -612,6 +612,26 @@ 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:`PY_COLORS` environment variable can be used. This variable takes +less precedence than ``NO_COLOR`` and ``FORCE_COLOR``. Options you shouldn't use ~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index f25eb4a405c82c..e473075a7472b6 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -25,6 +25,7 @@ import json import textwrap import traceback +import contextlib from functools import partial from pathlib import Path @@ -4314,24 +4315,29 @@ def foo(): self.assertEqual(actual, expected) def test_colorized_detection_checks_for_environment_variables(self): - 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", {'PY_COLORS': '1'}): - self.assertEqual(traceback._can_colorize(), True) - with unittest.mock.patch("os.environ", {'PY_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', "PY_COLORS": '1'}): - self.assertEqual(traceback._can_colorize(), False) - 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'}): + 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", {'PY_COLORS': '1'}): + self.assertEqual(traceback._can_colorize(), True) + with unittest.mock.patch("os.environ", {'PY_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', "PY_COLORS": '1'}): + self.assertEqual(traceback._can_colorize(), False) + 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) + isatty_mock.return_value = False self.assertEqual(traceback._can_colorize(), False) - isatty_mock.return_value = False - self.assertEqual(traceback._can_colorize(), False) if __name__ == "__main__": unittest.main() From e4a07172f118ee492f864b39132a4cfeb5f652f8 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Date: Tue, 5 Dec 2023 14:13:48 +0000 Subject: [PATCH 5/9] Add more colors Signed-off-by: Pablo Galindo --- Lib/test/test_traceback.py | 12 +++++---- Lib/traceback.py | 55 ++++++++++++++++++++++++++++++-------- 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index e473075a7472b6..34bef98e0108e0 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -4300,18 +4300,20 @@ def foo(): 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 "{__file__}", ' - f'line {lno_foo+5}, in test_colorized_traceback_is_the_default', + 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 "{__file__}", ' - f'line {lno_foo+1}, in foo', + 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}', - 'ZeroDivisionError: division by zero'] + f'{boldm}ZeroDivisionError{reset}: {magenta}division by zero{reset}'] self.assertEqual(actual, expected) def test_colorized_detection_checks_for_environment_variables(self): diff --git a/Lib/traceback.py b/Lib/traceback.py index a782a85e1e07fd..b185f4a1231f55 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -203,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): @@ -440,6 +446,9 @@ def _get_code_position(code, instruction_index): 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): @@ -543,8 +552,22 @@ def format_frame_summary(self, frame_summary, **kwargs): filename = frame_summary.filename if frame_summary.filename.startswith("-"): filename = "" - 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 @@ -1201,7 +1224,7 @@ def format_exception_only(self, *, show_group=False, _depth=0, **kwargs): 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 @@ -1209,14 +1232,14 @@ def format_exception_only(self, *, show_group=False, _depth=0, **kwargs): 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, colorize=colorize)] @@ -1240,8 +1263,18 @@ def _format_syntax_error(self, stype, **kwargs): colorize = kwargs.get("colorize", False) filename_suffix = '' if self.lineno is not None: - yield ' File "{}", line {}\n'.format( - self.filename or "", self.lineno) + if colorize: + yield ' File {}"{}"{}, line {}{}{}\n'.format( + _ANSIColors.MAGENTA, + self.filename or "", + _ANSIColors.RESET, + _ANSIColors.MAGENTA, + self.lineno, + _ANSIColors.RESET, + ) + else: + yield ' File "{}", line {}\n'.format( + self.filename or "", self.lineno) elif self.filename is not None: filename_suffix = ' ({})'.format(self.filename) From 1838f1c8f5a18792a7b5a0f6c3e44a84348072c3 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Date: Tue, 5 Dec 2023 15:09:49 +0000 Subject: [PATCH 6/9] Add test for syntax error (and fix colorization) --- Lib/test/test_traceback.py | 21 +++++++++++++++++++++ Lib/traceback.py | 20 +++++++++++++++----- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 34bef98e0108e0..7973846bd23616 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -4284,6 +4284,27 @@ def bar(): self.assertIn(boldr + ",4)" + reset, lines) self.assertIn(red + "bar" + reset + boldr + "()" + reset, lines) + def test_colorized_syntax_error(self): + try: + compile("a $ b", "", "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}""{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 diff --git a/Lib/traceback.py b/Lib/traceback.py index b185f4a1231f55..de8749b36f4bc1 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1310,22 +1310,32 @@ def _format_syntax_error(self, stype, **kwargs): # colorize from colno to end_colno ltext = ( ltext[:colno] + - _ANSIColors.RED + ltext[colno:end_colno] + _ANSIColors.RESET + + _ANSIColors.BOLD_RED + ltext[colno:end_colno] + _ANSIColors.RESET + ltext[end_colno:] ) - start_color = _ANSIColors.RED + start_color = _ANSIColors.BOLD_RED end_color = _ANSIColors.RESET yield ' {}\n'.format(ltext) - yield ' {}{}{}{}'.format( + yield ' {}{}{}{}\n'.format( "".join(caretspace), start_color, - ('^' * (end_colno - colno) + "\n"), + ('^' * (end_colno - colno)), end_color, ) else: yield ' {}\n'.format(ltext) msg = self.msg or "" - 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, **kwargs): """Format the exception. From a84f929b633ca64d97a3a4a66a807f719ea58420 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Date: Wed, 6 Dec 2023 00:25:42 +0000 Subject: [PATCH 7/9] Fix multiline output --- Lib/traceback.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/traceback.py b/Lib/traceback.py index de8749b36f4bc1..15887eb3538cd7 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -686,7 +686,7 @@ def output_line(lineno): colorized_line_parts = [] colorized_carets_parts = [] - for color, group in itertools.groupby(zip(line, carets), key=lambda x: x[1]): + 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) @@ -700,7 +700,7 @@ def output_line(lineno): colorized_line = "".join(colorized_line_parts) colorized_carets = "".join(colorized_carets_parts) - result[-1] = colorized_line + "\n" + result[-1] = colorized_line result.append(colorized_carets + "\n") else: result.append("".join(carets) + "\n") From 8a071637a0767d1131e7015da3a0e90f949fe65d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Wed, 6 Dec 2023 22:24:03 +0100 Subject: [PATCH 8/9] s/PY_COLORS/PYTHON_COLORS/ --- Doc/using/cmdline.rst | 6 +++--- Doc/whatsnew/3.13.rst | 6 +++--- Lib/test/test_traceback.py | 6 +++--- Lib/traceback.py | 4 ++-- Python/initconfig.c | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Doc/using/cmdline.rst b/Doc/using/cmdline.rst index 1396135b9db381..4eb288216f2cd4 100644 --- a/Doc/using/cmdline.rst +++ b/Doc/using/cmdline.rst @@ -630,7 +630,7 @@ 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:`PY_COLORS` environment variable can be used. This variable takes +:envvar:`PYTHON_COLORS` environment variable can be used. This variable takes less precedence than ``NO_COLOR`` and ``FORCE_COLOR``. Options you shouldn't use @@ -1130,9 +1130,9 @@ conflict. .. versionadded:: 3.13 -.. envvar:: PY_COLORS +.. envvar:: PYTHON_COLORS - If this variable is set to ``1``, the interpreter will colorize different kinds + 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 diff --git a/Doc/whatsnew/3.13.rst b/Doc/whatsnew/3.13.rst index 7de6029d5df866..9adf7a3893bd70 100644 --- a/Doc/whatsnew/3.13.rst +++ b/Doc/whatsnew/3.13.rst @@ -89,9 +89,9 @@ Improved Error Messages ----------------------- * The interpreter now colorizes error messages when displaying tracebacks by default. - This feature can be controlled via the new :envvar:`PY_COLORS` environment variable - as well as the canonical ``NO_COLOR`` and ``FORCE_COLOR`` environment variables. - (Contributed by Pablo Galindo Salgado in :gh:`112730`.) + 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 ====================== diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 7973846bd23616..9d0415b9e8d916 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -4347,13 +4347,13 @@ def test_colorized_detection_checks_for_environment_variables(self): 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", {'PY_COLORS': '1'}): + with unittest.mock.patch("os.environ", {'PYTHON_COLORS': '1'}): self.assertEqual(traceback._can_colorize(), True) - with unittest.mock.patch("os.environ", {'PY_COLORS': '0'}): + 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', "PY_COLORS": '1'}): + with unittest.mock.patch("os.environ", {'NO_COLOR': '1', "PYTHON_COLORS": '1'}): self.assertEqual(traceback._can_colorize(), False) with unittest.mock.patch("os.environ", {'FORCE_COLOR': '1'}): self.assertEqual(traceback._can_colorize(), True) diff --git a/Lib/traceback.py b/Lib/traceback.py index 15887eb3538cd7..e705f99c751da6 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -144,13 +144,13 @@ def _can_colorize(): if "NO_COLOR" in os.environ: return False - if os.environ.get("PY_COLORS") == "0": + if os.environ.get("PYTHON_COLORS") == "0": return False if not _COLORIZE: return False if "FORCE_COLOR" in os.environ: return True - if os.environ.get("PY_COLORS") == "1": + if os.environ.get("PYTHON_COLORS") == "1": return True if os.environ.get("TERM") == "dumb": return False diff --git a/Python/initconfig.c b/Python/initconfig.c index f823c61eaf1b11..06e317907b8ec9 100644 --- a/Python/initconfig.c +++ b/Python/initconfig.c @@ -293,8 +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" -"PY_COLORS: If this variable is set to 1, the interpreter will colorize different \n" -" kinds of output. Setting it to 0 deactivates this behavior.\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" From f0aea495834e9ad2bc514574bcea7249ebf63e66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Wed, 6 Dec 2023 22:35:21 +0100 Subject: [PATCH 9/9] Make PYTHON_COLORS higher precedence than NO_COLOR and FORCE_COLOR --- Doc/using/cmdline.rst | 3 ++- Lib/test/test_traceback.py | 4 +++- Lib/traceback.py | 8 ++++---- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Doc/using/cmdline.rst b/Doc/using/cmdline.rst index 4eb288216f2cd4..56235bf4c28c7c 100644 --- a/Doc/using/cmdline.rst +++ b/Doc/using/cmdline.rst @@ -631,7 +631,8 @@ 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 -less precedence than ``NO_COLOR`` and ``FORCE_COLOR``. +precedence over ``NO_COLOR``, which in turn takes precedence over +``FORCE_COLOR``. Options you shouldn't use ~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 9d0415b9e8d916..a6708119b81191 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -4354,11 +4354,13 @@ def test_colorized_detection_checks_for_environment_variables(self): 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(), False) + 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) diff --git a/Lib/traceback.py b/Lib/traceback.py index e705f99c751da6..1cf008c7e9da97 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -142,16 +142,16 @@ def _can_colorize(): except (ImportError, AttributeError): return False - if "NO_COLOR" in os.environ: - 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("PYTHON_COLORS") == "1": - return True if os.environ.get("TERM") == "dumb": return False try: pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy