Source code for ezcord.i18n

from __future__ import annotations

import inspect
import random
import re
from collections.abc import Callable
from copy import deepcopy
from pathlib import Path
from typing import TYPE_CHECKING, Literal, Union, overload

from .internal.dc import PYCORD, discord
from .logs import log

MESSAGE_SEND = discord.abc.Messageable.send
MESSAGE_REPLY = discord.Message.reply
MESSAGE_EDIT = discord.Message.edit

INTERACTION_SEND = discord.InteractionResponse.send_message
INTERACTION_EDIT = discord.InteractionResponse.edit_message
INTERACTION_MODAL = discord.InteractionResponse.send_modal

WEBHOOK_SEND = discord.Webhook.send
WEBHOOK_EDIT_MESSAGE = discord.Webhook.edit_message
WEBHOOK_EDIT = discord.WebhookMessage.edit

LOCALE = Union[str]  # noqa: UP007

if PYCORD:
    INTERACTION_EDIT_ORIGINAL = discord.Interaction.edit_original_response
    if hasattr(discord.Interaction, "respond"):
        # older py-cord versions might not have this method
        INTERACTION_RESPOND = discord.Interaction.respond
    else:
        INTERACTION_RESPOND = None
else:
    INTERACTION_RESPOND = None
    if hasattr(discord.Interaction, "edit_original_message"):
        INTERACTION_EDIT_ORIGINAL = discord.Interaction.edit_original_message
    else:
        INTERACTION_EDIT_ORIGINAL = None


if TYPE_CHECKING:
    import discord  # type: ignore

    LOCALE = Union[  # type: ignore  # noqa: UP007
        discord.Interaction,
        discord.ApplicationContext,
        discord.InteractionResponse,
        discord.Webhook,
        discord.Guild,
        discord.Member,
        str,
    ]

__all__ = ("I18N", "LOCALE", "TEmbed", "t")


def _no_lowercase(s: str) -> bool:
    """Check if a string contains no lowercase letters."""
    return not any(c.islower() for c in s)


