Rename: scenarios-->examples, add first content
[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, 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
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 exists(cnf.path) and guess < 0.9:
198         print('Config file format version >= 9.0 is required')
199         print('Configuration file: %s' % cnf.path)
200         print('Attempting to fix this:')
201         print('Calculating changes while preserving information')
202         lost_terms = cnf.rescue_old_file()
203         print('... DONE')
204         if lost_terms:
205             print 'The following information will NOT be preserved:'
206             print '\t', '\n\t'.join(lost_terms)
207         print('Kamaki is ready to convert the config file')
208         stdout.write('Create (overwrite) file %s ? [y/N] ' % cnf.path)
209         from sys import stdin
210         reply = stdin.readline()
211         if reply in ('Y\n', 'y\n'):
212             cnf.write()
213             print('... DONE')
214         else:
215             print('... ABORTING')
216             raise CLIError(
217                 'Invalid format for config file %s' % cnf.path,
218                 importance=3, details=[
219                     'Please, update config file',
220                     'For automatic conversion, rerun and say Y'])
221
222
223 def _init_session(arguments, is_non_API=False):
224     """
225     :returns: (AuthCachedClient, str) authenticator and cloud name
226     """
227     global _help
228     _help = arguments['help'].value
229     global _debug
230     _debug = arguments['debug'].value
231     global _include
232     _include = arguments['include'].value
233     global _verbose
234     _verbose = arguments['verbose'].value
235     _cnf = arguments['config']
236
237     if _help or is_non_API:
238         return None, None
239
240     _check_config_version(_cnf.value)
241
242     global _colors
243     _colors = _cnf.value.get_global('colors')
244     if not (stdout.isatty() and _colors == 'on'):
245         from kamaki.cli.utils import remove_colors
246         remove_colors()
247     _silent = arguments['silent'].value
248     _setup_logging(_silent, _debug, _verbose, _include)
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         elif num_of_clouds > 1:
257             raise CLIError(
258                 'Found %s clouds but none of them is set as default' % (
259                     num_of_clouds),
260                 importance=2, details=[
261                     'Please, choose one of the following cloud names:',
262                     ', '.join(_cnf.value.keys('cloud')),
263                     'To see all cloud settings:',
264                     '  kamaki config get cloud.<cloud name>',
265                     'To set a default cloud:',
266                     '  kamaki config set default_cloud <cloud name>',
267                     'To pick a cloud for the current session, use --cloud:',
268                     '  kamaki --cloud=<cloud name> ...'])
269     if not cloud in _cnf.value.keys('cloud'):
270         raise CLIError(
271             'No cloud%s is configured' % ((' "%s"' % cloud) if cloud else ''),
272             importance=3, details=[
273                 'To configure a new cloud "%s", find and set the' % (
274                     cloud or '<cloud name>'),
275                 'single authentication URL and token:',
276                 '  kamaki config set cloud.%s.url <URL>' % (
277                     cloud or '<cloud name>'),
278                 '  kamaki config set cloud.%s.token <t0k3n>' % (
279                     cloud or '<cloud name>')])
280     auth_args = dict()
281     for term in ('url', 'token'):
282         try:
283             auth_args[term] = _cnf.get_cloud(cloud, term)
284         except KeyError:
285             auth_args[term] = ''
286         if not auth_args[term]:
287             raise CLIError(
288                 'No authentication %s provided for cloud "%s"' % (
289                     term.upper(), cloud),
290                 importance=3, details=[
291                     'Set a %s for cloud %s:' % (term.upper(), cloud),
292                     '  kamaki config set cloud.%s.%s <%s>' % (
293                         cloud, term, term.upper())])
294
295     from kamaki.clients.astakos import AstakosClient as AuthCachedClient
296     try:
297         return AuthCachedClient(auth_args['url'], auth_args['token']), cloud
298     except AssertionError as ae:
299         kloger.warning('WARNING: Failed to load authenticator [%s]' % ae)
300         return None, cloud
301
302
303 def _load_spec_module(spec, arguments, module):
304     if not spec:
305         return None
306     pkg = None
307     for location in cmd_spec_locations:
308         location += spec if location == '' else '.%s' % spec
309         try:
310             pkg = __import__(location, fromlist=[module])
311             return pkg
312         except ImportError as ie:
313             continue
314     if not pkg:
315         kloger.debug('Loading cmd grp %s failed: %s' % (spec, ie))
316     return pkg
317
318
319 def _groups_help(arguments):
320     global _debug
321     global kloger
322     descriptions = {}
323     for cmd_group, spec in arguments['config'].get_cli_specs():
324         pkg = _load_spec_module(spec, arguments, '_commands')
325         if pkg:
326             cmds = getattr(pkg, '_commands')
327             try:
328                 for cmd in cmds:
329                     descriptions[cmd.name] = cmd.description
330             except TypeError:
331                 if _debug:
332                     kloger.warning(
333                         'No cmd description for module %s' % cmd_group)
334         elif _debug:
335             kloger.warning('Loading of %s cmd spec failed' % cmd_group)
336     print('\nOptions:\n - - - -')
337     print_dict(descriptions)
338
339
340 def _load_all_commands(cmd_tree, arguments):
341     _cnf = arguments['config']
342     for cmd_group, spec in _cnf.get_cli_specs():
343         try:
344             spec_module = _load_spec_module(spec, arguments, '_commands')
345             spec_commands = getattr(spec_module, '_commands')
346         except AttributeError:
347             if _debug:
348                 global kloger
349                 kloger.warning('No valid description for %s' % cmd_group)
350             continue
351         for spec_tree in spec_commands:
352             if spec_tree.name == cmd_group:
353                 cmd_tree.add_tree(spec_tree)
354                 break
355
356
357 #  Methods to be used by CLI implementations
358
359
360 def print_subcommands_help(cmd):
361     printout = {}
362     for subcmd in cmd.get_subcommands():
363         spec, sep, print_path = subcmd.path.partition('_')
364         printout[print_path.replace('_', ' ')] = subcmd.description
365     if printout:
366         print('\nOptions:\n - - - -')
367         print_dict(printout)
368
369
370 def update_parser_help(parser, cmd):
371     global _best_match
372     parser.syntax = parser.syntax.split('<')[0]
373     parser.syntax += ' '.join(_best_match)
374
375     description = ''
376     if cmd.is_command:
377         cls = cmd.get_class()
378         parser.syntax += ' ' + cls.syntax
379         parser.update_arguments(cls().arguments)
380         description = getattr(cls, 'long_description', '')
381         description = description.strip()
382     else:
383         parser.syntax += ' <...>'
384     if cmd.has_description:
385         parser.parser.description = cmd.help + (
386             ('\n%s' % description) if description else '')
387     else:
388         parser.parser.description = description
389
390
391 def print_error_message(cli_err):
392     errmsg = '%s' % cli_err
393     if cli_err.importance == 1:
394         errmsg = magenta(errmsg)
395     elif cli_err.importance == 2:
396         errmsg = yellow(errmsg)
397     elif cli_err.importance > 2:
398         errmsg = red(errmsg)
399     stdout.write(errmsg)
400     for errmsg in cli_err.details:
401         print('|  %s' % errmsg)
402
403
404 def exec_cmd(instance, cmd_args, help_method):
405     try:
406         return instance.main(*cmd_args)
407     except TypeError as err:
408         if err.args and err.args[0].startswith('main()'):
409             print(magenta('Syntax error'))
410             if _debug:
411                 raise err
412             if _verbose:
413                 print(unicode(err))
414             help_method()
415         else:
416             raise
417     return 1
418
419
420 def get_command_group(unparsed, arguments):
421     groups = arguments['config'].get_groups()
422     for term in unparsed:
423         if term.startswith('-'):
424             continue
425         if term in groups:
426             unparsed.remove(term)
427             return term
428         return None
429     return None
430
431
432 def set_command_params(parameters):
433     """Add a parameters list to a command
434
435     :param paramters: (list of str) a list of parameters
436     """
437     global command
438     def_params = list(command.func_defaults)
439     def_params[0] = parameters
440     command.func_defaults = tuple(def_params)
441
442
443 #  CLI Choice:
444
445 def run_one_cmd(exe_string, parser, auth_base, cloud):
446     global _history
447     _history = History(
448         parser.arguments['config'].get_global('history_file'))
449     _history.add(' '.join([exe_string] + argv[1:]))
450     from kamaki.cli import one_command
451     one_command.run(auth_base, cloud, parser, _help)
452
453
454 def run_shell(exe_string, parser, auth_base, cloud):
455     from command_shell import _init_shell
456     shell = _init_shell(exe_string, parser)
457     _load_all_commands(shell.cmd_tree, parser.arguments)
458     shell.run(auth_base, cloud, parser)
459
460
461 def is_non_API(parser):
462     nonAPIs = ('history', 'config')
463     for term in parser.unparsed:
464         if not term.startswith('-'):
465             if term in nonAPIs:
466                 return True
467             return False
468     return False
469
470
471 def main():
472     try:
473         exe = basename(argv[0])
474         parser = ArgumentParseManager(exe)
475
476         if parser.arguments['version'].value:
477             exit(0)
478
479         log_file = parser.arguments['config'].get_global('log_file')
480         if log_file:
481             logger.set_log_filename(log_file)
482         global filelog
483         filelog = logger.add_file_logger(__name__.split('.')[0])
484         filelog.info('* Initial Call *\n%s\n- - -' % ' '.join(argv))
485
486         auth_base, cloud = _init_session(parser.arguments, is_non_API(parser))
487
488         from kamaki.cli.utils import suggest_missing
489         global _colors
490         exclude = ['ansicolors'] if not _colors == 'on' else []
491         suggest_missing(exclude=exclude)
492
493         if parser.unparsed:
494             run_one_cmd(exe, parser, auth_base, cloud)
495         elif _help:
496             parser.parser.print_help()
497             _groups_help(parser.arguments)
498         else:
499             run_shell(exe, parser, auth_base, cloud)
500     except CLIError as err:
501         print_error_message(err)
502         if _debug:
503             raise err
504         exit(1)
505     except Exception as er:
506         print('Unknown Error: %s' % er)
507         if _debug:
508             raise
509         exit(1)