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