diff --git a/Doc/library/pprint.rst b/Doc/library/pprint.rst index 0bdbe8c2e2bc97..e52a8f2e27bafa 100644 --- a/Doc/library/pprint.rst +++ b/Doc/library/pprint.rst @@ -31,7 +31,8 @@ Functions --------- .. function:: pp(object, stream=None, indent=4, width=88, depth=None, *, \ - compact=False, sort_dicts=False, underscore_numbers=False) + color=True, compact=False, sort_dicts=False, \ + underscore_numbers=False) Prints the formatted representation of *object*, followed by a newline. This function may be used in the interactive interpreter @@ -63,6 +64,12 @@ Functions on the depth of the objects being formatted. :type depth: int | None + :param bool color: + If ``True`` (the default), output will be syntax highlighted using ANSI + escape sequences, if the *stream* and :ref:`environment variables + ` permit. + If ``False``, colored output is always disabled. + :param bool compact: Control the way long :term:`sequences ` are formatted. If ``False`` (the default), @@ -90,14 +97,21 @@ Functions .. versionadded:: 3.8 + .. versionchanged:: next + Added the *color* parameter. + .. function:: pprint(object, stream=None, indent=4, width=88, depth=None, *, \ - compact=False, sort_dicts=True, underscore_numbers=False) + color=True, compact=False, sort_dicts=True, \ + underscore_numbers=False) Alias for :func:`~pprint.pp` with *sort_dicts* set to ``True`` by default, which would automatically sort the dictionaries' keys, you might want to use :func:`~pprint.pp` instead where it is ``False`` by default. + .. versionchanged:: next + Added the *color* parameter. + .. function:: pformat(object, indent=4, width=88, depth=None, *, \ compact=False, sort_dicts=True, underscore_numbers=False) @@ -147,7 +161,7 @@ PrettyPrinter objects .. index:: single: ...; placeholder .. class:: PrettyPrinter(indent=4, width=88, depth=None, stream=None, *, \ - compact=False, sort_dicts=True, \ + color=True, compact=False, sort_dicts=True, \ underscore_numbers=False) Construct a :class:`PrettyPrinter` instance. @@ -210,6 +224,7 @@ PrettyPrinter objects No longer attempts to write to :data:`!sys.stdout` if it is ``None``. .. versionchanged:: next + Added the *color* parameter. Changed default *indent* from 1 to 4 and default *width* from 80 to 88. The default ``compact=False`` layout is now similar to diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 3cf69718e63b28..095943479e401a 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -1216,6 +1216,12 @@ pprint (Contributed by Stefan Todoran, Semyon Moroz and Hugo van Kemenade in :gh:`112632` and :gh:`149189`.) +* Add *color* parameter to :func:`~pprint.pp` and :func:`~pprint.pprint`. + If ``True`` (the default), output is highlighted in color, when the stream + and :ref:`environment variables ` permit. + If ``False``, colored output is always disabled. + (Contributed by Hugo van Kemenade in :gh:`145217`.) + * Add t-string support to :mod:`pprint`. (Contributed by Loïc Simon and Hugo van Kemenade in :gh:`134551`.) diff --git a/Lib/_pyrepl/utils.py b/Lib/_pyrepl/utils.py index b50426c31ead53..e1bd41183eb699 100644 --- a/Lib/_pyrepl/utils.py +++ b/Lib/_pyrepl/utils.py @@ -307,9 +307,14 @@ def iter_display_chars( buffer: str, colors: list[ColorSpan] | None = None, start_index: int = 0, + *, + escape: bool = True, ) -> Iterator[StyledChar]: """Yield visible display characters with widths and semantic color tags. + With ``escape=True`` (default) ASCII control chars are rewritten to caret + notation (``\\n`` -> ``^J``); pass ``escape=False`` to keep them verbatim. + Note: ``colors`` is consumed in place as spans are processed -- callers that split a buffer across multiple calls rely on this mutation to track which spans have already been handled. @@ -331,7 +336,7 @@ def iter_display_chars( if colors and color_idx < len(colors) and colors[color_idx].span.start == i: active_tag = colors[color_idx].tag - if control := _ascii_control_repr(c): + if escape and (control := _ascii_control_repr(c)): text = control width = len(control) elif ord(c) < 128: @@ -363,6 +368,8 @@ def disp_str( colors: list[ColorSpan] | None = None, start_index: int = 0, force_color: bool = False, + *, + escape: bool = True, ) -> tuple[CharBuffer, CharWidths]: r"""Decompose the input buffer into a printable variant with applied colors. @@ -374,6 +381,9 @@ def disp_str( - the second list is the visible width of each character in the input buffer. + With ``escape=True`` (default) ASCII control chars are rewritten to caret + notation (``\\n`` -> ``^J``); pass ``escape=False`` to keep them verbatim. + Note on colors: - The `colors` list, if provided, is partially consumed within. We're using a list and not a generator since we need to hold onto the current @@ -393,7 +403,9 @@ def disp_str( (['\x1b[1;34mw', 'h', 'i', 'l', 'e\x1b[0m', ' ', '1', ':'], [1, 1, 1, 1, 1, 1, 1, 1]) """ - styled_chars = list(iter_display_chars(buffer, colors, start_index)) + styled_chars = list( + iter_display_chars(buffer, colors, start_index, escape=escape) + ) chars: CharBuffer = [] char_widths: CharWidths = [] theme = THEME(force_color=force_color) diff --git a/Lib/pprint.py b/Lib/pprint.py index 1fd7e3ec95a073..a61fc5356d2f86 100644 --- a/Lib/pprint.py +++ b/Lib/pprint.py @@ -38,6 +38,11 @@ import sys as _sys import types as _types from io import StringIO as _StringIO +lazy import _colorize +lazy import re +lazy from _pyrepl.utils import disp_str, gen_colors +lazy from dataclasses import fields as dataclass_fields +lazy from dataclasses import is_dataclass __all__ = ["pprint","pformat","isreadable","isrecursive","saferepr", "PrettyPrinter", "pp"] @@ -50,15 +55,22 @@ def pprint( width=88, depth=None, *, + color=True, compact=False, sort_dicts=True, underscore_numbers=False, ): """Pretty-print a Python object to a stream [default is sys.stdout].""" printer = PrettyPrinter( - stream=stream, indent=indent, width=width, depth=depth, - compact=compact, sort_dicts=sort_dicts, - underscore_numbers=underscore_numbers) + stream=stream, + indent=indent, + width=width, + depth=depth, + color=color, + compact=compact, + sort_dicts=sort_dicts, + underscore_numbers=underscore_numbers, + ) printer.pprint(object) @@ -126,6 +138,24 @@ def _safe_tuple(t): return _safe_key(t[0]), _safe_key(t[1]) +def _colorize_output(text): + """Apply syntax highlighting.""" + if "\x1b[" in text: + # If the text already contains ANSI escape sequences + # (for example, from a custom __repr__), + # return as-is to avoid breaking their color. + return text + # Skip coloring inside <...> reprs (for example, ), + # they're placeholders, not Python source. + skip = [m.span() for m in re.finditer(r"<[^<>\n]*>", text)] + colors = [ + c for c in gen_colors(text) + if not any(start <= c.span.start < end for start, end in skip) + ] + chars, _ = disp_str(text, colors=colors, force_color=True, escape=False) + return "".join(chars) + + class PrettyPrinter: def __init__( self, @@ -134,6 +164,7 @@ def __init__( depth=None, stream=None, *, + color=True, compact=False, sort_dicts=True, underscore_numbers=False, @@ -154,6 +185,11 @@ def __init__( The desired output stream. If omitted (or false), the standard output stream available at construction will be used. + color + If true (the default), syntax highlighting is enabled for pprint + when the stream and environment variables permit. + If false, colored output is always disabled. + compact If true, several items will be combined in one line. @@ -182,11 +218,30 @@ def __init__( self._compact = bool(compact) self._sort_dicts = sort_dicts self._underscore_numbers = underscore_numbers + self._color = color def pprint(self, object): - if self._stream is not None: + if self._stream is None: + return + + use_color = False + if self._color: + try: + if _colorize.can_colorize(file=self._stream): + # Attempt to reify lazy imports, or ImportError + gen_colors, disp_str + use_color = True + except ImportError: + pass + + if use_color: + sio = _StringIO() + self._format(object, sio, 0, 0, {}, 0) + self._stream.write(_colorize_output(sio.getvalue())) + else: self._format(object, self._stream, 0, 0, {}, 0) - self._stream.write("\n") + + self._stream.write("\n") def pformat(self, object): sio = _StringIO() @@ -211,9 +266,6 @@ def _format(self, object, stream, indent, allowance, context, level): max_width = self._width - indent - allowance if len(rep) > max_width: p = self._dispatch.get(type(object).__repr__, None) - # Lazy import to improve module import time - from dataclasses import is_dataclass - if p is not None: context[objid] = 1 p(self, object, stream, indent, allowance, context, level + 1) @@ -254,9 +306,6 @@ def _write_indent_padding(self, write): write(self._indent_per_level * " ") def _pprint_dataclass(self, object, stream, indent, allowance, context, level): - # Lazy import to improve module import time - from dataclasses import fields as dataclass_fields - cls_name = object.__class__.__name__ if self._compact: indent += len(cls_name) + 1 @@ -436,9 +485,6 @@ def _pprint_str(self, object, stream, indent, allowance, context, level): if len(rep) <= max_width1: chunks.append(rep) else: - # Lazy import to improve module import time - import re - # A list of alternating (non-space, space) strings parts = re.findall(r'\S*\s*', line) assert parts diff --git a/Lib/test/test_pickle.py b/Lib/test/test_pickle.py index 55a3c654aa0a47..0d36f2e8575c41 100644 --- a/Lib/test/test_pickle.py +++ b/Lib/test/test_pickle.py @@ -779,6 +779,7 @@ def invoke_pickle(self, *flags): pickle._main(args=[*flags, self.filename]) return self.text_normalize(output.getvalue()) + @support.force_not_colorized def test_invocation(self): # test 'python -m pickle pickle_file' data = { diff --git a/Lib/test/test_pprint.py b/Lib/test/test_pprint.py index f439782f53e6fb..9d23729054b1be 100644 --- a/Lib/test/test_pprint.py +++ b/Lib/test/test_pprint.py @@ -11,6 +11,7 @@ import re import types import unittest +import unittest.mock from collections.abc import ItemsView, KeysView, Mapping, MappingView, ValuesView from test.support import cpython_only @@ -170,6 +171,122 @@ def test_init(self): self.assertRaises(ValueError, pprint.PrettyPrinter, depth=-1) self.assertRaises(ValueError, pprint.PrettyPrinter, width=0) + def test_color_pprint(self): + """Test pprint color parameter.""" + obj = {"key": "value"} + stream = io.StringIO() + + # color=False should produce no ANSI codes + pprint.pprint(obj, stream=stream, color=False) + result = stream.getvalue() + self.assertNotIn("\x1b[", result) + + # Explicit color=False should override FORCE_COLOR + stream = io.StringIO() + with unittest.mock.patch.dict( + "os.environ", {"FORCE_COLOR": "1", "NO_COLOR": ""} + ): + pprint.pprint(obj, stream=stream, color=False) + result = stream.getvalue() + self.assertNotIn("\x1b[", result) + + # color=True should produce no ANSI codes for streams + # that do not support color + stream = io.StringIO() + with unittest.mock.patch.dict( + "os.environ", {"FORCE_COLOR": "", "NO_COLOR": ""} + ): + pprint.pprint(obj, stream=stream, color=True) + result = stream.getvalue() + self.assertNotIn("\x1b[", result) + + def test_color_prettyprinter(self): + """Test PrettyPrinter color parameter.""" + obj = {"key": "value"} + + # color=False should produce no ANSI codes in pprint + stream = io.StringIO() + pp = pprint.PrettyPrinter(stream=stream, color=False) + pp.pprint(obj) + self.assertNotIn("\x1b[", stream.getvalue()) + + # color=True with FORCE_COLOR should produce ANSI codes in pprint + with unittest.mock.patch.dict( + "os.environ", {"FORCE_COLOR": "1", "NO_COLOR": ""} + ): + stream = io.StringIO() + pp = pprint.PrettyPrinter(stream=stream, color=True) + pp.pprint(obj) + self.assertIn("\x1b[", stream.getvalue()) + + # Explicit color=False should override FORCE_COLOR + with unittest.mock.patch.dict( + "os.environ", {"FORCE_COLOR": "1", "NO_COLOR": ""} + ): + stream = io.StringIO() + pp = pprint.PrettyPrinter(stream=stream, color=False) + pp.pprint(obj) + self.assertNotIn("\x1b[", stream.getvalue()) + + def test_color_preserves_newlines(self): + """Color multiline output must use real newlines, not '^J'.""" + obj = {"a": 1, "b": 2, "c": 3, "d": [10, 20, 30, 40, 50, 60, 70, 80]} + + plain_stream = io.StringIO() + pprint.pprint(obj, stream=plain_stream, width=20, color=False) + plain = plain_stream.getvalue() + self.assertIn("\n", plain) + + with unittest.mock.patch.dict( + "os.environ", {"FORCE_COLOR": "1", "NO_COLOR": ""} + ): + color_stream = io.StringIO() + pprint.pprint(obj, stream=color_stream, width=20, color=True) + color = color_stream.getvalue() + + self.assertIn("\x1b[", color) # has color + self.assertNotIn("^J", color) + stripped = re.sub(r"\x1b\[[0-9;]*m", "", color) + self.assertEqual(stripped, plain) + + def test_color_user_repr_with_ansi(self): + """If a __repr__ already contains ANSI escapes, don't add ours.""" + + class ColorObj: + def __repr__(self): + return "\x1b[31mred\x1b[0m" + + obj = {"a": ColorObj(), "b": 42, "c": "hello"} + + with unittest.mock.patch.dict( + "os.environ", {"FORCE_COLOR": "1", "NO_COLOR": ""} + ): + stream = io.StringIO() + pprint.pprint(obj, stream=stream, color=True) + result = stream.getvalue() + + # pprint should not have added any extra color codes + expected = "{'a': \x1b[31mred\x1b[0m, 'b': 42, 'c': 'hello'}\n" + self.assertEqual(result, expected) + + def test_color_skips_placeholder_reprs(self): + """<...> reprs are non-literal placeholders, not Python source, + so their contents must not be tokenized and colored.""" + recursive = [1, "two", None] + recursive.append(recursive) + + with unittest.mock.patch.dict( + "os.environ", {"FORCE_COLOR": "1", "NO_COLOR": ""} + ): + stream = io.StringIO() + pprint.pprint(recursive, stream=stream, color=True) + result = stream.getvalue() + + self.assertIn("\x1b[", result) # surrounding output is still colored + match = re.search(r"<[^<>\n]*>", result) + self.assertIsNotNone(match) + self.assertNotIn("\x1b[", match.group(0)) + def test_basic(self): # Verify .isrecursive() and .isreadable() w/o recursion pp = pprint.PrettyPrinter() diff --git a/Lib/test/test_pyrepl/test_utils.py b/Lib/test/test_pyrepl/test_utils.py index 3c55b6bdaeee9e..da141c2b9b5ae3 100644 --- a/Lib/test/test_pyrepl/test_utils.py +++ b/Lib/test/test_pyrepl/test_utils.py @@ -1,6 +1,12 @@ from unittest import TestCase -from _pyrepl.utils import str_width, wlen, prev_next_window, gen_colors +from _pyrepl.utils import ( + disp_str, + gen_colors, + prev_next_window, + str_width, + wlen, +) class TestUtils(TestCase): @@ -135,3 +141,12 @@ def test_gen_colors_keyword_highlighting(self): span_text = code[color.span.start:color.span.end + 1] actual_highlights.append((span_text, color.tag)) self.assertEqual(actual_highlights, expected_highlights) + + def test_disp_str_escape(self): + # default: control chars become caret notation + chars, _ = disp_str("a\nb\tc\x1bd") + self.assertEqual("".join(chars), "a^Jb^Ic^[d") + + # escape=False: control chars pass through verbatim + chars, _ = disp_str("a\nb\tc\x1bd", escape=False) + self.assertEqual("".join(chars), "a\nb\tc\x1bd") diff --git a/Misc/NEWS.d/next/Library/2026-02-25-16-19-21.gh-issue-145217.QQBY0-.rst b/Misc/NEWS.d/next/Library/2026-02-25-16-19-21.gh-issue-145217.QQBY0-.rst new file mode 100644 index 00000000000000..534eb1db16c4d1 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-02-25-16-19-21.gh-issue-145217.QQBY0-.rst @@ -0,0 +1,3 @@ +Add color parameter to :func:`pprint.pprint` and :func:`pprint.pp`, enabling +syntax highlighting by default when the stream supports it. Patch by Hugo van +Kemenade.