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
« 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
5import babel
7from couchers.i18n.i18next import I18Next
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"
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}
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())
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()
35def to_supported_locale(locale: str) -> str:
36 """Converts a locale to the closest supported one."""
38 if is_supported_locale(locale):
39 return locale
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
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
55 normalized_locale = "-".join(filter(None, [language, territory, script]))
56 if is_supported_locale(normalized_locale):
57 return normalized_locale
59 if is_supported_locale(language):
60 return language
62 return DEFAULT_LOCALE
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]
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="-")
82def load_locales(directory: Path) -> I18Next:
83 """Load all translation files from a locales directory and apply fallbacks."""
85 i18next = I18Next()
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"
91 with open(locale_file, "r", encoding="utf-8") as f:
92 translations = json.load(f)
94 translation = i18next.add_translation(locale)
95 translation.load_json_dict(translations)
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
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])
108 return i18next
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")