Internationalization
austial.i18n translates application strings on the server. It mirrors the
frontend convention we already use: one JSON file per module per locale,
merged into a <module>.<key> lookup namespace. The intended use is
application-level code — controller responses, error messages that reach the
end user, i18n-aware label prefixes. Framework internals (log lines, startup
banners, developer-facing exceptions) stay in English.
Layout#
locales/
en/
health.json # {"check": {"database_label": "Database"}}
app.json # {"welcome": "Hello, {name}!"}
fr/
health.json
app.jsonThe loader walks locales/<locale>/<module>.json and merges every file into
a {module: tree} dict for that locale. The first segment of a translation
key names the module (JSON filename); the rest is a dotted path into that
file’s tree:
service.t("health.check.database_label") # -> "Database"Locales are auto-discovered from the top-level directory names.
Setup#
from pathlib import Path
from austial import Module
from austial.i18n import I18nConfig, I18nModule
@Module(
imports=[
I18nModule.for_root(
I18nConfig(
locales_dir=Path("i18n/locales"),
default_locale="en",
fallback_chain={"en-IN": ["en"], "fr-CA": ["fr"]},
)
),
]
)
class AppModule:
passI18nModule is registered @Global() by default, so I18nService and
I18nContext are injectable everywhere without re-importing.
Translating in code#
from austial import Injectable
from austial.i18n import I18nService
@Injectable()
class HealthController:
def __init__(self, i18n: I18nService):
self._i18n = i18n
def readable_status(self, kind: str) -> str:
# Looks up "<module>.<subkey>", using the request's locale.
return self._i18n.t(f"health.check.{kind}_label")Pass locale= to override the request’s locale (batch jobs, notifications
sent in the recipient’s preferred language, tests):
self._i18n.t("app.welcome", locale="fr", name="Alice")I18nService.has("health.check.queue_label") returns True when the key
resolves to a string in the effective locale (walking the fallback chain
and the default locale). available_locales() returns the set of locales
the loader found on disk. add_locale(locale, translations) registers or
replaces a locale in-memory — for tests and hot-reload flows.
Interpolation#
The default placeholder syntax is {name}:
{"welcome": "Hello, {name}!"}i18n.t("app.welcome", name="Alice") # -> "Hello, Alice!"Switch to {{name}} (i18next-style) by setting interpolation="double_brace"
on I18nConfig.
A missing variable is substituted with "" and a warning is logged — in
strict=True mode it raises MissingTranslationError instead.
Fallback chain#
Lookup order for t("health.check.database_label", locale="fr-CA"):
fr-CAtranslations.- Each locale listed in
I18nConfig.fallback_chain["fr-CA"](in order). - The
default_locale. - If still nothing: return the key itself (or raise
MissingTranslationErrorwhenstrict=True).
The default locale is always tried last; you do not need to list it.
Locale middleware#
LocaleMiddleware decides which locale a request is in, and pushes it into
I18nContext for the duration of that request. Precedence (highest first):
- A dedicated fallback header (default
X-Locale), useful when the frontend already knows the user’s preference from their stored profile. Accept-Language, parsed withq-weighting. The first tag that has translations loaded wins; the language subtag is tried when the exact region tag is not deployed (en-IN→en).- The configured
default_locale.
Register it like any other Nest middleware — in your AppModule.configure():
from austial.common.middleware import MiddlewareConsumer
from austial.i18n import LocaleMiddleware
@Module(imports=[I18nModule.for_root(config)])
class AppModule:
def configure(self, consumer: MiddlewareConsumer) -> None:
consumer.apply(LocaleMiddleware).for_routes("*")Inside a handler:
from austial import Injectable
from austial.i18n import I18nContext, I18nService
@Injectable()
class InvoicesController:
def __init__(self, i18n: I18nService, ctx: I18nContext):
self._i18n = i18n
self._ctx = ctx
def show(self):
# ctx.current_locale() -> the request's locale, resolved by middleware.
return {"locale": self._ctx.current_locale(), "greeting": self._i18n.t("app.welcome", name="Alice")}Strict mode#
I18nConfig(strict=True) turns any missing key or missing interpolation
variable into a MissingTranslationError (500). Useful in CI and staging;
production usually wants the default lenient mode where a missing key
degrades to a visible-but-usable label.
Malformed files#
The loader logs a warning (via austial.common.logger.Logger) when a JSON
file fails to parse, or when a file’s top-level value is not an object, then
continues loading the rest. A bad locale file never crashes the app on boot;
it shows up loudly in the startup logs.
What this package is not for#
Framework internals — the log lines, exception messages you see as a
developer, the [Austial] startup banner — stay in English. austial.i18n
is for application code (product backends, landing page copy served from the
API, notifications sent to the end user). This mirrors the workspace rule in
CLAUDE.md: no hardcoded user-facing strings in app code; all messages,
labels and prefixes live in i18n JSON locale files, keyed by module.