Coverage for moptipy / utils / lang.py: 86%

161 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-24 08:49 +0000

1""" 

2The :class:`Lang` class provides facilities for easy internationalization. 

3 

4The idea here is to have simply tools that provide locale-specific keywords, 

5texts, and number formats. 

6""" 

7 

8from typing import Callable, Final, Iterable 

9 

10from matplotlib import rc # type: ignore 

11from matplotlib.font_manager import ( # type: ignore 

12 FontProperties, 

13 findSystemFonts, 

14) 

15from pycommons.types import check_int_range, type_error 

16 

17from moptipy.utils.strings import sanitize_name 

18 

19 

20class Lang: 

21 """A language-based dictionary for locale-specific keywords.""" 

22 

23 def __init__(self, name: str, font: str, decimal_stepwidth: int = 3, 

24 data: dict[str, str] | None = None, 

25 is_default: bool = False): 

26 """ 

27 Instantiate the language formatter. 

28 

29 :param name: the short name 

30 :param font: the font name 

31 :param decimal_stepwidth: the decimal step width 

32 :param data: the data 

33 :param is_default: is this the default language? 

34 """ 

35 #: the name of the locale 

36 self.__name: Final[str] = sanitize_name(name) 

37 if not isinstance(font, str): 

38 raise type_error(font, "font", str) 

39 if not isinstance(is_default, bool): 

40 raise type_error(is_default, "is_default", bool) 

41 

42 font = font.strip() 

43 if not font: 

44 raise ValueError(f"The font cannot be {font!r}.") 

45 #: the font name 

46 self.__font: Final[str] = font 

47 #: the decimal step width 

48 self.__decimal_stepwidth: Final[int] = check_int_range( 

49 decimal_stepwidth, "decimal_stepwidth", 1, 10) 

50 

51 #: the dictionary with the translation data 

52 self.__dict: Final[dict[str, str]] = {} 

53 if data: 

54 self.extend(data) 

55 

56 #: is this the default language? 

57 self.__is_default: Final[bool] = is_default 

58 

59 # register the language 

60 dc: Final[dict[str, Lang]] = Lang.__get_langs() 

61 if self.__name in dc: 

62 raise ValueError(f"Language {self.__name!r} already registered.") 

63 if is_default: 

64 for lang in dc.values(): 

65 if lang.__is_default: 

66 raise ValueError( 

67 f"Language {self.__name!r} cannot be default " 

68 f"language, {lang.__name!r} already is!") 

69 dc[self.__name] = self 

70 

71 if is_default: 

72 self.set_current() 

73 

74 def extend(self, data: dict[str, str]) -> None: 

75 """ 

76 Add a set of entries to this dictionary. 

77 

78 :param data: the language-specific data 

79 """ 

80 if not isinstance(data, dict): 

81 raise type_error(data, "data", dict) 

82 for ko, v in data.items(): 

83 k = sanitize_name(ko) 

84 if k != ko: 

85 raise ValueError(f"key {ko!r} is different from " 

86 f"its sanitized version {k!r}.") 

87 if (k in self.__dict) and (self.__dict[k] != v): 

88 raise ValueError( 

89 f"Key {k!r} appears twice, already assigned to " 

90 f"{self.__dict[k]!r}, cannot assign to {v!r}.") 

91 if not isinstance(v, str): 

92 raise type_error(v, f"value for key {k!r}", str) 

93 if not v: 

94 raise ValueError(f"Value for key {k!r} cannot be {v!r}.") 

95 self.__dict[k] = v 

96 

97 def filename(self, base: str) -> str: 

98 """ 

99 Make a suitable filename by appending the language id. 

100 

101 :param str base: the basename 

102 :return: the filename 

103 :rtype: str 

104 

105 >>> print(Lang.get("en").filename("test")) 

106 test 

107 >>> print(Lang.get("zh").filename("test")) 

108 test_zh 

109 """ 

110 base = sanitize_name(base) 

111 return base if self.__is_default else f"{base}_{self.__name}" 

112 

113 def __repr__(self): 

114 """ 

115 Get the language's name. 

116 

117 :return: the language's name 

118 :rtype: str 

119 """ 

120 return self.__name 

121 

122 def __getitem__(self, item: str) -> str: 

123 """ 

124 Get the language formatting code. 

125 

126 :param item: the item to get 

127 :return: the language-specific code 

128 """ 

129 if not isinstance(item, str): 

130 raise type_error(item, "item", str) 

131 return self.__dict[item] 

132 

133 def format_str(self, item: str, **kwargs) -> str: 

