Skip to content

Commit e11465b

Browse files
committed
Pass escape=False for pprint to keep real control chars
1 parent 1aac260 commit e11465b

File tree

4 files changed

+52
-4
lines changed

4 files changed

+52
-4
lines changed

Lib/_pyrepl/utils.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,9 +307,14 @@ def iter_display_chars(
307307
buffer: str,
308308
colors: list[ColorSpan] | None = None,
309309
start_index: int = 0,
310+
*,
311+
escape: bool = True,
310312
) -> Iterator[StyledChar]:
311313
"""Yield visible display characters with widths and semantic color tags.
312314
315+
With ``escape=True`` (default) ASCII control chars are rewritten to caret
316+
notation (``\\n`` -> ``^J``); pass ``escape=False`` to keep them verbatim.
317+
313318
Note: ``colors`` is consumed in place as spans are processed -- callers
314319
that split a buffer across multiple calls rely on this mutation to track
315320
which spans have already been handled.
@@ -331,7 +336,7 @@ def iter_display_chars(
331336
if colors and color_idx < len(colors) and colors[color_idx].span.start == i:
332337
active_tag = colors[color_idx].tag
333338

334-
if control := _ascii_control_repr(c):
339+
if escape and (control := _ascii_control_repr(c)):
335340
text = control
336341
width = len(control)
337342
elif ord(c) < 128:
@@ -363,6 +368,8 @@ def disp_str(
363368
colors: list[ColorSpan] | None = None,
364369
start_index: int = 0,
365370
force_color: bool = False,
371+
*,
372+
escape: bool = True,
366373
) -> tuple[CharBuffer, CharWidths]:
367374
r"""Decompose the input buffer into a printable variant with applied colors.
368375
@@ -374,6 +381,9 @@ def disp_str(
374381
- the second list is the visible width of each character in the input
375382
buffer.
376383
384+
With ``escape=True`` (default) ASCII control chars are rewritten to caret
385+
notation (``\\n`` -> ``^J``); pass ``escape=False`` to keep them verbatim.
386+
377387
Note on colors:
378388
- The `colors` list, if provided, is partially consumed within. We're using
379389
a list and not a generator since we need to hold onto the current
@@ -393,7 +403,9 @@ def disp_str(
393403
(['\x1b[1;34mw', 'h', 'i', 'l', 'e\x1b[0m', ' ', '1', ':'], [1, 1, 1, 1, 1, 1, 1, 1])
394404
395405
"""
396-
styled_chars = list(iter_display_chars(buffer, colors, start_index))
406+
styled_chars = list(
407+
iter_display_chars(buffer, colors, start_index, escape=escape)
408+
)
397409
chars: CharBuffer = []
398410
char_widths: CharWidths = []
399411
theme = THEME(force_color=force_color)

Lib/pprint.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ def _safe_tuple(t):
134134
def _colorize_output(text):
135135
"""Apply syntax highlighting."""
136136
colors = list(gen_colors(text))
137-
chars, _ = disp_str(text, colors=colors, force_color=True)
137+
chars, _ = disp_str(text, colors=colors, force_color=True, escape=False)
138138
return "".join(chars)
139139

140140

Lib/test/test_pprint.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,27 @@ def test_color_prettyprinter(self):
214214
pp.pprint(obj)
215215
self.assertNotIn("\x1b[", stream.getvalue())
216216

217+
def test_color_preserves_newlines(self):
218+
"""Color multiline output must use real newlines, not '^J'."""
219+
obj = {"a": 1, "b": 2, "c": 3, "d": [10, 20, 30, 40, 50, 60, 70, 80]}
220+
221+
plain_stream = io.StringIO()
222+
pprint.pprint(obj, stream=plain_stream, width=20, color=False)
223+
plain = plain_stream.getvalue()
224+
self.assertIn("\n", plain)
225+
226+
with unittest.mock.patch.dict(
227+
"os.environ", {"FORCE_COLOR": "1", "NO_COLOR": ""}
228+
):
229+
color_stream = io.StringIO()
230+
pprint.pprint(obj, stream=color_stream, width=20, color=True)
231+
color = color_stream.getvalue()
232+
233+
self.assertIn("\x1b[", color) # has color
234+
self.assertNotIn("^J", color)
235+
stripped = re.sub(r"\x1b\[[0-9;]*m", "", color)
236+
self.assertEqual(stripped, plain)
237+
217238
def test_basic(self):
218239
# Verify .isrecursive() and .isreadable() w/o recursion
219240
pp = pprint.PrettyPrinter()

Lib/test/test_pyrepl/test_utils.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
from unittest import TestCase
22

3-
from _pyrepl.utils import str_width, wlen, prev_next_window, gen_colors
3+
from _pyrepl.utils import (
4+
disp_str,
5+
gen_colors,
6+
prev_next_window,
7+
str_width,
8+
wlen,
9+
)
410

511

612
class TestUtils(TestCase):
@@ -135,3 +141,12 @@ def test_gen_colors_keyword_highlighting(self):
135141
span_text = code[color.span.start:color.span.end + 1]
136142
actual_highlights.append((span_text, color.tag))
137143
self.assertEqual(actual_highlights, expected_highlights)
144+
145+
def test_disp_str_escape(self):
146+
# default: control chars become caret notation
147+
chars, _ = disp_str("a\nb\tc\x1bd")
148+
self.assertEqual("".join(chars), "a^Jb^Ic^[d")
149+
150+
# escape=False: control chars pass through verbatim
151+
chars, _ = disp_str("a\nb\tc\x1bd", escape=False)
152+
self.assertEqual("".join(chars), "a\nb\tc\x1bd")

0 commit comments

Comments
 (0)