En producción¶
El tutorial ejecuta el ciclo una vez, en solitario, sobre un programa con un solo mensaje. En un proyecto real el ciclo no se detiene: los mensajes cambian después de haber sido traducidos, el traductor trabaja en otro sitio y con su propio calendario, y con cada versión se distribuye un catálogo compilado. Esta página es esa práctica: qué permanece en el repositorio, qué viaja, qué debe bloquear la CI y dónde vincula un idioma el tiempo de ejecución.
Todo ello se resume en seis comprobaciones, así que van primero; cada una de las secciones siguientes configura una de ellas.
pybabel update --checkpasa: ningún mensaje ha cambiado sin que los catálogos se enteren.pybabel compilecondiciona la build a su estado de salida.- Las entradas
fuzzyque quedan son intencionadas: cada una se renderiza como texto de origen hasta que un traductor la confirme. - La suite de pruebas renderiza una vez cada idioma distribuido con
strict=True. - El artefacto de producción contiene archivos
.moy nada de Babel. - El logger
gettext_tstringsestá enrutado a la monitorización.
La forma de un proyecto¶
myapp/
├── babel.cfg
├── pyproject.toml
├── src/
│ └── myapp/
└── locales/
├── messages.pot
├── ja/LC_MESSAGES/messages.po
└── de/LC_MESSAGES/messages.po
Versiona babel.cfg, la plantilla .pot y todos los .po: son las fuentes
de la build de traducción, y sus diffs son la forma de revisar los cambios de
traducción. Los archivos .mo compilados son artefactos de build: prodúcelos
en CI o al empaquetar, en lugar de versionarlos, para que un .po y su .mo
nunca puedan discrepar sobre lo que se distribuye.
Un archivo tiene un papel en cada dirección: el .pot lleva tus mensajes
hacia los traductores y los archivos .po traen las traducciones de
vuelta. El resto de esta página es lo que se mueve entre ambos.
flowchart LR
code["código fuente<br>puntos de llamada con t-strings"] -->|"pybabel extract"| pot["messages.pot"]
pot -->|"pybabel update"| po["un .po por idioma"]
po --> tr["traductor<br>o plataforma"]
tr --> po
po -->|"pybabel compile (CI)"| mo["archivos .mo"]
mo --> app["aplicación<br>en tiempo de ejecución"]
El ciclo tras la primera traducción¶
El pybabel init del tutorial se ejecuta normalmente una sola vez, al añadir
un idioma. A partir de ahí el ciclo de trabajo es extraer → actualizar →
traducir → compilar, y su centro es pybabel update, que incorpora una
plantilla recién extraída a los catálogos existentes sin descartar las
traducciones que ya contienen.
Supón que el saludo Hello {name} —ya traducido como こんにちは {name}— se
reescribe en el código como Welcome back, {name}. Extrae y actualiza:
$ pybabel extract -F babel.cfg -c "Translators:" -o locales/messages.pot .
extracting messages from app.py (encoding="utf-8")
writing PO template file to locales/messages.pot
$ pybabel update -i locales/messages.pot -d locales
updating catalog locales/ja/LC_MESSAGES/messages.po based on locales/messages.pot
El catálogo japonés contiene ahora:
#. gettext-tstrings
#: app.py:4
#, fuzzy, python-brace-format
msgid "Welcome back, {name}"
msgstr "こんにちは {name}"
Babel ha notado que el nuevo msgid se parece a uno eliminado y lo ha
emparejado con la traducción antigua, pero ha marcado la pareja como
fuzzy: la suposición de una máquina a la espera de un humano. La marca
cambia lo que se compila. pybabel compile excluye las entradas fuzzy del
.mo, así que, hasta que un traductor confirme la pareja, la aplicación
renderiza el nuevo texto inglés en lugar de un japonés obsoleto:
$ pybabel compile -d locales
compiling catalog locales/ja/LC_MESSAGES/messages.po to locales/ja/LC_MESSAGES/messages.mo
$ python app.py
Welcome back, Ada
Un mensaje modificado se degrada, por tanto, igual que uno roto: al idioma de
origen, nunca a una traducción desactualizada. La parte del traductor en el
ciclo consiste en revisar el msgstr y borrar la marca fuzzy; la siguiente
compilación recoge la entrada.
Los nombres de los marcadores forman parte de la identidad del mensaje
El msgid es la clave del catálogo, y el nombre del marcador está dentro
de él, así que renombrar una variable en el código (name → user_name)
cambia el msgid y devuelve al ciclo fuzzy la traducción de ese mensaje en
todos los idiomas. Da a las variables interpoladas nombres que un
traductor pueda entender, y renómbralas solo con motivo.
El formato es la imagen especular: !r y :.2f no forman parte del
msgid, de modo que ajustar
{amount:,.2f} a {amount:,.0f} no cambia nada en ningún catálogo.
Reformular la frase, por supuesto, sí es un cambio real: ese es el ciclo
de arriba.
Qué controla la CI¶
Tres fallos merecen una build en rojo: los catálogos se quedaron atrás respecto al código, una traducción rompió un marcador o una entrada rota llegó hasta el tiempo de ejecución. Un paso por fallo:
- run: pybabel extract -F babel.cfg -c "Translators:" -o locales/messages.pot .
- run: pybabel update -i locales/messages.pot -d locales --check
- run: pybabel compile -d locales
- run: pytest
pybabel update --check no reescribe nada y sale con código distinto de cero
cuando un catálogo está desactualizado respecto a la plantilla recién
extraída: la protección contra fusionar código cuyos mensajes nadie volvió a
extraer. pybabel compile ejecuta las comprobaciones de marcadores de Babel y
del
checker registrado
de este paquete.
Babel 2.18.0: --check no puede controlar un catálogo que usa contextos
En Babel 2.18.0, pybabel update --check informa de que todos los
catálogos que contienen un msgctxt están desactualizados, en cada
ejecución, por muy al día que estén. Una puerta que falla siempre es peor
que no tener puerta, porque el equipo acaba desactivándola; así que, si usas
pgettext o npgettext aunque sea mínimamente, sustituye este paso en
lugar de convivir con él. Leer la plantilla y cada catálogo con
babel.messages.pofile.read_po y comparar
{(m.context, m.id) for m in catalog if m.id} es toda la comprobación, y
es lo que hace la propia build de este sitio. La causa está
documentada en Escollos.
Comprueba el código de salida, no el log
pybabel compile informa de cada error de marcadores, sale con código
distinto de cero y aun así escribe el .mo. Un pipeline que compila y
luego copia locales/ en una imagen distribuye el catálogo roto, salvo
que el código de salida distinto de cero realmente lo detenga. Dejar que
el paso haga fallar la build, como arriba, es toda la solución.
La última línea es tu suite de pruebas habitual, con un hábito añadido: en algún lugar de ella, renderiza al menos un mensaje por idioma distribuido a través de un traductor estricto —
import gettext
from gettext_tstrings import Translator
def test_catalogs_render(language: str) -> None:
translations = gettext.translation("messages", localedir="locales", languages=[language])
_ = Translator(translations, strict=True)
name = "Ada"
assert _(t"Welcome back, {name}")
— porque strict=True lanza una excepción donde producción degradaría en
silencio, y un renderizado en
tiempo de ejecución es la única comprobación que ve el catálogo exactamente
como lo verá la aplicación, .mo incluido.
Trabajar con traductores y plataformas¶
El archivo .po es el formato de intercambio de todo el mundo gettext, y esa
es la razón por la que esta biblioteca lo reutiliza: delegar la traducción
significa entregar un archivo, tanto si el destinatario es un colega con un
editor de PO como una plataforma tipo Weblate o Crowdin. Tres cosas hacen que
la entrega funcione bien:
Di para qué sirve el mensaje. Un comentario en el código viaja con el
mensaje: eso es lo que recoge la opción -c "Translators:":
from gettext_tstrings import tr
name = "Ada"
# Translators: shown on the dashboard right after sign-in
print(tr(t"Welcome back, {name}"))
#. Translators: shown on the dashboard right after sign-in
#. gettext-tstrings
#: app.py:5
#, python-brace-format
msgid "Welcome back, {name}"
msgstr ""
Un traductor ve ese comentario en su editor, junto al mensaje, al otro lado
del mundo. Es la palanca de calidad más barata de todo el flujo de trabajo.
Para una palabra que es su propio homónimo —«Open» el botón frente a «Open» el
estado— dale al mensaje un contexto con
pgettext, que se convierte en un msgctxt visible en el catálogo.
Deja que la plataforma valide los marcadores. Cada mensaje extraído de una
t-string lleva la marca python-brace-format, y esa única línea es lo que
activa el control de calidad de marcadores en herramientas que no controlas:
Weblate documenta la comprobación, las plataformas comerciales basan la suya
en la misma marca y msgfmt --check-format la aplica en cualquier pipeline
GNU. Los detalles, y lo que el checker incluido detecta más allá de ellos,
están en la
página de extracción.
Confía en la red de seguridad exactamente hasta donde llega. Lo que vuelve de una plataforma sigue siendo datos que entran en tu build; las puertas de CI de arriba son lo que convierte «la plataforma probablemente lo comprobó» en «esto no puede distribuirse roto».
Vincular un idioma en tiempo de ejecución¶
Todo lo anterior produce catálogos. La decisión que queda es dónde selecciona uno la aplicación. Vincula una vez por ámbito de un idioma — el proceso en una CLI, la petición en un servicio web.
Una herramienta de línea de comandos o una aplicación de escritorio lee
el entorno del usuario una vez, al arrancar. No pasar languages= deja
que la biblioteca estándar negocie a partir de LANGUAGE, LC_ALL,
LC_MESSAGES y LANG; fallback=True devuelve un catálogo nulo —texto
de origen— en lugar de lanzar una excepción cuando ninguna de esas
variables coincide con un catálogo que distribuyas.
Una aplicación web decide por petición. Carga cada catálogo una vez en el
import y vincula el negociado al contexto antes de que se ejecute la
vista: set_translations es local al
contexto, así que las peticiones concurrentes en idiomas distintos nunca
ven la vinculación de las demás.
import gettext
from flask import Flask, request
from gettext_tstrings import set_translations, tr
LANGUAGES = ("en", "ja", "de")
CATALOGS = {
language: gettext.translation(
"messages", localedir="locales", languages=[language], fallback=True
)
for language in LANGUAGES
}
app = Flask(__name__)
@app.before_request
def bind_language() -> None:
language = request.accept_languages.best_match(LANGUAGES) or "en"
set_translations(CATALOGS[language])
@app.get("/")
def home() -> str:
name = "Ada"
return tr(t"Welcome back, {name}")
Con frameworks asíncronos —FastAPI, Starlette y cualquier otro ASGI—,
envuelve la petición en
use_translations: la vinculación vive
en un ContextVar, que el cambio de tareas asíncronas conserva por
petición.
import gettext
from fastapi import FastAPI, Request
from gettext_tstrings import tr, use_translations
LANGUAGES = ("en", "ja", "de")
CATALOGS = {
language: gettext.translation(
"messages", localedir="locales", languages=[language], fallback=True
)
for language in LANGUAGES
}
app = FastAPI()
@app.middleware("http")
async def bind_language(request: Request, call_next):
language = negotiate_language(request.headers.get("accept-language"), LANGUAGES)
with use_translations(CATALOGS[language]):
return await call_next(request)
negotiate_language representa tu análisis de Accept-Language: la
mayoría de los frameworks o sus ecosistemas ofrecen uno; lo que importa
aquí es la vinculación alrededor de call_next.
Dos hábitos de ejecución completan el cuadro. Las cadenas creadas durante el
import —una etiqueta de formulario, el nombre visible de un enum— no deben
capturar el idioma que estuviera activo durante el import; defínelas con
lazy_gettext y se renderizarán en el idioma
activo en el momento de usarlas. Y encamina el logger gettext_tstrings
adonde mire un humano: sus avisos son el modo tolerante informando de una
traducción que se coló por todas las puertas, una línea por mensaje roto en
lugar de una por renderizado.
Despliegue¶
Producción necesita el paquete, los archivos .mo y nada más. Babel es una
dependencia de desarrollo y de CI: deja gettext-tstrings[babel] fuera de la
imagen de producción e instala allí el paquete básico; el renderizado funciona
solo con la biblioteca estándar. Compila los catálogos en la misma build que
produce el artefacto que despliegas, para que los .mo de su interior sean
exactamente los .po revisados y nunca se distribuya nada compilado en el
portátil de alguien.
Cómo viajan depende de lo que despliegues. Una wheel los transporta como datos
del paquete, lo que significa que los catálogos tienen que vivir dentro del
directorio del paquete —src/myapp/locales/, no un locales/ de nivel
superior— y hay que indicarle al backend de build que incluya archivos que
.gitignore normalmente oculta:
Léelos de vuelta a través del paquete y no mediante una ruta relativa al árbol de fuentes, que deja de existir en cuanto la wheel se instala:
import gettext
from importlib.resources import as_file, files
with as_file(files("myapp") / "locales") as localedir:
translations = gettext.translation("messages", localedir=localedir, languages=["ja"])
Una imagen de contenedor lo tiene más fácil: compila durante la etapa de build y copia el resultado, dejando Babel atrás en esa etapa.
FROM python:3.14-slim AS build
COPY . /src
RUN cd /src && python -m pip install ".[babel]" \
&& pybabel compile -d src/myapp/locales
FROM python:3.14-slim
COPY --from=build /src /src
RUN python -m pip install /src # no [babel]: rendering needs the stdlib only
Antes de una versión, la lista de comprobación a la que se reduce esta página:
pybabel update --checkpasa: ningún mensaje cambió sin que los catálogos se enteraran.pybabel compilebloquea la build con su código de salida.- Las entradas
fuzzyrestantes son intencionadas: cada una se renderiza como texto de origen hasta que un traductor la confirme. - La suite de pruebas renderiza cada idioma distribuido una vez con
strict=True. - El artefacto de producción contiene archivos
.moy ningún Babel. - El logger
gettext_tstringsestá encaminado a la monitorización.
Próximos pasos¶
- Extracción — la referencia de la mitad de herramientas de esta página: opciones de mapping, nombres de función propios, modo estricto y todos los checkers.
- Guía — la mitad de ejecución: plurales, contextos, cadenas diferidas y los modos de fallo en detalle.
- Cómo funciona — por qué el msgid tiene la forma que tiene y qué comprueba realmente la validación.