"""Internationalization (i18n) — translation and localization system.
Provides translation lookup with locale fallback, plural rules, and
format string interpolation. Supports CSV and JSON translation files.
Public API:
from simvx.core import TranslationServer, tr
ts = TranslationServer.instance()
ts.load_json("translations.json")
ts.set_locale("fr")
label.text = tr("greeting", name=player_name) # "Bonjour, {name}!"
"""
from __future__ import annotations
from __future__ import annotations
import csv
import json
import locale
import logging
from pathlib import Path
from typing import ClassVar
log = logging.getLogger(__name__)
__all__ = [
"TranslationServer",
"tr",
"PluralRules",
"locale_from_system",
]
# ============================================================================
# PluralRules — CLDR-style plural category selection
# ============================================================================
[docs]
class PluralRules:
"""Basic CLDR-style plural category selection for common locales.
Returns a plural category string: "zero", "one", "two", "few", "many", "other".
Rules are simplified from the full CLDR specification but cover the most common cases.
"""
def _plural_en(n: int | float) -> str:
"""English: one (1), other."""
return "one" if n == 1 else "other"
def _plural_fr(n: int | float) -> str:
"""French: one (0 or 1), other."""
return "one" if n in (0, 1) else "other"
def _plural_de(n: int | float) -> str:
"""German: one (1), other."""
return "one" if n == 1 else "other"
def _plural_ja(n: int | float) -> str:
"""Japanese: always other (no grammatical plural)."""
return "other"
def _plural_ar(n: int | float) -> str:
"""Arabic: zero, one, two, few, many, other."""
ni = int(n)
if ni == 0:
return "zero"
if ni == 1:
return "one"
if ni == 2:
return "two"
mod100 = ni % 100
if 3 <= mod100 <= 10:
return "few"
if 11 <= mod100 <= 99:
return "many"
return "other"
def _plural_ru(n: int | float) -> str:
"""Russian: one, few, many, other."""
ni = int(n)
mod10 = ni % 10
mod100 = ni % 100
if mod10 == 1 and mod100 != 11:
return "one"
if 2 <= mod10 <= 4 and not (12 <= mod100 <= 14):
return "few"
if mod10 == 0 or (5 <= mod10 <= 9) or (11 <= mod100 <= 14):
return "many"
return "other"
def _plural_pl(n: int | float) -> str:
"""Polish: one, few, many, other."""
ni = int(n)
mod10 = ni % 10
mod100 = ni % 100
if ni == 1:
return "one"
if 2 <= mod10 <= 4 and not (12 <= mod100 <= 14):
return "few"
if mod10 in (0, 1) or (5 <= mod10 <= 9) or (12 <= mod100 <= 14):
return "many"
return "other"
def _plural_other(n: int | float) -> str:
"""Fallback: always other."""
return "other"
_PLURAL_RULES: dict[str, object] = {
"en": _plural_en,
"fr": _plural_fr,
"de": _plural_de,
"ja": _plural_ja,
"zh": _plural_ja, # Chinese — same as Japanese (no grammatical plural)
"ko": _plural_ja, # Korean — same
"ar": _plural_ar,
"ru": _plural_ru,
"uk": _plural_ru, # Ukrainian — same structure as Russian
"pl": _plural_pl,
}
# ============================================================================
# TranslationServer — singleton translation manager
# ============================================================================
[docs]
class TranslationServer:
"""Singleton translation server managing locale state and translation lookup.
Stores translations as ``{locale: {key: value}}`` and supports:
- Locale fallback chain (e.g. "fr_CA" -> "fr" -> default locale)
- Python format string interpolation via ``**kwargs``
- CSV and JSON file loading
- Plural form lookup
Example:
ts = TranslationServer.instance()
ts.add_translation("en", "greeting", "Hello, {name}!")
ts.add_translation("fr", "greeting", "Bonjour, {name}!")
ts.set_locale("fr")
print(ts.translate("greeting", name="World")) # "Bonjour, World!"
"""
_instance: ClassVar[TranslationServer | None] = None
def __init__(self):
self._locale: str = "en"
self._default_locale: str = "en"
self._translations: dict[str, dict[str, str]] = {}
[docs]
@classmethod
def instance(cls) -> TranslationServer:
"""Return the global singleton, creating it on first access."""
if cls._instance is None:
cls._instance = cls()
return cls._instance
@classmethod
def _reset(cls) -> None:
"""Reset singleton (for testing only)."""
cls._instance = None
# -- Locale management ---------------------------------------------------
[docs]
def set_locale(self, locale_code: str) -> None:
"""Set the active locale (e.g. "en", "fr", "ja", "fr_CA").
Args:
locale_code: An IETF-style language tag. Case-insensitive storage;
internally normalised to lowercase with underscores.
"""
self._locale = locale_code.replace("-", "_").lower()
log.debug("Locale set to %s", self._locale)
[docs]
def get_locale(self) -> str:
"""Return the current active locale string."""
return self._locale
[docs]
def set_default_locale(self, locale_code: str) -> None:
"""Set the fallback locale used when a key is missing in the active locale."""
self._default_locale = locale_code.replace("-", "_").lower()
[docs]
def get_default_locale(self) -> str:
"""Return the default/fallback locale string."""
return self._default_locale
[docs]
def get_available_locales(self) -> list[str]:
"""Return a sorted list of locales that have at least one translation."""
return sorted(self._translations)
# -- Translation data management -----------------------------------------
[docs]
def add_translation(self, locale_code: str, key: str, value: str) -> None:
"""Add a single translation entry.
Args:
locale_code: Target locale (e.g. "en", "fr").
key: Translation key.
value: Translated string, may contain ``{placeholder}`` fields.
"""
loc = locale_code.replace("-", "_").lower()
if loc not in self._translations:
self._translations[loc] = {}
self._translations[loc][key] = value
[docs]
def add_translations(self, locale_code: str, entries: dict[str, str]) -> None:
"""Bulk-add translations for a locale.
Args:
locale_code: Target locale.
entries: Mapping of key -> translated string.
"""
loc = locale_code.replace("-", "_").lower()
if loc not in self._translations:
self._translations[loc] = {}
self._translations[loc].update(entries)
[docs]
def load_csv(self, path: str | Path) -> None:
"""Load translations from a CSV file.
Expected CSV format (first column is the key, remaining columns are locales):
key,en,fr,ja
greeting,Hello,Bonjour,こんにちは
farewell,Goodbye,Au revoir,さようなら
The first row is the header defining locale codes.
Args:
path: Path to the CSV file.
"""
path = Path(path)
with path.open(newline="", encoding="utf-8") as f:
reader = csv.reader(f)
header = next(reader, None)
if not header or len(header) < 2:
log.warning("CSV %s has no valid header row", path)
return
locales = [h.strip().replace("-", "_").lower() for h in header[1:]]
for loc in locales:
if loc not in self._translations:
self._translations[loc] = {}
for row in reader:
if not row or not row[0].strip():
continue
key = row[0].strip()
for i, loc in enumerate(locales):
if i + 1 < len(row) and row[i + 1].strip():
self._translations[loc][key] = row[i + 1].strip()
[docs]
def load_json(self, path: str | Path) -> None:
"""Load translations from a JSON file.
Expected JSON format (outer keys are locale codes):
{
"en": {"greeting": "Hello", "farewell": "Goodbye"},
"fr": {"greeting": "Bonjour", "farewell": "Au revoir"}
}
Args:
path: Path to the JSON file.
"""
path = Path(path)
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
log.warning("JSON %s root is not an object", path)
return
for locale_code, entries in data.items():
if isinstance(entries, dict):
self.add_translations(locale_code, {k: str(v) for k, v in entries.items()})
[docs]
def load_dict(self, data: dict[str, dict[str, str]]) -> None:
"""Load translations from an in-memory dict (same format as JSON).
Args:
data: ``{locale: {key: value, ...}, ...}``
"""
for locale_code, entries in data.items():
if isinstance(entries, dict):
self.add_translations(locale_code, entries)
[docs]
def clear(self) -> None:
"""Remove all loaded translations and reset locale to default."""
self._translations.clear()
self._locale = self._default_locale
# -- Lookup --------------------------------------------------------------
def _fallback_chain(self, locale_code: str) -> list[str]:
"""Build the locale fallback chain.
"fr_ca" -> ["fr_ca", "fr", <default_locale>]
"en" -> ["en"]
"""
chain: list[str] = [locale_code]
if "_" in locale_code:
base = locale_code.split("_")[0]
if base != locale_code:
chain.append(base)
if self._default_locale not in chain:
chain.append(self._default_locale)
return chain
[docs]
def translate(self, key: str, **kwargs: object) -> str:
"""Look up a translation key in the current locale with fallback.
If the key is not found in any locale in the fallback chain, the key
itself is returned (useful for debugging missing translations).
Args:
key: Translation key.
**kwargs: Values for ``str.format()`` interpolation in the translated string.
Returns:
The translated (and optionally interpolated) string, or the raw key if not found.
"""
for loc in self._fallback_chain(self._locale):
table = self._translations.get(loc)
if table and key in table:
value = table[key]
if kwargs:
try:
return value.format(**kwargs)
except (KeyError, IndexError, ValueError) as e:
log.warning("Format error for key '%s' locale '%s': %s", key, loc, e)
return value
return value
return key
[docs]
def translate_plural(self, key: str, count: int | float, **kwargs: object) -> str:
"""Look up a plural-aware translation key.
Determines the plural category for *count* in the current locale, then
looks up ``key_<category>`` (e.g. "item_one", "item_other"). Falls back
to ``key_other``, then to the bare *key*.
The ``count`` value is automatically available as ``{count}`` in the
translated string.
Args:
key: Base translation key (e.g. "item").
count: The quantity determining the plural form.
**kwargs: Additional format values.
Returns:
The translated and interpolated plural string.
"""
category = PluralRules.get_plural_form(count, self._locale)
kwargs["count"] = count
# Try key_one, key_other, etc.
plural_key = f"{key}_{category}"
result = self.translate(plural_key, **kwargs)
if result != plural_key:
return result
# Fall back to key_other
other_key = f"{key}_other"
result = self.translate(other_key, **kwargs)
if result != other_key:
return result
# Final fallback: bare key
return self.translate(key, **kwargs)
# ============================================================================
# Module-level convenience function
# ============================================================================
[docs]
def tr(key: str, **kwargs: object) -> str:
"""Translate *key* using the global TranslationServer.
Shorthand for ``TranslationServer.instance().translate(key, **kwargs)``.
Args:
key: Translation key.
**kwargs: Format string interpolation values.
Returns:
Translated string, or the key itself if not found.
"""
return TranslationServer.instance().translate(key, **kwargs)
# ============================================================================
# System locale detection
# ============================================================================
[docs]
def locale_from_system() -> str:
"""Detect the system locale and return a normalised locale code.
Uses ``locale.getdefaultlocale()`` (deprecated but widely available) with
a fallback to ``locale.getlocale()``, then ``LANG`` environment variable.
Returns:
Locale string such as "en", "fr_ca", "ja", or "en" as ultimate fallback.
"""
code = None
try:
code = locale.getlocale()[0]
except (ValueError, AttributeError):
pass
if not code:
import os
lang = os.environ.get("LANG", "")
if lang:
code = lang.split(".")[0]
if not code:
return "en"
return code.replace("-", "_").lower()