0,0
рейтинг
12 апреля 2011 в 16:15

Разработка → Как я боролся с кодировками в консоли

В очередной раз запустив в Windows свой скрипт-информер для СамИздат-а и увидев в консоли «загадочные символы» я сказал себе: «Да уже сделай, наконец, себе нормальный кросс-платформенный логгинг!»

Об этом, и о том, как раскрасить вывод лога наподобие Django-вского в Win32 я попробую рассказать под хабра-катом (Всё ниженаписанное применимо к Python 2.x ветке)

Задача первая. Корректный вывод текста в консоль

Симптомы

До тех пор, пока мы не вносим каких-либо «поправок» в проинициализировавшуюся систему ввода-вывода и используем только оператор print с unicode строками, всё идёт более-менее нормально вне зависимости от ОС.

«Чудеса» начинаются дальше — если мы поменяли какие-либо кодировки (см. чуть дальше) или воспользовались модулем logging для вывода на экран. Вроде бы настроив ожидаемое поведение в Linux, в Windows получаешь «мусор» в utf-8. Начинаешь править под Win — вылезает 1251 в консоли…

Теоретический экскурс

За параметры преобразования символов и вывода их в консоль отвечают сразу несколько параметров:
  • Кодировка по-умолчанию модуля syssys.getdefaultencoding()
  • Предпочитаемая кодировка для текущей локали — locale.getpreferredencoding()
  • Кодировка стандартных потоков sys.stdout, sys.stderr
  • Кое-какую смуту вносит и кодировка самого файла, но договоримся, что у нас всё унифицировано и все файлы в utf-8 и содержат корректный заголовок
  • «Бонусом» идёт любовь стандартных потоков выдавать исключения, если, даже при корректно установленной кодировке, не найдётся нужного символа при печати unicode строки

Ищем решение

Очевидно, чтобы избавиться от всех этих проблем, надо как-то привести их к единообразию.
И вот тут начинается самое интересное:
# -*- coding: utf-8 -*-
>>> import sys
>>> import locale
>>> print sys.getdefaultencoding()
ascii
>>> print locale.getpreferredencoding() # linux
UTF-8
>>> print locale.getpreferredencoding() # win32/rus
cp1251
# и самое интересное:
>>> print sys.stdout.encoding # linux
UTF-8
>>> print sys.stdout.encoding # win32
cp866

Ага! Оказывается «система» у нас живёт вообще в ASCII. Как следствие — попытка по-простому работать с вводом/выводом заканчивается «любимым» исключением UnicodeEncodeError/UnicodeDecodeError.

Кроме того, как замечательно видно из примера, если в linux у нас везде utf-8, то в Windows — две разных кодировки — так называемая ANSI, она же cp1251, используемая для графической части и OEM, она же cp866, для вывода текста в консоли. OEM кодировка пришла к нам со времён DOS-а и, теоретически, может быть также перенастроена специальными командами, но на практике никто этого давно не делает.

До недавнего времени я пользовался распространённым способом исправить эту неприятность:
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# ==============
#      Main script file
# ==============
import sys
reload(sys)
sys.setdefaultencoding('utf-8')
# или
import locale
sys.setdefaultencoding(locale.getpreferredencoding())
# ...

И это, в общем-то, работало. Работало до тех пор, пока пользовался print-ом. При переходе к выводу на экран через logging всё сломалось.
Угу, подумал я, раз «оно» использует кодировку по-умолчанию, — выставлю-ка я ту же кодировку, что в консоли:
sys.setdefaultencoding(sys.stdout.encoding or sys.stderr.encoding)

Уже чуть лучше, но:
  • В Win32 текст печатается кракозябрами, явно напоминающими cp1251
  • При запуске с перенаправленным выводом опять получаем не то, что ожидалось
  • Периодически, при попытке напечатать текст, где есть преобразованный в unicode символ типа ① (①), «любезно» добавленный автором в какой-нибудь заголовок, снова получаем UnicodeEncodeError!

Присмотревшись к первому примеру, нетрудно заметить, что так желаемую кодировку «cp866» можно получить только проверив атрибут соответствующего потока. А он далеко не всегда оказывается доступен.
Вторая часть задачи — оставить системную кодировку в utf-8, но корректно настроить вывод в консоль.
Для индивидуальной настройки вывода надо переопределить обработку выходных потоков примерно так:
import sys
import codecs
sys.stdout = codecs.getwriter('cp866')(sys.stdout,'replace')

