]> git.llucax.com Git - software/pymin.git/blob - pymin/dispatcher.py
Merge baryon.com.ar:workspace/pymin
[software/pymin.git] / pymin / 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 import re
11
12 __ALL__ = ('Error', 'HandlerError', 'CommandNotFoundError', 'Handler',
13             'Dispatcher', 'handler', 'is_handler', 'get_help')
14
15 class Error(RuntimeError):
16     r"""Error(command) -> Error instance :: Base dispatching exceptions class.
17
18     All exceptions raised by the Dispatcher inherits from this one, so you can
19     easily catch any dispatching exception.
20
21     command - is the command that raised the exception, expressed as a list of
22               paths (or subcommands).
23     """
24
25     def __init__(self, message):
26         r"Initialize the Error object. See class documentation for more info."
27         self.message = message
28
29     def __unicode__(self):
30         return self.message
31
32     def __str__(self):
33         return unicode(self).encode('utf-8')
34
35 class HandlerError(Error):
36     r"""HandlerError(command) -> HandlerError instance :: Base handlers error.
37
38     All exceptions raised by the handlers should inherit from this one, so
39     dispatching errors could be separated from real programming errors (bugs).
40     """
41     pass
42
43 class CommandError(Error):
44     r"""CommandError(command) -> CommandError instance :: Base command error.
45
46     This exception is raised when there's a problem with the command itself.
47     It's the base class for all command (as a string) related error.
48     """
49
50     def __init__(self, command):
51         r"Initialize the object, see class documentation for more info."
52         self.command = command
53
54     def __unicode__(self):
55         return u'Error in command "%s".' % u' '.join(self.command)
56
57 class WrongArgumentsError(CommandError):
58     r"""WrongArgumentsError(handler, message) -> WrongArgumentsError instance.
59
60     This exception is raised when an empty command string is received.
61     """
62
63     def __init__(self, handler, message):
64         r"Initialize the object, see class documentation for more info."
65         self.handler = handler
66         self.message = message
67
68     def __unicode__(self):
69         return u'Command "%s" %s.' % (self.handler.__name__, self.message)
70
71 class CommandNotSpecifiedError(CommandError):
72     r"""CommandNotSpecifiedError() -> CommandNotSpecifiedError instance.
73
74     This exception is raised when an empty command string is received.
75     """
76
77     def __init__(self):
78         r"Initialize the object, see class documentation for more info."
79         pass
80
81     def __unicode__(self):
82         return u'Command not specified.'
83
84 class CommandIsAHandlerError(CommandError):
85     r"""CommandIsAHandlerError() -> CommandIsAHandlerError instance.
86
87     This exception is raised when a command is a handler containing commands
88     instead of a command itself.
89     """
90
91     def __unicode__(self):
92         command = ' '.join(self.command)
93         return u'"%s" is a handler, not a command (type "%s help" for help).' \
94                     % (command, command)
95
96 class CommandNotInHandlerError(CommandError):
97     r"""CommandNotInHandlerError() -> CommandNotInHandlerError instance.
98
99     This exception is raised when a command parent is a hanlder containing
100     commands, but the command itself is not found.
101     """
102
103     def __unicode__(self):
104         return u'Command "%(c)s" not found in handler "%(h)s" ' \
105                 u'(type "%(h)s help" for help).' \
106                         % dict(c=u' '.join(self.command[-1:]),
107                                 h=u' '.join(self.command[0:-1]))
108
109 class CommandNotFoundError(CommandError):
110     r"""CommandNotFoundError(command[, handler]) -> CommandNotFoundError object.
111
112     This exception is raised when the command received can't be dispatched
113     because there is no handlers to process it.
114     """
115
116     def __unicode__(self):
117         return u'Command "%s" not found.' % u' '.join(self.command)
118
119 class ParseError(CommandError):
120     r"""ParseError(command[, desc]) -> ParseError instance
121
122     This exception is raised when there is an error parsing a command.
123
124     command - Command that can't be parsed.
125
126     desc - Description of the error.
127     """
128
129     def __init__(self, command, desc="can't parse"):
130         r"""Initialize the object.
131
132         See class documentation for more info.
133         """
134         self.command = command
135         self.desc = desc
136
137     def __unicode__(self):
138         return u'Syntax error, %s: %s' % (self.desc, self.command)
139
140 class HelpNotFoundError(Error):
141     r"""HelpNotFoundError(command) -> HelpNotFoundError instance.
142
143     This exception is raised when a help command can't find the command
144     asked for help.
145     """
146
147     def __init__(self, command):
148         r"""Initialize the object.
149
150         See class documentation for more info.
151         """
152         self.command = command
153
154     def __unicode__(self):
155         return u"Can't get help for '%s', command not found." % self.command
156
157
158 def handler(help):
159     r"""handler(help) -> function wrapper :: Mark a callable as a handler.
160
161     This is a decorator to mark a callable object as a dispatcher handler.
162
163     help - Help string for the handler.
164     """
165     def wrapper(f):
166         if not help:
167             raise TypeError("'help' should not be empty")
168         f._dispatcher_handler = True
169         f.handler_help = help
170         return f
171     return wrapper
172
173 def is_handler(handler):
174     r"is_handler(handler) -> bool :: Tell if a object is a handler."
175     return callable(handler) and hasattr(handler, '_dispatcher_handler') \
176                 and handler._dispatcher_handler
177
178 class Handler:
179     r"""Handler() -> Handler instance :: Base class for all dispatcher handlers.
180
181     All dispatcher handlers should inherit from this class to have some extra
182     commands, like help. You should override the 'handler_help' attribute to a
183     nice help message describing the handler.
184     """
185
186     handler_help = u'Undocumented handler'
187
188     @handler(u'List available commands')
189     def commands(self):
190         r"""commands() -> generator :: List the available commands."""
191         return (a for a in dir(self) if is_handler(getattr(self, a)))
192
193     @handler(u'Show available commands with their help')
194     def help(self, command=None):
195         r"""help([command]) -> unicode/dict :: Show help on available commands.
196
197         If command is specified, it returns the help of that particular command.
198         If not, it returns a dictionary which keys are the available commands
199         and values are the help strings.
200         """
201         if command is None:
202             d = dict()
203             for a in dir(self):
204                 h = getattr(self, a)
205                 if a == 'parent': continue # Skip parents in SubHandlers
206                 if is_handler(h) or isinstance(h, Handler):
207                     d[a] = h.handler_help
208             return d
209         # A command was specified
210         if command == 'parent': # Skip parents in SubHandlers
211             raise HelpNotFoundError(command)
212         if not hasattr(self, command.encode('utf-8')):
213             raise HelpNotFoundError(command)
214         handler = getattr(self, command.encode('utf-8'))
215         if not is_handler(handler) and not hasattr(handler, 'handler_help'):
216             raise HelpNotFoundError(command)
217         return handler.handler_help
218
219     def handle_timer(self):
220         r"""handle_timer() -> None :: Do periodic tasks.
221
222         By default we do nothing but calling handle_timer() on subhandlers.
223         """
224         for a in dir(self):
225             if a == 'parent': continue # Skip parents in SubHandlers
226             h = getattr(self, a)
227             if isinstance(h, Handler):
228                 h.handle_timer()
229
230 def parse_command(command):
231     r"""parse_command(command) -> (args, kwargs) :: Parse a command.
232
233     This function parses a command and split it into a list of parameters. It
234     has a similar to bash commandline parser. Spaces are the basic token
235     separator but you can group several tokens into one by using (single or
236     double) quotes. You can escape the quotes with a backslash (\' and \"),
237     express a backslash literal using a double backslash (\\), use special
238     meaning escaped sequences (like \a, \n, \r, \b, \v) and use unescaped
239     single quotes inside a double quoted token or vice-versa. A special escape
240     sequence is provided to express a NULL/None value: \N and it should appear
241     always as a separated token.
242
243     Additionally it accepts keyword arguments. When an (not-escaped) equal
244     sign (=) is found, the argument is considered a keyword, and the next
245     argument it's interpreted as its value.
246
247     This function returns a tuple containing a list and a dictionary. The
248     first has the positional arguments, the second, the keyword arguments.
249
250     There is no restriction about the order, a keyword argument can be
251     followed by a positional argument and vice-versa. All type of arguments
252     are grouped in the list/dict returned. The order of the positional
253     arguments is preserved and if there are multiple keyword arguments with
254     the same key, the last value is the winner (all other values are lost).
255
256     The command should be a unicode string.
257
258     Examples:
259
260     >>> parse_command('hello world')
261     ([u'hello', u'world'], {})
262     >>> parse_command('hello planet=earth')
263     ([u'hello'], {'planet': u'earth'})
264     >>> parse_command('hello planet="third rock from the sun"')
265     ([u'hello'], {'planet': u'third rock from the sun'})
266     >>> parse_command(u'  planet="third rock from the sun" hello ')
267     ([u'hello'], {'planet': u'third rock from the sun'})
268     >>> parse_command(u'  planet="third rock from the sun" "hi, hello"'
269             '"how are you" ')
270     ([u'hi, hello', u'how are you'], {'planet': u'third rock from the sun'})
271     >>> parse_command(u'one two three "fourth number"=four')
272     ([u'one', u'two', u'three'], {'fourth number': u'four'})
273     >>> parse_command(u'one two three "fourth number=four"')
274     ([u'one', u'two', u'three', u'fourth number=four'], {})
275     >>> parse_command(u'one two three fourth\=four')
276     ([u'one', u'two', u'three', u'fourth=four'], {})
277     >>> parse_command(u'one two three fourth=four=five')
278     ([u'one', u'two', u'three'], {'fourth': u'four=five'})
279     >>> parse_command(ur'nice\nlong\n\ttext')
280     ([u'nice\nlong\n\ttext'], {})
281     >>> parse_command('=hello')
282     ([u'=hello'], {})
283     >>> parse_command(r'\thello')
284     ([u'\thello'], {})
285     >>> parse_command(r'hello \n')
286     ([u'hello', u'\n'], {})
287     >>> parse_command(r'hello \nmundo')
288     ([u'hello', u'\nmundo'], {})
289     >>> parse_command(r'test \N')
290     ([u'test', None], {})
291     >>> parse_command(r'\N')
292     ([None], {})
293     >>> parse_command(r'none=\N')
294     ([], {'none': None})
295     >>> parse_command(r'\N=none')
296     ([], {'\\N': 'none'})
297     >>> parse_command(r'Not\N')
298     ([u'Not\\N'], {})
299     >>> parse_command(r'\None')
300     ([u'\\None'], {})
301
302     This examples are syntax errors:
303     Missing quote: "hello world
304     Missing value: hello=
305     """
306     SEP, TOKEN, DQUOTE, SQUOTE, EQUAL = u' ', None, u'"', u"'", u'=' # states
307     separators = (u' ', u'\t', u'\v', u'\n') # token separators
308     escaped_chars = (u'a', u'n', u'r', u'b', u'v', u't') # escaped sequences
309     seq = []
310     dic = {}
311     buff = u''
312     escape = False
313     keyword = None
314     state = SEP
315     def register_token(buff, keyword, seq, dic):
316         if buff == r'\N':
317             buff = None
318         if keyword is not None:
319             dic[keyword.encode('utf-8')] = buff
320             keyword = None
321         else:
322             seq.append(buff)
323         buff = u''
324         return (buff, keyword)
325     for n, c in enumerate(command):
326         # Escaped character
327         if escape:
328             # Not yet registered the token
329             if state == SEP and buff:
330                 (buff, keyword) = register_token(buff, keyword, seq, dic)
331                 state = TOKEN
332             for e in escaped_chars:
333                 if c == e:
334                     buff += eval(u'"\\' + e + u'"')
335                     break
336             else:
337                 if c == 'N':
338                     buff += r'\N'
339                 else:
340                     buff += c
341             escape = False
342             continue
343         # Escaped sequence start
344         if c == u'\\':
345             escape = True
346             continue
347         # Looking for spaces
348         if state == SEP:
349             if c in separators:
350                 continue
351             if buff and n != 2: # Not the first item (even if was a escape seq)
352                 if c == EQUAL: # Keyword found
353                     keyword = buff
354                     buff = u''
355                     continue
356                 (buff, keyword) = register_token(buff, keyword, seq, dic)
357             state = TOKEN
358         # Getting a token
359         if state == TOKEN:
360             if c == DQUOTE:
361                 state = DQUOTE
362                 continue
363             if c == SQUOTE:
364                 state = SQUOTE
365                 continue
366             # Check if a keyword is added
367             if c == EQUAL and keyword is None and buff:
368                 keyword = buff
369                 buff = u''
370                 state = SEP
371                 continue
372             if c in separators:
373                 state = SEP
374                 continue
375             buff += c
376             continue
377         # Inside a double quote
378         if state == DQUOTE:
379             if c == DQUOTE:
380                 state = TOKEN
381                 continue
382             buff += c
383             continue
384         # Inside a single quote
385         if state == SQUOTE:
386             if c == SQUOTE:
387                 state = TOKEN
388                 continue
389             buff += c
390             continue
391         assert 0, u'Unexpected state'
392     if state == DQUOTE or state == SQUOTE:
393         raise ParseError(command, u'missing closing quote (%s)' % state)
394     if not buff and keyword is not None:
395         raise ParseError(command,
396                         u'keyword argument (%s) without value' % keyword)
397     if buff:
398         register_token(buff, keyword, seq, dic)
399     return (seq, dic)
400
401 args_re = re.compile(r'\w+\(\) takes (.+) (\d+) \w+ \((\d+) given\)')
402 kw_re = re.compile(r'\w+\(\) got an unexpected keyword argument (.+)')
403
404 class Dispatcher:
405     r"""Dispatcher([root]) -> Dispatcher instance :: Command dispatcher.
406
407     This class provides a modular and extensible dispatching mechanism. You
408     specify a root handler (probably as a pymin.dispatcher.Handler subclass),
409
410     The command can have arguments, separated by (any number of) spaces and
411     keyword arguments (see parse_command for more details).
412
413     The dispatcher tries to route the command as deeply as it can, passing
414     the other "path" components as arguments to the callable. To route the
415     command it inspects the callable attributes to find a suitable callable
416     attribute to handle the command in a more specific way, and so on.
417
418     Example:
419     >>> d = Dispatcher(dict(handler=some_handler))
420     >>> d.dispatch('handler attribute method arg1 "arg 2" arg=3')
421
422     If 'some_handler' is an object with an 'attribute' that is another
423     object which has a method named 'method', then
424     some_handler.attribute.method('arg1', 'arg 2', arg=3) will be called.
425     If some_handler is a function, then some_handler('attribute', 'method',
426     'arg1', 'arg 2', arg=3) will be called. The handler "tree" can be as
427     complex and deep as you want.
428
429     If some command can't be dispatched, a CommandError subclass is raised.
430     """
431
432     def __init__(self, root):
433         r"""Initialize the Dispatcher object.
434
435         See Dispatcher class documentation for more info.
436         """
437         self.root = root
438
439     def dispatch(self, route):
440         r"""dispatch(route) -> None :: Dispatch a command string.
441
442         This method searches for a suitable callable object in the routes
443         "tree" and call it, or raises a CommandError subclass if the command
444         can't be dispatched.
445
446         route - *unicode* string with the command route.
447         """
448         command = list()
449         (route, kwargs) = parse_command(route)
450         if not route:
451             raise CommandNotSpecifiedError()
452         handler = self.root
453         while not is_handler(handler):
454             if len(route) is 0:
455                 if isinstance(handler, Handler):
456                     raise CommandIsAHandlerError(command)
457                 raise CommandNotFoundError(command)
458             command.append(route[0])
459             if route[0] == 'parent':
460                 raise CommandNotFoundError(command)
461             if not hasattr(handler, route[0].encode('utf-8')):
462                 if isinstance(handler, Handler) and len(command) > 1:
463                     raise CommandNotInHandlerError(command)
464                 raise CommandNotFoundError(command)
465             handler = getattr(handler, route[0].encode('utf-8'))
466             route = route[1:]
467         try:
468             return handler(*route, **kwargs)
469         except TypeError, e:
470             m = args_re.match(unicode(e))
471             if m:
472                 (quant, n_ok, n_bad)  = m.groups()
473                 n_ok = int(n_ok)
474                 n_bad = int(n_bad)
475                 n_ok -= 1
476                 n_bad -= 1
477                 pl = ''
478                 if n_ok > 1:
479                     pl = 's'
480                 raise WrongArgumentsError(handler, u'takes %s %s argument%s, '
481                             '%s given' % (quant, n_ok, pl, n_bad))
482             m = kw_re.match(unicode(e))
483             if m:
484                 (kw,)  = m.groups()
485                 raise WrongArgumentsError(handler,
486                         u'got an unexpected keyword argument %s' % kw)
487             raise
488
489
490 if __name__ == '__main__':
491
492     @handler(u"test: Print all the arguments, return nothing")
493     def test_func(*args):
494         print 'func:', args
495
496     class TestClassSubHandler(Handler):
497         @handler(u"subcmd: Print all the arguments, return nothing")
498         def subcmd(self, *args):
499             print 'class.subclass.subcmd:', args
500
501     class TestClass(Handler):
502         @handler(u"cmd1: Print all the arguments, return nothing")
503         def cmd1(self, *args):
504             print 'class.cmd1:', args
505         @handler(u"cmd2: Print all the arguments, return nothing")
506         def cmd2(self, *args):
507             print 'class.cmd2:', args
508         subclass = TestClassSubHandler()
509
510     class RootHandler(Handler):
511         func = staticmethod(test_func)
512         inst = TestClass()
513
514     d = Dispatcher(RootHandler())
515
516     d.dispatch(r'''func arg1 arg2 arg3 "fourth 'argument' with \", a\ttab and\n\\n"''')
517     print 'inst commands:', tuple(d.dispatch('inst commands'))
518     print 'inst help:', d.dispatch('inst help')
519     d.dispatch('inst cmd1 arg1 arg2 arg3 arg4')
520     d.dispatch('inst cmd2 arg1 arg2')
521     print 'inst subclass help:', d.dispatch('inst subclass help')
522     d.dispatch('inst subclass subcmd arg1 arg2 arg3 arg4 arg5')
523     try:
524         d.dispatch('')
525     except CommandNotSpecifiedError, e:
526         print 'Not found:', e
527     try:
528         d.dispatch('sucutrule piquete culete')
529     except CommandNotFoundError, e:
530         print 'Not found:', e
531     try:
532         d.dispatch('inst cmd3 arg1 arg2 arg3')
533     except CommandNotInHandlerError, e:
534         print 'Not found:', e
535     try:
536         d.dispatch('inst')
537     except CommandIsAHandlerError, e:
538         print 'Not found:', e
539     print
540     print
541
542     # Parser tests
543     p = parse_command('hello world')
544     assert p == ([u'hello', u'world'], {}), p
545     p = parse_command('hello planet=earth')
546     assert p  == ([u'hello'], {'planet': u'earth'}), p
547     p = parse_command('hello planet="third rock from the sun"')
548     assert p == ([u'hello'], {'planet': u'third rock from the sun'}), p
549     p = parse_command(u'  planet="third rock from the sun" hello ')
550     assert p == ([u'hello'], {'planet': u'third rock from the sun'}), p
551     p = parse_command(u'  planet="third rock from the sun" "hi, hello" '
552                             '"how are you" ')
553     assert p == ([u'hi, hello', u'how are you'],
554                 {'planet': u'third rock from the sun'}), p
555     p = parse_command(u'one two three "fourth number"=four')
556     assert p == ([u'one', u'two', u'three'], {'fourth number': u'four'}), p
557     p = parse_command(u'one two three "fourth number=four"')
558     assert p == ([u'one', u'two', u'three', u'fourth number=four'], {}), p
559     p = parse_command(u'one two three fourth\=four')
560     assert p == ([u'one', u'two', u'three', u'fourth=four'], {}), p
561     p = parse_command(u'one two three fourth=four=five')
562     assert p == ([u'one', u'two', u'three'], {'fourth': u'four=five'}), p
563     p = parse_command(ur'nice\nlong\n\ttext')
564     assert p == ([u'nice\nlong\n\ttext'], {}), p
565     p = parse_command('=hello')
566     assert p == ([u'=hello'], {}), p
567     p = parse_command(r'\thello')
568     assert p == ([u'\thello'], {}), p
569     p = parse_command(r'hello \n')
570     assert p == ([u'hello', u'\n'], {}), p
571     p = parse_command(r'hello \nmundo')
572     assert p == ([u'hello', u'\nmundo'], {}), p
573     p = parse_command(r'test \N')
574     assert p == ([u'test', None], {}), p
575     p = parse_command(r'\N')
576     assert p == ([None], {}), p
577     p = parse_command(r'none=\N')
578     assert p == ([], {'none': None}), p
579     p = parse_command(r'\N=none')
580     assert p == ([], {'\\N': 'none'}), p
581     p = parse_command(r'Not\N')
582     assert p == ([u'Not\\N'], {}), p
583     p = parse_command(r'\None')
584     assert p == ([u'\\None'], {}), p
585     try:
586         p = parse_command('hello=')
587     except ParseError, e:
588         pass
589     else:
590         assert False, p + ' should raised a ParseError'
591     try:
592         p = parse_command('"hello')
593     except ParseError, e:
594         pass
595     else:
596         assert False, p + ' should raised a ParseError'
597