]> git.llucax.com Git - software/pymin.git/blob - pymin/dispatcher.py
10e7fb8fe74349bb94bc53b7960842a6a7d451bb
[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
388 class Dispatcher:
389     r"""Dispatcher([root]) -> Dispatcher instance :: Command dispatcher.
390
391     This class provides a modular and extensible dispatching mechanism. You
392     specify a root handler (probably as a pymin.dispatcher.Handler subclass),
393
394     The command can have arguments, separated by (any number of) spaces and
395     keyword arguments (see parse_command for more details).
396
397     The dispatcher tries to route the command as deeply as it can, passing
398     the other "path" components as arguments to the callable. To route the
399     command it inspects the callable attributes to find a suitable callable
400     attribute to handle the command in a more specific way, and so on.
401
402     Example:
403     >>> d = Dispatcher(dict(handler=some_handler))
404     >>> d.dispatch('handler attribute method arg1 "arg 2" arg=3')
405
406     If 'some_handler' is an object with an 'attribute' that is another
407     object which has a method named 'method', then
408     some_handler.attribute.method('arg1', 'arg 2', arg=3) will be called.
409     If some_handler is a function, then some_handler('attribute', 'method',
410     'arg1', 'arg 2', arg=3) will be called. The handler "tree" can be as
411     complex and deep as you want.
412
413     If some command can't be dispatched, a CommandError subclass is raised.
414     """
415
416     def __init__(self, root):
417         r"""Initialize the Dispatcher object.
418
419         See Dispatcher class documentation for more info.
420         """
421         self.root = root
422
423     def dispatch(self, route):
424         r"""dispatch(route) -> None :: Dispatch a command string.
425
426         This method searches for a suitable callable object in the routes
427         "tree" and call it, or raises a CommandError subclass if the command
428         can't be dispatched.
429
430         route - *unicode* string with the command route.
431         """
432         command = list()
433         (route, kwargs) = parse_command(route)
434         if not route:
435             raise CommandNotSpecifiedError()
436         handler = self.root
437         while not is_handler(handler):
438             if len(route) is 0:
439                 if isinstance(handler, Handler):
440                     raise CommandIsAHandlerError(command)
441                 raise CommandNotFoundError(command)
442             command.append(route[0])
443             if not hasattr(handler, route[0].encode('utf-8')):
444                 if isinstance(handler, Handler) and len(command) > 1:
445                     raise CommandNotInHandlerError(command)
446                 raise CommandNotFoundError(command)
447             handler = getattr(handler, route[0].encode('utf-8'))
448             route = route[1:]
449         try:
450             return handler(*route, **kwargs)
451         except TypeError, e:
452             m = args_re.match(unicode(e))
453             if m:
454                 (quant, n_ok, n_bad)  = m.groups()
455                 n_ok = int(n_ok)
456                 n_bad = int(n_bad)
457                 n_ok -= 1
458                 n_bad -= 1
459                 pl = ''
460                 if n_ok > 1:
461                     pl = 's'
462                 raise WrongArgumentsError(
463                         u'Command "%s" takes %s %s argument%s, %s given.'
464                             % (handler.__name__, quant, n_ok, pl, n_bad))
465             raise
466
467
468 if __name__ == '__main__':
469
470     @handler(u"test: Print all the arguments, return nothing")
471     def test_func(*args):
472         print 'func:', args
473
474     class TestClassSubHandler(Handler):
475         @handler(u"subcmd: Print all the arguments, return nothing")
476         def subcmd(self, *args):
477             print 'class.subclass.subcmd:', args
478
479     class TestClass(Handler):
480         @handler(u"cmd1: Print all the arguments, return nothing")
481         def cmd1(self, *args):
482             print 'class.cmd1:', args
483         @handler(u"cmd2: Print all the arguments, return nothing")
484         def cmd2(self, *args):
485             print 'class.cmd2:', args
486         subclass = TestClassSubHandler()
487
488     class RootHandler(Handler):
489         func = staticmethod(test_func)
490         inst = TestClass()
491
492     d = Dispatcher(RootHandler())
493
494     d.dispatch(r'''func arg1 arg2 arg3 "fourth 'argument' with \", a\ttab and\n\\n"''')
495     print 'inst commands:', tuple(d.dispatch('inst commands'))
496     print 'inst help:', d.dispatch('inst help')
497     d.dispatch('inst cmd1 arg1 arg2 arg3 arg4')
498     d.dispatch('inst cmd2 arg1 arg2')
499     print 'inst subclass help:', d.dispatch('inst subclass help')
500     d.dispatch('inst subclass subcmd arg1 arg2 arg3 arg4 arg5')
501     try:
502         d.dispatch('')
503     except CommandNotSpecifiedError, e:
504         print 'Not found:', e
505     try:
506         d.dispatch('sucutrule piquete culete')
507     except CommandNotFoundError, e:
508         print 'Not found:', e
509     try:
510         d.dispatch('inst cmd3 arg1 arg2 arg3')
511     except CommandNotInHandlerError, e:
512         print 'Not found:', e
513     try:
514         d.dispatch('inst')
515     except CommandIsAHandlerError, e:
516         print 'Not found:', e
517     print
518     print
519
520     # Parser tests
521     p = parse_command('hello world')
522     assert p == ([u'hello', u'world'], {}), p
523     p = parse_command('hello planet=earth')
524     assert p  == ([u'hello'], {'planet': u'earth'}), p
525     p = parse_command('hello planet="third rock from the sun"')
526     assert p == ([u'hello'], {'planet': u'third rock from the sun'}), p
527     p = parse_command(u'  planet="third rock from the sun" hello ')
528     assert p == ([u'hello'], {'planet': u'third rock from the sun'}), p
529     p = parse_command(u'  planet="third rock from the sun" "hi, hello" '
530                             '"how are you" ')
531     assert p == ([u'hi, hello', u'how are you'],
532                 {'planet': u'third rock from the sun'}), p
533     p = parse_command(u'one two three "fourth number"=four')
534     assert p == ([u'one', u'two', u'three'], {'fourth number': u'four'}), p
535     p = parse_command(u'one two three "fourth number=four"')
536     assert p == ([u'one', u'two', u'three', u'fourth number=four'], {}), p
537     p = parse_command(u'one two three fourth\=four')
538     assert p == ([u'one', u'two', u'three', u'fourth=four'], {}), p
539     p = parse_command(u'one two three fourth=four=five')
540     assert p == ([u'one', u'two', u'three'], {'fourth': u'four=five'}), p
541     p = parse_command(ur'nice\nlong\n\ttext')
542     assert p == ([u'nice\nlong\n\ttext'], {}), p
543     p = parse_command('=hello')
544     assert p == ([u'=hello'], {}), p
545     p = parse_command(r'\thello')
546     assert p == ([u'\thello'], {}), p
547     p = parse_command(r'hello \n')
548     assert p == ([u'hello', u'\n'], {}), p
549     p = parse_command(r'hello \nmundo')
550     assert p == ([u'hello', u'\nmundo'], {}), p
551     p = parse_command(r'test \N')
552     assert p == ([u'test', None], {}), p
553     p = parse_command(r'\N')
554     assert p == ([None], {}), p
555     p = parse_command(r'none=\N')
556     assert p == ([], {'none': None}), p
557     p = parse_command(r'\N=none')
558     assert p == ([], {'\\N': 'none'}), p
559     p = parse_command(r'Not\N')
560     assert p == ([u'Not\\N'], {}), p
561     p = parse_command(r'\None')
562     assert p == ([u'\\None'], {}), p
563     try:
564         p = parse_command('hello=')
565     except ParseError, e:
566         pass
567     else:
568         assert False, p + ' should raised a ParseError'
569     try:
570         p = parse_command('"hello')
571     except ParseError, e:
572         pass
573     else:
574         assert False, p + ' should raised a ParseError'
575