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.
_lis why this page exists.pybabelknows_,gettextandngettextand nothing else, so until the extractor was told about the lazy forms, every string wrapped in_lwas 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 --extractpasses-k _l -k lazy_gettext -k _n:1,2now. 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.