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
« 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.
4The idea here is to have simply tools that provide locale-specific keywords,
5texts, and number formats.
6"""
8from typing import Callable, Final, Iterable
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
17from moptipy.utils.strings import sanitize_name
20class Lang:
21 """A language-based dictionary for locale-specific keywords."""
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.
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)
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)
51 #: the dictionary with the translation data
52 self.__dict: Final[dict[str, str]] = {}
53 if data:
54 self.extend(data)
56 #: is this the default language?
57 self.__is_default: Final[bool] = is_default
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
71 if is_default:
72 self.set_current()
74 def extend(self, data: dict[str, str]) -> None:
75 """
76 Add a set of entries to this dictionary.
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
97 def filename(self, base: str) -> str:
98 """
99 Make a suitable filename by appending the language id.
101 :param str base: the basename
102 :return: the filename
103 :rtype: str
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}"
113 def __repr__(self):
114 """
115 Get the language's name.
117 :return: the language's name
118 :rtype: str
119 """
120 return self.__name
122 def __getitem__(self, item: str) -> str:
123 """
124 Get the language formatting code.
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]
133 def format_str(self, item: str, **kwargs) -> str:
134 """
135 Return a string based on the specified format.
137 :param item: the key
138 :param kwargs: the keyword-based arguments
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
154 def font(self) -> str:
155 """
156 Get the default font for this language.
158 :return: the default font for this language
160 >>> print(Lang.get("en").font())
161 DejaVu Sans
162 >>> print(Lang.get("zh").font())
163 Noto Sans SC
164 """
165 return self.__font
167 def format_int(self, value: int) -> str:
168 """
169 Convert an integer to a string.
171 :param value: the value
172 :returns: a string representation of the value
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 = ""
187 sss = str(value)
188 i = len(sss)
189 if i <= self.__decimal_stepwidth: # no formatting needed
190 return prefix + sss
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)
203 @staticmethod
204 def __get_langs() -> dict[str, "Lang"]:
205 """
206 Get the languages decode.
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)
215 @staticmethod
216 def get(name: str) -> "Lang":
217 """
218 Get the language of the given key.
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}.")
229 @staticmethod
230 def current() -> "Lang":
231 """
232 Get the current language.
234 :return: the current language
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
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())
253 @staticmethod
254 def all_langs() -> Iterable["Lang"]:
255 """
256 Get all presently loaded languages.
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
264 @staticmethod
265 def translate(key: str) -> str:
266 """
267 Translate the given key to a string in the current language.
269 :param key: the key
270 :returns: the value of the key in the current language
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]
283 @staticmethod
284 def translate_call(key: str) -> Callable:
285 """
286 Get a callable that always returns the current translation of a key.
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.
292 :param key: the key to translate
293 :returns: the callable doing the translation
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
311 @staticmethod
312 def translate_func(func: str) -> Callable[[str], str]:
313 """
314 Create a lambda taking a dimensions and presenting a function thereof.
316 :param func: the function name
317 :returns: the function
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
336# noinspection PyBroadException
337def __get_font(choices: list[str]) -> str:
338 """
339 Try to find an installed version of the specified font.
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"]
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)
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
389 return choices[0] # nothing found ... return default
392#: the English language
393EN: Final[Lang] = Lang("en",
394 __get_font(["DejaVu Sans", "Calibri",
395 "Arial", "Helvetica"]),
396 3, is_default=True)
398#: the German language
399DE: Final[Lang] = Lang("de", EN.font(), 3)
401#: the Chinese language
402ZH: Final[Lang] = Lang("zh",
403 __get_font(["Noto Sans SC", "FangSong", "SimSun",
404 "Arial Unicode", "SimHei"]),
405 4)
407del __get_font # get rid of no-longer needed data such as fonts list