]> git.llucax.com Git - software/pymin.git/blob - dispatcher.py
Merge /home/luca/pymin
[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. A special escape
145     sequence is provided to express a NULL/None value: \N and it should appear
146     always as a separated token.
147
148     Additionally it accepts keyword arguments. When an (not-escaped) equal
149     sign (=) is found, the argument is considered a keyword, and the next
150     argument it's interpreted as its value.
151
152     This function returns a tuple containing a list and a dictionary. The
153     first has the positional arguments, the second, the keyword arguments.
154
155     There is no restriction about the order, a keyword argument can be
156     followed by a positional argument and vice-versa. All type of arguments
157     are grouped in the list/dict returned. The order of the positional
158     arguments is preserved and if there are multiple keyword arguments with
159     the same key, the last value is the winner (all other values are lost).
160
161     Examples:
162
163     >>> parse_command('hello world')
164     ([u'hello', u'world'], {})
165     >>> parse_command('hello planet=earth')
166     ([u'hello'], {'planet': u'earth'})
167     >>> parse_command('hello planet="third rock from the sun"')
168     ([u'hello'], {'planet': u'third rock from the sun'})
169     >>> parse_command(u'  planet="third rock from the sun" hello ')
170     ([u'hello'], {'planet': u'third rock from the sun'})
171     >>> parse_command(u'  planet="third rock from the sun" "hi, hello"'
172             '"how are you" ')
173     ([u'hi, hello', u'how are you'], {'planet': u'third rock from the sun'})
174     >>> parse_command(u'one two three "fourth number"=four')
175     ([u'one', u'two', u'three'], {'fourth number': u'four'})
176     >>> parse_command(u'one two three "fourth number=four"')
177     ([u'one', u'two', u'three', u'fourth number=four'], {})
178     >>> parse_command(u'one two three fourth\=four')
179     ([u'one', u'two', u'three', u'fourth=four'], {})
180     >>> parse_command(u'one two three fourth=four=five')
181     ([u'one', u'two', u'three'], {'fourth': u'four=five'})
182     >>> parse_command(ur'nice\nlong\n\ttext')
183     ([u'nice\nlong\n\ttext'], {})
184     >>> parse_command('=hello')
185     ([u'=hello'], {})
186     >>> parse_command(r'\thello')
187     ([u'\thello'], {})
188     >>> parse_command(r'\N')
189     ([None], {})
190     >>> parse_command(r'none=\N')
191     ([], {'none': None})
192     >>> parse_command(r'\N=none')
193     ([], {'\\N': 'none'})
194     >>> parse_command(r'Not\N')
195     ([u'Not\\N'], {})
196     >>> parse_command(r'\None')
197     ([u'\\None'], {})
198
199     This examples are syntax errors:
200     Missing quote: "hello world
201     Missing value: hello=
202     """
203     SEP, TOKEN, DQUOTE, SQUOTE, EQUAL = u' ', None, u'"', u"'", u'=' # states
204     separators = (u' ', u'\t', u'\v', u'\n') # token separators
205     escaped_chars = (u'a', u'n', u'r', u'b', u'v', u't') # escaped sequences
206     seq = []
207     dic = {}
208     buff = u''
209     escape = False
210     keyword = None
211     state = SEP
212     for n, c in enumerate(command):
213         # Escaped character
214         if escape:
215             for e in escaped_chars:
216                 if c == e:
217                     buff += eval(u'"\\' + e + u'"')
218                     break
219             else:
220                 if c == 'N':
221                     buff += r'\N'
222                 else:
223                     buff += c
224             escape = False
225             continue
226         # Escaped sequence start
227         if c == u'\\':
228             escape = True
229             continue
230         # Looking for spaces
231         if state == SEP:
232             if c in separators:
233                 continue
234             if buff and n != 2: # Not the first item (even if was a escape seq)
235                 if c == EQUAL: # Keyword found
236                     keyword = buff
237                     buff = u''
238                     continue
239                 if buff == r'\N':
240                     buff = None
241                 if keyword is not None: # Value found
242                     dic[str(keyword)] = buff
243                     keyword = None
244                 else: # Normal parameter found
245                     seq.append(buff)
246                 buff = u''
247             state = TOKEN
248         # Getting a token
249         if state == TOKEN:
250             if c == DQUOTE:
251                 state = DQUOTE
252                 continue
253             if c == SQUOTE:
254                 state = SQUOTE
255                 continue
256             # Check if a keyword is added
257             if c == EQUAL and keyword is None and buff:
258                 keyword = buff
259                 buff = u''
260                 state = SEP
261                 continue
262             if c in separators:
263                 state = SEP
264                 continue
265             buff += c
266             continue
267         # Inside a double quote
268         if state == DQUOTE:
269             if c == DQUOTE:
270                 state = TOKEN
271                 continue
272             buff += c
273             continue
274         # Inside a single quote
275         if state == SQUOTE:
276             if c == SQUOTE:
277                 state = TOKEN
278                 continue
279             buff += c
280             continue
281         assert 0, u'Unexpected state'
282     if state == DQUOTE or state == SQUOTE:
283         raise ParseError(command, u'missing closing quote (%s)' % state)
284     if not buff and keyword is not None:
285         raise ParseError(command,
286                         u'keyword argument (%s) without value' % keyword)
287     if buff:
288         if buff == r'\N':
289             buff = None
290         if keyword is not None:
291             dic[str(keyword)] = buff
292         else:
293             seq.append(buff)
294     return (seq, dic)
295
296 class Dispatcher:
297     r"""Dispatcher([routes]) -> Dispatcher instance :: Command dispatcher
298
299     This class provides a modular and extensible dispatching mechanism. You
300     can specify root 'routes' (as a dict where the key is the string of the
301     root command and the value is a callable object to handle that command,
302     or a subcommand if the callable is an instance and the command can be
303     sub-routed).
304
305     The command can have arguments, separated by (any number of) spaces.
306
307     The dispatcher tries to route the command as deeply as it can, passing
308     the other "path" components as arguments to the callable. To route the
309     command it inspects the callable attributes to find a suitable callable
310     attribute to handle the command in a more specific way, and so on.
311
312     Example:
313     >>> d = Dispatcher(dict(handler=some_handler))
314     >>> d.dispatch('handler attribute method arg1 arg2 "third argument"')
315
316     If 'some_handler' is an object with an 'attribute' that is another
317     object which has a method named 'method', then
318     some_handler.attribute.method('arg1', 'arg2') will be called. If
319     some_handler is a function, then some_handler('attribute', 'method',
320     'arg1', 'arg2') will be called. The handler "tree" can be as complex
321     and deep as you want.
322
323     If some command can't be dispatched (because there is no root handler or
324     there is no matching callable attribute), a CommandNotFoundError is raised.
325     """
326
327     def __init__(self, routes=dict()):
328         r"""Initialize the Dispatcher object.
329
330         See Dispatcher class documentation for more info.
331         """
332         self.routes = routes
333
334     def dispatch(self, route):
335         r"""dispatch(route) -> None :: Dispatch a command string.
336
337         This method searches for a suitable callable object in the routes
338         "tree" and call it, or raises a CommandNotFoundError if the command
339         can't be dispatched.
340         """
341         command = list()
342         (route, kwargs) = parse_command(route)
343         if not route:
344             raise CommandNotFoundError(command)
345         command.append(route[0])
346         handler = self.routes.get(route[0], None)
347         if handler is None:
348             raise CommandNotFoundError(command)
349         route = route[1:]
350         while not is_handler(handler):
351             if len(route) is 0:
352                 raise CommandNotFoundError(command)
353             command.append(route[0])
354             if not hasattr(handler, route[0]):
355                 raise CommandNotFoundError(command)
356             handler = getattr(handler, route[0])
357             route = route[1:]
358         return handler(*route, **kwargs)
359
360
361 if __name__ == '__main__':
362
363     @handler(u"test: Print all the arguments, return nothing.")
364     def test_func(*args):
365         print 'func:', args
366
367     class TestClassSubHandler(Handler):
368         @handler(u"subcmd: Print all the arguments, return nothing.")
369         def subcmd(self, *args):
370             print 'class.subclass.subcmd:', args
371
372     class TestClass(Handler):
373         @handler(u"cmd1: Print all the arguments, return nothing.")
374         def cmd1(self, *args):
375             print 'class.cmd1:', args
376         @handler(u"cmd2: Print all the arguments, return nothing.")
377         def cmd2(self, *args):
378             print 'class.cmd2:', args
379         subclass = TestClassSubHandler()
380
381     test_class = TestClass()
382
383     d = Dispatcher(dict(
384             func=test_func,
385             inst=test_class,
386     ))
387
388     d.dispatch(r'''func arg1 arg2 arg3 "fourth 'argument' with \", a\ttab and\n\\n"''')
389     print 'inst commands:', tuple(d.dispatch('inst commands'))
390     print 'inst help:', d.dispatch('inst help')
391     d.dispatch('inst cmd1 arg1 arg2 arg3 arg4')
392     d.dispatch('inst cmd2 arg1 arg2')
393     print 'inst subclass help:', d.dispatch('inst subclass help')
394     d.dispatch('inst subclass subcmd arg1 arg2 arg3 arg4 arg5')
395     try:
396         d.dispatch('')
397     except CommandNotFoundError, e:
398         print 'Not found:', e
399     try:
400         d.dispatch('sucutrule piquete culete')
401     except CommandNotFoundError, e:
402         print 'Not found:', e
403     try:
404         d.dispatch('inst cmd3 arg1 arg2 arg3')
405     except CommandNotFoundError, e:
406         print 'Not found:', e
407
408     # Parser tests
409     p = parse_command('hello world')
410     assert p == ([u'hello', u'world'], {}), p
411     p = parse_command('hello planet=earth')
412     assert p  == ([u'hello'], {'planet': u'earth'}), p
413     p = parse_command('hello planet="third rock from the sun"')
414     assert p == ([u'hello'], {'planet': u'third rock from the sun'}), p
415     p = parse_command(u'  planet="third rock from the sun" hello ')
416     assert p == ([u'hello'], {'planet': u'third rock from the sun'}), p
417     p = parse_command(u'  planet="third rock from the sun" "hi, hello" '
418                             '"how are you" ')
419     assert p == ([u'hi, hello', u'how are you'],
420                 {'planet': u'third rock from the sun'}), p
421     p = parse_command(u'one two three "fourth number"=four')
422     assert p == ([u'one', u'two', u'three'], {'fourth number': u'four'}), p
423     p = parse_command(u'one two three "fourth number=four"')
424     assert p == ([u'one', u'two', u'three', u'fourth number=four'], {}), p
425     p = parse_command(u'one two three fourth\=four')
426     assert p == ([u'one', u'two', u'three', u'fourth=four'], {}), p
427     p = parse_command(u'one two three fourth=four=five')
428     assert p == ([u'one', u'two', u'three'], {'fourth': u'four=five'}), p
429     p = parse_command(ur'nice\nlong\n\ttext')
430     assert p == ([u'nice\nlong\n\ttext'], {}), p
431     p = parse_command('=hello')
432     assert p == ([u'=hello'], {}), p
433     p = parse_command(r'\thello')
434     assert p == ([u'\thello'], {}), p
435     p = parse_command(r'\N')
436     assert p == ([None], {}), p
437     p = parse_command(r'none=\N')
438     assert p == ([], {'none': None}), p
439     p = parse_command(r'\N=none')
440     assert p == ([], {'\\N': 'none'}), p
441     p = parse_command(r'Not\N')
442     assert p == ([u'Not\\N'], {}), p
443     p = parse_command(r'\None')
444     assert p == ([u'\\None'], {}), p
445     try:
446         p = parse_command('hello=')
447     except ParseError, e:
448         pass
449     else:
450         assert False, p + ' should raised a ParseError'
451     try:
452         p = parse_command('"hello')
453     except ParseError, e:
454         pass
455     else:
456         assert False, p + ' should raised a ParseError'
457