Перейти до змісту

Навіщо t-рядки

Чотири способи вставити значення в перекладне повідомлення, порівняні на тому самому повідомленні. Усі чотири іменують свої заповнювачі й дозволяють перекладачеві їх переставляти; вони різняться тим, що стається, коли переклад хибний, тим, наскільки далеко в вашу програму дістає каталог, і тим, скільки коштує їх упровадити.

Спершу йдуть таблиці, щоб ви могли знайти рядок, який вас цікавить, і прочитати лише той розділ, що стоїть за ним.

Кожного перекладеного повідомлення торкаються три сторони

Каталог — це файл перекладів: .po, поки його редагують люди, скомпільований у .mo, який завантажує застосунок (підручник проходить обидва). Кожного повідомлення торкаються три сторони: розробник пише вихідний рядок, перекладач редагує каталог — часто на зовнішній платформі, далеко від будь-якого рев'ю коду, — а застосунок рендерить їх разом під час виконання. Кожен стиль форматування нижче по-своєму відповідає на те саме питання: якою частиною мови форматування дозволено керувати каталогу? У прикладах _ — традиційне ім'я функції перекладу, а 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
Зрілість стандартна бібліотека стандартна бібліотека стабільний випуск альфа
Використовує звичайні каталоги 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альфа, і їй потрібен 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 замість спільного стека, тож наведене вище чергування розв'язується для кожної задачі окремо. Еквіваленти — на сторінці Кілька мов одночасно. Чого вона не дає, то це пошуку каталогу за кодом мови: ви передаєте об'єкт translations, який у типовому випадку є одним викликом 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, закрите без відповіді, — розказано з джерелами на сторінці Передісторія.