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

1"""Type-safe translations loaded from packaged YAML catalogues.""" 

2 

3from dataclasses import dataclass 

4from importlib.resources import files 

5from string import Formatter 

6from typing import Literal 

7 

8import yaml 

9 

10from ph.translation_keys import TranslationKey 

11 

12type LanguageCode = Literal["en", "de", "es", "fr", "it", "pt"] 

13 

14DEFAULT_LANGUAGE: LanguageCode = "en" 

15 

16 

17@dataclass(frozen=True, slots=True) 

18class Language: 

19 """One selectable interface language.""" 

20 

21 code: LanguageCode 

22 name: str 

23 

24 

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) 

33 

34 

35def _load_catalogue(language: LanguageCode) -> dict[TranslationKey, str]: 

36 """Load and validate one packaged translation catalogue.""" 

37 

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") 

42 

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 

56 

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 

62 

63 

64_TRANSLATIONS: dict[LanguageCode, dict[TranslationKey, str]] = { 

65 language.code: _load_catalogue(language.code) for language in LANGUAGES 

66} 

67_ENGLISH = _TRANSLATIONS[DEFAULT_LANGUAGE] 

68 

69 

70def normalize_language(value: object) -> LanguageCode: 

71 """Return a supported language code, defaulting safely to English.""" 

72 

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 

79 

80 

81def language_name(code: LanguageCode) -> str: 

82 """Return the native display name for a supported language.""" 

83 

84 return next(language.name for language in LANGUAGES if language.code == code) 

85 

86 

87def translate(code: LanguageCode, key: TranslationKey, **values: object) -> str: 

88 """Translate a type-safe UI key and interpolate its named values.""" 

89 

90 template = _TRANSLATIONS[code][key] 

91 return template.format_map(values) if values else template 

92 

93 

94def missing_translation_keys(language: LanguageCode) -> frozenset[TranslationKey]: 

95 """Expose catalogue completeness for validation tests.""" 

96 

97 return frozenset(_ENGLISH).difference(_TRANSLATIONS[language]) 

98 

99 

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 ) 

107 

108 

109def mismatched_placeholder_keys(language: LanguageCode) -> frozenset[TranslationKey]: 

110 """Return translations whose named format fields differ from English.""" 

111 

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 )