134 """ 

135 Return a string based on the specified format. 

136 

137 :param item: the key 

138 :param kwargs: the keyword-based arguments 

139 

140 >>> l = Lang.get("en") 

141 >>> l.extend({"z": "{a}: bla{b}"}) 

142 >>> print(l.format_str("z", a=5, b=6)) 

143 5: bla6 

144 """ 

145 if not isinstance(item, str): 

146 raise type_error(item, "item", str) 

147 fstr: str = self.__dict[item] 

148 # pylint: disable=W0123 # noqa: S307 

149 return eval( # nosec # nosemgrep # noqa 

150 f"f{fstr!r}", # nosec # nosemgrep # noqa: B028,S307 

151 {"__builtins__": None}, # nosec # nosemgrep # noqa:S307 

152 kwargs).strip() # nosec # nosemgrep # noqa: S307 

153 

154 def font(self) -> str: 

155 """ 

156 Get the default font for this language. 

157 

158 :return: the default font for this language 

159 

160 >>> print(Lang.get("en").font()) 

161 DejaVu Sans 

162 >>> print(Lang.get("zh").font()) 

163 Noto Sans SC 

164 """ 

165 return self.__font 

166 

167 def format_int(self, value: int) -> str: 

168 """ 

169 Convert an integer to a string. 

170 

171 :param value: the value 

172 :returns: a string representation of the value 

173 

174 >>> print(Lang.get("en").format_int(100000)) 

175 100'000 

176 >>> print(Lang.get("zh").format_int(100000)) 

177 10'0000 

178 """ 

179 if not isinstance(value, int): 

180 raise type_error(value, "value", int) 

181 if value < 0: 

182 prefix = "-" 

183 value = -value 

184 else: 

185 prefix = "" 

186 

187 sss = str(value) 

188 i = len(sss) 

189 if i <= self.__decimal_stepwidth: # no formatting needed 

190 return prefix + sss 

191 

192 # We divide the string into equally-sized chunks and insert "'" 

193 # between them. 

194 chunks: list[str] = [] 

195 for i in range(i, -1, -self.__decimal_stepwidth): # noqa 

196 k: str = sss[i:(i + self.__decimal_stepwidth)] 

197 if k: 

198 chunks.insert(0, k) 

199 if i > 0: 

200 chunks.insert(0, sss[0:i]) 

201 return prefix + "'".join(chunks) 

202 

203 @staticmethod 

204 def __get_langs() -> dict[str, "Lang"]: 

205 """ 

206 Get the languages decode. 

207 

208 :return: the languages decode 

209 """ 

210 att: Final[str] = "__map" 

211 if not hasattr(Lang.__get_langs, att): 

212 setattr(Lang.__get_langs, att, {}) 

213 return getattr(Lang.__get_langs, att) 

214 

215 @staticmethod 

216 def get(name: str) -> "Lang": 

217 """ 

218 Get the language of the given key. 

219 

220 :param name: the language name 

221 :return: the language 

222 """ 

223 name = sanitize_name(name) 

224 lang: Lang | None = Lang.__get_langs().get(name, None) 

225 if lang: 

226 return lang 

227 raise ValueError(f"Unknown language {name!r}.") 

228 

229 @staticmethod 

230 def current() -> "Lang": 

231 """ 

232 Get the current language. 

233 

234 :return: the current language 

235 

236 >>> Lang.get("en").set_current() 

237 >>> print(Lang.current().filename("b")) 

238 b 

239 >>> Lang.get("zh").set_current() 

240 >>> print(Lang.current().filename("b")) 

241 b_zh 

242 """ 

243 lang: Final[Lang] = getattr(Lang.__get_langs, "__current") 

244 if not lang: 

245 raise ValueError("Huh?") 

246 return lang 

247 

248 def set_current(self) -> None: 

249 """Mark this language as the current one.""" 

250 setattr(Lang.__get_langs, "__current", self) 

251 rc("font", family=self.font()) 

252 

253 @staticmethod 

254 def all_langs() -> Iterable["Lang"]: 

255 """ 

256 Get all presently loaded languages. 

257 

258 :return: an Iterable of the languages 

259 """ 

260 val = list(Lang.__get_langs().values()) 

261 val.sort(key=lambda x: x.__name) 

262 return val 

263 

264 @staticmethod 

265 def translate(key: str) -> str: 

266 """ 

267 Translate the given key to a string in the current language. 

268 

269 :param key: the key 

270 :returns: the value of the key in the current language 

271 

272 >>> EN.extend({'a': 'b'}) 

273 >>> EN.set_current() 

274 >>> print(Lang.translate("a")) 

275 b 

276 >>> DE.extend({'a': 'c'}) 

277 >>> Lang.get("de").set_current() 

278 >>> print(Lang.translate("a")) 

279 c 

280 """ 

