[V3 i18n] Internationalise help for commands and cogs (#1143)

* Framework for internationalised command help

* Translator for class docstring of cog

* Remove references to old context module

* Use CogManagerUI as PoC

* Replace all references to RedContext

* Rename CogI18n object to avoid confusion

* Update docs

* Update i18n docs.

* Store translators in list instead of dict

* Change commands module to package, updated refs in cogs

* Updated docs and more references in cogs

* Resolve syntax error

* Update from merge
This commit is contained in:
Tobotimus
2018-05-12 09:47:49 +10:00
committed by Kowlin
parent 1e60d1c265
commit 15ea5440a3
35 changed files with 575 additions and 259 deletions

View File

@@ -1,7 +1,6 @@
from .config import Config
from .context import RedContext
__all__ = ["Config", "RedContext", "__version__"]
__all__ = ["Config", "__version__"]
class VersionInfo:

View File

@@ -20,7 +20,7 @@ from .cog_manager import CogManager
from . import (
Config,
i18n,
RedContext,
commands,
rpc
)
from .help_formatter import Help, help as help_
@@ -193,7 +193,7 @@ class RedBase(BotBase, RpcMethodMixin):
admin_role = await self.db.guild(member.guild).admin_role()
return any(role.id in (mod_role, admin_role) for role in member.roles)
async def get_context(self, message, *, cls=RedContext):
async def get_context(self, message, *, cls=commands.Context):
return await super().get_context(message, cls=cls)
def list_packages(self):

View File

@@ -8,11 +8,10 @@ from typing import Tuple, Union, List
import redbot.cogs
import discord
from . import checks
from . import checks, commands
from .config import Config
from .i18n import CogI18n
from .i18n import Translator, cog_i18n
from .data_manager import cog_data_path
from discord.ext import commands
from .utils.chat_formatting import box, pagify
@@ -303,10 +302,13 @@ class CogManager:
invalidate_caches()
_ = CogI18n("CogManagerUI", __file__)
_ = Translator("CogManagerUI", __file__)
@cog_i18n(_)
class CogManagerUI:
"""Commands to interface with Red's cog manager."""
async def visible_paths(self, ctx):
install_path = await ctx.bot.cog_mgr.install_path()
cog_paths = await ctx.bot.cog_mgr.paths()

View File

@@ -0,0 +1,4 @@
from discord.ext.commands import *
from .commands import *
from .context import *

View File

@@ -0,0 +1,74 @@
"""Module for command helpers and classes.
This module contains extended classes and functions which are intended to
replace those from the `discord.ext.commands` module.
"""
import inspect
from discord.ext import commands
__all__ = ["Command", "Group", "command", "group"]
class Command(commands.Command):
"""Command class for Red.
This should not be created directly, and instead via the decorator.
This class inherits from `discord.ext.commands.Command`.
"""
def __init__(self, *args, **kwargs):
self._help_override = kwargs.pop('help_override', None)
super().__init__(*args, **kwargs)
self.translator = kwargs.pop("i18n", None)
@property
def help(self):
"""Help string for this command.
If the :code:`help` kwarg was passed into the decorator, it will
default to that. If not, it will attempt to translate the docstring
of the command's callback function.
"""
if self._help_override is not None:
return self._help_override
if self.translator is None:
translator = lambda s: s
else:
translator = self.translator
return inspect.cleandoc(translator(self.callback.__doc__))
@help.setter
def help(self, value):
# We don't want our help property to be overwritten, namely by super()
pass
class Group(Command, commands.Group):
"""Group command class for Red.
This class inherits from `discord.ext.commands.Group`, with `Command` mixed
in.
"""
pass
# decorators
def command(name=None, cls=Command, **attrs):
"""A decorator which transforms an async function into a `Command`.
Same interface as `discord.ext.commands.command`.
"""
attrs["help_override"] = attrs.pop("help", None)
return commands.command(name, cls, **attrs)
def group(name=None, **attrs):
"""A decorator which transforms an async function into a `Group`.
Same interface as `discord.ext.commands.group`.
"""
return command(name, cls=Group, **attrs)

View File

@@ -1,26 +1,23 @@
"""
The purpose of this module is to allow for Red to further customise the command
invocation context provided by discord.py.
"""
import asyncio
from typing import Iterable, List
import discord
from discord.ext import commands
from redbot.core.utils.chat_formatting import box
__all__ = ["RedContext"]
TICK = "\N{WHITE HEAVY CHECK MARK}"
__all__ = ["Context"]
class RedContext(commands.Context):
class Context(commands.Context):
"""Command invocation context for Red.
All context passed into commands will be of this type.
This class inherits from `commands.Context <discord.ext.commands.Context>`.
This class inherits from `discord.ext.commands.Context`.
"""
async def send_help(self) -> List[discord.Message]:

View File

@@ -16,13 +16,12 @@ from distutils.version import StrictVersion
import aiohttp
import discord
import pkg_resources
from discord.ext import commands
from redbot.core import __version__
from redbot.core import checks
from redbot.core import i18n
from redbot.core import rpc
from redbot.core.context import RedContext
from redbot.core import commands
from .utils import TYPE_CHECKING
from .utils.chat_formatting import pagify, box, inline
@@ -39,9 +38,10 @@ OWNER_DISCLAIMER = ("⚠ **Only** the person who is hosting Red should be "
"system.** ⚠")
_ = i18n.CogI18n("Core", __file__)
_ = i18n.Translator("Core", __file__)
@i18n.cog_i18n(_)
class Core:
"""Commands related to core functions"""
def __init__(self, bot):
@@ -52,7 +52,7 @@ class Core:
rpc.add_method('core', self.rpc_reload)
@commands.command()
async def info(self, ctx: RedContext):
async def info(self, ctx: commands.Context):
"""Shows info about Red"""
author_repo = "https://github.com/Twentysix26"
org_repo = "https://github.com/Cog-Creators"
@@ -103,7 +103,7 @@ class Core:
await ctx.send("I need the `Embed links` permission to send this")
@commands.command()
async def uptime(self, ctx: RedContext):
async def uptime(self, ctx: commands.Context):
"""Shows Red's uptime"""
since = ctx.bot.uptime.strftime("%Y-%m-%d %H:%M:%S")
passed = self.get_bot_uptime()
@@ -112,7 +112,7 @@ class Core:
passed, since
)
)
def get_bot_uptime(self, *, brief=False):
# Courtesy of Danny
now = datetime.datetime.utcnow()
@@ -134,7 +134,7 @@ class Core:
return fmt.format(d=days, h=hours, m=minutes, s=seconds)
@commands.group()
async def embedset(self, ctx: RedContext):
async def embedset(self, ctx: commands.Context):
"""
Commands for toggling embeds on or off.
@@ -157,7 +157,7 @@ class Core:
@embedset.command(name="global")
@checks.is_owner()
async def embedset_global(self, ctx: RedContext):
async def embedset_global(self, ctx: commands.Context):
"""
Toggle the global embed setting.
@@ -175,7 +175,7 @@ class Core:
@embedset.command(name="guild")
@checks.guildowner_or_permissions(administrator=True)
async def embedset_guild(self, ctx: RedContext, enabled: bool=None):
async def embedset_guild(self, ctx: commands.Context, enabled: bool=None):
"""
Toggle the guild's embed setting.
@@ -200,7 +200,7 @@ class Core:
)
@embedset.command(name="user")
async def embedset_user(self, ctx: RedContext, enabled: bool=None):
async def embedset_user(self, ctx: commands.Context, enabled: bool=None):
"""
Toggle the user's embed setting.
@@ -412,7 +412,7 @@ class Core:
"""Reloads packages"""
cognames = [c.strip() for c in cog_name.split(' ')]
for c in cognames:
ctx.bot.unload_extension(c)
@@ -428,7 +428,7 @@ class Core:
except RuntimeError:
notfound_packages.append(inline(c))
for spec, name in cogspecs:
for spec, name in cogspecs:
try:
self.cleanup_and_refresh_modules(spec.name)
await ctx.bot.load_extension(spec)
@@ -489,7 +489,7 @@ class Core:
except:
pass
await ctx.bot.shutdown()
@commands.command(name="restart")
@checks.is_owner()
async def _restart(self, ctx, silently: bool=False):
@@ -776,26 +776,26 @@ class Core:
await ctx.send(_("You have been set as owner."))
else:
await ctx.send(_("Invalid token."))
@_set.command()
@checks.is_owner()
async def token(self, ctx, token: str):
"""Change bot token."""
if not isinstance(ctx.channel, discord.DMChannel):
try:
await ctx.message.delete()
except discord.Forbidden:
pass
await ctx.send(
_("Please use that command in DM. Since users probably saw your token,"
" it is recommended to reset it right now. Go to the following link and"
" select `Reveal Token` and `Generate a new token?`."
"\n\nhttps://discordapp.com/developers/applications/me/{}").format(self.bot.user.id))
return
await ctx.bot.db.token.set(token)
await ctx.send("Token set. Restart me.")
@@ -834,7 +834,7 @@ class Core:
@commands.command()
@checks.is_owner()
async def listlocales(self, ctx: RedContext):
async def listlocales(self, ctx: commands.Context):
"""
Lists all available locales
@@ -1051,7 +1051,7 @@ class Core:
await ctx.send(_("User has been removed from whitelist."))
else:
await ctx.send(_("User was not in the whitelist."))
@whitelist.command(name='clear')
async def whitelist_clear(self, ctx):
"""

