Coverage for app / backend / src / couchers / i18n / i18next.py: 90%
103 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-03 06:18 +0000
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-03 06:18 +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 typing import Any
10from couchers.i18n.plurals import PluralRule
12PLURALIZABLE_VARIABLE_NAME = "count"
13"""Special variable name for which i18next supports pluralization forms."""
16@dataclass
17class I18Next:
18 """Retrieves translated strings from their keys based on the i18next format."""
20 languages_by_code: dict[str, Language] = field(default_factory=dict)
21 default_language: Language | None = None
22 """The language used to look up strings in unsupported languages."""
24 def add_language(self, code: str, plural_rule: PluralRule, *, json_dict: dict[str, Any] | None = None) -> Language:
25 language = Language(code, plural_rule)
26 self.languages_by_code[code] = language
27 if json_dict: 27 ↛ 28line 27 didn't jump to line 28 because the condition on line 27 was never true
28 language.load_json_dict(json_dict)
29 return language
31 def find_string(
32 self, key: str, language_code: str, substitutions: Mapping[str, str | int] | None = None
33 ) -> String | None:
34 """Find the string that will be localized, applying fallbacks and variant selection."""
35 language = self.languages_by_code.get(language_code, self.default_language)
36 candidate_languages = [language] + language.fallbacks if language else []
37 for candidate_language in candidate_languages:
38 if string := candidate_language.find_string(key, substitutions):
39 if string.template.can_render(substitutions):
40 return string
42 raise LocalizationError(language_code, key)
44 def localize(
45 self, string_key: str, language_code: str, substitutions: Mapping[str, str | int] | None = None
46 ) -> str:
47 if string := self.find_string(string_key, language_code, substitutions): 47 ↛ 50line 47 didn't jump to line 50 because the condition on line 47 was always true
48 return string.render(substitutions)
49 else:
50 raise LocalizationError(language_code, string_key)
53@dataclass
54class Language:
55 """A set of translated strings for a language."""
57 code: str
58 """The language code, e.g. 'en'"""
59 plural_rule: PluralRule
60 """The rule for plurals in this language."""
61 strings_by_key: dict[str, String] = field(default_factory=dict)
62 fallbacks: list[Language] = field(default_factory=list)
64 def load_json_dict(self, json_dict: Mapping[str, Any]) -> None:
65 def add_strings(json_dict: Mapping[str, Any], key_prefix: str | None) -> None:
66 for k, v in json_dict.items():
67 full_key = f"{key_prefix}.{k}" if key_prefix else k
68 if isinstance(v, str):
69 self.add_string(full_key, v)
70 elif isinstance(v, dict): 70 ↛ 73line 70 didn't jump to line 73 because the condition on line 70 was always true
71 add_strings(v, key_prefix=full_key)
72 else:
73 raise ValueError(f"Unexpected value type in language JSON: {type(v)}")
75 add_strings(json_dict, key_prefix=None)
77 def add_string(self, key: str, value: str) -> None:
78 self.strings_by_key[key] = String(key, StringTemplate.parse(value))
80 def find_string(self, key: str, substitutions: Mapping[str, str | int] | None = None) -> String | None:
81 # if we have a numerical "count" substitution,
82 # i18next will first search for a key with a suffix
83 # based on the plural category suggested by the count
84 # according to the current language's rules.
85 if substitutions:
86 if count := substitutions.get(PLURALIZABLE_VARIABLE_NAME):
87 if isinstance(count, int): 87 ↛ 92line 87 didn't jump to line 92 because the condition on line 87 was always true
88 plural_category = self.plural_rule(count)
89 plural_key = key + "_" + plural_category.value
90 if string := self.strings_by_key.get(plural_key):
91 return string
92 return self.strings_by_key.get(key)
94 def localize(self, string_key: str, substitutions: Mapping[str, str | int] | None = None) -> str:
95 string = self.find_string(string_key, substitutions)
96 if string is None:
97 raise LocalizationError(language_code=self.code, string_key=string_key)
98 return string.render(substitutions)
101@dataclass(frozen=True, slots=True)
102class String:
103 """An i18next string key + template pair."""
105 key: str
106 template: StringTemplate
108 def render(self, substitutions: Mapping[str, str | int] | None) -> str:
109 return self.template.render(substitutions)
112@dataclass(frozen=True, slots=True)
113class StringTemplate:
114 """A string value which may contain variable placeholders."""
116 segments: list[StringSegment]
118 def can_render(self, substitutions: Mapping[str, str | int] | None) -> bool:
119 for segment in self.segments:
120 if segment.is_variable:
121 if not substitutions or substitutions.get(segment.text) is None:
122 return False
123 return True
125 def render(self, substitutions: Mapping[str, str | int] | None) -> str:
126 substrings: list[str] = []
127 for segment in self.segments:
128 if segment.is_variable:
129 if substitutions is not None and segment.text in substitutions: 129 ↛ 132line 129 didn't jump to line 132 because the condition on line 129 was always true
130 substrings.append(str(substitutions[segment.text]))
131 else:
132 raise ValueError(f"Missing substitution for variable '{segment.text}'")
133 else:
134 substrings.append(segment.text)
135 return "".join(substrings)
137 @staticmethod
138 def parse(value: str) -> StringTemplate:
139 last_index = 0
140 segments: list[StringSegment] = []
141 for match in re.finditer(r"\{\{\s*([^\}]+?)\s*\}\}", value):
142 if match.start() > last_index:
143 segments.append(StringSegment(text=value[last_index : match.start()], is_variable=False))
144 segments.append(StringSegment(text=match.group(1), is_variable=True))
145 last_index = match.end()
146 if last_index < len(value):
147 segments.append(StringSegment(text=value[last_index:], is_variable=False))
148 return StringTemplate(segments)
151@dataclass(frozen=True, slots=True)
152class StringSegment:
153 """Either a literal text segment or a variable placeholder."""
155 text: str
156 is_variable: bool = False
159class LocalizationError(Exception):
160 """Raised failing to localize a string, e.g. if it is not found in any fallback language."""
162 def __init__(self, language_code: str, string_key: str):
163 self.language_code = language_code
164 self.string_key = string_key
165 super().__init__(f"Could not localize string {string_key} for language {language_code}")