Localización de aplicaciones Python con gettext

Recientemente me planteé traducir o localizar el juego que desarrollé en Python con Pygame. Y la verdad es que no tenía claro como hacerlo. Acabé usando el módulo gettext. Pero, a decir verdad, pensaba que iba a ser bastante más sencillo de lo que finalmente fué. Así que me ha parecido buena idea compartir como realizar este proceso y hacer algunas recomendaciones en base a mi experiencia.

Lo primero que me llamó la atención es que pensaba encontrar más opciones de base o al menos más sencillas de utilizar. Pero la realidad es que la única opción en las librerías estándar de Python es gettext.

Acerca de gettext

gettext es una librería o un sistema de internacionalización y localización de aplicaciones (i18n y l10n) que se usa de manera muy común en el desarrollo de aplicaciones en entornos tipo Unix (como Linux por ejemplo). De hecho la implementación más famosa de gettext es GNU gettext. Tanto es así que Python soporta dos maneras diferentes de usar gettext:

  • GNU API gettext
  • API basada en clases

La segunda de las opciones, la API basada en clases es la opción recomendada para localizar aplicaciones y módulos Python debido a que ofrece una mayor flexibilidad. Para más información puedes consultar la documentación oficial de Python para gettext.

Antes de pasar a como usar gettext para localizar tus aplicaciones python (o cualquier aplicación que soporte gettext en realidad) me gustaría indicar que gettext permite tanto internacionalización como localización. En mi caso, he usado gettext fundamentalmente para temas de localización. Es decir, trasladar los textos de mi aplicación a varios idiomas. Se podría decir que la internacionalización es algo más amplio que puede incluir el formato de las fechas, números u otros elementos.

Ficheros de gettext

Como buena utilidad para sistemas Linux gettext basa su funcionamiento en una serie de ficheros. En concreto, además de los ficheros de código fuente de tu aplicación, hay que tener en cuenta tres tipos de ficheros a la hora de trabajar con gettext:

  • .pot: Portable Object Template. Este fichero es la plantilla para traducción o localización que se genera cuando se extraen las cadenas a traducir de tu aplicación.
  • .po: Portable Object. Estos ficheros son los que contienen la traducción de las cadenas a los diferentes lenguajes. Normalmente habrá un fichero .po por cada traducción.
  • .mo: Machine Object. Los ficheros .mo son el resultado de compilar los ficheros .po. Como en el caso anterior, también habrá un fichero .mo por traducción. Se puede decir que la información de los ficheros .po y .mo es la misma. Pero, en el primer caso es legible por los humanos, en el segundo están en lenguaje máquina.

Utilidades de gettext

gettext tiene multitud de utilidades para diferentes objetivos. Puedes consultarlas todas aquí. Pero, para el caso que nos ocupa usaremos fundamentalmente tres:

  • xgettext o pygettext3: Esta utilidad escanea nuestros ficheros de código fuente para obtener cadenas localizables y generar el fichero de plantilla .pot.
  • msgmerge: Genera o regenera los ficheros .po a partir del fichero .pot. Como los ficheros que se usan para traducir son los .po, ¿qué pasa cuando incluimos nuevas cadenas localizables en nuestra aplicación? ¿hay que volver a crear y traducir los ficheros .po? La respuesta es msgmerge, que mezcla o une nuestro fichero .po que ya contiene traducciones con el fichero de referencia .pot. De este modo se preservan las traducciones existentes y se añaden las nuevas que encuentre en el fichero .pot.
  • msgfmt: Este programa crea un archivo binario .mo a partir de la compilación del fichero de texto .po.

Estructura de ficheros

Debido a la dependencia de gettext de los ficheros que hemos visto antes, también es necesaria una estructura de ficheros concreta para que el sistema pueda encontrar los ficheros .mo con las traducciones. En esencia, debes colocar tus ficheros .mo en la siguiente ruta: localedir/language/LC_MESSAGES/.

  • localedir: Es simplemente el directorio base de las traducciones. Veremos que puedes definir esta ubicación en tu código.
  • languaje: Se trata de un código de lenguaje. Puede ser genérico como en o es, o más específico como es_ES o es_AR.
  • LC_MESSAGES: Se trata de la categoría del locale. Por defecto, para gettext es LC_MESSAGES.

Puede parecer un poco complicado pero tampoco lo es demasiado. Como ejemplo, te muestro la estructura creada para mi juego en Pygame:

