comparison src/plugins/plugin_misc_text_commands.py @ 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 faa1129559b8
children 53c7678c27a6
comparison
equal deleted inserted replaced
1367:f71a0fc26886 1369:dd1a148bd3d8
23 from twisted.words.protocols.jabber import jid 23 from twisted.words.protocols.jabber import jid
24 from twisted.internet import defer 24 from twisted.internet import defer
25 from sat.core.log import getLogger 25 from sat.core.log import getLogger
26 log = getLogger(__name__) 26 log = getLogger(__name__)
27 from twisted.python import failure 27 from twisted.python import failure
28 from collections import OrderedDict
28 29
29 PLUGIN_INFO = { 30 PLUGIN_INFO = {
30 "name": "Text commands", 31 "name": "Text commands",
31 "import_name": C.TEXT_CMDS, 32 "import_name": C.TEXT_CMDS,
32 "type": "Misc", 33 "type": "Misc",
36 "handler": "no", 37 "handler": "no",
37 "description": _("""IRC like text commands""") 38 "description": _("""IRC like text commands""")
38 } 39 }
39 40
40 41
42 class InvalidCommandSyntax(Exception):
43 """Throwed while parsing @command in docstring if syntax is invalid"""
44 pass
45
46
47 CMD_KEY = "@command"
48 CMD_TYPES = ('group', 'one2one', 'all')
49
50
41 class TextCommands(object): 51 class TextCommands(object):
42 #FIXME: doc strings for commands have to be translatable 52 #FIXME: doc strings for commands have to be translatable
43 # plugins need a dynamic translation system (translation 53 # plugins need a dynamic translation system (translation
44 # should be downloadable independently) 54 # should be downloadable independently)
45 55
51 host.trigger.add("sendMessage", self.sendMessageTrigger) 61 host.trigger.add("sendMessage", self.sendMessageTrigger)
52 self._commands = {} 62 self._commands = {}
53 self._whois = [] 63 self._whois = []
54 self.registerTextCommands(self) 64 self.registerTextCommands(self)
55 65
66 def _parseDocString(self, cmd, cmd_name):
67 """Parse a docstring to get text command data
68
69 @param cmd: function or method callback for the command,
70 its docstring will be used for self documentation in the following way:
71 - first line is the command short documentation, shown with /help
72 - @command keyword can be used, see http://wiki.goffi.org/wiki/Coding_style/en for documentation
73 @return (dict): dictionary with parsed data where key can be:
74 - "doc_short_help" (default: ""): the untranslated short documentation
75 - "type" (default "all"): the command type as specified in documentation
76 - "args" (default: ""): the arguments available, using syntax specified in documentation.
77 - "doc_arg_[name]": the doc of [name] argument
78 """
79 data = OrderedDict([('doc_short_help', ""),
80 ('type', 'all'),
81 ('args', '')])
82 docstring = cmd.__doc__
83 if docstring is None:
84 log.warning(u"Not docstring found for command {}".format(cmd_name))
85 docstring = ""
86
87 doc_data = docstring.split("\n")
88 data["doc_short_help"] = doc_data[0]
89
90 try:
91 cmd_indent = 0 # >0 when @command is found are we are parsing it
92
93 for line in doc_data:
94 stripped = line.strip()
95 if cmd_indent and line[cmd_indent:cmd_indent+5] == " -":
96 colon_idx = line.find(":")
97 if colon_idx == -1:
98 raise InvalidCommandSyntax("No colon found in argument description")
99 arg_name = line[cmd_indent+6:colon_idx].strip()
100 if not arg_name:
101 raise InvalidCommandSyntax("No name found in argument description")
102 arg_help = line[colon_idx+1:].strip()
103 data["doc_arg_{}".format(arg_name)] = arg_help
104 elif cmd_indent:
105 # we are parsing command and indent level is not good, it's finished
106 break
107 elif stripped.startswith(CMD_KEY):
108 cmd_indent = line.find(CMD_KEY)
109
110 # type
111 colon_idx = stripped.find(":")
112 if colon_idx == -1:
113 raise InvalidCommandSyntax("missing colon")
114 type_data = stripped[len(CMD_KEY):colon_idx].strip()
115 if len(type_data) == 0:
116 type_data="(all)"
117 elif len(type_data) <= 2 or type_data[0] != '(' or type_data[-1] != ')':
118 raise InvalidCommandSyntax("Bad type data syntax")
119 type_ = type_data[1:-1]
120 if type_ not in CMD_TYPES:
121 raise InvalidCommandSyntax("Unknown type {}".format(type_))
122 data["type"] = type_
123
124 #args
125 data["args"] = stripped[colon_idx+1:].strip()
126 except InvalidCommandSyntax as e:
127 log.warning(u"Invalid command syntax for command {command}: {message}".format(command=cmd_name, message=e.message))
128
129 return data
130
131
56 def registerTextCommands(self, instance): 132 def registerTextCommands(self, instance):
57 """ Add a text command 133 """ Add a text command
134
58 @param instance: instance of a class containing text commands 135 @param instance: instance of a class containing text commands
59
60 """ 136 """
61 for attr in dir(instance): 137 for attr in dir(instance):
62 if attr.startswith('cmd_'): 138 if attr.startswith('cmd_'):
63 cmd = getattr(instance, attr) 139 cmd = getattr(instance, attr)
64 if not callable(cmd): 140 if not callable(cmd):
72 while (cmd_name + str(suff)) in self._commands: 148 while (cmd_name + str(suff)) in self._commands:
73 suff+=1 149 suff+=1
74 new_name = cmd_name + str(suff) 150 new_name = cmd_name + str(suff)
75 log.warning(_("Conflict for command [%(old_name)s], renaming it to [%(new_name)s]") % {'old_name': cmd_name, 'new_name': new_name}) 151 log.warning(_("Conflict for command [%(old_name)s], renaming it to [%(new_name)s]") % {'old_name': cmd_name, 'new_name': new_name})
76 cmd_name = new_name 152 cmd_name = new_name
77 self._commands[cmd_name] = cmd 153 self._commands[cmd_name] = cmd_data = OrderedDict({'callback':cmd}) # We use an Ordered dict to keep documenation order
154 cmd_data.update(self._parseDocString(cmd, cmd_name))
78 log.info(_("Registered text command [%s]") % cmd_name) 155 log.info(_("Registered text command [%s]") % cmd_name)
79 156
80 def addWhoIsCb(self, callback, priority=0): 157 def addWhoIsCb(self, callback, priority=0):
81 """Add a callback which give information to the /whois command 158 """Add a callback which give information to the /whois command
159
82 @param callback: a callback which will be called with the following arguments 160 @param callback: a callback which will be called with the following arguments
83 - whois_msg: list of information strings to display, callback need to append its own strings to it 161 - whois_msg: list of information strings to display, callback need to append its own strings to it
84 - target_jid: full jid from whom we want information 162 - target_jid: full jid from whom we want information
85 - profile: %(doc_profile)s 163 - profile: %(doc_profile)s
86 @param priority: priority of the information to show (the highest priority will be displayed first) 164 @param priority: priority of the information to show (the highest priority will be displayed first)
87
88 """ 165 """
89 self._whois.append((priority, callback)) 166 self._whois.append((priority, callback))
90 self._whois.sort(key=lambda item: item[0], reverse=True) 167 self._whois.sort(key=lambda item: item[0], reverse=True)
91 168
92 def sendMessageTrigger(self, mess_data, pre_xml_treatments, post_xml_treatments, profile): 169 def sendMessageTrigger(self, mess_data, pre_xml_treatments, post_xml_treatments, profile):
94 pre_xml_treatments.addCallback(self._sendMessageCmdHook, profile) 171 pre_xml_treatments.addCallback(self._sendMessageCmdHook, profile)
95 return True 172 return True
96 173
97 def _sendMessageCmdHook(self, mess_data, profile): 174 def _sendMessageCmdHook(self, mess_data, profile):
98 """ Check text commands in message, and react consequently 175 """ Check text commands in message, and react consequently
99 msg starting with / are potential command. If a command is found, it is executed, else message is sent normally 176
177 msg starting with / are potential command. If a command is found, it is executed, else and help message is sent
100 msg starting with // are escaped: they are sent with a single / 178 msg starting with // are escaped: they are sent with a single /
101 commands can abord message sending (if they return anything evaluating to False), or continue it (if they return True), eventually after modifying the message 179 commands can abord message sending (if they return anything evaluating to False), or continue it (if they return True), eventually after modifying the message
102 an "unparsed" key is added to message, containing part of the message not yet parsed 180 an "unparsed" key is added to message, containing part of the message not yet parsed
103 commands can be deferred or not 181 commands can be deferred or not
104 182 @param mess_data(dict): data comming from sendMessage trigger
183 @param profile: %(doc_profile)s
105 """ 184 """
106 msg = mess_data["message"] 185 msg = mess_data["message"]
107 try: 186 try:
108 if msg[:2] == '//': 187 if msg[:2] == '//':
109 # we have a double '/', it's the escape sequence 188 # we have a double '/', it's the escape sequence
121 # looks like an actual command, we try to call the corresponding method 200 # looks like an actual command, we try to call the corresponding method
122 def retHandling(ret): 201 def retHandling(ret):
123 """ Handle command return value: 202 """ Handle command return value:
124 if ret is True, normally send message (possibly modified by command) 203 if ret is True, normally send message (possibly modified by command)
125 else, abord message sending 204 else, abord message sending
126
127 """ 205 """
128 if ret: 206 if ret:
129 return mess_data 207 return mess_data
130 else: 208 else:
131 log.debug("text commands took over") 209 log.debug("text commands took over")
135 self.feedBack("Command failed with condition '%s'" % failure.value.condition, mess_data, profile) 213 self.feedBack("Command failed with condition '%s'" % failure.value.condition, mess_data, profile)
136 return False 214 return False
137 215
138 try: 216 try:
139 mess_data["unparsed"] = msg[1 + len(command):] # part not yet parsed of the message 217 mess_data["unparsed"] = msg[1 + len(command):] # part not yet parsed of the message
140 d = defer.maybeDeferred(self._commands[command], mess_data, profile) 218 d = defer.maybeDeferred(self._commands[command]["callback"], mess_data, profile)
141 d.addCallbacks(lambda ret: ret, genericErrback) # XXX: dummy callback is needed 219 d.addErrback(genericErrback)
142 d.addCallback(retHandling) 220 d.addCallback(retHandling)
143
144 except KeyError: 221 except KeyError:
145 self.feedBack(_("Unknown command /%s. ") % command + self.HELP_SUGGESTION, mess_data, profile) 222 self.feedBack(_("Unknown command /%s. ") % command + self.HELP_SUGGESTION, mess_data, profile)
146 log.debug("text commands took over") 223 log.debug("text commands took over")
147 raise failure.Failure(exceptions.CancelError()) 224 raise failure.Failure(exceptions.CancelError())
148 225
149 return d or mess_data # if a command is detected, we should have a deferred, else we send the message normally 226 return d or mess_data # if a command is detected, we should have a deferred, else we send the message normally
150 227
151 def getRoomJID(self, arg, service_jid): 228 def getRoomJID(self, arg, service_jid):
152 """Return a room jid with a shortcut 229 """Return a room jid with a shortcut
230
153 @param arg: argument: can be a full room jid (e.g.: sat@chat.jabberfr.org) 231 @param arg: argument: can be a full room jid (e.g.: sat@chat.jabberfr.org)
154 or a shortcut (e.g.: sat or sat@ for sat on current service) 232 or a shortcut (e.g.: sat or sat@ for sat on current service)
155 @param service_jid: jid of the current service (e.g.: chat.jabberfr.org) 233 @param service_jid: jid of the current service (e.g.: chat.jabberfr.org)
156 """ 234 """
157 nb_arobas = arg.count('@') 235 nb_arobas = arg.count('@')
222 return False 300 return False
223 301
224 d.addCallback(feedBack) 302 d.addCallback(feedBack)
225 return d 303 return d
226 304
305 def _getArgsHelp(self, cmd_data):
306 """Return help string for args of cmd_name, according to docstring data
307
308 @param cmd_data: command data
309 @return (list[unicode]): help strings
310 """
311 strings = []
312 for doc_name, doc_help in cmd_data.iteritems():
313 if doc_name.startswith("doc_arg_"):
314 arg_name = doc_name[8:]
315 strings.append(u"- {name}: {doc_help}".format(name=arg_name, doc_help=_(doc_help)))
316
317 return strings
318
227 def cmd_help(self, mess_data, profile): 319 def cmd_help(self, mess_data, profile):
228 """show help on available commands""" 320 """show help on available commands
229 commands = filter(lambda method: method.startswith('cmd_'), dir(self)) 321
230 longuest = max([len(command) for command in commands]) 322 @command: [cmd_name]
231 help_cmds = [] 323 - cmd_name: name of the command for detailed help
232 324 """
233 for command in sorted(self._commands): 325 cmd_name = mess_data["unparsed"].strip()
234 method = self._commands[command] 326 if cmd_name and cmd_name[0] == "/":
235 try: 327 cmd_name = cmd_name[1:]
236 help_str = method.__doc__.split('\n')[0] 328 if cmd_name and cmd_name not in self._commands:
237 except AttributeError: 329 self.feedBack(_(u"Invalid command name [{}]\n".format(cmd_name)), mess_data, profile)
238 help_str = '' 330 cmd_name = ""
239 spaces = (longuest - len(command)) * ' ' 331 if not cmd_name:
240 help_cmds.append(" /%s: %s %s" % (command, spaces, help_str)) 332 # we show the global help
241 333 longuest = max([len(command) for command in self._commands])
242 help_mess = _(u"Text commands available:\n%s") % (u'\n'.join(help_cmds), ) 334 help_cmds = []
335
336 for command in sorted(self._commands):
337 cmd_data = self._commands[command]
338 spaces = (longuest - len(command)) * ' '
339 help_cmds.append(" /{command}: {spaces} {short_help}".format(
340 command=command,
341 spaces=spaces,
342 short_help=cmd_data["doc_short_help"]
343 ))
344
345 help_mess = _(u"Text commands available:\n%s") % (u'\n'.join(help_cmds), )
346 else:
347 # we show detailled help for a command
348 cmd_data = self._commands[cmd_name]
349 syntax = cmd_data["args"]
350 help_mess = _(u"/{name}: {short_help}\n{syntax}{args_help}").format(
351 name=cmd_name,
352 short_help=cmd_data['doc_short_help'],
353 syntax=_(" "*4+"syntax: {}\n").format(syntax) if syntax else "",
354 args_help=u'\n'.join([u" "*8+"{}".format(line) for line in self._getArgsHelp(cmd_data)]))
355
243 self.feedBack(help_mess, mess_data, profile) 356 self.feedBack(help_mess, mess_data, profile)
244 357
245 def cmd_me(self, mess_data, profile): 358 def cmd_me(self, mess_data, profile):
246 """Display a message at third person""" 359 """Display a message at third person"""
247 # We just catch the method and continue it as the frontends should manage /me display 360 # We just catch the method and continue it as the frontends should manage /me display