Fix new deep-level bug in shell
[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 len(basic_list)
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     if not (stdout.isatty() and _colors == 'on'):
201         from kamaki.cli.utils import remove_colors
202         remove_colors()
203     _silent = arguments['silent'].value
204     _include = arguments['include'].value
205     _setup_logging(_silent, _debug, _verbose, _include)
206
207
208 def get_command_group(unparsed, arguments):
209     groups = arguments['config'].get_groups()
210     for term in unparsed:
211         if term.startswith('-'):
212             continue
213         if term in groups:
214             unparsed.remove(term)
215             return term
216         return None
217     return None
218
219
220 def _load_spec_module(spec, arguments, module):
221     spec_name = arguments['config'].get(spec, 'cli')
222     if spec_name is None:
223         return None
224     pkg = None
225     for location in cmd_spec_locations:
226         location += spec_name if location == '' else '.%s' % spec_name
227         try:
228             pkg = __import__(location, fromlist=[module])
229             return pkg
230         except ImportError:
231             continue
232     return pkg
233
234
235 def _groups_help(arguments):
236     global _debug
237     descriptions = {}
238     for spec in arguments['config'].get_groups():
239         pkg = _load_spec_module(spec, arguments, '_commands')
240         if pkg:
241             cmds = None
242             try:
243                 cmds = [
244                     cmd for cmd in getattr(pkg, '_commands')\
245                     if arguments['config'].get(cmd.name, 'cli')
246                 ]
247             except AttributeError:
248                 if _debug:
249                     print('Warning: No description for %s' % spec)
250             try:
251                 for cmd in cmds:
252                     descriptions[cmd.name] = cmd.description
253             except TypeError:
254                 if _debug:
255                     print('Warning: no cmd specs in module %s' % spec)
256         elif _debug:
257             print('Warning: Loading of %s cmd spec failed' % spec)
258     print('\nOptions:\n - - - -')
259     print_dict(descriptions)
260
261
262 def _print_subcommands_help(cmd):
263     printout = {}
264     for subcmd in cmd.get_subcommands():
265         spec, sep, print_path = subcmd.path.partition('_')
266         printout[print_path.replace('_', ' ')] = subcmd.description
267     if printout:
268         print('\nOptions:\n - - - -')
269         print_dict(printout)
270
271
272 def _update_parser_help(parser, cmd):
273     global _best_match
274     parser.prog = parser.prog.split('<')[0]
275     parser.prog += ' '.join(_best_match)
276
277     if cmd.is_command:
278         cls = cmd.get_class()
279         parser.prog += ' ' + cls.syntax
280         arguments = cls().arguments
281         update_arguments(parser, arguments)
282     else:
283         parser.prog += ' <...>'
284     if cmd.has_description:
285         parser.description = cmd.help
286
287
288 def _print_error_message(cli_err):
289     errmsg = '%s' % cli_err
290     if cli_err.importance == 1:
291         errmsg = magenta(errmsg)
292     elif cli_err.importance == 2:
293         errmsg = yellow(errmsg)
294     elif cli_err.importance > 2:
295         errmsg = red(errmsg)
296     stdout.write(errmsg)
297     print_list(cli_err.details)
298
299
300 def _get_best_match_from_cmd_tree(cmd_tree, unparsed):
301     matched = [term for term in unparsed if not term.startswith('-')]
302     while matched:
303         try:
304             return cmd_tree.get_command('_'.join(matched))
305         except KeyError:
306             matched = matched[:-1]
307     return None
308
309
310 def _exec_cmd(instance, cmd_args, help_method):
311     try:
312         return instance.main(*cmd_args)
313     except TypeError as err:
314         if err.args and err.args[0].startswith('main()'):
315             print(magenta('Syntax error'))
316             if _debug:
317                 raise err
318             if _verbose:
319                 print(unicode(err))
320             help_method()
321         else:
322             raise
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 def run_shell(exe_string, arguments):
383     from command_shell import _init_shell
384     shell = _init_shell(exe_string, arguments)
385     #  Load all commands in shell CommandTree
386     _config = arguments['config']
387     for spec in [spec for spec in _config.get_groups()\
388             if _config.get(spec, 'cli')]:
389         try:
390             spec_module = _load_spec_module(spec, arguments, '_commands')
391             spec_commands = getattr(spec_module, '_commands')
392         except AttributeError:
393             if _debug:
394                 print('Warning: No valid description for %s' % spec)
395             continue
396         for spec_tree in spec_commands:
397             if spec_tree.name == spec:
398                 shell.cmd_tree.add_tree(spec_tree)
399                 break
400     shell.run()
401
402
403 def main():
404     try:
405         exe = basename(argv[0])
406         parser = init_parser(exe, _arguments)
407         parsed, unparsed = parse_known_args(parser, _arguments)
408
409         if _arguments['version'].value:
410             exit(0)
411
412         _init_session(_arguments)
413
414         if unparsed:
415             _history = History(_arguments['config'].get('history', 'file'))
416             _history.add(' '.join([exe] + argv[1:]))
417             one_cmd(parser, unparsed, _arguments)
418         elif _help:
419             parser.print_help()
420             _groups_help(_arguments)
421         else:
422             run_shell(exe, _arguments)
423     except CLIError as err:
424         if _debug:
425             raise err
426         _print_error_message(err)
427         exit(1)
428     except Exception as err:
429         if _debug:
430             raise err
431         print('Unknown Error: %s' % err)