Coverage for app/backend/src/couchers/i18n/locales.py: 96%

64 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-21 09:29 +0000

1import json 

2from collections.abc import Callable 

3from functools import lru_cache 

4from pathlib import Path 

5 

6import babel 

7 

8from couchers.i18n.i18next import I18Next 

9 

10# The default locale if a language or string is unavailable. 

11# Note: "en" is a valid locale even if it doesn't include a region. 

12DEFAULT_LOCALE = "en" 

13 

14# Locale fallbacks (for those that don't fallback to English). 

15# If a string is not found in the requested language, we try the provided one before English 

16# Some mutually intelligible language variants fallback to each other. 

17_LOCALE_FALLBACKS: dict[str, str] = { 

18 "pt-BR": "pt", 

19 "pt": "pt-BR", 

20 "es-419": "es", 

21 "es": "es-419", 

22 "fr-CA": "fr", 

23} 

24 

25 

26def get_supported_locales() -> list[str]: 

27 """Gets the list of supported locales (i.e., for which we have translations).""" 

28 return list(get_main_i18next().translations_by_locale.keys()) 

29 

30 

31def is_supported_locale(locale: str) -> bool: 

32 """Checks if a locale is supported (i.e., if we have translations for it).""" 

33 return locale in get_main_i18next().translations_by_locale.keys() 

34 

35 

36def to_supported_locale(locale: str) -> str: 

37 """Converts a locale to the closest supported one.""" 

38 

39 if is_supported_locale(locale): 

40 return locale 

41 

42 # Normalize casing in case that's why we don't have a match (e.g., "en-us" vs "en-US") 

43 try: 

44 # Locale.parse returns either a 4-tuple or a 5-tuple 

45 result_tuple = babel.parse_locale(locale, sep="-") 

46 if len(result_tuple) == 4: 46 ↛ 48line 46 didn't jump to line 48 because the condition on line 46 was always true

47 result = (*result_tuple, None) # Normalize to 5-tuple for unpacking 

48 language, territory, script, _, _ = result 

49 except ValueError: 

50 return DEFAULT_LOCALE 

51 

52 language = language.lower() 

53 territory = territory.upper() if territory else None # pt-BR, fr-CA 

54 script = script.title() if script else None # zh-Hans, zh-Hant 

55 

56 normalized_locale = "-".join(filter(None, [language, territory, script])) 

57 if is_supported_locale(normalized_locale): 

58 return normalized_locale 

59 

60 if is_supported_locale(language): 

61 return language 

62 

63 return DEFAULT_LOCALE 

64 

65 

66def get_locale_fallbacks(locale: str) -> list[str]: 

67 """Gets the list of locales to which to fallback to if the given one is unavailable.""" 

68 if fallback := _LOCALE_FALLBACKS.get(locale): 

69 return [fallback, DEFAULT_LOCALE] 

70 if locale == DEFAULT_LOCALE: 

71 return [] 

72 return [DEFAULT_LOCALE] 

73 

74 

75def get_babel_locale(locale: str) -> babel.Locale: 

76 """ 

77 Returns the babel locale object for a given locale string. 

78 Guaranteed by tests to succeed for supported locales. 

79 """ 

80 return babel.Locale.parse(locale, sep="-") 

81 

82 

83def load_locales(directory: Path) -> I18Next: 

84 """Load all translation files from a locales directory and apply fallbacks.""" 

85 

86 i18next = I18Next() 

87 

88 # Load all locale JSON files from the locales directory 

89 for locale_file in directory.glob("*.json"): 

90 locale = locale_file.stem # e.g., "en" from "en.json" 

91 

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

93 translations = json.load(f) 

94 

95 translation = i18next.add_translation(locale) 

96 translation.load_json_dict(translations) 

97 

98 # English is our default for undefined languages 

99 default_translation = i18next.translations_by_locale.get(DEFAULT_LOCALE) 

100 if default_translation is None: 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true

101 raise RuntimeError("English translations must be loaded") 

102 i18next.default_translation = default_translation 

103 

104 # Apply fallbacks 

105 for translation in i18next.translations_by_locale.values(): 

106 for fallback_locale in get_locale_fallbacks(translation.locale): 

107 translation.fallbacks.append(i18next.translations_by_locale[fallback_locale]) 

108 

109 return i18next 

110 

111 

112@lru_cache(maxsize=1) 

113def get_main_i18next() -> I18Next: 

114 """Gets the I18Next instance for the main locales files.""" 

115 return load_locales(Path(__file__).parent / "locales") 

116 

117 

118@lru_cache(maxsize=1) 

119def get_admin_i18next() -> I18Next: 

120 """Gets the I18Next instance for the admin/editor locales files (English only).""" 

121 return load_locales(Path(__file__).parent / "admin_locales") 

122 

123 

124# Maps a translation component name to the I18Next instance that holds its strings. Servicers select 

125# the component when localizing (e.g. admin/editor errors live in their own English-only component). 

126_TRANSLATION_COMPONENTS: dict[str, Callable[[], I18Next]] = { 

127 "main": get_main_i18next, 

128 "admin": get_admin_i18next, 

129} 

130 

131 

132def get_translation_component(component: str) -> I18Next: 

133 """Gets the I18Next instance for a named translation component.""" 

134 return _TRANSLATION_COMPONENTS[component]()