Coverage for src/lexigram/admin/i18n/translator.py: 0%

98 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 23:18 +0800

1"""Translation engine — Translator class, helper functions, and base catalog.""" 

2 

3from __future__ import annotations 

4 

5import re 

6from typing import Any 

7 

8from lexigram.logging import get_logger 

9 

10logger = get_logger(__name__) 

11 

12_PLURAL_SEP = "|" 

13 

14 

15def _interpolate(template: str, **kwargs: Any) -> str: 

16 """Replace ``{key}`` placeholders in *template* with *kwargs* values.""" 

17 for k, v in kwargs.items(): 

18 template = template.replace(f"{{{k}}}", str(v)) 

19 return template 

20 

21 

22def _plural_form(value: str, count: int) -> str: 

23 """Select the correct plural form from a pipe-separated string. 

24 

25 Supports exactly two forms: singular|plural. If the translation has only 

26 one form it is always returned regardless of count. 

27 

28 Args: 

29 value: Raw translation value, e.g. ``"{count} item|{count} items"``. 

30 count: The count used for plural selection. 

31 

32 Returns: 

33 The selected form (singular if count == 1, plural otherwise). 

34 """ 

35 parts = value.split(_PLURAL_SEP, maxsplit=1) 

36 if len(parts) == 1: 

37 return parts[0] 

38 return parts[0] if count == 1 else parts[1] 

39 

40 

41def _parse_accept_language(header: str) -> list[str]: 

42 """Parse ``Accept-Language`` header into an ordered locale list. 

43 

44 Args: 

45 header: Raw ``Accept-Language`` header value. 

46 

47 Returns: 

48 Locale codes ordered by quality value (highest first). 

49 """ 

50 locales: list[tuple[float, str]] = [] 

51 for part in header.split(","): 

52 part = part.strip() 

53 if not part: 

54 continue 

55 m = re.match(r"([a-zA-Z\-]+)(?:;q=([0-9.]+))?", part) 

56 if m: 

57 tag = m.group(1).replace("_", "-") 

58 q = float(m.group(2)) if m.group(2) else 1.0 

59 locales.append((q, tag)) 

60 locales.sort(key=lambda x: x[0], reverse=True) 

61 return [tag for _, tag in locales] 

62 

63 

64class Translator: 

65 """Manages translation catalogs and resolves translations. 

66 

67 Args: 

68 default_locale: Locale used when no specific locale is given. 

69 Defaults to ``"en"``. 

70 """ 

71 

72 def __init__(self, default_locale: str = "en") -> None: 

73 self._catalogs: dict[str, dict[str, str]] = {} 

74 self._default_locale = default_locale 

75 

76 # ------------------------------------------------------------------ 

77 # Catalog management 

78 # ------------------------------------------------------------------ 

79 

80 def load_catalog(self, locale: str, catalog: dict[str, str]) -> None: 

81 """Merge *catalog* into the existing translations for *locale*. 

82 

83 Keys are dot-notation strings; values are translation strings with 

84 optional pipe-separated plural forms and ``{placeholder}`` tokens. 

85 

86 Args: 

87 locale: BCP 47 locale tag (e.g. ``"en"``, ``"fr-CA"``). 

88 catalog: Mapping of translation keys to values. 

89 """ 

90 existing = self._catalogs.setdefault(locale, {}) 

91 existing.update(catalog) 

92 logger.debug("Loaded %d translation keys for locale '%s'", len(catalog), locale) 

93 

94 def get_catalog(self, locale: str) -> dict[str, str]: 

95 """Return the raw catalog for a locale (empty dict if not loaded). 

96 

97 Args: 

98 locale: BCP 47 locale tag. 

99 """ 

100 return dict(self._catalogs.get(locale, {})) 

101 

102 @property 

103 def loaded_locales(self) -> list[str]: 

104 """Sorted list of locale codes that have catalogs loaded.""" 

105 return sorted(self._catalogs) 

106 

107 # ------------------------------------------------------------------ 

108 # Translation 

109 # ------------------------------------------------------------------ 

110 

111 def _fallback_chain(self, locale: str) -> list[str]: 

112 """Build the fallback chain for a locale. 

113 

114 ``fr-CA`` → ``fr-CA``, ``fr``, ``<default>``. 

115 

116 Args: 

117 locale: Starting locale. 

118 """ 

119 chain: list[str] = [locale] 

120 if "-" in locale: 

121 chain.append(locale.split("-", maxsplit=1)[0]) 

122 if self._default_locale not in chain: 

123 chain.append(self._default_locale) 

124 return chain 

125 

126 def t( 

127 self, 

128 key: str, 

129 *, 

130 locale: str | None = None, 

131 count: int | None = None, 

132 **kwargs: Any, 

133 ) -> str: 

134 """Translate *key* to the given locale. 

135 

136 Args: 

137 key: Dot-notation translation key. 

138 locale: Target locale. Falls back through the fallback chain to 

139 the default locale. 

140 count: When provided, plural form selection is performed before 

141 interpolation. Also added to interpolation kwargs as 

142 ``count``. 

143 **kwargs: Named placeholders substituted into the translation. 

144 

145 Returns: 

146 Translated string. If no translation is found, *key* is returned 

147 as-is (last-resort fallback) and a warning is logged. 

148 """ 

149 resolved_locale = locale or self._default_locale 

150 chain = self._fallback_chain(resolved_locale) 

