Translations

A product speaks the language its .env says it does, and every feature ships the catalogue for its own strings. What follows is the part that is easy to get wrong, because getting it wrong looks like nothing at all: the page renders, the label reads in English, and no error is raised anywhere.

Table of contents

Which language a product speaks

BABEL_DEFAULT_LOCALE=es
BABEL_SUPPORTED_LOCALES=es,en

Read by the framework, so a product does not have to do anything else. The order is the order a language switcher renders them in, and the first is what a reader who asked for nothing gets.

The product’s language comes ahead of the browser’s Accept-Language. A site built for an institution speaks that institution’s language, and a student whose laptop came set up in English should not get an English interface wrapped around Spanish course material. A reader who wants the other language uses the switcher, and their choice, kept in the session, beats everything. A product with no home country sets BABEL_NEGOTIATE_FROM_HEADER=true and hands the decision back.


Writing a translatable string

In a template and in ordinary request-time code, _():

from flask_babel import gettext as _

flash(_("Saved %(name)s.", name=page.name), "success")

In a form class, _l() and nothing else

from flask_babel import lazy_gettext as _l

class PageForm(FlaskForm):
    name = StringField(_l("Title"), validators=[DataRequired()])
    body_md = TextAreaField(_l("Body"))

A form class is built once, at import time, before any request has said what language it wants. An eager gettext there is evaluated during startup, so whichever locale happened to be active then is frozen into every label the product renders from that point on. lazy_gettext returns a proxy that resolves when the label is rendered, which is the only moment the answer is knowable.

_l is why this page exists. pybabel knows _, gettext and ngettext and nothing else, so until the extractor was told about the lazy forms, every string wrapped in _l was silently absent from every catalogue. The symptom is specific and easy to misread: a form whose placeholders translate and whose labels do not, in a product that looks fully translated everywhere else. Twenty-four strings across three features were in that state.

splent feature:translate --extract passes -k _l -k lazy_gettext -k _n:1,2 now. If you add another alias, add it there too.


The workflow

splent feature:translate courses --extract    # source  -> messages.pot
splent feature:translate courses --init es    # .pot    -> es/messages.po
# edit the .po
splent feature:translate courses --compile    # .po     -> .mo

--init on a locale that already exists runs pybabel update, merging what is new and keeping what is translated.

Read every entry it marks fuzzy

pybabel update guesses at new strings by similarity to old ones and marks the guesses #, fuzzy. A fuzzy entry is excluded from the compiled catalogue, so it shows in English, and if you clear the marker without reading it you ship the guess.

The guesses are not close. Real examples from this workspace:

written guessed
Compare Curso
Body Por
Hidden Cuándo
History of %(name)s Se ha eliminado %(name)s.

Read each one before clearing the marker. Twelve of twelve were wrong on one run, and “History of Lab 5” reading “Lab 5 has been deleted” is the kind of thing that reaches a user.


Product-supplied text

A string the product writes, not the feature, cannot be in any catalogue: it is chosen after the feature was released and a product cannot add entries to a feature’s .po. One string in a .env therefore stays in one language forever, which is how a wiki switched to English and went on saying Buscar en esta wiki in its search box.

Write a variant per language instead, in the product’s [tool.splent.config] block:

SEARCH_PLACEHOLDER = "es:Buscar en esta wiki|en:Search this wiki"

Resolved by splent_framework.i18n.localized, available in templates:

placeholder="{{ localized(config.SEARCH_PLACEHOLDER, _('Search this site')) }}"

It falls back rather than failing: the exact locale, then the base language so pt-BR finds pt, then the first variant written. A plain value with no language tags is used for every language, which is right for a proper noun and wrong for a sentence. A value is only split when every piece carries a tag, so a label containing a colon, a URL or a time of day is left alone.


Shipping the catalogue

The compiled .mo is what gettext reads, and it must be committed and packaged. It was ignored in git and missing from MANIFEST.in once, and the result was that every released feature shipped untranslated: the .po travelled and nothing read it. Development installs the checkout in place and reads whatever the last --compile left on disk, which is why every workspace looked translated and every product answered in English.

feature:create gets this right. A feature written before it did needs:

recursive-include src/<org>/<feature>/translations *.mo *.po *.pot

in MANIFEST.in, and no *.mo line in .gitignore.


Checking a product

splent product:run              # then read the pages

There is no substitute for looking. The three failures above all render a perfectly good page in the wrong language and raise nothing, so the tests pass, the logs are clean, and only a reader notices.


Back to top

splent. Distributed by an LGPL license v3. Contact us: drorganvidez@us.es