Coverage for src/ph/i18n.py: 85.9%
52 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-28 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-28 08:17 +0000
1"""Type-safe translations loaded from packaged YAML catalogues."""
3from dataclasses import dataclass
4from importlib.resources import files
5from string import Formatter
6from typing import Literal
8import yaml
10from ph.translation_keys import TranslationKey
12type LanguageCode = Literal["en", "de", "es", "fr", "it", "pt"]
14DEFAULT_LANGUAGE: LanguageCode = "en"
17@dataclass(frozen=True, slots=True)
18class Language:
19 """One selectable interface language."""
21 code: LanguageCode
22 name: str
25LANGUAGES: tuple[Language, ...] = (
26 Language("en", "English"),
27 Language("de", "Deutsch"),
28 Language("es", "Español"),
29 Language("fr", "Français"),
30 Language("it", "Italiano"),
31 Language("pt", "Português"),
32)
35def _load_catalogue(language: LanguageCode) -> dict[TranslationKey, str]:
36 """Load and validate one packaged translation catalogue."""
38 resource = files("ph.translations").joinpath(f"{language}.yaml")
39 payload: object = yaml.safe_load(resource.read_text(encoding="utf-8"))
40 if not isinstance(payload, dict): 40 ↛ 41line 40 didn't jump to line 41 because the condition on line 40 was never true
41 raise TypeError(f"Translation catalogue {language!r} must be a mapping")
43 catalogue: dict[TranslationKey, str] = {}
44 for raw_key, raw_value in payload.items():
45 if not isinstance(raw_key, str) or not isinstance(raw_value, str): 45 ↛ 46line 45 didn't jump to line 46 because the condition on line 45 was never true
46 raise TypeError(
47 f"Translation catalogue {language!r} must contain string keys and values"
48 )
49 try:
50 key = TranslationKey(raw_key)
51 except ValueError as error:
52 raise ValueError(
53 f"Translation catalogue {language!r} contains unknown key {raw_key!r}"
54 ) from error
55 catalogue[key] = raw_value
57 missing = frozenset(TranslationKey).difference(catalogue)
58 if missing: 58 ↛ 59line 58 didn't jump to line 59 because the condition on line 58 was never true
59 names = ", ".join(sorted(key.value for key in missing))
60 raise ValueError(f"Translation catalogue {language!r} is missing: {names}")
61 return catalogue
64_TRANSLATIONS: dict[LanguageCode, dict[TranslationKey, str]] = {
65 language.code: _load_catalogue(language.code) for language in LANGUAGES
66}
67_ENGLISH = _TRANSLATIONS[DEFAULT_LANGUAGE]
70def normalize_language(value: object) -> LanguageCode:
71 """Return a supported language code, defaulting safely to English."""
73 if isinstance(value, str):
74 normalized = value.strip().casefold().split("-", maxsplit=1)[0]
75 match normalized:
76 case "en" | "de" | "es" | "fr" | "it" | "pt":
77 return normalized
78 return DEFAULT_LANGUAGE
81def language_name(code: LanguageCode) -> str:
82 """Return the native display name for a supported language."""
84 return next(language.name for language in LANGUAGES if language.code == code)
87def translate(code: LanguageCode, key: TranslationKey, **values: object) -> str:
88 """Translate a type-safe UI key and interpolate its named values."""
90 template = _TRANSLATIONS[code][key]
91 return template.format_map(values) if values else template
94def missing_translation_keys(language: LanguageCode) -> frozenset[TranslationKey]:
95 """Expose catalogue completeness for validation tests."""
97 return frozenset(_ENGLISH).difference(_TRANSLATIONS[language])
100def _placeholder_fields(template: str) -> frozenset[str]:
101 formatter = Formatter()
102 return frozenset(
103 field_name
104 for _literal, field_name, _format_spec, _conversion in formatter.parse(template)
105 if field_name is not None
106 )
109def mismatched_placeholder_keys(language: LanguageCode) -> frozenset[TranslationKey]:
110 """Return translations whose named format fields differ from English."""
112 catalogue = _TRANSLATIONS[language]
113 return frozenset(
114 key
115 for key, english in _ENGLISH.items()
116 if _placeholder_fields(catalogue[key]) != _placeholder_fields(english)
117 )