Progetti multilingua in Python (parte 8/9).

Attenzione!

Queste guide non sono più aggiornate, e in alcuni punti sono proprio superate.
Il mio libro Python in Windows tratta questi temi, e molto altro, in modo molto più completo e aggiornato. 

Considerazioni varie (parte prima).

Abbiamo terminato la nostra panoramica sull’uso di gettext e sulle tecniche di traduzione più consuete. Aggiungiamo qui alcune considerazioni e consigli sparsi.

Troppi literals nel codice?

Forse un po’ in ritardo, dobbiamo avvertire che molti programmatori considerano cattiva pratica disseminare il codice di literals, e quindi anche di stringhe, che siano tradotte o meno. È lo stesso problema dei “numeri magici” nel codice. Più corretto sarebbe usare delle costanti il cui valore è attribuito in un modulo Python separato, o addirittura in una sorgente di dati esterna (un resource file, un file di configurazione, etc.).
Questo è senz’altro vero, ma l’opinione di chi scrive è più morbida. Prima di tutto, se le stringhe devono essere tradotte, allora è più facile usare gettext se queste sono in un modulo Python, invece che in un file esterno (vedremo comunque qualche scenario di questo tipo). In secondo luogo, è senz’altro possibile raggruppare tutte le stringhe in un modulo separato e richiamarle altrove per mezzo di costanti: ma di solito questo complica la leggibilità del codice, non la semplifica:
if foo > bar:
    # trovate piu' chiaro questo...
    emit_warning(WARNING104)
    # ...o questo?
    emit_warning('Your foo is getting too big. Soon it will explode.')
È vero che un codice ben scritto (che rispetta la Pep8, che usa nomi di variabile chiari…) si dovrebbe comprendere da solo; è vero che dove il codice non è chiaro dovrebbero esserci dei commenti; ma è anche vero che talvolta, quando ci sono già delle stringhe destinate a spiegare all’utente quello che sta succedendo, tutto diventa molto più facile anche per chi legge il codice sorgente.
Piuttosto c’è da dire questo: se nel vostro programma ci sono troppi punti diversi (moduli, classi, funzioni…) che hanno al loro interno delle stringhe tradotte, allora probabilmente state sbagliando qualcosa nell’architettura. I compiti dell’interfaccia utente (e quindi il codice CLI o GUI) dovrebbero essere organizzati in pochi punti ben riconoscibili, e non sparpagliati in giro nel codice.
Ancora a questo proposito, una particolare forma di crudeltà che talvolta ci è capitato di vedere è l’utilizzo di “stringhe identificative” per le stringhe tradotte, al posto di un normale valore “di default”:
if foo > bar:
    # invece del normale:
    emit_warning(_('Your foo is getting too big.'))
    # qualcosa come:
    emit_warning(_('Warning 104'))
Questo metodo produce naturalmente dei cataloghi .po del tipo:
msgid "Warning 104"
msgstr ""
L’idea qui è di usare i cataloghi .po come resource file multilingua esterni. Voi compilate il primo catalogo fornendo una “traduzione” (ovvero una spiegazione della sigla!) nella vostra lingua, e gli altri traduttori compilano i loro a partire dal vostro. È inutile dire che questa è una pessima idea, che mescola il peggio dei due mondi. La stringa “identificativa” funziona un po’ come una costante, ma non lo è: quindi per esempio non può essere messa insieme alle altre in un unico modulo, magari in ordine alfabetico per facilitarne la ricerca. Se poi deve essere interpolata, tende ad assumere un aspetto bizzarro (qualcosa come _('Warning 104 {foo} {bar}')). Infine, non esiste più l’ancora di salvezza della “lingua di default” della stringa originale: questo significa che se gettext per qualche motivo non trova nessuna traduzione, presenterà all’utente l’incomprensibile stringa 'Warning 104'.
Dopo aver ribadito che per noi questa è una cattiva idea, c’è però almeno una possibile ragione per implementarla, e ne parliamo nel prossimo paragrafo.

Problemi con la “lingua di default”.

