Skip to content

Latest commit

 

History

History
374 lines (274 loc) · 10.6 KB

File metadata and controls

374 lines (274 loc) · 10.6 KB

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. Locale Resolution
  6. Configuration
  7. Auto-Configuration
  8. Complete Example
  9. 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