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