From 38e78c1d6d5844055fe819eb598bf6e57bc32ccc Mon Sep 17 00:00:00 2001 From: PtiCalin <143633151+PtiCalin@users.noreply.github.com> Date: Sat, 12 Jul 2025 22:36:19 -0400 Subject: [PATCH] Add LocaleManager with YAML translations --- engine/locale_manager.py | 105 +++++++++++++++++++++++++++++++++++ locales/en.yaml | 9 +++ locales/fr.yaml | 9 +++ tests/test_locale_manager.py | 69 +++++++++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 engine/locale_manager.py create mode 100644 locales/en.yaml create mode 100644 locales/fr.yaml create mode 100644 tests/test_locale_manager.py diff --git a/engine/locale_manager.py b/engine/locale_manager.py new file mode 100644 index 0000000..026a6f7 --- /dev/null +++ b/engine/locale_manager.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Set, List +import os + +try: # pragma: no cover - allow tests without PyYAML + import yaml # type: ignore +except Exception: # pragma: no cover - fallback when PyYAML missing + yaml = None + + +@dataclass +class LocaleManager: + """Manage text translations and current language.""" + + current_locale: str = "en" + translations: Dict[str, Dict[str, str]] = field(default_factory=dict) + fallback_locale: str = "en" + locales_dir: str = "locales/" + missing_keys: Set[str] = field(default_factory=set) + + # ------------------------------------------------------------------ + # Loading helpers + # ------------------------------------------------------------------ + def load_locales(self, path: str) -> None: + """Load all ``*.yaml`` files from ``path`` into ``translations``.""" + if not os.path.isdir(path): + return + self.locales_dir = path + for fname in os.listdir(path): + if not fname.endswith(".yaml"): + continue + locale_code = os.path.splitext(fname)[0] + fpath = os.path.join(path, fname) + with open(fpath, "r", encoding="utf-8") as fh: + if yaml: + data = yaml.safe_load(fh) or {} + else: # pragma: no cover - fallback when PyYAML missing + import json + + data = json.load(fh) + flat: Dict[str, str] = {} + self._flatten(data, flat) + self.translations[locale_code] = flat + + def _flatten(self, data: Dict, out: Dict[str, str], prefix: str = "") -> None: + for key, value in (data or {}).items(): + compound = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + self._flatten(value, out, compound) + else: + out[compound] = str(value) + + # ------------------------------------------------------------------ + # Locale helpers + # ------------------------------------------------------------------ + def set_locale(self, locale_code: str) -> None: + """Switch the active locale to ``locale_code``.""" + self.current_locale = locale_code + + def _locale_chain(self, locale_code: str) -> List[str]: + chain = [locale_code] + if "-" in locale_code: + base = locale_code.split("-")[0] + if base not in chain: + chain.append(base) + if self.fallback_locale not in chain: + chain.append(self.fallback_locale) + return chain + + def translate(self, key: str) -> str: + """Return the translated string for ``key``.""" + for loc in self._locale_chain(self.current_locale): + entry = self.translations.get(loc, {}).get(key) + if entry is not None: + return entry + self.log_missing_key(key) + return key + + def has_translation(self, key: str) -> bool: + """Return ``True`` if ``key`` exists in the current locale chain.""" + for loc in self._locale_chain(self.current_locale): + if key in self.translations.get(loc, {}): + return True + return False + + def get_available_locales(self) -> List[str]: + """Return all loaded locale codes.""" + return sorted(self.translations.keys()) + + # ------------------------------------------------------------------ + # Missing key helpers + # ------------------------------------------------------------------ + def log_missing_key(self, key: str) -> None: + self.missing_keys.add(key) + + def export_missing_keys(self, file_path: str) -> None: + if not self.missing_keys: + return + os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True) + with open(file_path, "w", encoding="utf-8") as fh: + for key in sorted(self.missing_keys): + fh.write(key + "\n") + diff --git a/locales/en.yaml b/locales/en.yaml new file mode 100644 index 0000000..094e563 --- /dev/null +++ b/locales/en.yaml @@ -0,0 +1,9 @@ +ui: + start_game: "Start Game" + continue: "Continue" + settings: "Settings" + exit: "Exit" + +dialogue: + intro_line1: "Welcome to Dreamspace." + intro_line2: "Time folds inward here." diff --git a/locales/fr.yaml b/locales/fr.yaml new file mode 100644 index 0000000..b3ff592 --- /dev/null +++ b/locales/fr.yaml @@ -0,0 +1,9 @@ +ui: + start_game: "Commencer" + continue: "Continuer" + settings: "Paramètres" + exit: "Quitter" + +dialogue: + intro_line1: "Bienvenue dans l’Espace-Rêve." + intro_line2: "Ici, le temps se replie sur lui-même." diff --git a/tests/test_locale_manager.py b/tests/test_locale_manager.py new file mode 100644 index 0000000..43f2421 --- /dev/null +++ b/tests/test_locale_manager.py @@ -0,0 +1,69 @@ +import os +import sys +import pytest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +yaml = pytest.importorskip("yaml") + +from engine.locale_manager import LocaleManager + + +def _write_locale(path, content): + with open(path, "w", encoding="utf-8") as fh: + fh.write(content) + + +def test_load_and_translate(tmp_path): + loc_dir = tmp_path / "locales" + loc_dir.mkdir() + _write_locale( + loc_dir / "en.yaml", + """ +ui: + start_game: "Start Game" +dialogue: + hello: "Hello" +""", + ) + _write_locale( + loc_dir / "fr.yaml", + """ +ui: + start_game: "Commencer" +""", + ) + + lm = LocaleManager() + lm.load_locales(str(loc_dir)) + + assert set(lm.get_available_locales()) == {"en", "fr"} + assert lm.translate("ui.start_game") == "Start Game" + + lm.set_locale("fr") + assert lm.translate("ui.start_game") == "Commencer" + # fallback to en + assert lm.translate("dialogue.hello") == "Hello" + + # missing key + assert lm.translate("missing.key") == "missing.key" + assert "missing.key" in lm.missing_keys + + out_file = tmp_path / "missing.txt" + lm.export_missing_keys(str(out_file)) + assert out_file.read_text().strip() == "missing.key" + + +def test_locale_fallback_chain(tmp_path): + loc_dir = tmp_path / "locales" + loc_dir.mkdir() + _write_locale(loc_dir / "en.yaml", "greet: Hi") + _write_locale(loc_dir / "fr.yaml", "greet: Salut") + + lm = LocaleManager() + lm.load_locales(str(loc_dir)) + lm.set_locale("fr-CA") + + assert lm.translate("greet") == "Salut" + assert lm.has_translation("greet") is True +