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
« 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
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# Locales that we support for regional formatting,
15# but don't have dedicated translations.
16NON_TRANSLATED_LOCALES: list[str] = ["en-US"]
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"}
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())
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()
34def get_supported_locales() -> list[str]:
35 """Gets the list of locales supported."""
36 return get_locales_with_translations() + NON_TRANSLATED_LOCALES
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
44def to_supported_locale(locale: str) -> str:
45 """Converts a locale to the closest supported one."""
47 if is_supported_locale(locale):
48 return locale
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
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
64 normalized_locale = "-".join(filter(None, [language, territory, script]))
65 if is_supported_locale(normalized_locale):
66 return normalized_locale
68 if is_supported_locale(language):
69 return language
71 return DEFAULT_LOCALE
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]
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="-")
92def load_locales(directory: Path) -> I18Next:
93 """Load all translation files from a locales directory and apply fallbacks."""
95 i18next = I18Next()
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"
101 with open(locale_file, "r", encoding="utf-8") as f:
102 translations = json.load(f)
104 translation = i18next.add_translation(locale)
105 translation.load_json_dict(translations)
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")
112 return i18next
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")
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")
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}
135def get_translation_component(component: str) -> I18Next:
136 """Gets the I18Next instance for a named translation component."""
137 return _TRANSLATION_COMPONENTS[component]()