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
« 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
6import babel
8from couchers.i18n.i18next import I18Next
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"
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}
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())
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()
36def to_supported_locale(locale: str) -> str:
37 """Converts a locale to the closest supported one."""
39 if is_supported_locale(locale):
40 return locale
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
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
56 normalized_locale = "-".join(filter(None, [language, territory, script]))
57 if is_supported_locale(normalized_locale):
58 return normalized_locale
60 if is_supported_locale(language):
61 return language
63 return DEFAULT_LOCALE
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]
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="-")
83def load_locales(directory: Path) -> I18Next:
84 """Load all translation files from a locales directory and apply fallbacks."""
86 i18next = I18Next()
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"
92 with open(locale_file, "r", encoding="utf-8") as f:
93 translations = json.load(f)
95 translation = i18next.add_translation(locale)
96 translation.load_json_dict(translations)
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
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])
109 return i18next
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")
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")
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}
132def get_translation_component(component: str) -> I18Next:
133 """Gets the I18Next instance for a named translation component."""
134 return _TRANSLATION_COMPONENTS[component]()