Vai al contenuto

Perché le t-string

Quattro modi di mettere un valore in un messaggio traducibile, confrontati sullo stesso messaggio. Tutti e quattro danno un nome ai propri segnaposto e permettono a un traduttore di riordinarli; differiscono in che cosa succede quando una traduzione è sbagliata, in quanta parte del tuo programma il catalogo può raggiungere e in quanto costa adottarli.

Le tabelle vengono per prime, così puoi trovare la riga che ti interessa e leggere soltanto la sezione che le sta dietro.

Tre parti toccano ogni messaggio tradotto

Un catalogo è il file delle traduzioni — .po finché lo modificano gli esseri umani, compilato in .mo perché l'applicazione lo carichi (il tutorial li percorre entrambi). Tre parti toccano ogni messaggio: lo sviluppatore scrive la stringa sorgente, un traduttore modifica il catalogo — spesso su una piattaforma esterna, lontano da qualunque code review — e l'applicazione rende i due insieme a runtime. Ogni stile di formattazione qui sotto risponde in modo diverso alla stessa domanda: quanta parte del linguaggio di formato può controllare il catalogo? Negli esempi, _ è il nome convenzionale della funzione di traduzione, e tr è quello di questa libreria.

Fianco a fianco

Quando un traduttore sbaglia. Un catalogo passa per molte mani, e quasi tutto ciò che vi va storto è accidentale:

%(name)s .format() flufl.i18n $name t"…"
Una traduzione elimina un segnaposto — che cosa viene reso? il valore sparisce in silenzio il valore sparisce in silenzio il valore sparisce in silenzio il messaggio sorgente, con un avviso (per impostazione predefinita)
Una traduzione aggiunge un segnaposto sconosciuto — che cosa viene reso? un'eccezione un'eccezione il segnaposto resta visibile come testo il messaggio sorgente, con un avviso (per impostazione predefinita)
Una traduzione riformatta un segnaposto — che cosa viene reso? ciò che il catalogo ha chiesto, oppure un'eccezione se la lettera di tipo non si adatta più al valore ciò che il catalogo ha chiesto non esprimibile nelle $-string il messaggio sorgente, con un avviso
I segnaposto sono verificati al momento del rendering? no no no sì (vedi sotto)

Quanta autorità ha il catalogo. Una traduzione è un dato che viene da fuori del tuo repository, e ogni stile le consegna una quantità diversa di potere:

%(name)s .format() flufl.i18n $name t"…"
Da dove vengono i valori? un mapping esplicito argomenti espliciti le variabili locali e globali del chiamante, più l'extras opzionale i valori catturati dentro la t-string
Il catalogo può cambiare come un valore è formattato? no no
Il catalogo può entrare negli oggetti (accesso agli attributi)? no sì, con i nomi puntati no
Dove vive "la lingua corrente"? dove la mette l'applicazione dove la mette l'applicazione uno stack di codici di lingua sull'oggetto applicazione condiviso una ContextVar, per task o per richiesta

Quanto costa integrarli. Tutto quanto sopra è gratis se il tooling è adatto; è qui che potrebbe non esserlo:

%(name)s .format() flufl.i18n $name t"…"
Python minimo qualunque qualunque 3.10 3.14
Maturità libreria standard libreria standard release stabile alpha
Usa normali cataloghi PO/MO?
Serve un estrattore di sorgenti dedicato? no no no sì, per ora
Quale flag PO inferisce Babel, perché gli strumenti esistenti validino? python-format python-brace-format nessuno python-brace-format

Sul controllo a tempo di rendering: i messaggi singolari sono verificati per una corrispondenza esatta dei segnaposto. Anche i messaggi plurali sono verificati, contro la regola unione/intersezione che permette alle forme plurali di una lingua di destinazione di differire da quelle della sorgente; il controllo più severo, forma per forma, avviene alla compilazione dei cataloghi (Estrazione).

La riga sul flag di formato riguarda la validazione consapevole dei segnaposto, non la compatibilità dei cataloghi. nessuno significa che gli strumenti gettext standard leggono e compilano comunque il messaggio, ma msgfmt --check-format non ha una grammatica di segnaposto $ da applicare.

