python

Проталкиваем не‐ASCII в непредназначенные для этого места

  • четверг, 29 января 2015 г. в 02:10:59
http://habrahabr.ru/post/249129/

Сидел вечером дома, думал чем бы заняться. А: у Python есть отладчик, но в нём совершенно некрасивое приглашение ко вводу. Дай‐ка я впилю туда powerline. Дело казалось бы совершенно плёвое: нужно просто создать свой подкласс pdb.Pdb со своим свойством, да?
def use_powerline_prompt(cls):
    '''Decorator that installs powerline prompt to the class
    '''
    @property
    def prompt(self):
        try:
            powerline = self.powerline
        except AttributeError:
            powerline = PDBPowerline()
            powerline.setup(self)
            self.powerline = powerline
        return powerline.render(side='left')

    @prompt.setter
    def prompt(self, _):
        pass

    cls.prompt = prompt

    return cls
Нет. На Python-3 такой код ещё работать, но на Python-2 нас уже поджидает проблема: для вывода необходимо превратить юникодную строку в набор байт, что требует указания кодировки. Ну, это просто:
encoding = get_preferred_output_encoding()

def prompt(self):
    …
    ret = powerline.render(side='left')
    if not isinstance(ret, str):
        # Python-2
        ret = ret.encode(encoding)
    return ret
. Это просто и это работает… пока пользователь не установит pdbpp. Теперь нас приветствуют ряд ошибок, связанных с тем, что pdbpp может использовать pyrepl, а pyrepl не работает с Unicode (при чём будет ли использоваться pyrepl как‐то зависит от значения $TERM: при TERM=xterm-256color я получаю ошибки от pyrepl, а при TERM= или TERM=konsole-256color — нет и всё работает нормально). Ошибки, связанные с тем, что в приглашении кто‐то не хочет видеть Unicode, не новы — ещё IPython пытался запретить Unicode в rewrite prompt (эта то, что вы увидите, если вы включите autocall в IPython и наберёте int 42). Но здесь всё гораздо хуже: pyrepl использует from __future__ import unicode_literals, при этом делая с использованием обычных строк (превращённых этим импортом в юникодные) различные операции на строке приглашения, в явном виде конвертируемой в str.

Итак, вот что нам получается нужно:
  1. Класс‐наследник unicode, который бы конвертировался в str без выбрасывания ошибок на не‐ASCII символах (конвертация осуществляется просто в виде str(prompt)). Эта часть очень проста: нужно переопределить методы __str__ и __new__ (без второго можно, в принципе, и обойтись, но так удобнее при конвертации в этот класс из следующего и для возможности явного указания кодировки, которая будет использована).
  2. Класс‐наследник str, в который бы и конвертировался предыдущий класс. Здесь переопределения двух методов категорически недостаточно:
    1. __new__нужен для удобного сохранения кодировки и отсутствие необходимости в явном преобразовании unicodestr.
    2. __contains__ и несколько других методов должны работать с юникодными аргументами так, будто текущий класс есть unicode (для неюникодных аргументов ничего менять не нужно). Дело в том, что при наличиии unicode_literals '\n' in prompt выбрасывает исключение, если prompt — байтовая строка с не‐ASCII символами, так как пытается привести prompt к unicode, а не наоборот.
    3. find и схожие функции должны работать с юникодными аргументами так, будто это байтовые строки в текущей кодировке. Это нужно, чтобы они выдавали правильные индексы, но при этом не валились с ошибками из‐за конвертации байтовой строки в юникодную (а здесь‐то почему конвертация не обратная?).
    4. __len__ должен выдавать длину строки в юникодных codepoint’ах. Эта часть нужна, чтобы pyrepl, считающий, где заканчивается приглашение (и ставящий курсор соответственно), не ошибся и не сделал гиганский пробел между приглашением и курсором. Подозреваю, что нужно на самом деле использовать не codepoint’ы, а ширину строки в экранных ячейках (то, что делает, к примеру, strdisplaywidth() в Vim).
    5. __add__ должен возвращать наш первый класс‐наследник unicode при прибавлении к юникодной строке. __radd__ должен делать то же самое. Сложение байтовых строк должно давать наш класс‐наследник str. Подробнее в следующем пункте.
    6. Ну, и наконец, __getslice__ (внимание: __getitem__ не катит, str использует deprecated __getslice__ для срезов) должен возвращать объект того же самого класса, поскольку pyrepl в самом конце складывает пустую юникодную строку, срез от текущего класса и другой срез от него же. И если эту часть обойти вниманием, то опять получим какую‐то из UnicodeError.
В результате получатся следующие два уродца:
class PowerlineRenderBytesResult(bytes):
    def __new__(cls, s, encoding=None):
        encoding = encoding or s.encoding
        self = bytes.__new__(cls, s.encode(encoding) if isinstance(s, unicode) else s)
        self.encoding = encoding
        return self

    for meth in (
        '__contains__',
        'partition', 'rpartition',
        'split', 'rsplit',
        'count', 'join',
    ):
        exec((
            'def {0}(self, *args):\n'
            '   if any((isinstance(arg, unicode) for arg in args)):\n'
            '       return self.__unicode__().{0}(*args)\n'
            '   else:\n'
            '       return bytes.{0}(self, *args)'
        ).format(meth))

    for meth in (
        'find', 'rfind',
        'index', 'rindex',
    ):
        exec((
            'def {0}(self, *args):\n'
            '   if any((isinstance(arg, unicode) for arg in args)):\n'
            '       args = [arg.encode(self.encoding) if isinstance(arg, unicode) else arg for arg in args]\n'
            '   return bytes.{0}(self, *args)'
        ).format(meth))

    def __len__(self):
        return len(self.decode(self.encoding))

    def __getitem__(self, *args):
        return PowerlineRenderBytesResult(bytes.__getitem__(self, *args), encoding=self.encoding)

    def __getslice__(self, *args):
        return PowerlineRenderBytesResult(bytes.__getslice__(self, *args), encoding=self.encoding)

    @staticmethod
    def add(encoding, *args):
        if any((isinstance(arg, unicode) for arg in args)):
            return ''.join((
                arg
                if isinstance(arg, unicode)
                else arg.decode(encoding)
                for arg in args
            ))
        else:
            return PowerlineRenderBytesResult(b''.join(args), encoding=encoding)

    def __add__(self, other):
        return self.add(self.encoding, self, other)

    def __radd__(self, other):
        return self.add(self.encoding, other, self)

    def __unicode__(self):
        return PowerlineRenderResult(self)

class PowerlineRenderResult(unicode):
    def __new__(cls, s, encoding=None):
        encoding = (
            encoding
            or getattr(s, 'encoding', None)
            or get_preferred_output_encoding()
        )
        if isinstance(s, unicode):
            self = unicode.__new__(cls, s)
        else:
            self = unicode.__new__(cls, s, encoding, 'replace')
        self.encoding = encoding
        return self

    def __str__(self):
        return PowerlineRenderBytesResult(self)
(в Python2 bytes is str).

Результат на github пока есть только в моей ветке, позже будет в develop основного репозитория.
Разумеется, результат не ограничен только pyrepl, а может применяться в различных местах, куда вам нельзя подсунуть не‐ASCII строку, но очень хочется.