Глава 3. Локализация Aiogram, создание переводов и работа с Babel.
Шаблоны переводов
Переходим ко второму шагу к локализации. Теперь нам необходимо создать сами переводы, основываясь на переменных, которые уже есть в нашем коде и создать папки по такой структуре.
...
locales
├── messages.pot
├── en
│ └── LC_MESSAGES
│ └── my-super-bot.po
├── ru
│ └── LC_MESSAGES
│ └── my-super-bot.po
...
Предварительно нужно только создать папку locales в корне проекта. Остальная структура создается автоматически с помощью ранее установленного пакета утилит Babel.
Создаем основу — шаблон переводов. Запускаем в корне проекта из командной строки команду:
pybabel extract --input-dirs=. -o locales/messages.pot
Утилита проходит по нашим файлам и извлекает все строковые переменные,
обернутые функциями _()
и __()
, в файл шаблона messages.pot.
У нас получится файл .pot
(Portable Object Template, а в
простонародье горшок) — это шаблон переводов, на основании которого
генерируются переводы:
# Translations template for Bot Super Project.
# Copyright (C) 2024 John Doe
# This file is distributed under the same license as the Bot Super Project
# project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2024.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: Bot Super Project 0.1\n"
"Report-Msgid-Bugs-To: john@doe-email.com\n"
"POT-Creation-Date: 2024-01-12 16:11+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.13.1\n"
#: lesson1.py:13
msgid "Hello, {name}!"
msgstr ""
Обратите внимание, что при работе с gettext и Babel комментари,
начинающиеся с #
, являются значимыми — то есть их нельзя удалять.
Файл мы заполним своими данными (выделенные строки): это название проекта, версия, копирайты и электронный адрес для связи в случае багов. По идее это можно сразу сделать из командной строке при формировании шаблона переводов:
pybabel extract -o locales/messages.pot --copyright-holder="John Doe" --project="Bot Super Project" --version=0.1 --msgid-bugs-address=john@doe-email.com --input-dirs=.
Шаблон генерируется каждый раз после исправления или доработки кода, поэтому мы не храним его в репозитории исходников git. На основании шаблона будут создаваться файлы переводов на нужные нам языки.
Давайте создадим файл перевода на английский язык. Выполним в командной строке:
pybabel init -i locales/messages.pot -d locales -D my-super-bot -l en
А затем на русский:
pybabel init -i locales/messages.pot -d locales -D my-super-bot -l ru
Где,
-i locales/messages.pot
- путь к нашему шаблону .pot
-d locales
- наш каталог переводов
-D my-super-bot
- наш домен переводов
-l en
— код языка.
Будет создан файл перевода my-super-bot.po
в папке locales/en/LC_MESSAGES/
и locales/ru/LC_MESSAGES/
.
Файлы переводов .po
Файлы в формате .po
предназначены для переводчиков. И храним мы их в
репозитории в development ветке. Они нам нужны на случай изменения или
добавления строк в проекте. Генерация новых .po
файлов происходит с
учетом старых. Об этом чуть позже. Сначала откроем созданные файлы и
отредактируем их.
Нас интересуют строки вида
#: lesson1.py:13
msgid "Hello, {name}!"
msgstr ""
В комментарии указан файл, откуда взялась текстовая строка и номер
строки в этом файле. Затем идентификатор msgid
и перевод msgstr
,
который будет подставлен пользователю с выбранным языком. Заполняем
перевод msgstr
в обоих локалях ru и en.
Для ru
#: lesson1.py:13
msgid "Hello, {name}!"
msgstr "Привет, {name}!"
Для en
#: lesson1.py:13
msgid "Hello, {name}!"
msgstr "Hello, {name}!"
Теперь пользователь у которого язык английский, получит английское сообщение, а русский — русское. Естественно какой у пользователя язык, мы должны считать через наш middleware i18n.
Затем обязательно компилируем переводы в формат .mo
и готово:
pybabel compile -d locales -D my-super-bot
Внесение изменений в файлы переводов .po
Разберем еще один момент, связанный с изменениями переводов.
В какой-то момент мы решили изменить логику бота. И изменили код программы, изменив старые строки и добавив новые. Естественно мы вносим изменения в код в парадигме интернационализации.
1from aiogram import Bot, Dispatcher, F, html
2from aiogram.types import Message
3
4from aiogram.utils.i18n import gettext as _
5from aiogram.utils.i18n import lazy_gettext as __
6from aiogram.utils.i18n import I18n, ConstI18nMiddleware
7
8
9TOKEN = "token"
10dp = Dispatcher()
11
12
13@dp.message(F.text == __('start'))
14async def handler_1(message: Message) -> None:
15 await message.answer(_("Welcome, {name}!").format(name=html.quote(message.from_user.full_name)))
16 await message.answer(_("How many coins do you have? Input number, please:"))
17
18@dp.message(F.text)
19async def handler_2(message: Message) -> None:
20 await message.answer(_("You have {} coins!").format(message.text))
21
22
23def main() -> None:
24 bot = Bot(TOKEN, parse_mode="HTML")
25 i18n = I18n(path="locales", default_locale="en", domain="my-super-bot")
26 dp.message.outer_middleware(ConstI18nMiddleware(locale='en', i18n=i18n))
27 dp.run_polling(bot)
28
29
30if __name__ == "__main__":
31 main()
Мы добавили вопрос к пользователю и переделали приветственное сообщение.
Теперь нам снова нужно извлечь строки. Формируем .pot
файл. Для
удобства в версию добавляем минорный релиз 0.1.1.
pybabel extract -o locales/messages.pot --copyright-holder="John Doe" --project="Bot Super Project" —version=0.1.1 --msgid-bugs-address=john@doe-email.com —input-dirs=.
И получаем новый шаблон:
1# Translations template for Bot Super Project.
2# Copyright (C) 2024 John Doe
3# This file is distributed under the same license as the Bot Super Project
4# project.
5# FIRST AUTHOR <EMAIL@ADDRESS>, 2024.
6#
7#, fuzzy
8msgid ""
9msgstr ""
10"Project-Id-Version: Bot Super Project 0.1.1\n"
11"Report-Msgid-Bugs-To: john@doe-email.com\n"
12"POT-Creation-Date: 2024-01-12 17:25+0500\n"
13"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
14"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
15"Language-Team: LANGUAGE <LL@li.org>\n"
16"MIME-Version: 1.0\n"
17"Content-Type: text/plain; charset=utf-8\n"
18"Content-Transfer-Encoding: 8bit\n"
19"Generated-By: Babel 2.13.1\n"
20
21#: lesson1.py:15
22msgid "Welcome, {name}!"
23msgstr ""
24
25#: lesson1.py:16
26msgid "How many coins do you have? Input number, please:"
27msgstr ""
28
29#: lesson1.py:20
30msgid "You have {} coins!"
31msgstr ""
Обновляем файлы переводов командой update.
pybabel update -i locales/messages.pot -d locales -D my-super-bot -l ru
pybabel update -i locales/messages.pot -d locales -D my-super-bot -l en
И мы видим следующую картину.
1# Russian translations for Bot Super Project.
2# Copyright (C) 2024 John Doe
3# This file is distributed under the same license as the Bot Super Project
4# project.
5# FIRST AUTHOR <EMAIL@ADDRESS>, 2024.
6#
7msgid ""
8msgstr ""
9"Project-Id-Version: Bot Super Project 0.1\n"
10"Report-Msgid-Bugs-To: john@doe-email.com\n"
11"POT-Creation-Date: 2024-01-12 17:28+0500\n"
12"PO-Revision-Date: 2024-01-12 16:16+0500\n"
13"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
14"Language: ru\n"
15"Language-Team: ru <LL@li.org>\n"
16"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
17"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
18"MIME-Version: 1.0\n"
19"Content-Type: text/plain; charset=utf-8\n"
20"Content-Transfer-Encoding: 8bit\n"
21"Generated-By: Babel 2.13.1\n"
22
23#: lesson1.py:15
24#, fuzzy
25msgid "Welcome, {name}!"
26msgstr "Привет, {name}!"
27
28#: lesson1.py:16
29msgid "How many coins do you have? Input number, please:"
30msgstr ""
31
32#: lesson1.py:20
33msgid "You have {} coins!"
34msgstr ""
Прежний перевод сохранился, но при этом у нас строка была изменена с Hello на Welcome.
Babel увидел это, сохранил нам строку, но пометил перевод коментарием
#, fuzzy
что обозначает нечеткий или неточный перевод. Если скомпилировать сразу,
то такая строка не будет переводиться и отображаться пользователю.
Нам нужно поправить текст и убрать эту метку fuzzy
.
23#: lesson1.py:15
24msgid "Welcome, {name}!"
25msgstr "Добро пожаловать, {name}!"
26
27#: lesson1.py:16
28msgid "How many coins do you have? Input number, please:"
29msgstr "Сколько у тебя монет? Введи число, пожайлуйста:"
30
31#: lesson1.py:20
32msgid "You have {} coins!"
33msgstr "У тебя {} монет!"
То же самое делаем со вторым языком, и снова компилируем переводы.
В результате у нас все хорошо, кроме такого момента.
Если мы введем 1, то бот ответит «У тебя 1 монет!» или «You have 1 coins!», что с точки зрения грамматики любого языка — неверно.
Множественные формы
Например, в русском языке используются несколько множественных форм: 1 монета 2, 3 или 4 монет, 11 монет. А если слово сообщения, то 1 сообщение, 2 сообщения, 10 сообщений. И в английском у нас тоже проблема со множественными числами — 1 coins, хотя ожидалось 1 coin, 2 coins.
Давайте победим и эту историю.
Помните, я говорил о значащих комментариях в файлах .pot
и .po
.
В частности в файле переводов .po
для каждого языка формируется формула,
которая определяет количество множественных форм и правила их формирования.
Тут и будет вся магия работы с переводами множественных форм. Она содержится в строчках:
16"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
17"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
Это формула, по которой определяется для конкретного языка форма слова во множественном числе. Формулу разберем потом. А для начала нам нужно вернуться к интернационализации нашего кода.
Функция gettext не умеет работать со множественными формами. Для этого
существует ngettext из стандартной библиотеки python.
Однако для удобства в Aiogram это уже все спрятано в функции gettext
из aiogram.utils.i18n
.
Добавляем два идентификатора: передаем фразу в единственном, затем во множественном числе, и аргумент, принимающий число. Не забываем привести принимаемый от пользователя текст к int.
Изменим наш код.
18@dp.message(F.text)
19async def handler_2(message: Message) -> None:
20 try:
21 n = int(message.text)
22 await message.answer(_("You have {} coin!", "You have {} coins!", n).format(n))
23 except:
24 await message.answer(_("Please, enter a number"))
Теперь снова нужно произвести извлечение строк с помощью Babel. Для
извлечения строк с разным количеством аргументов, нам нужно запускать
pybabel extract с опциями -k _:1,1t
-k _:1,2
для gettext и
-k __
для lazy gettext (два подчеркивания).
pybabel extract -o locales/messages.pot -k _:1,1t -k _:1,2 -k __ \
--copyright-holder="John Doe" \
--project="Bot Super Project" \
--version=0.1.1 --msgid-bugs-address=john@doe-email.com \
--input-dirs=.
Babel может неадекватно извлекать строки, поэтому можно воспользоваться
командой xgettext
из пакета утилит GNU gettext.
xgettext -L Python --keyword=_:1,2 --keyword=__ -d my-super-bot
Заглянем в наш шаблон .pot
, и увидим, что теперь перевод имеет
строку для перевода единственного и множественного числа:
33#: lesson1.py:22
34msgid "You have {} coin!"
35msgid_plural "You have {} coins!"
36msgstr[0] ""
37msgstr[1] ""
Обновим перевод каждой из локалей:
pybabel update -i locales/messages.pot -d locales -D my-super-bot -l ru
и
pybabel update -i locales/messages.pot -d locales -D my-super-bot -l en
При генерации Babel по коду языка сгенерировал в файле .po для каждого языка свою формулу определения форм слова, а также сами строки для правильного перевода каждой формы.
В английской версии у нас две формы единственное и множественное число:
16"Plural-Forms: nplurals=2; plural=(n != 1);\n"
31#: lesson1.py:22
32msgid "You have {} coin!"
33msgid_plural "You have {} coins!"
34msgstr[0] ""
35msgstr[1] ""
Ниже Babel пометил старые строки удаленными с помощью комментария
#~
. (У меня не было перевода в английском файле, я забыл их
добавить. Поэтому строка msgstr
пустая.) Babel посчитал их не
нужными, потому что теперь появились такие же строки с множественными
формами)
37#~ msgid "You have {} coins!"
38#~ msgstr ""
В русском языке три множественных формы. Единственное, малое множественное и множественное:
16"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
17"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
31#: lesson1.py:22
32#, fuzzy
33msgid "You have {} coin!"
34msgid_plural "You have {} coins!"
35msgstr[0] "У Вас {} монет!"
36msgstr[1] ""
37msgstr[2] ""
Здесь Babel сохранил наш старый перевод из предыдущего файла .po
, именно поэтому я говорил,
что они нам нужны в ветке development. Он пометил данный перевод как неточный fuzzy
, чтоб мы исправили.
Вернемся теперь к формуле. Формула для вычисления множественных форм - это обычная тернарная
условная операция на СИ-подобном языке вида condition ? true : false
.
И именно для ее работы мы компилируем переводы.
Итак, в английском у нас две формы слова: nplurals=2
. А plural=(n != 1);\n"
означает формулу вычисления, по которой определяется надо ли ставить фразу в единственном числе или множественном,
в зависимости от переданного n
:
если полученное в основном коде из нашей функции
_("You have {} coin!", "You have {} coins!", n).format(n))
числоn
равно 1, то выражениеn != 1
возвращает 0 (То есть False. False неявно приводится к int и равно 0), и строки берутся изmsgstr[0]
. Это единственное число.если n не равно 1, то выражение
n != 1
, то возвращает 1 (True) и форма слова является множественным числом. Берутся данные изmsgstr[1]
.
Заполняем:
#: lesson1.py:22
msgid "You have {} coin!"
msgid_plural "You have {} coins!"
msgstr[0] "You have {} coin!"
msgstr[1] "You have {} coins!"
Если же для языка нет перевода, то отобразятся строки msgid
«You have {} coin!» и msgid_plural
«You have {} coins!»
В русском языке три формы слова nplurals=3
. Формула
plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " "n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
означает:
Если выражение
n%10==1 && n%100!=11
верно иn
заканчивается на единицу, но не заканчивается на 11, то возвращается 0 (потому что в тернарном выражении явно указано возвращать ноль после двоеточия). Берутся данные изmsgstr[0]
. И это и единственное число. То есть 1 монета, 101 монета, но не 111 монет.Иначе: вычисляем
n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20)
, это форма для чисел, заканчивающихся на 2, 3 и 4. Например, 3 монеты и 44 монеты. Если выражение верно, то возвращаем 1. Берутся данные изmsgstr[1]
.Иначе: возвращаем 2. И это остальные числа. 5, 11, 56, 110, 111 и т.д. монет. Берутся данные из
msgstr[2]
.
Выбор перевода это просто взятие k-го элемента msgstr[k]
, где k
вычислено по этой формуле.
Из предыдущего перевода (старого файла .po, который мы специально не удаляли) у нас подставилась часть ранее переведенных строк. Поэтому переводим недостающие элементы, не забываем удалить строки, помеченные для удаления, и метки неточного перевода fuzzy.
#: lesson1.py:22
msgid "You have {} coin!"
msgid_plural "You have {} coins!"
msgstr[0] "У Вас {} монета!"
msgstr[1] "У Вас {} монеты!"
msgstr[2] "У Вас {} монет!"
Компиляция переводов, файлы формата mo.
Особенность работы с gettext и Babel заключается в том, что все файлы переводов должны быть предварительно скомпилированы, поскольку перевод строк выбирается по индексам, рассчитанным по формулам.
Компилируем переводы командой:
pybabel compile -d locales -D my-super-bot
-d
- имя директории locales
-D
- домен «my-super-bot»
И получаем в нашей локали файлы .mo
, радом с файлами .po
.
Файлы .mo
храним в репозитории в ветке production, и распространяем с программой.
В отличие от файлов .po
, которые предназначены для разработки и хранятся в ветке development.
Финальный результат.
1from aiogram import Bot, Dispatcher, F, html
2from aiogram.types import Message
3
4from aiogram.utils.i18n import gettext as _
5from aiogram.utils.i18n import lazy_gettext as __
6from aiogram.utils.i18n import I18n, ConstI18nMiddleware
7
8TOKEN = "token"
9dp = Dispatcher()
10
11
12@dp.message(F.text == __("Start"))
13async def handler_1(message: Message) -> None:
14 await message.answer(_("Welcome, {name}!").format(name=html.quote(message.from_user.full_name)))
15 await message.answer(_("How many coins do you have? Input number, please:"))
16
17
18@dp.message(F.text)
19async def handler_2(message: Message) -> None:
20 try:
21 n = int(message.text)
22 await message.answer(_("You have {} coin!", "You have {} coins!", n).format(n))
23 except ValueError:
24 await message.answer(_("Please, enter a number"))
25
26
27def main() -> None:
28 bot = Bot(TOKEN, parse_mode="HTML")
29 i18n = I18n(path="locales", default_locale="en", domain="my-super-bot")
30 dp.message.outer_middleware(ConstI18nMiddleware(locale='ru', i18n=i18n))
31 dp.run_polling(bot)
Запускаем код, указываем константный русский язык в строке:
30dp.message.outer_middleware(ConstI18nMiddleware(locale='ru', i18n=i18n))
Тестируем. Меняем значение на locale='en'
и снова запускаем и
тестируем.
30dp.message.outer_middleware(ConstI18nMiddleware(locale='en', i18n=i18n))
Для динамического переключения языков, нам нужно хранить язык в базе
данных и реализовать свой класс middleware на базе I18nMiddleware
из
aiogram.utils.i18n.middleware
. Это мы сделаем чуть позже. А пока
разберемся с еще одним инструментом для локализации и
интернационализации на базе проекта Fluent от Mozilla.