53291a25e1a4df2420fb0f43dcfbf1e1302d8b61
[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 ..interaction import Request, BooleanRequest, ReloadUserInterfaceConfig
36 from ..ui import UserInterface, CommandMessage
37 from ..util.convert import from_string
38 from ..util.encoding import get_input_encoding, get_output_encoding
39
40
41 # Define a few helper classes.
42
43 class Default (object):
44     """Marker for options not given on the command line.
45     """
46     pass
47
48 class CommandLineParser (optparse.OptionParser):
49     """Implement a command line syntax for a
50     :class:`hooke.command.Command`.
51     """
52     def __init__(self, command, name_fn):
53         optparse.OptionParser.__init__(self, prog=name_fn(command.name))
54         self.command = command
55         self.command_opts = []
56         self.command_args = []
57         for a in command.arguments:
58             if a.name == 'help':
59                 continue # 'help' is a default OptionParser option
60             if a.optional == True:
61                 name = name_fn(a.name)
62                 type = a.type
63                 if type == 'bool':
64                     if a.default == True:
65                         self.add_option(
66                             '--disable-%s' % name, dest=name, default=Default,
67                             action='store_false')
68                         self.command_opts.append(a)
69                         continue
70                     elif a.default == False:
71                         self.add_option(
72                             '--enable-%s' % name, dest=name, default=Default,
73                             action='store_true')
74                         self.command_opts.append(a)
75                         continue
76                     else:
77                         type = 'string'
78                 elif type not in ['string', 'int', 'long', 'choice', 'float',
79                                   'complex']:
80                     type = 'string'
81                 self.add_option(
82                     '--%s' % name, dest=name, type=type, default=Default)
83                 self.command_opts.append(a)
84             else:
85                 self.command_args.append(a)
86         infinite_counters = [a for a in self.command_args if a.count == -1]
87         assert len(infinite_counters) <= 1, \
88             'Multiple infinite counts for %s: %s\nNeed a better CommandLineParser implementation.' \
89             % (command.name, ', '.join([a.name for a in infinite_counters]))
90         if len(infinite_counters) == 1: # move the big counter to the end.
91             infinite_counter = infinite_counters[0]
92             self.command_args.remove(infinite_counter)
93             self.command_args.append(infinite_counter)
94
95     def exit(self, status=0, msg=None):
96         """Override :meth:`optparse.OptionParser.exit` which calls
97         :func:`sys.exit`.
98         """
99         if msg:
100             raise optparse.OptParseError(msg)
101         raise optparse.OptParseError('OptParse EXIT')
102
103 class CommandMethod (object):
104     """Base class for method replacer.
105
106     The .__call__ methods of `CommandMethod` subclasses functions will
107     provide the `do_*`, `help_*`, and `complete_*` methods of
108     :class:`HookeCmd`.
109     """
110     def __init__(self, cmd, command, name_fn):
111         self.cmd = cmd
112         self.command = command
113         self.name_fn = name_fn
114
115     def __call__(self, *args, **kwargs):
116         raise NotImplementedError
117
118 class DoCommand (CommandMethod):
119     def __init__(self, *args, **kwargs):
120         super(DoCommand, self).__init__(*args, **kwargs)
121         self.parser = CommandLineParser(self.command, self.name_fn)
122         self.log = logging.getLogger('hooke')
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         self.log.debug('executing %s with %s' % (self.command.name, args))
132         self.cmd.inqueue.put(CommandMessage(self.command, args))
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     def __init__(self, *args, **kwargs):
300         super(HelpCommand, self).__init__(*args, **kwargs)
301         self.parser = CommandLineParser(self.command, self.name_fn)
302
303     def __call__(self):
304         blocks = [self.command.help(name_fn=self.name_fn),
305                   '----',
306                   'Usage: ' + self._usage_string(),
307                   '']
308         self.cmd.stdout.write('\n'.join(blocks))
309
310     def _message(self):
311         return self.command.help(name_fn=self.name_fn)
312
313     def _usage_string(self):
314         if len(self.parser.command_opts) == 0:
315             options_string = ''
316         else:
317             options_string = '[options]'
318         arg_string = ' '.join(
319             [self.name_fn(arg.name) for arg in self.parser.command_args])
320         return ' '.join([x for x in [self.parser.prog,
321                                      options_string,
322                                      arg_string]
323                          if x != ''])
324
325 class CompleteCommand (CommandMethod):
326     def __call__(self, text, line, begidx, endidx):
327         pass
328
329
330 # Define some additional commands
331
332 class LocalHelpCommand (Command):
333     """Called with an argument, prints that command's documentation.
334
335     With no argument, lists all available help topics as well as any
336     undocumented commands.
337     """
338     def __init__(self):
339         super(LocalHelpCommand, self).__init__(name='help', help=self.__doc__)
340         # We set .arguments now (vs. using th arguments option to __init__),
341         # to overwrite the default help argument.  We don't override
342         # :meth:`cmd.Cmd.do_help`, so `help --help` is not a valid command.
343         self.arguments = [
344             Argument(name='command', type='string', optional=True,
345                      help='The name of the command you want help with.')
346             ]
347
348     def _run(self, hooke, inqueue, outqueue, params):
349         raise NotImplementedError # cmd.Cmd already implements .do_help()
350
351 class LocalExitCommand (Command):
352     """Exit Hooke cleanly.
353     """
354     def __init__(self):
355         super(LocalExitCommand, self).__init__(
356             name='exit', aliases=['quit', 'EOF'], help=self.__doc__,
357             arguments = [
358                 Argument(name='force', type='bool', default=False,
359                          help="""
360 Exit without prompting the user.  Use if you save often or don't make
361 typing mistakes ;).
362 """.strip()),
363                 ])
364
365     def _run(self, hooke, inqueue, outqueue, params):
366         """The guts of the `do_exit/_quit/_EOF` commands.
367
368         A `True` return stops :meth:`.cmdloop` execution.
369         """
370         _exit = True
371         if params['force'] == False:
372             not_saved = [p.name for p in hooke.playlists
373                          if p.is_saved() == False]
374             msg = 'Exit?'
375             default = True
376             if len(not_saved) > 0:
377                 msg = 'Unsaved playlists (%s).  %s' \
378                     % (', '.join([str(p) for p in not_saved]), msg)
379                 default = False
380             outqueue.put(BooleanRequest(msg, default))
381             result = inqueue.get()
382             assert result.type == 'boolean'
383             _exit = result.value
384         if _exit == True:
385             raise Exit()
386
387
388 # Now onto the main attraction.
389
390 class HookeCmd (cmd.Cmd):
391     def __init__(self, ui, commands, inqueue, outqueue):
392         cmd.Cmd.__init__(self)
393         self.ui = ui
394         self.commands = commands
395         self.local_commands = [LocalExitCommand(), LocalHelpCommand()]
396         self.prompt = 'hooke> '
397         self._add_command_methods()
398         self.inqueue = inqueue
399         self.outqueue = outqueue
400
401     def _name_fn(self, name):
402         return name.replace(' ', '_')
403
404     def _add_command_methods(self):
405         for command in self.commands + self.local_commands:
406             for name in [command.name] + command.aliases:
407                 name = self._name_fn(name)
408                 setattr(self.__class__, 'help_%s' % name,
409                         HelpCommand(self, command, self._name_fn))
410                 if name != 'help':
411                     setattr(self.__class__, 'do_%s' % name,
412                             DoCommand(self, command, self._name_fn))
413                     setattr(self.__class__, 'complete_%s' % name,
414                             CompleteCommand(self, command, self._name_fn))
415
416     def parseline(self, line):
417         """Override Cmd.parseline to use shlex.split.
418
419         Notes
420         -----
421         This allows us to handle comments cleanly.  With the default
422         Cmd implementation, a pure comment line will call the .default
423         error message.
424
425         Since we use shlex to strip comments, we return a list of
426         split arguments rather than the raw argument string.
427         """
428         line = line.strip()
429         argv = shlex.split(line, comments=True, posix=True)
430         if len(argv) == 0:
431             return None, None, '' # return an empty line
432         cmd = argv[0]
433         args = argv[1:]
434         if cmd == '?':
435             cmd = 'help'
436         elif cmd == '!':
437             cmd = 'system'
438         return cmd, args, line
439
440     def do_help(self, arg):
441         """Wrap Cmd.do_help to handle our .parseline argument list.
442         """
443         if len(arg) == 0:
444             return cmd.Cmd.do_help(self, '')
445         return cmd.Cmd.do_help(self, arg[0])
446
447     def emptyline(self):
448         """Override Cmd.emptyline to not do anything.
449
450         Repeating the last non-empty command seems unwise.  Explicit
451         is better than implicit.
452         """
453         pass
454
455
456 class CommandLine (UserInterface):
457     """Command line interface.  Simple and powerful.
458     """
459     def __init__(self):
460         super(CommandLine, self).__init__(name='command line')
461
462     def _cmd(self, commands, ui_to_command_queue, command_to_ui_queue):
463         cmd = HookeCmd(self, commands,
464                        inqueue=ui_to_command_queue,
465                        outqueue=command_to_ui_queue)
466         #cmd.stdin = codecs.getreader(get_input_encoding())(cmd.stdin)
467         cmd.stdout = codecs.getwriter(get_output_encoding())(cmd.stdout)
468         return cmd
469
470     def run(self, commands, ui_to_command_queue, command_to_ui_queue):
471         cmd = self._cmd(commands, ui_to_command_queue, command_to_ui_queue)
472         cmd.cmdloop(self._splash_text(extra_info={
473                     'get-details':'run `license`',
474                     }))
475
476     def run_lines(self, commands, ui_to_command_queue, command_to_ui_queue,
477                   lines):
478         cmd = self._cmd(commands, ui_to_command_queue, command_to_ui_queue)
479         for line in lines:
480             cmd.onecmd(line)