Skip to content

textwrap truncation discards a broken long word (and, for shorten(), the whole text) instead of breaking it further #160633

Open

Description

@qiyuhuating

Bug description

With max_lines set (or with shorten(), which uses max_lines=1), when the last line consists of a single full-width chunk produced by _handle_long_word() — i.e. a piece of a long word that break_long_words=True (the default) broke across lines — the truncation loop in _wrap_chunks() drops the whole chunk instead of breaking it further to make room for the placeholder. All remaining text can be silently replaced by the placeholder alone:

>>> import textwrap
>>> textwrap.shorten('x' * 30, width=10)
'[...]'
>>> textwrap.wrap('x' * 30, width=10, max_lines=2)
['xxxxxxxxxx', '[...]']
>>> textwrap.fill('x' * 30, width=10, max_lines=2)
'xxxxxxxxxx\n[...]'

Expected output — the piece of the word is broken further, keeping width - len(placeholder) characters before the placeholder:

>>> textwrap.shorten('x' * 30, width=10)
'xxxx [...]'
>>> textwrap.wrap('x' * 30, width=10, max_lines=2)
['xxxxxxxxxx', 'xxxx [...]']

The docs for shorten() promise that "enough words are dropped from the end so that the remaining words plus the placeholder fit within width", and the docstring says "as many words as possible are joined". When the text is a single long word, the current implementation cannot keep any part of it at all — the output carries no information about the text. Since break_long_words=True is the default, the wrapper already breaks this word on earlier lines; refusing to break it on the final truncation line discards content that would fit.

This only affects lines consisting of a single full-width chunk. All other documented truncation behavior (dropping whole words from the end, backtracking the placeholder onto the previous line, and the eager "placeholder too large for max width" check) stays exactly the same.

Analysis

In TextWrapper._wrap_chunks() (Lib/textwrap.py), the else branch that places the placeholder drops chunks one at a time until the placeholder fits:

while cur_line:
    if (cur_line[-1].strip() and
        cur_len + len(self.placeholder) <= width):
        cur_line.append(self.placeholder)
        lines.append(indent + ''.join(cur_line))
        break
    cur_len -= len(cur_line[-1])
    del cur_line[-1]

For a line whose only chunk is a full-width piece of a broken long word, no chunk ever fits with the placeholder, so the loop empties cur_line and the bare placeholder is emitted. Breaking the chunk instead (to width - len(self.placeholder) characters) preserves part of the word and is consistent with break_long_words=True and with how _handle_long_word() already broke the word on previous lines.

The affected expectations were frozen when the feature was implemented in gh-62925 (bpo-18585, 2015) and were never discussed separately.

Linked PRs

Will submit a fix shortly.

CPython versions tested on

3.14, CPython main branch

Operating systems tested on

Windows 11

Linked PRs

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions