Skip to content

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

  1. Introduction
  2. Quick Example
  3. The MessageSource Protocol
  4. ResourceBundleMessageSource
  5. File Naming Convention
  6. Nested Keys
  7. Locale Fallback
  8. Placeholder Substitution
  9. Locale Resolution
  10. AcceptHeaderLocaleResolver
  11. FixedLocaleResolver
  12. Configuration
  13. Auto-Configuration
  14. Complete Example
  15. 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: AcceptHeaderLocaleResolver and FixedLocaleResolver.
  • I18nAutoConfiguration wires a MessageSource and a LocaleResolver into 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 raises KeyError when the code cannot be found.
  • get_message_or_default(code, default, args, locale) returns default (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/.yml bundles. JSON bundles need no extra dependency.

Nested Keys

Nested mappings are flattened with dots, so the YAML structure:

greeting:
  hello: "Hello, {0}!"

is accessed as:

source.get_message("greeting.hello", ("World",), "en")

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} references args[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 of MessageFormat).
  • 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:

pyfly:
  i18n:
    enabled: true
    base-path: "i18n/"
    default-locale: "en"
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) sets pyfly.i18n.enabled to true by 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
  • Configurationpyfly.yaml, profiles, property binding
  • Startersenable_application_stack enables i18n by default