Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Using Python textwrap.shorten for string but with bytes width

I'd like to shorten a string using textwrap.shorten or a function like it. The string can potentially have non-ASCII characters. What's special here is that the maximal width is for the bytes encoding of the string. This problem is motivated by the fact that several database column definitions and some message buses have a bytes based max length.

For example:

>>> import textwrap
>>> s = '☺ Ilsa, le méchant ☺ ☺ gardien ☺'

# Available function that I tried:
>>> textwrap.shorten(s, width=27)
'☺ Ilsa, le méchant ☺ [...]'
>>> len(_.encode())
31  # I want ⩽27

# Desired function:
>>> shorten_to_bytes_width(s, width=27)
'☺ Ilsa, le méchant [...]'
>>> len(_.encode())
27  # I want and get ⩽27

It's okay for the implementation to use a width greater than or equal to the length of the whitespace-stripped placeholder [...], i.e. 5.

The text should not be shortened any more than necessary. Some buggy implementations can use optimizations which on occasion result in excessive shortening.

Using textwrap.wrap with bytes count is a similar question but it's different enough from this one since it is about textwrap.wrap, not textwrap.shorten. Only the latter function uses a placeholder ([...]) which makes this question sufficiently unique.

Caution: Do not rely on any of the answers here for shortening a JSON encoded string in a fixed number of bytes. For it, substitute text.encode() with json.dumps(text).

like image 654
Asclepius Avatar asked May 31 '19 20:05

Asclepius


People also ask

What is the best way to wrap text in Python?

If you’re just wrapping or filling one or two text strings, the convenience functions should be good enough; otherwise, you should use an instance of TextWrapper for efficiency. textwrap. wrap (text, width=70, **kwargs) ¶ Wraps the single paragraph in text (a string) so every line is at most width characters long.

What are the advantages of using textwrapper in Python?

If you’re just wrapping or filling one or two text strings, the convenience functions should be good enough; otherwise, you should use an instance of TextWrapper for efficiency. Wraps the single paragraph in text (a string) so every line is at most width characters long. Returns a list of output lines, without final newlines.

What is the difference between textwrap shortened and textwrap indent?

Shortened: This function wraps the input paragraph such that each line n the paragraph is at most width [...] textwrap.indent (text, prefix, predicate=None): This function is used to add the given prefix to the beginning of the selected lines of the text. The predicate argument can be used to control which lines are indented.

What is the use of textwrap?

Last Updated : 31 May, 2017 The textwrap module can be used for wrapping and formatting of plain text. This module provides formatting of text by adjusting the line breaks in the input paragraph. The TextWrapper instance attributes (and keyword arguments to the constructor) are as follows:


1 Answers

In theory it's enough to encode your string, then check if it fits in the "width" constraint. If it does, then the string can be simply returned. Otherwise you can take the first "width" bytes from the encoded string (minus the bytes needed for the placeholder). To make sure it works like textwrap.shorten one also needs to find the last whitespace in the remaining bytes and return everything before the whitespace + the placeholder. If there is no whitespace only the placeholder needs to be returned.

Given that you mentioned that you really want it byte-amount constrained the function throws an exception if the placeholder is too large. Because having a placeholder that wouldn't fit into the byte-constrained container/datastructure simply doesn't make sense and avoids a lot of edge cases that could result in inconsistent "maximum byte size" and "placeholder byte size".

The code could look like this:

def shorten_rsplit(string: str, maximum_bytes: int, normalize_spaces: bool = False, placeholder: str = "[...]") -> str:
    # Make sure the placeholder satisfies the byte length requirement
    encoded_placeholder = placeholder.encode().strip()
    if maximum_bytes < len(encoded_placeholder):
        raise ValueError('placeholder too large for max width')

    # Get the UTF-8 bytes that represent the string and (optionally) normalize the spaces.    
    if normalize_spaces:
        string = " ".join(string.split())
    encoded_string = string.encode()

    # If the input string is empty simply return an empty string.
    if not encoded_string:
        return ''

    # In case we don't need to shorten anything simply return
    if len(encoded_string) <= maximum_bytes:
        return string

    # We need to shorten the string, so we need to add the placeholder
    substring = encoded_string[:maximum_bytes - len(encoded_placeholder)]
    splitted = substring.rsplit(b' ', 1)  # Split at last space-character
    if len(splitted) == 2:
        return b" ".join([splitted[0], encoded_placeholder]).decode()
    else:
        return '[...]'