Этот код позволяет убить двух зайцев — выставить нужную кодировку и защититься от исключений при печати всяких умляутов и прочей типографики, отсутствующей в 255 символах cp866.
Осталось сделать этот код универсальным — откуда мне знать OEM кодировку на произвольном сферическом компе? Гугление на предмет готовой поддержки ANSI/OEM кодировок в python ничего разумного не дало, посему пришлось немного вспомнить WinAPI
UINT GetOEMCP(void); // Возвращает системную OEM кодовую страницу как число
UINT GetANSICP(void); // то же для ANSI кодовой странцы

… и собрать всё вместе:
# -*- coding: utf-8 -*-
import sys
import codecs

def setup_console(sys_enc="utf-8"):
    reload(sys)
    try:
        # для win32 вызываем системную библиотечную функцию
        if sys.platform.startswith("win"):
            import ctypes
            enc = "cp%d" % ctypes.windll.kernel32.GetOEMCP() #TODO: проверить на win64/python64
        else:
            # для Linux всё, кажется, есть и так
            enc = (sys.stdout.encoding if sys.stdout.isatty() else
                        sys.stderr.encoding if sys.stderr.isatty() else
                            sys.getfilesystemencoding() or sys_enc)

        # кодировка для sys
        sys.setdefaultencoding(sys_enc)

        # переопределяем стандартные потоки вывода, если они не перенаправлены
        if sys.stdout.isatty() and sys.stdout.encoding != enc:
            sys.stdout = codecs.getwriter(enc)(sys.stdout, 'replace')

        if sys.stderr.isatty() and sys.stderr.encoding != enc:
            sys.stderr = codecs.getwriter(enc)(sys.stderr, 'replace')

    except:
        pass # Ошибка? Всё равно какая - работаем по-старому...

Задача вторая. Раскрашиваем вывод

Насмотревшись на отладочный вывод Джанги в связке с werkzeug, захотелось чего-то подобного для себя. Гугление выдаёт несколько проектов разной степени проработки и удобности — от простейшего наследника logging.StreamHandler, до некоего набора, при импорте автоматически подменяющего стандартный StreamHandler.

Попробовав несколько из них, я, в итоге, воспользовался простейшим наследником StreamHandler, приведённом в одном из комментов на Stack Overflow и пока вполне доволен:
class ColoredHandler( logging.StreamHandler ):
    def emit( self, record ):
        # Need to make a actual copy of the record
        # to prevent altering the message for other loggers
        myrecord = copy.copy( record )
        levelno = myrecord.levelno
        if( levelno >= 50 ): # CRITICAL / FATAL
            color = '\x1b[31;1m' # red
        elif( levelno >= 40 ): # ERROR
            color = '\x1b[31m' # red
        elif( levelno >= 30 ): # WARNING
            color = '\x1b[33m' # yellow
        elif( levelno >= 20 ): # INFO
            color = '\x1b[32m' # green
        elif( levelno >= 10 ): # DEBUG
            color = '\x1b[35m' # pink
        else: # NOTSET and anything else
            color = '\x1b[0m' # normal
        myrecord.msg = (u"%s%s%s" % (color, myrecord.msg, '\x1b[0m')).encode('utf-8')  # normal
        logging.StreamHandler.emit( self, myrecord )

Однако, в Windows всё это работать, разумеется, отказалось. И если раньше можно было «включить» поддержку ansi-кодов в консоли добавлением «магического» ansi.dll из проекта symfony куда-то в недра системных папок винды, то, начиная (кажется) с Windows 7 данная возможность окончательно «выпилена» из системы. Да и заставлять юзера копировать какую-то dll в системную папку тоже как-то «не кошерно».

Снова обращаемся к гуглу и, снова, получаем несколько вариантов решения. Все варианты так или иначе сводятся к подмене вывода ANSI escape-последовательностей вызовом WinAPI для управления атрибутами консоли.

Побродив некоторое время по ссылкам, набрёл на проект colorama. Он как-то понравился мне больше остального. К плюсам именно этого проекта ст́оит отнести, что подменяется весь консольный вывод — можно выводить раскрашенный текст простым print u"\x1b[31;40mЧто-то красное на чёрном\x1b[0m" если вдруг захочется поизвращаться.