281 return Lang.current()[key] 

282 

283 @staticmethod 

284 def translate_call(key: str) -> Callable: 

285 """ 

286 Get a callable that always returns the current translation of a key. 

287 

288 The callable will ignore all of its parameters and just return the 

289 translation. This means that you can pass parameters to it and they 

290 will be ignored. 

291 

292 :param key: the key to translate 

293 :returns: the callable doing the translation 

294 

295 >>> cal = Lang.translate_call("a") 

296 >>> EN.extend({'a': 'b'}) 

297 >>> EN.set_current() 

298 >>> print(cal()) 

299 b 

300 >>> print(cal(1, 2, 3)) 

301 b 

302 >>> DE.extend({'a': 'c'}) 

303 >>> Lang.get("de").set_current() 

304 >>> print(cal("x")) 

305 c 

306 """ 

307 def __trc(*_, ___key=key) -> str: 

308 return Lang.current()[___key] 

309 return __trc 

310 

311 @staticmethod 

312 def translate_func(func: str) -> Callable[[str], str]: 

313 """ 

314 Create a lambda taking a dimensions and presenting a function thereof. 

315 

316 :param func: the function name 

317 :returns: the function 

318 

319 >>> Lang.get("en").set_current() 

320 >>> Lang.get("en").extend({"ERT": "ERT"}) 

321 >>> Lang.get("en").extend({"FEs": "time in FEs"}) 

322 >>> f = Lang.translate_func("ERT") 

323 >>> print(f("FEs")) 

324 ERT\u2009[time in FEs] 

325 >>> Lang.get("de").set_current() 

326 >>> Lang.get("de").extend({"ERT": "ERT"}) 

327 >>> Lang.get("de").extend({"FEs": "Zeit in FEs"}) 

328 >>> print(f("FEs")) 

329 ERT\u2009[Zeit in FEs] 

330 """ 

331 def __tf(dim: str, f: str = func) -> str: 

332 return f"{Lang.translate(f)}\u2009[{Lang.translate(dim)}]" 

333 return __tf 

334 

335 

336# noinspection PyBroadException 

337def __get_font(choices: list[str]) -> str: 

338 """ 

339 Try to find an installed version of the specified font. 

340 

341 :param choices: the choices of the fonts 

342 :returns: the installed name of the font, or the value of `choices[0]` 

343 if no font was found 

344 """ 

345 if not isinstance(choices, list): 

346 raise type_error(choices, "choices", list) 

347 if len(choices) <= 0: 

348 raise ValueError("no font choices are provided!") 

349 attr: Final[str] = "fonts_list" 

350 func: Final = globals()["__get_font"] 

351 

352 # get the list of installed fonts 

353 font_list: list[str] 

354 if not hasattr(func, attr): 

355 font_list = [] 

356 for fname in findSystemFonts(): 

357 try: 

358 font_name = FontProperties( 

359 fname=fname).get_name().strip() 

360 if font_name.encode("ascii", "ignore").decode() == font_name: 

361 font_list.append(font_name) 

362 except Exception: # noqa 

363 # We can ignore the exceptions here. 

364 continue # nosec 

365 if len(font_list) <= 0: 

366 raise ValueError("Did not find any font.") 

367 setattr(func, attr, font_list) 

368 else: 

369 font_list = getattr(func, attr) 

370 

371 # find the installed font 

372 for choice in choices: 

373 clc: str = choice.lower() 

374 found_inside: str | None = None 

375 found_start: str | None = None 

376 for got in font_list: 

377 gotlc = got.lower() 

378 if clc == gotlc: 

379 return got 

380 if gotlc.startswith(clc): 

381 found_start = got 

382 if clc in gotlc: 

383 found_inside = got 

384 if found_start: 

385 return found_start 

386 if found_inside: 

387 return found_inside 

388 

389 return choices[0] # nothing found ... return default 

390 

391 

392#: the English language 

393EN: Final[Lang] = Lang("en", 

394 __get_font(["DejaVu Sans", "Calibri", 

395 "Arial", "Helvetica"]), 

396 3, is_default=True) 

397 

398#: the German language 

399DE: Final[Lang] = Lang("de", EN.font(), 3) 

400 

401#: the Chinese language 

402ZH: Final[Lang] = Lang("zh", 

403 __get_font(["Noto Sans SC", "FangSong", "SimSun", 

404 "Arial Unicode", "SimHei"]), 

405 4) 

406 

407del __get_font # get rid of no-longer needed data such as fonts list