Перейти к содержанию

Локализация

Текст в StageFlow пишут двое, и механизма тоже два.

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

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

Отвечать на языке

Локаль — не глобальная переменная. Она живёт в ContextVar, и именно это делает её пригодной для сервера: обработчик ставит ту локаль, которую попросили в запросе, два одновременно обслуживаемых запроса получают два языка, а задача, запущенная из обработчика, наследует его локаль.

from stageflow import i18n

i18n.available_locales()        # ['en', 'ru'] — на чём эта сборка умеет отвечать
i18n.get_locale()               # действующая локаль, либо 'en'

with i18n.use_locale("ru"):     # на время блока
    pipeline.validate()         # его ошибки выходят по-русски

token = i18n.set_locale("ru")   # или руками, в обработчике
i18n.reset_locale(token)

set_locale(None) возвращает к исходным строкам.

Что попросил вызывающий

HTTP-клиент заявляет предпочтение в Accept-Language, а это не тег, а упорядоченный список тегов. negotiate берёт его и отвечает локалью, которая у этой сборки действительно есть:

wanted = i18n.parse_accept_language("fr;q=0.9, ru-RU;q=0.8, en;q=0.5")
locale = i18n.negotiate(wanted)          # 'ru' — французского каталога нет, берём следующее

ru-RU находит каталог ru: тег откатывается к своим менее специфичным формам (pt_BR → pt), а тег, каталога для которого нет ни у кого, откатывается к исходному языку. Так региональному тегу не нужен отдельный каталог, а на незнакомый язык отвечают, а не отказывают.

В бэкенде это две строки в обработчике — где именно, показано в 2. Эндпоинты:

asked = request.headers.get("accept-language", "")
with i18n.use_locale(i18n.negotiate(i18n.parse_accept_language(asked))):
    ...

Читать заголовок — дело бэкенда. Фреймворк к запросу не прикасается: parse_accept_language — парсер, которому передали строку, negotiate сопоставляет теги с каталогами на диске, use_locale ставит локаль для этого контекста. На каком языке ответ, решается там, где есть запрос, а это не здесь.

Это про собственные сообщения фреймворка — что скажет ошибка валидации, что скажет отказ. Спеки стадий сюда не относятся: у них языка нет вообще, см. ниже.

У спек все языки, а не один

get_specs() кладёт в прозу все языки, на которых сборка умеет отвечать, — таблицей {локаль: текст}:

>>> ConcatStage.get_specs()["description"]
{'en': 'Concatenate stringified parts with separator',
 'ru': 'Склеивает части через разделитель, приводя их к строкам'}

Редактор забирает спеки один раз, а его читатель выбирает язык потом — и выбирает заново всякий раз, когда передумает. Бэкенд, который свёл бы прозу к одному языку, приходилось бы переспрашивать каждый раз, и выбор языка стал бы сетевым походом. Поэтому спека несёт выбор, а выбирает тот, кто её рисует; бэкенд не решает ничего.

Проза, которую никто не переводил, остаётся обычной строкой — таблица из одной записи была бы выбором, не несущим информации:

>>> SomeUntranslatedStage.get_specs()["description"]
'Takes a ticket out of the queue'

Действующая локаль этого не меняет. use_locale говорит, на каком языке этот ответ, а спека никому не ответ:

with i18n.use_locale("ru"):
    ConcatStage.get_specs()["description"]      # по-прежнему все языки

Явно названная локаль прозу всё-таки сворачивает — для того, кто действительно отвечает одному читателю, в основном для генератора документации:

ConcatStage.get_specs(locale="ru")["description"]
# 'Склеивает части через разделитель, приводя их к строкам'

Ваши стадии — на всех языках

Стадия описывает себя в YAML-докстринге. Любой кусок прозы в нём — description самой стадии и description каждого аргумента, выхода, события и входа — может быть таблицей по локалям вместо строки:

