Skip to content

Commit acc9f3f

Browse files
Issue #18725: The textwrap module now supports truncating multiline text.
1 parent bc2bfa6 commit acc9f3f

4 files changed

Lines changed: 165 additions & 58 deletions

File tree

Doc/library/textwrap.rst

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,22 @@ hyphenated words; only then will long words be broken if necessary, unless
250250
was to always allow breaking hyphenated words.
251251

252252

253+
.. attribute:: max_lines
254+
255+
(default: ``None``) If not ``None``, then the text be will truncated to
256+
*max_lines* lines.
257+
258+
.. versionadded:: 3.4
259+
260+
261+
.. attribute:: placeholder
262+
263+
(default: ``' [...]'``) String that will be appended to the last line of
264+
text if it will be truncated.
265+
266+
.. versionadded:: 3.4
267+
268+
253269
:class:`TextWrapper` also provides some public methods, analogous to the
254270
module-level convenience functions:
255271

@@ -266,15 +282,3 @@ hyphenated words; only then will long words be broken if necessary, unless
266282

267283
Wraps the single paragraph in *text*, and returns a single string
268284
containing the wrapped paragraph.
269-
270-
271-
.. function:: shorten(text, *, placeholder=" [...]")
272-
273-
Collapse and truncate the given text to fit in :attr:`width`
274-
characters.
275-
276-
The text first has its whitespace collapsed. If it then fits in
277-
:attr:`width`, it is returned as-is. Otherwise, as many words
278-
as possible are joined and then the *placeholder* is appended.
279-
280-
.. versionadded:: 3.4

Lib/test/test_textwrap.py

Lines changed: 98 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,6 @@ def check_split(self, text, expect):
4242
"\nexpected %r\n"
4343
"but got %r" % (expect, result))
4444

45-
def check_shorten(self, text, width, expect, **kwargs):
46-
result = shorten(text, width, **kwargs)
47-
self.check(result, expect)
48-
4945

5046
class WrapTestCase(BaseTestCase):
5147

@@ -433,6 +429,90 @@ def test_umlaut_followed_by_dash(self):
433429
self.check_wrap(text, 7, ["aa \xe4\xe4-", "\xe4\xe4"])
434430

435431

432+
class MaxLinesTestCase(BaseTestCase):
433+
text = "Hello there, how are you this fine day? I'm glad to hear it!"
434+
435+
def test_simple(self):
436+
self.check_wrap(self.text, 12,
437+
["Hello [...]"],
438+
max_lines=0)
439+
self.check_wrap(self.text, 12,
440+
["Hello [...]"],
441+
max_lines=1)
442+
self.check_wrap(self.text, 12,
443+
["Hello there,",
444+
"how [...]"],
445+
max_lines=2)
446+
self.check_wrap(self.text, 13,
447+
["Hello there,",
448+
"how are [...]"],
449+
max_lines=2)
450+
self.check_wrap(self.text, 80, [self.text], max_lines=1)
451+
self.check_wrap(self.text, 12,
452+
["Hello there,",
453+
"how are you",
454+
"this fine",
455+
"day? I'm",
456+
"glad to hear",
457+
"it!"],
458+
max_lines=6)
459+
460+
def test_spaces(self):
461+
# strip spaces before placeholder
462+
self.check_wrap(self.text, 12,
463+
["Hello there,",
464+
"how are you",
465+
"this fine",
466+
"day? [...]"],
467+
max_lines=4)
468+
# placeholder at the start of line
469+
self.check_wrap(self.text, 6,
470+
["Hello",
471+
"[...]"],
472+
max_lines=2)
473+
# final spaces
474+
self.check_wrap(self.text + ' ' * 10, 12,
475+
["Hello there,",
476+
"how are you",
477+
"this fine",
478+
"day? I'm",
479+
"glad to hear",
480+
"it!"],
481+
max_lines=6)
482+
483+
def test_placeholder(self):
484+
self.check_wrap(self.text, 12,
485+
["Hello..."],
486+
max_lines=1,
487+
placeholder='...')
488+
self.check_wrap(self.text, 12,
489+
["Hello there,",
490+
"how are..."],
491+
max_lines=2,
492+
placeholder='...')
493+
# long placeholder and indentation
494+
with self.assertRaises(ValueError):
495+
wrap(self.text, 16, initial_indent=' ',
496+
max_lines=1, placeholder=' [truncated]...')
497+
with self.assertRaises(ValueError):
498+
wrap(self.text, 16, subsequent_indent=' ',
499+
max_lines=2, placeholder=' [truncated]...')
500+
self.check_wrap(self.text, 16,
501+
[" Hello there,",
502+
" [truncated]..."],
503+
max_lines=2,
504+
initial_indent=' ',
505+
subsequent_indent=' ',
506+
placeholder=' [truncated]...')
507+
self.check_wrap(self.text, 16,
508+
[" [truncated]..."],
509+
max_lines=1,
510+
initial_indent=' ',
511+
subsequent_indent=' ',
512+
placeholder=' [truncated]...')
513+
self.check_wrap(self.text, 80, [self.text], placeholder='.' * 1000)
514+
515+
436516
class LongWordTestCase (BaseTestCase):
437517
def setUp(self):
438518
self.wrapper = TextWrapper()
@@ -493,6 +573,14 @@ def test_nobreak_long(self):
493573
result = wrap(self.text, width=30, break_long_words=0)
494574
self.check(result, expect)
495575

