5f04c366623a0db03689ab29284b19bef6a664a3
[hooke.git] / hooke / ui / commandline.py
1 # Copyright (C) 2010 W. Trevor King <wking@drexel.edu>
2 #
3 # This file is part of Hooke.
4 #
5 # Hooke is free software: you can redistribute it and/or modify it
6 # under the terms of the GNU Lesser General Public License as
7 # published by the Free Software Foundation, either version 3 of the
8 # License, or (at your option) any later version.
9 #
10 # Hooke is distributed in the hope that it will be useful, but WITHOUT
11 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
12 # or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General
13 # Public License for more details.
14 #
15 # You should have received a copy of the GNU Lesser General Public
16 # License along with Hooke.  If not, see
17 # <http://www.gnu.org/licenses/>.
18
19 """Defines :class:`CommandLine` for driving Hooke from the command
20 line.
21 """
22
23 import codecs
24 import cmd
25 import logging
26 import optparse
27 try:
28     import readline # including readline makes cmd.Cmd.cmdloop() smarter
29 except ImportError, e:
30     import logging
31     logging.warn('Could not import readline, bash-like line editing disabled.')
32 import shlex
33
34 from ..command import CommandExit, Exit, Command, Argument, StoreValue
35 from ..engine import CommandMessage
36 from ..interaction import Request, ReloadUserInterfaceConfig
37 from ..ui import UserInterface
38 from ..util.convert import from_string
39 from ..util.encoding import get_input_encoding, get_output_encoding
40
41
42 # Define a few helper classes.
43
44 class Default (object):
45     """Marker for options not given on the command line.
46     """
47     pass
48
49 class CommandLineParser (optparse.OptionParser):
50     """Implement a command line syntax for a
51     :class:`hooke.command.Command`.
52     """
53     def __init__(self, command, name_fn):
54         optparse.OptionParser.__init__(self, prog=name_fn(command.name))
55         self.command = command
56         self.command_opts = []
57         self.command_args = []
58         for a in command.arguments:
59             if a.name == 'help':
60                 continue # 'help' is a default OptionParser option
61             if a.optional == True:
62                 name = name_fn(a.name)
63                 type = a.type
64                 if type == 'bool':
65                     if a.default == True:
66                         self.add_option(
67                             '--disable-%s' % name, dest=name, default=Default,
68                             action='store_false')
69                         self.command_opts.append(a)
70                         continue
71                     elif a.default == False:
72                         self.add_option(
73                             '--enable-%s' % name, dest=name, default=Default,
74                             action='store_true')
75                         self.command_opts.append(a)
76                         continue
77                     else:
78                         type = 'string'
79                 elif type not in ['string', 'int', 'long', 'choice', 'float',
80                                   'complex']:
81                     type = 'string'
82                 self.add_option(
83                     '--%s' % name, dest=name, type=type, default=Default)
84                 self.command_opts.append(a)
85             else:
86                 self.command_args.append(a)
87         infinite_counters = [a for a in self.command_args if a.count == -1]
88         assert len(infinite_counters) <= 1, \
89             'Multiple infinite counts for %s: %s\nNeed a better CommandLineParser implementation.' \
90             % (command.name, ', '.join([a.name for a in infinite_counters]))
91         if len(infinite_counters) == 1: # move the big counter to the end.
92             infinite_counter = infinite_counters[0]
93             self.command_args.remove(infinite_counter)
94             self.command_args.append(infinite_counter)
95
96     def exit(self, status=0, msg=None):
97         """Override :meth:`optparse.OptionParser.exit` which calls
98         :func:`sys.exit`.
99         """
100         if msg:
101             raise optparse.OptParseError(msg)
102         raise optparse.OptParseError('OptParse EXIT')
103
104 class CommandMethod (object):
105     """Base class for method replacer.
106
107     The .__call__ methods of `CommandMethod` subclasses functions will
108     provide the `do_*`, `help_*`, and `complete_*` methods of
109     :class:`HookeCmd`.
110     """
111     def __init__(self, cmd, command, name_fn):
112         self.cmd = cmd
113         self.command = command
114         self.name_fn = name_fn
115
116     def __call__(self, *args, **kwargs):
117         raise NotImplementedError
118
119 class DoCommand (CommandMethod):
120     def __init__(self, *args, **kwargs):
121         super(DoCommand, self).__init__(*args, **kwargs)
122         self.parser = CommandLineParser(self.command, self.name_fn)
123         self.log = logging.getLogger('hooke')
124
125     def __call__(self, args):
126         try:
127             args = self._parse_args(args)
128         except optparse.OptParseError, e:
129             self.cmd.stdout.write(str(e).lstrip()+'\n')
130             self.cmd.stdout.write('Failure\n')
131             return
132         self.log.debug('executing %s with %s' % (self.command.name, args))
133         self.cmd.inqueue.put(CommandMessage(self.command.name, args))
134         while True:
135             msg = self.cmd.outqueue.get()
136             if isinstance(msg, Exit):
137                 return True
138             elif isinstance(msg, CommandExit):
139                 self.cmd.stdout.write(msg.__class__.__name__+'\n')
140                 self.cmd.stdout.write(str(msg).rstrip()+'\n')
141                 break
142             elif isinstance(msg, ReloadUserInterfaceConfig):
143                 self.cmd.ui.reload_config(msg.config)
144                 continue
145             elif isinstance(msg, Request):
146                 self._handle_request(msg)
147                 continue
148             self.cmd.stdout.write(str(msg).rstrip()+'\n')
149
150     def _parse_args(self, args):
151         options,args = self.parser.parse_args(args)
152         self._check_argument_length_bounds(args)
153         params = {}
154         for argument in self.parser.command_opts:
155             value = getattr(options, self.name_fn(argument.name))
156             if value != Default:
157                 params[argument.name] = value
158         arg_index = 0
159         for argument in self.parser.command_args:
160             if argument.count == 1:
161                 params[argument.name] = from_string(args[arg_index],
162                                                     argument.type)
163             elif argument.count > 1:
164                 params[argument.name] = [
165                     from_string(a, argument.type)
166                     for a in args[arg_index:arg_index+argument.count]]
167             else: # argument.count == -1:
168                 params[argument.name] = [
169                     from_string(a, argument.type) for a in args[arg_index:]]
170             arg_index += argument.count
171         return params
172
173     def _check_argument_length_bounds(self, arguments):
174         """Check that there are an appropriate number of arguments in
175         `args`.
176
177         If not, raise optparse.OptParseError().
178         """
179         min_args = 0
180         max_args = 0
181         for argument in self.parser.command_args:
182             if argument.optional == False and argument.count > 0:
183                 min_args += argument.count
184             if max_args >= 0: # otherwise already infinite
185                 if argument.count == -1:
186                     max_args = -1
187                 else:
188                     max_args += argument.count
189         if len(arguments) < min_args \
190                 or (max_args >= 0 and len(arguments) > max_args):
191             if min_args == max_args:
192                 target_string = str(min_args)
193             elif max_args == -1:
194                 target_string = 'more than %d' % min_args
195             else:
196                 target_string = '%d to %d' % (min_args, max_args)
197             raise optparse.OptParseError(
198                 '%d arguments given, but %s takes %s'
199                 % (len(arguments), self.name_fn(self.command.name),
200                    target_string))
201
202     def _handle_request(self, msg):
203         """Repeatedly try to get a response to `msg`.
204         """
205         prompt = getattr(self, '_%s_request_prompt' % msg.type, None)
206         if prompt == None:
207             raise NotImplementedError('_%s_request_prompt' % msg.type)
208         prompt_string = prompt(msg)
209         parser = getattr(self, '_%s_request_parser' % msg.type, None)
210         if parser == None:
211             raise NotImplementedError('_%s_request_parser' % msg.type)
212         error = None
213         while True:
214             if error != None:
215                 self.cmd.stdout.write(''.join([
216                         error.__class__.__name__, ': ', str(error), '\n']))
217             self.cmd.stdout.write(prompt_string)
218             value = parser(msg, self.cmd.stdin.readline())
219             try:
220                 response = msg.response(value)
221                 break
222             except ValueError, error:
223                 continue
224         self.cmd.inqueue.put(response)
225
226     def _boolean_request_prompt(self, msg):
227         if msg.default == True:
228             yn = ' [Y/n] '
229         else:
230             yn = ' [y/N] '
231         return msg.msg + yn
232
233     def _boolean_request_parser(self, msg, response):
234         value = response.strip().lower()
235         if value.startswith('y'):
236             value = True
237         elif value.startswith('n'):
238             value = False
239         elif len(value) == 0:
240             value = msg.default
241         return value
242
243     def _string_request_prompt(self, msg):
244         if msg.default == None:
245             d = ' '
246         else:
247             d = ' [%s] ' % msg.default
248         return msg.msg + d
249
250     def _string_request_parser(self, msg, response):
251         response = response.strip()
252         if response == '':
253             return msg.default
254         return response.strip()
255
256     def _float_request_prompt(self, msg):
257         return self._string_request_prompt(msg)
258
259     def _float_request_parser(self, msg, resposne):
260         if response.strip() == '':
261             return msg.default
262         return float(response)
263
264     def _selection_request_prompt(self, msg):
265         options = []
266         for i,option in enumerate(msg.options):
267             options.append('   %d) %s' % (i,option))
268         options = ''.join(options)
269         if msg.default == None:
270             prompt = '? '
271         else:
272             prompt = '? [%d] ' % msg.default
273         return '\n'.join([msg.msg,options,prompt])
274     
275     def _selection_request_parser(self, msg, response):
276         if response.strip() == '':
277             return msg.default
278         return int(response)
279
280     def _point_request_prompt(self, msg):
281         block = msg.curve.data[msg.block]
282         block_info = ('(curve: %s, block: %s, %d points)'
283                       % (msg.curve.name,
284                          block.info['name'],
285                          block.shape[0]))
286
287         if msg.default == None:
288             prompt = '? '
289         else:
290             prompt = '? [%d] ' % msg.default
291         return ' '.join([msg.msg,block_info,prompt])
292     
293     def _point_request_parser(self, msg, response):
294         if response.strip() == '':
295             return msg.default
296         return int(response)
297
298
299 class HelpCommand (CommandMethod):
300     """Supersedes :class:`hooke.plugin.engine.HelpCommand`.
301     """
302     def __init__(self, *args, **kwargs):
303         super(HelpCommand, self).__init__(*args, **kwargs)
304         self.parser = CommandLineParser(self.command, self.name_fn)
305
306     def __call__(self):
307         blocks = [self.command.help(name_fn=self.name_fn),
308                   '----',
309                   'Usage: ' + self._usage_string(),
310                   '']
311         self.cmd.stdout.write('\n'.join(blocks))
312
313     def _message(self):
314         return self.command.help(name_fn=self.name_fn)
315
316     def _usage_string(self):
317         if len(self.parser.command_opts) == 0:
318             options_string = ''
319         else:
320             options_string = '[options]'
321         arg_string = ' '.join(
322             [self.name_fn(arg.name) for arg in self.parser.command_args])
323         return ' '.join([x for x in [self.parser.prog,
324                                      options_string,
325                                      arg_string]
326                          if x != ''])
327
328 class CompleteCommand (CommandMethod):
329     def __call__(self, text, line, begidx, endidx):
330         pass
331
332
333
334 # Now onto the main attraction.
335
336 class HookeCmd (cmd.Cmd):
337     def __init__(self, ui, commands, inqueue, outqueue):
338         cmd.Cmd.__init__(self)
339         self.ui = ui
340         self.commands = commands
341         self.prompt = 'hooke> '
342         self._add_command_methods()
343         self.inqueue = inqueue
344         self.outqueue = outqueue
345
346     def _name_fn(self, name):
347         return name.replace(' ', '_')
348
349     def _add_command_methods(self):
350         for command in self.commands:
351             if command.name == 'exit':
352                 command.aliases.extend(['quit', 'EOF'])
353             for name in [command.name] + command.aliases:
354                 name = self._name_fn(name)
355                 setattr(self.__class__, 'help_%s' % name,
356                         HelpCommand(self, command, self._name_fn))
357                 if name != 'help':
358                     setattr(self.__class__, 'do_%s' % name,
359                             DoCommand(self, command, self._name_fn))
360                     setattr(self.__class__, 'complete_%s' % name,
361                             CompleteCommand(self, command, self._name_fn))
362
363     def parseline(self, line):
364         """Override Cmd.parseline to use shlex.split.
365
366         Notes
367         -----
368         This allows us to handle comments cleanly.  With the default
369         Cmd implementation, a pure comment line will call the .default
370         error message.
371
372         Since we use shlex to strip comments, we return a list of
373         split arguments rather than the raw argument string.
374         """
375         line = line.strip()
376         argv = shlex.split(line, comments=True, posix=True)
377         if len(argv) == 0:
378             return None, None, '' # return an empty line
379         cmd = argv[0]
380         args = argv[1:]
381         if cmd == '?':
382             cmd = 'help'
383         elif cmd == '!':
384             cmd = 'system'
385         return cmd, args, line
386
387     def do_help(self, arg):
388         """Wrap Cmd.do_help to handle our .parseline argument list.
389         """
390         if len(arg) == 0:
391             return cmd.Cmd.do_help(self, '')
392         return cmd.Cmd.do_help(self, arg[0])
393
394     def emptyline(self):
395         """Override Cmd.emptyline to not do anything.
396
397         Repeating the last non-empty command seems unwise.  Explicit
398         is better than implicit.
399         """
400         pass
401
402
403 class CommandLine (UserInterface):
404     """Command line interface.  Simple and powerful.
405     """
406     def __init__(self):
407         super(CommandLine, self).__init__(name='command line')
408
409     def _cmd(self, commands, ui_to_command_queue, command_to_ui_queue):
410         cmd = HookeCmd(self, commands,
411                        inqueue=ui_to_command_queue,
412                        outqueue=command_to_ui_queue)
413         #cmd.stdin = codecs.getreader(get_input_encoding())(cmd.stdin)
414         cmd.stdout = codecs.getwriter(get_output_encoding())(cmd.stdout)
415         return cmd
416
417     def run(self, commands, ui_to_command_queue, command_to_ui_queue):
418         cmd = self._cmd(commands, ui_to_command_queue, command_to_ui_queue)
419         cmd.cmdloop(self._splash_text(extra_info={
420                     'get-details':'run `license`',
421                     }))
422
423     def run_lines(self, commands, ui_to_command_queue, command_to_ui_queue,
424                   lines):
425         cmd = self._cmd(commands, ui_to_command_queue, command_to_ui_queue)
426         for line in lines:
427             cmd.onecmd(line)