locales
├── en
│   └── LC_MESSAGES
│       ├── pang-nightfall.mo
│       └── pang-nightfall.po
├── es
│   └── LC_MESSAGES
│       ├── pang-nightfall.mo
│       └── pang-nightfall.po
└── pang-nightfall.pot

Trabajando con getttext en Python

Una vez que hemos hablado un poco de gettext, su origen, su estructura y su funcionamiento básico, llega el momento de ponernos manos a la obra y mostrar como se puede usar para localizar tus aplicaciones Python.

Usando GNU API gettext en Python

Aunque he dicho antes que acabé usando la implementación API basada en clases porque es la recomendada, creo que para explicar el funcionamiento voy a comenzar mostrando el modo GNU API gettext.

Vamos a partir de un primer ejemplo, 0029_localizacion-python-gnu-gettext.py, donde se definen las cadenas con la función _() que a su vez se define como un alias para gettext.gettext().

import gettext
_ = gettext.gettext

def imprimir_cadenas():
    print(_("Somos cadenas localizables"))
    print(_("Hola noroute2host"))
    print(_("Adios noroute2host"))

if __name__ == '__main__':
    imprimir_cadenas()

Si ejecutamos este programa, el resultado será similar a si se hubiera hecho un print normal.

$ python 0029_localizacion-python-gnu-gettext.py
Somos cadenas localizables
Hola noroute2host
Adios noroute2host

Pero en realidad lo que esta pasando es no se están imprimiendo las cadenas directamente. Sino que se está devolviendo la traducción localizada de cada mensaje con la función gettext.gettext("mensaje"). Pero como no se han generado ficheros de traducción .mo ni definido donde se encontrarían, directamente devuelve y por tanto imprime por defecto el identificador de la cadena dentro de módulo de gettext o el msgid.

Usando la API basada en clases de gettext en Python

Ahora vamos a transformar el ejemplo anterior para usar el método recomendado, es decir, la API basada en clases del módulo gettext de Python. Esto dará lugar al fichero 0029_localizacion-python-API-basada-clases.py

import gettext

appname = "noroute2host"
localedir = "locales"

translations = gettext.translation(appname, localedir, fallback=True, languages=["en", "es"])
translations.install()

def imprimir_cadenas():
    print(_("Somos cadenas localizables"))
    print(_("Hola noroute2host"))
    print(_("Adios noroute2host"))

if __name__ == '__main__':
    imprimir_cadenas()

En este caso la gran diferencia es el uso de la función gettext.translation que según la documentación oficial se define por defecto del siguiente modo:

gettext.translation(domain, localedir=None, languages=None, class_=None, fallback=False)

Y en nuestro ejemplo la hemos usado así:

translations = gettext.translation(appname, localedir, fallback=True, languages=["en", "es"])

Paso a explicar cada uno de los parámetros que se han usado:

  • domain: Para el dominio se ha usado una variable llamada appname definida previamente con el valor noroute2host. La explicación podría ser más compleja, pero básicamente podemos quedarnos con que el dominio es el nombre de fichero que usará de base para buscar los ficheros de traducción .mo.
  • localedir: Se ha definido como una variable localedir que tiene el valor locales. Podría tener otro valor puesto que es básicamente la raíz donde va a buscar los ficheros de traducción.
  • fallback: Se ha puesto a True para que si no encuentra un fichero .mo la función no devuelva una excepción y devuelva un objeto de la clase NullTranslations. De hecho, en este ejemplo todavía no están definidos los ficheros de traducción y como veremos después no se genera ningún error.
  • languages: Es una lista de uno o más lenguajes. Son los que intentará cargar el fichero .mo si existe para localizar las cadenas.
  • class_: Este parámetro no se usa. Sirve para modificar el tipo de clase que devuelve la función translation. Si no se especifica nada, por defecto será un objeto de la clase GNUTranslations. Este es nuestro caso.

Por otro lado, hay otro cambio importante en el código:

translations.install()

Esto es simplemente una manera, aunque la más conveniente, de hacer disponible la función _() en la aplicación.

Después de todos los cambios el resultado es similar al del modo anterior. Como comentamos antes, se está intentando devolver la versión localizada de las cadenas pero como no se encuentran se devuelven tal cual.

$ python 0029_localizacion-python-API-basada-clases.py
Somos cadenas localizables
Hola noroute2host
Adios noroute2host

Esto se puede comprobar si se cambia el parámetro fallback a False. Al ejecutar nuestro ejemplo con este cambio, se produce un error al no encontrar los ficheros de traducción.

