f2c2f687257836f0f4369876308cd2effa42604e
[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 ArgumentParseManager
40 from kamaki.cli.history import History
41 from kamaki.cli.utils import print_dict, red, magenta, yellow
42 from kamaki.cli.errors import CLIError
43 from kamaki.cli import logger
44
45 _help = False
46 _debug = False
47 _include = False
48 _verbose = False
49 _colors = False
50 kloger = None
51 filelog = None
52
53 #  command auxiliary methods
54
55 _best_match = []
56
57
58 def _arg2syntax(arg):
59     return arg.replace(
60         '____', '[:').replace(
61             '___', ':').replace(
62                 '__', ']').replace(
63                     '_', ' ')
64
65
66 def _construct_command_syntax(cls):
67         spec = getargspec(cls.main.im_func)
68         args = spec.args[1:]
69         n = len(args) - len(spec.defaults or ())
70         required = ' '.join(['<%s>' % _arg2syntax(x) for x in args[:n]])
71         optional = ' '.join(['[%s]' % _arg2syntax(x) for x in args[n:]])
72         cls.syntax = ' '.join(x for x in [required, optional] if x)
73         if spec.varargs:
74             cls.syntax += ' <%s ...>' % spec.varargs
75
76
77 def _num_of_matching_terms(basic_list, attack_list):
78     if not attack_list:
79         return len(basic_list)
80
81     matching_terms = 0
82     for i, term in enumerate(basic_list):
83         try:
84             if term != attack_list[i]:
85                 break
86         except IndexError:
87             break
88         matching_terms += 1
89     return matching_terms
90
91
92 def _update_best_match(name_terms, prefix=[]):
93     if prefix:
94         pref_list = prefix if isinstance(prefix, list) else prefix.split('_')
95     else:
96         pref_list = []
97
98     num_of_matching_terms = _num_of_matching_terms(name_terms, pref_list)
99     global _best_match
100     if not prefix:
101         _best_match = []
102
103     if num_of_matching_terms and len(_best_match) <= num_of_matching_terms:
104         if len(_best_match) < num_of_matching_terms:
105             _best_match = name_terms[:num_of_matching_terms]
106         return True
107     return False
108
109
110 def command(cmd_tree, prefix='', descedants_depth=1):
111     """Load a class as a command
112         e.g. spec_cmd0_cmd1 will be command spec cmd0
113
114         :param 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         :returns: the specified class object
122     """
123
124     def wrap(cls):
125         global kloger
126         cls_name = cls.__name__
127
128         if not cmd_tree:
129             if _debug:
130                 kloger.warning('command %s found but not loaded' % cls_name)
131             return cls
132
133         name_terms = cls_name.split('_')
134         if not _update_best_match(name_terms, prefix):
135             if _debug:
136                 kloger.warning('%s failed to update_best_match' % cls_name)
137             return None
138
139         global _best_match
140         max_len = len(_best_match) + descedants_depth
141         if len(name_terms) > max_len:
142             partial = '_'.join(name_terms[:max_len])
143             if not cmd_tree.has_command(partial):  # add partial path
144                 cmd_tree.add_command(partial)
145             if _debug:
146                 kloger.warning('%s failed max_len test' % cls_name)
147             return None
148
149         (
150             cls.description, sep, cls.long_description
151         ) = cls.__doc__.partition('\n')
152         _construct_command_syntax(cls)
153
154         cmd_tree.add_command(cls_name, cls.description, cls)
155         return cls
156     return wrap
157
158
159 cmd_spec_locations = [
160     'kamaki.cli.commands',
161     'kamaki.commands',
162     'kamaki.cli',
163     'kamaki',
164     '']
165
166
167 #  Generic init auxiliary functions
168
169
170 def _setup_logging(silent=False, debug=False, verbose=False, include=False):
171     """handle logging for clients package"""
172
173     if silent:
174         logger.add_stream_logger(__name__, logging.CRITICAL)
175         return
176
177     sfmt, rfmt = '> %(message)s', '< %(message)s'
178     if debug:
179         print('Logging location: %s' % logger.get_log_filename())
180         logger.add_stream_logger('kamaki.clients.send', logging.DEBUG, sfmt)
181         logger.add_stream_logger('kamaki.clients.recv', logging.DEBUG, rfmt)
182         logger.add_stream_logger(__name__, logging.DEBUG)
183     elif verbose:
184         logger.add_stream_logger('kamaki.clients.send', logging.INFO, sfmt)
185         logger.add_stream_logger('kamaki.clients.recv', logging.INFO, rfmt)
186         logger.add_stream_logger(__name__, logging.INFO)
187     if include:
188         logger.add_stream_logger('kamaki.clients.send', logging.INFO, sfmt)
189         logger.add_stream_logger('kamaki.clients.recv', logging.INFO, rfmt)
190     logger.add_stream_logger(__name__, logging.WARNING)
191     global kloger
192     kloger = logger.get_logger(__name__)
193
194
195 def _check_config_version(cnf):
196     guess = cnf.guess_version()
197     if guess < 0.9:
198         print('Config file format version >= 9.0 is required')
199         print('Configuration file "%s" format is not up to date' % (
200             cnf.path))
201         print('but kamaki can fix this:')
202         print('Calculating changes while preserving information')
203         lost_terms = cnf.rescue_old_file()
204         print('... DONE')
205         if lost_terms:
206             print 'The following information will NOT be preserved:'
207             print '\t', '\n\t'.join(lost_terms)
208         print('Kamaki is ready to convert the config file to version 3.0')
209         stdout.write('Create (overwrite) file %s ? [y/N] ' % cnf.path)
210         from sys import stdin
211         reply = stdin.readline()
212         if reply in ('Y\n', 'y\n'):
213             cnf.write()
214             print('... DONE')
215         else:
216             print('... ABORTING')
217             raise CLIError(
218                 'Invalid format for config file %s' % cnf.path,
219                 importance=3, details=[
220                     'Please, update config file to v3.0',
221                     'For automatic conversion, rerun and say Y'])
222
223
224 def _init_session(arguments, is_non_API=False):
225     """
226     :returns: (AuthCachedClient, str) authenticator and cloud name
227     """
228     global _help
229     _help = arguments['help'].value
230     global _debug
231     _debug = arguments['debug'].value
232     global _include
233     _include = arguments['include'].value
234     global _verbose
235     _verbose = arguments['verbose'].value
236     _cnf = arguments['config']
237     _check_config_version(_cnf.value)
238
239     global _colors
240     _colors = _cnf.value.get_global('colors')
241     if not (stdout.isatty() and _colors == 'on'):
242         from kamaki.cli.utils import remove_colors
243         remove_colors()
244     _silent = arguments['silent'].value
245     _setup_logging(_silent, _debug, _verbose, _include)
246
247     if _help or is_non_API:
248         return None, None
249
250     cloud = arguments['cloud'].value or _cnf.value.get(
251         'global', 'default_cloud')
252     if not cloud:
253         num_of_clouds = len(_cnf.value.keys('cloud'))
254         if num_of_clouds == 1:
255             cloud = _cnf.value.keys('cloud')[0]
256         else:
257             raise CLIError(
258                 'Found %s clouds but none of them is set as default',
259                 importance=2, details=[
260                     'Please, choose one of the following cloud names:',
261                     ', '.join(_cnf.value.keys('cloud')),
262                     'To set a default cloud:',
263                     '  kamaki config set default_cloud <cloud name>',
264                     'To pick a cloud for the current session, use --cloud:',
265                     '  kamaki --cloud=<cloud name> ...'])
266     if not cloud in _cnf.value.keys('cloud'):
267         raise CLIError(
268             'No cloud "%s" is configured' % cloud,
269             importance=3, details=[
270                 'To configure a new cloud, find and set the',
271                 'single authentication URL and token:',
272                 '  kamaki config set cloud.%s.url <URL>' % cloud,
273                 '  kamaki config set cloud.%s.token <t0k3n>' % cloud])
274     auth_args = dict()
275     for term in ('url', 'token'):
276         try:
277             auth_args[term] = _cnf.get_cloud(cloud, term)
278         except KeyError:
279             auth_args[term] = ''
280         if not auth_args[term]:
281             raise CLIError(
282                 'No authentication %s provided for cloud "%s"' % (term, cloud),
283                 importance=3, details=[
284                     'Set a %s for cloud %s:' % (term, cloud),
285                     '  kamaki config set cloud.%s.%s <t0k3n>' % (cloud, term)])
286
287     from kamaki.clients.astakos import AstakosClient as AuthCachedClient
288     try:
289         return AuthCachedClient(auth_args['url'], auth_args['token']), cloud
290     except AssertionError as ae:
291         kloger.warning('WARNING: Failed to load authenticator [%s]' % ae)
292         return None, cloud
293
294
295 def _load_spec_module(spec, arguments, module):
296     if not spec:
297         return None
298     pkg = None
299     for location in cmd_spec_locations:
300         location += spec if location == '' else '.%s' % spec
301         try:
302             pkg = __import__(location, fromlist=[module])
303             return pkg
304         except ImportError as ie:
305             continue
306     if not pkg:
307         kloger.debug('Loading cmd grp %s failed: %s' % (spec, ie))
308     return pkg
309
310
311 def _groups_help(arguments):
312     global _debug
313     global kloger
314     descriptions = {}
315     for cmd_group, spec in arguments['config'].get_cli_specs():
316         pkg = _load_spec_module(spec, arguments, '_commands')
317         if pkg:
318             cmds = getattr(pkg, '_commands')
319             try:
320                 for cmd in cmds:
321                     descriptions[cmd.name] = cmd.description
322             except TypeError:
323                 if _debug:
324                     kloger.warning(
325                         'No cmd description for module %s' % cmd_group)
326         elif _debug:
327             kloger.warning('Loading of %s cmd spec failed' % cmd_group)
328     print('\nOptions:\n - - - -')
329     print_dict(descriptions)
330
331
332 def _load_all_commands(cmd_tree, arguments):
333     _cnf = arguments['config']
334     for cmd_group, spec in _cnf.get_cli_specs():
335         try:
336             spec_module = _load_spec_module(spec, arguments, '_commands')
337             spec_commands = getattr(spec_module, '_commands')
338         except AttributeError:
339             if _debug:
340                 global kloger
341                 kloger.warning('No valid description for %s' % cmd_group)
342             continue
343         for spec_tree in spec_commands:
344             if spec_tree.name == cmd_group:
345                 cmd_tree.add_tree(spec_tree)
346                 break
347
348
349 #  Methods to be used by CLI implementations
350
351
352 def print_subcommands_help(cmd):
353     printout = {}
354     for subcmd in cmd.get_subcommands():
355         spec, sep, print_path = subcmd.path.partition('_')
356         printout[print_path.replace('_', ' ')] = subcmd.description
357     if printout:
358         print('\nOptions:\n - - - -')
359         print_dict(printout)
360
361
362 def update_parser_help(parser, cmd):
363     global _best_match
364     parser.syntax = parser.syntax.split('<')[0]
365     parser.syntax += ' '.join(_best_match)
366
367     description = ''
368     if cmd.is_command:
369         cls = cmd.get_class()
370         parser.syntax += ' ' + cls.syntax
371         parser.update_arguments(cls().arguments)
372         description = getattr(cls, 'long_description', '')
373         description = description.strip()
374     else:
375         parser.syntax += ' <...>'
376     if cmd.has_description:
377         parser.parser.description = cmd.help + (
378             ('\n%s' % description) if description else '')
379     else:
380         parser.parser.description = description
381
382
383 def print_error_message(cli_err):
384     errmsg = '%s' % cli_err
385     if cli_err.importance == 1:
386         errmsg = magenta(errmsg)
387     elif cli_err.importance == 2:
388         errmsg = yellow(errmsg)
389     elif cli_err.importance > 2:
390         errmsg = red(errmsg)
391     stdout.write(errmsg)
392     for errmsg in cli_err.details:
393         print('|  %s' % errmsg)
394
395
396 def exec_cmd(instance, cmd_args, help_method):
397     try:
398         return instance.main(*cmd_args)
399     except TypeError as err:
400         if err.args and err.args[0].startswith('main()'):
401             print(magenta('Syntax error'))
402             if _debug:
403                 raise err
404             if _verbose:
405                 print(unicode(err))
406             help_method()
407         else:
408             raise
409     return 1
410
411
412 def get_command_group(unparsed, arguments):
413     groups = arguments['config'].get_groups()
414     for term in unparsed:
415         if term.startswith('-'):
416             continue
417         if term in groups:
418             unparsed.remove(term)
419             return term
420         return None
421     return None
422
423
424 def set_command_params(parameters):
425     """Add a parameters list to a command
426
427     :param paramters: (list of str) a list of parameters
428     """
429     global command
430     def_params = list(command.func_defaults)
431     def_params[0] = parameters
432     command.func_defaults = tuple(def_params)
433
434
435 #  CLI Choice:
436
437 def run_one_cmd(exe_string, parser, auth_base, cloud):
438     global _history
439     _history = History(
440         parser.arguments['config'].get_global('history_file'))
441     _history.add(' '.join([exe_string] + argv[1:]))
442     from kamaki.cli import one_command
443     one_command.run(auth_base, cloud, parser, _help)
444
445
446 def run_shell(exe_string, parser, auth_base, cloud):
447     from command_shell import _init_shell
448     shell = _init_shell(exe_string, parser)
449     _load_all_commands(shell.cmd_tree, parser.arguments)
450     shell.run(auth_base, cloud, parser)
451
452
453 def is_non_API(parser):
454     nonAPIs = ('history', 'config')
455     for term in parser.unparsed:
456         if not term.startswith('-'):
457             if term in nonAPIs:
458                 return True
459             return False
460     return False
461
462
463 def main():
464     try:
465         exe = basename(argv[0])
466         parser = ArgumentParseManager(exe)
467
468         if parser.arguments['version'].value:
469             exit(0)
470
471         log_file = parser.arguments['config'].get_global('log_file')
472         if log_file:
473             logger.set_log_filename(log_file)
474         global filelog
475         filelog = logger.add_file_logger(__name__.split('.')[0])
476         filelog.info('* Initial Call *\n%s\n- - -' % ' '.join(argv))
477
478         auth_base, cloud = _init_session(parser.arguments, is_non_API(parser))
479
480         from kamaki.cli.utils import suggest_missing
481         suggest_missing()
482
483         if parser.unparsed:
484             run_one_cmd(exe, parser, auth_base, cloud)
485         elif _help:
486             parser.parser.print_help()
487             _groups_help(parser.arguments)
488         else:
489             run_shell(exe, parser, auth_base, cloud)
490     except CLIError as err:
491         print_error_message(err)
492         if _debug:
493             raise err
494         exit(1)
495     except Exception as er:
496         print('Unknown Error: %s' % er)
497         if _debug:
498             raise
499         exit(1)