diff --git a/Doc/library/pprint.rst b/Doc/library/pprint.rst index 4f043fbb3a46df..7ee803d066e42f 100644 --- a/Doc/library/pprint.rst +++ b/Doc/library/pprint.rst @@ -31,7 +31,7 @@ Functions --------- .. function:: pp(object, stream=None, indent=1, width=80, depth=None, *, \ - compact=False, expand=False, sort_dicts=False, \ + color=True, compact=False, expand=False, sort_dicts=False, \ underscore_numbers=False) Prints the formatted representation of *object*, followed by a newline. @@ -64,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), @@ -101,15 +107,21 @@ Functions .. versionadded:: 3.8 + .. versionchanged:: next + Added the *color* parameter. + .. function:: pprint(object, stream=None, indent=1, width=80, depth=None, *, \ - compact=False, expand=False, sort_dicts=True, \ + color=True, compact=False, expand=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=1, width=80, depth=None, *, \ compact=False, expand=False, sort_dicts=True, \ @@ -154,14 +166,14 @@ Functions .. _prettyprinter-objects: -PrettyPrinter Objects +PrettyPrinter objects --------------------- .. index:: single: ...; placeholder .. class:: PrettyPrinter(indent=1, width=80, depth=None, stream=None, *, \ - compact=False, expand=False, sort_dicts=True, \ - underscore_numbers=False) + color=True, compact=False, expand=False, \ + sort_dicts=True, underscore_numbers=False) Construct a :class:`PrettyPrinter` instance. @@ -220,6 +232,9 @@ PrettyPrinter Objects .. versionchanged:: 3.11 No longer attempts to write to :data:`!sys.stdout` if it is ``None``. + .. versionchanged:: next + Added the *color* parameter. + .. versionchanged:: 3.15 Added the *expand* parameter. diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 21327b611120a2..6461faf8eee642 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -942,6 +942,16 @@ pickle (Contributed by Zackery Spytz and Serhiy Storchaka in :gh:`77188`.) +pprint +------ + +* 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`.) + + re -- 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 f197d7d17cdb96..dc42b257bb3f45 100644 --- a/Lib/pprint.py +++ b/Lib/pprint.py @@ -39,18 +39,38 @@ import types as _types from io import StringIO as _StringIO +lazy import _colorize +lazy from _pyrepl.utils import disp_str, gen_colors + __all__ = ["pprint","pformat","isreadable","isrecursive","saferepr", "PrettyPrinter", "pp"] -def pprint(object, stream=None, indent=1, width=80, depth=None, *, - compact=False, expand=False, sort_dicts=True, - underscore_numbers=False): +def pprint( + object, + stream=None, + indent=1, + width=80, + depth=None, + *, + color=True, + compact=False, + expand=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, expand=expand, sort_dicts=sort_dicts, - underscore_numbers=underscore_numbers) + stream=stream, + indent=indent, + width=width, + depth=depth, + color=color, + compact=compact, + expand=expand, + sort_dicts=sort_dicts, + underscore_numbers=underscore_numbers, + ) printer.pprint(object) @@ -111,10 +131,27 @@ def _safe_tuple(t): return _safe_key(t[0]), _safe_key(t[1]) +def _colorize_output(text): + """Apply syntax highlighting.""" + colors = list(gen_colors(text)) + chars, _ = disp_str(text, colors=colors, force_color=True, escape=False) + return "".join(chars) + + class PrettyPrinter: - def __init__(self, indent=1, width=80, depth=None, stream=None, *, - compact=False, expand=False, sort_dicts=True, - underscore_numbers=False): + def __init__( + self, + indent=1, + width=80, + depth=None, + stream=None, + *, + color=True, + compact=False, + expand=False, + sort_dicts=True, + underscore_numbers=False, + ): """Handle pretty printing operations onto a stream using a set of configured parameters. @@ -131,6 +168,11 @@ def __init__(self, indent=1, width=80, depth=None, stream=None, *, 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. Incompatible with expand mode. @@ -168,10 +210,16 @@ def __init__(self, indent=1, width=80, depth=None, stream=None, *, self._expand = bool(expand) self._sort_dicts = sort_dicts self._underscore_numbers = underscore_numbers + self._color = color def pprint(self, object): if self._stream is not None: - self._format(object, self._stream, 0, 0, {}, 0) + if self._color and _colorize.can_colorize(file=self._stream): + 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") def pformat(self, object): diff --git a/Lib/test/test_pickle.py b/Lib/test/test_pickle.py index 48375cf459ea0b..9ad1ab7f4e1b75 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 45e081c233f0b0..7973cc9501842b 100644 --- a/Lib/test/test_pprint.py +++ b/Lib/test/test_pprint.py @@ -10,6 +10,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 @@ -166,6 +167,74 @@ def test_init(self): self.assertRaises(ValueError, pprint.PrettyPrinter, width=0) self.assertRaises(ValueError, pprint.PrettyPrinter, compact=True, expand=True) + 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) + + 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_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..ee4cd9a86eeb43 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-02-25-16-19-21.gh-issue-145217.QQBY0-.rst @@ -0,0 +1 @@ +Add colour to :mod:`pprint` output. Patch by Hugo van Kemenade.