@register_stage("TakeTicketStage")
class TakeTicketStage(BaseStage):
    """
    description:
      en: Takes a ticket out of the queue
      ru: Берёт тикет из очереди
    category: support
    outputs:
      - name: ticket
        description:
          en: The ticket taken
          ru: Взятый тикет
    """

Такая таблица — это уже все языки, поэтому get_specs() отдаёт её как написано, а редактор выбирает из неё:

TakeTicketStage.get_specs()["description"]
# {'en': 'Takes a ticket out of the queue', 'ru': 'Берёт тикет из очереди'}

TakeTicketStage.get_specs(locale="ru")["description"]    # 'Берёт тикет из очереди'
TakeTicketStage.get_specs(locale="pt-BR")["description"] # английское: записи pt нет

Там, где один язык всё-таки надо выбрать — locale= выше или редактор, выбирающий за читателя, — ключи согласуются точно так же, как теги запроса, а исходный язык работает откатом. Таблица, написанная только на одном языке, — это всё равно ответ; стадия с обычными строками читается одинаково на любом языке, и для стадии, которую никто не переводил, это правильный ответ.

Свой каталог

Таблица на каждую строку хороша для горстки стадий и плоха для сотни. Хост, у которого их столько, регистрирует собственный домен gettext и указывает на него свои стадии — тогда фреймворк ищет их прозу там, а не берёт как написано:

i18n.register_domain("mystages", "/path/to/locale")

@register_stage("TakeTicketStage")
class TakeTicketStage(BaseStage):
    """
    description: Takes a ticket out of the queue
    """
    i18n_domain = "mystages"

Каталог раскладывается обычным для gettext образом — <locale>/LC_MESSAGES/<domain>.mo. Отдельный домен, а не добавка к домену stageflow: ваши строки — ваши, а искать их в каталоге фреймворка значит искать в чужом.

Каталоги, которые поставляются

В stageflow/locale/ лежит исходный каталог (stageflow.pot) и по каталогу на язык: .po, который правит переводчик, и скомпилированный .mo, который читает рантайм. В колесо попадают оба, так что у переводчика, у которого есть пакет, есть и исходник.

Локаль существует потому, что на диске существует каталог для неё. Ни один язык не назван в коде нигде, кроме SOURCE_LOCALE, так что добавить язык — это добавить файл, а не поправить список; available_locales() о нём сообщит.

Работу с каталогами делает tools/i18n.py (pip install -e '.[i18n]' — ради babel, который нужен только при разработке и никогда для запуска StageFlow):

python tools/i18n.py extract              # исходники -> locale/stageflow.pot
python tools/i18n.py update               # .pot -> .po каждой локали
python tools/i18n.py update --locale de   # или одной, создав её, если она новая
python tools/i18n.py compile              # .po -> тот .mo, который поставляется
python tools/i18n.py stats                # сколько где сделано

Извлечение читает два места, потому что переводимый текст фреймворка живёт в двух: _("…") в исходниках и проза встроенных стадий, написанная в YAML-докстринге, которую не разбирает ни один экстрактор — поэтому стадии импортируются и опрашиваются, что извлекает ровно то, что публикует get_specs().

Одно оставлено по-английски сознательно: сообщения проверок stageflow.testing. Их читает разработчик, прогоняющий тесты, рядом с трейсбеком Python — перевод не помог бы никому.

Редактор

Редактор — отдельный проект с отдельным механизмом для своего собственного текста: плоский JSON-каталог на язык, выбор в «Вид → Язык».

Спеки стадий его собственным текстом не являются, и переводить их он не пытается: он берёт таблицы {локаль: текст}, которые прислал бэкенд, один раз разрешает их под язык, на котором нарисован, и дальше работает со строками. Региональный тег удовлетворяет запрос языка, отсутствующий язык откатывается к тому, на котором спеки написаны, а таблица без того и другого отдаёт свою единственную запись.

То есть бэкенду, обслуживающему редактор, о языке своих стадий не нужно делать вообще ничего — он отдаёт get_specs(), а редактор разбирается сам.