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