83795b24d91c50367ed30427c2718a0cbe12ba4d
[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 AstakosClient as AuthCachedClient
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(x for x in [required, optional] if x)
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.9:
201         print('Config file format version >= 9.0 is required')
202         print('Configuration file: %s' % cnf.path)
203         print('Attempting to fix this:')
204         print('Calculating changes while preserving information')
205         lost_terms = cnf.rescue_old_file()
206         print('... DONE')
207         if lost_terms:
208             print 'The following information will NOT be preserved:'
209             print '\t', '\n\t'.join(lost_terms)
210         print('Kamaki is ready to convert the config file')
211         stdout.write('Create (overwrite) file %s ? [y/N] ' % cnf.path)
212         from sys import stdin
213         reply = stdin.readline()
214         if reply in ('Y\n', 'y\n'):
215             cnf.write()
216             print('... DONE')
217         else:
218             print('... ABORTING')
219             raise CLIError(
220                 'Invalid format for config file %s' % cnf.path,
221                 importance=3, details=[
222                     'Please, update config file',
223                     'For automatic conversion, rerun and say Y'])
224
225
226 def _init_session(arguments, is_non_API=False):
227     """
228     :returns: cloud name
229     """
230     global _help
231     _help = arguments['help'].value
232     global _debug
233     _debug = arguments['debug'].value
234     global _verbose
235     _verbose = arguments['verbose'].value
236     _cnf = arguments['config']
237
238     _silent = arguments['silent'].value
239     _setup_logging(_silent, _debug, _verbose)
240
241     if _help or is_non_API:
242         return None
243
244     _check_config_version(_cnf.value)
245
246     global _colors
247     _colors = _cnf.value.get('global', 'colors')
248     if not (stdout.isatty() and _colors == 'on'):
249         from kamaki.cli.utils import remove_colors
250         remove_colors()
251
252     cloud = arguments['cloud'].value or _cnf.value.get(
253         'global', 'default_cloud')
254     if not cloud:
255         num_of_clouds = len(_cnf.value.keys('cloud'))
256         if num_of_clouds == 1:
257             cloud = _cnf.value.keys('cloud')[0]
258         elif num_of_clouds > 1:
259             raise CLIError(
260                 'Found %s clouds but none of them is set as default' % (
261                     num_of_clouds),
262                 importance=2, details=[
263                     'Please, choose one of the following cloud names:',
264                     ', '.join(_cnf.value.keys('cloud')),
265                     'To see all cloud settings:',
266                     '  kamaki config get cloud.<cloud name>',
267                     'To set a default cloud:',
268                     '  kamaki config set default_cloud <cloud name>',
269                     'To pick a cloud for the current session, use --cloud:',
270                     '  kamaki --cloud=<cloud name> ...'])
271     if not cloud in _cnf.value.keys('cloud'):
272         raise CLIError(
273             'No cloud%s is configured' % ((' "%s"' % cloud) if cloud else ''),
274             importance=3, details=[
275                 'To configure a new cloud "%s", find and set the' % (
276                     cloud or '<cloud name>'),
277                 'single authentication URL and token:',
278                 '  kamaki config set cloud.%s.url <URL>' % (
279                     cloud or '<cloud name>'),
280                 '  kamaki config set cloud.%s.token <t0k3n>' % (
281                     cloud or '<cloud name>')])
282     auth_args = dict()
283     for term in ('url', 'token'):
284         try:
285             auth_args[term] = _cnf.get_cloud(cloud, term)
286         except KeyError or IndexError:
287             auth_args[term] = ''
288         if not auth_args[term]:
289             raise CLIError(
290                 'No authentication %s provided for cloud "%s"' % (
291                     term.upper(), cloud),
292                 importance=3, details=[
293                     'Set a %s for cloud %s:' % (term.upper(), cloud),
294                     '  kamaki config set cloud.%s.%s <%s>' % (
295                         cloud, term, term.upper())])
296     return cloud
297
298
299 def init_cached_authenticator(config_argument, cloud, logger):
300     try:
301         _cnf = config_argument.value
302         url = _cnf.get_cloud(cloud, 'url')
303         tokens = _cnf.get_cloud(cloud, 'token').split()
304         auth_base, failed = None, []
305         for token in tokens:
306             try:
307                 if auth_base:
308                     auth_base.authenticate(token)
309                 else:
310                     tmp_base = AuthCachedClient(url, token)
311                     from kamaki.cli.commands import _command_init
312                     fake_cmd = _command_init(dict(config=config_argument))
313                     fake_cmd.client = auth_base
314                     fake_cmd._set_log_params()
315                     fake_cmd._update_max_threads()
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 run_one_cmd(exe_string, parser, cloud):
490     global _history
491     try:
492         token = parser.arguments['config'].get_cloud(cloud, 'token').split()[0]
493     except Exception:
494         token = None
495     _history = History(
496         parser.arguments['config'].get('global', 'history_file'), token=token)
497     _history.add(' '.join([exe_string] + argv[1:]))
498     from kamaki.cli import one_command
499     one_command.run(cloud, parser, _help)
500
501
502 def run_shell(exe_string, parser, cloud):
503     from command_shell import _init_shell
504     global kloger
505     _cnf = parser.arguments['config']
506     auth_base = init_cached_authenticator(_cnf, cloud, kloger)
507     try:
508         username, userid = (
509             auth_base.user_term('name'), auth_base.user_term('id'))
510     except Exception:
511         username, userid = '', ''
512     shell = _init_shell(exe_string, parser, username, userid)
513     _load_all_commands(shell.cmd_tree, parser.arguments)
514     shell.run(auth_base, cloud, parser)
515
516
517 def is_non_API(parser):
518     nonAPIs = ('history', 'config')
519     for term in parser.unparsed:
520         if not term.startswith('-'):
521             if term in nonAPIs:
522                 return True
523             return False
524     return False
525
526
527 def main():
528     try:
529         exe = basename(argv[0])
530         parser = ArgumentParseManager(exe)
531
532         if parser.arguments['version'].value:
533             exit(0)
534
535         _cnf = parser.arguments['config']
536         log_file = _cnf.get('global', 'log_file')
537         if log_file:
538             logger.set_log_filename(log_file)
539         global filelog
540         filelog = logger.add_file_logger(__name__.split('.')[0])
541         filelog.info('* Initial Call *\n%s\n- - -' % ' '.join(argv))
542
543         cloud = _init_session(parser.arguments, is_non_API(parser))
544         from kamaki.cli.utils import suggest_missing
545         global _colors
546         exclude = ['ansicolors'] if not _colors == 'on' else []
547         suggest_missing(exclude=exclude)
548
549         if parser.unparsed:
550             run_one_cmd(exe, parser, cloud)
551         elif _help:
552             parser.parser.print_help()
553             _groups_help(parser.arguments)
554         else:
555             run_shell(exe, parser, cloud)
556     except CLIError as err:
557         print_error_message(err)
558         if _debug:
559             raise err
560         exit(1)
561     except Exception as er:
562         print('Unknown Error: %s' % er)
563         if _debug:
564             raise
565         exit(1)