576+
def test_max_lines_long(self):
577+
self.check_wrap(self.text, 12,
578+
['Did you say ',
579+
'"supercalifr',
580+
'agilisticexp',
581+
'[...]'],
582+
max_lines=4)
583+
496584

497585
class IndentTestCases(BaseTestCase):
498586

@@ -782,6 +870,10 @@ def test_indent_empty_lines(self):
782870

783871
class ShortenTestCase(BaseTestCase):
784872

873+
def check_shorten(self, text, width, expect, **kwargs):
874+
result = shorten(text, width, **kwargs)
875+
self.check(result, expect)
876+
785877
def test_simple(self):
786878
# Simple case: just words, spaces, and a bit of punctuation
787879
text = "Hello there, how are you this fine day? I'm glad to hear it!"
@@ -825,10 +917,9 @@ def test_whitespace(self):
825917
self.check_shorten("hello world! ", 10, "[...]")
826918

827919
def test_width_too_small_for_placeholder(self):
828-
wrapper = TextWrapper(width=8)
829-
wrapper.shorten("x" * 20, placeholder="(......)")
920+
shorten("x" * 20, width=8, placeholder="(......)")
830921
with self.assertRaises(ValueError):
831-
wrapper.shorten("x" * 20, placeholder="(.......)")
922+
shorten("x" * 20, width=8, placeholder="(.......)")
832923

833924
def test_first_word_too_long_but_placeholder_fits(self):
834925
self.check_shorten("Helloo", 5, "[...]")

Lib/textwrap.py

Lines changed: 49 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@
1919
# since 0xa0 is not in range(128).
2020
_whitespace = '\t\n\x0b\x0c\r '
2121

22-
_default_placeholder = ' [...]'
23-
2422
class TextWrapper:
2523
"""
2624
Object for wrapping/filling text. The public interface consists of
@@ -64,6 +62,10 @@ class TextWrapper:
6462
compound words.
6563
drop_whitespace (default: true)
6664
Drop leading and trailing whitespace from lines.
65+
max_lines (default: None)
66+
Truncate wrapped lines.
67+
placeholder (default: ' [...]')
68+
Append to the last line of truncated text.
6769
"""
6870

