Coverage for app/backend/src/couchers/i18n/i18next.py: 88%
130 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-21 21:52 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-21 21:52 +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 def add_translation(self, locale: str, *, json_dict: dict[str, Any] | None = None) -> Translation:
32 translation = Translation(babel_locale=babel.Locale.parse(locale, sep="-"))
33 self.translations_by_locale[locale] = translation
34 if json_dict:
35 translation.load_json_dict(json_dict)
36 return translation
38 def find_string(self, key: str, locales: list[str], substitutions: SubstitutionDict | None = None) -> String | None:
39 """Find the string that will be localized, trying each locale in order."""
40 for locale in locales:
41 if t := self.translations_by_locale.get(locale):
42 if string := t.find_string(key, substitutions):
43 if string.template.can_render(substitutions):
44 return string
46 raise LocalizationError(locales, key)
48 def localize(self, string_key: str, locales: list[str], substitutions: SubstitutionDict | None = None) -> str:
49 """Finds a translated string in the best matching locale and performs substitutions."""
50 if string := self.find_string(string_key, locales, substitutions): 50 ↛ 53line 50 didn't jump to line 53 because the condition on line 50 was always true
51 return string.render(substitutions)
52 else:
53 raise LocalizationError(locales, string_key)
55 def localize_with_markup(
56 self, string_key: str, locales: list[str], substitutions: SubstitutionDict | None = None
57 ) -> Markup:
58 """
59 Finds a translated string that might contain markup in the best matching locale,
60 and performs substitutions in a way that results in a markup-safe string.
61 """
62 if string := self.find_string(string_key, locales, substitutions): 62 ↛ 65line 62 didn't jump to line 65 because the condition on line 62 was always true
63 return string.render_with_markup(substitutions)
64 else:
65 raise LocalizationError(locales, string_key)
68class Translation:
69 """A set of translated strings for a locale."""
71 def __init__(self, *, babel_locale: babel.Locale) -> None:
72 # The Babel library locale for this translation. Used to resolved plural forms.
73 self.babel_locale = babel_locale
74 self.strings_by_key: dict[str, String] = dict()
76 @property
77 def locale(self) -> str:
78 return str(self.babel_locale).replace("_", "-")
80 def load_json_dict(self, json_dict: Mapping[str, Any]) -> None:
81 def add_strings(json_dict: Mapping[str, Any], key_prefix: str | None) -> None:
82 for k, v in json_dict.items():
83 full_key = f"{key_prefix}.{k}" if key_prefix else k
84 if isinstance(v, str):
85 self.add_string(full_key, v)
86 elif isinstance(v, dict): 86 ↛ 89line 86 didn't jump to line 89 because the condition on line 86 was always true
87 add_strings(v, key_prefix=full_key)
88 else:
89 raise ValueError(f"Unexpected value type in locale JSON: {type(v)}")
91 add_strings(json_dict, key_prefix=None)
93 def add_string(self, key: str, value: str) -> None:
94 # Weblate and i18next consider an empty string as the absence of a translation.
95 if value:
96 self.strings_by_key[key] = String(key, StringTemplate.parse(value))
98 def find_string(self, key: str, substitutions: SubstitutionDict | None = None) -> String | None:
99 # if we have a numerical "count" substitution,
100 # i18next will first search for a key with a suffix
101 # based on the plural category suggested by the count
102 # according to the current locale's rules.
103 if substitutions:
104 if count := substitutions.get(PLURALIZABLE_VARIABLE_NAME):
105 if isinstance(count, int): 105 ↛ 109line 105 didn't jump to line 109 because the condition on line 105 was always true
106 plural_key = key + "_" + self.babel_locale.plural_form(count)
107 if string := self.strings_by_key.get(plural_key):
108 return string
109 return self.strings_by_key.get(key)
111 def localize(self, string_key: str, substitutions: SubstitutionDict | None = None) -> str:
112 string = self.find_string(string_key, substitutions)
113 if string is None:
114 raise LocalizationError(locales=[self.locale], string_key=string_key)
115 return string.render(substitutions)
117 def localize_with_markup(self, string_key: str, substitutions: SubstitutionDict | None = None) -> Markup:
118 string = self.find_string(string_key, substitutions)
119 if string is None:
120 raise LocalizationError(locales=[self.locale], string_key=string_key)
121 return string.render_with_markup(substitutions)
124@dataclass(frozen=True, slots=True)
125class String:
126 """An i18next string key + template pair."""
128 key: str
129 template: StringTemplate
131 def render(self, substitutions: SubstitutionDict | None) -> str:
132 return self.template.render(substitutions)
134 def render_with_markup(self, substitutions: SubstitutionDict | None) -> Markup:
135 return self.template.render_with_markup(substitutions)
138@dataclass(frozen=True, slots=True)
139class StringTemplate:
140 """A string value which may contain variable placeholders."""
142 segments: list[StringSegment]
144 def can_render(self, substitutions: SubstitutionDict | None) -> bool:
145 for segment in self.segments:
146 if segment.is_variable:
147 if not substitutions or substitutions.get(segment.text) is None:
148 return False
149 return True
151 def render(self, substitutions: SubstitutionDict | None) -> str:
152 return self._render(substitutions, with_markup=False)
154 def render_with_markup(self, substitutions: SubstitutionDict | None) -> Markup:
155 return Markup(self._render(substitutions, with_markup=True))
157 def _render(self, substitutions: SubstitutionDict | None, with_markup: bool) -> str:
158 substrings: list[str] = []
159 for segment in self.segments:
160 if segment.is_variable:
161 if substitutions is not None and segment.text in substitutions: 161 ↛ 173line 161 didn't jump to line 173 because the condition on line 161 was always true
162 value = substitutions[segment.text]
163 if with_markup and not isinstance(value, Markup) and not isinstance(value, int):
164 # Auto-escape since we're producing markup
165 substrings.append(escape(value))
166 elif isinstance(value, Markup) and not with_markup:
167 # We're producing a string where markup is not evaluated
168 # so resolve escape sequences like "
169 substrings.append(unescape(value))
170 else:
171 substrings.append(str(value))
172 else:
173 raise ValueError(f"Missing substitution for variable '{segment.text}'")
174 else:
175 substrings.append(segment.text)
176 return "".join(substrings)
178 @staticmethod
179 def parse(value: str) -> StringTemplate:
180 last_index = 0
181 segments: list[StringSegment] = []
182 for match in re.finditer(r"\{\{\s*([^\}]+?)\s*\}\}", value):
183 if match.start() > last_index:
184 segments.append(StringSegment(text=value[last_index : match.start()], is_variable=False))
185 segments.append(StringSegment(text=match.group(1), is_variable=True))
186 last_index = match.end()
187 if last_index < len(value):
188 segments.append(StringSegment(text=value[last_index:], is_variable=False))
189 return StringTemplate(segments)
192@dataclass(frozen=True, slots=True)
193class StringSegment:
194 """Either a literal text segment or a variable placeholder."""
196 text: str
197 is_variable: bool = False
200class LocalizationError(Exception):
201 """Raised failing to localize a string, e.g. if it is not found in any of the given locales."""
203 def __init__(self, locales: list[str], string_key: str):
204 self.locales = locales
205 self.string_key = string_key
206 super().__init__(f"Could not localize string {string_key} for locales {locales}")
209def full_string_key(key: str, *, relative_base: str | None) -> str:
210 """Resolves any relative string key (starting with '.') into a full string key."""
211 if key.startswith("."):
212 if relative_base is None:
213 raise ValueError("Relative string key requires a relative base.")
214 return relative_base + key
215 return key