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