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

Зачем нужны t-строки

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

Сначала идут таблицы — найдите интересующую вас строку и читайте только тот раздел, который за ней стоит.

К каждому переведённому сообщению причастны три стороны

Каталог — это файл переводов: .po, пока его редактируют люди, и скомпилированный .mo, который загружает приложение — учебник проходит оба шага. К каждому сообщению причастны три стороны: разработчик пишет исходную строку, переводчик редактирует каталог — часто на внешней платформе, вдали от какого-либо code review, — а приложение соединяет их во время выполнения. Каждый стиль форматирования ниже по-своему отвечает на один и тот же вопрос: какую часть языка форматирования контролирует каталог? В примерах _ — традиционное имя функции перевода, а tr — имя из этой библиотеки.

Сравнение

Когда переводчик ошибается. Каталог проходит через множество рук, и бо́льшая часть того, что в нём портится, портится случайно:

%(name)s .format() flufl.i18n $name t"…"
Перевод удаляет заполнитель — что выводится? значение молча исчезает значение молча исчезает значение молча исчезает исходное сообщение с предупреждением (по умолчанию)
Перевод добавляет неизвестный заполнитель — что выводится? исключение исключение заполнитель остаётся видимым как текст исходное сообщение с предупреждением (по умолчанию)
Перевод переформатирует заполнитель — что выводится? то, что запросил каталог, либо исключение, если буква типа больше не подходит значению то, что запросил каталог в $-строках невыразимо исходное сообщение с предупреждением
Проверяются ли заполнители при рендеринге? нет нет нет да (см. ниже)

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

%(name)s .format() flufl.i18n $name t"…"
Откуда берутся значения? явное отображение явные аргументы локальные и глобальные переменные вызывающей стороны плюс необязательный extras значения, захваченные внутри t-строки
Может ли каталог изменить форматирование значения? да да нет нет
Может ли каталог заглянуть внутрь объектов (доступ к атрибутам)? нет да да, с именами через точку нет
Где живёт «текущий язык»? там, куда его положит приложение там, куда его положит приложение стек кодов языков в общем объекте приложения ContextVar, для каждой задачи или запроса

Во что обходится внедрение. Всё вышеперечисленное достаётся бесплатно, если инструменты подходят; вот там, где они могут не подойти:

%(name)s .format() flufl.i18n $name t"…"
Минимальный Python любой любой 3.10 3.14
Зрелость стандартная библиотека стандартная библиотека стабильный выпуск alpha
Использует ли обычные каталоги PO/MO? да да да да
Нужен ли специальный экстрактор исходного кода? нет нет нет да, сейчас
Какой флаг PO выводит Babel для проверки существующими инструментами? python-format python-brace-format отсутствует python-brace-format

О проверке при рендеринге: для сообщений в единственном числе проверяется точное совпадение набора заполнителей. Сообщения с множественным числом тоже проверяются — по правилу объединения и пересечения, которое позволяет формам множественного числа целевого языка отличаться от исходных; более строгая проверка каждой формы выполняется при компиляции каталогов (Извлечение).

Строка о флаге формата относится к проверке с учётом заполнителей, а не к совместимости каталога. отсутствует означает, что стандартные инструменты gettext по-прежнему читают и компилируют сообщение, но у msgfmt --check-format нет грамматики $-заполнителей для применения.

Совместимость и зрелость

Первые две строки последней таблицы и решают вопрос о внедрении, так что их стоит проговорить словами, а не ячейками.

%-формат и .format() встроены в Python и вообще не требуют зависимостей. flufl.i18n — зрелый пакет, выпущенный и используемый в продакшене, который работает на Python 3.10 и новее. gettext-tstrings находится на стадии alpha и требует Python 3.14 или новее, потому что t-строки — новый синтаксис в 3.14, обратного портирования нет и быть не может. Его спецификация — стабильная часть; Python API до 1.0 ещё может измениться.

Чего не стоит ни один из них, так это совместимости каталогов. Все четыре дают обычные файлы POT/PO/MO, которые уже читают любой PO-редактор, любая платформа перевода и любой инструмент GNU gettext, — поэтому выбор ниже обратим так, как не была бы обратима смена формата каталога. Миграция описывает переход существующего проекта.

Разделы ниже разбирают каждый компромисс подробно, по одному методу за раз.

%-форматирование

_("Hello %(name)s") % {"name": name}

Что может пойти не так: повреждённая подстановка оборачивается исключением во время выполнения, если только проверка каталога не поймает её раньше.

В каталоге находится синтаксис printf, в том числе завершающая буква типа — s в %(name)s, — которую легко не заметить и легко повредить:

>>> "Hello %(name)" % {"name": "Ada"}  # the trailing "s" was deleted
Traceback (most recent call last):
  ...
ValueError: incomplete format

Правка одного символа в PO-редакторе становится трейсбэком в продакшене. GNU msgfmt --check-format это обнаружит, но лишь для сообщений с флагом python-format и только если каталог действительно проходит через msgfmt по пути к вашему приложению.

str.format

_("Hello {name}").format(name=name)

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

str.format — небольшой язык выражений, и его вызов для строки даёт этой строке право использовать этот язык:

>>> "{name.__class__.__mro__}".format(name="Ada")
"(<class 'str'>, <class 'object'>)"
>>> settings.api_key = "sk-live-…"
>>> "{conf.api_key}".format(conf=settings)
'sk-live-…'

Теперь замените эти литеральные строки на то, что возвращает _(). Если перевод Hello {name} вернулся как {conf.api_key}, его рендеринг напечатает ваш API-ключ — что именно будет прочитано, решил каталог, а не ваш код. Каталог — не код, но путешествует он как данные: на платформу перевода, через несколько рук, обратно в виде .po, компилируется в .mo, а иногда целиком поставляется извне проекта. .format() даёт каждому шагу этого пути доступ к атрибутам передаваемых объектов.

