]> git.llucax.com Git - software/pymin.git/blob - pymin/dispatcher.py
Bugfix: skip SubHandlers parent when looking for a handler/command/help.
[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 parse_command(command):
220     r"""parse_command(command) -> (args, kwargs) :: Parse a command.
221
222     This function parses a command and split it into a list of parameters. It
223     has a similar to bash commandline parser. Spaces are the basic token
224     separator but you can group several tokens into one by using (single or
225     double) quotes. You can escape the quotes with a backslash (\' and \"),
226     express a backslash literal using a double backslash (\\), use special
227     meaning escaped sequences (like \a, \n, \r, \b, \v) and use unescaped
228     single quotes inside a double quoted token or vice-versa. A special escape
229     sequence is provided to express a NULL/None value: \N and it should appear
230     always as a separated token.
231
232     Additionally it accepts keyword arguments. When an (not-escaped) equal
233     sign (=) is found, the argument is considered a keyword, and the next
234     argument it's interpreted as its value.
235
236     This function returns a tuple containing a list and a dictionary. The
237     first has the positional arguments, the second, the keyword arguments.
238
239     There is no restriction about the order, a keyword argument can be
240     followed by a positional argument and vice-versa. All type of arguments
241     are grouped in the list/dict returned. The order of the positional
242     arguments is preserved and if there are multiple keyword arguments with
243     the same key, the last value is the winner (all other values are lost).
244
245     The command should be a unicode string.
246
247     Examples:
248
249     >>> parse_command('hello world')
250     ([u'hello', u'world'], {})
251     >>> parse_command('hello planet=earth')
252     ([u'hello'], {'planet': u'earth'})
253     >>> parse_command('hello planet="third rock from the sun"')
254     ([u'hello'], {'planet': u'third rock from the sun'})
255     >>> parse_command(u'  planet="third rock from the sun" hello ')
256     ([u'hello'], {'planet': u'third rock from the sun'})
257     >>> parse_command(u'  planet="third rock from the sun" "hi, hello"'
258             '"how are you" ')
259     ([u'hi, hello', u'how are you'], {'planet': u'third rock from the sun'})
260     >>> parse_command(u'one two three "fourth number"=four')
261     ([u'one', u'two', u'three'], {'fourth number': u'four'})
262     >>> parse_command(u'one two three "fourth number=four"')
263     ([u'one', u'two', u'three', u'fourth number=four'], {})
264     >>> parse_command(u'one two three fourth\=four')
265     ([u'one', u'two', u'three', u'fourth=four'], {})
266     >>> parse_command(u'one two three fourth=four=five')
267     ([u'one', u'two', u'three'], {'fourth': u'four=five'})
268     >>> parse_command(ur'nice\nlong\n\ttext')
269     ([u'nice\nlong\n\ttext'], {})
270     >>> parse_command('=hello')
271     ([u'=hello'], {})
272     >>> parse_command(r'\thello')
273     ([u'\thello'], {})
274     >>> parse_command(r'hello \n')
275     ([u'hello', u'\n'], {})
276     >>> parse_command(r'hello \nmundo')
277     ([u'hello', u'\nmundo'], {})
278     >>> parse_command(r'test \N')
279     ([u'test', None], {})
280     >>> parse_command(r'\N')
281     ([None], {})
282     >>> parse_command(r'none=\N')
283     ([], {'none': None})
284     >>> parse_command(r'\N=none')
285     ([], {'\\N': 'none'})
286     >>> parse_command(r'Not\N')
287     ([u'Not\\N'], {})
288     >>> parse_command(r'\None')
289     ([u'\\None'], {})
290
291     This examples are syntax errors:
292     Missing quote: "hello world
293     Missing value: hello=
294     """
295     SEP, TOKEN, DQUOTE, SQUOTE, EQUAL = u' ', None, u'"', u"'", u'=' # states
296     separators = (u' ', u'\t', u'\v', u'\n') # token separators
297     escaped_chars = (u'a', u'n', u'r', u'b', u'v', u't') # escaped sequences
298     seq = []
299     dic = {}
300     buff = u''
301     escape = False
302     keyword = None
303     state = SEP
304     def register_token(buff, keyword, seq, dic):
305         if buff == r'\N':
306             buff = None
307         if keyword is not None:
308             dic[keyword.encode('utf-8')] = buff
309             keyword = None
310         else:
311             seq.append(buff)
312         buff = u''
313         return (buff, keyword)
314     for n, c in enumerate(command):
315         # Escaped character
316         if escape:
317             # Not yet registered the token
318             if state == SEP and buff:
319                 (buff, keyword) = register_token(buff, keyword, seq, dic)
320                 state = TOKEN
321             for e in escaped_chars:
322                 if c == e:
323                     buff += eval(u'"\\' + e + u'"')
324                     break
325             else:
326                 if c == 'N':
327                     buff += r'\N'
328                 else:
329                     buff += c
330             escape = False
331             continue
332         # Escaped sequence start
333         if c == u'\\':
334             escape = True
335             continue
336         # Looking for spaces
337         if state == SEP:
338             if c in separators:
339                 continue
340             if buff and n != 2: # Not the first item (even if was a escape seq)
341                 if c == EQUAL: # Keyword found
342                     keyword = buff
343                     buff = u''
344                     continue
345                 (buff, keyword) = register_token(buff, keyword, seq, dic)
346             state = TOKEN
347         # Getting a token
348         if state == TOKEN:
349             if c == DQUOTE:
350                 state = DQUOTE
351                 continue
352             if c == SQUOTE:
353                 state = SQUOTE
354                 continue
355             # Check if a keyword is added
356             if c == EQUAL and keyword is None and buff:
357                 keyword = buff
358                 buff = u''
359                 state = SEP
360                 continue
361             if c in separators:
362                 state = SEP
363                 continue
364             buff += c
365             continue
366         # Inside a double quote
367         if state == DQUOTE:
368             if c == DQUOTE:
369                 state = TOKEN
370                 continue
371             buff += c
372             continue
373         # Inside a single quote
374         if state == SQUOTE:
375             if c == SQUOTE:
376                 state = TOKEN
377                 continue
378             buff += c
379             continue
380         assert 0, u'Unexpected state'
381     if state == DQUOTE or state == SQUOTE:
382         raise ParseError(command, u'missing closing quote (%s)' % state)
383     if not buff and keyword is not None:
384         raise ParseError(command,
385                         u'keyword argument (%s) without value' % keyword)
386     if buff:
387         register_token(buff, keyword, seq, dic)
388     return (seq, dic)
389
390 args_re = re.compile(r'\w+\(\) takes (.+) (\d+) \w+ \((\d+) given\)')
391 kw_re = re.compile(r'\w+\(\) got an unexpected keyword argument (.+)')
392
393 class Dispatcher:
394     r"""Dispatcher([root]) -> Dispatcher instance :: Command dispatcher.
395
396     This class provides a modular and extensible dispatching mechanism. You
397     specify a root handler (probably as a pymin.dispatcher.Handler subclass),
398
399     The command can have arguments, separated by (any number of) spaces and
400     keyword arguments (see parse_command for more details).
401
402     The dispatcher tries to route the command as deeply as it can, passing
403     the other "path" components as arguments to the callable. To route the
404     command it inspects the callable attributes to find a suitable callable
405     attribute to handle the command in a more specific way, and so on.
406
407     Example:
408     >>> d = Dispatcher(dict(handler=some_handler))
409     >>> d.dispatch('handler attribute method arg1 "arg 2" arg=3')
410
411     If 'some_handler' is an object with an 'attribute' that is another
412     object which has a method named 'method', then
413     some_handler.attribute.method('arg1', 'arg 2', arg=3) will be called.
414     If some_handler is a function, then some_handler('attribute', 'method',
415     'arg1', 'arg 2', arg=3) will be called. The handler "tree" can be as
416     complex and deep as you want.
417
418     If some command can't be dispatched, a CommandError subclass is raised.
419     """
420
421     def __init__(self, root):
422         r"""Initialize the Dispatcher object.
423
424         See Dispatcher class documentation for more info.
425         """
426         self.root = root
427
428     def dispatch(self, route):
429         r"""dispatch(route) -> None :: Dispatch a command string.
430
431         This method searches for a suitable callable object in the routes
432         "tree" and call it, or raises a CommandError subclass if the command
433         can't be dispatched.
434
435         route - *unicode* string with the command route.
436         """
437         command = list()
438         (route, kwargs) = parse_command(route)
439         if not route:
440             raise CommandNotSpecifiedError()
441         handler = self.root
442         while not is_handler(handler):
443             if len(route) is 0:
444                 if isinstance(handler, Handler):
445                     raise CommandIsAHandlerError(command)
446                 raise CommandNotFoundError(command)
447             command.append(route[0])
448             if route[0] == 'parent':
449                 raise CommandNotFoundError(command)
450             if not hasattr(handler, route[0].encode('utf-8')):
451                 if isinstance(handler, Handler) and len(command) > 1:
452                     raise CommandNotInHandlerError(command)
453                 raise CommandNotFoundError(command)
454             handler = getattr(handler, route[0].encode('utf-8'))
455             route = route[1:]
456         try:
457             return handler(*route, **kwargs)
458         except TypeError, e:
459             m = args_re.match(unicode(e))
460             if m:
461                 (quant, n_ok, n_bad)  = m.groups()
462                 n_ok = int(n_ok)
463                 n_bad = int(n_bad)
464                 n_ok -= 1
465                 n_bad -= 1
466                 pl = ''
467                 if n_ok > 1:
468                     pl = 's'
469                 raise WrongArgumentsError(handler, u'takes %s %s argument%s, '
470                             '%s given' % (quant, n_ok, pl, n_bad))
471             m = kw_re.match(unicode(e))
472             if m:
473                 (kw,)  = m.groups()
474                 raise WrongArgumentsError(handler,
475                         u'got an unexpected keyword argument %s' % kw)
476             raise
477
478
479 if __name__ == '__main__':
480
481     @handler(u"test: Print all the arguments, return nothing")
482     def test_func(*args):
483         print 'func:', args
484
485     class TestClassSubHandler(Handler):
486         @handler(u"subcmd: Print all the arguments, return nothing")
487         def subcmd(self, *args):
488             print 'class.subclass.subcmd:', args
489
490     class TestClass(Handler):
491         @handler(u"cmd1: Print all the arguments, return nothing")
492         def cmd1(self, *args):
493             print 'class.cmd1:', args
494         @handler(u"cmd2: Print all the arguments, return nothing")
495         def cmd2(self, *args):
496             print 'class.cmd2:', args
497         subclass = TestClassSubHandler()
498
499     class RootHandler(Handler):
500         func = staticmethod(test_func)
501         inst = TestClass()
502
503     d = Dispatcher(RootHandler())
504
505     d.dispatch(r'''func arg1 arg2 arg3 "fourth 'argument' with \", a\ttab and\n\\n"''')
506     print 'inst commands:', tuple(d.dispatch('inst commands'))
507     print 'inst help:', d.dispatch('inst help')
508     d.dispatch('inst cmd1 arg1 arg2 arg3 arg4')
509     d.dispatch('inst cmd2 arg1 arg2')
510     print 'inst subclass help:', d.dispatch('inst subclass help')
511     d.dispatch('inst subclass subcmd arg1 arg2 arg3 arg4 arg5')
512     try:
513         d.dispatch('')
514     except CommandNotSpecifiedError, e:
515         print 'Not found:', e
516     try:
517         d.dispatch('sucutrule piquete culete')
518     except CommandNotFoundError, e:
519         print 'Not found:', e
520     try:
521         d.dispatch('inst cmd3 arg1 arg2 arg3')
522     except CommandNotInHandlerError, e:
523         print 'Not found:', e
524     try:
525         d.dispatch('inst')
526     except CommandIsAHandlerError, e:
527         print 'Not found:', e
528     print
529     print
530
531     # Parser tests
532     p = parse_command('hello world')
533     assert p == ([u'hello', u'world'], {}), p
534     p = parse_command('hello planet=earth')
535     assert p  == ([u'hello'], {'planet': u'earth'}), p
536     p = parse_command('hello planet="third rock from the sun"')
537     assert p == ([u'hello'], {'planet': u'third rock from the sun'}), p
538     p = parse_command(u'  planet="third rock from the sun" hello ')
539     assert p == ([u'hello'], {'planet': u'third rock from the sun'}), p
540     p = parse_command(u'  planet="third rock from the sun" "hi, hello" '
541                             '"how are you" ')
542     assert p == ([u'hi, hello', u'how are you'],
543                 {'planet': u'third rock from the sun'}), p
544     p = parse_command(u'one two three "fourth number"=four')
545     assert p == ([u'one', u'two', u'three'], {'fourth number': u'four'}), p
546     p = parse_command(u'one two three "fourth number=four"')
547     assert p == ([u'one', u'two', u'three', u'fourth number=four'], {}), p
548     p = parse_command(u'one two three fourth\=four')
549     assert p == ([u'one', u'two', u'three', u'fourth=four'], {}), p
550     p = parse_command(u'one two three fourth=four=five')
551     assert p == ([u'one', u'two', u'three'], {'fourth': u'four=five'}), p
552     p = parse_command(ur'nice\nlong\n\ttext')
553     assert p == ([u'nice\nlong\n\ttext'], {}), p
554     p = parse_command('=hello')
555     assert p == ([u'=hello'], {}), p
556     p = parse_command(r'\thello')
557     assert p == ([u'\thello'], {}), p
558     p = parse_command(r'hello \n')
559     assert p == ([u'hello', u'\n'], {}), p
560     p = parse_command(r'hello \nmundo')
561     assert p == ([u'hello', u'\nmundo'], {}), p
562     p = parse_command(r'test \N')
563     assert p == ([u'test', None], {}), p
564     p = parse_command(r'\N')
565     assert p == ([None], {}), p
566     p = parse_command(r'none=\N')
567     assert p == ([], {'none': None}), p
568     p = parse_command(r'\N=none')
569     assert p == ([], {'\\N': 'none'}), p
570     p = parse_command(r'Not\N')
571     assert p == ([u'Not\\N'], {}), p
572     p = parse_command(r'\None')
573     assert p == ([u'\\None'], {}), p
574     try:
575         p = parse_command('hello=')
576     except ParseError, e:
577         pass
578     else:
579         assert False, p + ' should raised a ParseError'
580     try:
581         p = parse_command('"hello')
582     except ParseError, e:
583         pass
584     else:
585         assert False, p + ' should raised a ParseError'
586