Coverage for src/couchers/i18n/i18n.py: 95%
60 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-18 14:01 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-18 14:01 +0000
1import json
2from functools import lru_cache
3from pathlib import Path
5from couchers.i18n.constants import DEFAULT_FALLBACK, LANGUAGE_FALLBACKS
8class MissingTranslationError(Exception):
9 """Raised when a translation template is not found in any fallback language."""
11 def __init__(self, lang: str, component: str, string_name: str):
12 self.lang = lang
13 self.component = component
14 self.string_name = string_name
15 super().__init__(f"Missing translation: {lang}.{component}.{string_name}")
18@lru_cache(maxsize=1)
19def get_translations() -> dict[str, dict[str, dict[str, str]]]:
20 """
21 Load all translation files from the locales directory and apply fallbacks.
22 Each locale JSON file contains components as top-level keys that map to
23 dictionaries of string translations. Fallbacks are prebaked so every language
24 has complete coverage using English as the base and applying fallbacks in the
25 correct precedence order.
27 Returns:
28 Dictionary structure: lang -> component -> (string -> translated string)
30 The result is cached so that translations are only loaded once per process.
31 """
32 all_langs_all_strings: dict[str, dict[str, dict[str, str]]] = {}
34 locales_dir = Path(__file__).parent / "locales"
36 # Load all locale JSON files from the locales directory
37 for locale_file in locales_dir.glob("*.json"):
38 lang = locale_file.stem # e.g., "en" from "en.json"
40 with open(locale_file, "r", encoding="utf-8") as f:
41 translations = json.load(f)
43 # Initialize the language dictionary if needed
44 if lang not in all_langs_all_strings:
45 all_langs_all_strings[lang] = {}
47 # Each top-level key in the JSON file is a component
48 for component_name, component_translations in translations.items():
49 if component_name not in all_langs_all_strings[lang]:
50 all_langs_all_strings[lang][component_name] = {}
52 # Store the translations for this component
53 all_langs_all_strings[lang][component_name] = component_translations
55 # Apply fallbacks: English is our source of truth - must exist
56 if "en" not in all_langs_all_strings:
57 raise RuntimeError("English translations must be loaded")
59 en_strings = all_langs_all_strings["en"]
61 # Get all languages we need to process (loaded languages + those in fallback config)
62 all_languages = set(all_langs_all_strings.keys()) | set(LANGUAGE_FALLBACKS.keys())
64 for lang in all_languages:
65 if lang == "en":
66 continue # English is already complete
68 # Start with a complete copy of English as the base
69 lang_strings = {}
70 for component in en_strings:
71 lang_strings[component] = en_strings[component].copy()
73 # Get fallback chain for this language
74 fallback_chain = LANGUAGE_FALLBACKS.get(lang, DEFAULT_FALLBACK)
76 # Apply fallbacks in reverse order (so more specific overrides less specific)
77 # For pt-BR with fallbacks ["pt", "en"]: Apply "en" (already done) → then "pt"
78 for fallback_lang in reversed(fallback_chain):
79 if fallback_lang in all_langs_all_strings:
80 for component in all_langs_all_strings[fallback_lang]:
81 if component not in lang_strings:
82 lang_strings[component] = {}
83 lang_strings[component].update(all_langs_all_strings[fallback_lang][component])
85 # Finally, apply the language's own translations (highest priority)
86 if lang in all_langs_all_strings:
87 for component in all_langs_all_strings[lang]:
88 if component not in lang_strings:
89 lang_strings[component] = {}
90 lang_strings[component].update(all_langs_all_strings[lang][component])
92 # Replace the language entry with the complete version
93 all_langs_all_strings[lang] = lang_strings
95 return all_langs_all_strings
98def get_raw_translation_string(
99 lang: str | None, component: str, string_name: str, *, substitutions: dict[str, str] | None = None
100) -> str:
101 """
102 Retrieves a translated string from the all_langs_all_strings dictionary
103 and performs variable substitutions. Fallbacks have been prebaked during
104 module initialization, so this is now a simple lookup.
106 Args:
107 lang: Language code (e.g., "en", "pt-BR"). If None, defaults to the default fallback language ("en")
108 component: Component name (e.g., "errors")
109 string_name: The key for the specific string
110 substitutions: Dictionary of variable substitutions for the string (e.g., {"hours": "24"})
112 Returns:
113 The translated string with substitutions applied
114 """
115 # Get translations (cached)
116 all_langs_all_strings = get_translations()
118 # Use default fallback language if lang is None or doesn't exist
119 if lang is None or lang not in all_langs_all_strings:
120 lang = "en"
122 # Direct lookup (fallbacks already applied during initialization)
123 try:
124 template = all_langs_all_strings[lang][component][string_name]
125 except KeyError as e:
126 raise MissingTranslationError(lang, component, string_name) from e
128 # Perform substitutions by replacing {{key}} with the corresponding value
129 if substitutions:
130 for key, value in substitutions.items():
131 template = template.replace(f"{{{{{key}}}}}", str(value))
133 return template