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