Главная » Хабрахабр » Мега-Учебник Flask, Часть XIII: I18n и L10n (издание 2018)

Мега-Учебник Flask, Часть XIII: I18n и L10n (издание 2018)

Miguel Grinberg


Туда Сюда

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

Для справки ниже приведен список статей этой серии.

Оглавление

Примечание 1: Если вы ищете старые версии данного курса, это здесь.

Примечание 2: Если вдруг Вы захотели бы выступить в поддержку моей(Мигеля) работы, или просто не имеете терпения дожидаться статьи неделю, я (Мигель Гринберг)предлагаю полную версию данного руководства(на английском языке) в виде электронной книги или видео. Для получения более подробной информации посетите learn.miguelgrinberg.com.

Эта глава посвящена интернационализации и локализации, сокращенно I18n и L10n. Чтобы сделать приложение доступным для людей, не владеющих английским языком, будет реализован процесс перевода, который, с помощью сервисов-переводчиков языка, позволит мне предложить пользователям язык-приложения на выбор.

Ссылки GitHub для этой главы: Browse, Zip, Diff.

Введение в Flask-Babel

Как вы, наверное, уже догадались, существует расширение Flask, которое упрощает работу с переводами. Расширение называется Flask-Babel и устанавливается с помощью pip:

(venv) $ pip install flask-babel

Flask-Babel инициализируется, как и большинство других расширений Flask:

app/__init__.py: Инициализация Flask-Babel.

# ...
from flask_babel import Babel app = Flask(__name__)
# ...
babel = Babel(app)

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

config.py: Список поддерживаемых языков.

class Config(object): # ... LANGUAGES = ['en', 'es']

Я использую двухбуквенные коды языков для этого приложения, но если вам нужно быть более конкретным, можно добавить код страны. Например, вы можете использовать en-US, en-GB и en-CA для поддержки английского с разными диалектами США, Великобритания или Канада.

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

app/__init__.py: Выбор предпочтительного языка.

from flask import request # ... @babel.localeselector
def get_locale(): return request.accept_languages.best_match(app.config['LANGUAGES'])

Здесь я использую атрибут объекта Flask request, называемый accept_languages. Этот объект обеспечивает интерфейс высокого уровня для работы с заголовком Accept-Language, отправляемым клиентами с запросом. Этот заголовок указывает язык клиента и языковые предпочтения в виде средневзвешенного списка. Содержимое этого заголовка можно настроить на странице настроек браузера, при этом по умолчанию обычно импортируются из языковых настроек в операционной системе компьютера. Большинство людей даже не знают, что такая настройка существует, но это полезно, поскольку пользователи могут предоставить список предпочтительных языков, каждый из которых имеет вес. Если вам интересно, вот пример сложного заголовка Accept-Languages:

Accept-Language: da, en-gb;q=0.8, en;q=0.7

Видим, что Датский (da) является предпочтительным языком (значение веса по умолчанию 1,0), а затем Британский английский (en-gb) с весом 0,8, и в качестве последнего варианта Общий Английский (en) с весом 0,7.

Чтобы выбрать лучший язык, вам нужно сравнить список языков, запрашиваемых клиентом, с языками, которые поддерживает приложение, и, используя предоставленные клиентом веса, найти лучший язык. Возможно вам кажется эта логика слишком сложной, но все это инкапсулируется в метод best_match(), который принимает список языков, предлагаемых приложением в качестве аргумента и возвращает лучший выбор.

Маркировка текстов для перевода в исходном коде Python

Рано обрадовались. Теперь о грустном. Обычный рабочий процесс при создании приложения на нескольких языках заключается в разметке всех текстов, требующие перевода в исходном коде. После того, как тексты будут помечены, Flask-Babel будет сканировать все файлы и извлекать эти тексты в отдельный файл перевода, используя инструмент gettext. К сожалению, это утомительная задача, которая должна быть выполнена для перевода.

Я собираюсь показать вам несколько примеров этой маркировки, но получить полный набор изменений вы можете из пакета для этой главы или репозитория GitHub.

