Moved QueueMessage and subclasses from hooke.ui to the more central hooke.engine.
[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, BooleanRequest, 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         self.log.debug('executing %s with %s' % (self.command.name, args))
133         self.cmd.inqueue.put(CommandMessage(self.command, args))
134         while True:
135             msg = self.cmd.outqueue.get()
136             if isinstance(msg, Exit):
137                 return True
138             elif isinstance(msg, CommandExit):
139                 self.cmd.stdout.write(msg.__class__.__name__+'\n')
140                 self.cmd.stdout.write(str(msg).rstrip()+'\n')
141                 break
142             elif isinstance(msg, ReloadUserInterfaceConfig):
143                 self.cmd.ui.reload_config(msg.config)
144                 continue
145             elif isinstance(msg, Request):
146                 self._handle_request(msg)
147                 continue
148             self.cmd.stdout.write(str(msg).rstrip()+'\n')
149
150     def _parse_args(self, args):
151         options,args = self.parser.parse_args(args)
152         self._check_argument_length_bounds(args)
153         params = {}
154         for argument in self.parser.command_opts:
155             value = getattr(options, self.name_fn(argument.name))
156             if value != Default:
157                 params[argument.name] = value
158         arg_index = 0
159         for argument in self.parser.command_args:
160             if argument.count == 1:
161                 params[argument.name] = from_string(args[arg_index],
162                                                     argument.type)
163             elif argument.count > 1:
164                 params[argument.name] = [
165                     from_string(a, argument.type)
166                     for a in args[arg_index:arg_index+argument.count]]
167             else: # argument.count == -1:
168                 params[argument.name] = [
169                     from_string(a, argument.type) for a in args[arg_index:]]
170             arg_index += argument.count
171         return params
172
173     def _check_argument_length_bounds(self, arguments):
174         """Check that there are an appropriate number of arguments in
175         `args`.
176
177         If not, raise optparse.OptParseError().
178         """
179         min_args = 0
180         max_args = 0
181         for argument in self.parser.command_args:
182             if argument.optional == False and argument.count > 0:
183                 min_args += argument.count
184             if max_args >= 0: # otherwise already infinite
185                 if argument.count == -1:
186                     max_args = -1
187                 else:
188                     max_args += argument.count
189         if len(arguments) < min_args \
190                 or (max_args >= 0 and len(arguments) > max_args):
191             if min_args == max_args:
192                 target_string = str(min_args)
193             elif max_args == -1:
194                 target_string = 'more than %d' % min_args
195             else:
196                 target_string = '%d to %d' % (min_args, max_args)
197             raise optparse.OptParseError(
198                 '%d arguments given, but %s takes %s'
199                 % (len(arguments), self.name_fn(self.command.name),
200                    target_string))
201
202     def _handle_request(self, msg):
203         """Repeatedly try to get a response to `msg`.
204         """
205         prompt = getattr(self, '_%s_request_prompt' % msg.type, None)
206         if prompt == None:
207             raise NotImplementedError('_%s_request_prompt' % msg.type)
208         prompt_string = prompt(msg)
209         parser = getattr(self, '_%s_request_parser' % msg.type, None)
210         if parser == None:
211             raise NotImplementedError('_%s_request_parser' % msg.type)
212         error = None
213         while True:
214             if error != None:
215                 self.cmd.stdout.write(''.join([
216                         error.__class__.__name__, ': ', str(error), '\n']))
217             self.cmd.stdout.write(prompt_string)
218             value = parser(msg, self.cmd.stdin.readline())
219             try:
220                 response = msg.response(value)
221                 break
222             except ValueError, error:
223                 continue
224         self.cmd.inqueue.put(response)
225
226     def _boolean_request_prompt(self, msg):
227         if msg.default == True:
228             yn = ' [Y/n] '
229         else:
230             yn = ' [y/N] '
231         return msg.msg + yn
232
233     def _boolean_request_parser(self, msg, response):
234         value = response.strip().lower()
235         if value.startswith('y'):
236             value = True
237         elif value.startswith('n'):
238             value = False
239         elif len(value) == 0:
240             value = msg.default
241         return value
242
243     def _string_request_prompt(self, msg):
244         if msg.default == None:
245             d = ' '
246         else:
247             d = ' [%s] ' % msg.default
248         return msg.msg + d
249
250     def _string_request_parser(self, msg, response):
251         response = response.strip()
252         if response == '':
253             return msg.default
254         return response.strip()
255
256     def _float_request_prompt(self, msg):
257         return self._string_request_prompt(msg)
258
259     def _float_request_parser(self, msg, resposne):
260         if response.strip() == '':
261             return msg.default
262         return float(response)
263
264     def _selection_request_prompt(self, msg):
265         options = []
266         for i,option in enumerate(msg.options):
267             options.append('   %d) %s' % (i,option))
268         options = ''.join(options)
269         if msg.default == None:
270             prompt = '? '
271         else:
272             prompt = '? [%d] ' % msg.default
273         return '\n'.join([msg.msg,options,prompt])
274     
275     def _selection_request_parser(self, msg, response):
276         if response.strip() == '':
277             return msg.default
278         return int(response)
279
280     def _point_request_prompt(self, msg):
281         block = msg.curve.data[msg.block]
282         block_info = ('(curve: %s, block: %s, %d points)'
283                       % (msg.curve.name,
284                          block.info['name'],
285                          block.shape[0]))
286
287         if msg.default == None:
288             prompt = '? '
289         else:
290             prompt = '? [%d] ' % msg.default
291         return ' '.join([msg.msg,block_info,prompt])
292     
293     def _point_request_parser(self, msg, response):
294         if response.strip() == '':
295             return msg.default
296         return int(response)
297
298
299 class HelpCommand (CommandMethod):
300     def __init__(self, *args, **kwargs):
301         super(HelpCommand, self).__init__(*args, **kwargs)
302         self.parser = CommandLineParser(self.command, self.name_fn)
303
304     def __call__(self):
305         blocks = [self.command.help(name_fn=self.name_fn),
306                   '----',
307                   'Usage: ' + self._usage_string(),
308                   '']
309         self.cmd.stdout.write('\n'.join(blocks))
310
311     def _message(self):
312         return self.command.help(name_fn=self.name_fn)
313
314     def _usage_string(self):
315         if len(self.parser.command_opts) == 0:
316             options_string = ''
317         else:
318             options_string = '[options]'
319         arg_string = ' '.join(
320             [self.name_fn(arg.name) for arg in self.parser.command_args])
321         return ' '.join([x for x in [self.parser.prog,
322                                      options_string,
323                                      arg_string]
324                          if x != ''])
325
326 class CompleteCommand (CommandMethod):
327     def __call__(self, text, line, begidx, endidx):
328         pass
329
330
331 # Define some additional commands
332
333 class LocalHelpCommand (Command):
334     """Called with an argument, prints that command's documentation.
335
336     With no argument, lists all available help topics as well as any
337     undocumented commands.
338     """
339     def __init__(self):
340         super(LocalHelpCommand, self).__init__(name='help', help=self.__doc__)
341         # We set .arguments now (vs. using th arguments option to __init__),
342         # to overwrite the default help argument.  We don't override
343         # :meth:`cmd.Cmd.do_help`, so `help --help` is not a valid command.
344         self.arguments = [
345             Argument(name='command', type='string', optional=True,
346                      help='The name of the command you want help with.')
347             ]
348
349     def _run(self, hooke, inqueue, outqueue, params):
350         raise NotImplementedError # cmd.Cmd already implements .do_help()
351
352 class LocalExitCommand (Command):
353     """Exit Hooke cleanly.
354     """
355     def __init__(self):
356         super(LocalExitCommand, self).__init__(
357             name='exit', aliases=['quit', 'EOF'], help=self.__doc__,
358             arguments = [
359                 Argument(name='force', type='bool', default=False,
360                          help="""
361 Exit without prompting the user.  Use if you save often or don't make
362 typing mistakes ;).
363 """.strip()),
364                 ])
365
366     def _run(self, hooke, inqueue, outqueue, params):
367         """The guts of the `do_exit/_quit/_EOF` commands.
368
369         A `True` return stops :meth:`.cmdloop` execution.
370         """
371         _exit = True
372         if params['force'] == False:
373             not_saved = [p.name for p in hooke.playlists
374                          if p.is_saved() == False]
375             msg = 'Exit?'
376             default = True
377             if len(not_saved) > 0:
378                 msg = 'Unsaved playlists (%s).  %s' \
379                     % (', '.join([str(p) for p in not_saved]), msg)
380                 default = False
381             outqueue.put(BooleanRequest(msg, default))
382             result = inqueue.get()
383             assert result.type == 'boolean'
384             _exit = result.value
385         if _exit == True:
386             raise Exit()
387
388
389 # Now onto the main attraction.
390
391 class HookeCmd (cmd.Cmd):
392     def __init__(self, ui, commands, inqueue, outqueue):
393         cmd.Cmd.__init__(self)
394         self.ui = ui
395         self.commands = commands
396         self.local_commands = [LocalExitCommand(), LocalHelpCommand()]
397         self.prompt = 'hooke> '
398         self._add_command_methods()
399         self.inqueue = inqueue
400         self.outqueue = outqueue
401
402     def _name_fn(self, name):
403         return name.replace(' ', '_')
404
405     def _add_command_methods(self):
406         for command in self.commands + self.local_commands:
407             for name in [command.name] + command.aliases:
408                 name = self._name_fn(name)
409                 setattr(self.__class__, 'help_%s' % name,
410                         HelpCommand(self, command, self._name_fn))
411                 if name != 'help':
412                     setattr(self.__class__, 'do_%s' % name,
413                             DoCommand(self, command, self._name_fn))
414                     setattr(self.__class__, 'complete_%s' % name,
415                             CompleteCommand(self, command, self._name_fn))
416
417     def parseline(self, line):
418         """Override Cmd.parseline to use shlex.split.
419
420         Notes
421         -----
422         This allows us to handle comments cleanly.  With the default
423         Cmd implementation, a pure comment line will call the .default
424         error message.
425
426         Since we use shlex to strip comments, we return a list of
427         split arguments rather than the raw argument string.
428         """
429         line = line.strip()
430         argv = shlex.split(line, comments=True, posix=True)
431         if len(argv) == 0:
432             return None, None, '' # return an empty line
433         cmd = argv[0]
434         args = argv[1:]
435         if cmd == '?':
436             cmd = 'help'
437         elif cmd == '!':
438             cmd = 'system'
439         return cmd, args, line
440
441     def do_help(self, arg):
442         """Wrap Cmd.do_help to handle our .parseline argument list.
443         """
444         if len(arg) == 0:
445             return cmd.Cmd.do_help(self, '')
446         return cmd.Cmd.do_help(self, arg[0])
447
448     def emptyline(self):
449         """Override Cmd.emptyline to not do anything.
450
451         Repeating the last non-empty command seems unwise.  Explicit
452         is better than implicit.
453         """
454         pass
455
456
457 class CommandLine (UserInterface):
458     """Command line interface.  Simple and powerful.
459     """
460     def __init__(self):
461         super(CommandLine, self).__init__(name='command line')
462
463     def _cmd(self, commands, ui_to_command_queue, command_to_ui_queue):
464         cmd = HookeCmd(self, commands,
465                        inqueue=ui_to_command_queue,
466                        outqueue=command_to_ui_queue)
467         #cmd.stdin = codecs.getreader(get_input_encoding())(cmd.stdin)
468         cmd.stdout = codecs.getwriter(get_output_encoding())(cmd.stdout)
469         return cmd
470
471     def run(self, commands, ui_to_command_queue, command_to_ui_queue):
472         cmd = self._cmd(commands, ui_to_command_queue, command_to_ui_queue)
473         cmd.cmdloop(self._splash_text(extra_info={
474                     'get-details':'run `license`',
475                     }))
476
477     def run_lines(self, commands, ui_to_command_queue, command_to_ui_queue,
478                   lines):
479         cmd = self._cmd(commands, ui_to_command_queue, command_to_ui_queue)
480         for line in lines:
481             cmd.onecmd(line)