Merge branch 'hotfix-0.12.10'
[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, stderr
36 from os.path import basename, exists
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, CLICmdSpecError
43 from kamaki.cli import logger
44 from kamaki.clients.astakos import CachedAstakosClient
45 from kamaki.clients import ClientError
46
47 _help = False
48 _debug = False
49 _verbose = False
50 _colors = False
51 kloger = None
52 filelog = None
53
54 #  command auxiliary methods
55
56 _best_match = []
57
58
59 def _arg2syntax(arg):
60     return arg.replace(
61         '____', '[:').replace(
62             '___', ':').replace(
63                 '__', ']').replace(
64                     '_', ' ')
65
66
67 def _construct_command_syntax(cls):
68         spec = getargspec(cls.main.im_func)
69         args = spec.args[1:]
70         n = len(args) - len(spec.defaults or ())
71         required = ' '.join(['<%s>' % _arg2syntax(x) for x in args[:n]])
72         optional = ' '.join(['[%s]' % _arg2syntax(x) for x in args[n:]])
73         cls.syntax = ' '.join([required, optional])
74         if spec.varargs:
75             cls.syntax += ' <%s ...>' % spec.varargs
76
77
78 def _num_of_matching_terms(basic_list, attack_list):
79     if not attack_list:
80         return len(basic_list)
81
82     matching_terms = 0
83     for i, term in enumerate(basic_list):
84         try:
85             if term != attack_list[i]:
86                 break
87         except IndexError:
88             break
89         matching_terms += 1
90     return matching_terms
91
92
93 def _update_best_match(name_terms, prefix=[]):
94     if prefix:
95         pref_list = prefix if isinstance(prefix, list) else prefix.split('_')
96     else:
97         pref_list = []
98
99     num_of_matching_terms = _num_of_matching_terms(name_terms, pref_list)
100     global _best_match
101     if not prefix:
102         _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         e.g., spec_cmd0_cmd1 will be command spec cmd0
114
115         :param cmd_tree: is initialized in cmd_spec file and is the structure
116             where commands are loaded. Var name should be _commands
117         :param prefix: if given, load only commands prefixed with prefix,
118         :param descedants_depth: is the depth of the tree descedants of the
119             prefix command. It is used ONLY if prefix and if prefix is not
120             a terminal command
121
122         :returns: the specified class object
123     """
124
125     def wrap(cls):
126         global kloger
127         cls_name = cls.__name__
128
129         if not cmd_tree:
130             if _debug:
131                 kloger.warning('command %s found but not loaded' % cls_name)
132             return cls
133
134         name_terms = cls_name.split('_')
135         if not _update_best_match(name_terms, prefix):
136             if _debug:
137                 kloger.warning('%s failed to update_best_match' % cls_name)
138             return None
139
140         global _best_match
141         max_len = len(_best_match) + descedants_depth
142         if len(name_terms) > max_len:
143             partial = '_'.join(name_terms[:max_len])
144             if not cmd_tree.has_command(partial):  # add partial path
145                 cmd_tree.add_command(partial)
146             if _debug:
147                 kloger.warning('%s failed max_len test' % cls_name)
148             return None
149
150         try:
151             (
152                 cls.description, sep, cls.long_description
153             ) = cls.__doc__.partition('\n')
154         except AttributeError:
155             raise CLICmdSpecError(
156                 'No commend in %s (acts as cmd description)' % cls.__name__)
157         _construct_command_syntax(cls)
158
159         cmd_tree.add_command(
160             cls_name, cls.description, cls, cls.long_description)
161         return cls
162     return wrap
163
164
165 cmd_spec_locations = [
166     'kamaki.cli.commands',
167     'kamaki.commands',
168     'kamaki.cli',
169     'kamaki',
170     '']
171
172
173 #  Generic init auxiliary functions
174
175
176 def _setup_logging(silent=False, debug=False, verbose=False):
177     """handle logging for clients package"""
178
179     if silent:
180         logger.add_stream_logger(__name__, logging.CRITICAL)
181         return
182
183     sfmt, rfmt = '> %(message)s', '< %(message)s'
184     if debug:
185         print('Logging location: %s' % logger.get_log_filename())
186         logger.add_stream_logger('kamaki.clients.send', logging.DEBUG, sfmt)
187         logger.add_stream_logger('kamaki.clients.recv', logging.DEBUG, rfmt)
188         logger.add_stream_logger(__name__, logging.DEBUG)
189     elif verbose:
190         logger.add_stream_logger('kamaki.clients.send', logging.INFO, sfmt)
191         logger.add_stream_logger('kamaki.clients.recv', logging.INFO, rfmt)
192         logger.add_stream_logger(__name__, logging.INFO)
193     logger.add_stream_logger(__name__, logging.WARNING)
194     global kloger
195     kloger = logger.get_logger(__name__)
196
197
198 def _check_config_version(cnf):
199     guess = cnf.guess_version()
200     if exists(cnf.path) and guess < 0.12:
201         print('Config file format version >= 0.12 is required (%s found)' % (
202             guess))
203         print('Configuration file: %s' % cnf.path)
204         print('Attempting to fix this:')
205         print('Calculating changes while preserving information')
206         lost_terms = cnf.rescue_old_file()
207         print('... DONE')
208         if lost_terms:
209             print 'The following information will NOT be preserved:'
210             print '\t', '\n\t'.join(lost_terms)
211         print('Kamaki is ready to convert the config file')
212         stdout.write('Create (overwrite) file %s ? [y/N] ' % cnf.path)
213         from sys import stdin
214         reply = stdin.readline()
215         if reply in ('Y\n', 'y\n'):
216             cnf.write()
217             print('... DONE')
218         else:
219             print('... ABORTING')
220             raise CLIError(
221                 'Invalid format for config file %s' % cnf.path,
222                 importance=3, details=[
223                     'Please, update config file',
224                     'For automatic conversion, rerun and say Y'])
225
226
227 def _init_session(arguments, is_non_API=False):
228     """
229     :returns: cloud name
230     """
231     global _help
232     _help = arguments['help'].value
233     global _debug
234     _debug = arguments['debug'].value
235     global _verbose
236     _verbose = arguments['verbose'].value
237     _cnf = arguments['config']
238
239     _silent = arguments['silent'].value
240     _setup_logging(_silent, _debug, _verbose)
241
242     if _help or is_non_API:
243         return None
244
245     _check_config_version(_cnf.value)
246
247     global _colors
248     _colors = _cnf.value.get('global', 'colors')
249     if not (stdout.isatty() and _colors == 'on'):
250         from kamaki.cli.utils import remove_colors
251         remove_colors()
252
253     cloud = arguments['cloud'].value or _cnf.value.get(
254         'global', 'default_cloud')
255     if not cloud:
256         num_of_clouds = len(_cnf.value.keys('cloud'))
257         if num_of_clouds == 1:
258             cloud = _cnf.value.keys('cloud')[0]
259         elif num_of_clouds > 1:
260             raise CLIError(
261                 'Found %s clouds but none of them is set as default' % (
262                     num_of_clouds),
263                 importance=2, details=[
264                     'Please, choose one of the following cloud names:',
265                     ', '.join(_cnf.value.keys('cloud')),
266                     'To see all cloud settings:',
267                     '  kamaki config get cloud.<cloud name>',
268                     'To set a default cloud:',
269                     '  kamaki config set default_cloud <cloud name>',
270                     'To pick a cloud for the current session, use --cloud:',
271                     '  kamaki --cloud=<cloud name> ...'])
272     if not cloud in _cnf.value.keys('cloud'):
273         raise CLIError(
274             'No cloud%s is configured' % ((' "%s"' % cloud) if cloud else ''),
275             importance=3, details=[
276                 'To configure a new cloud "%s", find and set the' % (
277                     cloud or '<cloud name>'),
278                 'single authentication URL and token:',
279                 '  kamaki config set cloud.%s.url <URL>' % (
280                     cloud or '<cloud name>'),
281                 '  kamaki config set cloud.%s.token <t0k3n>' % (
282                     cloud or '<cloud name>')])
283     auth_args = dict()
284     for term in ('url', 'token'):
285         try:
286             auth_args[term] = _cnf.get_cloud(cloud, term)
287         except KeyError or IndexError:
288             auth_args[term] = ''
289         if not auth_args[term]:
290             raise CLIError(
291                 'No authentication %s provided for cloud "%s"' % (
292                     term.upper(), cloud),
293                 importance=3, details=[
294                     'Set a %s for cloud %s:' % (term.upper(), cloud),
295                     '  kamaki config set cloud.%s.%s <%s>' % (
296                         cloud, term, term.upper())])
297     return cloud
298
299
300 def init_cached_authenticator(config_argument, cloud, logger):
301     try:
302         _cnf = config_argument.value
303         url = _cnf.get_cloud(cloud, 'url')
304         tokens = _cnf.get_cloud(cloud, 'token').split()
305         auth_base, failed = None, []
306         for token in tokens:
307             try:
308                 if auth_base:
309                     auth_base.authenticate(token)
310                 else:
311                     tmp_base = CachedAstakosClient(url, token)
312                     from kamaki.cli.commands import _command_init
313                     fake_cmd = _command_init(dict(config=config_argument))
314                     fake_cmd.client = auth_base
315                     fake_cmd._set_log_params()
316                     tmp_base.authenticate(token)
317                     auth_base = tmp_base
318             except ClientError as ce:
319                 if ce.status in (401, ):
320                     logger.warning(
321                         'WARNING: Failed to authenticate token %s' % token)
322                     failed.append(token)
323                 else:
324                     raise
325         for token in failed:
326             r = raw_input(
327                 'Token %s failed to authenticate. Remove it? [y/N]: ' % token)
328             if r in ('y', 'Y'):
329                 tokens.remove(token)
330         if set(failed).difference(tokens):
331             _cnf.set_cloud(cloud, 'token', ' '.join(tokens))
332             _cnf.write()
333         if tokens:
334             return auth_base
335         logger.warning('WARNING: cloud.%s.token is now empty' % cloud)
336     except AssertionError as ae:
337         logger.warning('WARNING: Failed to load authenticator [%s]' % ae)
338     return None
339
340
341 def _load_spec_module(spec, arguments, module):
342     global kloger
343     if not spec:
344         return None
345     pkg = None
346     for location in cmd_spec_locations:
347         location += spec if location == '' else '.%s' % spec
348         try:
349             kloger.debug('Import %s from %s' % ([module], location))
350             pkg = __import__(location, fromlist=[module])
351             kloger.debug('\t...OK')
352             return pkg
353         except ImportError as ie:
354             kloger.debug('\t...Failed')
355             continue
356     if not pkg:
357         msg = 'Loading command group %s failed: %s' % (spec, ie)
358         msg += '\nHINT: use a text editor to remove all global.*_cli'
359         msg += '\n\tsettings from the configuration file'
360         kloger.debug(msg)
361     return pkg
362
363
364 def _groups_help(arguments):
365     global _debug
366     global kloger
367     descriptions = {}
368     acceptable_groups = arguments['config'].groups
369     for cmd_group, spec in arguments['config'].cli_specs:
370         pkg = _load_spec_module(spec, arguments, '_commands')
371         if pkg:
372             cmds = getattr(pkg, '_commands')
373             try:
374                 for cmd_tree in cmds:
375                     if cmd_tree.name in acceptable_groups:
376                         descriptions[cmd_tree.name] = cmd_tree.description
377             except TypeError:
378                 if _debug:
379                     kloger.warning(
380                         'No cmd description (help) for module %s' % cmd_group)
381         elif _debug:
382             kloger.warning('Loading of %s cmd spec failed' % cmd_group)
383     print('\nOptions:\n - - - -')
384     print_dict(descriptions)
385
386
387 def _load_all_commands(cmd_tree, arguments):
388     _cnf = arguments['config']
389     for cmd_group, spec in _cnf.cli_specs:
390         try:
391             spec_module = _load_spec_module(spec, arguments, '_commands')
392             spec_commands = getattr(spec_module, '_commands')
393         except AttributeError:
394             if _debug:
395                 global kloger
396                 kloger.warning('No valid description for %s' % cmd_group)
397             continue
398         for spec_tree in spec_commands:
399             if spec_tree.name == cmd_group:
400                 cmd_tree.add_tree(spec_tree)
401                 break
402
403
404 #  Methods to be used by CLI implementations
405
406
407 def print_subcommands_help(cmd):
408     printout = {}
409     for subcmd in cmd.subcommands.values():
410         spec, sep, print_path = subcmd.path.partition('_')
411         printout[print_path.replace('_', ' ')] = subcmd.help
412     if printout:
413         print('\nOptions:\n - - - -')
414         print_dict(printout)
415
416
417 def update_parser_help(parser, cmd):
418     global _best_match
419     parser.syntax = parser.syntax.split('<')[0]
420     parser.syntax += ' '.join(_best_match)
421
422     description = ''
423     if cmd.is_command:
424         cls = cmd.cmd_class
425         parser.syntax += ' ' + cls.syntax
426         parser.update_arguments(cls().arguments)
427         description = getattr(cls, 'long_description', '').strip()
428     else:
429         parser.syntax += ' <...>'
430     parser.parser.description = (
431         cmd.help + ('\n' if description else '')) if cmd.help else description
432
433
434 def print_error_message(cli_err, out=stderr):
435     errmsg = '%s' % cli_err
436     if cli_err.importance == 1:
437         errmsg = magenta(errmsg)
438     elif cli_err.importance == 2:
439         errmsg = yellow(errmsg)
440     elif cli_err.importance > 2:
441         errmsg = red(errmsg)
442     out.write(errmsg)
443     for errmsg in cli_err.details:
444         out.write('|  %s\n' % errmsg)
445         out.flush()
446
447
448 def exec_cmd(instance, cmd_args, help_method):
449     try:
450         return instance.main(*cmd_args)
451     except TypeError as err:
452         if err.args and err.args[0].startswith('main()'):
453             print(magenta('Syntax error'))
454             if _debug:
455                 raise err
456             if _verbose:
457                 print(unicode(err))
458             help_method()
459         else:
460             raise
461     return 1
462
463
464 def get_command_group(unparsed, arguments):
465     groups = arguments['config'].groups
466     for term in unparsed:
467         if term.startswith('-'):
468             continue
469         if term in groups:
470             unparsed.remove(term)
471             return term
472         return None
473     return None
474
475
476 def set_command_params(parameters):
477     """Add a parameters list to a command
478
479     :param paramters: (list of str) a list of parameters
480     """
481     global command
482     def_params = list(command.func_defaults)
483     def_params[0] = parameters
484     command.func_defaults = tuple(def_params)
485
486
487 #  CLI Choice:
488
489 def is_non_API(parser):
490     nonAPIs = ('history', 'config')
491     for term in parser.unparsed:
492         if not term.startswith('-'):
493             if term in nonAPIs:
494                 return True
495             return False
496     return False
497
498
499 def main(func):
500     def wrap():
501         try:
502             exe = basename(argv[0])
503             parser = ArgumentParseManager(exe)
504
505             if parser.arguments['version'].value:
506                 exit(0)
507
508             _cnf = parser.arguments['config']
509             log_file = _cnf.get('global', 'log_file')
510             if log_file:
511                 logger.set_log_filename(log_file)
512             global filelog
513             filelog = logger.add_file_logger(__name__.split('.')[0])
514             filelog.info('* Initial Call *\n%s\n- - -' % ' '.join(argv))
515
516             from kamaki.cli.utils import suggest_missing
517             global _colors
518             exclude = ['ansicolors'] if not _colors == 'on' else []
519             suggest_missing(exclude=exclude)
520             func(exe, parser)
521         except CLIError as err:
522             print_error_message(err)
523             if _debug:
524                 raise err
525             exit(1)
526         except KeyboardInterrupt:
527             print('Canceled by user')
528             exit(1)
529         except Exception as er:
530             print('Unknown Error: %s' % er)
531             if _debug:
532                 raise
533             exit(1)
534     return wrap
535
536
537 @main
538 def run_shell(exe, parser):
539     parser.arguments['help'].value = False
540     cloud = _init_session(parser.arguments)
541     from command_shell import _init_shell
542     global kloger
543     _cnf = parser.arguments['config']
544     auth_base = init_cached_authenticator(_cnf, cloud, kloger)
545     try:
546         username, userid = (
547             auth_base.user_term('name'), auth_base.user_term('id'))
548     except Exception:
549         username, userid = '', ''
550     shell = _init_shell(exe, parser, username, userid)
551     _load_all_commands(shell.cmd_tree, parser.arguments)
552     shell.run(auth_base, cloud, parser)
553
554
555 @main
556 def run_one_cmd(exe, parser):
557     cloud = _init_session(parser.arguments, is_non_API(parser))
558     if parser.unparsed:
559         global _history
560         try:
561             token = parser.arguments['config'].get_cloud(
562                 cloud, 'token').split()[0]
563         except Exception:
564             token = None
565         _history = History(
566             parser.arguments['config'].get('global', 'history_file'),
567             token=token)
568         _history.add(' '.join([exe] + argv[1:]))
569         from kamaki.cli import one_command
570         one_command.run(cloud, parser, _help)
571     else:
572         parser.print_help()
573         _groups_help(parser.arguments)
574         print('kamaki-shell: An interactive command line shell')