Способ, которым тексты помечены для перевода, заключается в обертывании их в вызов функции, которая вызывается как соглашение _(), просто подчеркивание. Простейшими случаями являются те, где литеральные строки появляются в исходном коде. Ниже приведен пример оператора flash()`:

from flask_babel import _
# ...
flash(_('Your post is now live!'))

Идея заключается в том, что функция _() переносит текст на базовый язык (в данном случае английский). Она будет использовать лучший по ее мнению язык, выбранный функцией get_locale, декорированной функцией localeselector, чтобы найти правильный перевод для данного клиента. Затем функция _() вернет переведенный текст, который в этом случае станет аргументом для flash().

К сожалению, не все случаи так просты. Рассмотрим этот другой вызов flash() из приложения:

flash('User {} not found.'.format(username))

Этот текст имеет динамический компонент, который вставлен в середине статического текста. Функция _() имеет синтаксис, поддерживающий этот тип текстов, но основанный на старом синтаксисе подстановки строк:

flash(_('User %(username)s not found.', username=username))

Есть еще более трудный случай. Некоторые строковые литералы назначаются вне запроса, как правило, когда приложение запускается, поэтому в то время, когда эти тексты оцениваются, нет способа узнать, какой язык использовать. Примером этого являются метки, связанные с полями формы. Единственное решение для обработки этих текстов — найти способ отложить оценку строки до ее использования, которая будет находиться под фактическим запросом. Flask-Babel предоставляет версию (lazy evaluation) отложенного вычисления _(), которая называется lazy_gettext():

from flask_babel import lazy_gettext as _l class LoginForm(FlaskForm): username = StringField(_l('Username'), validators=[DataRequired()]) # ...

Здесь я импортирую альтернативную функцию перевода и переименовываю ее в _l (), так что она была схожа по названию с оригинальной _(). Эта новая функция переносит текст в специальный объект, содержащий метод перевода, который состоится позже, в момент использования строки.

Расширение Flask-Login высвечивает сообщение при каждой переадресации пользователя на страницу входа. Это сообщение написано на английском языке и формируется в умолчаниях самого расширения. Чтобы убедиться, что это сообщение также переведено, я собираюсь переопределить сообщение по умолчанию и предоставить другой вариант декорированный функцией _l() для отложенного вызова:

login = LoginManager(app)
login.login_view = 'login'
login.login_message = _l('Please log in to access this page.')

Разметка текстов для перевода в шаблонах

В предыдущем разделе вы видели, как разметить переводимые тексты в исходном коде модулей Python, но это только часть процесса, так как файлы шаблонов также содержат текст. Функция _() также доступна в шаблонах, поэтому процесс сильно похож. Например, рассмотрим этот фрагмент HTML из 404.html:

<h1>File Not Found</h1>

Версия с поддержкой перевода:

<h1>{{ _('File Not Found') }}</h1>

Обратите внимание, что здесь, помимо обертывания текста с помощью _(), необходимо добавить {{...}}, чтобы заставить _() вычислять вместо того, чтобы считаться литералом в шаблоне.

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

<h1>{{ _('Hi, %(username)s!', username=current_user.username) }}</h1>

В файле _post.html есть особенно сложный случай, который заставил меня разбираться:

 {% set user_link %} <a href="{{ url_for('user', username=post.author.username) }}"> {{ post.author.username }} </a> {% endset %} {{ _('%(username)s said %(when)s', username=user_link, when=moment(post.timestamp).fromNow()) }}

Проблема здесь заключается в том, что я хотел, чтобы имя пользователя было ссылкой, указывающей на страницу профиля пользователя, а не только именем, так что мне пришлось создать промежуточную переменную под названием user_link с помощью set и endset директивы шаблонов, а затем передать это как аргумент функции перевода.

Как я уже упоминал выше, вы можете скачать версию приложения со всеми переводимыми текстами в исходном коде Python и шаблонах.

Извлечение текста для перевода

После того, как у вас есть приложение со всеми _() и_l() на своих местах, вы можете использовать команду pybabel, чтобы извлечь их в файл a.pot, что означает portable object template. Это текстовый файл, содержащий все тексты, которые были помечены как нуждающиеся в переводе. Цель этого файла состоит в том, чтобы служить шаблоном для создания файлов перевода на любой другой язык.

Для процесса извлечения требуется небольшой файл конфигурации, который сообщает pybabel, какие файлы следует сканировать для переводимых текстов. Ниже вы можете увидеть babel.cfg, который я создал для этого приложения:

babel.cfg: PyBabel configuration file.

[python: app/**.py]
[jinja2: app/templates/**.html]
extensions=jinja2.ext.autoescape,jinja2.ext.with_

Первые две строки определяют имена файлов шаблонов Python и Jinja2 соответственно. Третья строка определяет два расширения, предоставляемые движком шаблонов Jinja2, которые помогают Flask-Babel правильно анализировать файлы шаблонов.

Чтобы извлечь все тексты в .pot файл, вы можете использовать следующую команду:

(venv) $ pybabel extract -F babel.cfg -k _l -o messages.pot .

Команда pybabel extract считывает файл конфигурации, указанный в параметре -F, а затем сканирует все файлы py и html в каталогах, соответствующих настроенным источникам, начиная с каталога, указанного в команде (текущий каталог или . в этом случае.) По умолчанию, pybabel будем искать _() как текстовый маркер, но я также использовал lazy вариант, который я импортировал как _l(), так что мне нужно сказать об этом инструменту поиска опцией -k _l. Параметр -o указывает имя выходного файла.

Должен отметить, что messages.pot не является файлом, который должен быть включен в проект. Это файл, который можно легко регенерировать в любое время, просто выполнив команду выше снова. Таким образом, нет необходимости передавать этот файл в систему управления версиями.

Создание Language Catalog

Следующим шагом в процессе является создание перевода для каждого языка, который будет поддерживаться в дополнение к базовому, который в данном случае английский. Я сказал, что собираюсь начать с добавления испанского языка (код языка es), так что команда, которая делает это:

(venv) $ pybabel init -i messages.pot -d app/translations -l es
creating catalog app/translations/es/LC_MESSAGES/messages.po based on messages.pot

Команда pybabel init принимает файл messages.pot в качестве входных данных и создает новый каталог для определенного языка, указанного в параметре -l в каталог, указанный в параметре -d. Я буду сохранять все переводы в директории app/translations, потому что там Flask-Babel будет искать файлы перевода по умолчанию. Команда создаст подкаталог es внутри этого каталога для данных на испанском. В частности, там появится новый файл с названием app/translations/es/LC_MESSAGES/messages.po. То есть там, где переводы должны быть сделаны.

Если вы хотите поддержать другие языки, то повторите вышеуказанную команду с каждым из кодов языка. Таким образом, что бы каждый язык получил свой собственный репозитарий с файлом messages.po.

Этот messages.po-файл, созданный в каждом языковом репозитории, использует формат, который является стандартом де-факто для языковых переводов, Формат, используемый утилитой gettext. Вот несколько строк начала испанского messages.po:

# Spanish translations for PROJECT.
# Copyright (C) 2017 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2017.
#
msgid ""
msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "POT-Creation-Date: 2017-09-29 23:23-0700\n" "PO-Revision-Date: 2017-09-29 23:25-0700\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language: es\n" "Language-Team: es <LL@li.org>\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.5.1\n" #: app/email.py:21
msgid "[Microblog] Reset Your Password"
msgstr "" #: app/forms.py:12 app/forms.py:19 app/forms.py:50
msgid "Username"
msgstr "" #: app/forms.py:13 app/forms.py:21 app/forms.py:43
msgid "Password"
msgstr ""

Если пропустить заголовок, то видно, что ниже приведен список строк, которые были извлечены из вызовов _() и _l(). Для каждого текста вы получаете ссылку на расположение текста в приложении. Затем строка msgid содержит текст на базовом языке, а следующая строка msgstr содержит пустую строку. Эти пустые строки должны быть отредактированы, чтобы иметь текст на целевом языке.

Есть много приложений, которые работают с переводом .po-файлов. Если вы чувствуете себя комфортно при редактировании текстового файла, то этого достаточно, но если вы работаете с большим проектом, то может быть рекомендовано работать со специализированным редактором. Наиболее популярным приложением для перевода является poedit с открытым исходным кодом, который доступен для всех основных операционных систем. Если вы знакомы с VIM, то po.vim плагин дает некоторые ключевые отображения, которые делают работу с этими файлами проще.

Ниже вы можете увидеть часть испанской версии messages.po после того, как я добавил перевод:

#: app/email.py:21
msgid "[Microblog] Reset Your Password"
msgstr "[Microblog] Nueva Contraseña" #: app/forms.py:12 app/forms.py:19 app/forms.py:50
msgid "Username"
msgstr "Nombre de usuario" #: app/forms.py:13 app/forms.py:21 app/forms.py:43
msgid "Password"
msgstr "Contraseña"

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

Файл messages.po -это своего рода файл-источник для переводов. Если вы хотите начать использовать эти переведенные тексты, то файл должен быть скомпилирован в формат, который эффективен для использования приложением во время выполнения. Чтобы собрать все переводы для приложения, вы можете использовать команду компиляции pybabel compile следующим образом:

(venv) $ pybabel compile -d app/translations
compiling catalog app/translations/es/LC_MESSAGES/messages.po to
app/translations/es/LC_MESSAGES/messages.mo

Эта операция добавляет файл messages.mo рядом с messages.po в каждом языковом репозитории. Файл .mo — это файл, который Flask-Babel будет использовать для загрузки переводов в приложение.

После создания messages.mo для испанского или любых других языков, добавленных в проект, эти языки готовы к использованию в приложении. Если вы хотите увидеть, как выглядит приложение на испанском языке, Вы можете изменить конфигурацию языка в веб-браузере, чтобы испанский язык был предпочтительным языком. Для Chrome это расширенная часть в настройках:

Если вы предпочитаете не изменять настройки браузера, другой альтернативой является принудительное использование языка, заставляя функцию localeselector всегда возвращать один и тот же. Для испанского это выглядит так:

app/__init__.py: Выбор испанского ( директивно).

@babel.localeselector
def get_locale(): # return request.accept_languages.best_match(app.config['LANGUAGES']) return 'es'

Запуск приложения в браузере, настроенном на испанский язык, или в случае принудительного присвоения значения es функции localeselector, заставит все тексты появляться на испанском языке в приложении.

Обновление переводов

Одна из распространенных ситуаций при работе с переводами заключается в том, что вы можете начать использовать файл перевода, даже если он неполный. Это совершенно нормально, можно компилировать неполные файлы messages.po. В этом случае будут использоваться po-файлы и любые доступные переводы, а отсутствующие будут использовать базовый язык. Затем можно продолжить работу над переводами и выполнить компиляцию для обновления messages.mo.

Другой распространенный случай возникает, если вы пропустили некоторые тексты при добавлении _() обертки. В этом случае вы увидите, что те тексты, которые вы пропустили, останутся на английском языке, потому что Flask-Babel ничего о них не знает. В этом случае необходимо добавить _() или _l() обертки при обнаружении текстов, которые не имеют их, а затем выполнить процедуру обновления, которая включает в себя два шага:

(venv) $ pybabel extract -f babel.cfg -k _l -o messages.pot .
(venv) $ pybabel update -i messages.pot -d app/translations

Команда extract идентична той, которую я описывал ранее, но теперь она будет генерировать новую версию messages.pot со всеми предыдущими текстами плюс что-нибудь новое, которое вы недавно обернули с помощью _() или _l(). Вызов обновления принимает новый файл messages.pot и объединяет его во все файлы messages.po, связанные с проектом. Это будет интеллектуальное слияние, в котором любые существующие тексты будут оставлены в покое, в то время как будут затронуты только записи, которые были добавлены или удалены в messages.pot.

После обновления messages.po вы можете продолжить и перевести все новые тесты, а затем скомпилировать сообщения еще раз, чтобы сделать их доступными для приложения.

Перевод дат и времени

Теперь у меня есть полный испанский перевод для всех текстов в коде Python и шаблонах. Но если вы запустите приложение на испанском языке и будете хорошим наблюдателем, то вы заметите, что есть еще несколько мест, которые остались на английском языке. Я имею в виду временные метки, созданные Flask-Moment и moment.js, которые, очевидно, не были включены в перевод, потому что ни один из текстов, созданных этими пакетами, не является частью исходного кода или шаблона приложения.

moment.js поддерживает локализацию и интернационализацию, поэтому все, что мне нужно сделать, это настроить правильный язык. Flask-Babel возвращает выбранный язык и локаль для такого случая с помощью функции get_locale(), поэтому я собираюсь добавить локаль в объект g, чтобы получить доступ к нему из базового шаблона:

app/routes.py: Сохраняем выбранный язык в flask.g.

# ...
from flask import g
from flask_babel import get_locale # ... @app.before_request
def before_request(): # ... g.locale = str(get_locale())

Функция get_locale() из Flask-Babel возвращает объект, но я просто хочу иметь код языка, который может быть получен путем преобразования объекта в строку. Теперь, когда у меня есть g.locale, я могу получить к нему доступ из базового шаблона, чтобы настроить moment.js с правильным языком:

app/templates/base.html: Устанавливаем языковой стандарт для moment.js.

...
{% block scripts %} {{ super() }} {{ moment.include_moment() }} {{ moment.lang(g.locale) }}
{% endblock %}

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

На этом этапе все тексты, кроме тех, которые были предоставлены пользователем в сообщениях блога или описаниях профиля, должны быть переведены на другие языки.

Усовершенствования для командной строки

Вы, вероятно, согласитесь со мной, что команды pybabel слгка длинны и их трудно запомнить. Я собираюсь использовать эту возможность, чтобы показать вам, как вы можете создавать пользовательские команды, интегрированные с командой flask. До сих пор вы видели использование flask run, flask shell, и несколько flask db суб-команды в Flask-Migrate. На самом деле легко добавить специфичные для приложения команды в flask. Итак, теперь я собираюсь создать несколько простых команд, которые запускают команды pybabel со всеми аргументами, которые специфичны для этого приложения. Команды, которые я собираюсь добавить:

  • flask translate init LANG добавить новый язык
  • flask translate update обновить все языковые репозитории
  • flask translate compile для компиляции всех языковых репозиториев

babel export не будет командой, потому что генерация файла messages.pot всегда является предварительным условием для выполнения команд init или update. Поэтому реализация этих команд будет генерировать файл шаблона перевода как временный файл.

Flask полагается на Click для всех своих операций с командной строкой. Команды, такие как translate, которые являются корнем для нескольких подкоманд, создаются с помощью декоратора app.cli.group(). Я собираюсь поместить эти команды в новый модуль под названием app/cli.py:

app/cli.py: Перевести группу команд.

from app import app @app.cli.group()
def translate(): """Translation and localization commands.""" pass

Имя команды происходит от имени декорированной функции, а справочное сообщение поступает из docstring. Поскольку это родительская команда, которая существует только для обеспечения базы для подкоманд, самой функции ничего не нужно делать.

Update -обновление и compile -компиляцию легко реализовать, поскольку они не принимают никаких аргументов:

app/cli.py: Обновление и компиляция вложенных команд.

import os # ... @translate.command()
def update(): """Update all languages.""" if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'): raise RuntimeError('extract command failed') if os.system('pybabel update -i messages.pot -d app/translations'): raise RuntimeError('update command failed') os.remove('messages.pot') @translate.command()
def compile(): """Compile all languages.""" if os.system('pybabel compile -d app/translations'): raise RuntimeError('compile command failed')

Обратите внимание, что декоратор из этих функций является производным от родительской функции translate. Это может показаться запутанным, так как translate() — это функция, но это стандартный способ, которым Click создает группы команд. Так же, как и в функции translate(), docstrings -строки документации для этих функций используются в качестве сообщения справки в выводе --help.

Возможно вы заметили, что во всех командах, которые я запускаю есть проверка возвращаемого значения на ноль. Это означает, что команда выполнена и не вернула никакой ошибки. Если в команде ошибка, то я поднимаю RuntimeError, что приводит к остановке скрипта. Функция update() объединяет шаги извлечения и обновления в одной команде, и если все прошло успешно, она удаляет файл messages.pot после завершения обновления, так как этот файл может быть легко регенерирован при необходимости еще раз.

Команда init принимает новый код языка в качестве аргумента. Вот реализация:

app/cli.py: Init — sub-команда инициализации.

import click @translate.command()
@click.argument('lang')
def init(lang): """Initialize a new language.""" if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'): raise RuntimeError('extract command failed') if os.system( 'pybabel init -i messages.pot -d app/translations -l ' + lang): raise RuntimeError('init command failed') os.remove('messages.pot')

Эта команда использует декоратор @click.argument для определения кода языка. Click передает значение, указанное в команде функции обработчика в качестве аргумента, а затем я включаю аргумент в команду init.

Последним шагом для включения этих команд является их импорт, чтобы команды регистрировались. Я решил сделать это в файле microblog.py в каталоге верхнего уровня:

microblog.py: Регистрация команд командной строки.

from app import cli

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

В этот момент, запуск flask --help передаст команду translate в качестве опции. И flask translate --help отобразит вывод трех суб-команд, которые я определил:

(venv) $ flask translate --help
Usage: flask translate [OPTIONS] COMMAND [ARGS]... Translation and localization commands. Options: --help Show this message and exit. Commands: compile Compile all languages. init Initialize a new language. update Update all languages.

Так что теперь, рабочий процесс гораздо проще и нет необходимости помнить длинные и сложные команды. Чтобы добавить новый язык, используйте:

(venv) $ flask translate init <language-code>

Обновить все языки после внесения изменений в маркеры _() и _l() :

(venv) $ flask translate update

И компилировать все языки после обновления файлов перевода:

(venv) $ flask translate compile

Туда Сюда


x

Ещё Hi-Tech Интересное!

Современная веб-разработка: выбери себе приключение

Недавно я делал доклад о том, какие шишки можно набить, занимаясь современной веб-разработкой. Привет. Предлагаю вам почитать этот пост, выбрать свои варианты, дополнить квест и обсудить то, что наболело. Получилось что-то вроде квеста с распределенным выбором: от того, какой язык ...

Oh, My Code: Как стать успешным в IT

Что нужно, чтобы добиться успеха в IT? Об этом и многом другом мы поговорили в новом выпуске ток-шоу Oh, My Code с Дмитрием Гришиным, сооснователем и председателем совета директоров Mail.ru Group, основателем Grishin Robotics. Не могу не начать наш разговор ...