Skip to main content

Internationalization (i18n)

Framework M provides built-in internationalization support through the I18nProtocol.

I18nProtocol

The core interface for translations:

from framework_m_core.interfaces.i18n import I18nProtocol

class I18nProtocol(Protocol):
async def translate(
self,
text: str,
locale: str | None = None,
*,
context: dict[str, str] | None = None,
default: str | None = None,
) -> str: ...

async def get_locale(self) -> str: ...
async def set_locale(self, locale: str) -> None: ...
async def get_available_locales(self) -> list[str]: ...

Using Translations

from framework_m_core.interfaces import t

# In a controller or service
async def greet_user(i18n: I18nProtocol, name: str, locale: str = "en"):
greeting = await i18n.translate(
t("Hello, {name}!"),
locale=locale,
context={"name": name}
)
return greeting

InMemoryI18nAdapter

A simple adapter for testing and development:

from framework_m.adapters.i18n import InMemoryI18nAdapter

i18n = InMemoryI18nAdapter(default_locale="en")

# Add translations
i18n.add_translation("es", "Hello", "Hola")
i18n.add_translation("fr", "Hello", "Bonjour")

# Use translations
text = await i18n.translate("Hello", locale="es")
# Returns: "Hola"

# With interpolation
i18n.add_translation("es", "Hello, {name}!", "¡Hola, {name}!")
text = await i18n.translate(
"Hello, {name}!",
locale="es",
context={"name": "Juan"}
)
# Returns: "¡Hola, Juan!"

Locale Resolution

The framework supports locale resolution in this order:

  1. Request locale - Accept-Language header
  2. User preference - user.locale setting
  3. Tenant default - tenant.default_locale
  4. System default - settings.DEFAULT_LOCALE

Creating Custom Adapters

Implement I18nProtocol for custom translation backends:

class DatabaseI18nAdapter:
"""Load translations from database."""

def __init__(self, repo: TranslationRepository):
self.repo = repo
self._cache: dict[str, dict[str, str]] = {}

async def translate(
self,
text: str,
locale: str | None = None,
*,
context: dict[str, str] | None = None,
default: str | None = None,
) -> str:
target_locale = locale or await self.get_locale()

# Check cache first
if target_locale in self._cache:
if text in self._cache[target_locale]:
translation = self._cache[target_locale][text]
if context:
for key, value in context.items():
translation = translation.replace(f"{{{key}}}", value)
return translation

# Fallback to original text
return default or text

async def load_translations(self, locale: str) -> None:
"""Load all translations for a locale into cache."""
translations = await self.repo.get_by_locale(locale)
self._cache[locale] = {t.source: t.translated for t in translations}

DocType Labels

Field labels and titles in DocTypes support i18n out-of-the-box. You can define them as raw string literals. The framework's static translation extraction tool automatically scans for calls to Field(description="..."), Field(title="..."), and class Meta: label = "..." to add them to your catalogs:

class Invoice(BaseDocType):
class Meta:
label = "Invoice Document" # Automatically extracted

customer: str = Field(
description="Customer name", # Automatically extracted
title="Customer Name", # Automatically extracted
)

The Meta API automatically translates these descriptions (which represent field labels on the frontend) at runtime based on the resolved request locale.

Modular Translations & Namespaces

To prevent translation catalogs from growing into single monolithic files, Framework M supports namespaced translations. This allows libraries and packages to encapsulate their own translation keys.

Namespace Resolution in the Frontend (React)

You can load keys from a specific namespace (e.g. desk or ui) using the useTranslation hook:

import { useTranslation } from "react-i18next";

export function SaveButton() {
// Use the "ui" namespace
const { t } = useTranslation("ui");

return <button>{t("Save")}</button>; // Resolves to ui:Save
}

By default, standard components loaded by standard UI views utilize their respective namespace (e.g., standard form elements use "ui", workspace management uses "desk", and other custom code uses "common").

Namespace Resolution in the Backend (Python)

On the backend, you can specify the target namespace as a keyword argument in translate() calls:

from framework_m_core.interfaces import I18nProtocol, t

async def validate_data(i18n: I18nProtocol, locale: str):
# Resolves translation specifically in the "desk" namespace
error_msg = await i18n.translate(
t("Invalid workspace structure"),
locale=locale,
namespace="desk"
)
return error_msg