La lingua “originale” che voi usate nel codice è pur sempre una delle lingue che possono essere richieste dall’utente (in modo esplicito, o implicito attraverso il locale). In certi scenari, questo potrebbe essere un problema.
Supponiamo di avere, nel codice originale, delle stringhe in Italiano. Naturalmente questo significa che l’Italiano non sarà disponibile tra le lingue tradotte: concretamente, non ci sarà nessun catalogo locale/it/LC_MESSAGES/myapp.mo. Di solito questo non è un problema. Infatti se installate gettext chiedendo l’Italiano, così:
t = gettext.translation('myapp', 'locale', languages=['it'], 
                        fallback=True)
t.intall()
allora gettext non trova il catalogo italiano e visualizza le stringhe originali… in Italiano, appunto! Il paracadute è offerto dal parametro fallback=True, che probabilmente volete impostare in ogni caso. Infatti se gettext non trova nessuna delle lingue richieste e fallback=False, allora abortisce con un OSError. Non è una cosa che potete permettervi, soprattutto perché i cataloghi delle traduzioni sono pur sempre risorse esterne che possono sfuggire al controllo della vostra applicazione (qualcuno può cancellarli per sbaglio, per dire).
Lo stesso paracadute si apre anche quando istallate con gettext.install, così:
os.environ['LANG'] = locale.getdefaultlocale()[0] # supponiamo sia "it"
gettext.install('myapp', 'locale')
perché, ricordiamo, gettext.install installa automaticamente con fallback=True.
Il problema si pone invece quando volete prevedere dei meccanismi di fallback più complessi:
t = gettext.translation('myapp', 'locale', languages=['fr', 'it', 'en'], 
                        fallback=True)
t.intall()
Questo non funzionerà come sperate: voi chiedete il Francese, dove non c’è allora l’Italiano, e come ultima risorsa l’Inglese; ma se gettext non trova il Francese salterà subito all’Inglese, perché in effetti non trova nessun catalogo italiano. Tuttavia l’Italiano c’è eccome, nelle stringhe originali del codice! Ma questo gettext non può saperlo. Ora, va detto che questo è uno scenario un po’ assurdo: voi sapete già che il codice originale ha le stringhe in Italiano, e quindi sapete che l’Italiano sarà sempre disponibile come fallback prima di dover ricorre all’Inglese. La sequenza ['fr', 'it', 'en'] non ha senso, se il codice originale ha le stringhe in Italiano: tutto ciò che viene dopo 'it' non dovrebbe importare. A rigore, non importa neppure 'it': tanto voi sapete già che il catalogo italiano non esiste, ma avete cura di imporre fallback=True. Quello che volete, in questo caso, si potrebbe ottenere in modo più corretto (e più semplice) così:
t = gettext.translation('myapp', 'locale', languages=['fr'], 
                        fallback=True)
Chiedete il Francese; se non è disponibile, il fallback restituirà l’Italiano (la lingua delle stringhe originali). Non ha senso pensare ad altri fallback dopo l’Italiano, perché per definizione tutte le stringhe hanno una versione italiana (quella del codice originale, ovvio).
Tuttavia, potreste essere in uno scenario convoluto in cui il componente che fa la richiesta di traduzione arriva da una terza parte, e non sa che le stringhe originarie sono in Italiano. Dal suo punto di vista, una sequenza come ['fr', 'it', 'en'] potrebbe aver senso. E sarebbe sgradevole dare l’impressione che non sia disponibile la lingua italiana solo perché non si trova il catalogo della traduzione italiana. Una soluzione è quella di implementare il pattern delle “stringhe-identificativo” visto nel paragrafo precedente: in pratica il codice sorgente è language agnostic e invece delle stringhe c’è soltanto un identificativo. A questo punto, siete costretti a produrre un catalogo anche per l’Italiano. Come sapete, questa soluzione non ci convince.
Un’altra soluzione è fornire comunque un catalogo anche per l’Italiano: un catalogo che “traduce” ogni stringa con la stessa identica stringa. Non avete bisogno di farvelo a mano: per questo compito esiste msgen tra le utility di GNU Gettext:
> xgettext -d myapp main.py
> msgen myapp.po > locale/it/LC_MESSAGES/myapp.po