View File

@@ -7,9 +7,8 @@ from contextlib import redirect_stdout
from copy import copy
import discord
from discord.ext import commands
from . import checks
from .i18n import CogI18n
from . import checks, commands
from .i18n import Translator
from .utils.chat_formatting import box, pagify
"""
Notice:
@@ -19,7 +18,7 @@ Notice:
https://github.com/Rapptz/RoboDanny/blob/master/cogs/repl.py
"""
_ = CogI18n("Dev", __file__)
_ = Translator("Dev", __file__)
class Dev:

View File

@@ -1,5 +1,5 @@
"""The checks in this module run on every command."""
from discord.ext import commands
from . import commands
def init_global_checks(bot):

View File

@@ -28,7 +28,6 @@ from collections import namedtuple
from typing import List
import discord
from discord.ext import commands
from discord.ext.commands import formatter
import inspect
import itertools
@@ -36,6 +35,8 @@ import re
import sys
import traceback
from . import commands
EMPTY_STRING = u'\u200b'
@@ -133,7 +134,12 @@ class Help(formatter.HelpFormatter):
'fields': []
}
description = self.command.description if not self.is_cog() else inspect.getdoc(self.command)
if self.is_cog():
translator = getattr(self.command, '__translator__', lambda s: s)
description = inspect.cleandoc(translator(self.command.__doc__))
else:
description = self.command.description
if not description == '' and description is not None:
description = '*{0}*'.format(description)

