Coverage for app / backend / src / couchers / i18n / i18next.py: 89%
129 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
1"""
2Implements localizing strings stored in the i18next json format.
3"""
5import re
6from collections.abc import Mapping
7from dataclasses import dataclass, field
8from html import escape, unescape
9from typing import Any
11from babel import Locale, UnknownLocaleError
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]
25@dataclass
26class I18Next:
27 """Retrieves translated strings from their keys based on the i18next format."""
29 translations_by_locale: dict[str, Translation] = field(default_factory=dict)
30 default_translation: Translation | None = None
31 """The translation used to look up strings in unsupported locales."""
33 def add_translation(self, locale: str, *, json_dict: dict[str, Any] | None = None) -> Translation:
34 translation = Translation(locale)
35 self.translations_by_locale[locale] = translation
36 if json_dict:
37 translation.load_json_dict(json_dict)
38 return translation
40 def find_string(self, key: str, locale: str, substitutions: SubstitutionDict | None = None) -> String | None:
41 """Find the string that will be localized, applying fallbacks and variant selection."""
42 translation = self.translations_by_locale.get(locale, self.default_translation)
43 candidate_translations = [translation] + translation.fallbacks if translation else []
44 for candidate_translation in candidate_translations:
45 if string := candidate_translation.find_string(key, substitutions):
46 if string.template.can_render(substitutions):
47 return string
49 raise LocalizationError(locale, key)
51 def localize(self, string_key: str, locale: str, substitutions: SubstitutionDict | None = None) -> str:
52 """Finds a translated string in the best matching locale and performs substitutions."""
53 if string := self.find_string(string_key, locale, substitutions): 53 ↛ 56line 53 didn't jump to line 56 because the condition on line 53 was always true
54 return string.render(substitutions)
55 else:
56 raise LocalizationError(locale, string_key)
58 def localize_with_markup(
59 self, string_key: str, locale: str, substitutions: SubstitutionDict | None = None
60 ) -> Markup:
61 """
62 Finds a translated string that might contain markup in the best matching locale,
63 and performs substitutions in a way that results in a markup-safe string.
64 """
65 if string := self.find_string(string_key, locale, substitutions): 65 ↛ 68line 65 didn't jump to line 68 because the condition on line 65 was always true
66 return string.render_with_markup(substitutions)
67 else:
68 raise LocalizationError(locale, string_key)
71@dataclass
72class Translation:
73 """A set of translated strings for a locale."""
75 locale: str
76 """The locale, e.g. 'en' or 'fr-CA'"""
77 strings_by_key: dict[str, String] = field(default_factory=dict)
78 fallbacks: list[Translation] = field(default_factory=list)
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 ↛ 114line 105 didn't jump to line 114 because the condition on line 105 was always true
106 try:
107 plural_form = Locale(self.locale).plural_form(count)
108 except UnknownLocaleError:
109 # Fallback to English-style plural rule.
110 plural_form = "one" if 1 else "other"
111 plural_key = key + "_" + plural_form
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}")