151 

152 raw: str | None = None 

153 for candidate in chain: 

154 catalog = self._catalogs.get(candidate, {}) 

155 if key in catalog: 

156 raw = catalog[key] 

157 break 

158 

159 if raw is None: 

160 logger.warning( 

161 "Missing translation key '%s' for locale '%s'", key, resolved_locale 

162 ) 

163 raw = key 

164 

165 if count is not None: 

166 raw = _plural_form(raw, count) 

167 kwargs.setdefault("count", count) 

168 

169 if kwargs: 

170 raw = _interpolate(raw, **kwargs) 

171 

172 return raw 

173 

174 # Shorthand 

175 __call__ = t 

176 

177 # ------------------------------------------------------------------ 

178 # Formatting helpers 

179 # ------------------------------------------------------------------ 

180 

181 @staticmethod 

182 def format_number( 

183 value: float, *, _locale: str = "en", decimals: int | None = None 

184 ) -> str: 

185 """Format *value* as a locale-aware number string. 

186 

187 Args: 

188 value: Numeric value to format. 

189 locale: BCP 47 locale tag (used for grouping/decimal separator). 

190 decimals: Number of decimal places. ``None`` lets Python decide. 

191 

192 Returns: 

193 Formatted string, e.g. ``"1,234.56"`` for ``en``. 

194 """ 

195 if decimals is not None: 

196 return f"{value:,.{decimals}f}" 

197 if isinstance(value, int): 

198 return f"{value:,}" 

199 return f"{value:,}" 

200 

201 @staticmethod 

202 def format_currency( 

203 amount: float, currency: str = "USD", *, _locale: str = "en" 

204 ) -> str: 

205 """Format *amount* as a currency string. 

206 

207 Args: 

208 amount: Monetary amount. 

209 currency: ISO 4217 currency code (e.g. ``"USD"``, ``"EUR"``). 

210 locale: BCP 47 locale tag (reserved for future locale-aware impl). 

211 

212 Returns: 

213 Formatted string, e.g. ``"$1,234.56"`` for USD. 

214 """ 

215 symbols = {"USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥"} 

216 symbol = symbols.get(currency.upper(), currency) 

217 formatted = f"{amount:,.2f}" 

218 return f"{symbol}{formatted}" 

219 

220 @staticmethod 

221 def format_date(dt: Any, *, fmt: str = "%Y-%m-%d", _locale: str = "en") -> str: 

222 """Format a date/datetime object. 

223 

224 Args: 

225 dt: A ``datetime.date`` or ``datetime.datetime`` instance. 

226 fmt: ``strftime`` format string. 

227 locale: Reserved for future locale-aware formatting. 

228 

229 Returns: 

230 Formatted date string. 

231 """ 

232 return dt.strftime(fmt) 

233 

234 @staticmethod 

235 def format_datetime( 

236 dt: Any, 

237 *, 

238 fmt: str = "%Y-%m-%d %H:%M", 

239 timezone: str | None = None, 

240 _locale: str = "en", 

241 ) -> str: 

242 """Format a datetime, optionally converting to a target timezone. 

243 

244 Args: 

245 dt: A ``datetime.datetime`` instance. 

246 fmt: ``strftime`` format string. 

247 timezone: IANA timezone name (e.g. ``"America/New_York"``). 

248 If ``None``, no conversion is performed. 

249 locale: Reserved for future locale-aware formatting. 

250 

251 Returns: 

252 Formatted datetime string in the target timezone. 

253 """ 

254 from datetime import datetime as _datetime 

255 from zoneinfo import ZoneInfo, ZoneInfoNotFoundError 

256 

257 if timezone and isinstance(dt, _datetime): 

258 try: 

259 tz = ZoneInfo(timezone) 

260 dt = dt.astimezone(tz) 

261 except (ZoneInfoNotFoundError, ValueError, AttributeError): 

262 pass 

263 return dt.strftime(fmt) 

264 

265 

266_BASE_EN: dict[str, str] = { 

267 "admin.save": "Save", 

268 "admin.cancel": "Cancel", 

269 "admin.delete": "Delete", 

270 "admin.restore": "Restore", 

271 "admin.edit": "Edit", 

272 "admin.create": "Create", 

273 "admin.search": "Search", 

274 "admin.filter": "Filter", 

275 "admin.export": "Export", 

276 "admin.import": "Import", 

277 "admin.back": "Back", 

278 "admin.confirm": "Are you sure?", 

279 "admin.loading": "Loading…", 

280 "admin.no_results": "No results found.", 

281 "admin.items_selected": "{count} item selected|{count} items selected", 

282 "admin.page_of": "Page {page} of {total}", 

283 "admin.error.required": "{field} is required.", 

284 "admin.error.not_found": "{resource} not found.", 

285 "admin.success.created": "{resource} created successfully.", 

286 "admin.success.updated": "{resource} updated successfully.", 

287 "admin.success.deleted": "{resource} deleted successfully.", 

288} 

289 

290#: Module-level :class:`Translator` instance. Pre-loaded with a minimal 

291#: English base catalog. Applications should call :meth:`Translator.load_catalog` 

292#: at startup to extend / override translations. 

293translator = Translator(default_locale="en") 

294translator.load_catalog("en", _BASE_EN) 

295 

296 

297__all__ = [ 

298 "Translator", 

299 "translator", 

300]