changeset 1369:dd1a148bd3d8

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
author Goffi <goffi@goffi.org>
date Thu, 19 Mar 2015 14:02:37 +0100
parents f71a0fc26886
children 53c7678c27a6
files src/plugins/plugin_misc_text_commands.py
diffstat 1 files changed, 135 insertions(+), 22 deletions(-) [+]
line wrap: on
line diff
--- a/src/plugins/plugin_misc_text_commands.py	Wed Mar 18 10:52:28 2015 +0100
+++ b/src/plugins/plugin_misc_text_commands.py	Thu Mar 19 14:02:37 2015 +0100
@@ -25,6 +25,7 @@
 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 @@
 }
 
 
+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 @@
         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
-
         """
         for attr in dir(instance):
             if attr.startswith('cmd_'):
@@ -74,17 +150,18 @@
                     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 @@
 
     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 @@
                 """ 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 @@
 
             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 @@
 
     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 @@
         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 = []
 
-        help_mess = _(u"Text commands available:\n%s") % (u'\n'.join(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)]))
+
         self.feedBack(help_mess, mess_data, profile)
 
     def cmd_me(self, mess_data, profile):