$-строки и flufl.i18n

from flufl.i18n import initialize

_ = initialize("example")

name = "Ada"
print(_("Hello $name"))  # Hello Ada — the value came from the caller's locals

Стандартная библиотека предоставляет язык интерполяции $name через string.Template, но сам по себе это не API перевода. flufl.i18n сочетает этот стиль с поиском в каталогах gettext. Обратите внимание: значение никуда не передаётся — flufl.i18n строит пространство имён подстановки из глобальных и локальных переменных вызывающей стороны, так что сообщению доступны любые переменные, существующие в точке вызова. Необязательное отображение extras имеет приоритет над ними. В синтаксисе для переводчика нет завершающей буквы типа или спецификатора формата, а заполнители можно свободно переставлять.

Недоступная подстановка не вызывает исключения. При name = "Ada" и отсутствии nombre в пространстве имён вызывающей стороны перевод каталога Hello $nombre отображается как Hello $nombre: неразрешённый заполнитель остаётся видимым. Это документированное поведение сохраняет остальную часть переведённого сообщения вместо сбоя вызова. Исключения при разрешении атрибута или преобразовании значения всё же могут распространяться дальше.

В одном важном отношении flufl.i18n способен на большее, чем обычный string.Template. Его собственный Template принимает заполнители с точками, например $settings.api_key, а его переводчик разрешает такие пути по значениям вызывающей стороны. Заполнитель перевода может назвать любую доступную локальную или глобальную переменную вызывающей стороны и с помощью точечной записи пройти по её атрибутам. Это удобно, когда сообщению нужен атрибут, но одновременно делает фрейм вызывающей стороны частью пространства имён подстановки каталога. Здесь описывается flufl.i18n 6.0.0, а не все возможные способы использования string.Template.

Он отвечает ещё на один вопрос, который два других стиля форматирования целиком оставляют приложению: какой язык сейчас активен и как его сменить. Объект приложения держит стек языков, _.push(code) и _.pop() двигают его, with _.using(code): вкладывает блоки, а стратегия находит каталог по коду языка, так что самому приложению никогда не приходится иметь дело с объектами каталогов. Сервер, которому в пределах одной единицы работы нужно выдать текст более чем на одном языке — страницу для читателя, уведомление тому, у кого в аккаунте выбран другой язык, — это и есть тот случай, ради которого всё это существует.

Стек живёт в этом объекте приложения, а его разделяет весь процесс. Поэтому два перекрывающихся запроса делят один стек, и блоки, вложенность которых не строгая во времени, передают друг другу не тот язык:

async def greet(code, delay):
    with _.using(code):
        await asyncio.sleep(delay)
        return _("Hello $name")


async def main():
    return await asyncio.gather(greet("fr", 0.01), greet("ja", 0.02))
>>> asyncio.run(main())  # "fr" entered first and left first, so it read "ja" off the top
['こんにちは Ada', 'Bonjour Ada']

Эта библиотека сохраняет ту же возможность — привязки так же вкладываются и разворачиваются, — но в ContextVar, а не в общем стеке, поэтому чередование выше разрешается для каждой задачи отдельно. Эквиваленты — на странице Несколько языков сразу. Чего она не даёт, так это поиска каталога по коду языка: вы передаёте объект переводов, который в обычном случае сводится к одному вызову gettext.translation(), а стандартная библиотека кеширует разобранный каталог.

t-строки

tr(t"Hello {name}")

Каталог по-прежнему видит Hello {name} и остаётся обычным каталогом PO/MO. Разница в том, что переводу разрешено сказать — и кто это проверяет.

Эта библиотека сверяет каждый перевод с заполнителями исходного сообщения перед рендерингом и принимает простые имена — и ничего больше. Для t"Hello {name}":

Перевод содержит Причина отклонения
{name.__class__.__mro__} placeholder {name.__class__.__mro__} must be a plain name, copied from the source message unchanged
{name!r} placeholder {name} adds formatting; write {name} on its own, because the source message decides how the value is formatted
{0} placeholder {0} must be a plain name, copied from the source message unchanged
{nombre} translation does not match the source placeholders: {name} is missing; {nombre} is not in the source message

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

Форматирование остаётся там, где было написано, — в коде:

amount = 1234.5
tr(t"Total: {amount:,.2f}")  # msgid is "Total: {amount}"

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

Ещё одно отличие — инструменты: t-строки — новый синтаксис, поэтому для их извлечения в .pot сейчас нужен экстрактор с поддержкой t-строк, например тот, который этот пакет предоставляет для Babel.

Цена ограничения

Помимо требования к версии Python, за всё это платят одним правилом: каждая интерполяция должна быть простым именем.

tr(t"Hello {user.name}")  # raises InvalidTemplateError at the call site
name = user.name  # compute it first
tr(t"Hello {name}")

Это реальное ограничение — и то самое, из которого рождаются все гарантии выше. Вместе с привязкой значений на стороне исходного кода и проверкой заполнителей во время выполнения оно не позволяет строкам каталога вычислять выражения и сохраняет имена заполнителей осмысленными для того, кто их переводит.

f-строку вообще нельзя использовать таким образом: к моменту, когда её видит любая библиотека, это уже готовая строка, поэтому её перевод означает перевод фрагмента. t-строки (PEP 750) хранят статический текст и значения раздельно, сохраняя похожий на f-строки синтаксис и явную привязку значений.

Как Python пришёл к этому — две PEP с разницей в десять лет и обсуждение в stdlib, закрытое без ответа, — рассказано со ссылками на источники на странице Предыстория.