From cdc51a699462fe03e1af05b703501c6f3cdbe86f Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sat, 30 Jun 2018 03:59:34 +0300 Subject: [PATCH] Implement i18n middleware and add example. --- .gitignore | 3 + aiogram/contrib/middlewares/i18n.py | 137 +++++++++++++++++++++++ examples/i18n_example.py | 57 ++++++++++ examples/locales/en/LC_MESSAGES/mybot.po | 28 +++++ examples/locales/mybot.pot | 27 +++++ examples/locales/ru/LC_MESSAGES/mybot.po | 29 +++++ examples/locales/uk/LC_MESSAGES/mybot.po | 29 +++++ 7 files changed, 310 insertions(+) create mode 100644 aiogram/contrib/middlewares/i18n.py create mode 100644 examples/i18n_example.py create mode 100644 examples/locales/en/LC_MESSAGES/mybot.po create mode 100644 examples/locales/mybot.pot create mode 100644 examples/locales/ru/LC_MESSAGES/mybot.po create mode 100644 examples/locales/uk/LC_MESSAGES/mybot.po diff --git a/.gitignore b/.gitignore index 6c2a9404..a8b34bd1 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ experiment.py # Doc's docs/html + +# i18n/l10n +*.mo diff --git a/aiogram/contrib/middlewares/i18n.py b/aiogram/contrib/middlewares/i18n.py new file mode 100644 index 00000000..3b60e070 --- /dev/null +++ b/aiogram/contrib/middlewares/i18n.py @@ -0,0 +1,137 @@ +import gettext +import os +from contextvars import ContextVar +from typing import Any, Dict, Tuple + +from babel import Locale + +from ... import types +from ...dispatcher.middlewares import BaseMiddleware + + +class I18nMiddleware(BaseMiddleware): + """ + I18n middleware based on gettext util + + >>> dp = Dispatcher(bot) + >>> i18n = I18nMiddleware(DOMAIN, LOCALES_DIR) + >>> dp.middleware.setup(i18n) + and then + >>> _ = i18n.gettext + or + >>> _ = i18n = I18nMiddleware(DOMAIN_NAME, LOCALES_DIR) + """ + + ctx_locale = ContextVar('ctx_user_locale', default=None) + + def __init__(self, domain, path=None, default='en'): + """ + :param domain: domain + :param path: path where located all *.mo files + :param default: default locale name + """ + super(I18nMiddleware, self).__init__() + + if path is None: + path = os.path.join(os.getcwd(), 'locales') + + self.domain = domain + self.path = path + self.default = default + + self.locales = self.find_locales() + + def find_locales(self) -> Dict[str, gettext.GNUTranslations]: + """ + Load all compiled locales from path + + :return: dict with locales + """ + translations = {} + + for name in os.listdir(self.path): + if not os.path.isdir(self.path): + continue + mo_path = os.path.join(self.path, name, 'LC_MESSAGES', self.domain + '.mo') + + if os.path.exists(mo_path): + with open(mo_path, 'rb') as fp: + translations[name] = gettext.GNUTranslations(fp) + + return translations + + def reload(self): + """ + Hot reload locles + """ + self.locales = self.find_locales() + + @property + def available_locales(self) -> Tuple[str]: + """ + list of loaded locales + + :return: + """ + return tuple(self.locales.keys()) + + def __call__(self, singular, plural=None, n=1, locale=None) -> str: + return self.gettext(singular, plural, n, locale) + + def gettext(self, singular, plural=None, n=1, locale=None) -> str: + """ + Get text + + :param singular: + :param plural: + :param n: + :param locale: + :return: + """ + if locale is None: + locale = self.ctx_locale.get() + + if locale not in self.locales: + if n is 1: + return singular + else: + return plural + + translator = self.locales[locale] + + if plural is None: + return translator.gettext(singular) + else: + return translator.ngettext(singular, plural, n) + + async def get_user_locale(self, action: str, args: Tuple[Any]) -> str: + """ + User locale getter + You can override the method if you want to use different way of getting user language. + + :param action: event name + :param args: event arguments + :return: locale name + """ + user: types.User = types.User.current() + locale: Locale = user.locale + + if locale: + *_, data = args + language = data['locale'] = locale.language + return language + + async def trigger(self, action, args): + """ + Event trigger + + :param action: event name + :param args: event arguments + :return: + """ + if 'update' not in action \ + and 'error' not in action \ + and action.startswith('pre_process'): + locale = await self.get_user_locale(action, args) + self.ctx_locale.set(locale) + return True diff --git a/examples/i18n_example.py b/examples/i18n_example.py new file mode 100644 index 00000000..a7ccbde3 --- /dev/null +++ b/examples/i18n_example.py @@ -0,0 +1,57 @@ +""" +Internalize your bot + +Step 1: extract texts + # pybabel extract i18n_example.py -o locales/mybot.pot +Step 2: create *.po files. For e.g. create en, ru, uk locales. + # echo {en,ru,uk} | xargs -n1 pybabel init -i locales/mybot.pot -d locales -D mybot -l +Step 3: translate texts +Step 4: compile translations + # pybabel compile -d locales -D mybot + +Step 5: When you change the code of your bot you need to update po & mo files. + Step 5.1: regenerate pot file: + command from step 1 + Step 5.2: update po files + # pybabel update -d locales -D mybot -i locales/mybot.pot + Step 5.3: update your translations + Step 5.4: compile mo files + command from step 4 +""" + +from pathlib import Path + +from aiogram import Bot, Dispatcher, types +from aiogram.contrib.middlewares.i18n import I18nMiddleware +from aiogram.utils import executor + +TOKEN = 'BOT TOKEN HERE' +I18N_DOMAIN = 'mybot' + +BASE_DIR = Path(__file__).parent +LOCALES_DIR = BASE_DIR / 'locales' + +bot = Bot(TOKEN, parse_mode=types.ParseMode.HTML) +dp = Dispatcher(bot) + +# Setup i18n middleware +i18n = I18nMiddleware(I18N_DOMAIN, LOCALES_DIR) +dp.middleware.setup(i18n) + +# Alias for gettext method +_ = i18n.gettext + + +@dp.message_handler(commands=['start']) +async def cmd_start(message: types.Message): + # Simply use `_('message')` instead of `'message'` and never use f-strings for translatable texts. + await message.reply(_('Hello, {user}!').format(user=message.from_user.full_name)) + + +@dp.message_handler(commands=['lang']) +async def cmd_lang(message: types.Message, locale): + await message.reply(_('Your current language: {language}').format(language=locale)) + + +if __name__ == '__main__': + executor.start_polling(dp, skip_updates=True) diff --git a/examples/locales/en/LC_MESSAGES/mybot.po b/examples/locales/en/LC_MESSAGES/mybot.po new file mode 100644 index 00000000..75970929 --- /dev/null +++ b/examples/locales/en/LC_MESSAGES/mybot.po @@ -0,0 +1,28 @@ +# English translations for PROJECT. +# Copyright (C) 2018 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2018. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2018-06-30 03:50+0300\n" +"PO-Revision-Date: 2018-06-30 03:43+0300\n" +"Last-Translator: FULL NAME \n" +"Language: en\n" +"Language-Team: en \n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.6.0\n" + +#: i18n_example.py:48 +msgid "Hello, {user}!" +msgstr "" + +#: i18n_example.py:53 +msgid "Your current language: {language}" +msgstr "" + diff --git a/examples/locales/mybot.pot b/examples/locales/mybot.pot new file mode 100644 index 00000000..988ed463 --- /dev/null +++ b/examples/locales/mybot.pot @@ -0,0 +1,27 @@ +# Translations template for PROJECT. +# Copyright (C) 2018 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2018. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2018-06-30 03:50+0300\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.6.0\n" + +#: i18n_example.py:48 +msgid "Hello, {user}!" +msgstr "" + +#: i18n_example.py:53 +msgid "Your current language: {language}" +msgstr "" + diff --git a/examples/locales/ru/LC_MESSAGES/mybot.po b/examples/locales/ru/LC_MESSAGES/mybot.po new file mode 100644 index 00000000..73876f30 --- /dev/null +++ b/examples/locales/ru/LC_MESSAGES/mybot.po @@ -0,0 +1,29 @@ +# Russian translations for PROJECT. +# Copyright (C) 2018 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2018. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2018-06-30 03:50+0300\n" +"PO-Revision-Date: 2018-06-30 03:43+0300\n" +"Last-Translator: FULL NAME \n" +"Language: ru\n" +"Language-Team: ru \n" +"Plural-Forms: 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" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.6.0\n" + +#: i18n_example.py:48 +msgid "Hello, {user}!" +msgstr "Привет, {user}!" + +#: i18n_example.py:53 +msgid "Your current language: {language}" +msgstr "Твой язык: {language}" + diff --git a/examples/locales/uk/LC_MESSAGES/mybot.po b/examples/locales/uk/LC_MESSAGES/mybot.po new file mode 100644 index 00000000..25970c19 --- /dev/null +++ b/examples/locales/uk/LC_MESSAGES/mybot.po @@ -0,0 +1,29 @@ +# Ukrainian translations for PROJECT. +# Copyright (C) 2018 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2018. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2018-06-30 03:50+0300\n" +"PO-Revision-Date: 2018-06-30 03:43+0300\n" +"Last-Translator: FULL NAME \n" +"Language: uk\n" +"Language-Team: uk \n" +"Plural-Forms: 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" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.6.0\n" + +#: i18n_example.py:48 +msgid "Hello, {user}!" +msgstr "Привіт, {user}!" + +#: i18n_example.py:53 +msgid "Your current language: {language}" +msgstr "Твоя мова: {language}" +