Сразу замечу, что текущая версия 0.1.18 содержит досадный баг, ломающий вывод unicode строк. Но простейшее решение я привёл там же при создании issue.

Собственно осталось объединить оба пожелания и начать пользоваться вместо традиционных «костылей»:
# -*- coding: utf-8 -*-
import sys
import codecs
import copy
import logging

#: Is ANSI printing available
ansi = not sys.platform.startswith("win")

def setup_console(sys_enc='utf-8', use_colorama=True):
    """
    Set sys.defaultencoding to `sys_enc` and update stdout/stderr writers to corresponding encoding

    .. note:: For Win32 the OEM console encoding will be used istead of `sys_enc`
    """
    global ansi
    reload(sys)
    try:
        if sys.platform.startswith("win"):
#... код, показанный выше
        if use_colorama and sys.platform.startswith("win"):
            try:
                # пробуем подключить colorama для винды и взводим флаг `ansi`, если всё получилось
                from colorama import init
                init()
                ansi = True
            except:
                pass

class ColoredHandler( logging.StreamHandler ):
    def emit( self, record ):
        # Need to make a actual copy of the record
        # to prevent altering the message for other loggers
        myrecord = copy.copy( record )
        levelno = myrecord.levelno
        if( levelno >= 50 ): # CRITICAL / FATAL
            color = '\x1b[31;1m' # red
        elif( levelno >= 40 ): # ERROR
            color = '\x1b[31m' # red
        elif( levelno >= 30 ): # WARNING
            color = '\x1b[33m' # yellow
        elif( levelno >= 20 ): # INFO
            color = '\x1b[32m' # green
        elif( levelno >= 10 ): # DEBUG
            color = '\x1b[35m' # pink
        else: # NOTSET and anything else
            color = '\x1b[0m' # normal
        myrecord.msg = (u"%s%s%s" % (color, myrecord.msg, '\x1b[0m')).encode('utf-8')  # normal
        logging.StreamHandler.emit( self, myrecord )

Дальше в своём проекте, в запускаемом файле пользуемся:
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from setupcon import setup_console
setup_console('utf-8', False)
#...

# или если будем пользоваться раскрашиванием логов
import setupcon
setupcon.setup_console()
import logging
#...
if setupcon.ansi:
    logging.getLogger().addHandler(setupcon.ColoredHandler())


На этом всё. Из потенциальных доработок осталось проверить работоспособность под win64 python и, возможно, добаботать ColoredHandler чтобы проверял себя на isatty, как в более сложных примерах на том же StackOverflow.

