Skip to content

Commit cb92f79

Browse files
committed
gh-161114: Speed up pprint on deeply nested containers
Stop building a container's one-line repr as soon as it is wider than the remaining line, instead of formatting the whole subtree at every level.
1 parent 9c0cfb1 commit cb92f79

3 files changed

Lines changed: 123 additions & 4 deletions

File tree

‎Lib/pprint.py‎

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -198,9 +198,9 @@ def _format(self, object, stream, indent, allowance, context, level):
198198
self._recursive = True
199199
self._readable = False
200200
return
201-
rep = self._repr(object, context, level)
202201
max_width = self._width - indent - prefix_len - allowance
203-
if len(rep) > max_width:
202+
rep = self._repr_if_fits(object, context, level, max_width)
203+
if rep is None or len(rep) > max_width:
204204
p = self._dispatch.get(type(object).__repr__, None)
205205
# Lazy import to improve module import time
206206
from dataclasses import is_dataclass
@@ -220,6 +220,8 @@ def _format(self, object, stream, indent, allowance, context, level):
220220
self._pprint_dataclass(object, stream, indent, allowance, context, level + 1)
221221
del context[objid]
222222
return
223+
if rep is None:
224+
rep = self._repr(object, context, level)
223225
stream.write(rep)
224226

225227
def _format_block_start(self, start_str, indent):
@@ -626,8 +628,8 @@ def _format_items(self, items, stream, indent, allowance, context, level):
626628
max_width -= allowance
627629
width -= allowance
628630
if self._compact:
629-
rep = self._repr(ent, context, level)
630-
w = len(rep) + 2
631+
rep = self._repr_if_fits(ent, context, level, max_width - 2)
632+
w = max_width + 1 if rep is None else len(rep) + 2
631633
if width < w:
632634
width = max_width
633635
if delim:
@@ -655,6 +657,33 @@ def _repr(self, object, context, level):
655657
self._recursive = True
656658
return repr
657659