Compatibilità e maturità

Le prime due righe dell'ultima tabella sono quelle che decidono l'adozione, quindi vale la pena dirle apertamente invece che dentro delle celle.

Il %-format e .format() sono incorporati in Python e non richiedono nessuna dipendenza. flufl.i18n è un pacchetto maturo, rilasciato e in uso in produzione, che gira su Python 3.10 e successivi. gettext-tstrings è una alpha e richiede Python 3.14 o più recente, perché le t-string sono sintassi nuova della 3.14 — non c'è un back-port e non può esserci. La sua specifica ne è la parte stabile; l'API Python può ancora muoversi prima della 1.0.

Ciò che nessuno di loro costa è la compatibilità dei cataloghi. Tutti e quattro producono normali file POT/PO/MO che ogni editor PO, ogni piattaforma di traduzione e ogni strumento GNU gettext già legge, quindi la scelta qui sotto è reversibile in un modo in cui cambiare formato di catalogo non lo sarebbe. Migrazione copre lo spostamento di un progetto esistente.

Le sezioni seguenti mostrano ogni compromesso in dettaglio, un metodo alla volta.

%-format

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

Che cosa può andare storto: un segnaposto danneggiato diventa un'eccezione a runtime, a meno che la validazione del catalogo non lo intercetti prima.

La stringa nel catalogo trasporta sintassi printf, inclusa una lettera di tipo finale — la s di %(name)s — facile da trascurare e facile da danneggiare:

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

Una modifica di un carattere in un editor PO diventa un'eccezione a runtime, a meno che la validazione del catalogo non la intercetti prima. GNU msgfmt --check-format questa la intercetta, sì, ma solo per i messaggi marcati python-format, e solo se il catalogo passa davvero per msgfmt nel suo tragitto verso l'applicazione.

str.format

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

Elimina la lettera di tipo finale mantenendo un segnaposto con nome, liberamente riordinabile. Ciò che può andare storto si sposta sull'altro lato dello scambio: la traduzione acquista potere sui tuoi oggetti.

str.format è un piccolo linguaggio di espressioni, e chiamarlo su una stringa significa consegnare a quella stringa il diritto di usarlo:

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

Ora sostituisci quelle stringhe letterali con qualunque cosa restituisca _(). Se una traduzione di Hello {name} torna come {conf.api_key}, renderla stampa la tua chiave API — è stato il catalogo, non il tuo codice, a decidere che cosa leggere. Un catalogo non è codice, ma viaggia come dati: va verso una piattaforma di traduzione, passa per molte mani, torna come .po, viene compilato in .mo, a volte è vendorizzato interamente da fuori del tuo progetto. .format() dà a ogni tappa di quel viaggio l'accesso agli attributi degli oggetti che passi.

Le $-string e flufl.i18n

from flufl.i18n import initialize

_ = initialize("example")

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

Il string.Template della libreria standard fornisce il linguaggio di interpolazione $name, ma non è di per sé un'API di traduzione. flufl.i18n combina quello stile con la ricerca nei cataloghi gettext. Nota che il valore non viene mai passato: flufl.i18n costruisce lo spazio dei nomi di sostituzione dai globali e dai locali del chiamante — qualunque variabile esista nel punto di chiamata è disponibile al messaggio. Un mapping extras opzionale ha precedenza su entrambi. La sua sintassi rivolta al traduttore non ha lettera di tipo finale né specifica di formato, e i segnaposto restano liberamente riordinabili.

Una sostituzione non disponibile non solleva eccezioni. Con name = "Ada" e nessun nombre nello spazio dei nomi del chiamante, una traduzione da catalogo di Hello $nombre viene resa come Hello $nombre: il segnaposto non risolto rimane visibile. Quel comportamento documentato preserva il resto del messaggio tradotto invece di far fallire la chiamata. Le eccezioni sollevate risolvendo un attributo o convertendo un valore possono comunque propagarsi.

