Mercurial > libervia-backend
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 |