В продакшене¶
Учебник проходит цикл один раз, в одиночку, на программе с одним сообщением. В реальном проекте цикл продолжает вращаться: сообщения меняются после того, как их перевели, переводчик работает в другом месте и по собственному расписанию, а скомпилированный каталог уходит с каждым релизом. Эта страница — про эту практику: что остаётся в репозитории, что путешествует, что обязан проверять CI и где среда выполнения привязывает язык.
В сумме получается шесть проверок, так что вот они сразу; каждый раздел ниже настраивает одну из них.
pybabel update --checkпроходит — ни одно сообщение не изменилось так, чтобы каталоги об этом не услышали.pybabel compileостанавливает сборку по своему коду возврата.- Оставшиеся записи
fuzzyоставлены намеренно — каждая выводится как исходный текст, пока переводчик её не подтвердит. - Набор тестов один раз рендерит каждый поставляемый язык со
strict=True. - Артефакт продакшена содержит
.mo-файлы и не содержит Babel. - Логгер
gettext_tstringsнаправлен в мониторинг.
Структура проекта¶
myapp/
├── babel.cfg
├── pyproject.toml
├── src/
│ └── myapp/
└── locales/
├── messages.pot
├── ja/LC_MESSAGES/messages.po
└── de/LC_MESSAGES/messages.po
Коммитьте babel.cfg, шаблон .pot и каждый .po — это исходники сборки
перевода, и их диффы — то, по чему вы рецензируете изменения переводов.
Скомпилированные .mo-файлы — артефакты сборки: создавайте их в CI или при
упаковке, а не коммитьте, чтобы .po и его .mo никогда не могли разойтись
в том, что уходит в поставку.
У одного файла роль в обе стороны: .pot несёт ваши сообщения к
переводчикам, .po-файлы несут переводы обратно. Остальная часть этой
страницы — про то, что движется между ними.
flowchart LR
code["исходный код<br>вызовы с t-строками"] -->|"pybabel extract"| pot["messages.pot"]
pot -->|"pybabel update"| po["один .po на язык"]
po --> tr["переводчик<br>или платформа"]
tr --> po
po -->|"pybabel compile (CI)"| mo[".mo-файлы"]
mo --> app["приложение<br>во время выполнения"]
Цикл после первого перевода¶
pybabel init из учебника обычно выполняется один раз — когда добавляется
язык. Дальше рабочий цикл — извлечь → обновить → перевести → скомпилировать, и
его центр — pybabel update, который вливает свежий шаблон в существующие
каталоги, не отбрасывая уже накопленные переводы.
Допустим, приветствие Hello {name} — уже переведённое как
こんにちは {name} — переформулировано в коде в Welcome back, {name}.
Извлеките и обновите:
$ pybabel extract -F babel.cfg -c "Translators:" -o locales/messages.pot .
extracting messages from app.py (encoding="utf-8")
writing PO template file to locales/messages.pot
$ pybabel update -i locales/messages.pot -d locales
updating catalog locales/ja/LC_MESSAGES/messages.po based on locales/messages.pot
Японский каталог теперь содержит:
#. gettext-tstrings
#: app.py:4
#, fuzzy, python-brace-format
msgid "Welcome back, {name}"
msgstr "こんにちは {name}"
Babel заметил, что новый msgid похож на удалённый, и связал его со старым
переводом — но пометил пару как fuzzy: догадка машины, ожидающая
человека. Флаг меняет то, что компилируется. pybabel compile исключает
fuzzy-записи из .mo, поэтому, пока переводчик не подтвердит пару, приложение выводит
новый английский текст, а не устаревший японский:
$ pybabel compile -d locales
compiling catalog locales/ja/LC_MESSAGES/messages.po to locales/ja/LC_MESSAGES/messages.mo
$ python app.py
Welcome back, Ada
Изменённое сообщение, таким образом, деградирует так же, как сломанное, — к
исходному языку и никогда к устаревшему переводу. Часть цикла на стороне
переводчика — поправить msgstr и удалить флаг fuzzy; следующая
компиляция подхватит запись.
Имена заполнителей — часть идентичности сообщения
msgid — это ключ каталога, и имя заполнителя находится внутри него,
поэтому переименование переменной в коде (name → user_name) меняет
msgid и отправляет перевод этого сообщения на каждом языке обратно в
цикл fuzzy. Называйте интерполируемые переменные словами, понятными
переводчику, и переименовывайте их только по веской причине.
Форматирование — зеркальный случай: !r и :.2f не входят в
msgid, поэтому уточнение
{amount:,.2f} до {amount:,.0f} не меняет ничего ни в одном каталоге.
Переформулировка самого предложения, разумеется, настоящее изменение —
это и есть цикл выше.
Что проверяет CI¶
Красной сборки стоят три сбоя: каталоги отстали от кода, перевод сломал заполнитель или сломанная запись просочилась до времени выполнения. По одному шагу на каждый сбой:
- run: pybabel extract -F babel.cfg -c "Translators:" -o locales/messages.pot .
- run: pybabel update -i locales/messages.pot -d locales --check
- run: pybabel compile -d locales
- run: pytest
pybabel update --check ничего не переписывает и завершается ненулевым
статусом, когда каталог устарел относительно свежеизвлечённого шаблона, —
это защита от слияния кода, чьи сообщения никто не переизвлёк.
pybabel compile выполняет проверки заполнителей и самого Babel, и
зарегистрированного checker
этого пакета.
Babel 2.18.0: --check не может проверять каталог, использующий контексты
На Babel 2.18.0 pybabel update --check сообщает, что устарел каждый
каталог, содержащий msgctxt, — при каждом запуске и насколько бы
свежим тот ни был. Вечно падающие ворота хуже, чем никаких, потому что
команда их отключает, — так что, если вы вообще пользуетесь pgettext или
npgettext, замените этот шаг, а не миритесь с ним. Прочитать шаблон и
каждый каталог через babel.messages.pofile.read_po и сравнить
{(m.context, m.id) for m in catalog if m.id} — вот и вся проверка, и
именно её делает собственная сборка этого сайта. Причина
разобрана на странице «Подводные камни».
Проверяйте статус выхода, а не журнал
pybabel compile сообщает о каждой ошибке заполнителей, выходит с
ненулевым статусом — и всё равно записывает .mo. Pipeline, который
компилирует и затем копирует locales/ в образ, отправит сломанный
каталог в поставку, если ненулевой статус его на самом деле не
остановит. Позволить этому шагу провалить сборку, как выше, — и есть всё
решение.
Последняя строка — ваш обычный набор тестов с одной добавленной привычкой: где-нибудь в нём прорендерите хотя бы одно сообщение на каждый поставляемый язык через строгий переводчик —
import gettext
from gettext_tstrings import Translator
def test_catalogs_render(language: str) -> None:
translations = gettext.translation("messages", localedir="locales", languages=[language])
_ = Translator(translations, strict=True)
name = "Ada"
assert _(t"Welcome back, {name}")
— потому что strict=True вызывает исключение там, где продакшен молча
откатился бы, а рендеринг во
время выполнения — единственная проверка, которая видит каталог ровно таким,
каким его увидит приложение, вместе с .mo.
Работа с переводчиками и платформами¶
.po-файл — формат обмена всего мира gettext, и именно поэтому эта
библиотека использует его повторно: передать перевод — значит передать файл,
будь получатель коллегой с PO-редактором или платформой вроде Weblate или
Crowdin. Передачу делают удачной три вещи:
Скажите, для чего это сообщение. Комментарий в коде путешествует вместе
с сообщением — именно его собирает флаг -c "Translators:":
from gettext_tstrings import tr
name = "Ada"
# Translators: shown on the dashboard right after sign-in
print(tr(t"Welcome back, {name}"))
#. Translators: shown on the dashboard right after sign-in
#. gettext-tstrings
#: app.py:5
#, python-brace-format
msgid "Welcome back, {name}"
msgstr ""
Переводчик видит этот комментарий в своём редакторе, рядом с сообщением, на
другом конце света. Это самый дешёвый рычаг качества во всём процессе. Для
слова-омонима — «Open» как кнопка против «Open» как состояние — дайте
сообщению контекст через pgettext, который
станет видимым msgctxt в каталоге.
Пусть платформа проверяет заполнители. Каждое сообщение, извлечённое из
t-строки, несёт флаг python-brace-format, и именно эта одна строка
включает QA заполнителей в инструментах, которыми вы не управляете: Weblate
документирует эту проверку, коммерческие платформы привязывают собственные к
тому же флагу, а msgfmt --check-format обеспечивает её в любом конвейере
GNU. Подробности — и что сверх них ловит поставляемый checker — на
странице извлечения.
Доверяйте страховке ровно настолько, насколько она тянет. Что бы ни вернулось с платформы, это по-прежнему данные, входящие в вашу сборку; именно ворота CI выше превращают «платформа, наверное, это проверила» в «это не может уйти в поставку сломанным».
Привязка языка во время выполнения¶
Всё предыдущее производит каталоги. Остаётся решить, где приложение выбирает один из них. Привязывайте один раз на область действия языка — процесс для CLI, запрос для веб-сервиса.
Консольный инструмент или настольное приложение читает окружение
пользователя один раз, при запуске. Без languages= стандартная
библиотека сама договаривается по LANGUAGE, LC_ALL, LC_MESSAGES и
LANG; fallback=True возвращает нулевой каталог — исходный текст —
вместо исключения, когда ни одно из них не совпало с каталогом, который
вы поставляете.
Веб-приложение решает на каждый запрос. Загрузите каждый каталог один
раз при импорте, затем привяжите согласованный к контексту до выполнения
view — set_translations локальна для
контекста, поэтому параллельные запросы на разных языках никогда не
видят чужую привязку.
import gettext
from flask import Flask, request
from gettext_tstrings import set_translations, tr
LANGUAGES = ("en", "ja", "de")
CATALOGS = {
language: gettext.translation(
"messages", localedir="locales", languages=[language], fallback=True
)
for language in LANGUAGES
}
app = Flask(__name__)
@app.before_request
def bind_language() -> None:
language = request.accept_languages.best_match(LANGUAGES) or "en"
set_translations(CATALOGS[language])
@app.get("/")
def home() -> str:
name = "Ada"
return tr(t"Welcome back, {name}")
Под асинхронными фреймворками — FastAPI, Starlette и всем прочим ASGI —
оберните запрос в use_translations:
привязка живёт в ContextVar, который переключение async-задач
сохраняет для каждого запроса.
import gettext
from fastapi import FastAPI, Request
from gettext_tstrings import tr, use_translations
LANGUAGES = ("en", "ja", "de")
CATALOGS = {
language: gettext.translation(
"messages", localedir="locales", languages=[language], fallback=True
)
for language in LANGUAGES
}
app = FastAPI()
@app.middleware("http")
async def bind_language(request: Request, call_next):
language = negotiate_language(request.headers.get("accept-language"), LANGUAGES)
with use_translations(CATALOGS[language]):
return await call_next(request)
negotiate_language обозначает ваш разбор Accept-Language — он есть в
большинстве фреймворков или их экосистем; важна здесь привязка вокруг
call_next.
Картину завершают две привычки времени выполнения. Строки, созданные при
импорте, — метка формы, отображаемое имя enum — не должны захватывать язык,
активный во время импорта; определяйте их через
lazy_gettext, и они выведутся на языке,
активном в момент использования. И направьте logger gettext_tstrings
туда, куда смотрит человек: его предупреждения — это мягкий режим,
сообщающий о переводе, проскользнувшем мимо всех ворот, по одной строке на
сломанное сообщение, а не на каждый рендеринг.
Поставка¶
Продакшену нужны пакет, .mo-файлы и больше ничего. Babel — зависимость
разработки и CI: держите gettext-tstrings[babel] вне продакшен-образа и
устанавливайте там голый пакет; рендеринг работает на одной стандартной
библиотеке. Компилируйте каталоги в той же сборке, которая производит
развёртываемый артефакт, — тогда .mo-файлы внутри него будут ровно теми
самыми отрецензированными .po-файлами, и ничто, скомпилированное на чьём-то
ноутбуке, никогда не уйдёт в поставку.
Как именно они путешествуют, зависит от того, что вы развёртываете. Wheel
несёт их как данные пакета, а значит, каталоги должны лежать внутри
каталога пакета — src/myapp/locales/, а не locales/ на верхнем уровне, — и
бэкенду сборки надо сказать, чтобы он включил файлы, которые .gitignore
обычно прячет:
Читайте их обратно через пакет, а не по пути относительно дерева исходников, которое перестаёт существовать в тот момент, когда wheel установлен:
import gettext
from importlib.resources import as_file, files
with as_file(files("myapp") / "locales") as localedir:
translations = gettext.translation("messages", localedir=localedir, languages=["ja"])
У образа контейнера задача проще: скомпилируйте на стадии сборки и скопируйте результат, оставив Babel на той стадии.
FROM python:3.14-slim AS build
COPY . /src
RUN cd /src && python -m pip install ".[babel]" \
&& pybabel compile -d src/myapp/locales
FROM python:3.14-slim
COPY --from=build /src /src
RUN python -m pip install /src # no [babel]: rendering needs the stdlib only
Перед релизом — контрольный список, к которому сводится эта страница:
pybabel update --checkпроходит — ни одно сообщение не изменилось так, чтобы каталоги об этом не услышали.pybabel compileблокирует сборку своим статусом выхода.- Оставшиеся записи
fuzzyнамеренны — каждая выводится как исходный текст, пока переводчик её не подтвердит. - Набор тестов рендерит каждый поставляемый язык один раз со
strict=True. - Продакшен-артефакт содержит
.mo-файлы и никакого Babel. - Logger
gettext_tstringsнаправлен в мониторинг.
Что дальше¶
- Извлечение — справочник по инструментальной половине этой страницы: опции сопоставления, собственные имена функций, строгий режим и каждая проверка.
- Руководство — половина времени выполнения: множественное число, контексты, отложенные строки и режимы сбоев в подробностях.
- Как это работает — почему msgid выглядит именно так и что на самом деле проверяет валидация.