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