]> git.llucax.com Git - software/pymin.git/blob - dispatcher.py
Add support for quoted and keyword arguments to the Dispatcher.
[software/pymin.git] / dispatcher.py
1 # vim: set et sts=4 sw=4 encoding=utf-8 :
2
3 r"""Command dispatcher.
4
5 This module provides a convenient and extensible command dispatching mechanism.
6 It's based on Zope or Cherrypy dispatching (but implemented from the scratch)
7 and translates commands to functions/objects/methods.
8 """
9
10 __ALL__ = ('Error', 'HandlerError', 'CommandNotFoundError', 'Handler',
11             'Dispatcher', 'handler', 'is_handler', 'get_help')
12
13 class Error(RuntimeError):
14     r"""Error(command) -> Error instance :: Base dispatching exceptions class.
15
16     All exceptions raised by the Dispatcher inherits from this one, so you can
17     easily catch any dispatching exception.
18
19     command - is the command that raised the exception, expressed as a list of
20               paths (or subcommands).
21     """
22     pass
23
24 class HandlerError(Error):
25     r"""HandlerError(command) -> HandlerError instance :: Base handlers error.
26
27     All exceptions raised by the handlers should inherit from this one, so
28     dispatching errors could be separated from real programming errors (bugs).
29     """
30     pass
31
32 class CommandError(Error):
33     r"""CommandError(command) -> CommandError instance :: Base command error.
34
35     This exception is raised when there's a problem with the command itself.
36     It's the base class for all command (as a string) related error.
37     """
38
39     def __init__(self, command):
40         r"""Initialize the object.
41
42         See class documentation for more info.
43         """
44         self.command = command
45
46     def __str__(self):
47         return 'Command error: "%s"' % self.command
48
49 class CommandNotFoundError(CommandError):
50     r"""CommandNotFoundError(command) -> CommandNotFoundError instance.
51
52     This exception is raised when the command received can't be dispatched
53     because there is no handlers to process it.
54     """
55
56     def __str__(self):
57         return 'Command not found: "%s"' % ' '.join(
58                                                 repr(c) for c in self.command)
59
60 class ParseError(CommandError):
61     r"""ParseError(command[, desc]) -> ParseError instance
62
63     This exception is raised when there is an error parsing a command.
64
65     command - Command that can't be parsed.
66
67     desc - Description of the error.
68     """
69
70     def __init__(self, command, desc="can't parse"):
71         r"""Initialize the object.
72
73         See class documentation for more info.
74         """
75         self.command = command
76         self.desc = desc
77
78     def __str__(self):
79         return 'Syntax error, %s: %s' % (self.desc, self.command)
80
81 def handler(help):
82     r"""handler(help) -> function wrapper :: Mark a callable as a handler.
83
84     This is a decorator to mark a callable object as a dispatcher handler.
85
86     help - Help string for the handler.
87     """
88     def wrapper(f):
89         if not help:
90             raise TypeError("'help' should not be empty")
91         f._dispatcher_help = help
92         return f
93     return wrapper
94
95 def is_handler(handler):
96     r"is_handler(handler) -> bool :: Tell if a object is a handler."
97     return callable(handler) and hasattr(handler, '_dispatcher_help')
98
99 def get_help(handler):
100     r"get_help(handler) -> unicode :: Get a handler's help string."
101     if not is_handler(handler):
102         raise TypeError("'%s' should be a handler" % handler.__name__)
103     return handler._dispatcher_help
104
105 class Handler:
106     r"""Handler() -> Handler instance :: Base class for all dispatcher handlers.
107
108     All dispatcher handlers should inherit from this class to have some extra
109     commands, like help.
110     """
111
112     @handler(u'List available commands.')
113     def commands(self):
114         r"""commands() -> generator :: List the available commands."""
115         return (a for a in dir(self) if is_handler(getattr(self, a)))
116
117     @handler(u'Show available commands with their help.')
118     def help(self, command=None):
119         r"""help([command]) -> unicode/dict :: Show help on available commands.
120
121         If command is specified, it returns the help of that particular command.
122         If not, it returns a dictionary which keys are the available commands
123         and values are the help strings.
124         """
125         if command is None:
126             return dict((a, get_help(getattr(self, a)))
127                         for a in dir(self) if is_handler(getattr(self, a)))
128         if not hasattr(self, command):
129             raise CommandNotFoundError(command)
130         handler = getattr(self, command)
131         if not is_handler(handler):
132             raise CommandNotFoundError(command)
133         return get_help(handler)
134
135 def parse_command(command):
136     r"""parse_command(command) -> (args, kwargs) :: Parse a command.
137
138     This function parses a command and split it into a list of parameters. It
139     has a similar to bash commandline parser. Spaces are the basic token
140     separator but you can group several tokens into one by using (single or
141     double) quotes. You can escape the quotes with a backslash (\' and \"),
142     express a backslash literal using a double backslash (\\), use special
143     meaning escaped sequences (like \a, \n, \r, \b, \v) and use unescaped
144     single quotes inside a double quoted token or vice-versa.
145
146     Additionally it accepts keyword arguments. When an (not-escaped) equal
147     sign (=) is found, the argument is considered a keyword, and the next
148     argument it's interpreted as its value.
149
150     This function returns a tuple containing a list and a dictionary. The
151     first has the positional arguments, the second, the keyword arguments.
152
153     There is no restriction about the order, a keyword argument can be
154     followed by a positional argument and vice-versa. All type of arguments
155     are grouped in the list/dict returned. The order of the positional
156     arguments is preserved and if there are multiple keyword arguments with
157     the same key, the last value is the winner (all other values are lost).
158
159     Examples:
160
161     >>> parse_command('hello world')
162     ([u'hello', u'world'], {})
163     >>> parse_command('hello planet=earth')
164     ([u'hello'], {u'planet': u'earth'})
165     >>> parse_command('hello planet="third rock from the sun"')
166     ([u'hello'], {u'planet': u'third rock from the sun'})
167     >>> parse_command(u'  planet="third rock from the sun" hello ')
168     ([u'hello'], {u'planet': u'third rock from the sun'})
169     >>> parse_command(u'  planet="third rock from the sun" "hi, hello"'
170             '"how are you" ')
171     ([u'hi, hello', u'how are you'], {u'planet': u'third rock from the sun'})
172     >>> parse_command(u'one two three "fourth number"=four')
173     ([u'one', u'two', u'three'], {u'fourth number': u'four'})
174     >>> parse_command(u'one two three "fourth number=four"')
175     ([u'one', u'two', u'three', u'fourth number=four'], {})
176     >>> parse_command(u'one two three fourth\=four')
177     ([u'one', u'two', u'three', u'fourth=four'], {})
178     >>> parse_command(u'one two three fourth=four=five')
179     ([u'one', u'two', u'three'], {u'fourth': u'four=five'})
180     >>> parse_command(ur'nice\nlong\n\ttext')
181     ([u'nice\nlong\n\ttext'], {})
182     >>> parse_command('=hello')
183     ([u'=hello'], {})
184
185     This examples are syntax errors:
186     Missing quote: "hello world
187     Missing value: hello=
188     """
189     SEP, TOKEN, DQUOTE, SQUOTE, EQUAL = u' ', None, u'"', u"'", u'=' # states
190     separators = (u' ', u'\t', u'\v', u'\n') # token separators
191     escaped_chars = (u'a', u'n', u'r', u'b', u'v', u't') # escaped sequences
192     seq = []
193     dic = {}
194     buff = u''
195     escape = False
196     keyword = None
197     state = SEP
198     for c in command:
199         # Escaped character
200         if escape:
201             for e in escaped_chars:
202                 if c == e:
203                     buff += eval(u'"\\' + e + u'"')
204                     break
205             else:
206                 buff += c
207             escape = False
208             continue
209         # Escaped sequence start
210         if c == u'\\':
211             escape = True
212             continue
213         # Looking for spaces
214         if state == SEP:
215             if c in separators:
216                 continue
217             if buff:
218                 if c == EQUAL: # Keyword found
219                     keyword = buff
220                     buff = u''
221                     continue
222                 if keyword is not None: # Value found
223                     dic[str(keyword)] = buff
224                     keyword = None
225                 else: # Normal parameter found
226                     seq.append(buff)
227                 buff = u''
228             state = TOKEN
229         # Getting a token
230         if state == TOKEN:
231             if c == DQUOTE:
232                 state = DQUOTE
233                 continue
234             if c == SQUOTE:
235                 state = SQUOTE
236                 continue
237             # Check if a keyword is added
238             if c == EQUAL and keyword is None and buff:
239                 keyword = buff
240                 buff = u''
241                 state = SEP
242                 continue
243             if c in separators:
244                 state = SEP
245                 continue
246             buff += c
247             continue
248         # Inside a double quote
249         if state == DQUOTE:
250             if c == DQUOTE:
251                 state = TOKEN
252                 continue
253             buff += c
254             continue
255         # Inside a single quote
256         if state == SQUOTE:
257             if c == SQUOTE:
258                 state = TOKEN
259                 continue
260             buff += c
261             continue
262         assert 0, u'Unexpected state'
263     if state == DQUOTE or state == SQUOTE:
264         raise ParseError(command, u'missing closing quote (%s)' % state)
265     if not buff and keyword is not None:
266         raise ParseError(command,
267                         u'keyword argument (%s) without value' % keyword)
268     if buff:
269         if keyword is not None:
270             dic[str(keyword)] = buff
271         else:
272             seq.append(buff)
273     return (seq, dic)
274
275 class Dispatcher:
276     r"""Dispatcher([routes]) -> Dispatcher instance :: Command dispatcher
277
278     This class provides a modular and extensible dispatching mechanism. You
279     can specify root 'routes' (as a dict where the key is the string of the
280     root command and the value is a callable object to handle that command,
281     or a subcommand if the callable is an instance and the command can be
282     sub-routed).
283
284     The command can have arguments, separated by (any number of) spaces.
285
286     The dispatcher tries to route the command as deeply as it can, passing
287     the other "path" components as arguments to the callable. To route the
288     command it inspects the callable attributes to find a suitable callable
289     attribute to handle the command in a more specific way, and so on.
290
291     Example:
292     >>> d = Dispatcher(dict(handler=some_handler))
293     >>> d.dispatch('handler attribute method arg1 arg2 "third argument"')
294
295     If 'some_handler' is an object with an 'attribute' that is another
296     object which has a method named 'method', then
297     some_handler.attribute.method('arg1', 'arg2') will be called. If
298     some_handler is a function, then some_handler('attribute', 'method',
299     'arg1', 'arg2') will be called. The handler "tree" can be as complex
300     and deep as you want.
301
302     If some command can't be dispatched (because there is no root handler or
303     there is no matching callable attribute), a CommandNotFoundError is raised.
304     """
305
306     def __init__(self, routes=dict()):
307         r"""Initialize the Dispatcher object.
308
309         See Dispatcher class documentation for more info.
310         """
311         self.routes = routes
312
313     def dispatch(self, route):
314         r"""dispatch(route) -> None :: Dispatch a command string.
315
316         This method searches for a suitable callable object in the routes
317         "tree" and call it, or raises a CommandNotFoundError if the command
318         can't be dispatched.
319         """
320         command = list()
321         (route, kwargs) = parse_command(route)
322         if not route:
323             raise CommandNotFoundError(command)
324         command.append(route[0])
325         handler = self.routes.get(route[0], None)
326         if handler is None:
327             raise CommandNotFoundError(command)
328         route = route[1:]
329         while not is_handler(handler):
330             if len(route) is 0:
331                 raise CommandNotFoundError(command)
332             command.append(route[0])
333             if not hasattr(handler, route[0]):
334                 raise CommandNotFoundError(command)
335             handler = getattr(handler, route[0])
336             route = route[1:]
337         return handler(*route, **kwargs)
338
339
340 if __name__ == '__main__':
341
342     @handler(u"test: Print all the arguments, return nothing.")
343     def test_func(*args):
344         print 'func:', args
345
346     class TestClassSubHandler(Handler):
347         @handler(u"subcmd: Print all the arguments, return nothing.")
348         def subcmd(self, *args):
349             print 'class.subclass.subcmd:', args
350
351     class TestClass(Handler):
352         @handler(u"cmd1: Print all the arguments, return nothing.")
353         def cmd1(self, *args):
354             print 'class.cmd1:', args
355         @handler(u"cmd2: Print all the arguments, return nothing.")
356         def cmd2(self, *args):
357             print 'class.cmd2:', args
358         subclass = TestClassSubHandler()
359
360     test_class = TestClass()
361
362     d = Dispatcher(dict(
363             func=test_func,
364             inst=test_class,
365     ))
366
367     d.dispatch(r'''func arg1 arg2 arg3 "fourth 'argument' with \", a\ttab and\n\\n"''')
368     print 'inst commands:', tuple(d.dispatch('inst commands'))
369     print 'inst help:', d.dispatch('inst help')
370     d.dispatch('inst cmd1 arg1 arg2 arg3 arg4')
371     d.dispatch('inst cmd2 arg1 arg2')
372     print 'inst subclass help:', d.dispatch('inst subclass help')
373     d.dispatch('inst subclass subcmd arg1 arg2 arg3 arg4 arg5')
374     try:
375         d.dispatch('')
376     except CommandNotFoundError, e:
377         print 'Not found:', e
378     try:
379         d.dispatch('sucutrule piquete culete')
380     except CommandNotFoundError, e:
381         print 'Not found:', e
382     try:
383         d.dispatch('inst cmd3 arg1 arg2 arg3')
384     except CommandNotFoundError, e:
385         print 'Not found:', e
386
387     # Parser tests
388     print parse_command('hello world')
389     print parse_command('hello planet=earth')
390     print parse_command('hello planet="third rock from the sun"')
391     print parse_command(u'  planet="third rock from the sun" hello ')
392     print parse_command(u'  planet="third rock from the sun" "hi, hello"'
393                                                             '"how are you" ')
394     print parse_command(u'one two three "fourth number"=four')
395     print parse_command(u'one two three "fourth number=four"')
396     print parse_command(u'one two three fourth\=four')
397     print parse_command(u'one two three fourth=four=five')
398     print parse_command(ur'nice\nlong\n\ttext')
399     print parse_command('=hello')
400     try:
401         parse_command('hello=')
402     except ParseError, e:
403         print e
404     try:
405         parse_command('"hello')
406     except ParseError, e:
407         print e
408