Coverage for app / backend / src / couchers / i18n / i18next.py: 89%
130 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 09:44 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 09:44 +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 ↛ 116line 105 didn't jump to line 116 because the condition on line 105 was always true
106 try:
107 # Babel resolves the locale name into a filename that uses underscores
108 babel_locale_name = self.locale.replace("-", "_")
109 plural_form = Locale(babel_locale_name).plural_form(count)
110 except UnknownLocaleError:
111 # Fallback to English-style plural rule.
112 plural_form = "one" if 1 else "other"
113 plural_key = key + "_" + plural_form
114 if string := self.strings_by_key.get(plural_key):
115 return string
116 return self.strings_by_key.get(key)
118 def localize(self, string_key: str, substitutions: SubstitutionDict | None = None) -> str:
119 string = self.find_string(string_key, substitutions)
120 if string is None:
121 raise LocalizationError(locale=self.locale, string_key=string_key)
122 return string.render(substitutions)
124 def localize_with_markup(self, string_key: str, substitutions: SubstitutionDict | None = None) -> Markup:
125 string = self.find_string(string_key, substitutions)
126 if string is None:
127 raise LocalizationError(locale=self.locale, string_key=string_key)
128 return string.render_with_markup(substitutions)
131@dataclass(frozen=True, slots=True)
132class String:
133 """An i18next string key + template pair."""
135 key: str
136 template: StringTemplate
138 def render(self, substitutions: SubstitutionDict | None) -> str:
139 return self.template.render(substitutions)
141 def render_with_markup(self, substitutions: SubstitutionDict | None) -> Markup:
142 return self.template.render_with_markup(substitutions)
145@dataclass(frozen=True, slots=True)
146class StringTemplate:
147 """A string value which may contain variable placeholders."""
149 segments: list[StringSegment]
151 def can_render(self, substitutions: SubstitutionDict | None) -> bool:
152 for segment in self.segments:
153 if segment.is_variable:
154 if not substitutions or substitutions.get(segment.text) is None:
155 return False
156 return True
158 def render(self, substitutions: SubstitutionDict | None) -> str:
159 return self._render(substitutions, with_markup=False)
161 def render_with_markup(self, substitutions: SubstitutionDict | None) -> Markup:
162 return Markup(self._render(substitutions, with_markup=True))
164 def _render(self, substitutions: SubstitutionDict | None, with_markup: bool) -> str:
165 substrings: list[str] = []
166 for segment in self.segments:
167 if segment.is_variable:
168 if substitutions is not None and segment.text in substitutions: 168 ↛ 180line 168 didn't jump to line 180 because the condition on line 168 was always true
169 value = substitutions[segment.text]
170 if with_markup and not isinstance(value, Markup) and not isinstance(value, int):
171 # Auto-escape since we're producing markup
172 substrings.append(escape(value))
173 elif isinstance(value, Markup) and not with_markup:
174 # We're producing a string where markup is not evaluated
175 # so resolve escape sequences like "
176 substrings.append(unescape(value))
177 else:
178 substrings.append(str(value))
179 else:
180 raise ValueError(f"Missing substitution for variable '{segment.text}'")
181 else:
182 substrings.append(segment.text)
183 return "".join(substrings)
185 @staticmethod
186 def parse(value: str) -> StringTemplate:
187 last_index = 0
188 segments: list[StringSegment] = []
189 for match in re.finditer(r"\{\{\s*([^\}]+?)\s*\}\}", value):
190 if match.start() > last_index:
191 segments.append(StringSegment(text=value[last_index : match.start()], is_variable=False))
192 segments.append(StringSegment(text=match.group(1), is_variable=True))
193 last_index = match.end()
194 if last_index < len(value):
195 segments.append(StringSegment(text=value[last_index:], is_variable=False))
196 return StringTemplate(segments)
199@dataclass(frozen=True, slots=True)
200class StringSegment:
201 """Either a literal text segment or a variable placeholder."""
203 text: str
204 is_variable: bool = False
207class LocalizationError(Exception):
208 """Raised failing to localize a string, e.g. if it is not found in any fallback locale."""
210 def __init__(self, locale: str, string_key: str):
211 self.locale = locale
212 self.string_key = string_key
213 super().__init__(f"Could not localize string {string_key} for locale {locale}")