Major plugin restructuring.
[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
6 # modify it under the terms of the GNU Lesser General Public
7 # License as published by the Free Software Foundation, either
8 # version 3 of the License, or (at your option) any later version.
9 #
10 # Hooke is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU Lesser General 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 cmd
24 import optparse
25 import readline # including readline makes cmd.Cmd.cmdloop() smarter
26 import shlex
27
28 from ..command import CommandExit, Exit, Command, Argument, StoreValue
29 from ..interaction import Request, BooleanRequest, ReloadUserInterfaceConfig
30 from ..ui import UserInterface, CommandMessage
31
32
33 # Define a few helper classes.
34
35 class Default (object):
36     """Marker for options not given on the command line.
37     """
38     pass
39
40 class CommandLineParser (optparse.OptionParser):
41     """Implement a command line syntax for a
42     :class:`hooke.command.Command`.
43     """
44     def __init__(self, command, name_fn):
45         optparse.OptionParser.__init__(self, prog=name_fn(command.name))
46         self.command = command
47         self.command_opts = []
48         self.command_args = []
49         for a in command.arguments:
50             if a.name == 'help':
51                 continue # 'help' is a default OptionParser option
52             if a.optional == True:
53                 name = name_fn(a.name)
54                 self.add_option(
55                     '--%s' % name, dest=name, default=Default)
56                 self.command_opts.append(a)
57             else:
58                 self.command_args.append(a)
59         infinite_counters = [a for a in self.command_args if a.count == -1]
60         assert len(infinite_counters) <= 1, \
61             'Multiple infinite counts for %s: %s\nNeed a better CommandLineParser implementation.' \
62             % (command.name, ', '.join([a.name for a in infinite_counters]))
63         if len(infinite_counters) == 1: # move the big counter to the end.
64             infinite_counter = infinite_counters[0]
65             self.command_args.remove(infinite_counter)
66             self.command_args.append(infinite_counter)
67
68     def exit(self, status=0, msg=None):
69         """Override :meth:`optparse.OptionParser.exit` which calls
70         :func:`sys.exit`.
71         """
72         if msg:
73             raise optparse.OptParseError(msg)
74         raise optparse.OptParseError('OptParse EXIT')
75
76 class CommandMethod (object):
77     """Base class for method replacer.
78
79     The .__call__ methods of `CommandMethod` subclasses functions will
80     provide the `do_*`, `help_*`, and `complete_*` methods of
81     :class:`HookeCmd`.
82     """
83     def __init__(self, cmd, command, name_fn):
84         self.cmd = cmd
85         self.command = command
86         self.name_fn = name_fn
87
88     def __call__(self, *args, **kwargs):
89         raise NotImplementedError
90
91 class DoCommand (CommandMethod):
92     def __init__(self, *args, **kwargs):
93         super(DoCommand, self).__init__(*args, **kwargs)
94         self.parser = CommandLineParser(self.command, self.name_fn)
95
96     def __call__(self, args):
97         try:
98             args = self._parse_args(args)
99         except optparse.OptParseError, e:
100             self.cmd.stdout.write(str(e).lstrip()+'\n')
101             self.cmd.stdout.write('Failure\n')
102             return
103         self.cmd.inqueue.put(CommandMessage(self.command, args))
104         while True:
105             msg = self.cmd.outqueue.get()
106             if isinstance(msg, Exit):
107                 return True
108             elif isinstance(msg, CommandExit):
109                 self.cmd.stdout.write(msg.__class__.__name__+'\n')
110                 self.cmd.stdout.write(str(msg).rstrip()+'\n')
111                 break
112             elif isinstance(msg, ReloadUserInterfaceConfig):
113                 self.cmd.ui.reload_config(msg.config)
114                 continue
115             elif isinstance(msg, Request):
116                 self._handle_request(msg)
117                 continue
118             self.cmd.stdout.write(str(msg).rstrip()+'\n')
119
120     def _parse_args(self, args):
121         argv = shlex.split(args, comments=True, posix=True)
122         options,args = self.parser.parse_args(argv)
123         self._check_argument_length_bounds(args)
124         params = {}
125         for argument in self.parser.command_opts:
126             value = getattr(options, self.name_fn(argument.name))
127             if value != Default:
128                 params[argument.name] = value
129         arg_index = 0
130         for argument in self.parser.command_args:
131             if argument.count == 1:
132                 params[argument.name] = args[arg_index]
133             elif argument.count > 1:
134                 params[argument.name] = \
135                     args[arg_index:arg_index+argument.count]
136             else: # argument.count == -1:
137                 params[argument.name] = args[arg_index:]
138             arg_index += argument.count
139         return params
140
141     def _check_argument_length_bounds(self, arguments):
142         """Check that there are an appropriate number of arguments in
143         `args`.
144
145         If not, raise optparse.OptParseError().
146         """
147         min_args = 0
148         max_args = 0
149         for argument in self.parser.command_args:
150             if argument.optional == False and argument.count > 0:
151                 min_args += argument.count
152             if max_args >= 0: # otherwise already infinite
153                 if argument.count == -1:
154                     max_args = -1
155                 else:
156                     max_args += argument.count
157         if len(arguments) < min_args \
158                 or (max_args >= 0 and len(arguments) > max_args):
159             if min_args == max_args:
160                 target_string = str(min_args)
161             elif max_args == -1:
162                 target_string = 'more than %d' % min_args
163             else:
164                 target_string = '%d to %d' % (min_args, max_args)
165             raise optparse.OptParseError(
166                 '%d arguments given, but %s takes %s'
167                 % (len(arguments), self.name_fn(self.command.name),
168                    target_string))
169
170     def _handle_request(self, msg):
171         """Repeatedly try to get a response to `msg`.
172         """
173         prompt = getattr(self, '_%s_request_prompt' % msg.type, None)
174         if prompt == None:
175             raise NotImplementedError('_%s_request_prompt' % msg.type)
176         prompt_string = prompt(msg)
177         parser = getattr(self, '_%s_request_parser' % msg.type, None)
178         if parser == None:
179             raise NotImplementedError('_%s_request_parser' % msg.type)
180         error = None
181         while True:
182             if error != None:
183                 self.cmd.stdout.write(''.join([
184                         error.__class__.__name__, ': ', str(error), '\n']))
185             self.cmd.stdout.write(prompt_string)
186             value = parser(msg, self.cmd.stdin.readline())
187             try:
188                 response = msg.response(value)
189                 break
190             except ValueError, error:
191                 continue
192         self.cmd.inqueue.put(response)
193
194     def _boolean_request_prompt(self, msg):
195         if msg.default == True:
196             yn = ' [Y/n] '
197         else:
198             yn = ' [y/N] '
199         return msg.msg + yn
200
201     def _boolean_request_parser(self, msg, response):
202         value = response.strip().lower()
203         if value.startswith('y'):
204             value = True
205         elif value.startswith('n'):
206             value = False
207         elif len(value) == 0:
208             value = msg.default
209         return value
210
211     def _string_request_prompt(self, msg):
212         if msg.default == None:
213             d = ' '
214         else:
215             d = ' [%s] ' % msg.default
216         return msg.msg + d
217
218     def _string_request_parser(self, msg, response):
219         return response.strip()
220
221     def _float_request_prompt(self, msg):
222         return self._string_request_prompt(msg)
223
224     def _float_request_parser(self, msg, resposne):
225         return float(response)
226
227     def _selection_request_prompt(self, msg):
228         options = []
229         for i,option in enumerate(msg.options):
230             options.append('   %d) %s' % (i,option))
231         options = ''.join(options)
232         if msg.default == None:
233             prompt = '? '
234         else:
235             prompt = '? [%d] ' % msg.default
236         return '\n'.join([msg,options,prompt])
237     
238     def _selection_request_parser(self, msg, response):
239         return int(response)
240
241
242 class HelpCommand (CommandMethod):
243     def __init__(self, *args, **kwargs):
244         super(HelpCommand, self).__init__(*args, **kwargs)
245         self.parser = CommandLineParser(self.command, self.name_fn)
246
247     def __call__(self):
248         blocks = [self.command.help(name_fn=self.name_fn),
249                   '----',
250                   'Usage: ' + self._usage_string(),
251                   '']
252         self.cmd.stdout.write('\n'.join(blocks))
253
254     def _message(self):
255         return self.command.help(name_fn=self.name_fn)
256
257     def _usage_string(self):
258         if len(self.parser.command_opts) == 0:
259             options_string = ''
260         else:
261             options_string = '[options]'
262         arg_string = ' '.join(
263             [self.name_fn(arg.name) for arg in self.parser.command_args])
264         return ' '.join([x for x in [self.parser.prog,
265                                      options_string,
266                                      arg_string]
267                          if x != ''])
268
269 class CompleteCommand (CommandMethod):
270     def __call__(self, text, line, begidx, endidx):
271         pass
272
273
274 # Define some additional commands
275
276 class LocalHelpCommand (Command):
277     """Called with an argument, prints that command's documentation.
278
279     With no argument, lists all available help topics as well as any
280     undocumented commands.
281     """
282     def __init__(self):
283         super(LocalHelpCommand, self).__init__(name='help', help=self.__doc__)
284         # We set .arguments now (vs. using th arguments option to __init__),
285         # to overwrite the default help argument.  We don't override
286         # :meth:`cmd.Cmd.do_help`, so `help --help` is not a valid command.
287         self.arguments = [
288             Argument(name='command', type='string', optional=True,
289                      help='The name of the command you want help with.')
290             ]
291
292     def _run(self, hooke, inqueue, outqueue, params):
293         raise NotImplementedError # cmd.Cmd already implements .do_help()
294
295 class LocalExitCommand (Command):
296     """Exit Hooke cleanly.
297     """
298     def __init__(self):
299         super(LocalExitCommand, self).__init__(
300             name='exit', aliases=['quit', 'EOF'], help=self.__doc__,
301             arguments = [
302                 Argument(name='force', type='bool', default=False,
303                          help="""
304 Exit without prompting the user.  Use if you save often or don't make
305 typing mistakes ;).
306 """.strip()),
307                 ])
308
309     def _run(self, hooke, inqueue, outqueue, params):
310         """The guts of the `do_exit/_quit/_EOF` commands.
311
312         A `True` return stops :meth:`.cmdloop` execution.
313         """
314         _exit = True
315         if params['force'] == False:
316             not_saved = [p.name for p in hooke.playlists
317                          if p.is_saved() == False]
318             msg = 'Exit?'
319             default = True
320             if len(not_saved) > 0:
321                 msg = 'Unsaved playlists (%s).  %s' \
322                     % (', '.join([str(p) for p in not_saved]), msg)
323                 default = False
324             outqueue.put(BooleanRequest(msg, default))
325             result = inqueue.get()
326             assert result.type == 'boolean'
327             _exit = result.value
328         if _exit == True:
329             raise Exit()
330
331
332 # Now onto the main attraction.
333
334 class HookeCmd (cmd.Cmd):
335     def __init__(self, ui, commands, inqueue, outqueue):
336         cmd.Cmd.__init__(self)
337         self.ui = ui
338         self.commands = commands
339         self.local_commands = [LocalExitCommand(), LocalHelpCommand()]
340         self.prompt = 'hooke> '
341         self._add_command_methods()
342         self.inqueue = inqueue
343         self.outqueue = outqueue
344
345     def _name_fn(self, name):
346         return name.replace(' ', '_')
347
348     def _add_command_methods(self):
349         for command in self.commands + self.local_commands:
350             for name in [command.name] + command.aliases:
351                 name = self._name_fn(name)
352                 setattr(self.__class__, 'help_%s' % name,
353                         HelpCommand(self, command, self._name_fn))
354                 if name != 'help':
355                     setattr(self.__class__, 'do_%s' % name,
356                             DoCommand(self, command, self._name_fn))
357                     setattr(self.__class__, 'complete_%s' % name,
358                             CompleteCommand(self, command, self._name_fn))
359
360
361 class CommandLine (UserInterface):
362     """Command line interface.  Simple and powerful.
363     """
364     def __init__(self):
365         super(CommandLine, self).__init__(name='command line')
366
367     def run(self, commands, ui_to_command_queue, command_to_ui_queue):
368         print 'running in ui'
369         cmd = HookeCmd(self, commands,
370                        inqueue=ui_to_command_queue,
371                        outqueue=command_to_ui_queue)
372         print 'cmdloop'
373         cmd.cmdloop(self._splash_text())
374
375     def run_lines(self, commands, ui_to_command_queue, command_to_ui_queue,
376                   lines):
377         cmd = HookeCmd(self, commands,
378                        inqueue=ui_to_command_queue,
379                        outqueue=command_to_ui_queue)
380         for line in lines:
381             cmd.onecmd(line)