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