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