""" The MIT License (MIT) Copyright (c) 2021-present Disnake Development Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ 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): ... @overload def __init__(self: Localized[Optional[str]], *, key: str): ... @overload def __init__( self: Localized[StringT], string: StringT, *, data: Union[Optional[LocalizationsDict], LocalizationValue], ): ... @overload def __init__( self: Localized[Optional[str]], *, data: Union[Optional[LocalizationsDict], LocalizationValue], ): ... # 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, ): 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, 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]): 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) @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): 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(f"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