6971
unicode_whitespace_trans = {}
@@ -106,7 +108,10 @@ def __init__(self,
106108
break_long_words=True,
107109
drop_whitespace=True,
108110
break_on_hyphens=True,
109-
tabsize=8):
111+
tabsize=8,
112+
*,
113+
max_lines=None,
114+
placeholder=' [...]'):
110115
self.width = width
111116
self.initial_indent = initial_indent
112117
self.subsequent_indent = subsequent_indent
@@ -117,6 +122,8 @@ def __init__(self,
117122
self.drop_whitespace = drop_whitespace
118123
self.break_on_hyphens = break_on_hyphens
119124
self.tabsize = tabsize
125+
self.max_lines = max_lines
126+
self.placeholder = placeholder
120127

121128

122129
# -- Private methods -----------------------------------------------
@@ -225,6 +232,13 @@ def _wrap_chunks(self, chunks):
225232
lines = []
226233
if self.width <= 0:
227234
raise ValueError("invalid width %r (must be > 0)" % self.width)
235+
if self.max_lines is not None:
236+
if self.max_lines > 1:
237+
indent = self.subsequent_indent
238+
else:
239+
indent = self.initial_indent
240+
if len(indent) + len(self.placeholder.lstrip()) > self.width:
241+
raise ValueError("placeholder too large for max width")
228242

229243
# Arrange in reverse order so items can be efficiently popped
230244
# from a stack of chucks.
@@ -267,15 +281,41 @@ def _wrap_chunks(self, chunks):
267281
# fit on *any* line (not just this one).
268282
if chunks and len(chunks[-1]) > width:
269283
self._handle_long_word(chunks, cur_line, cur_len, width)
284+
cur_len = sum(map(len, cur_line))
270285

271286
# If the last chunk on this line is all whitespace, drop it.
272287
if self.drop_whitespace and cur_line and cur_line[-1].strip() == '':
288+
cur_len -= len(cur_line[-1])
273289
del cur_line[-1]
274290

275-
# Convert current line back to a string and store it in list
276-
# of all lines (return value).
277291
if cur_line:
278-
lines.append(indent + ''.join(cur_line))
292+
if (self.max_lines is None or
293+
len(lines) + 1 < self.max_lines or
294+
(not chunks or
295+
self.drop_whitespace and
296+
len(chunks) == 1 and
297+
not chunks[0].strip()) and cur_len <= width):
298+
# Convert current line back to a string and store it in
299+
# list of all lines (return value).
300+
lines.append(indent + ''.join(cur_line))
301+
else:
302+
while cur_line:
303+
if (cur_line[-1].strip() and
304+
cur_len + len(self.placeholder) <= width):
305+
cur_line.append(self.placeholder)
306+
lines.append(indent + ''.join(cur_line))
307+
break
308+
cur_len -= len(cur_line[-1])
309+
del cur_line[-1]
310+
else:
311+
if lines:
312+
prev_line = lines[-1].rstrip()
313+
if (len(prev_line) + len(self.placeholder) <=
314+
self.width):
315+
lines[-1] = prev_line + self.placeholder
316+
break
317+
lines.append(indent + self.placeholder.lstrip())
318+
break
279319

280320
return lines
281321

@@ -308,36 +348,6 @@ def fill(self, text):
308348
"""
309349
return "\n".join(self.wrap(text))
310350

311-
def shorten(self, text, *, placeholder=_default_placeholder):
312-
"""shorten(text: str) -> str
313-
314-
Collapse and truncate the given text to fit in 'self.width' columns.
315-
"""
316-
max_length = self.width
317-
if max_length < len(placeholder.strip()):
318-
raise ValueError("placeholder too large for max width")
319-
sep = ' '
320-
sep_len = len(sep)
321-
parts = []
322-
cur_len = 0
323-
chunks = self._split_chunks(text)
324-
for chunk in chunks:
325-
if not chunk.strip():
326-
continue
327-
chunk_len = len(chunk) + sep_len if parts else len(chunk)
328-
if cur_len + chunk_len > max_length:
329-
break
330-
parts.append(chunk)
331-
cur_len += chunk_len
332-
else:
333-
# No truncation necessary
334-
return sep.join(parts)
335-
max_truncated_length = max_length - len(placeholder)
336-
while parts and cur_len > max_truncated_length:
337-
last = parts.pop()
338-
cur_len -= len(last) + sep_len
339-
return (sep.join(parts) + placeholder).strip()
340-
341351

342352
# -- Convenience interface ---------------------------------------------
343353

@@ -366,7 +376,7 @@ def fill(text, width=70, **kwargs):
366376
w = TextWrapper(width=width, **kwargs)
367377
return w.fill(text)
368378

369-
def shorten(text, width, *, placeholder=_default_placeholder, **kwargs):
379+
def shorten(text, width, **kwargs):
370380
"""Collapse and truncate the given text to fit in the given width.
371381
372382
The text first has its whitespace collapsed. If it then fits in
@@ -378,8 +388,8 @@ def shorten(text, width, *, placeholder=_default_placeholder, **kwargs):
378388
>>> textwrap.shorten("Hello world!", width=11)
379389
'Hello [...]'
380390
"""
381-
w = TextWrapper(width=width, **kwargs)
382-
return w.shorten(text, placeholder=placeholder)
391+
w = TextWrapper(width=width, max_lines=1, **kwargs)
392+
return w.fill(' '.join(text.strip().split()))
383393

384394

385395
# -- Loosely related functionality -------------------------------------

Misc/NEWS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ Core and Builtins
4242
Library
4343
-------
4444

45+
- Issue #18725: The textwrap module now supports truncating multiline text.
46+
4547
- Issue #18776: atexit callbacks now display their full traceback when they
4648
raise an exception.
4749

0 commit comments

Comments
 (0)