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