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