Coverage for src/couchers/i18n/i18next.py: 94%
109 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-25 10:58 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-25 10:58 +0000
1"""
2Implements localizing strings stored in the i18next json format.
3"""
5import re
6from dataclasses import dataclass, field
8from couchers.i18n.plurals import PluralRule
10PLURALIZABLE_VARIABLE_NAME = "count"
11"""Special variable name for which i18next supports pluralization forms."""
14@dataclass
15class I18Next:
16 """Retrieves translated strings from their keys based on the i18next format."""
18 languages_by_code: "dict[str, Language]" = field(default_factory=dict)
19 fallback_language: "Language | None" = None
20 """The language used to look up strings in unsupported languages."""
22 def add_language(self, code: str, plural_rule: PluralRule) -> "Language":
23 language = Language(code, plural_rule)
24 self.languages_by_code[code] = language
25 return language
27 def find_string(
28 self, key: str, language_code: str, substitutions: dict[str, str | int] | None = None
29 ) -> "String | None":
30 """Find the string that will be localized, applying fallbacks and variant selection."""
31 language = self.languages_by_code.get(language_code, self.fallback_language)
32 while True:
33 if language is None:
34 raise LocalizationError(language_code, key)
36 string = language.find_string(key, substitutions)
37 if string is None or not string.template.can_render(substitutions):
38 if language.fallback is None and language != self.fallback_language:
39 language = self.fallback_language
40 else:
41 language = language.fallback
42 continue
44 return string
46 def localize(self, string_key: str, language_code: str, substitutions: dict[str, str | int] | None = None) -> str:
47 if string := self.find_string(string_key, language_code, substitutions):
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 fallback: "Language | None" = None
64 def load_json_dict(self, json_dict: dict):
65 def add_strings(json_dict: dict, key_prefix: str | 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):
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):
78 self.strings_by_key[key] = String(key, StringTemplate.parse(value))
80 def find_string(self, key: str, substitutions: dict[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):
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: dict[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: dict[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: dict[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: dict[str, str | int] | None) -> str:
126 substrings: list[str] = []
127 for segment in self.segments:
128 if segment.is_variable:
129 if segment.text in substitutions:
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) -> "list[StringSegment]":
139 last_index = 0
140 segments: list[StringSegment] = []
141 for match in re.finditer(r"\{\{([^\}]+)\}\}", 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}")