Complete UI/cli interface refactoring, minor bugs
[kamaki] / kamaki / cli / __init__.py
1 # Copyright 2012-2013 GRNET S.A. All rights reserved.
2 #
3 # Redistribution and use in source and binary forms, with or
4 # without modification, are permitted provided that the following
5 # conditions are met:
6 #
7 #   1. Redistributions of source code must retain the above
8 #      copyright notice, this list of conditions and the following
9 #      disclaimer.
10 #
11 #   2. Redistributions in binary form must reproduce the above
12 #      copyright notice, this list of conditions and the following
13 #      disclaimer in the documentation and/or other materials
14 #      provided with the distribution.
15 #
16 # THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
17 # OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
19 # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
20 # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
23 # USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
24 # AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
25 # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
26 # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27 # POSSIBILITY OF SUCH DAMAGE.
28 #
29 # The views and conclusions contained in the software and
30 # documentation are those of the authors and should not be
31 # interpreted as representing official policies, either expressed
32 # or implied, of GRNET S.A.command
33
34 import logging
35 from sys import argv, exit, stdout
36 from os.path import basename
37 from inspect import getargspec
38
39 from kamaki.cli.argument import _arguments, parse_known_args, init_parser,\
40     update_arguments
41 from kamaki.cli.history import History
42 from kamaki.cli.utils import print_dict, print_list, red, magenta, yellow
43 from kamaki.cli.errors import CLIError
44
45 _help = False
46 _debug = False
47 _verbose = False
48 _colors = False
49
50
51 def _construct_command_syntax(cls):
52         spec = getargspec(cls.main.im_func)
53         args = spec.args[1:]
54         n = len(args) - len(spec.defaults or ())
55         required = ' '.join('<%s>' % x\
56             .replace('____', '[:')\
57             .replace('___', ':')\
58             .replace('__', ']').\
59             replace('_', ' ') for x in args[:n])
60         optional = ' '.join('[%s]' % x\
61             .replace('____', '[:')\
62             .replace('___', ':')\
63             .replace('__', ']').\
64             replace('_', ' ') for x in args[n:])
65         cls.syntax = ' '.join(x for x in [required, optional] if x)
66         if spec.varargs:
67             cls.syntax += ' <%s ...>' % spec.varargs
68
69
70 def _get_cmd_tree_from_spec(spec, cmd_tree_list):
71     for tree in cmd_tree_list:
72         if tree.name == spec:
73             return tree
74     return None
75
76
77 _best_match = []
78
79
80 def _num_of_matching_terms(basic_list, attack_list):
81     if not attack_list:
82         return 1
83
84     matching_terms = 0
85     for i, term in enumerate(basic_list):
86         try:
87             if term != attack_list[i]:
88                 break
89         except IndexError:
90             break
91         matching_terms += 1
92     return matching_terms
93
94
95 def _update_best_match(name_terms, prefix=[]):
96     if prefix:
97         pref_list = prefix if isinstance(prefix, list) else prefix.split('_')
98     else:
99         pref_list = []
100
101     num_of_matching_terms = _num_of_matching_terms(name_terms, pref_list)
102     global _best_match
103
104     if num_of_matching_terms and len(_best_match) <= num_of_matching_terms:
105         if len(_best_match) < num_of_matching_terms:
106             _best_match = name_terms[:num_of_matching_terms]
107         return True
108     return False
109
110
111 def command(cmd_tree, prefix='', descedants_depth=1):
112     """Load a class as a command
113         spec_cmd0_cmd1 will be command spec cmd0
114         @cmd_tree is initialized in cmd_spec file and is the structure
115             where commands are loaded. Var name should be _commands
116         @param prefix if given, load only commands prefixed with prefix,
117         @param descedants_depth is the depth of the tree descedants of the
118             prefix command. It is used ONLY if prefix and if prefix is not
119             a terminal command
120     """
121
122     def wrap(cls):
123         cls_name = cls.__name__
124
125         if not cmd_tree:
126             if _debug:
127                 print('Warning: command %s found but not loaded' % cls_name)
128             return cls
129
130         name_terms = cls_name.split('_')
131         if not _update_best_match(name_terms, prefix):
132             return None
133
134         global _best_match
135         max_len = len(_best_match) + descedants_depth
136         if len(name_terms) > max_len:
137             partial = '_'.join(name_terms[:max_len])
138             if not cmd_tree.has_command(partial):  # add partial path
139                 cmd_tree.add_command(partial)
140             return None
141
142         cls.description, sep, cls.long_description\
143         = cls.__doc__.partition('\n')
144         _construct_command_syntax(cls)
145
146         cmd_tree.add_command(cls_name, cls.description, cls)
147         return cls
148     return wrap
149
150
151 def get_cmd_terms():
152     global command
153     return [term for term in command.func_defaults[0]\
154         if not term.startswith('-')]
155
156 cmd_spec_locations = [
157     'kamaki.cli.commands',
158     'kamaki.commands',
159     'kamaki.cli',
160     'kamaki',
161     '']
162
163
164 def _setup_logging(silent=False, debug=False, verbose=False, include=False):
165     """handle logging for clients package"""
166
167     def add_handler(name, level, prefix=''):
168         h = logging.StreamHandler()
169         fmt = logging.Formatter(prefix + '%(message)s')
170         h.setFormatter(fmt)
171         logger = logging.getLogger(name)
172         logger.addHandler(h)
173         logger.setLevel(level)
174
175     if silent:
176         add_handler('', logging.CRITICAL)
177     elif debug:
178         add_handler('requests', logging.INFO, prefix='* ')
179         add_handler('clients.send', logging.DEBUG, prefix='> ')
180         add_handler('clients.recv', logging.DEBUG, prefix='< ')
181     elif verbose:
182         add_handler('requests', logging.INFO, prefix='* ')
183         add_handler('clients.send', logging.INFO, prefix='> ')
184         add_handler('clients.recv', logging.INFO, prefix='< ')
185     elif include:
186         add_handler('clients.recv', logging.INFO)
187     else:
188         add_handler('', logging.WARNING)
189
190
191 def _init_session(arguments):
192     global _help
193     _help = arguments['help'].value
194     global _debug
195     _debug = arguments['debug'].value
196     global _verbose
197     _verbose = arguments['verbose'].value
198     global _colors
199     _colors = arguments['config'].get('global', 'colors')
200     _silent = arguments['silent'].value
201     _include = arguments['include'].value
202     _setup_logging(_silent, _debug, _verbose, _include)
203
204
205 def get_command_group(unparsed, arguments):
206     groups = arguments['config'].get_groups()
207     for term in unparsed:
208         if term.startswith('-'):
209             continue
210         if term in groups:
211             unparsed.remove(term)
212             return term
213         return None
214     return None
215
216
217 def _load_spec_module(spec, arguments, module):
218     spec_name = arguments['config'].get(spec, 'cli')
219     if spec_name is None:
220         return None
221     pkg = None
222     for location in cmd_spec_locations:
223         location += spec_name if location == '' else '.%s' % spec_name
224         try:
225             pkg = __import__(location, fromlist=[module])
226             return pkg
227         except ImportError:
228             continue
229     return pkg
230
231
232 def _groups_help(arguments):
233     global _debug
234     descriptions = {}
235     for spec in arguments['config'].get_groups():
236         pkg = _load_spec_module(spec, arguments, '_commands')
237         if pkg:
238             cmds = None
239             try:
240                 cmds = [
241                     cmd for cmd in getattr(pkg, '_commands')\
242                     if arguments['config'].get(cmd.name, 'cli')
243                 ]
244             except AttributeError:
245                 if _debug:
246                     print('Warning: No description for %s' % spec)
247             try:
248                 for cmd in cmds:
249                     descriptions[cmd.name] = cmd.description
250             except TypeError:
251                 if _debug:
252                     print('Warning: no cmd specs in module %s' % spec)
253         elif _debug:
254             print('Warning: Loading of %s cmd spec failed' % spec)
255     print('\nOptions:\n - - - -')
256     print_dict(descriptions)
257
258
259 def _print_subcommands_help(cmd):
260     printout = {}
261     for subcmd in cmd.get_subcommands():
262         printout[subcmd.path.replace('_', ' ')] = subcmd.description
263     if printout:
264         print('\nOptions:\n - - - -')
265         print_dict(printout)
266
267
268 def _update_parser_help(parser, cmd):
269     global _best_match
270     parser.prog = parser.prog.split('<')[0]
271     parser.prog += ' '.join(_best_match)
272
273     if cmd.is_command:
274         cls = cmd.get_class()
275         parser.prog += ' ' + cls.syntax
276         arguments = cls().arguments
277         update_arguments(parser, arguments)
278     else:
279         parser.prog += ' <...>'
280     if cmd.has_description:
281         parser.description = cmd.help
282
283
284 def _print_error_message(cli_err):
285     errmsg = '%s' % cli_err
286     if cli_err.importance == 1:
287         errmsg = magenta(errmsg)
288     elif cli_err.importance == 2:
289         errmsg = yellow(errmsg)
290     elif cli_err.importance > 2:
291         errmsg = red(errmsg)
292     stdout.write(errmsg)
293     print_list(cli_err.details)
294
295
296 def _get_best_match_from_cmd_tree(cmd_tree, unparsed):
297     matched = [term for term in unparsed if not term.startswith('-')]
298     while matched:
299         try:
300             return cmd_tree.get_command('_'.join(matched))
301         except KeyError:
302             matched = matched[:-1]
303     return None
304
305
306 def _exec_cmd(instance, cmd_args, help_method):
307     try:
308         return instance.main(*cmd_args)
309     except TypeError as err:
310         if err.args and err.args[0].startswith('main()'):
311             print(magenta('Syntax error'))
312             if _debug:
313                 raise err
314             if _verbose:
315                 print(unicode(err))
316             help_method()
317         else:
318             raise
319     except CLIError as err:
320         if _debug:
321             raise err
322         _print_error_message(err)
323     return 1
324
325
326 def set_command_param(param, value):
327     if param == 'prefix':
328         pos = 0
329     elif param == 'descedants_depth':
330         pos = 1
331     else:
332         return
333     global command
334     def_params = list(command.func_defaults)
335     def_params[pos] = value
336     command.func_defaults = tuple(def_params)
337
338
339 def one_cmd(parser, unparsed, arguments):
340     group = get_command_group(list(unparsed), arguments)
341     if not group:
342         parser.print_help()
343         _groups_help(arguments)
344         exit(0)
345
346     set_command_param(
347         'prefix',
348         [term for term in unparsed if not term.startswith('-')]
349     )
350     global _best_match
351     _best_match = []
352
353     spec_module = _load_spec_module(group, arguments, '_commands')
354
355     cmd_tree = _get_cmd_tree_from_spec(group, spec_module._commands)
356
357     if _best_match:
358         cmd = cmd_tree.get_command('_'.join(_best_match))
359     else:
360         cmd = _get_best_match_from_cmd_tree(cmd_tree, unparsed)
361         _best_match = cmd.path.split('_')
362     if cmd is None:
363         if _debug or _verbose:
364             print('Unexpected error: failed to load command')
365         exit(1)
366
367     _update_parser_help(parser, cmd)
368
369     if _help or not cmd.is_command:
370         parser.print_help()
371         _print_subcommands_help(cmd)
372         exit(0)
373
374     cls = cmd.get_class()
375     executable = cls(arguments)
376     parsed, unparsed = parse_known_args(parser, executable.arguments)
377     for term in _best_match:
378         unparsed.remove(term)
379     _exec_cmd(executable, unparsed, parser.print_help)
380
381
382 from command_shell import _fix_arguments, Shell
383
384
385 def _start_shell():
386     shell = Shell()
387     shell.set_prompt(basename(argv[0]))
388     from kamaki import __version__ as version
389     shell.greet(version)
390     shell.do_EOF = shell.do_exit
391     return shell
392
393
394 def run_shell(arguments):
395     _fix_arguments()
396     shell = _start_shell()
397     _config = _arguments['config']
398     from kamaki.cli.command_tree import CommandTree
399     shell.cmd_tree = CommandTree(
400         'kamaki', 'A command line tool for poking clouds')
401     for spec in [spec for spec in _config.get_groups()\
402             if arguments['config'].get(spec, 'cli')]:
403         try:
404             spec_module = _load_spec_module(spec, arguments, '_commands')
405             spec_commands = getattr(spec_module, '_commands')
406         except AttributeError:
407             if _debug:
408                 print('Warning: No valid description for %s' % spec)
409             continue
410         for spec_tree in spec_commands:
411             if spec_tree.name == spec:
412                 shell.cmd_tree.add_tree(spec_tree)
413                 break
414     shell.run()
415
416
417 def main():
418     exe = basename(argv[0])
419     parser = init_parser(exe, _arguments)
420     parsed, unparsed = parse_known_args(parser, _arguments)
421
422     if _arguments['version'].value:
423         exit(0)
424
425     _init_session(_arguments)
426
427     if unparsed:
428         _history = History(_arguments['config'].get('history', 'file'))
429         _history.add(' '.join([exe] + argv[1:]))
430         one_cmd(parser, unparsed, _arguments)
431     elif _help:
432         parser.print_help()
433         _groups_help(_arguments)
434     else:
435         run_shell(_arguments)