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

65 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-04 22:32 +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# Locales that we support for regional formatting, 

15# but don't have dedicated translations. 

16NON_TRANSLATED_LOCALES: list[str] = ["en-US"] 

17 

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

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

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

21_LOCALE_FALLBACKS: dict[str, str] = {"pt-BR": "pt", "pt": "pt-BR", "es-419": "es", "es": "es-419"} 

22 

23 

24def get_locales_with_translations() -> list[str]: 

25 """Gets the list of locales which have translations.""" 

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

27 

28 

29def is_locale_with_translations(locale: str) -> bool: 

30 """Checks if we have translations for a given locale.""" 

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

32 

33 

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

35 """Gets the list of locales supported.""" 

36 return get_locales_with_translations() + NON_TRANSLATED_LOCALES 

37 

38 

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

40 """Checks if a locale is supported.""" 

41 return is_locale_with_translations(locale) or locale in NON_TRANSLATED_LOCALES 

42 

43 

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

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

46 

47 if is_supported_locale(locale): 

48 return locale 

49 

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

51 try: 

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

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

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

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

56 language, territory, script, _, _ = result 

57 except ValueError: 

58 return DEFAULT_LOCALE 

59 

60 language = language.lower() 

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

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

63 

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

65 if is_supported_locale(normalized_locale): 

66 return normalized_locale 

67 

68 if is_supported_locale(language): 

69 return language 

70 

71 return DEFAULT_LOCALE 

72 

73 

74def get_locale_chain(locale: str) -> list[str]: 

75 """Gets the ordered list of locales to try when looking up a string, starting with the given locale.""" 

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

77 return [locale, fallback, DEFAULT_LOCALE] 

78 if locale == DEFAULT_LOCALE: 

79 return [locale] 

80 return [locale, DEFAULT_LOCALE] 

81 

82 

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

84 """ 

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

86 Guaranteed by tests to succeed for supported locales. 

87 """ 

88 # TODO(#9184): Once we have en-US available, "en" should return the babel locale for "en-001" (Global English) 

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

90 

91 

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

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

94 

95 i18next = I18Next() 

96 

97 # Load all locale JSON files from the locales directory 

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

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

100 

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

102 translations = json.load(f) 

103 

104 translation = i18next.add_translation(locale) 

105 translation.load_json_dict(translations) 

106 

107 # English is our default for undefined languages 

108 default_translation = i18next.translations_by_locale.get(DEFAULT_LOCALE) 

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

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

111 

112 return i18next 

113 

114 

115@lru_cache(maxsize=1) 

116def get_main_i18next() -> I18Next: 

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

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

119 

120 

121@lru_cache(maxsize=1) 

122def get_admin_i18next() -> I18Next: 

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

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

125 

126 

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

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

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

130 "main": get_main_i18next, 

131 "admin": get_admin_i18next, 

132} 

133 

134 

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

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

137 return _TRANSLATION_COMPONENTS[component]()