Conoscere la lingua usata a runtime.

Va detto che gettext.GNUTranslations non conserva l’elenco delle lingue di cui sta fornendo la traduzione: chiama gettext.find in fase di inizializzazione per trovare i cataloghi richiesti e li carica, ma una volta usate queste informazioni per costruire il suo dizionario interno, non se ne preoccupa più.
Ricordiamo che c’è una differenza tra “lingua/e richiesta/e” e “lingua/e fornita/e”. Di conseguenza non potete semplicemente affidarvi a una variabile mysettings.LANGUAGE o alla lettura di variabili d’ambiente o al locale predefinito: tutte queste cose esprimono solo il desiderio di avere una certa lingua. Non vi resta che usare voi stessi gettext.find per capire esattamente come stanno le cose. Ricordate anche che se gettext.find restituisce None o una lista vuota, vuol dire che la lingua usata è quella del codice originale… e a questo punto non c’è modo di conoscerla se non ispezionando il codice.

Traduzioni, GUI e design MCV.

Un avvertimento da non sottovalutare: l’aggiunta di traduzioni potrebbe non essere solo un fattore cosmetico per il vostro codice. Soprattutto nel caso di GUI (programmi a interfaccia grafica) potreste dover modificare l’architettura in modo piuttosto significativo. Una buona notizia: la presenza di stringhe tradotte potrebbe spingervi a scrivere codice migliore.
Ci riferiamo a tutte quelle circostenze in cui preleviamo dall’utente una stringa, e poi per prigrizia ne utilizziamo il valore “così com’è” per calcoli successivi. Finché la stringa non è tradotta, spesso ce la caviamo comunque. Ma se la stringa è tradotta, il valore di ritorno potrebbe essere qualsiasi cosa, e di solito questo manda all’aria i nostri piani.
Per esempio, supponiamo di presentare all’utente una scelta tra alcuni valori in una lista (il codice che segue è per wxPython, ma si capisce senza problemi):
choices = ['mela', 'pera', 'banana']
ctrl = wx.ListBox(parent, choices=choices)
Questo disegna una lista con tre scelte. Quando l’utente fa clic su una voce della lista, noi potremmo voler discriminare in base alla scelta:
selected = ctrl.GetString(ctrl.GetSelection())
if selected == 'mela':
    foo()
elif selected == 'pera':
    bar()
