plugin text commands: docstring parsing for commands, and better /help command:

- docstring are parsed according to http://wiki.goffi.org/wiki/Coding_style/en#Text_commands, a data dictionary is filled accordingly
    - /help command can now show detailed help for a specific command
This commit is contained in:
Goffi 2015-03-19 14:02:37 +01:00
commit c4cd6fd5f3

View file

@ -25,6 +25,7 @@ from twisted.internet import defer
from sat.core.log import getLogger
log = getLogger(__name__)
from twisted.python import failure
from collections import OrderedDict
PLUGIN_INFO = {
"name": "Text commands",
@ -38,6 +39,15 @@ PLUGIN_INFO = {
}
class InvalidCommandSyntax(Exception):
"""Throwed while parsing @command in docstring if syntax is invalid"""
pass
CMD_KEY = "@command"
CMD_TYPES = ('group', 'one2one', 'all')
class TextCommands(object):
#FIXME: doc strings for commands have to be translatable
# plugins need a dynamic translation system (translation
@ -53,10 +63,76 @@ class TextCommands(object):
self._whois = []
self.registerTextCommands(self)
def _parseDocString(self, cmd, cmd_name):
"""Parse a docstring to get text command data
@param cmd: function or method callback for the command,
its docstring will be used for self documentation in the following way:
- first line is the command short documentation, shown with /help
- @command keyword can be used, see http://wiki.goffi.org/wiki/Coding_style/en for documentation
@return (dict): dictionary with parsed data where key can be:
- "doc_short_help" (default: ""): the untranslated short documentation
- "type" (default "all"): the command type as specified in documentation
- "args" (default: ""): the arguments available, using syntax specified in documentation.
- "doc_arg_[name]": the doc of [name] argument
"""
data = OrderedDict([('doc_short_help', ""),
('type', 'all'),
('args', '')])
docstring = cmd.__doc__
if docstring is None:
log.warning(u"Not docstring found for command {}".format(cmd_name))
docstring = ""
doc_data = docstring.split("\n")
data["doc_short_help"] = doc_data[0]
try:
cmd_indent = 0 # >0 when @command is found are we are parsing it
for line in doc_data:
stripped = line.strip()
if cmd_indent and line[cmd_indent:cmd_indent+5] == " -":
colon_idx = line.find(":")
if colon_idx == -1:
raise InvalidCommandSyntax("No colon found in argument description")
arg_name = line[cmd_indent+6:colon_idx].strip()
if not arg_name:
raise InvalidCommandSyntax("No name found in argument description")
arg_help = line[colon_idx+1:].strip()
data["doc_arg_{}".format(arg_name)] = arg_help
elif cmd_indent:
# we are parsing command and indent level is not good, it's finished
break
elif stripped.startswith(CMD_KEY):
cmd_indent = line.find(CMD_KEY)
# type
colon_idx = stripped.find(":")
if colon_idx == -1:
raise InvalidCommandSyntax("missing colon")
type_data = stripped[len(CMD_KEY):colon_idx].strip()
if len(type_data) == 0:
type_data="(all)"
elif len(type_data) <= 2 or type_data[0] != '(' or type_data[-1] != ')':
raise InvalidCommandSyntax("Bad type data syntax")
type_ = type_data[1:-1]
if type_ not in CMD_TYPES:
raise InvalidCommandSyntax("Unknown type {}".format(type_))
data["type"] = type_
#args
data["args"] = stripped[colon_idx+1:].strip()
except InvalidCommandSyntax as e:
log.warning(u"Invalid command syntax for command {command}: {message}".format(command=cmd_name, message=e.message))
return data
def registerTextCommands(self, instance):
""" Add a text command
@param instance: instance of a class containing text commands
@param instance: instance of a class containing text commands
"""
for attr in dir(instance):
if attr.startswith('cmd_'):
@ -74,17 +150,18 @@ class TextCommands(object):
new_name = cmd_name + str(suff)
log.warning(_("Conflict for command [%(old_name)s], renaming it to [%(new_name)s]") % {'old_name': cmd_name, 'new_name': new_name})
cmd_name = new_name
self._commands[cmd_name] = cmd
self._commands[cmd_name] = cmd_data = OrderedDict({'callback':cmd}) # We use an Ordered dict to keep documenation order
cmd_data.update(self._parseDocString(cmd, cmd_name))
log.info(_("Registered text command [%s]") % cmd_name)
def addWhoIsCb(self, callback, priority=0):
"""Add a callback which give information to the /whois command
@param callback: a callback which will be called with the following arguments
- whois_msg: list of information strings to display, callback need to append its own strings to it
- target_jid: full jid from whom we want information
- profile: %(doc_profile)s
@param priority: priority of the information to show (the highest priority will be displayed first)
"""
self._whois.append((priority, callback))
self._whois.sort(key=lambda item: item[0], reverse=True)
@ -96,12 +173,14 @@ class TextCommands(object):
def _sendMessageCmdHook(self, mess_data, profile):
""" Check text commands in message, and react consequently
msg starting with / are potential command. If a command is found, it is executed, else message is sent normally
msg starting with / are potential command. If a command is found, it is executed, else and help message is sent
msg starting with // are escaped: they are sent with a single /
commands can abord message sending (if they return anything evaluating to False), or continue it (if they return True), eventually after modifying the message
an "unparsed" key is added to message, containing part of the message not yet parsed
commands can be deferred or not
@param mess_data(dict): data comming from sendMessage trigger
@param profile: %(doc_profile)s
"""
msg = mess_data["message"]
try:
@ -123,7 +202,6 @@ class TextCommands(object):
""" Handle command return value:
if ret is True, normally send message (possibly modified by command)
else, abord message sending
"""
if ret:
return mess_data
@ -137,10 +215,9 @@ class TextCommands(object):
try:
mess_data["unparsed"] = msg[1 + len(command):] # part not yet parsed of the message
d = defer.maybeDeferred(self._commands[command], mess_data, profile)
d.addCallbacks(lambda ret: ret, genericErrback) # XXX: dummy callback is needed
d = defer.maybeDeferred(self._commands[command]["callback"], mess_data, profile)
d.addErrback(genericErrback)
d.addCallback(retHandling)
except KeyError:
self.feedBack(_("Unknown command /%s. ") % command + self.HELP_SUGGESTION, mess_data, profile)
log.debug("text commands took over")
@ -150,6 +227,7 @@ class TextCommands(object):
def getRoomJID(self, arg, service_jid):
"""Return a room jid with a shortcut
@param arg: argument: can be a full room jid (e.g.: sat@chat.jabberfr.org)
or a shortcut (e.g.: sat or sat@ for sat on current service)
@param service_jid: jid of the current service (e.g.: chat.jabberfr.org)
@ -224,22 +302,57 @@ class TextCommands(object):
d.addCallback(feedBack)
return d
def _getArgsHelp(self, cmd_data):
"""Return help string for args of cmd_name, according to docstring data
@param cmd_data: command data
@return (list[unicode]): help strings
"""
strings = []
for doc_name, doc_help in cmd_data.iteritems():
if doc_name.startswith("doc_arg_"):
arg_name = doc_name[8:]
strings.append(u"- {name}: {doc_help}".format(name=arg_name, doc_help=_(doc_help)))
return strings
def cmd_help(self, mess_data, profile):
"""show help on available commands"""
commands = filter(lambda method: method.startswith('cmd_'), dir(self))
longuest = max([len(command) for command in commands])
help_cmds = []
"""show help on available commands
for command in sorted(self._commands):
method = self._commands[command]
try:
help_str = method.__doc__.split('\n')[0]
except AttributeError:
help_str = ''
spaces = (longuest - len(command)) * ' '
help_cmds.append(" /%s: %s %s" % (command, spaces, help_str))
@command: [cmd_name]
- cmd_name: name of the command for detailed help
"""
cmd_name = mess_data["unparsed"].strip()
if cmd_name and cmd_name[0] == "/":
cmd_name = cmd_name[1:]
if cmd_name and cmd_name not in self._commands:
self.feedBack(_(u"Invalid command name [{}]\n".format(cmd_name)), mess_data, profile)
cmd_name = ""
if not cmd_name:
# we show the global help
longuest = max([len(command) for command in self._commands])
help_cmds = []
for command in sorted(self._commands):
cmd_data = self._commands[command]
spaces = (longuest - len(command)) * ' '
help_cmds.append(" /{command}: {spaces} {short_help}".format(
command=command,
spaces=spaces,
short_help=cmd_data["doc_short_help"]
))
help_mess = _(u"Text commands available:\n%s") % (u'\n'.join(help_cmds), )
else:
# we show detailled help for a command
cmd_data = self._commands[cmd_name]
syntax = cmd_data["args"]
help_mess = _(u"/{name}: {short_help}\n{syntax}{args_help}").format(
name=cmd_name,
short_help=cmd_data['doc_short_help'],
syntax=_(" "*4+"syntax: {}\n").format(syntax) if syntax else "",
args_help=u'\n'.join([u" "*8+"{}".format(line) for line in self._getArgsHelp(cmd_data)]))
help_mess = _(u"Text commands available:\n%s") % (u'\n'.join(help_cmds), )
self.feedBack(help_mess, mess_data, profile)
def cmd_me(self, mess_data, profile):