And a simple test case:

t = '☺ Ilsa, le méchant ☺ ☺ gardien ☺'

for i in range(5, 50):
    shortened = shorten_rsplit(t, i)
    byte_length = len(shortened.encode())
    print(byte_length <= i, i, byte_length, shortened)

Which returns

True 5 5 [...]
True 6 5 [...]
True 7 5 [...]
True 8 5 [...]
True 9 9 ☺ [...]
True 10 9 ☺ [...]
True 11 9 ☺ [...]
True 12 9 ☺ [...]
True 13 9 ☺ [...]
True 14 9 ☺ [...]
True 15 15 ☺ Ilsa, [...]
True 16 15 ☺ Ilsa, [...]
True 17 15 ☺ Ilsa, [...]
True 18 18 ☺ Ilsa, le [...]
True 19 18 ☺ Ilsa, le [...]
True 20 18 ☺ Ilsa, le [...]
True 21 18 ☺ Ilsa, le [...]
True 22 18 ☺ Ilsa, le [...]
True 23 18 ☺ Ilsa, le [...]
True 24 18 ☺ Ilsa, le [...]
True 25 18 ☺ Ilsa, le [...]
True 26 18 ☺ Ilsa, le [...]
True 27 27 ☺ Ilsa, le méchant [...]
True 28 27 ☺ Ilsa, le méchant [...]
True 29 27 ☺ Ilsa, le méchant [...]
True 30 27 ☺ Ilsa, le méchant [...]
True 31 31 ☺ Ilsa, le méchant ☺ [...]
True 32 31 ☺ Ilsa, le méchant ☺ [...]
True 33 31 ☺ Ilsa, le méchant ☺ [...]
True 34 31 ☺ Ilsa, le méchant ☺ [...]
True 35 35 ☺ Ilsa, le méchant ☺ ☺ [...]
True 36 35 ☺ Ilsa, le méchant ☺ ☺ [...]
True 37 35 ☺ Ilsa, le méchant ☺ ☺ [...]
True 38 35 ☺ Ilsa, le méchant ☺ ☺ [...]
True 39 35 ☺ Ilsa, le méchant ☺ ☺ [...]
True 40 35 ☺ Ilsa, le méchant ☺ ☺ [...]
True 41 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 42 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 43 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 44 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 45 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 46 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 47 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 48 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 49 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺

The function also has an argument for normalizing the spaces. That could be helpful in case you have different kind of whitespaces (newlines, etc.) or multiple sequential spaces. Although it will be a bit slower.

Performance

I did a quick test using simple_benchmark (a library I wrote) to ensure it's actually faster.

For the benchmark I create a string containing random unicode characters where the (on average) one out of 8 characters is a whitespace. I also use half the length of the string as byte-width to split. Both have no special reason, it could bias the benchmarks though, that's why I wanted to mention it. enter image description here

The functions used in the benchmark:

def shorten_rsplit(string: str, maximum_bytes: int, normalize_spaces: bool = False, placeholder: str = "[...]") -> str:
    encoded_placeholder = placeholder.encode().strip()
    if maximum_bytes < len(encoded_placeholder):
        raise ValueError('placeholder too large for max width')
    if normalize_spaces:
        string = " ".join(string.split())
    encoded_string = string.encode()
    if not encoded_string:
        return ''
    if len(encoded_string) <= maximum_bytes:
        return string
    substring = encoded_string[:maximum_bytes - len(encoded_placeholder)]
    splitted = substring.rsplit(b' ', 1)  # Split at last space-character
    if len(splitted) == 2:
        return b" ".join([splitted[0], encoded_placeholder]).decode()
    else:
        return '[...]'

