Broke type conversion out into hooke.util.convert and expanded its use.
[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 optparse
26 import readline # including readline makes cmd.Cmd.cmdloop() smarter
27 import shlex
28
29 from ..command import CommandExit, Exit, Command, Argument, StoreValue
30 from ..interaction import Request, BooleanRequest, ReloadUserInterfaceConfig
31 from ..ui import UserInterface, CommandMessage
32 from ..util.convert import from_string
33 from ..util.encoding import get_input_encoding, get_output_encoding
34
35
36 # Define a few helper classes.
37
38 class Default (object):
39     """Marker for options not given on the command line.
40     """
41     pass
42
43 class CommandLineParser (optparse.OptionParser):
44     """Implement a command line syntax for a
45     :class:`hooke.command.Command`.
46     """
47     def __init__(self, command, name_fn):
48         optparse.OptionParser.__init__(self, prog=name_fn(command.name))
49         self.command = command
50         self.command_opts = []
51         self.command_args = []
52         for a in command.arguments:
53             if a.name == 'help':
54                 continue # 'help' is a default OptionParser option
55             if a.optional == True:
56                 name = name_fn(a.name)
57                 type = a.type
58                 if type == 'bool':
59                     if a.default == True:
60                         self.add_option(
61                             '--disable-%s' % name, dest=name, default=Default,
62                             action='store_false')
63                         self.command_opts.append(a)
64                         continue
65                     elif a.default == False:
66                         self.add_option(
67                             '--enable-%s' % name, dest=name, default=Default,
68                             action='store_true')
69                         self.command_opts.append(a)
70                         continue
71                     else:
72                         type = 'string'
73                 elif type not in ['string', 'int', 'long', 'choice', 'float',
74                                   'complex']:
75                     type = 'string'
76                 self.add_option(
77                     '--%s' % name, dest=name, type=type, default=Default)
78                 self.command_opts.append(a)
79             else:
80                 self.command_args.append(a)
81         infinite_counters = [a for a in self.command_args if a.count == -1]
82         assert len(infinite_counters) <= 1, \
83             'Multiple infinite counts for %s: %s\nNeed a better CommandLineParser implementation.' \
84             % (command.name, ', '.join([a.name for a in infinite_counters]))
85         if len(infinite_counters) == 1: # move the big counter to the end.
86             infinite_counter = infinite_counters[0]
87             self.command_args.remove(infinite_counter)
88             self.command_args.append(infinite_counter)
89
90     def exit(self, status=0, msg=None):
91         """Override :meth:`optparse.OptionParser.exit` which calls
92         :func:`sys.exit`.
93         """
94         if msg:
95             raise optparse.OptParseError(msg)
96         raise optparse.OptParseError('OptParse EXIT')
97
98 class CommandMethod (object):
99     """Base class for method replacer.
100
101     The .__call__ methods of `CommandMethod` subclasses functions will
102     provide the `do_*`, `help_*`, and `complete_*` methods of
103     :class:`HookeCmd`.
104     """
105     def __init__(self, cmd, command, name_fn):
106         self.cmd = cmd
107         self.command = command
108         self.name_fn = name_fn
109
110     def __call__(self, *args, **kwargs):
111         raise NotImplementedError
112
113 class DoCommand (CommandMethod):
114     def __init__(self, *args, **kwargs):
115         super(DoCommand, self).__init__(*args, **kwargs)
116         self.parser = CommandLineParser(self.command, self.name_fn)
117
118     def __call__(self, args):
119         try:
120             args = self._parse_args(args)
121         except optparse.OptParseError, e:
122             self.cmd.stdout.write(str(e).lstrip()+'\n')
123             self.cmd.stdout.write('Failure\n')
124             return
125         print args
126         self.cmd.inqueue.put(CommandMessage(self.command, args))
127         while True:
128             msg = self.cmd.outqueue.get()
129             if isinstance(msg, Exit):
130                 return True
131             elif isinstance(msg, CommandExit):
132                 self.cmd.stdout.write(msg.__class__.__name__+'\n')
133                 self.cmd.stdout.write(str(msg).rstrip()+'\n')
134                 break
135             elif isinstance(msg, ReloadUserInterfaceConfig):
136                 self.cmd.ui.reload_config(msg.config)
137                 continue
138             elif isinstance(msg, Request):
139                 self._handle_request(msg)
140                 continue
141             self.cmd.stdout.write(str(msg).rstrip()+'\n')
142
143     def _parse_args(self, args):
144         options,args = self.parser.parse_args(args)
145         self._check_argument_length_bounds(args)
146         params = {}
147         for argument in self.parser.command_opts:
148             value = getattr(options, self.name_fn(argument.name))
149             if value != Default:
150                 params[argument.name] = value
151         arg_index = 0
152         for argument in self.parser.command_args:
153             if argument.count == 1:
154                 params[argument.name] = from_string(args[arg_index],
155                                                     argument.type)
156             elif argument.count > 1:
157                 params[argument.name] = [
158                     from_string(a, argument.type)
159                     for a in args[arg_index:arg_index+argument.count]]
160             else: # argument.count == -1:
161                 params[argument.name] = [
162                     from_string(a, argument.type) for a in args[arg_index:]]
163             arg_index += argument.count
164         return params
165
166     def _check_argument_length_bounds(self, arguments):
167         """Check that there are an appropriate number of arguments in
168         `args`.
169
170         If not, raise optparse.OptParseError().
171         """
172         min_args = 0
173         max_args = 0
174         for argument in self.parser.command_args:
175             if argument.optional == False and argument.count > 0:
176                 min_args += argument.count
177             if max_args >= 0: # otherwise already infinite
178                 if argument.count == -1:
179                     max_args = -1
180                 else:
181                     max_args += argument.count
182         if len(arguments) < min_args \
183                 or (max_args >= 0 and len(arguments) > max_args):
184             if min_args == max_args:
185                 target_string = str(min_args)
186             elif max_args == -1:
187                 target_string = 'more than %d' % min_args
188             else:
189                 target_string = '%d to %d' % (min_args, max_args)
190             raise optparse.OptParseError(
191                 '%d arguments given, but %s takes %s'
192                 % (len(arguments), self.name_fn(self.command.name),
193                    target_string))
194
195     def _handle_request(self, msg):
196         """Repeatedly try to get a response to `msg`.
197         """
198         prompt = getattr(self, '_%s_request_prompt' % msg.type, None)
199         if prompt == None:
200             raise NotImplementedError('_%s_request_prompt' % msg.type)
201         prompt_string = prompt(msg)
202         parser = getattr(self, '_%s_request_parser' % msg.type, None)
203         if parser == None:
204             raise NotImplementedError('_%s_request_parser' % msg.type)
205         error = None
206         while True:
207             if error != None:
208                 self.cmd.stdout.write(''.join([
209                         error.__class__.__name__, ': ', str(error), '\n']))
210             self.cmd.stdout.write(prompt_string)
211             value = parser(msg, self.cmd.stdin.readline())
212             try:
213                 response = msg.response(value)
214                 break
215             except ValueError, error:
216                 continue
217         self.cmd.inqueue.put(response)
218
219     def _boolean_request_prompt(self, msg):
220         if msg.default == True:
221             yn = ' [Y/n] '
222         else:
223             yn = ' [y/N] '
224         return msg.msg + yn
225
226     def _boolean_request_parser(self, msg, response):
227         value = response.strip().lower()
228         if value.startswith('y'):
229             value = True
230         elif value.startswith('n'):
231             value = False
232         elif len(value) == 0:
233             value = msg.default
234         return value
235
236     def _string_request_prompt(self, msg):
237         if msg.default == None:
238             d = ' '
239         else:
240             d = ' [%s] ' % msg.default
241         return msg.msg + d
242
243     def _string_request_parser(self, msg, response):
244         response = response.strip()
245         if response == '':
246             return msg.default
247         return response.strip()
248
249     def _float_request_prompt(self, msg):
250         return self._string_request_prompt(msg)
251
252     def _float_request_parser(self, msg, resposne):
253         if response.strip() == '':
254             return msg.default
255         return float(response)
256
257     def _selection_request_prompt(self, msg):
258         options = []
259         for i,option in enumerate(msg.options):
260             options.append('   %d) %s' % (i,option))
261         options = ''.join(options)
262         if msg.default == None:
263             prompt = '? '
264         else:
265             prompt = '? [%d] ' % msg.default
266         return '\n'.join([msg.msg,options,prompt])
267     
268     def _selection_request_parser(self, msg, response):
269         if response.strip() == '':
270             return msg.default
271         return int(response)
272
273     def _point_request_prompt(self, msg):
274         block = msg.curve.data[msg.block]
275         block_info = ('(curve: %s, block: %s, %d points)'
276                       % (msg.curve.name,
277                          block.info['name'],
278                          block.shape[0]))
279
280         if msg.default == None:
281             prompt = '? '
282         else:
283             prompt = '? [%d] ' % msg.default
284         return ' '.join([msg.msg,block_info,prompt])
285     
286     def _point_request_parser(self, msg, response):
287         if response.strip() == '':
288             return msg.default
289         return int(response)
290
291
292 class HelpCommand (CommandMethod):
293     def __init__(self, *args, **kwargs):
294         super(HelpCommand, self).__init__(*args, **kwargs)
295         self.parser = CommandLineParser(self.command, self.name_fn)
296
297     def __call__(self):
298         blocks = [self.command.help(name_fn=self.name_fn),
299                   '----',
300                   'Usage: ' + self._usage_string(),
301                   '']
302         self.cmd.stdout.write('\n'.join(blocks))
303
304     def _message(self):
305         return self.command.help(name_fn=self.name_fn)
306
307     def _usage_string(self):
308         if len(self.parser.command_opts) == 0:
309             options_string = ''
310         else:
311             options_string = '[options]'
312         arg_string = ' '.join(
313             [self.name_fn(arg.name) for arg in self.parser.command_args])
314         return ' '.join([x for x in [self.parser.prog,
315                                      options_string,
316                                      arg_string]
317                          if x != ''])
318
319 class CompleteCommand (CommandMethod):
320     def __call__(self, text, line, begidx, endidx):
321         pass
322
323
324 # Define some additional commands
325
326 class LocalHelpCommand (Command):
327     """Called with an argument, prints that command's documentation.
328
329     With no argument, lists all available help topics as well as any
330     undocumented commands.
331     """
332     def __init__(self):
333         super(LocalHelpCommand, self).__init__(name='help', help=self.__doc__)
334         # We set .arguments now (vs. using th arguments option to __init__),
335         # to overwrite the default help argument.  We don't override
336         # :meth:`cmd.Cmd.do_help`, so `help --help` is not a valid command.
337         self.arguments = [
338             Argument(name='command', type='string', optional=True,
339                      help='The name of the command you want help with.')
340             ]
341
342     def _run(self, hooke, inqueue, outqueue, params):
343         raise NotImplementedError # cmd.Cmd already implements .do_help()
344
345 class LocalExitCommand (Command):
346     """Exit Hooke cleanly.
347     """
348     def __init__(self):
349         super(LocalExitCommand, self).__init__(
350             name='exit', aliases=['quit', 'EOF'], help=self.__doc__,
351             arguments = [
352                 Argument(name='force', type='bool', default=False,
353                          help="""
354 Exit without prompting the user.  Use if you save often or don't make
355 typing mistakes ;).
356 """.strip()),
357                 ])
358
359     def _run(self, hooke, inqueue, outqueue, params):
360         """The guts of the `do_exit/_quit/_EOF` commands.
361
362         A `True` return stops :meth:`.cmdloop` execution.
363         """
364         _exit = True
365         if params['force'] == False:
366             not_saved = [p.name for p in hooke.playlists
367                          if p.is_saved() == False]
368             msg = 'Exit?'
369             default = True
370             if len(not_saved) > 0:
371                 msg = 'Unsaved playlists (%s).  %s' \
372                     % (', '.join([str(p) for p in not_saved]), msg)
373                 default = False
374             outqueue.put(BooleanRequest(msg, default))
375             result = inqueue.get()
376             assert result.type == 'boolean'
377             _exit = result.value
378         if _exit == True:
379             raise Exit()
380
381
382 # Now onto the main attraction.
383
384 class HookeCmd (cmd.Cmd):
385     def __init__(self, ui, commands, inqueue, outqueue):
386         cmd.Cmd.__init__(self)
387         self.ui = ui
388         self.commands = commands
389         self.local_commands = [LocalExitCommand(), LocalHelpCommand()]
390         self.prompt = 'hooke> '
391         self._add_command_methods()
392         self.inqueue = inqueue
393         self.outqueue = outqueue
394
395     def _name_fn(self, name):
396         return name.replace(' ', '_')
397
398     def _add_command_methods(self):
399         for command in self.commands + self.local_commands:
400             for name in [command.name] + command.aliases:
401                 name = self._name_fn(name)
402                 setattr(self.__class__, 'help_%s' % name,
403                         HelpCommand(self, command, self._name_fn))
404                 if name != 'help':
405                     setattr(self.__class__, 'do_%s' % name,
406                             DoCommand(self, command, self._name_fn))
407                     setattr(self.__class__, 'complete_%s' % name,
408                             CompleteCommand(self, command, self._name_fn))
409
410     def parseline(self, line):
411         """Override Cmd.parseline to use shlex.split.
412
413         Notes
414         -----
415         This allows us to handle comments cleanly.  With the default
416         Cmd implementation, a pure comment line will call the .default
417         error message.
418
419         Since we use shlex to strip comments, we return a list of
420         split arguments rather than the raw argument string.
421         """
422         line = line.strip()
423         argv = shlex.split(line, comments=True, posix=True)
424         if len(argv) == 0:
425             return None, None, '' # return an empty line
426         elif argv[0] == '?':
427             argv[0] = 'help'
428         elif argv[0] == '!':
429             argv[0] = 'system'
430         return argv[0], argv[1:], line
431
432     def do_help(self, arg):
433         """Wrap Cmd.do_help to handle our .parseline argument list.
434         """
435         if len(arg) == 0:
436             return cmd.Cmd.do_help(self, '')
437         return cmd.Cmd.do_help(self, arg[0])
438
439     def empytline(self):
440         """Override Cmd.emptyline to not do anything.
441
442         Repeating the last non-empty command seems unwise.  Explicit
443         is better than implicit.
444         """
445         pass
446
447 class CommandLine (UserInterface):
448     """Command line interface.  Simple and powerful.
449     """
450     def __init__(self):
451         super(CommandLine, self).__init__(name='command line')
452
453     def _cmd(self, commands, ui_to_command_queue, command_to_ui_queue):
454         cmd = HookeCmd(self, commands,
455                        inqueue=ui_to_command_queue,
456                        outqueue=command_to_ui_queue)
457         #cmd.stdin = codecs.getreader(get_input_encoding())(cmd.stdin)
458         cmd.stdout = codecs.getwriter(get_output_encoding())(cmd.stdout)
459         return cmd
460
461     def run(self, commands, ui_to_command_queue, command_to_ui_queue):
462         cmd = self._cmd(commands, ui_to_command_queue, command_to_ui_queue)
463         cmd.cmdloop(self._splash_text(extra_info={
464                     'get-details':'run `license`',
465                     }))
466
467     def run_lines(self, commands, ui_to_command_queue, command_to_ui_queue,
468                   lines):
469         cmd = self._cmd(commands, ui_to_command_queue, command_to_ui_queue)
470         for line in lines:
471             cmd.onecmd(line)