Coverage for app/backend/src/couchers/i18n/i18next.py: 89%
133 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
1"""
2Implements localizing strings stored in the i18next json format.
3"""
5import re
6from collections.abc import Mapping
7from dataclasses import dataclass
8from html import escape, unescape
9from typing import Any
11import babel
12from markupsafe import Markup
14PLURALIZABLE_VARIABLE_NAME = "count"
15"""Special variable name for which i18next supports pluralization forms."""
18# A dictionary of values to substitute placeholders with.
19# str: Default case, will be escaped if localizing with markup.
20# int: Supports plurals.
21# Markup: Will be unescaped if localizing without markup.
22SubstitutionDict = Mapping[str, str | int | Markup]
25class I18Next:
26 """Retrieves translated strings from their keys based on the i18next format."""
28 def __init__(self) -> None:
29 self.translations_by_locale: dict[str, Translation] = dict()
31 # The translation used to look up strings in unsupported locales.
32 self.default_translation: Translation | None = None
34 def add_translation(self, locale: str, *, json_dict: dict[str, Any] | None = None) -> Translation:
35 translation = Translation(babel_locale=babel.Locale.parse(locale, sep="-"))
36 self.translations_by_locale[locale] = translation
37 if json_dict:
38 translation.load_json_dict(json_dict)
39 return translation
41 def find_string(self, key: str, locale: str, substitutions: SubstitutionDict | None = None) -> String | None:
42 """Find the string that will be localized, applying fallbacks and variant selection."""
43 translation = self.translations_by_locale.get(locale, self.default_translation)
44 candidate_translations = [translation] + translation.fallbacks if translation else []
45 for candidate_translation in candidate_translations:
46 if string := candidate_translation.find_string(key, substitutions):
47 if string.template.can_render(substitutions):
48 return string
50 raise LocalizationError(locale, key)
52 def localize(self, string_key: str, locale: str, substitutions: SubstitutionDict | None = None) -> str:
53 """Finds a translated string in the best matching locale and performs substitutions."""
54 if string := self.find_string(string_key, locale, substitutions): 54 ↛ 57line 54 didn't jump to line 57 because the condition on line 54 was always true
55 return string.render(substitutions)
56 else:
57 raise LocalizationError(locale, string_key)
59 def localize_with_markup(
60 self, string_key: str, locale: str, substitutions: SubstitutionDict | None = None
61 ) -> Markup:
62 """
63 Finds a translated string that might contain markup in the best matching locale,
64 and performs substitutions in a way that results in a markup-safe string.
65 """
66 if string := self.find_string(string_key, locale, substitutions): 66 ↛ 69line 66 didn't jump to line 69 because the condition on line 66 was always true
67 return string.render_with_markup(substitutions)
68 else:
69 raise LocalizationError(locale, string_key)
72class Translation:
73 """A set of translated strings for a locale."""
75 def __init__(self, *, babel_locale: babel.Locale) -> None:
76 # The Babel library locale for this translation. Used to resolved plural forms.
77 self.babel_locale = babel_locale
78 self.strings_by_key: dict[str, String] = dict()
79 self.fallbacks: list[Translation] = []
81 @property
82 def locale(self) -> str:
83 return str(self.babel_locale).replace("_", "-")
85 def load_json_dict(self, json_dict: Mapping[str, Any]) -> None:
86 def add_strings(json_dict: Mapping[str, Any], key_prefix: str | None) -> None:
87 for k, v in json_dict.items():
88 full_key = f"{key_prefix}.{k}" if key_prefix else k
89 if isinstance(v, str):
90 self.add_string(full_key, v)
91 elif isinstance(v, dict): 91 ↛ 94line 91 didn't jump to line 94 because the condition on line 91 was always true
92 add_strings(v, key_prefix=full_key)
93 else:
94 raise ValueError(f"Unexpected value type in locale JSON: {type(v)}")
96 add_strings(json_dict, key_prefix=None)
98 def add_string(self, key: str, value: str) -> None:
99 # Weblate and i18next consider an empty string as the absence of a translation.
100 if value:
101 self.strings_by_key[key] = String(key, StringTemplate.parse(value))
103 def find_string(self, key: str, substitutions: SubstitutionDict | None = None) -> String | None:
104 # if we have a numerical "count" substitution,
105 # i18next will first search for a key with a suffix
106 # based on the plural category suggested by the count
107 # according to the current locale's rules.
108 if substitutions:
109 if count := substitutions.get(PLURALIZABLE_VARIABLE_NAME):
110 if isinstance(count, int): 110 ↛ 114line 110 didn't jump to line 114 because the condition on line 110 was always true
111 plural_key = key + "_" + self.babel_locale.plural_form(count)
112 if string := self.strings_by_key.get(plural_key):
113 return string
114 return self.strings_by_key.get(key)
116 def localize(self, string_key: str, substitutions: SubstitutionDict | None = None) -> str:
117 string = self.find_string(string_key, substitutions)
118 if string is None:
119 raise LocalizationError(locale=self.locale, string_key=string_key)
120 return string.render(substitutions)
122 def localize_with_markup(self, string_key: str, substitutions: SubstitutionDict | None = None) -> Markup:
123 string = self.find_string(string_key, substitutions)
124 if string is None:
125 raise LocalizationError(locale=self.locale, string_key=string_key)
126 return string.render_with_markup(substitutions)
129@dataclass(frozen=True, slots=True)
130class String:
131 """An i18next string key + template pair."""
133 key: str
134 template: StringTemplate
136 def render(self, substitutions: SubstitutionDict | None) -> str:
137 return self.template.render(substitutions)
139 def render_with_markup(self, substitutions: SubstitutionDict | None) -> Markup:
140 return self.template.render_with_markup(substitutions)
143@dataclass(frozen=True, slots=True)
144class StringTemplate:
145 """A string value which may contain variable placeholders."""
147 segments: list[StringSegment]
149 def can_render(self, substitutions: SubstitutionDict | None) -> bool:
150 for segment in self.segments:
151 if segment.is_variable:
152 if not substitutions or substitutions.get(segment.text) is None:
153 return False
154 return True
156 def render(self, substitutions: SubstitutionDict | None) -> str:
157 return self._render(substitutions, with_markup=False)
159 def render_with_markup(self, substitutions: SubstitutionDict | None) -> Markup:
160 return Markup(self._render(substitutions, with_markup=True))
162 def _render(self, substitutions: SubstitutionDict | None, with_markup: bool) -> str:
163 substrings: list[str] = []
164 for segment in self.segments:
165 if segment.is_variable:
166 if substitutions is not None and segment.text in substitutions: 166 ↛ 178line 166 didn't jump to line 178 because the condition on line 166 was always true
167 value = substitutions[segment.text]
168 if with_markup and not isinstance(value, Markup) and not isinstance(value, int):
169 # Auto-escape since we're producing markup
170 substrings.append(escape(value))
171 elif isinstance(value, Markup) and not with_markup:
172 # We're producing a string where markup is not evaluated
173 # so resolve escape sequences like "
174 substrings.append(unescape(value))
175 else:
176 substrings.append(str(value))
177 else:
178 raise ValueError(f"Missing substitution for variable '{segment.text}'")
179 else:
180 substrings.append(segment.text)
181 return "".join(substrings)
183 @staticmethod
184 def parse(value: str) -> StringTemplate:
185 last_index = 0
186 segments: list[StringSegment] = []
187 for match in re.finditer(r"\{\{\s*([^\}]+?)\s*\}\}", value):
188 if match.start() > last_index:
189 segments.append(StringSegment(text=value[last_index : match.start()], is_variable=False))
190 segments.append(StringSegment(text=match.group(1), is_variable=True))
191 last_index = match.end()
192 if last_index < len(value):
193 segments.append(StringSegment(text=value[last_index:], is_variable=False))
194 return StringTemplate(segments)
197@dataclass(frozen=True, slots=True)
198class StringSegment:
199 """Either a literal text segment or a variable placeholder."""
201 text: str
202 is_variable: bool = False
205class LocalizationError(Exception):
206 """Raised failing to localize a string, e.g. if it is not found in any fallback locale."""
208 def __init__(self, locale: str, string_key: str):
209 self.locale = locale
210 self.string_key = string_key
211 super().__init__(f"Could not localize string {string_key} for locale {locale}")
214def full_string_key(key: str, *, relative_base: str | None) -> str:
215 """Resolves any relative string key (starting with '.') into a full string key."""
216 if key.startswith("."):
217 if relative_base is None:
218 raise ValueError("Relative string key requires a relative base.")
219 return relative_base + key
220 return key