import textwrap

_MIN_WIDTH = 5
def shorten_to_bytes_width(text: str, width: int) -> str:
    width = max(_MIN_WIDTH, width)
    text = textwrap.shorten(text, width)
    while len(text.encode()) > width:
        text = textwrap.shorten(text, len(text) - 1)
    assert len(text.encode()) <= width
    return text

def naive(text: str, width: int) -> str:
    width = max(_MIN_WIDTH, width)
    text = textwrap.shorten(text, width)
    if len(text.encode()) <= width:
        return text

    current_width = _MIN_WIDTH
    index = 0
    slice_index = 0
    endings = ' '
    while True:
        new_width = current_width + len(text[index].encode())
        if new_width > width:
            break
        if text[index] in endings:
            slice_index = index
        index += 1
        current_width = new_width
    if slice_index:
        slice_index += 1  # to include found space
    text = text[:slice_index] + '[...]'
    assert len(text.encode()) <= width
    return text


MAX_BYTES_PER_CHAR = 4
def bytes_to_char_length(input, bytes, start=0, max_length=None):
    if bytes <= 0 or (max_length is not None and max_length <= 0):
        return 0
    if max_length is None:
        max_length = min(bytes, len(input) - start)
    bytes_too_much = len(input[start:start + max_length].encode()) - bytes
    if bytes_too_much <= 0:
        return max_length
    min_length = max(max_length - bytes_too_much, bytes // MAX_BYTES_PER_CHAR)
    max_length -= (bytes_too_much + MAX_BYTES_PER_CHAR - 1) // MAX_BYTES_PER_CHAR
    new_start = start + min_length
    bytes_left = bytes - len(input[start:new_start].encode())
    return min_length + bytes_to_char_length(input, bytes_left, new_start, max_length - min_length)


def shorten_to_bytes(input, bytes, placeholder=' [...]', start=0):
    if len(input[start:start + bytes + 1].encode()) <= bytes:
        return input
    bytes -= len(placeholder.encode())
    max_chars = bytes_to_char_length(input, bytes, start)
    if max_chars <= 0:
        return placeholder.strip() if bytes >= 0 else ''
    w = input.rfind(' ', start, start + max_chars + 1)
    if w > 0:
        return input[start:w] + placeholder
    else:
        return input[start:start + max_chars] + placeholder

# Benchmark

from simple_benchmark import benchmark, MultiArgument

import random

def get_random_unicode(length):  # https://stackoverflow.com/a/21666621/5393381
    get_char = chr
    include_ranges = [
        (0x0021, 0x0021), (0x0023, 0x0026), (0x0028, 0x007E), (0x00A1, 0x00AC), (0x00AE, 0x00FF), 
        (0x0100, 0x017F), (0x0180, 0x024F), (0x2C60, 0x2C7F), (0x16A0, 0x16F0), (0x0370, 0x0377), 
        (0x037A, 0x037E), (0x0384, 0x038A), (0x038C, 0x038C)
    ]

    alphabet = [
        get_char(code_point) for current_range in include_ranges
            for code_point in range(current_range[0], current_range[1] + 1)
    ]
    # Add more whitespaces
    for _ in range(len(alphabet) // 8):
        alphabet.append(' ')
    return ''.join(random.choice(alphabet) for i in range(length))

r = benchmark(
    [shorten_rsplit, shorten_to_bytes, shorten_to_bytes_width, naive, bytes_to_char_length],
    {2**exponent: MultiArgument([get_random_unicode(2**exponent), 2**exponent // 2]) for exponent in range(4, 15)},
    "string length"
)

I also did a second benchmark excluding the shorten_to_bytes_width function so I could benchmark even longer strings:

r = benchmark(
    [shorten_rsplit, shorten_to_bytes, naive],
    {2**exponent: MultiArgument([get_random_unicode(2**exponent), 2**exponent // 2]) for exponent in range(4, 20)},
    "string length"
)

enter image description here

like image 140
MSeifert Avatar answered Sep 21 '22 02:09

MSeifert