Использование Python textwrap.shorten для строки, но с шириной байтов - PullRequest
6 голосов
/ 31 мая 2019

Я бы хотел сократить строку, используя textwrap.shorten или подобную функцию.Строка может содержать символы не ASCII.Что здесь особенного, так это то, что максимальное значение width предназначено для кодирования bytes строки .Эта проблема вызвана тем, что несколько определений столбцов базы данных и некоторые шины сообщений имеют максимальную длину на основе bytes.

Например:

>>> 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

Можно использовать реализациюширина больше или равна длине заполнителя с пробелами [...], то есть 5.

Текст не должен быть сокращен больше, чем необходимо.Некоторые ошибочные реализации могут использовать оптимизации, которые в некоторых случаях приводят к чрезмерному сокращению.

Использование textwrap.wrap с количеством байтов - аналогичный вопрос, но он достаточно отличается от этого, поскольку он составляет около textwrap.wrap, а не textwrap.shorten.Только последняя функция использует placeholder ([...]), что делает этот вопрос достаточно уникальным.

Внимание : не полагайтесь ни на один из ответов здесь для сокращения строки в кодировке JSONв фиксированном количестве байтов.Для этого замените text.encode() на json.dumps(text).

Ответы [ 4 ]

3 голосов
/ 03 июня 2019

Теоретически достаточно encode вашей строки, а затем проверить, соответствует ли она ограничению "ширины".Если это так, то строка может быть просто возвращена.В противном случае вы можете взять первые байты "ширины" из закодированной строки (минус байты, необходимые для заполнителя).Чтобы убедиться, что он работает как textwrap.shorten, нужно также найти последний пробел в оставшихся байтах и ​​вернуть все до пробела + заполнитель.Если пробелов нет, нужно возвращать только местозаполнитель.

Учитывая, что вы упомянули, что вы действительно хотите ограничить его количеством байтов, функция выдает исключение, если заполнитель слишком велик.Потому что наличие заполнителя, который не помещался бы в контейнер / структуру данных с ограниченным байтом, просто не имеет смысла и позволяет избежать многих крайних случаев, которые могут привести к несовместимому «максимальному размеру байта» и «размеру байта заполнителя».

Код может выглядеть следующим образом:

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 '[...]'

И простой тестовый пример:

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)

, который возвращает

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 ☺

Функция также имеетаргумент для нормализации пробелов.Это может быть полезно в том случае, если у вас есть другой тип пробелов (новые строки и т. Д.) Или несколько последовательных пробелов.Хотя это будет немного медленнее.

Производительность

Я провел быстрый тест с использованием simple_benchmark (библиотека, которую я написал), чтобы убедиться, что она действительно быстрее.

Для теста я создаю строку, содержащую случайные символы Юникода, где (в среднем) один из 8 символов является пробелом.Я также использую половину длины строки как ширину в байтах для разделения.У обоих нет особой причины, это может повлиять на критерии, поэтому я и хотел об этом упомянуть.enter image description here

Функции, используемые в бенчмарке:

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"
)

Я также сделал второй бенчмарк, исключая функцию shorten_to_bytes_width, чтобы я мог бенчмаркировать даже более длинные строки:

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

3 голосов
/ 31 мая 2019

Это решение неэффективно, но, похоже, оно всегда работает правильно и без чрезмерного сокращения. Он служит канонической базой для тестирования любых эффективных решений.

Сначала сокращается, делая вид, что текст является строкой ASCII; это может сократить недостаточно, но не чрезмерно. Затем он неэффективно укорачивает один символ за раз и не более, чем необходимо.

import textwrap

_MIN_WIDTH = 5  # == len(textwrap.shorten(string.ascii_letters, len(string.ascii_letters) - 1)) == len('[...]')


def shorten_to_bytes_width(text: str, width: int) -> str:
    # Ref: https://stackoverflow.com/a/56401167/
    width = max(_MIN_WIDTH, width)  # This prevents ValueError if width < _MIN_WIDTH
    text = textwrap.shorten(text, width)  # After this line, len(text.encode()) >= width
    while len(text.encode()) > width:
        text = textwrap.shorten(text, len(text) - 1)
    assert len(text.encode()) <= width
    return text

Кредит: Спасибо Саньяшу за улучшение.

Тест

>>> s = '☺ Ilsa, le méchant ☺ ☺ gardien ☺'
>>> shorten_to_bytes_width(s, 27)
'☺ Ilsa, le méchant [...]'
>>> len(_.encode())
27

Проверка ответа кандидата

Любой вариант ответа можно проверить, сравнив его выходы с выходами моей функции для width из range(50, -1, -1) или как минимум range(50, 5, -1). Учитывая функцию candidate, код ниже реализует модульный тест:

import unittest

class TestShortener(unittest.TestCase):
    def test_candidate(self):
        text = '☺ Ilsa, le méchant ☺ ☺ gardien ☺'
        for width in range(50, -1, -1):
            with self.subTest(width=width):
                self.assertEqual(shorten_to_bytes_width(text, width), candidate(text, width))
2 голосов
/ 04 июня 2019

Я предложу наивное решение с циклом и проверкой длины кодированных символов, таких как len(text[index].encode()).Также добавлено время для улучшения, предложенное в этом комментарии

import textwrap, timeit

_MIN_WIDTH = 5

