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