Browse Source

[commands] Initial implementation of help command.

pull/95/head
Rapptz 9 years ago
parent
commit
958d278771
  1. 1
      discord/ext/commands/__init__.py
  2. 49
      discord/ext/commands/bot.py
  3. 50
      discord/ext/commands/core.py
  4. 285
      discord/ext/commands/formatter.py

1
discord/ext/commands/__init__.py

@ -14,3 +14,4 @@ from .bot import Bot, when_mentioned
from .context import Context
from .core import *
from .errors import *
from .formatter import HelpFormatter

49
discord/ext/commands/bot.py

@ -29,11 +29,13 @@ import discord
import inspect
import importlib
import sys
import functools
from .core import GroupMixin, Command
from .core import GroupMixin, Command, command
from .view import StringView
from .context import Context
from .errors import CommandNotFound
from .formatter import HelpFormatter
def _get_variable(name):
stack = inspect.stack()
@ -50,6 +52,28 @@ def when_mentioned(bot, msg):
to being mentioned, e.g. ``@bot ``."""
return '{0.user.mention} '.format(bot)
@command(pass_context=True, name='help')
@asyncio.coroutine
def _default_help_command(ctx, *commands : str):
"""Shows this message."""
bot = ctx.bot
destination = ctx.message.channel if not bot.pm_help else ctx.message.author
# help by itself just lists our own commands.
if len(commands) == 0:
pages = bot.formatter.format_help_for(ctx, bot)
else:
try:
command = functools.reduce(dict.__getitem__, commands, bot.commands)
except KeyError as e:
yield from bot.send_message(destination, 'No command called {} found.'.format(e))
return
pages = bot.formatter.format_help_for(ctx, command)
for page in pages:
yield from bot.send_message(destination, page)
class Bot(GroupMixin, discord.Client):
"""Represents a discord bot.
@ -74,13 +98,34 @@ class Bot(GroupMixin, discord.Client):
multiple checks for the prefix should be used and the first one to
match will be the invocation prefix. You can get this prefix via
:attr:`Context.prefix`.
description : str
The content prefixed into the default help message.
formatter : :class:`HelpFormatter`
The formatter used to format the help message. By default, it uses a
the :class:`HelpFormatter`. Check it for more info on how to override it.
If you want to change the help command completely (add aliases, etc) then
a call to :meth:`remove_command` with 'help' as the argument would do the
trick.
pm_help : bool
A boolean that indicates if the help command should PM the user instead of
sending it to the channel it received it from. Defaults to ``False``.
"""
def __init__(self, command_prefix, **options):
def __init__(self, command_prefix, formatter=None, description=None, pm_help=False, **options):
super().__init__(**options)
self.command_prefix = command_prefix
self.extra_events = {}
self.cogs = {}
self.extensions = {}
self.description = description
self.pm_help = pm_help
if formatter is not None:
if not isinstance(formatter, HelpFormatter):
raise discord.ClientException('Formatter must be a subclass of HelpFormatter')
self.formatter = formatter
else:
self.formatter = HelpFormatter()
self.add_command(_default_help_command)
# internal helpers

50
discord/ext/commands/core.py

@ -93,6 +93,11 @@ class Command:
:exc:`CommandError` should be used. Note that if the checks fail then
:exc:`CheckFailure` exception is raised to the :func:`on_command_error`
event.
description : str
The message prefixed into the default help command.
hidden : bool
If ``True``, the default help command does not show this in the
help output.
"""
def __init__(self, name, callback, **kwargs):
self.name = name
@ -102,6 +107,8 @@ class Command:
self.brief = kwargs.get('brief')
self.aliases = kwargs.get('aliases', [])
self.pass_context = kwargs.get('pass_context', False)
self.description = kwargs.get('description')
self.hidden = kwargs.get('hidden', False)
signature = inspect.signature(callback)
self.params = signature.parameters.copy()
self.checks = kwargs.get('checks', [])
@ -276,12 +283,8 @@ class Command:
try:
if not self.enabled:
raise DisabledCommand('{0.name} command is disabled'.format(self))
predicates = self.checks
if predicates:
check = all(predicate(ctx) for predicate in predicates)
if not check:
raise CheckFailure('The check functions for command {0.name} failed.'.format(self))
if not self.can_run(ctx):
raise CheckFailure('The check functions for command {0.name} failed.'.format(self))
except CommandError as exc:
self.handle_local_error(exc, ctx)
ctx.bot.dispatch('command_error', exc, ctx)
@ -327,6 +330,41 @@ class Command:
"""The name of the cog this command belongs to. None otherwise."""
return type(self.instance).__name__ if self.instance is not None else None
@property
def short_doc(self):
"""Gets the "short" documentation of a command.
By default, this is the :attr:`brief` attribute.
If that lookup leads to an empty string then the first line of the
:attr:`help` attribute is used instead.
"""
if self.brief:
return self.brief
if self.help:
return self.help.split('\n', 1)[0]
return ''
def can_run(self, context):
"""Checks if the command can be executed by checking all the predicates
inside the :attr:`checks` attribute.
Parameters
-----------
context : :class:`Context`
The context of the command currently being invoked.
Returns
--------
bool
A boolean indicating if the command can be invoked.
"""
predicates = self.checks
if not predicates:
# since we have no checks, then we just return True.
return True
return all(predicate(context) for predicate in predicates)
class GroupMixin:
"""A mixin that implements common functionality for classes that behave
similar to :class:`Group` and are allowed to register commands.

285
discord/ext/commands/formatter.py

@ -0,0 +1,285 @@
# -*- coding: utf-8 -*-
"""
The MIT License (MIT)
Copyright (c) 2015-2016 Rapptz
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.
"""
import textwrap
import itertools
from .core import GroupMixin, Command
# help -> shows info of bot on top/bottom and lists subcommands
# help command -> shows detailed info of command
# help command <subcommand chain> -> same as above
# <description>
# <command signature with aliases>
# <long doc>
# Cog:
# <command> <shortdoc>
# <command> <shortdoc>
# Other Cog:
# <command> <shortdoc>
# No Category:
# <command> <shortdoc>
# Type <prefix>help command for more info on a command.
# You can also type <prefix>help category for more info on a category.
class HelpFormatter:
"""The default base implementation that handles formatting of the help
command.
To override the behaviour of the formatter, :meth:`format`
should be overridden. A number of utility functions are provided for use
inside that method.
Parameters
-----------
show_hidden : bool
Dictates if hidden commands should be shown in the output.
Defaults to ``False``.
show_check_faiure : bool
Dictates if commands that have their :attr:`Command.checks` failed
shown. Defaults to ``False``.
width : int
The maximum number of characters that fit in a line.
Defaults to 80.
"""
def __init__(self, show_hidden=False, show_check_faiure=False, width=80):
self.wrapper = textwrap.TextWrapper(width=width)
self.show_hidden = show_hidden
self.show_check_faiure = show_check_faiure
def has_subcommands(self):
"""bool : Specifies if the command has subcommands."""
return isinstance(self.command, GroupMixin)
def is_bot(self):
"""bool : Specifies if the command being formatted is the bot itself."""
return self.command is self.context.bot
def shorten(self, text):
"""Shortens text to fit into the :attr:`width`."""
tmp = self.wrapper.max_lines
self.wrapper.max_lines = 1
res = self.wrapper.fill(text)
self.wrapper.max_lines = tmp
del tmp
return res
@property
def max_name_size(self):
"""int : Returns the largest name length of a command or if it has subcommands
the largest subcommand name."""
try:
return max(map(lambda c: len(c.name), self.command.commands.values()))
except AttributeError:
return len(self.command.name)
@property
def clean_prefix(self):
"""The cleaned up invoke prefix. i.e. mentions are ``@name`` instead of ``<@id>``."""
user = self.context.bot.user
# this breaks if the prefix mention is not the bot itself but I
# consider this to be an *incredibly* strange use case. I'd rather go
# for this common use case rather than waste performance for the
# odd one.
return self.context.prefix.replace(user.mention, '@' + user.name)
def get_command_signature(self):
"""Retrieves the signature portion of the help page."""
result = []
prefix = self.clean_prefix
cmd = self.command
if len(cmd.aliases) > 0:
aliases = '|'.join(cmd.aliases)
name = '{0}[{1.name}|{2}]'.format(prefix, cmd, aliases)
result.append(name)
else:
result.append(prefix + cmd.name)
params = cmd.clean_params
if len(params) > 0:
for name, param in params.items():
cleaned_name = name.replace('_', '-')
if param.default is not param.empty:
result.append('{0}={1}'.format(cleaned_name, param.default))
elif param.kind == param.VAR_POSITIONAL:
result.append(cleaned_name + '...')
else:
result.append(cleaned_name)
return ' '.join(result)
def get_ending_note(self):
return "Type {0}help command for more info on a command.\n" \
"You can also type {0}help category for more info on a category.".format(self.clean_prefix)
def filter_command_list(self):
"""Returns a filtered list of commands based on the two attributes
provided, :attr:`show_check_faiure` and :attr:`show_hidden`.
Returns
--------
iterable
An iterable with the filter being applied. The resulting value is
a (key, value) tuple of the command name and the command itself.
"""
def predicate(tuple):
cmd = tuple[1]
if cmd.hidden and not self.show_hidden:
return False
if self.show_check_faiure:
# we don't wanna bother doing the checks if the user does not
# care about them, so just return true.
return True
return cmd.can_run(self.context)
return filter(predicate, self.command.commands.items())
def _check_new_page(self):
# be a little on the safe side
if self._count > 1920:
# add the page
self._current_page.append('```')
self._pages.append('\n'.join(self._current_page))
self._current_page = ['```']
self._count = 4
def _add_subcommands_to_page(self, max_width, commands):
for name, command in commands:
if name in command.aliases:
# skip aliases
continue
entry = ' {0:<{width}} {1}'.format(name, command.short_doc, width=max_width)
shortened = self.shorten(entry)
self._count += len(shortened)
self._check_new_page()
self._current_page.append(shortened)
def format_help_for(self, context, command_or_bot):
"""Formats the help page and handles the actual heavy lifting of how
the help command looks like. To change the behaviour, override the
:meth:`format` method.
Parameters
-----------
context : :class:`Context`
The context of the invoked help command.
command_or_bot : :class:`Command` or :class:`Bot`
The bot or command that we are getting the help of.
Returns
--------
list
A paginated output of the help command.
"""
self.context = context
self.command = command_or_bot
return self.format()
def format(self):
"""Handles the actual behaviour involved with formatting.
To change the behaviour, this method should be overridden.
Returns
--------
list
A paginated output of the help command.
"""
self._pages = []
self._count = 4 # ``` + '\n'
self._current_page = ['```']
# we need a padding of ~80 or so
if self.command.description:
# <description> portion
self._current_page.append(self.command.description)
self._current_page.append('')
self._count += len(self.command.description)
if not self.is_bot():
# <signature portion>
signature = self.get_command_signature()
self._count += 2 + len(signature) # '\n' sig '\n'
self._current_page.append(signature)
self._current_page.append('')
# <long doc> section
if self.command.help:
self._count += 2 + len(self.command.help)
self._current_page.append(self.command.help)
self._current_page.append('')
self._check_new_page()
if not self.has_subcommands():
self._current_page.append('```')
self._pages.append('\n'.join(self._current_page))
return self._pages
max_width = self.max_name_size
def category(tup):
cog = tup[1].cog_name
# we insert the zero width space there to give it approximate
# last place sorting position.
return cog + ':' if cog is not None else '\u200bNo Category:'
if self.is_bot():
data = sorted(self.filter_command_list(), key=category)
for category, commands in itertools.groupby(data, key=category):
# there simply is no prettier way of doing this.
commands = list(commands)
if len(commands) > 0:
self._current_page.append(category)
self._count += len(category)
self._check_new_page()
self._add_subcommands_to_page(max_width, commands)
else:
self._current_page.append('Commands:')
self._count += 1 + len(self._current_page[-1])
self._add_subcommands_to_page(max_width, self.filter_command_list())
# add the ending note
self._current_page.append('')
ending_note = self.get_ending_note()
self._count += len(ending_note)
self._check_new_page()
self._current_page.append(ending_note)
if len(self._current_page) > 1:
self._current_page.append('```')
self._pages.append('\n'.join(self._current_page))
return self._pages
Loading…
Cancel
Save