flufl.i18n è più capace di un semplice string.Template in un modo qui rilevante. Il suo Template personalizzato accetta segnaposto con il punto come $settings.api_key, e il suo translator risolve quei percorsi contro i valori del chiamante. Un segnaposto tradotto può nominare qualunque locale o globale disponibile del chiamante e, con la sintassi puntata, attraversarne gli attributi. È comodo quando un messaggio ha bisogno di un attributo, ma rende anche il frame del chiamante parte dello spazio dei nomi di sostituzione del catalogo. Il confronto qui descrive flufl.i18n 6.0.0, non ogni possibile uso di string.Template.

Risponde anche a una domanda che gli altri due stili di formattazione lasciano interamente all'applicazione: quale lingua è quella corrente, e come cambiarla. Un oggetto applicazione mantiene uno stack di lingue, _.push(code) e _.pop() lo muovono, with _.using(code): annida, e una strategia trova il catalogo per un codice di lingua, così che l'applicazione non maneggi mai oggetti catalogo. Un server che deve produrre testo in più di una lingua nel corso di una sola unità di lavoro — una pagina per chi legge, una notifica per qualcuno il cui account è impostato diversamente — è il caso per cui questo esiste.

Lo stack vive su quell'oggetto applicazione, che l'intero processo condivide. Due richieste sovrapposte condividono quindi un unico stack, e i blocchi che non sono strettamente annidati nel tempo si passano l'un l'altro la lingua sbagliata:

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']

Questa libreria conserva la stessa capacità — i binding si annidano e si sciolgono allo stesso modo — dentro una ContextVar invece che in uno stack condiviso, così l'intreccio qui sopra si risolve per task. Gli equivalenti sono su Più lingue insieme. Ciò che non fornisce è la ricerca dal codice di lingua al catalogo: passi un oggetto translations, che nel caso comune è una sola chiamata a gettext.translation(), e la libreria standard mette in cache il catalogo già analizzato.

t-string

tr(t"Hello {name}")

Il catalogo vede sempre Hello {name} e rimane un normale catalogo PO/MO. La differenza è che cosa una traduzione può dire, e chi lo verifica.

Questa libreria valida ogni traduzione contro i segnaposto del messaggio sorgente prima del rendering, e accetta nomi semplici e nient'altro. Contro t"Hello {name}":

Una traduzione che contiene viene rifiutata con
{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

Rifiutata non significa crash: per impostazione predefinita la libreria registra un avviso nel log e rende il messaggio sorgente, così un catalogo danneggiato non abbatte mai l'applicazione — lo stesso contratto che gettext stesso mantiene.

La formattazione resta dov'era scritta, nel codice:

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

:,.2f non raggiunge mai il catalogo, quindi nessuna traduzione può cambiarlo, e nessun traduttore deve guardarlo. È però un formato fisso, non un formato localizzato — scegliere cifre e separatori per ogni lingua è compito di Babel, prima della chiamata.

Un'altra differenza è il tooling: le t-string sono sintassi nuova, quindi estrarle in un .pot richiede al momento un estrattore che le comprenda, come quello che questo pacchetto fornisce per Babel.

Il costo della restrizione

Oltre al requisito su Python, il prezzo di tutto questo è una sola regola: un'interpolazione deve essere un nome semplice.

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

È un vincolo reale, ed è lo stesso vincolo che produce le garanzie qui sopra. Insieme al legame dei valori sul lato sorgente e al controllo dei segnaposto a runtime, impedisce alle stringhe di catalogo di valutare espressioni e mantiene i nomi dei segnaposto significativi per chi li traduce.

Una f-string non si può usare affatto in questo modo — quando una libreria ne vede una è già una stringa finita, quindi tradurla significa tradurre un frammento. Le t-string (PEP 750) mantengono separati il testo statico e i valori conservando una sintassi simile alle f-string e il legame esplicito dei valori.

Come Python sia arrivato fin qui — due PEP a dieci anni di distanza e la discussione sulla stdlib chiusa senza una risposta — è raccontato con le fonti in Contesto.