[docs] def t(obj: LOCALE | str, key: str, count: int | None = None, **variables) -> str: """Get the localized string for the given key and insert all variables. Parameters ---------- obj: The object to get the locale from. key: The key of the string in the language file. count: The count for pluralization. Defaults to ``None``. """ locale = I18N.get_locale(obj) return I18N.load_text(key, locale, count, **variables)
[docs] class TEmbed(discord.Embed): """A subclass of :class:`discord.Embed` for localized embeds. Parameters ---------- key: The key of the embed in the language file. kwargs: Parameters from :class:`discord.Embed` or custom variables. """ def __init__(self, key: str = "embed", **kwargs): variables, kwargs = _extract_parameters(discord.Embed.__init__, **kwargs) super().__init__(**kwargs) self.key = key self.variables = variables _, method, class_ = I18N.get_location() self.method_name = method self.class_name = class_
def _extract_parameters(func, **kwargs): """Extract all kwargs that are not part of the function signature and returns them as a dictionary of variables. """ params = inspect.signature(func).parameters variables = {key: kwargs.pop(key) for key, value in kwargs.copy().items() if key not in params} return variables, kwargs def _check_embed(locale: str, count: int | None, variables: dict, **kwargs): """Check if the kwargs contain an embed. Returns the updated kwargs. - Embed is a TEmbed: Load the embed from the language file. - Embed is a default Embed: Load all keys inside the embed from the language file """ add_locations: tuple = () embed = kwargs.get("embed") if isinstance(embed, TEmbed): variables = {**variables, **embed.variables} add_locations = (embed.method_name, embed.class_name) embed = I18N.load_embed(embed, locale) if embed: if "count" in variables: count = variables["count"] new_embed_dict = I18N.load_lang_keys( embed.to_dict(), locale, count, add_locations, **variables ) kwargs["embed"] = discord.Embed.from_dict(new_embed_dict) return kwargs def _check_embeds(locale: str, count: int | None, variables: dict, **kwargs): """Check if the kwargs contain an embed list. Returns the updated kwargs.""" add_locations: tuple = () embeds = kwargs.get("embeds") if not embeds: return kwargs new_embeds = [] for embed in embeds: if isinstance(embed, TEmbed): variables = {**variables, **embed.variables} add_locations = (embed.method_name, embed.class_name) embed = I18N.load_embed(embed, locale) if "count" in variables: count = variables["count"] new_embed_dict = I18N.load_lang_keys( embed.to_dict(), locale, count, add_locations, **variables ) new_embeds.append(discord.Embed.from_dict(new_embed_dict)) kwargs["embeds"] = new_embeds return kwargs def _check_components(component, locale: str, count: int | None, class_name: str, **variables): """Recursively walks through components and loads language file keys.""" if isinstance(component, discord.ui.Container): for item in component.items: _check_components(item, locale, count, class_name, **variables) if isinstance(component, discord.ui.ActionRow): for item in component.children: _check_components(item, locale, count, class_name, **variables) if isinstance(component, discord.ui.Section): for item in component.items: _check_components(item, locale, count, class_name, **variables) _check_components(component.accessory, locale, count, class_name, **variables) # TextDisplay if hasattr(component, "content"): component.content = I18N.load_text( component.content, locale, count, class_name, **variables ) if isinstance(component, discord.ui.Button) and hasattr(component, "label"): component.label = I18N.load_text(component.label, locale, count, class_name, **variables) if hasattr(component, "placeholder"): component.placeholder = I18N.load_text( component.placeholder, locale, count, class_name, **variables ) if hasattr(component, "options"): for option in component.options: option.label = I18N.load_text(option.label, locale, count, class_name, **variables) option.description = I18N.load_text( option.description, locale, count, class_name, **variables ) def _check_view(locale: str, count: int | None, variables: dict, **kwargs): """Load all keys inside the view from the language file.""" if "count" in variables: count = variables["count"] view = kwargs.get("view") if view: class_name = view.__class__.__name__ for child in view.children: if isinstance(child, discord.ui.ActionRow): # check each item inside the action row with the class name of that item for item in child.children: class_name = item.__class__.__name__ _check_components(item, locale, count, class_name, **variables) continue if type(child) not in [discord.ui.Select, discord.ui.Button]: # if a child element of the view has its own subclass, search for this class name # in the language file instead of the view name class_name = child.__class__.__name__ _check_components(child, locale, count, class_name, **variables) return kwargs def _localize_send(send_func): async def wrapper( self: ( discord.InteractionResponse | discord.Webhook | discord.abc.Messageable | discord.Interaction ), content=None, *, count: int | None = None, locale: LOCALE | None = None, **kwargs, ): """Wrapper to localize the content and the embed of a message. Parameters ---------- self: The object to send the message from. content: The content of the message. count: The count for pluralization. Defaults to ``None``. locale: Use a specific object to extract the locale from. This is useful for DMs or followup messages. Defaults to ``None``. """ if isinstance(self, discord.Interaction): # This is used for cases where followup.send is executed inside of interaction.respond, # because the locale can't be extracted from application webhooks return await send_func(self, content, count=count, locale=self, **kwargs) locale_str = I18N.get_locale(locale or self) variables, kwargs = _extract_parameters(send_func, **kwargs) # Check content content = I18N.load_text(content, locale_str, **variables) kwargs = _check_embed(locale_str, count, variables, **kwargs) kwargs = _check_embeds(locale_str, count, variables, **kwargs) kwargs = _check_view(locale_str, count, variables, **kwargs) return await send_func(self, content, **kwargs) return wrapper def _localize_edit(edit_func): async def wrapper( self: discord.InteractionResponse | discord.Interaction | discord.Message | discord.Webhook, message_id: int | None = None, *, count: int | None = None, locale: LOCALE | None = None, **kwargs, ): """Localizes message edit functions. The message_id is only needed for followup.edit_message, because it's a positional argument in the original function. The parameter "locale" is only needed for followup.edit_message, because the locale can't be extracted automatically. """ locale_str = I18N.get_locale(locale or self) variables, kwargs = _extract_parameters(edit_func, **kwargs) # Check content (must be a kwarg) content = kwargs.get("content") if content: new_content = I18N.load_text(content, locale_str, count, **variables) kwargs["content"] = new_content kwargs = _check_embed(locale_str, count, variables, **kwargs) kwargs = _check_embeds(locale_str, count, variables, **kwargs) kwargs = _check_view(locale_str, count, variables, **kwargs) if isinstance(self, discord.Webhook): return await edit_func(self, message_id, **kwargs) return await edit_func(self, **kwargs) return wrapper async def _localize_modal( self: discord.InteractionResponse, modal: discord.ui.Modal, *, count: int | None = None, **kwargs, ): locale = I18N.get_locale(self) variables, kwargs = _extract_parameters(INTERACTION_MODAL, **kwargs) modal_name = modal.__class__.__name__ modal.title = I18N.load_text(modal.title, locale, count, modal_name, **variables) def check_attribute(item, attribute: str): if hasattr(item, attribute): value = getattr(item, attribute) localized_value = I18N.load_text(value, locale, count, modal_name, **variables) setattr(item, attribute, localized_value) for child in modal.children: if isinstance(child, discord.ui.Label): # DesignerModal _check_components(child.item, locale, count, modal_name, **variables) if hasattr(child, "item"): check_attribute(child.item, "placeholder") check_attribute(child, "label") check_attribute(child, "description") check_attribute(child, "content") check_attribute(child, "placeholder") check_attribute(child, "value") return await INTERACTION_MODAL(self, modal)
[docs] class I18N: """A simple class to handle the localization of strings. A list of available languages is available here: https://discord.com/developers/docs/reference#locales .. note:: Methods in this class are called automatically and do not need to be called manually in most cases. Parameters ---------- localizations: A dictionary containing the localizations for all strings. If an ``en`` key is found, the values will be used for both ``en-GB`` and ``en-US``. fallback_locale: The locale to use if the user's locale is not found in the localizations. Defaults to ``en-US``. process_strings: Whether to replace general variables when loading the language file. Defaults to ``True``. prefer_user_locale: Whether to prefer the user's locale over the guild's locale. Defaults to ``False``. localize_numbers: This sets the thousands separator to a period or comma, depending on the current locale. Defaults to ``True``. ignore_discord_ids: Whether to not localize numbers that could be a Discord ID. Default to ``True``. This only has an effect if ``localize_numbers`` is set to ``True``. do_not_localize: If a key starts with this string, it will not be localized. Defaults to ``".."``. exclude_methods: Method names to exclude from the search of keys in the language file. disable_translations: A list of translations to disable. Defaults to ``None``. The log level in :meth:`ezcord.logs.set_log` must be set to ``DEBUG`` for this to work. debug: Whether to send debug messages and warnings. Defaults to ``True``. language_settings: A function to set custom language settings. The function takes user/guild IDs and returns a locale or None. Defaults to ``None``. user_locale_settings: A function to dynamically set `prefer_user_locale` per guild. The function takes a guild IDs and returns a boolean. Defaults to ``None``. variables: Additional variables to replace in the language file. This is useful for values that are the same in all languages. """ localizations: dict[str, dict] fallback_locale: str prefer_user_locale: bool = False localize_numbers: bool ignore_discord_ids: bool do_not_localize: str = ".." exclude_methods: list[str] | None _general_values: dict = {} # general values for the current localization _current_general: dict = {} # general values for the current group cmd_localizations: dict[str, dict] = {} # set through bot.localize_commands initialized: bool = False _custom_language_settings: Callable[[int], int | None] | None = None _custom_user_locale_settings: Callable[[int], bool] | None = None def __init__( self, localizations: dict[str, dict], *, fallback_locale: str = "en-US", process_strings: bool = True, prefer_user_locale: bool = False, localize_numbers: bool = True, ignore_discord_ids: bool = True, do_not_localize: str = "..", exclude_methods: list[str] | None = None, disable_translations: ( list[ Literal[ "send", "edit", "reply", "send_message", "send_modal", "edit_message", "edit_original_response", "webhook_send", "webhook_edit_message", ] ] | None ) = None, debug: bool = True, language_settings: Callable[[int], int | None] | None = None, user_locale_settings: Callable[[int], bool] | None = None, **variables, ): I18N.initialized = True if "en" in localizations: en = localizations.pop("en") localizations["en-GB"] = en localizations["en-US"] = en if fallback_locale == "en": fallback_locale = "en-US" if process_strings: for var in variables.keys(): if not _no_lowercase(var): raise ValueError(f"Custom variable key '{var}' must be uppercase.") I18N.localizations = self._process_strings(localizations, **variables) else: I18N.localizations = localizations I18N.fallback_locale = fallback_locale I18N.prefer_user_locale = prefer_user_locale I18N.localize_numbers = localize_numbers I18N.ignore_discord_ids = ignore_discord_ids if not exclude_methods: exclude_methods = [] I18N.exclude_methods = exclude_methods I18N.do_not_localize = do_not_localize I18N._custom_language_settings = language_settings I18N._custom_user_locale_settings = user_locale_settings if not disable_translations: disable_translations = [] if debug: I18N._check_localizations() if "send" not in disable_translations: setattr(discord.abc.Messageable, "send", _localize_send(MESSAGE_SEND)) if "edit" not in disable_translations: setattr(discord.Message, "edit", _localize_edit(MESSAGE_EDIT)) if "reply" not in disable_translations: setattr(discord.Message, "reply", _localize_send(MESSAGE_REPLY)) if "send_message" not in disable_translations: setattr(discord.InteractionResponse, "send_message", _localize_send(INTERACTION_SEND)) if "send_modal" not in disable_translations: setattr(discord.InteractionResponse, "send_modal", _localize_modal) if "edit_message" not in disable_translations: setattr(discord.InteractionResponse, "edit_message", _localize_edit(INTERACTION_EDIT)) if "edit_original_response" not in disable_translations: setattr( discord.Interaction, "edit_original_response", _localize_edit(INTERACTION_EDIT_ORIGINAL), ) if "webhook_send" not in disable_translations: setattr(discord.Webhook, "send", _localize_send(WEBHOOK_SEND)) setattr(discord.Interaction, "respond", _localize_send(INTERACTION_RESPOND)) if "webhook_edit_message" not in disable_translations: setattr(discord.Webhook, "edit_message", _localize_edit(WEBHOOK_EDIT_MESSAGE)) if "webhook_edit_message" not in disable_translations: setattr(discord.WebhookMessage, "edit_message", _localize_edit(WEBHOOK_EDIT))
[docs] @staticmethod def get_locale(obj: LOCALE) -> str: """Get the locale from the given object. By default, this is the guild's locale. This method can be called even if the I18N class has not been initialized. Parameters ---------- obj: The object to get the locale from. """ if isinstance(obj, str): if hasattr(I18N, "localizations") and obj not in I18N.localizations: return I18N.fallback_locale return obj guild_id, user_id = None, None # determine interaction interaction, locale = None, None if isinstance(obj, discord.Interaction): interaction = obj elif isinstance(obj, discord.InteractionResponse): interaction = obj._parent elif isinstance(obj, discord.ApplicationContext): interaction = obj.interaction # determine locale elif isinstance(obj, discord.Webhook) and obj.guild: locale = obj.guild.preferred_locale guild_id = obj.guild.id elif isinstance(obj, discord.Member): locale = obj.guild.preferred_locale guild_id = obj.guild.id user_id = obj.id elif isinstance(obj, discord.Guild): locale = obj.preferred_locale guild_id = obj.id elif ( isinstance(obj, discord.abc.Messageable | discord.Message) and hasattr(obj, "guild") and obj.guild ): locale = obj.guild.preferred_locale guild_id = obj.guild.id elif isinstance(obj, discord.User): # It's not possible to determine the user locale without an interaction locale = I18N.fallback_locale user_id = obj.id custom_locale = None if interaction: if interaction.guild and not I18N.prefer_user_locale: locale = interaction.guild_locale guild_id = interaction.guild.id else: # prevent language setting from overriding the user locale # when prefer_user_locale is True custom_locale = interaction.locale user_id = interaction.user.id if I18N._custom_user_locale_settings and guild_id: prefer_user_locale = I18N._custom_user_locale_settings(guild_id) if prefer_user_locale: custom_locale = interaction.locale user_id = interaction.user.id # check custom language settings if I18N._custom_language_settings and not custom_locale: if guild_id: custom_locale = I18N._custom_language_settings(guild_id) if user_id: custom_locale = I18N._custom_language_settings(user_id) locale = custom_locale or locale # check if the locale is available. if not, use the fallback locale if hasattr(I18N, "localizations"): if locale not in I18N.localizations: return I18N.fallback_locale return locale return locale
[docs] @staticmethod def get_clean_locale(obj: LOCALE) -> str: """Get the clean locale from the given object. This is the locale without the region, e.g. ``en`` instead of ``en-US``. Parameters ---------- obj: The object to get the locale from. """ locale = I18N.get_locale(obj) return str(locale).split("-")[0]
[docs] @staticmethod def get_location(): """Returns the name of the file, method and class for the current interaction. This can only get the class if a method was executed from inside the class. """ inspect_stack = inspect.stack() # Ignore the following internal sources to determine the origin method # "sub" is used for regex substitution when searching for keys within other keys. methods = ["respond", "sub"] + I18N.exclude_methods files = ["i18n", "emb", "interactions"] file, method, class_ = None, None, None for i in inspect_stack[2:]: if i.function not in methods and Path(i.filename).stem not in files: try: class_ = i.frame.f_locals["self"].__class__.__name__ except KeyError: pass # No class found file = i.filename method = i.function break if file: file = Path(file).stem return file, method, class_
@staticmethod def _replace_variables(string: str, locale: str, **variables): """Replace all given variables in the string. Supports localized numbers. Example ------- >>> I18N._replace_variables("Hello {name}", name="Timo") "Hello Timo" """ if not string: return string for key, value in variables.items(): if I18N.localize_numbers and isinstance(value, int): if not (I18N.ignore_discord_ids and len(str(value)) >= 17): value = f"{value:,}" if locale == "de": value = value.replace(",", ".") string = string.replace("{" + key + "}", str(value)) return string @staticmethod def _get_text( key: str, locale: str, count: int | None, called_class: str | None, add_locations: tuple ) -> str: """Looks for the specified key in different locations of the language file.""" if key.startswith(I18N.do_not_localize): return key.replace(I18N.do_not_localize, "", 1) file_name, method_name, class_name = I18N.get_location() lookups: list[list | tuple] if "." in key: lookups = [key.split("."), [file_name] + key.split(".")] else: lookups = [ (file_name, method_name, key), (file_name, called_class, key), (file_name, class_name, key), (file_name, "general", key), ("general", key), (file_name, key), ] for location in add_locations: lookups.append((file_name, location, key)) localizations = I18N.localizations[locale] for lookup in lookups: current_section = localizations.copy() for location in lookup: try: current_section = current_section.get(location, {}) except AttributeError: return key txt = current_section if isinstance(txt, str): return txt elif isinstance(txt, int): return str(txt) elif isinstance(txt, list): return random.choice(txt) elif count is not None and isinstance(txt, dict): # Load pluralization if available if count == 0 and "zero" in txt: return txt["zero"] elif count == 1 and "one" in txt: return txt["one"] elif count > 1 and "many" in txt: return txt["many"] elif str(count) in txt: return txt[str(count)] return key @overload @staticmethod def load_text( key: None, locale: str, count: int | None = ..., called_class: str | None = ..., add_locations: tuple = ..., **variables, ) -> None: ... @overload @staticmethod def load_text( key: str, locale: str, count: int | None = ..., called_class: str | None = ..., add_locations: tuple = ..., **variables, ) -> str: ...
[docs] @staticmethod def load_text( key: str, locale: str, count: int | None = None, called_class: str | None = None, add_locations: tuple = (), **variables, ) -> str | None: """A helper methods that calls :meth:`get_text` to load the specified key and :meth:`replace_variables` to replace the variables. A class name can be given if the kwargs contain a view or modal. This name will be used to load strings from the init method or from decorators, as the class can only be fetched automatically if a method was executed from inside the class. Additional locations can be added to the lookup by passing a tuple of strings. This is used to load embed keys from the location of the embed creation, instead of the location of the embed usage. """ if key is None: return None string = I18N._get_text(key, locale, count, called_class, add_locations) if count: variables = {**variables, "count": count} string = I18N._replace_variables(string, locale, **variables) def replace_keys(m: re.Match): k = m.group(1) check_key = I18N._get_text(k, locale, count, called_class, add_locations) return check_key if check_key != k else m.group() # check if key contains other keys if "{" in string and "}" in string: string = re.sub(r"{(.*?)}", replace_keys, string) return I18N._replace_variables(string, locale, **variables)
[docs] @staticmethod def load_embed(embed: TEmbed, locale: str) -> discord.Embed: """Loads an embed from the language file.""" file_name, method_name, class_name = I18N.get_location() # search not only the location of the embed usage, # but also the location of the embed creation original_method, original_class = embed.method_name, embed.class_name lookups: list[list | tuple] if "." in embed.key: lookups = [embed.key.split("."), [file_name] + embed.key.split(".")] else: lookups = [ (file_name, method_name, embed.key), (file_name, original_method, embed.key), (file_name, original_class, embed.key), (file_name, class_name, embed.key), (file_name, embed.key), ] localizations = I18N.localizations[locale] for lookup in lookups: current_section = localizations.copy() for location in lookup: current_section = current_section.get(location, {}) if current_section: t_embed_dict = embed.to_dict() for key, value in current_section.items(): t_embed_dict[key] = value return discord.Embed.from_dict(t_embed_dict) return discord.Embed(description=embed.key, color=discord.Color.blurple())
[docs] @staticmethod def load_lang_keys( content: dict | str, locale: str, count: int | None = None, add_locations: tuple = (), **variables, ) -> dict | str: """Iterates through the content, loads the keys from the language file and replaces all variables with their values. Does not modify the original content. """ if isinstance(content, str): return I18N.load_text(content, locale, count, add_locations=add_locations, **variables) content = deepcopy(content) for key, value in content.items(): if isinstance(value, str): if key in ["color", "colour", "type", "url", "timestamp", "image", "thumbnail"]: continue content[key] = I18N.load_text( value, locale, count, add_locations=add_locations, **variables ) elif isinstance(value, list): items = [] for element in value: items.append( I18N.load_lang_keys(element, locale, count, add_locations, **variables) ) content[key] = items elif isinstance(value, dict): content[key] = I18N.load_lang_keys(value, locale, count, add_locations, **variables) return content
@staticmethod def _replace_general_variables(string: str) -> str: """Replaces global and local general variables with their values.""" def replace_local(match: re.Match): match = match.group().replace("{.", "").replace("}", "") if match in I18N._current_general: return I18N._current_general[match] if match in I18N._general_values: return I18N._general_values[match] return match def replace_global(possible_match: re.Match) -> str: match = possible_match.group() clean_match = match.replace("{", "").replace("}", "") if clean_match in I18N._general_values and _no_lowercase(str(clean_match)): if type(I18N._general_values[clean_match]) is str: return I18N._general_values[clean_match] return str(match) string = re.sub(r"{\..*?}", replace_local, string) string = re.sub(r"{.*?}", replace_global, string) return string @staticmethod def _replace_dict(content: dict | str) -> dict | str: """Iterates through the content and replaces all general variables with their values. This is only needed once when loading the language file. """ if isinstance(content, dict) and "general" in content: I18N._current_general = content["general"] if isinstance(content, str): return I18N._replace_general_variables(content) for key, value in content.items(): if isinstance(value, str): content[key] = I18N._replace_general_variables(value) if isinstance(value, list): items = [] for element in value: items.append(I18N._replace_dict(element)) content[key] = items elif isinstance(value, dict): content[key] = I18N._replace_dict(value) return content @staticmethod def _process_strings(localizations: dict, **variables) -> dict: """Process all strings and replace general variables when loading the language file. A general variable is defined in one of the "general" sections of the language files. """ new_dict = {} for locale, values in localizations.items(): if "general" in values: I18N._general_values = {**values["general"], **variables} else: I18N._general_values = variables new_dict[locale] = I18N._replace_dict(values) return new_dict @staticmethod def _find_missing_keys(fallback: dict, current_locale: dict): """Find keys and sub-keys that are missing in the current locale.""" missing_keys = [] def explore_dict(original: dict, current: dict, path: str): for key, value in original.items(): if key not in current: missing_keys.append(f"{path}.{key}".lstrip(".")) elif isinstance(value, dict) and isinstance(current.get(key), dict): explore_dict(value, current[key], f"{path}.{key}") explore_dict(fallback, current_locale, "") return missing_keys @staticmethod def _check_localizations(): """Checks if all locales have the same keys.""" for locale, values in I18N.localizations.items(): missing_keys = I18N._find_missing_keys(I18N.localizations[I18N.fallback_locale], values) if len(missing_keys) > 0: log.warning( f"Locale '{locale}' misses some keys from the fallback locale: {missing_keys}" )