Adding all files
This commit is contained in:
419
.local/lib/python3.14/site-packages/disnake/i18n.py
Normal file
419
.local/lib/python3.14/site-packages/disnake/i18n.py
Normal file
@@ -0,0 +1,419 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user