Coverage for src / lexigram / admin / i18n / translator.py: 0%
98 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
1"""Translation engine — Translator class, helper functions, and base catalog."""
3from __future__ import annotations
5import re
6from typing import Any
8from lexigram.logging import get_logger
10logger = get_logger(__name__)
12_PLURAL_SEP = "|"
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
22def _plural_form(value: str, count: int) -> str:
23 """Select the correct plural form from a pipe-separated string.
25 Supports exactly two forms: singular|plural. If the translation has only
26 one form it is always returned regardless of count.
28 Args:
29 value: Raw translation value, e.g. ``"{count} item|{count} items"``.
30 count: The count used for plural selection.
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]
41def _parse_accept_language(header: str) -> list[str]:
42 """Parse ``Accept-Language`` header into an ordered locale list.
44 Args:
45 header: Raw ``Accept-Language`` header value.
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]
64class Translator:
65 """Manages translation catalogs and resolves translations.
67 Args:
68 default_locale: Locale used when no specific locale is given.
69 Defaults to ``"en"``.
70 """
72 def __init__(self, default_locale: str = "en") -> None:
73 self._catalogs: dict[str, dict[str, str]] = {}
74 self._default_locale = default_locale
76 # ------------------------------------------------------------------
77 # Catalog management
78 # ------------------------------------------------------------------
80 def load_catalog(self, locale: str, catalog: dict[str, str]) -> None:
81 """Merge *catalog* into the existing translations for *locale*.
83 Keys are dot-notation strings; values are translation strings with
84 optional pipe-separated plural forms and ``{placeholder}`` tokens.
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)
94 def get_catalog(self, locale: str) -> dict[str, str]:
95 """Return the raw catalog for a locale (empty dict if not loaded).
97 Args:
98 locale: BCP 47 locale tag.
99 """
100 return dict(self._catalogs.get(locale, {}))
102 @property
103 def loaded_locales(self) -> list[str]:
104 """Sorted list of locale codes that have catalogs loaded."""
105 return sorted(self._catalogs)
107 # ------------------------------------------------------------------
108 # Translation
109 # ------------------------------------------------------------------
111 def _fallback_chain(self, locale: str) -> list[str]:
112 """Build the fallback chain for a locale.
114 ``fr-CA`` → ``fr-CA``, ``fr``, ``<default>``.
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
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.
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.
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)
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
159 if raw is None:
160 logger.warning(
161 "Missing translation key '%s' for locale '%s'", key, resolved_locale
162 )
163 raw = key
165 if count is not None:
166 raw = _plural_form(raw, count)
167 kwargs.setdefault("count", count)
169 if kwargs:
170 raw = _interpolate(raw, **kwargs)
172 return raw
174 # Shorthand
175 __call__ = t
177 # ------------------------------------------------------------------
178 # Formatting helpers
179 # ------------------------------------------------------------------
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.
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.
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:,}"
201 @staticmethod
202 def format_currency(
203 amount: float, currency: str = "USD", *, _locale: str = "en"
204 ) -> str:
205 """Format *amount* as a currency string.
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).
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}"
220 @staticmethod
221 def format_date(dt: Any, *, fmt: str = "%Y-%m-%d", _locale: str = "en") -> str:
222 """Format a date/datetime object.
224 Args:
225 dt: A ``datetime.date`` or ``datetime.datetime`` instance.
226 fmt: ``strftime`` format string.
227 locale: Reserved for future locale-aware formatting.
229 Returns:
230 Formatted date string.
231 """
232 return dt.strftime(fmt)
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.
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.
251 Returns:
252 Formatted datetime string in the target timezone.
253 """
254 from datetime import datetime as _datetime
255 from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
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)
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}
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)
297__all__ = [
298 "Translator",
299 "translator",
300]