View File

@@ -1,7 +1,10 @@
import re
from pathlib import Path
__all__ = ['get_locale', 'set_locale', 'reload_locales', 'CogI18n']
from . import commands
__all__ = ['get_locale', 'set_locale', 'reload_locales', 'cog_i18n',
'Translator']
_current_locale = 'en_us'
@@ -13,7 +16,7 @@ IN_MSGSTR = 4
MSGID = 'msgid "'
MSGSTR = 'msgstr "'
_i18n_cogs = {}
_translators = []
def get_locale():
@@ -27,8 +30,8 @@ def set_locale(locale):
def reload_locales():
for cog_name, i18n in _i18n_cogs.items():
i18n.load_translations()
for translator in _translators:
translator.load_translations()
def _parse(translation_file):
@@ -145,25 +148,36 @@ def get_locale_path(cog_folder: Path, extension: str) -> Path:
return cog_folder / 'locales' / "{}.{}".format(get_locale(), extension)
class CogI18n:
class Translator:
"""Function to get translated strings at runtime."""
def __init__(self, name, file_location):
"""
Initializes the internationalization object for a given cog.
Initializes an internationalization object.
:param name: Your cog name.
:param file_location:
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_cogs.update({self.cog_name: self})
_translators.append(self)
self.load_translations()
def __call__(self, untranslated: str):
"""Translate the given string.
This will look for the string in the translator's :code:`.pot` file,
with respect to the current locale.
"""
normalized_untranslated = _normalize(untranslated, True)
try:
return self.translations[normalized_untranslated]
@@ -172,7 +186,7 @@ class CogI18n:
def load_translations(self):
"""
Loads the current translations for this cog.
Loads the current translations.
"""
self.translations = {}
translation_file = None
@@ -201,3 +215,14 @@ class CogI18n:
if translated:
self.translations.update({untranslated: translated})
def cog_i18n(translator: Translator):
"""Get a class decorator to link the translator to this cog."""
def decorator(cog_class: type):
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

View File

@@ -0,0 +1,197 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR ORGANIZATION
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"POT-Creation-Date: 2017-12-06 11:27+1100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=cp1252\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: pygettext.py 1.5\n"
#: ../cog_manager.py:21
#, docstring
msgid ""
"Directory manager for Red's cogs.\n"
"\n"
" This module allows you to load cogs from multiple directories and even from\n"
" outside the bot directory. You may also set a directory for downloader to\n"
" install new cogs to, the default being the :code:`cogs/` folder in the root\n"
" bot directory.\n"
" "
msgstr ""
#: ../cog_manager.py:40
#, docstring
msgid ""
"Get all currently valid path directories.\n"
"\n"
" Returns\n"
" -------\n"
" `tuple` of `pathlib.Path`\n"
" All valid cog paths.\n"
"\n"
" "
msgstr ""
#: ../cog_manager.py:64
#, docstring
msgid ""
"Get the install path for 3rd party cogs.\n"
"\n"
" Returns\n"
" -------\n"
" pathlib.Path\n"
" The path to the directory where 3rd party cogs are stored.\n"
"\n"
" "
msgstr ""
#: ../cog_manager.py:273
#, docstring
msgid ""
"Finds the names of all available modules to load.\n"
" "
msgstr ""
#: ../cog_manager.py:285
#, docstring
msgid ""
"Re-evaluate modules in the py cache.\n"
"\n"
" This is an alias for an importlib internal and should be called\n"
" any time that a new module has been installed to a cog directory.\n"
" "
msgstr ""
#: ../cog_manager.py:298
#, docstring
msgid ""
"Commands to interface with Red's cog manager."
msgstr ""
"(TRANSLATED) Commands to interface with Red's cog manager."
#: ../cog_manager.py:302
#, docstring
msgid ""
"\n"
" Lists current cog paths in order of priority."
" "
msgstr ""
"\n"
" (TRANSLATED) Lists current cog paths in order of priority."
" "
#: ../cog_manager.py:321
#, docstring
msgid ""
"\n"
" Add a path to the list of available cog paths."
" "
msgstr ""
"\n"
" (TRANSLATED) Add a path to the list of available cog paths."
" "
#: ../cog_manager.py:340
#, docstring
msgid ""
"\n"
" Removes a path from the available cog paths given the path_number"
" from !paths"
" "
msgstr ""
"\n"
" (TRANSLATED) Removes a path from the available cog paths given the path_number"
" from !paths"
" "
#: ../cog_manager.py:357
#, docstring
msgid ""
"\n"
" Reorders paths internally to allow discovery of different cogs."
" "
msgstr ""
"\n"
" (TRANSLATED) Reorders paths internally to allow discovery of different cogs."
" "
#: ../cog_manager.py:383
#, docstring
msgid ""
"\n"
" Returns the current install path or sets it if one is provided."
" The provided path must be absolute or relative to the bot's"
" directory and it must already exist."
"\n"
" No installed cogs will be transferred in the process."
" "
msgstr ""
"\n"
" (TRANSLATED) Returns the current install path or sets it if one is provided."
" The provided path must be absolute or relative to the bot's"
" directory and it must already exist."
"\n"
" No installed cogs will be transferred in the process."
" "
#: ../cog_manager.py:406
#, docstring
msgid ""
"\n"
" Lists all loaded and available cogs."
" "
msgstr ""
"\n"
" (TRANSLATED) Lists all loaded and available cogs."
" "
#: ../cog_manager.py:309
msgid ""
"Install Path: {}\n"
"\n"
msgstr ""
#: ../cog_manager.py:325
msgid "That path is does not exist or does not point to a valid directory."
msgstr ""
#: ../cog_manager.py:334
msgid "Path successfully added."
msgstr ""
#: ../cog_manager.py:347
msgid "That is an invalid path number."
msgstr ""
#: ../cog_manager.py:351
msgid "Path successfully removed."
msgstr ""
#: ../cog_manager.py:367
msgid "Invalid 'from' index."
msgstr ""
#: ../cog_manager.py:373
msgid "Invalid 'to' index."
msgstr ""
#: ../cog_manager.py:377
msgid "Paths reordered."
msgstr ""
#: ../cog_manager.py:395
msgid "That path does not exist."
msgstr ""
#: ../cog_manager.py:399
msgid "The bot will install new cogs to the `{}` directory."
msgstr ""

View File

@@ -7,10 +7,10 @@ Ported to Red V3 by Palm__ (https://github.com/palmtree5)
import asyncio
import discord
from redbot.core import RedContext
from redbot.core import commands
async def menu(ctx: RedContext, pages: list,
async def menu(ctx: commands.Context, pages: list,
controls: dict,
message: discord.Message=None, page: int=0,
timeout: float=30.0):
@@ -28,7 +28,7 @@ async def menu(ctx: RedContext, pages: list,
Parameters
----------
ctx: RedContext
ctx: commands.Context
The command context
pages: `list` of `str` or `discord.Embed`
The pages of the menu.
@@ -92,7 +92,7 @@ async def menu(ctx: RedContext, pages: list,
timeout, react.emoji)
async def next_page(ctx: RedContext, pages: list,
async def next_page(ctx: commands.Context, pages: list,
controls: dict, message: discord.Message, page: int,
timeout: float, emoji: str):
perms = message.channel.permissions_for(ctx.guild.me)
@@ -109,7 +109,7 @@ async def next_page(ctx: RedContext, pages: list,
page=page, timeout=timeout)
async def prev_page(ctx: RedContext, pages: list,
async def prev_page(ctx: commands.Context, pages: list,
controls: dict, message: discord.Message, page: int,
timeout: float, emoji: str):
perms = message.channel.permissions_for(ctx.guild.me)
@@ -126,7 +126,7 @@ async def prev_page(ctx: RedContext, pages: list,
page=next_page, timeout=timeout)
async def close_menu(ctx: RedContext, pages: list,
async def close_menu(ctx: commands.Context, pages: list,
controls: dict, message: discord.Message, page: int,
timeout: float, emoji: str):
if message: