Локализация Python-скриптов при помощи gettext

Разрабатывая приложение, поддерживающее несколько языков, приходится делать выбор — изобретать велосипед самому, или использовать одну из существующих библиотек (а если использовать, то какую именно). Одним из наиболее распространенных механизмов является GNU gettext — библиотека для локализации приложений, портированная на все мыслимые и немыслимые платформы. Доступна она и Python-скриптам в виде встроенного модуля.

Логика её работы очень проста. Все литералы (строковые константы) в программе обёртываются вызовами функции gettext, обычно сокращаемой до одного подчёркивания: вместо

print('Hello, world!')

пишем

print(_('Hello, world!'))

Библиотека gettext загружает специальный бинарный файл с переводами (.mo), в котором хранятся соответствия строчек ('Hello, world!' -> 'Приве-ет, чувакиии...').

Бинарный файл можно скомпилировать при помощи утилиты msgfmt из состава GNU gettext. Исходным файлом для компилятора является текстовый файл определённой структуры (.po). Шаблон такого файла создается автоматически в результате сканирования исходного кода локализуемой программы при помощи другой утилиты — xgettext, которая ищет в тексте программы все вызовы функции _() и вытаскивает строчки для перевода. Сам перевод после этого можно сделать в любом текстовом редакторе. Я пользуюсь более удобным специализированным приложением Poedit, которое позволяет при помощи графического интерфейса и сканировать исходники, и переводить найденные строки.

Фразы, в которые нужно включать данные, собираются строго при помощи sprintf-подобных функций, чтобы переводчик мог, при необходимости, поменять порядок следования слов в предложении. Никогда не надо делать так (даже без локализации не надо, но с локализацией — особенно):

_('There are ') + str(number) + _(' of ') + fruit_type + _(' in the basket.')

Вместо этого надо писать

_('There are {num} of {ftype} in the basket.').format(num=number, ftype=fruit_type)

чтобы переводчик мог адаптировать фразу для другого языка, в том числе с изменением порядка слов в предложении:

'Вид фруктов в корзинке — {ftype}. Количество — {num} штук'

Кроме стандартной функции gettext(), модуль предоставляет несколько дополнительных функций — ngettext(), lgettext() и др., о назначении которых можно почитать в официальной документации.

Модуль gettext используется в Python-скриптах повсеместно, в то же время документация не совсем прозрачна. Мне оказалось непросто разобраться, как это работает, пришлось экспериментировать и изучать исходный код самого модуля.

Выбор API

Существует два принципиально разных способа вызова gettext в Python: при помощи «традиционного» GNU gettext API (аналогичного стандартному C API) и «более современного» сlass-based API. Документация рекомендует работать с сlass-based API, где все вызовы завёрнуты в класс (обычно GNUTranslations). Разница в том, что GNU gettext API действует сразу на всё приложение, в то время, как при работе с сlass-based API вся локализационная функциональность ограничена текущим модулем (или любым другим пространством имён; хоть отдельной функцией!).

Что именно использовать — зависит от задачи. Для себя я пришел к выводу, что лучше всего использовать именно GNU gettext API, вопреки рекомендациям официального мануала. Основная причина — возможность включить в состав своего приложения перевод сообщений, выводимых стандартными библиотеками Python.

Например, я использую argparse для обработки параметров командной строки. argparse по умолчанию умеет выводить полезную информацию: справку по параметрам командной строки и сообщения о недопустимых значениях указанных пользователем параметров. Внутри модуля всё это корректно завёрнуто в gettext. Но поиск файлов с переводом и выбор локализации там осуществляется автоматически по хитрому алгоритму, способы влияния на который неочевидны.

Я во всех скриптах стараюсь предусмотреть возможность указать вручную --lang=en, или --locale=ru_RU), но при использовании class-based API эти параметры будут влиять только на перевод текста непосредственно в моих исходниках. В результате описания параметров будут переводиться корректно (т.к. я их создаю в своём модуле), а всё остальное будет переводиться как получится (в зависимости от наличия файлов перевода в составе дистрибутива Python и значений переменных среды). Примерно так:

Чтобы обеспечить корректный перевод всего пользовательского интерфейса, надо либо создавать подкласс от argparse.ArgumentParser и переопределять все методы, выводящие что-либо на экран, либо использовать GNU gettext API, заставляя все встроенные модули брать переводы строк в том месте, где мы им укажем. Второй подход требует гораздо меньших трудозатрат.

Механизм работы

Если непосредственно с переводом строк всё более или менее понятно, остается вопрос, каким образом gettext выбирает язык перевода и находит нужные .mo-файлы. Опишу логику реализации этих функций; она, в основном, сконцентрирована в gettext.find(). Все остальные инициализирующие функции модуля для выбора языка и поиска .mo-файлов обращаются именно к gettext.find(), на поведение которой влияют параметры domain, localedir и languages. Также коснусь параметра codeset (почти ни на что не влияющего).

Домен

Домен (он же текстовый домен, textdomain) — это уникальный идентификатор нашего приложения, который позволяет библиотеке gettext отличать строки нашего приложения от строк других приложений.

Windows-программистам, обычно, не очень ясно зачем это надо. В Windows каждое приложение более или менее самодостаточно в рамках своей папки, в которой лежат и исполняемые файлы, и динамические библиотеки (DLL), и файлы локализации (обычно также в форме DLL), и другие всякие данные.

В *nix-системах всё иначе. Установленное приложение «размазывается» тонким слоем по всей системе: исполняемые файлы устанавливаются в /bin, /sys/bin, /usr/bin, а также /opt/<appname>/bin и другие экзотические места; динамические библиотеки — в /lib, /usr/lib и т.д.; общие данные (типа значков и картинок) — в /share и /usr/share/; данные, динамические изменяемые во время работы программы — в /var и т.д.

Причин такого «размазывания» касаться не будем, они отчасти исторические, отчасти оправданы типичными сценариями использования и администрирования *nix-систем. Суть в том, что файлы локализации всех приложений устанавливаются в одну папку (обычно /usr/share/locale) и лежат там все в куче. Чтобы они друг с другом не конфликтовали и друг друга не перезаписывали, каждый файл должен иметь уникальное имя. Это и есть домен. Обычно домен совпадает с названием приложения.

Выбор языка перевода

Для определения списка возможных языков перевода и их приоритета, функция gettext.find() изучает содержимое переданного ей параметра languages, а если он не передавался — значения переменных среды LANGUAGE, LC_ALL, LC_MESSAGES и LANG (в указанном порядке). Нахождение любого значения в одной из этих переменных приводит к тому, что остальные переменные не проверяются. То есть, если в переменных среды указано LANGUAGE=en_US.UTF-8, можно сколько угодно устанавливать LC_MESSAGES=ru_RU.UTF-8, это значение будет проигнорировано.

Параметр может содержать как одно значение, так и несколько. При передаче параметра languages, несколько значений передается в виде списка (['ru', 'en']). В переменных среды значения отделяются друг от друга двоеточием. Например, установка LANGUAGE=ru_RU.UTF-8:en_US.UTF-8 приведет к тому, что gettext сначала попытается найти файл с русским переводом, а если это не удастся, будет искать английский .mo-файл.

Все значения локалей будут автоматически «нормализованы». При указании значения ru, gettext будет рассматривать в качестве возможных вариантов ru_RU.UTF-8, ru_RU, ru.UTF-8 и просто ru. Для en будут рассмотрены en_US.ISO8859-1, en_US, en.ISO8859-1 и en (несмотря на то, что родина английского языка — не США). В конце всегда добавляется локаль по умолчанию C. Таким образом, languages=['ru', 'en'] будет превращено в допустимые варианты локали ru_RU.UTF-8, ru_RU, ru.UTF-8, ruen_US.ISO8859-1, en_US, en.ISO8859-1en и C, которые будут искаться по очереди в указанном порядке.

Выбор папки с файлами переводов

Типичная ситуация при начале использования gettext (весь интернет завален вопросами): готовим .mo-файл, называем его ru_RU.mo, кладём рядом со скриптом, выполняем в том или ином виде инициализацию, получаем ничего. gettext «не подцепляет» нужный файл.

Проблема кроется в том, какую именно папку gettext считает наиболее логичным местом для поиска файлов с переводом. Это <localedir>/<locale>/LC_MESSAGES/<domain>.mo, где:

  • <localedir> — папка с локализационными данными (по умолчанию что-то типа /usr/share/locale в *nix и c:\Program Files\Python34\share\locale в Windows).
  • <locale> — определённые на предыдущем шаге варианты локали (типа ru_RU и en_US.ISO8859-1).
  • <domain> — наш текстовый домен (по умолчанию 'messages').

То есть, если мы решили использовать текстовый домен 'myapp' и указали путь './lang' (предполагаем, что при запуске скрипта текущей папкой всегда будет папка, где он расположен), файл с русским переводом мы должны поместить в папку ./lang/ru_RU/LC_MESSAGES/myapp.mo.

А что, логично.

Кодировка

В нормальной ситуации gettext вообще не занимается кодировками. В какой кодировке текст записан в .mo-файл, в такой он и будет скормлен приложению. В составе модуля есть функции lgettext() и lngettext(), которые работают точно так же, как gettext() и ngettext(), но перед выдачей пропускают результат через .encode() с соответствующими параметрами. Стандартные модули Python эти функции не используют, так что сообщения, сгенерированные ими, не будут конвертироваться, какую кодировку ни указывай.

Если очень надо конвертировать кодировку для какого-нибудь экзотического терминала, надёжнее, наверное, будет пропускать stdout/stderr через какой-нибудь пайп.

Пример использования

Предлагаю два примера использования gettext — с использованием GNU gettext API и class-based API.

В обоих случаях создаем в папке приложения подпапки /lang/en_US/LC_MESSAGES и /lang/ru_RU/LC_MESSAGES. В каждой из них создаем при помощи Poedit файлы myapp.po и myapp.mo (последний создается автоматически при сохранении .po-файла). В свойствах каталога указываем путь поиска (sources paths) ../../../. Переводим строчки, как нам нравится.

Для русского языка у меня получился примерно такой .po-файл (я поленился и перевёл не все строчки):

GNU gettext API

Это кусочек файла из моего стандартного шаблона приложения, поэтому помимо, собственно, gettext’а здесь используется ещё пара штуковин: глобальные настройки приложения и обработчик параметров командной строки. Не стал убирать, вдруг кому будет полезно.

Проверяем:

Class-based API

Реально я такой вариант не использую, поэтому здесь не будет никаких хитрых штуковин, только минимальный работающий пример:

Наверх ↑

В последних версиях Windows сделаны шаги в направлении *nix: общие данные приложения теперь предлагается складывать не в папки приложения в /Program Files, а в папку приложения в /ProgramData. Уже начиная с Windows NT данные, уникальные для каждого пользователя, лежат в личной папке этого пользователя (хотя до сих пор есть уникумы, находящие возможным положить настройки программы в .ini-файл в папке приложения под /Program Files).