Allow kamaki-shell to ignore unrecognized commands
[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([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.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                     tmp_base.authenticate(token)
316                     auth_base = tmp_base
317             except ClientError as ce:
318                 if ce.status in (401, ):
319                     logger.warning(
320                         'WARNING: Failed to authenticate token %s' % token)
321                     failed.append(token)
322                 else:
323                     raise
324         for token in failed:
325             r = raw_input(
326                 'Token %s failed to authenticate. Remove it? [y/N]: ' % token)
327             if r in ('y', 'Y'):
328                 tokens.remove(token)
329         if set(failed).difference(tokens):
330             _cnf.set_cloud(cloud, 'token', ' '.join(tokens))
331             _cnf.write()
332         if tokens:
333             return auth_base
334         logger.warning('WARNING: cloud.%s.token is now empty' % cloud)
335     except AssertionError as ae:
336         logger.warning('WARNING: Failed to load authenticator [%s]' % ae)
337     return None
338
339
340 def _load_spec_module(spec, arguments, module):
341     global kloger
342     if not spec:
343         return None
344     pkg = None
345     for location in cmd_spec_locations:
346         location += spec if location == '' else '.%s' % spec
347         try:
348             kloger.debug('Import %s from %s' % ([module], location))
349             pkg = __import__(location, fromlist=[module])
350             kloger.debug('\t...OK')
351             return pkg
352         except ImportError as ie:
353             kloger.debug('\t...Failed')
354             continue
355     if not pkg:
356         msg = 'Loading command group %s failed: %s' % (spec, ie)
357         msg += '\nHINT: use a text editor to remove all global.*_cli'
358         msg += '\n\tsettings from the configuration file'
359         kloger.debug(msg)
360     return pkg
361
362
363 def _groups_help(arguments):
364     global _debug
365     global kloger
366     descriptions = {}
367     acceptable_groups = arguments['config'].groups
368     for cmd_group, spec in arguments['config'].cli_specs:
369         pkg = _load_spec_module(spec, arguments, '_commands')
370         if pkg:
371             cmds = getattr(pkg, '_commands')
372             try:
373                 for cmd_tree in cmds:
374                     if cmd_tree.name in acceptable_groups:
375                         descriptions[cmd_tree.name] = cmd_tree.description
376             except TypeError:
377                 if _debug:
378                     kloger.warning(
379                         'No cmd description (help) for module %s' % cmd_group)
380         elif _debug:
381             kloger.warning('Loading of %s cmd spec failed' % cmd_group)
382     print('\nOptions:\n - - - -')
383     print_dict(descriptions)
384
385
386 def _load_all_commands(cmd_tree, arguments):
387     _cnf = arguments['config']
388     for cmd_group, spec in _cnf.cli_specs:
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                 global kloger
395                 kloger.warning('No valid description for %s' % cmd_group)
396             continue
397         for spec_tree in spec_commands:
398             if spec_tree.name == cmd_group:
399                 cmd_tree.add_tree(spec_tree)
400                 break
401
402
403 #  Methods to be used by CLI implementations
404
405
406 def print_subcommands_help(cmd):
407     printout = {}
408     for subcmd in cmd.subcommands.values():
409         spec, sep, print_path = subcmd.path.partition('_')
410         printout[print_path.replace('_', ' ')] = subcmd.help
411     if printout:
412         print('\nOptions:\n - - - -')
413         print_dict(printout)
414
415
416 def update_parser_help(parser, cmd):
417     global _best_match
418     parser.syntax = parser.syntax.split('<')[0]
419     parser.syntax += ' '.join(_best_match)
420
421     description = ''
422     if cmd.is_command:
423         cls = cmd.cmd_class
424         parser.syntax += ' ' + cls.syntax
425         parser.update_arguments(cls().arguments)
426         description = getattr(cls, 'long_description', '').strip()
427     else:
428         parser.syntax += ' <...>'
429     parser.parser.description = (
430         cmd.help + ('\n' if description else '')) if cmd.help else description
431
432
433 def print_error_message(cli_err, out=stderr):
434     errmsg = '%s' % cli_err
435     if cli_err.importance == 1:
436         errmsg = magenta(errmsg)
437     elif cli_err.importance == 2:
438         errmsg = yellow(errmsg)
439     elif cli_err.importance > 2:
440         errmsg = red(errmsg)
441     out.write(errmsg)
442     for errmsg in cli_err.details:
443         out.write('|  %s\n' % errmsg)
444         out.flush()
445
446
447 def exec_cmd(instance, cmd_args, help_method):
448     try:
449         return instance.main(*cmd_args)
450     except TypeError as err:
451         if err.args and err.args[0].startswith('main()'):
452             print(magenta('Syntax error'))
453             if _debug:
454                 raise err
455             if _verbose:
456                 print(unicode(err))
457             help_method()
458         else:
459             raise
460     return 1
461
462
463 def get_command_group(unparsed, arguments):
464     groups = arguments['config'].groups
465     for term in unparsed:
466         if term.startswith('-'):
467             continue
468         if term in groups:
469             unparsed.remove(term)
470             return term
471         return None
472     return None
473
474
475 def set_command_params(parameters):
476     """Add a parameters list to a command
477
478     :param paramters: (list of str) a list of parameters
479     """
480     global command
481     def_params = list(command.func_defaults)
482     def_params[0] = parameters
483     command.func_defaults = tuple(def_params)
484
485
486 #  CLI Choice:
487
488 def is_non_API(parser):
489     nonAPIs = ('history', 'config')
490     for term in parser.unparsed:
491         if not term.startswith('-'):
492             if term in nonAPIs:
493                 return True
494             return False
495     return False
496
497
498 def main(foo):
499     def wrap():
500         try:
501             exe = basename(argv[0])
502             parser = ArgumentParseManager(exe)
503
504             if parser.arguments['version'].value:
505                 exit(0)
506
507             _cnf = parser.arguments['config']
508             log_file = _cnf.get('global', 'log_file')
509             if log_file:
510                 logger.set_log_filename(log_file)
511             global filelog
512             filelog = logger.add_file_logger(__name__.split('.')[0])
513             filelog.info('* Initial Call *\n%s\n- - -' % ' '.join(argv))
514
515             from kamaki.cli.utils import suggest_missing
516             global _colors
517             exclude = ['ansicolors'] if not _colors == 'on' else []
518             suggest_missing(exclude=exclude)
519             foo(exe, parser)
520         except CLIError as err:
521             print_error_message(err)
522             if _debug:
523                 raise err
524             exit(1)
525         except KeyboardInterrupt:
526             print('Canceled by user')
527             exit(1)
528         except Exception as er:
529             print('Unknown Error: %s' % er)
530             if _debug:
531                 raise
532             exit(1)
533     return wrap
534
535
536 @main
537 def run_shell(exe, parser):
538     parser.arguments['help'].value = False
539     cloud = _init_session(parser.arguments)
540     from command_shell import _init_shell
541     global kloger
542     _cnf = parser.arguments['config']
543     auth_base = init_cached_authenticator(_cnf, cloud, kloger)
544     try:
545         username, userid = (
546             auth_base.user_term('name'), auth_base.user_term('id'))
547     except Exception:
548         username, userid = '', ''
549     shell = _init_shell(exe, parser, username, userid)
550     _load_all_commands(shell.cmd_tree, parser.arguments)
551     shell.run(auth_base, cloud, parser)
552
553
554 @main
555 def run_one_cmd(exe, parser):
556     cloud = _init_session(parser.arguments, is_non_API(parser))
557     if parser.unparsed:
558         global _history
559         try:
560             token = parser.arguments['config'].get_cloud(
561                 cloud, 'token').split()[0]
562         except Exception:
563             token = None
564         _history = History(
565             parser.arguments['config'].get('global', 'history_file'),
566             token=token)
567         _history.add(' '.join([exe] + argv[1:]))
568         from kamaki.cli import one_command
569         one_command.run(cloud, parser, _help)
570     else:
571         parser.print_help()
572         _groups_help(parser.arguments)
573         print('kamaki-shell: An interactive command line shell')