def A_B_B(text: str, width: int) -> str:
    width = max(_MIN_WIDTH, width)  # This prevents ValueError if width < _MIN_WIDTH
    text = textwrap.shorten(text, width)  # After this line, len(text.encode()) >= 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)  # This prevents ValueError if width < TEXTWRAP_MIN_WIDTH
    # textwrap.shorten does a lot of work like merging several spaces into one,
    # so we will use it first
    text = textwrap.shorten(text, width)
    if len(text.encode()) <= width:
        return text

    current_width = _MIN_WIDTH  # len of placeholder
    index = 0
    slice_index = 0  # we will do a slice on a last found space if necessary 
                     # (to avoid slicing in a middle of a word, for example)
    endings = ' '  # there also can be some more endings like \t \n
    while True:
        # we will use the fact that if str = str1 + str2 then
        # len(str.encode()) = len(str1.encode()) + len(str2.encode())
        new_width = current_width + len(text[index].encode()) # taking one more character
        if new_width > width:
            break
        if text[index] in endings:
            slice_index = index
        index += 1
        current_width = new_width
    if slice_index: # slice_index = 0 is a special case 
                    # when we dont go further than end of first word
        slice_index += 1  # to include found space
    text = text[:slice_index] + '[...]'
    assert len(text.encode()) <= width
    return text

s = '☺ Ilsa, le méchant ☺ ☺ gardien ☺'
n = 27

print(timeit.timeit(lambda: A_B_B(s, n), number=1000))
print(timeit.timeit(lambda: naive(s, n), number=1000))

Время:

0.032570790994213894
0.0206866109801922
1 голос
/ 04 июня 2019

Вот решение, которое пытается решить эту проблему напрямую, без использования метода проб и ошибок с textwrap.shorten(), используя разные входные строки.

Он использует рекурсивный алгоритм, основанный на образованных предположениях о минимальной и максимальной длине строки. Частичные решения (основанные на предполагаемой минимальной длине) используются для быстрого уменьшения размера проблемы.

Решение состоит из двух частей:

  1. bytes_to_char_length() вычисляет максимальное количество символов в строке, которые вписываются в число байтов (примеры работы см. Ниже).
  2. shorten_to_bytes(), который использует результат bytes_to_char_length() для расчета позиции заполнителя.
MAX_BYTES_PER_CHAR = 4


def bytes_to_char_length(input, bytes_left, start=0, max_length=None):
    if bytes_left <= 0 or (max_length is not None and max_length <= 0):
        return 0

    if max_length is None:
        max_length = min(bytes_left, len(input) - start)

    bytes_too_much = len(input[start:start + max_length].encode()) - bytes_left
    if bytes_too_much <= 0:
        return max_length

    # Conservative estimate for the min_length assuming all chars at the end were
    # only 1 Byte.
    min_length = max(max_length - bytes_too_much, bytes_left // MAX_BYTES_PER_CHAR)
    # Generous estimate for the new max_length assuming all chars at the end of
    # max_string were MAX_BYTES_PER_CHAR sized.
    max_length -= (bytes_too_much + MAX_BYTES_PER_CHAR - 1) // MAX_BYTES_PER_CHAR

    # Now take `min_length` as a partial solution and call the function
    # recursively to fill the remaining bytes.
    new_start = start + min_length
    bytes_left -= 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(text, byte_width, placeholder='', start=0):
    if len(text[start:start + byte_width + 1].encode()) <= byte_width:
        return text

    byte_width_p = byte_width - len(placeholder.encode())
    if byte_width_p <= 0:
        p = placeholder.strip()
        return p if len(p.encode()) <= byte_width else ''
    max_chars = bytes_to_char_length(text, byte_width_p, start)

    # Find rightmost whitespace if any
    w = text.rfind(' ', start, start + max_chars + 1)
    if w > 0:
        return text[start:w] + placeholder
    else:
        return text[start:start + max_chars] + placeholder

Примеры того, как bytes_to_char_length() работает

В целях иллюстрации предположим, что каждая цифра в строке кодируется в свое значение в байтах. Так '1', '2', '3', '4' занимают 1, 2, 3 и 4 байта соответственно.

За bytes_to_char_length('11111', 3) мы получим:

  1. max_length по умолчанию установлено на 3.
  2. input[start:start + max_length] = '111', который имеет 3 байта, поэтому bytes_too_much = 0
  3. Это точный размер, который мы искали, поэтому мы закончили.

Для bytes_to_char_length('441111', 10):

  1. max_length установлено на 6
  2. input[start:start + max_length] = '441111' с 12 байтами, поэтому bytes_too_much = 2
  3. min_length установлено на max_length - 2 == 4. (Требуется максимум 2 символа, чтобы занять 2 байта).
  4. max_length уменьшается на 1 (требуется минимум 1 символ, чтобы взять 2 байта).
  5. bytes_left = 0, max_length = 1
  6. Рекурсивный вызов немедленно возвращает 0, так как не осталось байтов. Результат min_length + 0 == 4.

Для bytes_to_char_length('111144', 10):

  1. max_length установлено на 6 (как раньше)
  2. input[start:start + max_length] = '111144' с 12 байтами, поэтому bytes_too_much = 2
  3. min_length установлено на max_length - 2 == 4
  4. max_length уменьшается на 1.
  5. new_start = 4, remaining_bytes = 6, max_length = 1
  6. Рекурсивный вызов: 4 + bytes_to_char_length('111144', 6, start=4, max_length=1)
  7. input[start:start + max_length] = '4' с 4 байтами, поэтому bytes_too_much = -2
  8. Немедленно возвратитесь из рекурсии, вернув max_length == 1, верните 5 как результат.

Формально он делает следующие предположения:

  • Каждый символ занимает не менее одного байта в закодированной строке.
  • Каждый символ занимает не менее MAX_BYTES_BY_CHAR в закодированной строке.
  • Для двух подстрок, если я разделю строку s на подстроки s == s1 + s2, тогда s.encode() == s1.encode() + s2.encode()

Performance

  • Он должен работать плавно даже для длинных строк ввода, так как он избегает копирования строки.
  • Согласно моим временным измерениям, для простого теста это примерно на порядок быстрее.
...