Итоговый вариант получившегося модуля можно забрать на dumpz.org
Александр Воронин @av0000
карма
32,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (35)

  • +2
    это:
    logging.getLogger().addHandler(setupcon.ColoredLogger())
    вот так:
    logging.getLogger().addHandler(setupcon.ColoredHandler())
    ?
    • +1
      Разумеется :)
      Ох уж мне этот автопилот… Поправил, благодарю
  • –3
    Ох. С этим в питоне всегда столько секса. Помню в QT мучался. Там был какой то хак, не знаю что он такого делал, но после этого все работало
    reload(sys)
    sys.setdefaultencoding('utf-8')
    • 0
      Только что заметил, что в топике как раз описан этот способ :)
  • 0
    Когда же начнется переход на Py3…
    • +5
      Для windows чудеса с cp866 в консоли и cp1251 во всех остальных местах остаются.
      Py3k уменьшает проблемы со строками — но от знаний основ работы с кодировоками не избавляет.
  • 0
    Для логов есть полустандартный велосипед pypi.python.org/pypi/logutils/
    Раскрашивает консоль, помимо всего прочего.

    Использовать reload(sys) и sys.setdefaultencoding(...) — дурной тон.
    Одно то, что site убирает эту функцию из модуля — должно непрозрачно намекать, что разработчики языка хотели максимально ограничить использование setdefaultencoding. Не стоит гладить кошку против шерсти.
    • 0
      Ну, да, получил тот же велосипед, «но свой» :) Как оправдание — мой таки одноколёсный и в абстрактном сферическом линуксе не тянет за собой ничего стороннего. По большому счёту статья — заметка на память, дабы каждый раз не искать «и как же оно там делалось-то».

      Про reload — согласен, а толку… Если то же Qt норовил (боролся в pyQt 4.6.что-то, дальше не знаю) воспользоваться именно sys.getdefaultencoding() и корёжить всякий ввод/вывод. Можно сделать reload опцией, например…
    • –1
      Нет версии для Win64.
      • +1
        В смысле — нет?
        Там же чистый питоновский код и пара вызовов ctypes, которые работают одинаково что на win32 что на win64.
        Не так?
        • –1
          Всё так. Я про установщик. Конечно, можно распотрошить tar.gz и взять в свой проект. Немного некрасиво, но работает.
          • +1
            Не нужно потрошить — следует делать python setup.py install
  • 0
    Удивительно встретить на просторах Хабра статью о скрипте для самиздата — тоже ваял его для себя, правда, в итоге добавил Qt-шный интерфейс и выложил.
    Чем СИшные страницы парсите?
    • +1
      Начинал с BeautifulSoap. Тормоз и глюкало :(

      В работающей версии — re+lxml+неработающий гуй на Qt (фактически иконка в трее и окно настроек частоты проверки), тем более что lxml даёт более подходящий мне diff на html-e. Какая-то версия этого дела даже лежит в гуглокоде. Там, кажись только re и htmldiff+ElementTree на генерацию diff-ов и нговостей (http://code.google.com/p/py-samizdat/)

      В неспешно пишущейся сейчас — ручной разбор строк, re, HTMLParser. Во что выльется дальше — пока не ясно…

      ЗЫ: я те только качаю новости, но и выкачиваю тексты чтоб не потерять, если автор убирает произведение…
  • –8
    Есть очень простое решение — не писать на питоне.
    • +4
      Есть и еще более простое — не писать. Тогда эффект «кривых рук» никак не проявится.
    • +4
      К тому, что виндоус — говно (во многих местах), питон отношения не имеет. Если бы его создатели думали мозгом больше, то проблем было бы меньше. Консольное окошко — это просто изделие века. Миксовать 3 разных кодировки по системе — это супер.
  • 0
    таки logging всёравно же будет вызывать __repr__ или __str__ для конвертирования объектов в строку.
    ваши махинации с консолью спасут от необходисомти везде лепить
    __repr__ = lambda self: unicode(self).encode("utf-8")?
    • 0
      тоесть даже unicode(self).encode(get_whatever_console_encoding()) для кросплатформизма.
    • 0
      Ничего не понял. Для каких-таких объектов будет вызываться __str__ или __repr__?
      • 0
        например, для
        logging.debug(«foo=%s»,MyClass(u«блабла»))
        logging.debug(«foo=%s»,{'foo': MyClass(u«блабла»)})
        logging.debug(«foo=%s»,ForeignClass(MyClass(u«блабла»)))
        насколько я понимаю, в первом случае вызовется Foo.__str__ из logger
        во втором — изнутри dict.__repr__ и там уже непонятно repr или str

        в третьем случае может случиться вобще всё что угодно.
        (такой случай происходит, например, при рисовании графа сделанного networx через graphviz)

        • +1
          Достаточно иметь
          logging.debug(u'foo=%s', 'string')
          Logging handler (или stream для StreamHandler, например) должен знать, как преобразовать юникод в байты для дальнейшего использования.

          Преобразование объектов, выполняемое для форматирования строк — не забота системы логирования.
          Тем не менее создание необходимых __unicode__ (если мы говорим о python 2.x) не считаю чем-то сложным или непонятным.
  • –3
    Я уж надеялся, что это полное решение проблемы Юникода в Python под Windows…
    То есть я хочу сказать, что печатать Юникод в консоль Windows можно. И не ограничиваясь системной (или какой-то одной) восьмибитной кодировкой. Для этого есть соответствующие API. А Python использует stdout, который идёт из стандартной библитеки Си, которая все эти приколы Windows не знает.
    • +3
      Python использует GetStdHandle(STD_OUTPUT_HANDLE) в качестве stdout.
      Это — обычный дескриптор. Работает с байтами (WriteFile и так далее).
      Где там Unicode API?
      stdout — это ведь не всегда консоль!
      • –1
        Python/sysmodule.c
        PyObject *
        _PySys_Init(void)
        {
        //...
        sysin = PyFile_FromFile(stdin, "", "r", NULL);
        sysout = PyFile_FromFile(stdout, "", "w", _check_and_flush);
        syserr = PyFile_FromFile(stderr, "", "w", _check_and_flush);
        //...
        }

        А вы что нашли?

        Эти функции корректно работают с Юникодом. Кодовая страница не имеет никакого значения (хотя на терминале охвата шрифтов не хватит для всего Юникода). Попробуйте, скажем:
        import ctypes
        ctypes.windll.msvcrt._putwch(1093)


        Ну хорошо, это файл. Только к чему вы это сказали? Проверить, что это на самом деле, во время выполнения - не так сложно.
        • +1
          Я попытался вспомнить, как создается stdout в CRT. А сверху — да. Файлы из стандартной библиотеки.

          Теперь займемся предложенными функциями из console io.

          Скрестить их и sys.stdout (который в питоне полноценный файл по определению) не совсем легко.
          Насколько я помню, в последний раз это обсуждалось в python-dev прошедшими весной-летом — и ни к чему не пришли.
          Получалось, что если запустили не в консоли — то stdout прикидывается файлом (pipe на самом деле, сейчас не важно). А если консоль — то уже не файл а console io. Со своими тараканами.

          Выходило, что полной совместимости и прозрачности так не добиться. На Windows свои штучки, на Posix — свои.
          И если уж не получается сделать так, чтобы один и тот же код работал везде одинаково — то не стоит предлагать полурабочее решение.

          Если хочется работать с console io — следует делать import msvcrt, там все есть. Оборачивать их в file-like object (или io.TextIOBase для тройки) и подставлять в sys.stdout. Проблемы, способные выползти при subprocess.Popen(..., stdout=subprocess.PIPE), например — пусть разработчик решает сам. Или что там еще может произойти при вызове C Extension, которая печатает в CRT stdout.Если что-то поломается — пусть будут виноваты не внутренности Питона, а программист в явном виде.

          Быть может когда-нибудь найдется разработчик, который починит всё-всё-всё. Пока такого нет, а возможные неоднозначности при решении «в лоб» — есть. И различных аспектов, неочевидных при беглом осмотре — очень много.

          Еще раз повторюсь: тема консоли и юникода для Windows возникает регулярно. И спецы, съевшие на этом деле не один пуд соли, вываливали десятки специфических случаев в которых простое решение не работало. Я этим спецам верю.
          • –1
            А разве стандартная библиотека C, в свою очередь, не оборачивает тот самый Console IO?
            • +1
              Мы говорим о _putwch или fwrite?
              О stdout или таки о console io?
              • 0
                Стандартная библиотека C, то бишь fwrite и компания, не есть самый низкий уровень. Она же построена поверх чего-то.
                Было бы странно, если текст в консоль попадал принципиально иными путями. Мне кажется, это все равно в итоге сводится к Console IO.
                • +1
                  _putch превращается в WriteConsoleW (возвращаясь к WriteConsoleA есть WriteConsoleW возвращает ERROR_CALL_NOT_IMPLEMENTED — кажется возможно только на Win9x).
                  fwrite — это WriteFile с буферизацией.

                  Довольно разные штуки. Т.е. если есть handle для stdout — с ним можно работать через file api или через console api (оба из kernel32). Они очень слабо пересекаются. Смешивать способы настоятельно не рекомендуется.
                  • 0
                    Почитал комментарии к багу на багтрекере Python, и понял, что ждать фикса в ближайшем времени бессмысленно. Ох, Windows.
                    • +1
                      Ааа. Знаменитый баг.
                      После него проникаешься могучей любовью к cmd.exe
                      При этом, что обидно, само по себе API вполне приличное, можно было бы работать. Именно cmd.exe создает массу проблем на пустом месте.
  • 0
    а cygwin использовать, не?
    • 0
      Насколько знаю, cygwin стоит не у каждого пользователя Windows. Большинство обходится без него — и они тоже хотят запускать программы, написанные на Питоне.
      • –1
        Ну так и питон мало у кого стоит. А если вы ПО распространяете, то можно сделать сборку cygwin + python.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.