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