Picarones / picarones /i18n.py
Claude
sprint30: polish, accessibilité WCAG, DX
eb547cb unverified
Raw
History Blame
4.11 kB
"""Labels i18n pour le rapport HTML et l'interface Picarones.
Langues supportées
------------------
- ``"fr"`` : français (défaut)
- ``"en"`` : anglais patrimonial (heritage English)
Depuis le Sprint 17, les traductions sont stockées dans
``picarones/report/i18n/{lang}.json`` et chargées au premier accès.
``TRANSLATIONS`` reste exposé comme dict pour compatibilité ascendante.
Sprint 30 — durcissement
------------------------
- Chargement lazy + thread-safe via verrou explicite ; les serveurs
web sous charge concurrente ne peuvent plus initialiser deux fois.
- ``reload_translations()`` exposé pour les tests qui modifient les
fichiers JSON à la volée.
- ``get_labels()`` mémoizé via ``functools.lru_cache`` pour absorber
le fallback ``lang → fr`` sans relire le dict à chaque appel.
"""
from __future__ import annotations
import json
import logging
import threading
from functools import lru_cache
from pathlib import Path
logger = logging.getLogger(__name__)
_I18N_DIR = Path(__file__).parent / "report" / "i18n"
_LOAD_LOCK = threading.Lock()
_TRANSLATIONS_CACHE: dict[str, dict[str, str]] | None = None
def _load_translations() -> dict[str, dict[str, str]]:
"""Charge tous les fichiers JSON du dossier i18n.
Un fichier ``{lang}.json`` définit les labels de la langue ``lang``.
Retourne toujours un dict non-vide, même si le dossier est manquant
(dans ce cas, le dict est vide et ``get_labels`` tombe sur un fallback).
"""
translations: dict[str, dict[str, str]] = {}
if not _I18N_DIR.is_dir():
return translations
for path in sorted(_I18N_DIR.glob("*.json")):
lang = path.stem
try:
with path.open(encoding="utf-8") as fh:
translations[lang] = json.load(fh)
except (OSError, json.JSONDecodeError) as e:
logger.warning("[i18n] fichier '%s' ignoré : %s", path, e)
return translations
def _get_translations() -> dict[str, dict[str, str]]:
"""Retourne le cache de translations, initialisé une seule fois.
Thread-safe : deux threads qui appellent simultanément en démarrage
ne déclencheront qu'une seule lecture disque.
"""
global _TRANSLATIONS_CACHE
if _TRANSLATIONS_CACHE is not None:
return _TRANSLATIONS_CACHE
with _LOAD_LOCK:
if _TRANSLATIONS_CACHE is None:
_TRANSLATIONS_CACHE = _load_translations()
return _TRANSLATIONS_CACHE
def reload_translations() -> None:
"""Force la relecture des fichiers JSON au prochain ``get_labels``.
Utile pour les tests qui modifient ``report/i18n/*.json`` à la volée.
"""
global _TRANSLATIONS_CACHE
with _LOAD_LOCK:
_TRANSLATIONS_CACHE = None
_get_labels_cached.cache_clear()
@lru_cache(maxsize=None)
def _get_labels_cached(lang: str) -> tuple[tuple[str, str], ...]:
"""Cache mémoïsé : ``lang -> tuple ordonné des paires``.
Le retour en tuple permet à ``lru_cache`` de mémoriser sans
contrainte de hashabilité, et est trivialement converti en dict
par ``get_labels`` à chaque appel (coût O(n)).
"""
translations = _get_translations()
labels = translations.get(lang) or translations.get("fr") or {}
return tuple(labels.items())
def get_labels(lang: str = "fr") -> dict[str, str]:
"""Retourne le dictionnaire de labels pour la langue donnée.
Parameters
----------
lang:
Code langue : ``"fr"`` (défaut) ou ``"en"``.
Returns
-------
dict
Labels traduits. Toujours valide : bascule sur ``"fr"`` si lang inconnu.
Si ``"fr"`` lui-même manque, retourne un dict vide (comportement dégradé
mais non bloquant).
"""
return dict(_get_labels_cached(lang))
# ``TRANSLATIONS`` reste accessible comme attribut module pour les
# consommateurs externes qui le lisaient directement. Initialisé
# paresseusement à l'import — n'engendre **pas** de lecture si le
# module n'est jamais utilisé.
TRANSLATIONS: dict[str, dict[str, str]] = _get_translations()
SUPPORTED_LANGS: list[str] = list(TRANSLATIONS.keys())