660+
# Set by _repr_if_fits() for the duration of its _repr() call and
661+
# consumed by _safe_repr(), which raises _TooWide as soon as the
662+
# representation of a container grows beyond it. Finding out that an
663+
# object does not fit on one line thus costs no more than the width of
664+
# the line, instead of the size of the object, for each level of nesting.
665+
# It is an attribute, not an argument, because subclasses override
666+
# format() with its existing signature.
667+
_max_width = None
668+
669+
def _repr_if_fits(self, object, context, level, max_width):
670+
# Return the repr of object, or None if building it was cut short
671+
# because it cannot fit in max_width. A completed repr is returned
672+
# even when it is too wide, so that _format() does not have to
673+
# build it again when it writes it.
674+
if type(self).format is not PrettyPrinter.format:
675+
# An override may shorten what PrettyPrinter.format() returns,
676+
# so only the complete result can tell whether it fits.
677+
return self._repr(object, context, level)
678+
saved = self._max_width
679+
self._max_width = max_width
680+
try:
681+
return self._repr(object, context, level)
682+
except _TooWide:
683+
return None
684+
finally:
685+
self._max_width = saved
686+
658687
def format(self, object, context, maxlevels, level):
659688
"""Format object for a specific context, returning a string
660689
and flags indicating whether the representation is 'readable'
@@ -852,6 +881,12 @@ def _safe_repr(self, object, context, maxlevels, level):
852881
components = []
853882
append = components.append
854883
level += 1
884+
max_width = self._max_width
885+
width = 0
886+
if max_width is not None and 4 * len(object) > max_width:
887+
# Every item costs at least "k: v, ": do not bother
888+
# formatting (and sorting) them.
889+
raise _TooWide
855890
if self._sort_dicts:
856891
items = sorted(object.items(), key=_safe_tuple)
857892
else:
@@ -865,6 +900,10 @@ def _safe_repr(self, object, context, maxlevels, level):
865900
readable = readable and kreadable and vreadable
866901
if krecur or vrecur:
867902
recursive = True
903+
if max_width is not None:
904+
width += len(krepr) + len(vrepr) + 4
905+
if width > max_width:
906+
raise _TooWide
868907
del context[objid]
869908
rep = "{%s}" % ", ".join(components)
870909
if is_frozendict:
@@ -894,6 +933,8 @@ def _safe_repr(self, object, context, maxlevels, level):
894933
components = []
895934
append = components.append
896935
level += 1
936+
max_width = self._max_width
937+
width = 0
897938
for o in object:
898939
orepr, oreadable, orecur = self.format(
899940
o, context, maxlevels, level)
@@ -902,6 +943,10 @@ def _safe_repr(self, object, context, maxlevels, level):
902943
readable = False
903944
if orecur:
904945
recursive = True
946+
if max_width is not None:
947+
width += len(orepr) + 2
948+
if width > max_width:
949+
raise _TooWide
905950
del context[objid]
906951
return format % ", ".join(components), readable, recursive
907952

@@ -923,6 +968,9 @@ def _safe_repr(self, object, context, maxlevels, level):
923968
# We have a view that somehow has lost its type's _mapping, raise
924969
# an error by calling repr() instead of failing cryptically later
925970
return repr(object), True, False
971+
max_width = self._max_width
972+
if max_width is not None and 2 * len(object) > max_width:
973+
raise _TooWide
926974
if self._sort_dicts:
927975
object = sorted(object, key=key)
928976
context[objid] = 1
@@ -931,13 +979,18 @@ def _safe_repr(self, object, context, maxlevels, level):
931979
components = []
932980
append = components.append
933981
level += 1
982+
width = 0
934983
for val in object:
935984
vrepr, vreadable, vrecur = self.format(
936985
val, context, maxlevels, level)
937986
append(vrepr)
938987
readable = readable and vreadable
939988
if vrecur:
940989
recursive = True
990+
if max_width is not None:
991+
width += len(vrepr) + 2
992+
if width > max_width:
993+
raise _TooWide
941994
del context[objid]
942995
return typ.__name__ + '([%s])' % ", ".join(components), readable, recursive
943996

@@ -954,6 +1007,13 @@ def _recursion(object):
9541007
% (type(object).__name__, id(object)))
9551008

9561009

1010+
class _TooWide(BaseException):
1011+
"""Raised by _safe_repr() when the representation it is building is
1012+
already wider than PrettyPrinter._max_width. It is not an Exception,
1013+
so that "except Exception" handlers it passes through do not catch it.
1014+
"""
1015+
1016+
9571017
def _wrap_bytes_repr(object, width, allowance):
9581018
current = b''
9591019
last = len(object) // 4 * 4

‎Lib/test/test_pprint.py‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,6 +1039,61 @@ def test_depth(self):
10391039
self.assertEqual(pprint.pformat(nested_dict, depth=1), lv1_dict)
10401040
self.assertEqual(pprint.pformat(nested_list, depth=1), lv1_list)
10411041

1042+
def test_deeply_nested_items_not_formatted_per_level(self):
1043+
# The items of the innermost container must not be formatted again
1044+
# for each of the containers around it, which would make the time
1045+
# proportional to the size of the data times its nesting depth.
1046+
class Item(int):
1047+
calls = 0
1048+
def __repr__(self):
1049+
Item.calls += 1
1050+
return int.__repr__(self)
1051+
1052+
depth = 50
1053+
size = 1000
1054+
width = 80
1055+
for wrap in (
1056+
lambda o: [o],
1057+
lambda o: (o,),
1058+
lambda o: {'k': o},
1059+
lambda o: frozendict({'k': o}),
1060+
lambda o: {'k': o}.values(),
1061+
):
1062+
for items in ([Item(i) for i in range(size)],
1063+
{Item(i): Item(i) for i in range(size)}):
1064+
for kwargs in {}, {'compact': True}, {'expand': True}:
1065+
nested = items
1066+
for i in range(depth):
1067+
nested = wrap(nested)
1068+
with self.subTest(type=type(nested).__name__,
1069+
items=type(items).__name__, **kwargs):
1070+
Item.calls = 0
1071+
pprint.pformat(nested, width=width, **kwargs)
1072+
# Each level may format a line's worth of items to
1073+
# find out that the container does not fit on one
1074+
# line, and every item is formatted a few times once
1075+
# the innermost container is laid out.
1076+
self.assertLess(Item.calls,
1077+
depth * width + 4 * len(items))
1078+
1079+
def test_format_override_shortening_repr(self):
1080+
# A format() override may shorten what PrettyPrinter.format()
1081+
# returns, so whether that fits on one line cannot be decided before
1082+
# it is complete.
1083+
class ShortPrettyPrinter(pprint.PrettyPrinter):
1084+
def format(self, object, context, maxlevels, level):
1085+
rep, readable, recursive = super().format(
1086+
object, context, maxlevels, level)
1087+
if len(rep) > 20:
1088+
rep = rep[:17] + '...'
1089+
return rep, False, recursive
1090+
1091+
printer = ShortPrettyPrinter(width=30)
1092+
self.assertEqual(printer.pformat({'a': list(range(100))}),
1093+
"{'a': [0, 1, 2, 3...")
1094+
self.assertEqual(printer.pformat([list(range(100))] * 3),
1095+
"[[0, 1, 2, 3, 4, ...")
1096+
10421097
def test_sort_unorderable_values(self):
10431098
# Issue 3976: sorted pprints fail for unorderable values.
10441099
n = 20
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Improve performance of :func:`pprint.pformat`, :func:`pprint.pprint` and
2+
:class:`pprint.PrettyPrinter` for deeply nested containers that do not fit on
3+
one line. Formatting time no longer grows with the size of the data multiplied
4+
by its nesting depth.

0 commit comments

Comments
 (0)