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

1import json 

2from functools import lru_cache 

3from pathlib import Path 

4 

5from couchers.i18n.constants import DEFAULT_FALLBACK, LANGUAGE_FALLBACKS 

6 

7 

8class MissingTranslationError(Exception): 

9 """Raised when a translation template is not found in any fallback language.""" 

10 

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}") 

16 

17 

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. 

26 

27 Returns: 

28 Dictionary structure: lang -> component -> (string -> translated string) 

29 

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]]] = {} 

33 

34 locales_dir = Path(__file__).parent / "locales" 

35 

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" 

39 

40 with open(locale_file, "r", encoding="utf-8") as f: 

41 translations = json.load(f) 

42 

43 # Initialize the language dictionary if needed 

44 if lang not in all_langs_all_strings: 

45 all_langs_all_strings[lang] = {} 

46 

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] = {} 

51 

52 # Store the translations for this component 

53 all_langs_all_strings[lang][component_name] = component_translations 

54 

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") 

58 

59 en_strings = all_langs_all_strings["en"] 

60 

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()) 

63 

64 for lang in all_languages: 

65 if lang == "en": 

66 continue # English is already complete 

67 

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() 

72 

73 # Get fallback chain for this language 

74 fallback_chain = LANGUAGE_FALLBACKS.get(lang, DEFAULT_FALLBACK) 

75 

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]) 

84 

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]) 

91 

92 # Replace the language entry with the complete version 

93 all_langs_all_strings[lang] = lang_strings 

94 

95 return all_langs_all_strings 

96 

97 

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. 

105 

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"}) 

111 

112 Returns: 

113 The translated string with substitutions applied 

114 """ 

115 # Get translations (cached) 

116 all_langs_all_strings = get_translations() 

117 

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" 

121 

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 

127 

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)) 

132 

133 return template