107 lines
3.3 KiB
Python
107 lines
3.3 KiB
Python
"""Translation helpers and language-aware mixins."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Dict
|
|
|
|
try: # Python 3.11+
|
|
import tomllib # type: ignore[attr-defined]
|
|
except ModuleNotFoundError: # pragma: no cover - fallback
|
|
with contextlib.suppress(ModuleNotFoundError):
|
|
import tomli as tomllib # type: ignore[assignment]
|
|
if "tomllib" not in globals():
|
|
tomllib = None # type: ignore[assignment]
|
|
|
|
|
|
LANG_DIR = Path(__file__).resolve().parent / "lang"
|
|
FALLBACK_LANGUAGE = "en"
|
|
|
|
|
|
def _available_language_files() -> Dict[str, Path]:
|
|
files: Dict[str, Path] = {}
|
|
if LANG_DIR.exists():
|
|
for path in LANG_DIR.glob("*.toml"):
|
|
files[path.stem.lower()] = path
|
|
return files
|
|
|
|
|
|
def _load_translations(lang: str) -> Dict[str, str]:
|
|
if tomllib is None:
|
|
return {}
|
|
lang_files = _available_language_files()
|
|
path = lang_files.get(lang.lower())
|
|
if path is None:
|
|
return {}
|
|
try:
|
|
with path.open("rb") as handle:
|
|
data = tomllib.load(handle)
|
|
except (OSError, AttributeError, ValueError, TypeError): # type: ignore[arg-type]
|
|
return {}
|
|
translations = data.get("translations")
|
|
if not isinstance(translations, dict):
|
|
return {}
|
|
out: Dict[str, str] = {}
|
|
for key, value in translations.items():
|
|
if isinstance(key, str) and isinstance(value, str):
|
|
out[key] = value
|
|
return out
|
|
|
|
|
|
@dataclass
|
|
class Translator:
|
|
"""Simple lookup-based translator with file-backed dictionaries."""
|
|
|
|
language: str = FALLBACK_LANGUAGE
|
|
_translations: Dict[str, str] = field(default_factory=dict, init=False)
|
|
_fallback: Dict[str, str] = field(default_factory=dict, init=False)
|
|
|
|
def __post_init__(self) -> None:
|
|
self._fallback = _load_translations(FALLBACK_LANGUAGE)
|
|
self.set_language(self.language)
|
|
|
|
def set_language(self, language: str) -> None:
|
|
chosen = language.lower()
|
|
data = _load_translations(chosen)
|
|
if not data:
|
|
chosen = FALLBACK_LANGUAGE
|
|
data = _load_translations(FALLBACK_LANGUAGE)
|
|
self.language = chosen
|
|
self._translations = data or {}
|
|
|
|
def translate(self, key: str, **values: Any) -> str:
|
|
template = self._translations.get(key) or self._fallback.get(key) or key
|
|
if values:
|
|
try:
|
|
return template.format(**values)
|
|
except (KeyError, ValueError):
|
|
return template
|
|
return template
|
|
|
|
|
|
class I18nMixin:
|
|
"""Mixin providing translated text helpers."""
|
|
|
|
language: str
|
|
translator: Translator
|
|
|
|
def init_i18n(self, language: str | None = None) -> None:
|
|
self.translator = Translator()
|
|
self.set_language(language or FALLBACK_LANGUAGE)
|
|
|
|
def set_language(self, language: str) -> None:
|
|
self.translator.set_language(language)
|
|
self.language = self.translator.language
|
|
|
|
def _t(self, key: str, **values: Any) -> str:
|
|
return self.translator.translate(key, **values)
|
|
|
|
@property
|
|
def available_languages(self) -> tuple[str, ...]:
|
|
return tuple(sorted(_available_language_files().keys()))
|
|
|
|
|
|
__all__ = ["I18nMixin", "Translator", "LANG_DIR", "FALLBACK_LANGUAGE"]
|