Issue #18585: Add :func:`textwrap.shorten` to collapse and truncate a piece of text to a given length.
diff --git a/Lib/test/test_textwrap.py b/Lib/test/test_textwrap.py
index c86f5cf..20b7655 100644
--- a/Lib/test/test_textwrap.py
+++ b/Lib/test/test_textwrap.py
@@ -9,9 +9,8 @@
#
import unittest
-from test import support
-from textwrap import TextWrapper, wrap, fill, dedent, indent
+from textwrap import TextWrapper, wrap, fill, dedent, indent, shorten
class BaseTestCase(unittest.TestCase):
@@ -43,6 +42,10 @@
"\nexpected %r\n"
"but got %r" % (expect, result))
+ def check_shorten(self, text, width, expect, **kwargs):
+ result = shorten(text, width, **kwargs)
+ self.check(result, expect)
+
class WrapTestCase(BaseTestCase):
@@ -777,12 +780,59 @@
self.assertEqual(indent(text, prefix, predicate), expect)
-def test_main():
- support.run_unittest(WrapTestCase,
- LongWordTestCase,
- IndentTestCases,
- DedentTestCase,
- IndentTestCase)
+class ShortenTestCase(BaseTestCase):
+
+ def test_simple(self):
+ # Simple case: just words, spaces, and a bit of punctuation
+ text = "Hello there, how are you this fine day? I'm glad to hear it!"
+
+ self.check_shorten(text, 18, "Hello there, (...)")
+ self.check_shorten(text, len(text), text)
+ self.check_shorten(text, len(text) - 1,
+ "Hello there, how are you this fine day? "
+ "I'm glad to (...)")
+
+ def test_placeholder(self):
+ text = "Hello there, how are you this fine day? I'm glad to hear it!"
+
+ self.check_shorten(text, 17, "Hello there,$$", placeholder='$$')
+ self.check_shorten(text, 18, "Hello there, how$$", placeholder='$$')
+ self.check_shorten(text, 18, "Hello there, $$", placeholder=' $$')
+ self.check_shorten(text, len(text), text, placeholder='$$')
+ self.check_shorten(text, len(text) - 1,
+ "Hello there, how are you this fine day? "
+ "I'm glad to hear$$", placeholder='$$')
+
+ def test_empty_string(self):
+ self.check_shorten("", 6, "")
+
+ def test_whitespace(self):
+ # Whitespace collapsing
+ text = """
+ This is a paragraph that already has
+ line breaks and \t tabs too."""
+ self.check_shorten(text, 62,
+ "This is a paragraph that already has line "
+ "breaks and tabs too.")
+ self.check_shorten(text, 61,
+ "This is a paragraph that already has line "
+ "breaks and (...)")
+
+ self.check_shorten("hello world! ", 12, "hello world!")
+ self.check_shorten("hello world! ", 11, "hello (...)")
+ # The leading space is trimmed from the placeholder
+ # (it would be ugly otherwise).
+ self.check_shorten("hello world! ", 10, "(...)")
+
+ def test_width_too_small_for_placeholder(self):
+ wrapper = TextWrapper(width=8)
+ wrapper.shorten("x" * 20, placeholder="(......)")
+ with self.assertRaises(ValueError):
+ wrapper.shorten("x" * 20, placeholder="(.......)")
+
+ def test_first_word_too_long_but_placeholder_fits(self):
+ self.check_shorten("Helloo", 5, "(...)")
+
if __name__ == '__main__':
- test_main()
+ unittest.main()
diff --git a/Lib/textwrap.py b/Lib/textwrap.py
index 7024d4d..b19f124 100644
--- a/Lib/textwrap.py
+++ b/Lib/textwrap.py
@@ -19,6 +19,8 @@
# since 0xa0 is not in range(128).
_whitespace = '\t\n\x0b\x0c\r '
+_default_placeholder = ' (...)'
+
class TextWrapper:
"""
Object for wrapping/filling text. The public interface consists of
@@ -277,6 +279,9 @@
return lines
+ def _split_chunks(self, text):
+ text = self._munge_whitespace(text)
+ return self._split(text)
# -- Public interface ----------------------------------------------
@@ -289,8 +294,7 @@
and all other whitespace characters (including newline) are
converted to space.
"""
- text = self._munge_whitespace(text)
- chunks = self._split(text)
+ chunks = self._split_chunks(text)
if self.fix_sentence_endings:
self._fix_sentence_endings(chunks)
return self._wrap_chunks(chunks)
@@ -304,6 +308,36 @@
"""
return "\n".join(self.wrap(text))
+ def shorten(self, text, *, placeholder=_default_placeholder):
+ """shorten(text: str) -> str
+
+ Collapse and truncate the given text to fit in 'self.width' columns.
+ """
+ max_length = self.width
+ if max_length < len(placeholder.strip()):
+ raise ValueError("placeholder too large for max width")
+ sep = ' '
+ sep_len = len(sep)
+ parts = []
+ cur_len = 0
+ chunks = self._split_chunks(text)
+ for chunk in chunks:
+ if not chunk.strip():
+ continue
+ chunk_len = len(chunk) + sep_len if parts else len(chunk)
+ if cur_len + chunk_len > max_length:
+ break
+ parts.append(chunk)
+ cur_len += chunk_len
+ else:
+ # No truncation necessary
+ return sep.join(parts)
+ max_truncated_length = max_length - len(placeholder)
+ while parts and cur_len > max_truncated_length:
+ last = parts.pop()
+ cur_len -= len(last) + sep_len
+ return (sep.join(parts) + placeholder).strip()
+
# -- Convenience interface ---------------------------------------------
@@ -332,6 +366,21 @@
w = TextWrapper(width=width, **kwargs)
return w.fill(text)
+def shorten(text, width, *, placeholder=_default_placeholder, **kwargs):
+ """Collapse and truncate the given text to fit in the given width.
+
+ The text first has its whitespace collapsed. If it then fits in
+ the *width*, it is returned as is. Otherwise, as many words
+ as possible are joined and then the placeholder is appended::
+
+ >>> textwrap.shorten("Hello world!", width=12)
+ 'Hello world!'
+ >>> textwrap.shorten("Hello world!", width=11)
+ 'Hello (...)'
+ """
+ w = TextWrapper(width=width, **kwargs)
+ return w.shorten(text, placeholder=placeholder)
+
# -- Loosely related functionality -------------------------------------