Source code for simvx.core.i18n

"""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. """
[docs] @staticmethod def get_plural_form(n: int | float, locale_code: str = "en") -> str: """Return the plural category for *n* in the given locale. Args: n: The count value (typically a non-negative number). locale_code: Base locale code (e.g. "en", "fr", "ar"). Region suffixes are stripped. Returns: One of "zero", "one", "two", "few", "many", "other". """ base = locale_code.split("_")[0].split("-")[0].lower() rule_fn = _PLURAL_RULES.get(base, _plural_other) return rule_fn(n)
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()