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

Оглавление

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

Ссылки на GitHub для этой главы: BrowseZipDiff.

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

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

(venv) $ pip install flask-babel

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

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

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

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

Экземпляр Babel инициализируется аргументом locale_selector, в который должна быть передана функция, которая будет вызываться для каждого запроса. Функция может просмотреть запрос пользователя и выбрать наилучший языковой перевод для использования в этом запросе. Вот инициализация расширения Flask-Babel:

app/__init__.py: Инициализируем Flask-Babel.

from flask import request
# ...
from flask_babel import Babel

def get_locale():
    return request.accept_languages.best_match(app.config['LANGUAGES'])

app = Flask(__name__)
# ...
babel = Babel(app, locale_selector=get_locale)
# ...

Здесь я использую атрибут request объекта Flask под названием 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() атрибута request.accept_languages , который принимает список языков, предлагаемых приложением, в качестве аргумента и возвращает наилучший выбор.

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

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

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

Тексты для перевода помечаются путем их переноса в вызов функции, которая, как принято, называется просто подчеркиванием _(). Простейшие случаи - это те, когда в исходном коде появляются строки-литералы. Вот пример инструкции flash():

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

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

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

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

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

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

Есть еще более сложный случай для обработки. Некоторые строковые литералы назначаются вне веб-запроса, обычно при запуске приложения, поэтому во время оценки этих текстов невозможно определить, какой язык использовать. Примером этого являются метки, связанные с полями формы. Единственное решение для обработки этих текстов - найти способ отложить вычисление строк до тех пор, пока они не будут использованы, что будет выполняться по фактическому запросу. Flask-Babel предоставляет ленивую ознакомительную версию_(), которая называется 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()) }}

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

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

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

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

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

babel.cfg: файл конфигурации PyBabel.

[python: app/**.py]
[jinja2: app/templates/**.html]

Эти строки определяют шаблоны имен файлов Python и Jinja соответственно. Flask-Babel будет искать любые файлы, соответствующие этим шаблонам, и сканировать их на наличие текстов, обернутых для перевода.

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

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

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

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

Создание языкового каталога

Следующим шагом в процессе является создание перевода для каждого языка, который будет поддерживаться в дополнение к базовому, которым в данном случае является английский. Я сказал, что собираюсь начать с добавления испанского (код языка 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 в качестве входных данных и записывает новый языковой каталог в каталог, указанный в опции -d для языка, указанного в опции -l . Я собираюсь установить все переводы в каталог app/translate, потому что по умолчанию Flask-Babel ожидает, что именно там будут находиться файлы переводов. Команда создаст подкаталог es внутри этого каталога для файлов данных на испанском языке. В частности, появится новый файл с именем app/translations/es/LC_MESSAGES/messages.po, именно там необходимо выполнить переводы.

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

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

# Spanish translations for PROJECT.
# Copyright (C) 2021 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2021.
#
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2021-06-29 23:23-0700\n"
"PO-Revision-Date: 2021-06-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 это находится на странице расширенных настроек:

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

app/__init__.py: Выбор лучшего языка.

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

Функция get_locale() возвращает es, теперь все надписи в приложении будут отображаться на Испанском.

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

Одна из распространенных ситуаций при работе с переводами заключается в том, что вы можете захотеть начать использовать файл перевода, даже если он неполный. Это совершенно нормально, вы можете скомпилировать неполный файл messages.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(). Вызов update принимает новый файл messages.pot и объединяет его со всеми файлами messages.po , связанными с проектом. Это будет интеллектуальное объединение, при котором все существующие тексты будут оставлены в покое, а записи, которые были добавлены или удалены в messages.pot будут затронуты.

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

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

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

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

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 возвращает объект locale, но я просто хочу иметь код языка, который можно получить, преобразовав объект в строку. Теперь, когда у меня есть g.locale Я могу получить к нему доступ из базового шаблона для настройки moment.js с правильным языком:

app/templates/base.html: Настройка локали для moment.js.

...

    {{ moment.include_moment() }}
    {{ moment.lang(g.locale) }}
  </body>
</html>
Примечание от переводчика

Объект g - это контейнер существующий в течении всего запроса, он позволяет передавать информацию между обрабатывающими запрос функциями, такими как before_request, route, after_request. Переменная доступна только в контексте конкретного запроса.

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

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

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

Вы, вероятно, согласитесь со мной, что команды pybabel немного длинные и их трудно запомнить. Я собираюсь воспользоваться этой возможностью, чтобы показать вам, как вы можете создавать пользовательские команды, интегрированные в команду flask. До сих пор вы видели, как я использовал flask runflask 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

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

Подкоманды 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(), строки документации для этих функций используются в качестве справочного сообщения в выводе --help.

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

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

app/cli.py: Вспомогательная команда init.

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

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