Flesh out command line help messages.
[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 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(str(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(str(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             self.cmd.stdout.write(unicode(msg).rstrip()+'\n')
178
179     def _parse_args(self, args):
180         options,args = self.parser.parse_args(args)
181         self._check_argument_length_bounds(args)
182         params = {}
183         for argument in self.parser.command_opts:
184             value = getattr(options, self.name_fn(argument.name))
185             if value != Default:
186                 params[argument.name] = value
187         arg_index = 0
188         for argument in self.parser.command_args:
189             if argument.count == 1:
190                 params[argument.name] = from_string(args[arg_index],
191                                                     argument.type)
192             elif argument.count > 1:
193                 params[argument.name] = [
194                     from_string(a, argument.type)
195                     for a in args[arg_index:arg_index+argument.count]]
196             else: # argument.count == -1:
197                 params[argument.name] = [
198                     from_string(a, argument.type) for a in args[arg_index:]]
199             arg_index += argument.count
200         return params
201
202     def _check_argument_length_bounds(self, arguments):
203         """Check that there are an appropriate number of arguments in
204         `args`.
205
206         If not, raise optparse.OptParseError().
207         """
208         min_args = 0
209         max_args = 0
210         for argument in self.parser.command_args:
211             if argument.optional == False and argument.count > 0:
212                 min_args += argument.count
213             if max_args >= 0: # otherwise already infinite
214                 if argument.count == -1:
215                     max_args = -1
216                 else:
217                     max_args += argument.count
218         if len(arguments) < min_args \
219                 or (max_args >= 0 and len(arguments) > max_args):
220             if min_args == max_args:
221                 target_string = str(min_args)
222             elif max_args == -1:
223                 target_string = 'more than %d' % min_args
224             else:
225                 target_string = '%d to %d' % (min_args, max_args)
226             raise optparse.OptParseError(
227                 '%d arguments given, but %s takes %s'
228                 % (len(arguments), self.name_fn(self.command.name),
229                    target_string))
230
231     def _handle_request(self, msg):
232         """Repeatedly try to get a response to `msg`.
233         """
234         prompt = getattr(self, '_%s_request_prompt' % msg.type, None)
235         if prompt == None:
236             raise NotImplementedError('_%s_request_prompt' % msg.type)
237         prompt_string = prompt(msg)
238         parser = getattr(self, '_%s_request_parser' % msg.type, None)
239         if parser == None:
240             raise NotImplementedError('_%s_request_parser' % msg.type)
241         error = None
242         while True:
243             if error != None:
244                 self.cmd.stdout.write(''.join([
245                         error.__class__.__name__, ': ', str(error), '\n']))
246             self.cmd.stdout.write(prompt_string)
247             stdin = sys.stdin
248             try:
249                 sys.stdin = self.cmd.stdin
250                 raw_response = raw_input()
251             except EOFError, e:
252                 self.cmd.inqueue.put(EOFResponse())
253                 self.cmd.inqueue.put(CloseEngine())
254                 raise EOF
255             finally:
256                 sys.stdin = stdin
257             value = parser(msg, raw_response)
258             try:
259                 response = msg.response(value)
260                 break
261             except ValueError, error:
262                 continue
263         self.cmd.inqueue.put(response)
264
265     def _boolean_request_prompt(self, msg):
266         if msg.default == True:
267             yn = ' [Y/n] '
268         else:
269             yn = ' [y/N] '
270         return msg.msg + yn
271
272     def _boolean_request_parser(self, msg, response):
273         value = response.strip().lower()
274         if value.startswith('y'):
275             value = True
276         elif value.startswith('n'):
277             value = False
278         elif len(value) == 0:
279             value = msg.default
280         return value
281
282     def _string_request_prompt(self, msg):
283         if msg.default == None:
284             d = ' '
285         else:
286             d = ' [%s] ' % msg.default
287         return msg.msg + d
288
289     def _string_request_parser(self, msg, response):
290         response = response.strip()
291         if response == '':
292             return msg.default
293         return response.strip()
294
295     def _float_request_prompt(self, msg):
296         return self._string_request_prompt(msg)
297
298     def _float_request_parser(self, msg, resposne):
299         if response.strip() == '':
300             return msg.default
301         return float(response)
302
303     def _selection_request_prompt(self, msg):
304         options = []
305         for i,option in enumerate(msg.options):
306             options.append('   %d) %s' % (i,option))
307         options = ''.join(options)
308         if msg.default == None:
309             prompt = '? '
310         else:
311             prompt = '? [%d] ' % msg.default
312         return '\n'.join([msg.msg,options,prompt])
313     
314     def _selection_request_parser(self, msg, response):
315         if response.strip() == '':
316             return msg.default
317         return int(response)
318
319     def _point_request_prompt(self, msg):
320         block = msg.curve.data[msg.block]
321         block_info = ('(curve: %s, block: %s, %d points)'
322                       % (msg.curve.name,
323                          block.info['name'],
324                          block.shape[0]))
325
326         if msg.default == None:
327             prompt = '? '
328         else:
329             prompt = '? [%d] ' % msg.default
330         return ' '.join([msg.msg,block_info,prompt])
331     
332     def _point_request_parser(self, msg, response):
333         if response.strip() == '':
334             return msg.default
335         return int(response)
336
337
338 class HelpCommand (CommandMethod):
339     """Supersedes :class:`hooke.plugin.engine.HelpCommand`.
340     """
341     def __init__(self, *args, **kwargs):
342         super(HelpCommand, self).__init__(*args, **kwargs)
343         self.parser = CommandLineParser(self.command, self.name_fn)
344
345     def __call__(self):
346         blocks = [self.parser.format_help(),
347                   self._command_message(),
348                   '----',
349                   'Usage: ' + self._usage_string(),
350                   '']
351         self.cmd.stdout.write('\n'.join(blocks))
352
353     def _command_message(self):
354         return self.command._help
355
356     def _usage_string(self):
357         if len(self.parser.command_opts) == 0:
358             options_string = ''
359         else:
360             options_string = '[options]'
361         arg_string = ' '.join(
362             [self.name_fn(arg.name) for arg in self.parser.command_args])
363         return ' '.join([x for x in [self.parser.prog,
364                                      options_string,
365                                      arg_string]
366                          if x != ''])
367
368 class CompleteCommand (CommandMethod):
369     def __call__(self, text, line, begidx, endidx):
370         pass
371
372
373
374 # Now onto the main attraction.
375
376 class HookeCmd (cmd.Cmd):
377     def __init__(self, ui, commands, inqueue, outqueue):
378         cmd.Cmd.__init__(self)
379         self.ui = ui
380         self.commands = commands
381         self.prompt = 'hooke> '
382         self._add_command_methods()
383         self.inqueue = inqueue
384         self.outqueue = outqueue
385
386     def _name_fn(self, name):
387         return name.replace(' ', '_')
388
389     def _add_command_methods(self):
390         for command in self.commands:
391             if command.name == 'exit':
392                 command.aliases.extend(['quit', 'EOF'])
393             for name in [command.name] + command.aliases:
394                 name = self._name_fn(name)
395                 setattr(self.__class__, 'help_%s' % name,
396                         HelpCommand(self, command, self._name_fn))
397                 if name != 'help':
398                     setattr(self.__class__, 'do_%s' % name,
399                             DoCommand(self, command, self._name_fn))
400                     setattr(self.__class__, 'complete_%s' % name,
401                             CompleteCommand(self, command, self._name_fn))
402
403     def parseline(self, line):
404         """Override Cmd.parseline to use shlex.split.
405
406         Notes
407         -----
408         This allows us to handle comments cleanly.  With the default
409         Cmd implementation, a pure comment line will call the .default
410         error message.
411
412         Since we use shlex to strip comments, we return a list of
413         split arguments rather than the raw argument string.
414         """
415         line = line.strip()
416         argv = shlex.split(line, comments=True, posix=True)
417         if len(argv) == 0:
418             return None, None, '' # return an empty line
419         cmd = argv[0]
420         args = argv[1:]
421         if cmd == '?':
422             cmd = 'help'
423         elif cmd == '!':
424             cmd = 'system'
425         return cmd, args, line
426
427     def do_help(self, arg):
428         """Wrap Cmd.do_help to handle our .parseline argument list.
429         """
430         if len(arg) == 0:
431             return cmd.Cmd.do_help(self, '')
432         return cmd.Cmd.do_help(self, arg[0])
433
434     def emptyline(self):
435         """Override Cmd.emptyline to not do anything.
436
437         Repeating the last non-empty command seems unwise.  Explicit
438         is better than implicit.
439         """
440         pass
441
442
443 class CommandLine (UserInterface):
444     """Command line interface.  Simple and powerful.
445     """
446     def __init__(self):
447         super(CommandLine, self).__init__(name='command line')
448
449     def _cmd(self, commands, ui_to_command_queue, command_to_ui_queue):
450         cmd = HookeCmd(self, commands,
451                        inqueue=ui_to_command_queue,
452                        outqueue=command_to_ui_queue)
453         #cmd.stdin = codecs.getreader(get_input_encoding())(cmd.stdin)
454         cmd.stdout = codecs.getwriter(get_output_encoding())(cmd.stdout)
455         return cmd
456
457     def run(self, commands, ui_to_command_queue, command_to_ui_queue):
458         cmd = self._cmd(commands, ui_to_command_queue, command_to_ui_queue)
459         cmd.cmdloop(self._splash_text(extra_info={
460                     'get-details':'run `license`',
461                     }))
462
463     def run_lines(self, commands, ui_to_command_queue, command_to_ui_queue,
464                   lines):
465         cmd = self._cmd(commands, ui_to_command_queue, command_to_ui_queue)
466         for line in lines:
467             cmd.onecmd(line)