Internationalisation (i18n) Guide¶
Resolve user-facing messages in multiple languages with locale-specific
resource bundles, automatic locale fallback, and MessageFormat-style
placeholder substitution.
Table of Contents¶
- Introduction
- Quick Example
- The MessageSource Protocol
- ResourceBundleMessageSource
- File Naming Convention
- Nested Keys
- Locale Fallback
- Placeholder Substitution
- Locale Resolution
- AcceptHeaderLocaleResolver
- FixedLocaleResolver
- Configuration
- Auto-Configuration
- Complete Example
- See Also
Introduction¶
Most applications that serve a global audience need to render text — error messages, email subjects, UI labels — in the user's preferred language. The PyFly i18n module gives you a declarative, pluggable way to do this without sprinkling translation logic through your services.
The module is built around a hexagonal architecture:
- MessageSource is the outbound port — the abstract message-resolution interface every backend implements.
- ResourceBundleMessageSource is the built-in adapter that loads messages from locale-specific YAML or JSON files.
- LocaleResolver is the port for determining the locale of an incoming
request, with two built-in resolvers:
AcceptHeaderLocaleResolverandFixedLocaleResolver. - I18nAutoConfiguration wires a
MessageSourceand aLocaleResolverinto the container when the subsystem is enabled.
The i18n subsystem is opt-in — it only activates when
pyfly.i18n.enabled is set to true.
Public types are available from a single import:
from pyfly.i18n import (
AcceptHeaderLocaleResolver,
FixedLocaleResolver,
LocaleResolver,
MessageSource,
)
from pyfly.i18n.adapters.resource_bundle import ResourceBundleMessageSource
Quick Example¶
Create a resource bundle, then resolve a message for a locale:
from pyfly.i18n.adapters.resource_bundle import ResourceBundleMessageSource
# i18n/messages_en.yaml:
# greeting:
# hello: "Hello, {0}!"
# i18n/messages_es.yaml:
# greeting:
# hello: "¡Hola, {0}!"
source = ResourceBundleMessageSource(base_path="i18n/", default_locale="en")
print(source.get_message("greeting.hello", ("World",), "en")) # Hello, World!
print(source.get_message("greeting.hello", ("Mundo",), "es")) # ¡Hola, Mundo!
The MessageSource Protocol¶
MessageSource is the framework-agnostic port for resolving internationalised
messages. All message backends (resource bundles, database-backed, etc.) must
satisfy this protocol. It is @runtime_checkable.
from typing import Any, Protocol, runtime_checkable
@runtime_checkable
class MessageSource(Protocol):
def get_message(
self,
code: str,
args: tuple[Any, ...] = (),
locale: str = "en",
) -> str:
"""Resolve *code* for *locale*, substituting *args*.
Raises ``KeyError`` when the code cannot be resolved.
"""
def get_message_or_default(
self,
code: str,
default: str,
args: tuple[Any, ...] = (),
locale: str = "en",
) -> str:
"""Resolve *code* for *locale*, returning *default* on miss."""
get_message(code, args, locale)resolves the message and raisesKeyErrorwhen the code cannot be found.get_message_or_default(code, default, args, locale)returnsdefault(with the same placeholder substitution applied) when the code is missing.
ResourceBundleMessageSource¶
The built-in adapter resolves messages from locale-specific YAML or JSON resource files.
from pyfly.i18n.adapters.resource_bundle import ResourceBundleMessageSource
source = ResourceBundleMessageSource(
base_path="i18n/", # directory holding the bundle files
default_locale="en", # used for fallback
)
Loaded bundles are cached per locale on first access.
File Naming Convention¶
For a given locale, the adapter looks for these files in order and uses the first that exists:
{base_path}/messages_{locale}.yaml (preferred)
{base_path}/messages_{locale}.yml (fallback)
{base_path}/messages_{locale}.json (fallback)
For example, with base_path="i18n/" and locale es, it searches
i18n/messages_es.yaml, then i18n/messages_es.yml, then
i18n/messages_es.json.
Requires PyYAML to load
.yaml/.ymlbundles. JSON bundles need no extra dependency.
Nested Keys¶
Nested mappings are flattened with dots, so the YAML structure:
is accessed as:
All leaf values are coerced to strings.
Locale Fallback¶
When a code is missing in the requested locale, the adapter falls back to the
default_locale. If the code is missing in both locales, get_message
raises KeyError:
# 'unknown.code' is absent in both 'fr' and the default 'en':
source.get_message("unknown.code", (), "fr") # raises KeyError
# Or supply a default to avoid the exception:
source.get_message_or_default("unknown.code", "fallback text", (), "fr")
Placeholder Substitution¶
Substitution mirrors the quote semantics of java.text.MessageFormat:
{n}referencesargs[n]by zero-based index — e.g.{0},{1}.{n,type,style}is accepted; the type/style after the index is parsed but not locale-applied — only the positional argument is inserted (a documented subset ofMessageFormat).- An index with no corresponding argument is left as the literal placeholder (lenient behavior).
''(two single quotes) renders as a single literal apostrophe.- Single-quoted text (
'...') is copied literally and placeholders inside it are not substituted, so'{0}'renders as{0}.
# messages_en.yaml:
# order.summary: "Order {0} for {1} items"
# apostrophe: "It''s ready"
# literal: "Use '{0}' as a placeholder"
source.get_message("order.summary", ("A-17", 3), "en") # Order A-17 for 3 items
source.get_message("apostrophe", (), "en") # It's ready
source.get_message("literal", ("ignored",), "en") # Use {0} as a placeholder
Locale Resolution¶
LocaleResolver is the port for determining the locale from an incoming
request. It is a @runtime_checkable protocol:
from typing import Any, Protocol, runtime_checkable
@runtime_checkable
class LocaleResolver(Protocol):
def resolve_locale(self, request: Any) -> str: ...
AcceptHeaderLocaleResolver¶
Parses the Accept-Language header and returns the language tag with the
highest quality (q) value. It reads the header from a accept_language
attribute on the request, or from request.headers["accept-language"]. When no
header is present or parsing fails, it falls back to default_locale.
from pyfly.i18n import AcceptHeaderLocaleResolver
resolver = AcceptHeaderLocaleResolver(default_locale="en")
# For 'en-US,en;q=0.9,fr;q=0.8' this returns 'en'
locale = resolver.resolve_locale(request)
The resolver normalises the chosen tag to its primary language subtag in
lowercase (en-US becomes en).
FixedLocaleResolver¶
Always returns a pre-configured locale, ignoring the request — useful for single-language deployments or tests.
from pyfly.i18n import FixedLocaleResolver
resolver = FixedLocaleResolver(locale="es")
resolver.resolve_locale(request) # always 'es'
Configuration¶
The i18n subsystem is configured in pyfly.yaml and is disabled by
default — it only activates when pyfly.i18n.enabled is true:
| Key | Description | Default |
|---|---|---|
pyfly.i18n.enabled |
Enable or disable the i18n subsystem | false |
pyfly.i18n.base-path |
Directory holding the message bundle files | i18n/ |
pyfly.i18n.default-locale |
Locale used for fallback and resolver default | en |
The Application starter (
enable_application_stack) setspyfly.i18n.enabledtotrueby default.
Auto-Configuration¶
When pyfly.i18n.enabled is true, PyFly registers a MessageSource and a
LocaleResolver bean through I18nAutoConfiguration.
I18nAutoConfiguration¶
Conditions: @conditional_on_property("pyfly.i18n.enabled", having_value="true").
| Bean | Type | Description |
|---|---|---|
message_source |
ResourceBundleMessageSource |
Resolves messages from YAML/JSON bundles under base-path |
locale_resolver |
AcceptHeaderLocaleResolver |
Resolves the request locale from Accept-Language |
Both beans are registered with @conditional_on_missing_bean, so providing
your own MessageSource or LocaleResolver bean overrides the
auto-configured one:
from pyfly.container import configuration
from pyfly.container.bean import bean
from pyfly.i18n import FixedLocaleResolver, LocaleResolver
@configuration
class MyI18nConfig:
@bean
def locale_resolver(self) -> LocaleResolver:
return FixedLocaleResolver(locale="es")
Source: src/pyfly/i18n/auto_configuration.py
Complete Example¶
from pyfly.container import service
from pyfly.i18n import AcceptHeaderLocaleResolver, MessageSource
@service
class GreetingService:
"""Greets users in their preferred language."""
def __init__(
self,
messages: MessageSource,
locale_resolver: AcceptHeaderLocaleResolver,
) -> None:
self._messages = messages
self._locale_resolver = locale_resolver
def greet(self, request, name: str) -> str:
locale = self._locale_resolver.resolve_locale(request)
return self._messages.get_message_or_default(
"greeting.hello",
default="Hello, {0}!",
args=(name,),
locale=locale,
)
With bundles:
# i18n/messages_en.yaml
greeting:
hello: "Hello, {0}!"
# i18n/messages_es.yaml
greeting:
hello: "¡Hola, {0}!"
A request carrying Accept-Language: es resolves to es, so greet(request,
"Mundo") returns ¡Hola, Mundo!.
See Also¶
- Web Layer — Controllers, request objects, and header access
- Configuration —
pyfly.yaml, profiles, property binding - Starters —
enable_application_stackenables i18n by default