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

57 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-14 11:44 +0000

1import json 

2from functools import lru_cache 

3from pathlib import Path 

4 

5import babel 

6 

7from couchers.i18n.i18next import I18Next 

8 

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

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

11DEFAULT_LOCALE = "en" 

12 

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

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

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

16_LOCALE_FALLBACKS: dict[str, str] = { 

17 "pt-BR": "pt", 

18 "pt": "pt-BR", 

19 "es-419": "es", 

20 "es": "es-419", 

21 "fr-CA": "fr", 

22} 

23 

24 

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

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

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

28 

29 

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

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

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

33 

34 

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

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

37 

38 if is_supported_locale(locale): 

39 return locale 

40 

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

42 try: 

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

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

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

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

47 language, territory, script, _, _ = result 

48 except ValueError: 

49 return DEFAULT_LOCALE 

50 

51 language = language.lower() 

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

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

54 

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

56 if is_supported_locale(normalized_locale): 

57 return normalized_locale 

58 

59 if is_supported_locale(language): 

60 return language 

61 

62 return DEFAULT_LOCALE 

63 

64 

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

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

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

68 return [fallback, DEFAULT_LOCALE] 

69 if locale == DEFAULT_LOCALE: 

70 return [] 

71 return [DEFAULT_LOCALE] 

72 

73 

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

75 """ 

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

77 Guaranteed by tests to succeed for supported locales. 

78 """ 

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

80 

81 

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

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

84 

85 i18next = I18Next() 

86 

87 # Load all locale JSON files from the locales directory 

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

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

90 

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

92 translations = json.load(f) 

93 

94 translation = i18next.add_translation(locale) 

95 translation.load_json_dict(translations) 

96 

97 # English is our default for undefined languages 

98 default_translation = i18next.translations_by_locale.get(DEFAULT_LOCALE) 

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

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

101 i18next.default_translation = default_translation 

102 

103 # Apply fallbacks 

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

105 for fallback_locale in get_locale_fallbacks(translation.locale): 

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

107 

108 return i18next 

109 

110 

111@lru_cache(maxsize=1) 

112def get_main_i18next() -> I18Next: 

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

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