from __future__ import annotations
import contextlib
import functools
import io
import os
import logging
import discord
from pathlib import Path
from typing import Callable, TYPE_CHECKING, Union, Dict, Optional, TypeVar
import babel.localedata
from babel.core import Locale
from redbot.core import _i18n
from redbot.core._i18n import (
current_locale as _current_locale,
current_regional_format as _current_regional_format,
set_contextual_locale as _set_contextual_locale,
set_contextual_regional_format as _set_contextual_regional_format,
)
if TYPE_CHECKING:
from redbot.core.bot import Red
__all__ = (
"get_locale",
"get_regional_format",
"set_contextual_locale",
"set_contextual_regional_format",
"get_locale_from_guild",
"get_regional_format_from_guild",
"set_contextual_locales_from_guild",
"Translator",
"get_babel_locale",
"get_babel_regional_format",
"cog_i18n",
)
log = logging.getLogger("red.i18n")
WAITING_FOR_MSGID = 1
IN_MSGID = 2
WAITING_FOR_MSGSTR = 3
IN_MSGSTR = 4
MSGID = 'msgid "'
MSGSTR = 'msgstr "'
[docs]def get_locale() -> str:
"""
Get locale in a current context.
Returns
-------
str
Current locale's language code with country code included, e.g. "en-US".
"""
return _current_locale.get(_i18n.current_locale_default)
[docs]def set_contextual_locale(language_code: str, /) -> str:
"""
Set contextual locale (without regional format) to the given value.
Parameters
----------
language_code: str
Locale's language code with country code included, e.g. "en-US".
Returns
-------
str
Standardized locale name.
Raises
------
ValueError
Language code is invalid.
"""
return _set_contextual_locale(language_code, verify_language_code=True)
[docs]def set_contextual_regional_format(language_code: Optional[str], /) -> Optional[str]:
"""
Set contextual regional format to the given value.
Parameters
----------
language_code: str, optional
Contextual regional's language code with country code included, e.g. "en-US"
or ``None`` if regional format should inherit the contextual locale's value.
Returns
-------
str
Standardized locale name or ``None`` if ``None`` was passed.
Raises
------
ValueError
Language code is invalid.
"""
return _set_contextual_regional_format(language_code, verify_language_code=True)
[docs]async def get_locale_from_guild(bot: Red, guild: Optional[discord.Guild]) -> str:
"""
Get locale set for the given guild.
Parameters
----------
bot: Red
The bot's instance.
guild: Optional[discord.Guild]
The guild contextual locale is set for.
Use `None` if the context doesn't involve guild.
Returns
-------
str
Guild locale's language code with country code included, e.g. "en-US".
"""
return await bot._i18n_cache.get_locale(guild)
[docs]async def set_contextual_locales_from_guild(bot: Red, guild: Optional[discord.Guild]) -> None:
"""
Set contextual locales (locale and regional format) for given guild context.
Parameters
----------
bot: Red
The bot's instance.
guild: Optional[discord.Guild]
The guild contextual locale is set for.
Use `None` if the context doesn't involve guild.
"""
locale = await get_locale_from_guild(bot, guild)
regional_format = await get_regional_format_from_guild(bot, guild)
_set_contextual_locale(locale)
_set_contextual_regional_format(regional_format)
def _parse(translation_file: io.TextIOWrapper) -> Dict[str, str]:
"""
Custom gettext parsing of translation files.
Parameters
----------
translation_file : io.TextIOWrapper
An open text file containing translations.
Returns
-------
Dict[str, str]
A dict mapping the original strings to their translations. Empty
translated strings are omitted.
"""
step = None
untranslated = ""
translated = ""
translations = {}
locale = get_locale()
translations[locale] = {}
for line in translation_file:
line = line.strip()
if line.startswith(MSGID):
# New msgid
if step is IN_MSGSTR and translated:
# Store the last translation
translations[locale][_unescape(untranslated)] = _unescape(translated)
step = IN_MSGID
untranslated = line[len(MSGID) : -1]
elif line.startswith('"') and line.endswith('"'):
if step is IN_MSGID:
# Line continuing on from msgid
untranslated += line[1:-1]
elif step is IN_MSGSTR:
# Line continuing on from msgstr
translated += line[1:-1]
elif line.startswith(MSGSTR):
# New msgstr
step = IN_MSGSTR
translated = line[len(MSGSTR) : -1]
if step is IN_MSGSTR and translated:
# Store the final translation
translations[locale][_unescape(untranslated)] = _unescape(translated)
return translations
def _unescape(string):
string = string.replace(r"\\", "\\")
string = string.replace(r"\t", "\t")
string = string.replace(r"\r", "\r")
string = string.replace(r"\n", "\n")
string = string.replace(r"\"", '"')
return string
def _get_locale_path(cog_folder: Path, extension: str) -> Path:
"""
Gets the folder path containing localization files.
:param Path cog_folder:
The cog folder that we want localizations for.
:param str extension:
Extension of localization files.
:return:
Path of possible localization file, it may not exist.
"""
return cog_folder / "locales" / "{}.{}".format(get_locale(), extension)
[docs]class Translator(Callable[[str], str]):
"""Function to get translated strings at runtime."""
def __init__(self, name: str, file_location: Union[str, Path, os.PathLike]):
"""
Initializes an internationalization object.
Parameters
----------
name : str
Your cog name.
file_location : `str` or `pathlib.Path`
This should always be ``__file__`` otherwise your localizations
will not load.
"""
self.cog_folder = Path(file_location).resolve().parent
self.cog_name = name
self.translations = {}
_i18n.translators.append(self)
self.load_translations()
[docs] def __call__(self, untranslated: str) -> str:
"""Translate the given string.
This will look for the string in the translator's :code:`.pot` file,
with respect to the current locale.
"""
locale = get_locale()
try:
return self.translations[locale][untranslated]
except KeyError:
return untranslated
[docs] def load_translations(self):
"""
Loads the current translations.
"""
locale = get_locale()
if locale.lower() == "en-us":
# Red is written in en-US, no point in loading it
return
if locale in self.translations:
# Locales cannot be loaded twice as they have an entry in
# self.translations
return
locale_path = _get_locale_path(self.cog_folder, "po")
with contextlib.suppress(IOError, FileNotFoundError):
with locale_path.open(encoding="utf-8") as file:
self._parse(file)
def _parse(self, translation_file):
self.translations.update(_parse(translation_file))
def _add_translation(self, untranslated, translated):
untranslated = _unescape(untranslated)
translated = _unescape(translated)
if translated:
self.translations[untranslated] = translated
@functools.lru_cache()
def _get_babel_locale(red_locale: str) -> babel.core.Locale:
supported_locales = babel.localedata.locale_identifiers()
try: # Handles cases where red_locale is already Babel supported
babel_locale = Locale(*babel.parse_locale(red_locale))
except (ValueError, babel.core.UnknownLocaleError):
try:
babel_locale = Locale(*babel.parse_locale(red_locale, sep="-"))
except (ValueError, babel.core.UnknownLocaleError):
# ValueError is Raised by `parse_locale` when an invalid Locale is given to it
# Lets handle it silently and default to "en_US"
try:
# Try to find a babel locale that's close to the one used by red
babel_locale = Locale(Locale.negotiate([red_locale], supported_locales, sep="-"))
except (ValueError, TypeError, babel.core.UnknownLocaleError):
# If we fail to get a close match we will then default to "en_US"
babel_locale = Locale("en", "US")
return babel_locale
[docs]def get_babel_locale(locale: Optional[str] = None) -> babel.core.Locale:
"""Function to convert a locale to a `babel.core.Locale`.
Parameters
----------
locale : Optional[str]
The locale to convert, if not specified it defaults to the bot's locale.
Returns
-------
babel.core.Locale
The babel locale object.
"""
if locale is None:
locale = get_locale()
return _get_babel_locale(locale)
# This import to be down here to avoid circular import issues.
# This will be cleaned up at a later date
# noinspection PyPep8
from . import commands
_TypeT = TypeVar("_TypeT", bound=type)
[docs]def cog_i18n(translator: Translator) -> Callable[[_TypeT], _TypeT]:
"""Get a class decorator to link the translator to this cog."""
def decorator(cog_class: _TypeT) -> _TypeT:
cog_class.__translator__ = translator
for name, attr in cog_class.__dict__.items():
if isinstance(attr, (commands.Group, commands.Command)):
attr.translator = translator
setattr(cog_class, name, attr)
return cog_class
return decorator