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