Add unicode-capable IO to hooke.ui.commandline.
[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 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         argv = shlex.split(args, comments=True, posix=True)
124         options,args = self.parser.parse_args(argv)
125         self._check_argument_length_bounds(args)
126         params = {}
127         for argument in self.parser.command_opts:
128             value = getattr(options, self.name_fn(argument.name))
129             if value != Default:
130                 params[argument.name] = value
131         arg_index = 0
132         for argument in self.parser.command_args:
133             if argument.count == 1:
134                 params[argument.name] = args[arg_index]
135             elif argument.count > 1:
136                 params[argument.name] = \
137                     args[arg_index:arg_index+argument.count]
138             else: # argument.count == -1:
139                 params[argument.name] = args[arg_index:]
140             arg_index += argument.count
141         return params
142
143     def _check_argument_length_bounds(self, arguments):
144         """Check that there are an appropriate number of arguments in
145         `args`.
146
147         If not, raise optparse.OptParseError().
148         """
149         min_args = 0
150         max_args = 0
151         for argument in self.parser.command_args:
152             if argument.optional == False and argument.count > 0:
153                 min_args += argument.count
154             if max_args >= 0: # otherwise already infinite
155                 if argument.count == -1:
156                     max_args = -1
157                 else:
158                     max_args += argument.count
159         if len(arguments) < min_args \
160                 or (max_args >= 0 and len(arguments) > max_args):
161             if min_args == max_args:
162                 target_string = str(min_args)
163             elif max_args == -1:
164                 target_string = 'more than %d' % min_args
165             else:
166                 target_string = '%d to %d' % (min_args, max_args)
167             raise optparse.OptParseError(
168                 '%d arguments given, but %s takes %s'
169                 % (len(arguments), self.name_fn(self.command.name),
170                    target_string))
171
172     def _handle_request(self, msg):
173         """Repeatedly try to get a response to `msg`.
174         """
175         prompt = getattr(self, '_%s_request_prompt' % msg.type, None)
176         if prompt == None:
177             raise NotImplementedError('_%s_request_prompt' % msg.type)
178         prompt_string = prompt(msg)
179         parser = getattr(self, '_%s_request_parser' % msg.type, None)
180         if parser == None:
181             raise NotImplementedError('_%s_request_parser' % msg.type)
182         error = None
183         while True:
184             if error != None:
185                 self.cmd.stdout.write(''.join([
186                         error.__class__.__name__, ': ', str(error), '\n']))
187             self.cmd.stdout.write(prompt_string)
188             value = parser(msg, self.cmd.stdin.readline())
189             try:
190                 response = msg.response(value)
191                 break
192             except ValueError, error:
193                 continue
194         self.cmd.inqueue.put(response)
195
196     def _boolean_request_prompt(self, msg):
197         if msg.default == True:
198             yn = ' [Y/n] '
199         else:
200             yn = ' [y/N] '
201         return msg.msg + yn
202
203     def _boolean_request_parser(self, msg, response):
204         value = response.strip().lower()
205         if value.startswith('y'):
206             value = True
207         elif value.startswith('n'):
208             value = False
209         elif len(value) == 0:
210             value = msg.default
211         return value
212
213     def _string_request_prompt(self, msg):
214         if msg.default == None:
215             d = ' '
216         else:
217             d = ' [%s] ' % msg.default
218         return msg.msg + d
219
220     def _string_request_parser(self, msg, response):
221         return response.strip()
222
223     def _float_request_prompt(self, msg):
224         return self._string_request_prompt(msg)
225
226     def _float_request_parser(self, msg, resposne):
227         return float(response)
228
229     def _selection_request_prompt(self, msg):
230         options = []
231         for i,option in enumerate(msg.options):
232             options.append('   %d) %s' % (i,option))
233         options = ''.join(options)
234         if msg.default == None:
235             prompt = '? '
236         else:
237             prompt = '? [%d] ' % msg.default
238         return '\n'.join([msg,options,prompt])
239     
240     def _selection_request_parser(self, msg, response):
241         return int(response)
242
243
244 class HelpCommand (CommandMethod):
245     def __init__(self, *args, **kwargs):
246         super(HelpCommand, self).__init__(*args, **kwargs)
247         self.parser = CommandLineParser(self.command, self.name_fn)
248
249     def __call__(self):
250         blocks = [self.command.help(name_fn=self.name_fn),
251                   '----',
252                   'Usage: ' + self._usage_string(),
253                   '']
254         self.cmd.stdout.write('\n'.join(blocks))
255
256     def _message(self):
257         return self.command.help(name_fn=self.name_fn)
258
259     def _usage_string(self):
260         if len(self.parser.command_opts) == 0:
261             options_string = ''
262         else:
263             options_string = '[options]'
264         arg_string = ' '.join(
265             [self.name_fn(arg.name) for arg in self.parser.command_args])
266         return ' '.join([x for x in [self.parser.prog,
267                                      options_string,
268                                      arg_string]
269                          if x != ''])
270
271 class CompleteCommand (CommandMethod):
272     def __call__(self, text, line, begidx, endidx):
273         pass
274
275
276 # Define some additional commands
277
278 class LocalHelpCommand (Command):
279     """Called with an argument, prints that command's documentation.
280
281     With no argument, lists all available help topics as well as any
282     undocumented commands.
283     """
284     def __init__(self):
285         super(LocalHelpCommand, self).__init__(name='help', help=self.__doc__)
286         # We set .arguments now (vs. using th arguments option to __init__),
287         # to overwrite the default help argument.  We don't override
288         # :meth:`cmd.Cmd.do_help`, so `help --help` is not a valid command.
289         self.arguments = [
290             Argument(name='command', type='string', optional=True,
291                      help='The name of the command you want help with.')
292             ]
293
294     def _run(self, hooke, inqueue, outqueue, params):
295         raise NotImplementedError # cmd.Cmd already implements .do_help()
296
297 class LocalExitCommand (Command):
298     """Exit Hooke cleanly.
299     """
300     def __init__(self):
301         super(LocalExitCommand, self).__init__(
302             name='exit', aliases=['quit', 'EOF'], help=self.__doc__,
303             arguments = [
304                 Argument(name='force', type='bool', default=False,
305                          help="""
306 Exit without prompting the user.  Use if you save often or don't make
307 typing mistakes ;).
308 """.strip()),
309                 ])
310
311     def _run(self, hooke, inqueue, outqueue, params):
312         """The guts of the `do_exit/_quit/_EOF` commands.
313
314         A `True` return stops :meth:`.cmdloop` execution.
315         """
316         _exit = True
317         if params['force'] == False:
318             not_saved = [p.name for p in hooke.playlists
319                          if p.is_saved() == False]
320             msg = 'Exit?'
321             default = True
322             if len(not_saved) > 0:
323                 msg = 'Unsaved playlists (%s).  %s' \
324                     % (', '.join([str(p) for p in not_saved]), msg)
325                 default = False
326             outqueue.put(BooleanRequest(msg, default))
327             result = inqueue.get()
328             assert result.type == 'boolean'
329             _exit = result.value
330         if _exit == True:
331             raise Exit()
332
333
334 # Now onto the main attraction.
335
336 class HookeCmd (cmd.Cmd):
337     def __init__(self, ui, commands, inqueue, outqueue):
338         cmd.Cmd.__init__(self)
339         self.ui = ui
340         self.commands = commands
341         self.local_commands = [LocalExitCommand(), LocalHelpCommand()]
342         self.prompt = 'hooke> '
343         self._add_command_methods()
344         self.inqueue = inqueue
345         self.outqueue = outqueue
346
347     def _name_fn(self, name):
348         return name.replace(' ', '_')
349
350     def _add_command_methods(self):
351         for command in self.commands + self.local_commands:
352             for name in [command.name] + command.aliases:
353                 name = self._name_fn(name)
354                 setattr(self.__class__, 'help_%s' % name,
355                         HelpCommand(self, command, self._name_fn))
356                 if name != 'help':
357                     setattr(self.__class__, 'do_%s' % name,
358                             DoCommand(self, command, self._name_fn))
359                     setattr(self.__class__, 'complete_%s' % name,
360                             CompleteCommand(self, command, self._name_fn))
361
362
363 class CommandLine (UserInterface):
364     """Command line interface.  Simple and powerful.
365     """
366     def __init__(self):
367         super(CommandLine, self).__init__(name='command line')
368
369     def _cmd(self, commands, ui_to_command_queue, command_to_ui_queue):
370         cmd = HookeCmd(self, commands,
371                        inqueue=ui_to_command_queue,
372                        outqueue=command_to_ui_queue)
373         cmd.stdin = codecs.getreader(get_input_encoding())(cmd.stdin)
374         cmd.stdout = codecs.getwriter(get_output_encoding())(cmd.stdout)
375         return cmd
376
377     def run(self, commands, ui_to_command_queue, command_to_ui_queue):
378         cmd = self._cmd(commands, ui_to_command_queue, command_to_ui_queue)
379         cmd.cmdloop(self._splash_text())
380
381     def run_lines(self, commands, ui_to_command_queue, command_to_ui_queue,
382                   lines):
383         cmd = self._cmd(commands, ui_to_command_queue, command_to_ui_queue)
384         for line in lines:
385             cmd.onecmd(line)