def truncate(string, max_bytes):
    """
    Truncates a string to no longer than the specified number of bytes.

    >>> truncate('foobar', 8)
    'foobar'
    >>> truncate('hello', 5)
    'hello'

    Lob off "partial" words, where practical:
    >>> truncate('lorem ipsum dolor sit amet', 21)
    'lorem ipsum […]'
    >>> truncate('lorem ipsum dolor sit amet', 22)
    'lorem ipsum […]'
    >>> truncate('lorem ipsum dolor sit amet', 23)
    'lorem ipsum dolor […]'

    Otherwise, break apart the word:
    >>> truncate('howdeedoodeethere', 11)
    'howdee[…]'

    Note that ``max_bytes`` must be ≥ what's required to return the worst-case truncation:
    >>> truncate('hello world', 5)
    '[…]'
    >>> truncate('hello world', 4)
    Traceback (most recent call last):
        ...
    AssertionError: max_bytes ≱ 5
    """

    # These should really be constants, but meh…
    ellipsis = '[…]'
    space = ' '
    ellipsis_bytes = len(ellipsis.encode())
    max_bytes_available_when_truncated = max_bytes - ellipsis_bytes
    assert max_bytes_available_when_truncated >= 0, 'max_bytes ≱ {0:d}'.format(ellipsis_bytes)

    # If we're within budget, brill…
    if len(string.encode()) <= max_bytes:
        return string

    # Cut things down to size. If we snip across a multibyte character, we've asked the decoder to turn a blind eye…
    string = string.encode()[:max_bytes_available_when_truncated].decode(errors='ignore')
    # If the string (is non-empty and) ends with a "partial" word, then lob that off…
    if string and (not string[-1].isspace()):
        split = string.rsplit(maxsplit=1)
        if len(split) == 2:
            string = split[0] + space
    # Finally, tack on the ellipsis, and call it a day…
    truncated_string = string + ellipsis
    assert len(truncated_string.encode()) <= max_bytes
    return truncated_string