Babel
Sending mail to people usually means speaking their language. So the natural next
battery is internationalization. Our Babel extension wraps
Babel and gives you familiar
gettext in your templates, locale aware formatting filters and a
database-backed translation store you can update at runtime.
Enabling Babel
Babel leans on the database for its runtime translations, so it requires the SQLAlchemy extension to be enabled. Flip both switches and you are set:
[general]
SECRET_KEY = supersecret
[data]
DATABASE_URI = sqlite:///app.db
[extensions]
EXT_SQLALCHEMY = 1
EXT_BABEL = 1
When Babel expands your fluid it installs the i18n extension into your
jinja_env, registers a set of formatting filters and wires up a small
WebSocket so the same translations can be reused on the client. It also brings its own
models, I18nKey and I18nMessage, which are where runtime
translations are persisted.
Because these are regular db models, they become part of your schema the moment Babel is on. So this is the perfect time to run the migration flow from the previous chapter again (wf migrate revision app -a and wf migrate upgrade app) to create the translation tables.
Translating in templates
The gettext callables are installed new-style, so the usual underscore helper is available
everywhere in your templates — you don't need any special base layout for it, enabling
Babel is enough. So we can sprinkle gettext straight into the hand-written
index.html we've carried since Getting Started. A source string is its own key
— if no translation is found, the source is returned untouched:
<title>{{ _('Home') }}</title>
<!-- ... -->
<h1>{{ _('Hello %(name)s!', name=name) }}</h1>
<p>{{ ngettext('%(num)d model', '%(num)d models', count) }}</p>
(We'll trade this hand-written page for the framework's own base layout once we reach the
Frontend chapters — the translation calls
stay exactly the same.) Alongside gettext you get ngettext (plurals),
pgettext (contextual) and npgettext. For strings that are built
outside of a request — module level constants, for example — reach for the lazy
variants like babel.lazy_gettext, which only resolve once they are actually
rendered.
Providing translations
The fastest way to ship translations is the runtime store. You hand Babel a
resolver — a callable that returns your catalog — and it writes
the messages into the translation table and the in-memory cache during startup. The easiest
way to build such a resolver is from JSON files with the bundled
translation_resolver helper. Note that updates are only accepted
before the server is up.
Drop one JSON file per plural form and context into a folder. The plain
default.json holds the singular (and non-plural) messages, and every file maps a
source string to a list of translations — one entry per locale:
{
"Home": ["Home", "Startseite"],
"Hello %(name)s!": ["Hello %(name)s!", "Hallo %(name)s!"]
}
Plural forms and contexts are encoded in the file name. A file called
pf-other.json carries the other plural form, and
ctx-menu.json would carry messages in the menu context (you can even
combine them, like pf-other_ctx-menu.json):
{
"%(num)d model": ["%(num)d models", "%(num)d Modelle"]
}
Then build the resolver from that folder, mapping each locale to its index in the JSON lists,
and hand it to Babel. This must run before fluid.mix(), so import the module from
your config or factory:
from pathlib import Path
from webfluid.extensions.babel.utils import translation_resolver
from webfluid.core.ext import babel
# "en" is index 0, "de" is index 1 in every JSON list:
translations = translation_resolver(
Path(__file__).parent, { "en": 0, "de": 1 }
)
# The default domain is called "messages". This must run before
# fluid.mix(), so make sure the module is imported by then.
babel.update_translations("messages", translations)
More than one catalog
"messages" is just the default domain name. Register another with
babel.register_domain("shop") and scope a callable to it with the
babel.domain_context("shop") decorator — useful when an Additive ships its
own vocabulary and you do not want it mixed into the main app's.
A lookup escalates rather than failing: the active domain first, then the default domain, then
a domain literally named __fallback__ if you registered one, and finally the
framework's own catalog. The first domain that actually translates the string wins; if none
does, you get the source string back untouched.
Prefer to keep the catalog in code? update_translations accepts any callable that
returns the same shape (locale → key → message-data → message,
where message-data is a JSON string like "{}" or
{"pf": "other"}). The translation_resolver just
builds that for you from the files above. If you prefer classic .po catalogs as a
static fallback, extract them once with the CLI — more on that below.
Translations are db-backed with a runtime cache that is not optimized for very large catalogs
yet. If you have big or rarely used strings, you can opt individual keys out of the cache with
the I18nMessage store's uncache / recache helpers so they are read
straight from the database instead.
A source string is stored once per domain, which is what the key table's
(key, domain) constraint enforces — so two domains can translate the same
string differently. The escalation asks each catalog whether it has the message
rather than comparing the answer to the source, so a deliberate translation that equals its
source — an English catalog translating Save to Save —
counts as a hit and stops the chain, which is what you meant. Before beta 3 it was read as a
miss and the next domain answered instead.
Use symbolic keys like ERROR_TITLE rather than English sentences for any catalog
you own. It is what the framework's own catalog does, and it is still the right habit: a key
that is not a sentence in any language can never be mistaken for one, reads the same in a
diff, and survives an editor rewording the English.
Upgrading from beta 1? That constraint replaced a unique index on the source string alone, so an existing translation database needs a migration to swap the index — and any messages a second domain wrote against the first domain's key before the fix belong to that first domain and have to be re-imported under their own.
Translating outside a template
Everything above happens while a template renders. When you need a translated string in your own code — a mail subject, a JSON error message — the same four calls are available on the extension, in a synchronous and an awaitable flavour:
from webfluid.core.ext import babel, mail
async def welcome(address: str, value: str):
# agettext / angettext / apgettext / anpgettext read anything the cache
# does not hold through an async session, instead of blocking the loop.
subject = await babel.agettext("Welcome aboard!")
body = await babel.agettext("Saved your model: %(value)s", value=value)
await mail.asend(address, subject, {"plain": body})
The synchronous gettext family is unchanged and still what the
{{ _('...') }} callables in your templates use. Reach for the
a-variants wherever you are already in async code. There is no
alazy_gettext: a lazy string resolves through str(), which cannot
await.
Selecting the locale
Out of the box Babel resolves the active locale from the lang query parameter,
then the lang cookie, then the Accept-Language header (best match
against your supported locales) and finally the default. The query parameter first means
/?lang=de is enough to preview a page in another language without touching
cookies. The timezone works the same way through the tz cookie or the
X-Timezone header. You declare your supported set in the config:
from webfluid.core.config import register_config
@register_config(10)
class MyConfig:
SESSION_COOKIE_SECURE = True
MYEXT_BAR = "crazy"
BABEL_DEFAULT_LOCALE = "en"
BABEL_DEFAULT_TIMEZONE = "UTC"
BABEL_SUPPORTED_LOCALES = ["en", "de"]
Both the locale and the timezone are resolved once per request and cached for it, so a page
with two hundred translated strings parses Accept-Language exactly once. A
selector you register yourself always wins and is asked every time — keep it cheap.
Every step of that chain is treated as untrusted input. A ?lang= value, a cookie
or a header that does not resolve to a real locale or zone is skipped and the next candidate
gets its turn, ending at your configured default — so /?lang=xx is a page
in your default language, not a 500. Note the one behavioural change beta 2 made here: a
request with no Accept-Language header at all now falls through to
BABEL_DEFAULT_LOCALE instead of picking the first entry of
BABEL_SUPPORTED_LOCALES. If your supported list does not start with your default,
that is a different language than before — the right one.
Need your own logic? Register a selector. Whatever it returns wins over the defaults, and
you can temporarily override both with the Babel.force /
Babel.aforce context managers:
from webfluid.core.ext import babel
@babel.locale_selector
def select_locale():
from webfluid.core.context import FluidContext
user = FluidContext.current().request.session.get("user")
return user["locale"] if user else babel.default_locale
# Render something in a fixed locale, no matter the request:
async with babel.aforce(locale="de"):
subject = babel.gettext("Welcome aboard!")
Formatting filters
Numbers, dates and currencies should follow the user's locale too. Babel registers a family of filters for exactly that, all locale (and where it matters, timezone) aware:
<p>{{ model.created_at|datetimeformat }}</p>
<p>{{ model.created_at|dateformat('full') }}</p>
<p>{{ 1234.5|decimalformat }}</p>
<p>{{ 19.99|currencyformat('EUR') }}</p>
<p>{{ 0.75|percentformat }}</p>
The full set is datetimeformat, dateformat,
timeformat, timedeltaformat, numberformat,
decimalformat, currencyformat, percentformat and
scientificformat.
Every filter is a plain function in webfluid.extensions.babel.utils, so the same
formatting is available in your own code — together with the two helpers that move a
datetime between the user's zone and yours. to_user_timezone reads a naive value
as UTC and converts it into the active zone; to_utc is its inverse, reading a
naive value as user-local and handing you a naive UTC one back. Store UTC, render local:
from webfluid.extensions.babel.utils import (
format_date, to_user_timezone, to_utc
)
def line(model):
local = to_user_timezone(model.created_at) # UTC in, active zone out
return format_date(local, fmt="full") # keyword is fmt
def store(user_input):
return to_utc(user_input) # naive local in, naive UTC out
Both of those changed in beta 2. format_date's keyword used to be spelled
ftm; the typo is gone and the parameter is fmt like every other
formatter's. And to_utc used to only drop the offset, which keeps the wall clock
and throws the actual instant away — a Berlin 12:00+02:00 came back as a
naive 12:00 that everything downstream then read as UTC, two hours off. It
converts first now. If you were using it to strip a tzinfo, call
replace(tzinfo=None) yourself.
On the frontend
Because the extension also exposes a translation WebSocket, the same catalog is reachable
from the browser. WebFluid injects a small i18n.js client that mirrors the
server API (_, _n, _p, _np) and caches
the active locale in localStorage. We'll come back to client side wiring in
the Frontend chapters — for now it is enough to know it is there.
The cached half is the half to use. On connect the client asks the socket for the whole
catalog of the active locale, keeps it in localStorage, and answers every
_() out of it — no round trip, correct language. Alongside it sits a
live namespace that asks the server per string, and that one is currently not
trustworthy:
live._() and its siblings answer in BABEL_DEFAULT_LOCALE, whatever
the visitor's language is. The translate half of /ws/i18n runs
without a request context, so the locale resolution has nothing to read — while the
cache half resolves it correctly, which is why the two disagree. On top of that,
live._p and live._np send their context argument in the wrong
position and come back wrong even in the default language. Stay on the cached API until this
is fixed.
Fallback catalogs
If you want gettext-style .po files as a static fallback below the runtime
store, the Babel CLI extracts and compiles them for you, scanning both your project and the
framework templates:
# Produce messages.pot and init / update the catalogs
wf babel extract
# Turn the .po files into the .mo files Babel reads at runtime
wf babel compile
The first command writes a messages.pot and initializes or updates the catalogs
under translations/; the second compiles them. Both steps run for you if you ever
scaffold a project with wf create project --babel-fallback.
The config values
BABEL_DEFAULT_LOCALE/BABEL_DEFAULT_TIMEZONE— the fallbacks when nothing else resolves. Defaults "en" and "UTC".BABEL_SUPPORTED_LOCALES— the setAccept-Languageis matched against. Default ["en"].BABEL_DATE_FORMATS— the per-kind format map behind the date and time filters.BABEL_DATABASE_BIND— put the two translation tables on a non-default bind. Default None.BABEL_DISABLE_AUTOUPDATE— ignore everyupdate_translationscall and read whatever is already in the database. Handy once a catalog is stable and you would rather not write it on every boot. Default False.BABEL_CONFIGURE_JINJA— install the gettext callables and formatting filters into your template environment. Default True.BABEL_CONFIGURE_SOCKET— mount/ws/i18nand inject the client. Default True.
Continue reading
From here you can continue straight with Security.