Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions engine/locale_manager.py
Original file line number Diff line number Diff line change
@@ -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")

9 changes: 9 additions & 0 deletions locales/en.yaml
Original file line number Diff line number Diff line change
@@ -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."
9 changes: 9 additions & 0 deletions locales/fr.yaml
Original file line number Diff line number Diff line change
@@ -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."
69 changes: 69 additions & 0 deletions tests/test_locale_manager.py
Original file line number Diff line number Diff line change
@@ -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

Loading