(...)
Questo è cattivo design: ma tutto sommato, poco importa. Se però introduciamo nel nostro progetto le traduzioni, allora le stringhe tra cui scegliere diventeranno:
choices = [_('mela'), _('pera'), _('banana')]
Adesso, a seconda della lingua l’utente farà clic su una stringa completamente inaspettata (potrebbe essere “apple”, “apfel”, manzana"…) e così la nostra logica di selezione non tiene più. Possiamo pensare di recuperare il numero di riga selezionata, invece della stringa: ma questo vale solo per il caso semplice, senza tener conto che la lista potrebbe essere riordinata o modificata a runtime in vari modi.
Problemi di questo tipo appaiono continuamente, quando prendiamo la scorciatoia di prelevare direttamente dall’interfaccia (la “view”) e poi da questa passare alla logica “di business”. La soluzione è implementare un’architettura MCV che separa ciò che vede l’utente dagli oggetti reali da manipolare per gestire la logica dell’applicazione.
Ora, MCV potrebbe apparire come una sovrastruttura gigantesca e molto intricata, ma in realtà spesso si tratta di implementarlo “dal basso”, widget per widget. Come sempre in questi casi, prima di escogitare soluzioni fai-da-te è meglio verificare che il framework già non disponga di strumenti utili. Per esempio, per questi casi semplici wxPython ha il concetto di client data: a ciascuna riga della lista è possibile associare un oggetto qualsiasi, nascosto. Potete approfittarne per “agganciare” degli indici di qualche tipo, che mantenete stabili (il model) mentre le stringhe della lista (la view) variano con la traduzione; wxPython provvede al meccanismo (il controller) per passare direttamente dalla selezione dell’utente all’indice corrispondente, senza interessarsi alla stringa di rappresentazione:
# la "view": valori tradotti per l'utente
choices = [_('mela'), _('pera'), _('banana')] 
# il "model": una lista di valori stabili
values = ['mela', 'pera', 'banana']

ctrl = wx.ListBox(parent)
for ch, val in zip(choices, values):
    # "val" resta nascosto: e' il "client data" della riga
    ctrl.Append(ch, val) 

(...)

# recuperiamo direttamente il "client data" della selezione
selected = ctrl.GetClientData(ctrl.GetSelection())
# adesso la selezione avviene rispetto al "model", non alla "view"
if selected == 'mela':
    foo()
elif selected == 'pera':
    bar()
(...)
Questo adesso funziona qualunque sia la “view”: e non solo riguardo alla traduzione delle stringhe della lista, ma anche rispetto per esempio al riordino, inserimenti modifiche e cancellazioni, etc.: il client data resta sempre attaccato alla sua riga, qualsiasi cosa succeda. Naturalmente per casi più complessi esistono poi strumenti più raffinati, ma il concetto resta identico.

Lingua, locale, layout.

Ci sarebbe da scrivere un libro: e in effetti si trovano in giro saggi, guidelines, discussioni… e non tutto ciò che trovate è concorde e ugualmente affidabile. Ci limitiamo ad alcune osservazioni basilari, più che altro per segnalare che, in effetti, i problemi esistono e sono parecchi.
In primo luogo, ricordiamo ancora che “lingua” (i18n) e “locale” (l10n) sono concetti diversi, anche se parzialmente sovrapposti. Potete impostare la lingua ma non il locale, o viceversa, o entrambi o nessuno. Idealmente, la “lingua di default” dovrebbe andare d’accordo con il “locale di default”: ovvero, un utente in Italia dovrebbe visualizzare il testo in Italiano insieme alle convenzioni per il locale italiano (date, numeri…). In pratica però le cose possono complicarsi in vari modi. Primo, il locale di default vale per il computer, non per l’utente che lo sta usando: un utente egiziano che usa un computer italiano non vedrà il locale “giusto” per lui se chiamate semplicemente setlocale(LC_ALL, ''). In teoria potreste far scegliere anche il locale all’utente. In pratica però questo non è un disturbo che in genere le applicazioni si prendono: se il locale non piace all’utente, è compito suo re-impostarlo a livello di sistema operativo, in modo che poi tutte le applicazioni locale-aware facciano la cosa giusta per lui. Quindi, se volete essere locale-aware, potete limitarvi a chiamare setlocale(LC_ALL, '') senza troppi problemi.
Secondo, la lingua scelta potrebbe non essere quella suggerita dal locale scelto. È vero che, se all’utente non piace la lingua predefinita, potrebbe cambiare locale nel sistema operativo. Tuttavia, per l’utente di solito un locale straniero è più sopportabile di una lingua straniera. Se il vostro programma offre traduzioni alternative, allora dovrebbe anche offrire un modo di scegliere una lingua disponibile (anche se magari non di cambiarla a runtime). Impostare la lingua di default del locale va bene solo come scelta di default, appunto: ma l’utente dovrebbe poter cambiare lingua. Questo significa naturalmente che l’utente potrebbe vedere il programma nella sua lingua, ma con un locale diverso dal suo: sta a voi decidere se per la vostra applicazione questo è inaccettabile, ed eventualmente cambiare anche il locale.
Poi c’è da considerare lo spinoso problema delle lingue per cui non è sufficiente una “traduzione”. Qui lo scoglio maggiore è il supporto per le lingue che si scrivono da destra a sinistra (RTL, right to left). Il problema è troppo vasto per essere discusso qui: basterà dire che Unicode rappresenta l’ordine logico dei caratteri (la sequenza temporale in cui vanno letti), e non l’ordine visivo (RTL o LTR). Esiste però un Unicode Bidirectional Algorithm che descrive il modo in cui andrebbe trattato il testo per essere bidirectional-aware (badate: è un pochino più complesso che fare txt = txt[::-1]). Python non ha il supporto per il testo bidirezionale nella libreria standard. Esiste Python-bidi, un’implementazione pure-Python dell’algoritmo Unicode. Chi scrive non ha sufficiente competenza per dire fino a che punto l’implementazione di Python-bidi sia completa e corretta: in genere si trova sempre qualche corner-case insidioso. Altrimenti, l’implementazione di riferimento è GNU Fribidi, scritta in C e quindi facile da chiamare con ctypes: ne esistono a nostra conoscenza almeno due wrapper Python: Pyfribidi e Python-fribidi.
A parte il problema della bidirezionalità, potreste aver bisogno di ricorrre a soluzioni ad-hoc per lingue specifiche, come l’Arabo (che unisce i caratteri con legature in base al contesto, vedi Python Arabic Reshaper) o il Giapponese (che ha diversi sistemi di scrittura, vedi Jaconv). Ma più in generale, per tutte le sottigliezze Unicode non supportate nativamente da Python, il vostro primo riferimento dovrebbero essere le librerie ICU, per cui esiste il binding Python PyICU.
Infine, e potrebbe essere l’ostacolo maggiore, il supporto per le lingue bidirezionali si estende a tutto il layout della GUI. Se la lingua è RTL, non solo il testo andrebbe allineato a destra, ma proprio tutto il layout dovrebbe essere realizzato “a specchio”. Potete dare un’occhiata a queste linee guida di Google per farvi un’idea delle dimensioni del problema. Come sempre, prima di lanciarvi in soluzioni artigianali pasticciate, verificate il supporto per la bidirezionalità nel framework che volete usare. Per esempio nel campo delle web app molti front-end supportano soluzioni CSS, javascript, etc.

Altre considerazioni sul cambio di lingua a runtime.

Cambiare lingua a runtime, per esempio in risposta a un input dell’utente, è relativamente semplice dal punto di vista di gettext, ma potrebbe essere complicato da eseguire nell’architettura complessiva della vostra applicazione. Abbiamo visto qualche possibile trappola parlando di deferred translation; aggiungiamo qui alcune considerazioni ulteriori.
Prima di tutto, se lasciate all’utente la possibilità di scegliere una lingua, allora dovete accertarvi che il catalogo corrispondente esista davvero: se l’utente chiede una lingua e ne vede un’altra (magari una lingua di fallback, o quella della stringa originale) potrebbe pensare a un baco. Usate gettext.find per scoprire se il catalogo esiste, ed eventualmente comunicate il problema all’utente; oppure lasciategli scegliere solo tra un elenco di traduzioni che siete sicuri che esistono.
Se il vostro programma ha un’interfaccia testuale (CLI), in genere le cose sono facili: l’utente fa la sua scelta, e da quel momento in poi l’output del programma compare nella nuova lingua.
Se invece il programma ha un’interfaccia grafica (GUI), l’approccio deve essere studiato meglio. Se vi limitate a cambiare la lingua, anche dopo aver superato lo scoglio della valutazione delle variabili dal lato di gettext, resta il fatto che dal lato della GUI l’interfaccia non si aggiorna da sola. Di solito i singoli elementi si aggiornano man mano che vengono ri-disegnati dal gui framework: per esempio, un menu comparirà nella nuova lingua la prossima volta che l’utente lo seleziona; ma l’etichetta di un pulsante probabilmente resterà nella vecchia lingua per molto tempo ancora. Dovete pensare a una strategia per forzare l’aggiornamento di tutti gli elementi: per esempio nascondere temporaneamente tutta l’interfaccia per un istante (mostrando eventualmente il cursore “di attesa” nel frattempo), inviare a tutti gli elementi un segnale di aggiornamento, e quindi far riapparire la finestra.
Tutto sommato, in genere potete risparmiarvi il disturbo: se l’utente vuole cambiare lingua, trascrivete la sua decisione in un file di configurazione e informatelo che la modifica avrà effetto dal prossimo riavvio dell’applicazione. Di solito è più che sufficiente.

Commenti

Post più popolari