421 lines
13 KiB
Python
421 lines
13 KiB
Python
# 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:
|
|
warnings.warn(
|
|
"value was never localized, this is likely a library bug",
|
|
LocalizationWarning,
|
|
stacklevel=2,
|
|
)
|
|
return None
|
|
return self._data
|
|
|
|
def __eq__(self, other) -> bool:
|
|
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, 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
|