$ python 0029_localizacion-python-API-basada-clases.py
Traceback (most recent call last):
  File "0029_localizacion-python-API-basada-clases.py", line 6, in <module>
    translations = gettext.translation(appname, localedir, fallback=False, languages=["en", "es"])
  File "/usr/lib/python3.8/gettext.py", line 603, in translation
    raise FileNotFoundError(ENOENT,
FileNotFoundError: [Errno 2] No translation file found for domain: 'noroute2host

Trabajando con los ficheros de localización

Lo primero que hay que hacer es crear la estructura de directorios que ya hemos esbozado antes. En principio, vamos a generar localizaciones para dos idiomas: Español (es) e Inglés (es).

$ mkdir locales
$ mkdir locales/es
$ mkdir locales/es/LC_MESSAGES
$ mkdir locales/es
$ mkdir locales/es/LC_MESSAGES

Generando el fichero de platilla (.pot)

El primer paso será generar el fichero de plantilla .pot. Este fichero se genera con la utilidad pygettext3 o xgettext. Esta utilidad lo que hace es extraer los cadenas localizables de nuestro fichero Python. ¿Y como sabe cuáles son? Pues la respuesta es bastante sencilla, simplemente extrae los textos que se pasan como parámetros a la función _(). Y esto es bastante importante porque significa, que lo que pongamos en esas llamadas es el texto base de nuestra aplicación. Es decir, lo que se mostrará si no hay traducción. Por lo que esta decisión “puede” marcar el lenguaje base de tu aplicación. Y digo puede porque al final te contaré como me gusta a mí usar este lenguaje base.

En cuanto a usar pygettext3 o xgettext puedes usar la que quieras. Antiguamente xgettext no funcionaba del todo con ficheros Python y por eso había una versión Python: pygettext. Pero en la actualidad xgettext soporta multitud de lenguajes, entre los que está Python.

Para instalar las utilidades de GNU gettext, es posible que tengas que instalarlas antes. En en el caso de Debian/Ubuntu bastaría con usar apt:

# apt install gettext

Sin embargo a mí me gusta más la salida que muestra pygettext3 y por eso es el que uso este último, pero es simplemente una cuestión de gustos. En cualquier caso, pygettext suele venir incluido en las instalaciones de Python.

La ejecución de esta herramienta podría ser:

pygettext3 -debug --verbose -d DOMINIO -o FICHERO_POT_SALIDA FICHERO_PYTHON_ENTRADA

Donde los parámetros son:

  • DOMINIO: Es un nombre único que identifica tu aplicación.
  • FICHERO_POT_SALIDA: Es el la ruta donde se dejará el fichero .pot de salida.
  • FICHERO_PYTHON_SALIDA: El fichero de entrada donde se buscarán las cadenas.

Por tanto, en el ejemplo que nos ocupa el comando quedaría tal que así:

pygettext3 -debug --verbose -d noroute2host -o locales/noroute2host.pot 0029_localizacion-python-API-basada-clases.py

Y el resultado es el fichero locales/noroute2host.pot

# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR ORGANIZATION
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"POT-Creation-Date: 2024-07-25 20:53+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: pygettext.py 1.5\n"


#: 0029_localizacion-python-API-basada-clases.py:10
msgid "Somos cadenas localizables"
msgstr ""

#: 0029_localizacion-python-API-basada-clases.py:11
msgid "Hola noroute2host"
msgstr ""

#: 0029_localizacion-python-API-basada-clases.py:12
msgid "Adios noroute2host"
msgstr ""

Creando los ficheros de traducción (.po) y localizando los textos

Con el fichero de plantilla creado, y teniendo en cuenta que vamos a trabajar con dos idiomas (español e inglés), hay que generar los ficheros de traducciones y localizar las cadenas.

El modo más simple de generar los ficheros de traducción es simplemente copiar el fichero .pot para crear los ficheros .po.

$ cp -a locales/noroute2host.pot locales/es/LC_MESSAGES/noroute2host.po
$ cp -a locales/noroute2host.pot locales/en/LC_MESSAGES/noroute2host.po

Ahora que ya están creados los ficheros .po, lo que hay que hacer es traducirlos. En el ejemplo que nos ocupa los hemos dejado así:

# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR ORGANIZATION
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"POT-Creation-Date: 2024-07-25 20:53+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: pygettext.py 1.5\n"

#: 0029_localizacion-python-API-basada-clases.py:10
msgid "Somos cadenas localizables"
msgstr "Somos cadenas localizables"

#: 0029_localizacion-python-API-basada-clases.py:11
msgid "Hola noroute2host"
msgstr "Hola noroute2host"

#: 0029_localizacion-python-API-basada-clases.py:12
msgid "Adios noroute2host"
msgstr "Adios noroute2host"
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR ORGANIZATION
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"POT-Creation-Date: 2024-07-25 20:53+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: pygettext.py 1.5\n"

#: 0029_localizacion-python-API-basada-clases.py:10
msgid "Somos cadenas localizables"
msgstr "We are localizable strings"

#: 0029_localizacion-python-API-basada-clases.py:11
msgid "Hola noroute2host"
msgstr "Hi noroute2host"

#: 0029_localizacion-python-API-basada-clases.py:12
msgid "Adios noroute2host"
msgstr "Goodbye noroute2host"

Compilando los ficheros .po para generar los ficheros binarios .mo

Con lo hecho anteriormente tenemos los catálogos de mensajes en formato texto. Pero nuestro programa Python necesita que estos mensajes estén compilados en un formato binario que pueda entender. Para este cometido usaremos la utilidad msgfmt. Usaremos esta utilidad de la siguiente forma:

msgfmt -f -o FICHERO_MO_SALIDA FICHERO_PO_ENTRADA

Donde las opciones y los parámetros son:

  • -f: Opción fuzzy. Con esta opción se tienen en cuenta los mensajes que las utilidades anteriores han marcado como fuzzy o difusos. En mi caso es una opción que uso porque ha veces (no entiendo el por qué hay mensajes que los detecta así. Y si no se indica esta opción no se compilan en la traducción.
  • -o: Sirve para especificar el fichero .mo de salida.
  • FICHERO_MO_SALIDA: Es el la ruta donde se dejará el fichero .mo de salida.
  • FICHERO_PO_ENTRADA: El fichero de entrada .po que se usará de base para la compilación.

Además, algo muy importante que hay que tener en cuenta es que la compilación tiene un fichero de entrada y uno de salida. Por tanto, hay que ejecutar el msgfmt una vez por cada fichero .po que tengamos. En nuestro ejemplo, tenemos 2 ficheros .po por lo que habría que lanzar los siguientes 2 comandos:

$ msgfmt -f -o locales/es/LC_MESSAGES/noroute2host.mo locales/es/LC_MESSAGES/noroute2host.po
$ msgfmt -f -o locales/en/LC_MESSAGES/noroute2host.mo locales/en/LC_MESSAGES/noroute2host.po

Estructura de ficheros de localización

Ya tenemos todos los ficheros de localización creados. Esta sería la estructura de directorios y ficheros de localización:

locales/
├── en
│   └── LC_MESSAGES
│       ├── noroute2host.mo
│       └── noroute2host.po
├── es
│   └── LC_MESSAGES
│       ├── noroute2host.mo
│       └── noroute2host.po
└── noroute2host.pot

Probando la aplicación localizada

Antes de pasar a probar la aplicación con localización, vamos a hacer un pequeño cambio en el código para que nos permita seleccionar el idioma antes de imprimir la salida. No es la mejor solución para pedir al usuario que elija el idioma de una lista pero para el ejemplo valdrá.

Esta versión del programa quedará reflejada en el fichero 0029_localizacion-python-API-basada-clases-multi.py

import gettext

appname = "noroute2host"
localedir = "locales"

# Funcion para seleccionar el idioma
def preguntar_idioma():
    print("Seleccion de Idioma")
    response = ''
    while response not in {"esp", "eng"}:
        response = input("Por favor introduce esp o eng: ").lower()
    if response == "eng":
        translations = gettext.translation(appname, localedir, fallback=True, languages=["en"])
    else:
        translations = gettext.translation(appname, localedir, fallback=True, languages=["es"])
    translations.install()

# Funcion para imprimir cadenas
def imprimir_cadenas():
    print(_("Somos cadenas localizables"))
    print(_("Hola noroute2host"))
    print(_("Adios noroute2host"))

if __name__ == '__main__':
    preguntar_idioma()
    imprimir_cadenas()

El pequeño cambio que hemos hecho es crear la función preguntar_idioma(), que pregunta al usuario por el idioma previamente. Para que, de este modo, establecer la localización antes de imprimir las cadenas. Es verdad que se imprime primero el texto que pregunta al usuario sin localizar pero para fines didácticos se ve claro. El resultado es el siguiente:

  • Seleccionando Español:
$ python 0029_localizacion-python-API-basada-clases-multi.py
Seleccion de Idioma
Por favor introduce esp o eng: esp
Somos cadenas localizables
Hola noroute2host
Adios noroute2host
  • Seleccionando Inglés:
$ python 0029_localizacion-python-API-basada-clases-multi.py
Seleccion de Idioma
Por favor introduce esp o eng: eng
We are localizable strings
Hi noroute2host
Goodbye noroute2host

Como puedes ver, las cadenas se imprimen en un idioma diferente en función de la selección del usuario. Así que ¡objetivo conseguido!

Actualizando la localización

Todo lo anterior es estupendo, pero ¿y si necesito añadir nuevos mensajes a mi aplicación? Pues la respuesta es que gettext también tiene herramientas para actualizar los ficheros de traducción. Así que voy a contarte como hacerlo.

Incluir nuevos mensajes en la aplicación

En primer lugar, vamos a añadir un nuevo mensaje a la función imprimir_cadenas(). En concreto será la línea print(_("Soy una nueva cadena localizable")).

import gettext

appname = "noroute2host"
localedir = "locales"

# Funcion para seleccionar el idioma
def preguntar_idioma():
    print("Seleccion de Idioma")
    response = ''
    while response not in {"esp", "eng"}:
        response = input("Por favor introduce esp o eng: ").lower()
    if response == "eng":
        translations = gettext.translation(appname, localedir, fallback=True, languages=["en"])
    else:
        translations = gettext.translation(appname, localedir, fallback=True, languages=["es"])
    translations.install()

# Funcion para imprimir cadenas
def imprimir_cadenas():
    print(_("Somos cadenas localizables"))
    print(_("Hola noroute2host"))
    print(_("Adios noroute2host"))
    print(_("Soy una nueva cadena localizable"))

if __name__ == '__main__':
    preguntar_idioma()
    imprimir_cadenas()

Actualizando el fichero de platilla (.pot)

La actualización del fichero de plantilla consiste simplemente en volver a generarlo. Para ello usaremos exactamente el mismo comando que antes, salvo por el detalle de que ahora el fichero de entrada es el de la versión que tenemos con la selección del usuario:

pygettext3 -debug --verbose -d noroute2host -o locales/noroute2host.pot 0029_localizacion-python-API-basada-clases-multi.py

Esto volverá a generar el fichero locales/noroute2host.pot donde ya se encontrará el nuevo mensaje para localizar.

# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR ORGANIZATION
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"POT-Creation-Date: 2024-07-29 20:01+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: pygettext.py 1.5\n"

#: 0029_localizacion-python-API-basada-clases-multi.py:20
msgid "Somos cadenas localizables"
msgstr ""

#: 0029_localizacion-python-API-basada-clases-multi.py:21
msgid "Hola noroute2host"
msgstr ""

#: 0029_localizacion-python-API-basada-clases-multi.py:22
msgid "Adios noroute2host"
msgstr ""

#: 0029_localizacion-python-API-basada-clases-multi.py:23
msgid "Soy una nueva cadena localizable"
msgstr ""

Regenerando los ficheros de localización (.po) a partir del nuevo fichero de plantilla (.pot)

Ahora que ya se ha actualizado el fichero de plantilla con la lista actualizada de mensajes localizables llega el momento de mezclar la nueva plantilla con los ficheros de localización previos. Para este cometido existe la utilidad msgmerge que permite hacer esta mezcla para incorporar los nuevos mensajes localizables a los ficheros .po previos que ya contienen la traducción de los mensajes anteriores sin que esta se pierda.

El uso de msgmerge sería de la siguiente manera:

msgmerge -U FICHERO_PO_A_ACTUALIZAR FICHERO_POT_PLANTILLA

Otra vez hay que tener en cuenta que hay que ejecutar esto para los dos ficheros .po, uno por idioma, que estamos usando.

$ msgmerge -U locales/es/LC_MESSAGES/noroute2host.po locales/noroute2host.pot 
...... terminado.
$ msgmerge -U locales/en/LC_MESSAGES/noroute2host.po locales/noroute2host.pot
...... terminado.

El resultado serán los nuevos ficheros .po en los que ya se puede añadir la traducción de los nuevos mensajes. En este caso, los presento ya con la traducción del nuevo mensaje incluida.

# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR ORGANIZATION
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"POT-Creation-Date: 2024-07-29 20:01+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: pygettext.py 1.5\n"

#: 0029_localizacion-python-API-basada-clases-multi.py:20
msgid "Somos cadenas localizables"
msgstr "Somos cadenas localizables"

#: 0029_localizacion-python-API-basada-clases-multi.py:21
msgid "Hola noroute2host"
msgstr "Hola noroute2host"

#: 0029_localizacion-python-API-basada-clases-multi.py:22
msgid "Adios noroute2host"
msgstr "Adios noroute2host"

#: 0029_localizacion-python-API-basada-clases-multi.py:23
#, fuzzy
msgid "Soy una nueva cadena localizable"
msgstr "Soy una nueva cadena localizable"
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR ORGANIZATION
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"POT-Creation-Date: 2024-07-29 20:01+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: pygettext.py 1.5\n"

#: 0029_localizacion-python-API-basada-clases-multi.py:20
msgid "Somos cadenas localizables"
msgstr "We are localizable strings"

#: 0029_localizacion-python-API-basada-clases-multi.py:21
msgid "Hola noroute2host"
msgstr "Hi noroute2host"

#: 0029_localizacion-python-API-basada-clases-multi.py:22
msgid "Adios noroute2host"
msgstr "Goodbye noroute2host"

#: 0029_localizacion-python-API-basada-clases-multi.py:23
#, fuzzy
msgid "Soy una nueva cadena localizable"
msgstr "I am a new localizable string"

ReCompilando los ficheros .po para ReGenerar los ficheros binarios .mo

Ya solo nos quedaría la última operación que consiste simplemente en volver a generar los ficheros compilados .mo a partir de ficheros .po. Además, como dato curioso se ha dado el caso de que ha añadido el nuevo mensaje como fuzzy. No tengo muy claro porque ocurre esto a veces, pero es por esto mismo que os recomendar la opción -f del comando msgfmt.

$ msgfmt -f -o locales/es/LC_MESSAGES/noroute2host.mo locales/es/LC_MESSAGES/noroute2host.po
$ msgfmt -f -o locales/en/LC_MESSAGES/noroute2host.mo locales/en/LC_MESSAGES/noroute2host.po

Probando de nuevo la aplicación localizada con el nuevo texto.

Con lo anterior realizado, nuestra aplicación ya contiene el nuevo mensaje y sus versiones traducidas. Así que solo queda ejecutarla y comprobar que todo funciona correctamente:

  • Seleccionando Español:
$ python 0029_localizacion-python-API-basada-clases-multi.py
Seleccion de Idioma
Por favor introduce esp o eng: esp
Somos cadenas localizables
Hola noroute2host
Adios noroute2host
Soy una nueva cadena localizable
  • Seleccionando Inglés:
$ python 0029_localizacion-python-API-basada-clases-multi.py
Seleccion de Idioma
Por favor introduce esp o eng: eng
We are localizable strings
Hi noroute2host
Goodbye noroute2host
I am a new localizable string

Bonus Track: Buenas prácticas y recomendaciones personales

Lo cierto es que ha quedado un artículo un poco largo, pero no quiero terminarlo sin dejar algunas recomendaciones personales o comentar alguna buena práctica.

Definir las cadenas con nombres representativos, no con la traducción del idioma por defecto.

Reconozco que esta primera recomendación es algo bastante personal, pero en conjunción con la siguiente clarifica y facilita mucho el trabajo con las cadenas a localizar y su gestión.

Si observamos la estructura de los ficheros .pot y .po podemos ver que cada mensaje localizable tiene un msgid y un msgstr. Por defecto el msgid se rellena con la cadena que se le pasa a la función _(). Y además el msgid es lo que se muestra por defecto en la aplicación si no hay una localización (un fichero .mo) específico para el lenguaje que se quiera usar. Esto puede ser un punto positivo a favor de usar directamente las cadenas del lenguaje “base” como msgid pues siempre mostrará un texto real en algún idioma.

Aún con esto, los ficheros quedan un poco raros en el lenguaje base teniendo msgid igual a msgstr, a mi gusto, y puede hacer que en los msgid haya caracteres especiales. Es por esto que yo uso una nomenclatura alternativa para los msgid: los defino como si fueran nombres de variables, usando solo números y letras pero usando el signo - para separar palabras. Por ejemplo, en vez de usar la línea:

print(_("Hola noroute2host"))

Usaría la siguiente:

print(_("hola-noroute2host")

Este cambio haría que al generar el fichero noroute2host.pot, las entradas para esta cadena fueran las siguientes:

#: 0029_localizacion-python-API-basada-clases-multi.py:21
msgid "hola-noroute2host"
msgstr ""

Y ya, tal y como hemos explicado antes, en los msgstr ficheros .po de cada idioma se incluyen las traducciones.

La única pega es que, en caso de no encontrar una localización válida, se mostraría “hola-noroute2host”. Para evitar esto, mi recomendación es controlar si hay o no una traducción disponible, y en caso de no haberla fijar un idioma por defecto. En mi opinión, esta manera de definir las cadenas localizables en combinación con la siguiente recomendación ayuda a trabajar de una manera más fácil y ágil con gettext en Python.

Unifica tus cadenas en un fichero

Una cosa que me no me ha gustado al trabajar con gettext es que las cadenas quedan un poco desperdigadas por los diferentes ficheros .py. Aunque esto no es raro cuando estás programando en Python, e incluso puedes pasarle varios ficheros o un directorio de ficheros a pygettext3 o xgettext, si me parece un problema cuando quieres cambiar algún texto. O incluso cuando simplemente quieres ver si se han extraído todas las cadenas localizables.

Así que para mejorar esto te propongo algo: ¿por qué no crear un fichero .py donde se definan todas las cadenas como variables o constantes?. De este modo una vez generado el fichero de cadenas de texto, lo único que necesitas es importarlo en el resto de tus ficheros y referenciar las variables o constantes que hayas definido. De esto modo, solo tendrás un fichero donde definir tus cadenas de texto y el trabajo con ellas se facilitará mucho. Incluso puedes definir con comentarios secciones paras las diferentes cadenas. En mi opinión, esto facilita muchísimo el trabajo con las cadenas. Trabajas solo con un fichero .py para los comandos de gettext, si te ayudan traductores solo manejaras un fichero .pot y .po, y por supuesto la lista de cadenas localizables no está repartida por toda tu aplicación.

Esto es puramente una opinión personal, no se si es porque he trabajado con algún framework en otros lenguajes y hacían algo similar o porque es que así lo veo mucho más claro. En cualquier caso, en combinación con la anterior recomendación facilita mucho el trabajo con gettext. De hecho la “trampita” que hemos hecho para preguntar el idioma y toda la definición de cadenas irá al fichero propio de definición de cadenas, dejando nuestro código mucho más claro y limpio. Así que os dejo los cambios y el resultado a continuación.

Lo primero a mostrar serán nuestros dos ficheros .py.

import gettext

appname = "noroute2host-defstr"
localedir = "locales"

# Funcion para seleccionar el idioma
def preguntar_idioma():
    print("Seleccion de Idioma")
    response = ''
    while response not in {"esp", "eng"}:
        response = input("Por favor introduce esp o eng: ").lower()
    if response == "eng":
        translations = gettext.translation(appname, localedir, fallback=True, languages=["en"])
    else:
        translations = gettext.translation(appname, localedir, fallback=True, languages=["es"])
    translations.install()

# Se instalan las traducciones
preguntar_idioma()

# Definicion de cadenas localizables

STR_CADENAS_LOCALIZABLES = _("somos-cadenas-localizables")
STR_HOLA = _("hola-noroute2host")
STR_ADIOS = _("adios-noroute2host")
STR_NUEVA_CADENA_LOCALIZABLE = _("nueva-cadena-localizable")
import definicion_cadenas as defstr

# Funcion para imprimir cadenas
def imprimir_cadenas():
    print(defstr.STR_CADENAS_LOCALIZABLES)
    print(defstr.STR_HOLA)
    print(defstr.STR_ADIOS)
    print(defstr.STR_NUEVA_CADENA_LOCALIZABLE)

if __name__ == '__main__':
    imprimir_cadenas()

A continuación vamos a generar un nuevo conjunto de ficheros .pot y .po a los que les hemos añadido -defstr en el nombre para diferenciarlos de los anteriores ejemplos.

# Creación de fichero de plantilla .pot
$ pygettext3 -debug --verbose -d noroute2host-defstr -o locales/noroute2host-defstr.pot definicion_cadenas.py 
Working on definicion_cadenas.py

# Creación inicial de nuevos ficheros .po
$ cp -a locales/noroute2host-defstr.pot locales/es/LC_MESSAGES/noroute2host-defstr.po
$ cp -a locales/noroute2host-defstr.pot locales/en/LC_MESSAGES/noroute2host-defstr.po

Y posteriormente se crean las traducciones de cada idioma (español e inglés) en los ficheros .po. Este es el resultado final de los ficheros .pot y .po.

# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR ORGANIZATION
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"POT-Creation-Date: 2024-07-31 17:36+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: pygettext.py 1.5\n"


#: definicion_cadenas.py:23
msgid "somos-cadenas-localizables"
msgstr ""

#: definicion_cadenas.py:24
msgid "hola-noroute2host"
msgstr ""

#: definicion_cadenas.py:25
msgid "adios-noroute2host"
msgstr ""

#: definicion_cadenas.py:26
msgid "nueva-cadena-localizable"
msgstr ""
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR ORGANIZATION
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"POT-Creation-Date: 2024-07-31 17:36+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: pygettext.py 1.5\n"


#: definicion_cadenas.py:23
msgid "somos-cadenas-localizables"
msgstr "Somos cadenas localizables"

#: definicion_cadenas.py:24
msgid "hola-noroute2host"
msgstr "Hola noroute2host"

#: definicion_cadenas.py:25
msgid "adios-noroute2host"
msgstr "Adiós noroute2host"

#: definicion_cadenas.py:26
msgid "nueva-cadena-localizable"
msgstr "Soy una nueva cadena localizable"
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR ORGANIZATION
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"POT-Creation-Date: 2024-07-31 17:36+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: pygettext.py 1.5\n"


#: definicion_cadenas.py:23
msgid "somos-cadenas-localizables"
msgstr "We are localizable string"

#: definicion_cadenas.py:24
msgid "hola-noroute2host"
msgstr "Hi noroute2host"

#: definicion_cadenas.py:25
msgid "adios-noroute2host"
msgstr "Goodbye noroute2host"

#: definicion_cadenas.py:26
msgid "nueva-cadena-localizable"
msgstr "I am a new localizable string"

Finalmente solo nos queda compilar nuestros ficheros .po para generar los ficheros .mo

$ msgfmt -f -o locales/es/LC_MESSAGES/noroute2host-defstr.mo locales/es/LC_MESSAGES/noroute2host-defstr.po
$ msgfmt -f -o locales/en/LC_MESSAGES/noroute2host-defstr.mo locales/en/LC_MESSAGES/noroute2host-defstr.po

Por último, lo único que queda es comprobar que el resultado de la ejecución sigue siendo el mismo.

  • Seleccionando español:
$ python 0029_localizacion-python-API-basada-clases-multi-con-fichero-cadenas.py
Seleccion de Idioma
Por favor introduce esp o eng: esp
Somos cadenas localizables
Hola noroute2host
Adiós noroute2host
Soy una nueva cadena localizable
  • Seleccionando inglés:
$ python 0029_localizacion-python-API-basada-clases-multi-con-fichero-cadenas.py
Seleccion de Idioma
Por favor introduce esp o eng: eng
We are localizable string
Hi noroute2host
Goodbye noroute2host
I am a new localizable string

Como se puede observar el funcionamiento del programa es el mismo, pero hemos mejorado la estructura y la claridad de nuestro código.

Añade los ficheros binarios (.mo) y el fichero de plantilla (.pot) a tu .gitignore

Esta best-practice es simple, añade las extensiones .mo y .pot a tu fichero .gitignore. Recuerda que los ficheros .mo son compilados y no son código fuente, y que el fichero de plantilla .pot tiene en los ficheros .po específicos de cada idioma las versiones útiles del mismo. Aunque en el caso de los ficheros .mo hay que tener en cuenta que si debes incluirlos en tus releases puesto que si no están los textos no aparecerán correctamente.

Yo por mi parte estoy usando gitignore.io para la generación de los ficheros .gitignore. Este servicio te permite generar ficheros .gitignore seleccionando tu sistema operativo, lenguajes de programación y otros elementos para generar un fichero de exclusiones para git fácilmente. Además de ser un servicio web, es software libre bajo licencia MIT y tiene disponible repositorio en github, por lo que te lo recomiendo sin dudas.

A localizar tus aplicaciones

Como has podido observar, la localización de aplicaciones con gettext no es una tarea sencilla, requiere de trabajo extra. Además te he propuesto algunas recomendaciones para facilitar y organizar mejor las cadenas localizables y los ficheros de traducción. Todos los ficheros que te he mostrado en este post están en el repositorio del blog noroute2host-files. En el código de los ejemplos no he utilizado orientación a objetos por simplicidad, pero es fácilmente adaptable.

Por último, no puedo dejar de recordar que la localización e internacionalización de aplicaciones es más amplia que solo la traducción de cadenas. Permite jugar con los plurales, fechas o valores monetarios. Así que ya está en tu mano comenzar a localizar tus aplicaciones y aprovechar todas las posibilidades que gettext nos brinda.

Enlaces de interés:

Artículo anterior