/
i18n.py
425 lines (340 loc) · 13.1 KB
/
i18n.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
# SPDX-License-Identifier: MIT
from __future__ import annotations
import logging
import os
import warnings
from abc import ABC, abstractmethod
from collections import defaultdict
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
DefaultDict,
Dict,
Generic,
Literal,
Optional,
Set,
TypeVar,
Union,
overload,
)
from . import utils
from .custom_warnings import LocalizationWarning
from .enums import Locale
from .errors import LocalizationKeyError
if TYPE_CHECKING:
from typing_extensions import Self
LocalizedRequired = Union[str, "Localized[str]"]
LocalizedOptional = Union[Optional[str], "Localized[Optional[str]]"]
__all__ = (
"Localized",
"Localised",
"LocalizationValue",
"LocalizationProtocol",
"LocalizationStore",
)
MISSING = utils.MISSING
_log = logging.getLogger(__name__)
LocalizationsDict = Union[Dict[Locale, str], Dict[str, str]]
Localizations = Union[str, LocalizationsDict]
StringT = TypeVar("StringT", str, Optional[str], covariant=True)
# This is generic over `string`, as some localized strings can be optional, e.g. option descriptions.
# The basic idea for parameters is this:
# abc: LocalizedRequired
# xyz: LocalizedOptional = None
#
# With that, one may use `abc="somename"` and `abc=Localized("somename", ...)`,
# but not `abc=Localized(None, ...)`. All three work fine for `xyz` though.
class Localized(Generic[StringT]):
"""A container type used for localized parameters.
Exactly one of ``key`` or ``data`` must be provided.
There is an alias for this called ``Localised``.
.. versionadded:: 2.5
Parameters
----------
string: Optional[:class:`str`]
The default (non-localized) value of the string.
Whether this is optional or not depends on the localized parameter type.
key: :class:`str`
A localization key used for lookups.
Incompatible with ``data``.
data: Union[Dict[:class:`.Locale`, :class:`str`], Dict[:class:`str`, :class:`str`]]
A mapping of locales to localized values.
Incompatible with ``key``.
"""
__slots__ = ("string", "localizations")
@overload
def __init__(self: Localized[StringT], string: StringT, *, key: str) -> None:
...
@overload
def __init__(self: Localized[Optional[str]], *, key: str) -> None:
...
@overload
def __init__(
self: Localized[StringT],
string: StringT,
*,
data: Union[Optional[LocalizationsDict], LocalizationValue],
) -> None:
...
@overload
def __init__(
self: Localized[Optional[str]],
*,
data: Union[Optional[LocalizationsDict], LocalizationValue],
) -> None:
...
# note: `data` accepting `LocalizationValue` is intentionally undocumented,
# as it's only meant to be used internally
def __init__(
self,
string: StringT = None,
*,
key: str = MISSING,
data: Union[Optional[LocalizationsDict], LocalizationValue] = MISSING,
) -> None:
self.string: StringT = string
if not (key is MISSING) ^ (data is MISSING):
raise TypeError("Exactly one of `key` or `data` must be provided")
if isinstance(data, LocalizationValue):
self.localizations = data
else:
self.localizations = LocalizationValue(key if key is not MISSING else data)
@overload
@classmethod
def _cast(cls, string: LocalizedOptional, required: Literal[False]) -> Localized[Optional[str]]:
...
@overload
@classmethod
def _cast(cls, string: LocalizedRequired, required: Literal[True]) -> Localized[str]:
...
@classmethod
def _cast(cls, string: Union[Optional[str], Localized[Any]], required: bool) -> Localized[Any]:
if not isinstance(string, Localized):
string = cls(string, data=None)
# enforce the `StringT` type at runtime
if required and string.string is None:
raise ValueError("`string` parameter must be provided")
return string
@overload
def _upgrade(self, *, key: Optional[str]) -> Self:
...
@overload
def _upgrade(self, string: str, *, key: Optional[str] = None) -> Localized[str]:
...
def _upgrade(
self: Localized[Any], string: Optional[str] = None, *, key: Optional[str] = None
) -> Localized[Any]:
# update key if provided and not already set
self.localizations._upgrade(key)
# Only overwrite if not already set (`Localized()` parameter value takes precedence over function names etc.)
# Note: not checking whether `string` is an empty string, to keep generic typing correct
if not self.string and string is not None:
self.string = string
# this is safe, see above
return self
Localised = Localized
class LocalizationValue:
"""Container type for (pending) localization data.
.. versionadded:: 2.5
"""
__slots__ = ("_key", "_data")
def __init__(self, localizations: Optional[Localizations]) -> None:
self._key: Optional[str]
self._data: Optional[Dict[str, str]]
if localizations is None:
# no localization
self._key = None
self._data = None
elif isinstance(localizations, str):
# got localization key
self._key = localizations
self._data = MISSING # not localized yet
elif isinstance(localizations, dict):
# got localization data
self._key = None
self._data = {str(k): v for k, v in localizations.items()}
else:
raise TypeError(f"Invalid localizations type: {type(localizations).__name__}")
def _upgrade(self, key: Optional[str]) -> None:
if not key:
return
# if empty, use new key
if self._key is None and self._data is None:
self._key = key
self._data = MISSING
return
# if key is the same, ignore
if self._key == key:
return
# at this point, the keys don't match, which either means that they're different strings,
# or that there is no existing `_key` but `_data` is set
raise ValueError("Can't specify multiple localization keys or dicts")
def _link(self, store: LocalizationProtocol) -> None:
"""Loads localizations from the specified store if this object has a key."""
if self._key is not None:
self._data = store.get(self._key)
def _copy(self) -> LocalizationValue:
cls = self.__class__
ins = cls.__new__(cls)
ins._key = self._key
ins._data = self._data
return ins
@property
def data(self) -> Optional[Dict[str, str]]:
"""Optional[Dict[:class:`str`, :class:`str`]]: A dict with a locale -> localization mapping, if available."""
if self._data is MISSING:
# This will happen when `_link(store)` hasn't been called yet, which *shouldn't* occur under normal circumstances.
warnings.warn(
f"Localization value ('{self._key}') was never linked to bot; this may be a library bug.",
LocalizationWarning,
stacklevel=2,
)
return None
return self._data
def __eq__(self, other) -> bool:
# if both are pending, compare keys instead
if self._data is MISSING and other._data is MISSING:
return self._key == other._key
d1 = self.data
d2 = other.data
# consider values equal if they're both falsy, or actually equal
# (it doesn't matter if localizations are `None` or `{}`)
return (not d1 and not d2) or d1 == d2
class LocalizationProtocol(ABC):
"""Manages a key-value mapping of localizations.
This is an abstract class, a concrete implementation is provided as :class:`LocalizationStore`.
.. versionadded:: 2.5
"""
@abstractmethod
def get(self, key: str) -> Optional[Dict[str, str]]:
"""Returns localizations for the specified key.
Parameters
----------
key: :class:`str`
The lookup key.
Raises
------
LocalizationKeyError
May be raised if no localizations for the provided key were found,
depending on the implementation.
Returns
-------
Optional[Dict[:class:`str`, :class:`str`]]
The localizations for the provided key.
May return ``None`` if no localizations could be found.
"""
raise NotImplementedError
# subtypes don't have to implement this
def load(self, path: Union[str, os.PathLike]) -> None:
"""Adds localizations from the provided path.
Parameters
----------
path: Union[:class:`str`, :class:`os.PathLike`]
The path to the file/directory to load.
Raises
------
RuntimeError
The provided path is invalid or couldn't be loaded
"""
raise NotImplementedError
# subtypes don't have to implement this
def reload(self) -> None:
"""Clears localizations and reloads all previously loaded sources again.
If an exception occurs, the previous data gets restored and the exception is re-raised.
"""
pass
class LocalizationStore(LocalizationProtocol):
"""Manages a key-value mapping of localizations using ``.json`` files.
.. versionadded:: 2.5
Attributes
----------
strict: :class:`bool`
Specifies whether :meth:`.get` raises an exception if localizations for a provided key couldn't be found.
"""
def __init__(self, *, strict: bool) -> None:
self.strict = strict
self._loc: DefaultDict[str, Dict[str, str]] = defaultdict(dict)
self._paths: Set[Path] = set()
def get(self, key: str) -> Optional[Dict[str, str]]:
"""Returns localizations for the specified key.
Parameters
----------
key: :class:`str`
The lookup key.
Raises
------
LocalizationKeyError
No localizations for the provided key were found.
Raised only if :attr:`strict` is enabled, returns ``None`` otherwise.
Returns
-------
Optional[Dict[:class:`str`, :class:`str`]]
The localizations for the provided key.
Returns ``None`` if no localizations could be found and :attr:`strict` is disabled.
"""
data = self._loc.get(key)
if data is None and self.strict:
raise LocalizationKeyError(key)
return data
def load(self, path: Union[str, os.PathLike]) -> None:
"""Adds localizations from the provided path to the store.
If the path points to a file, the file gets loaded.
If it's a directory, all ``.json`` files in that directory get loaded (non-recursive).
Parameters
----------
path: Union[:class:`str`, :class:`os.PathLike`]
The path to the file/directory to load.
Raises
------
RuntimeError
The provided path is invalid or couldn't be loaded
"""
path = Path(path)
if path.is_file():
self._load_file(path)
elif path.is_dir():
for file in path.glob("*.json"):
if not file.is_file():
continue
self._load_file(file)
else:
raise RuntimeError(f"Path '{path}' does not exist or is not a directory/file")
self._paths.add(path)
def reload(self) -> None:
"""Clears localizations and reloads all previously loaded files/directories again.
If an exception occurs, the previous data gets restored and the exception is re-raised.
See :func:`~LocalizationStore.load` for possible raised exceptions.
"""
old = self._loc
try:
self._loc = defaultdict(dict)
for path in self._paths:
self.load(path)
except Exception:
# restore in case of error
self._loc = old
raise
def _load_file(self, path: Path) -> None:
try:
if path.suffix != ".json":
raise ValueError("not a .json file")
locale = path.stem
if not (api_locale := utils.as_valid_locale(locale)):
raise ValueError(f"invalid locale '{locale}'")
locale = api_locale
data = utils._from_json(path.read_text("utf-8"))
self._load_dict(data, locale)
_log.debug(f"Loaded localizations from '{path}'")
except Exception as e:
raise RuntimeError(f"Unable to load '{path}': {e}") from e
def _load_dict(self, data: Dict[str, Optional[str]], locale: str) -> None:
if not isinstance(data, dict) or not all(
o is None or isinstance(o, str) for o in data.values()
):
raise TypeError("data must be a flat dict with string/null values")
for key, value in data.items():
d = self._loc[key] # always create dict, regardless of value
if value is not None:
d[locale] = value