Statistics
| Branch: | Tag: | Revision:

root / kamaki / cli / __init__.py @ fa382f9e

History | View | Annotate | Download (16.5 kB)

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"' % (term, cloud),
289
                importance=3, details=[
290
                    'Set a %s for cloud %s:' % (term, cloud),
291
                    '  kamaki config set cloud.%s.%s <%s>' % (
292
                        cloud, term, term)])
293

    
294
    from kamaki.clients.astakos import AstakosClient as AuthCachedClient
295
    try:
296
        return AuthCachedClient(auth_args['url'], auth_args['token']), cloud
297
    except AssertionError as ae:
298
        kloger.warning('WARNING: Failed to load authenticator [%s]' % ae)
299
        return None, cloud
300

    
301

    
302
def _load_spec_module(spec, arguments, module):
303
    if not spec:
304
        return None
305
    pkg = None
306
    for location in cmd_spec_locations:
307
        location += spec if location == '' else '.%s' % spec
308
        try:
309
            pkg = __import__(location, fromlist=[module])
310
            return pkg
311
        except ImportError as ie:
312
            continue
313
    if not pkg:
314
        kloger.debug('Loading cmd grp %s failed: %s' % (spec, ie))
315
    return pkg
316

    
317

    
318
def _groups_help(arguments):
319
    global _debug
320
    global kloger
321
    descriptions = {}
322
    for cmd_group, spec in arguments['config'].get_cli_specs():
323
        pkg = _load_spec_module(spec, arguments, '_commands')
324
        if pkg:
325
            cmds = getattr(pkg, '_commands')
326
            try:
327
                for cmd in cmds:
328
                    descriptions[cmd.name] = cmd.description
329
            except TypeError:
330
                if _debug:
331
                    kloger.warning(
332
                        'No cmd description for module %s' % cmd_group)
333
        elif _debug:
334
            kloger.warning('Loading of %s cmd spec failed' % cmd_group)
335
    print('\nOptions:\n - - - -')
336
    print_dict(descriptions)
337

    
338

    
339
def _load_all_commands(cmd_tree, arguments):
340
    _cnf = arguments['config']
341
    for cmd_group, spec in _cnf.get_cli_specs():
342
        try:
343
            spec_module = _load_spec_module(spec, arguments, '_commands')
344
            spec_commands = getattr(spec_module, '_commands')
345
        except AttributeError:
346
            if _debug:
347
                global kloger
348
                kloger.warning('No valid description for %s' % cmd_group)
349
            continue
350
        for spec_tree in spec_commands:
351
            if spec_tree.name == cmd_group:
352
                cmd_tree.add_tree(spec_tree)
353
                break
354

    
355

    
356
#  Methods to be used by CLI implementations
357

    
358

    
359
def print_subcommands_help(cmd):
360
    printout = {}
361
    for subcmd in cmd.get_subcommands():
362
        spec, sep, print_path = subcmd.path.partition('_')
363
        printout[print_path.replace('_', ' ')] = subcmd.description
364
    if printout:
365
        print('\nOptions:\n - - - -')
366
        print_dict(printout)
367

    
368

    
369
def update_parser_help(parser, cmd):
370
    global _best_match
371
    parser.syntax = parser.syntax.split('<')[0]
372
    parser.syntax += ' '.join(_best_match)
373

    
374
    description = ''
375
    if cmd.is_command:
376
        cls = cmd.get_class()
377
        parser.syntax += ' ' + cls.syntax
378
        parser.update_arguments(cls().arguments)
379
        description = getattr(cls, 'long_description', '')
380
        description = description.strip()
381
    else:
382
        parser.syntax += ' <...>'
383
    if cmd.has_description:
384
        parser.parser.description = cmd.help + (
385
            ('\n%s' % description) if description else '')
386
    else:
387
        parser.parser.description = description
388

    
389

    
390
def print_error_message(cli_err):
391
    errmsg = '%s' % cli_err
392
    if cli_err.importance == 1:
393
        errmsg = magenta(errmsg)
394
    elif cli_err.importance == 2:
395
        errmsg = yellow(errmsg)
396
    elif cli_err.importance > 2:
397
        errmsg = red(errmsg)
398
    stdout.write(errmsg)
399
    for errmsg in cli_err.details:
400
        print('|  %s' % errmsg)
401

    
402

    
403
def exec_cmd(instance, cmd_args, help_method):
404
    try:
405
        return instance.main(*cmd_args)
406
    except TypeError as err:
407
        if err.args and err.args[0].startswith('main()'):
408
            print(magenta('Syntax error'))
409
            if _debug:
410
                raise err
411
            if _verbose:
412
                print(unicode(err))
413
            help_method()
414
        else:
415
            raise
416
    return 1
417

    
418

    
419
def get_command_group(unparsed, arguments):
420
    groups = arguments['config'].get_groups()
421
    for term in unparsed:
422
        if term.startswith('-'):
423
            continue
424
        if term in groups:
425
            unparsed.remove(term)
426
            return term
427
        return None
428
    return None
429

    
430

    
431
def set_command_params(parameters):
432
    """Add a parameters list to a command
433

434
    :param paramters: (list of str) a list of parameters
435
    """
436
    global command
437
    def_params = list(command.func_defaults)
438
    def_params[0] = parameters
439
    command.func_defaults = tuple(def_params)
440

    
441

    
442
#  CLI Choice:
443

    
444
def run_one_cmd(exe_string, parser, auth_base, cloud):
445
    global _history
446
    _history = History(
447
        parser.arguments['config'].get_global('history_file'))
448
    _history.add(' '.join([exe_string] + argv[1:]))
449
    from kamaki.cli import one_command
450
    one_command.run(auth_base, cloud, parser, _help)
451

    
452

    
453
def run_shell(exe_string, parser, auth_base, cloud):
454
    from command_shell import _init_shell
455
    shell = _init_shell(exe_string, parser)
456
    _load_all_commands(shell.cmd_tree, parser.arguments)
457
    shell.run(auth_base, cloud, parser)
458

    
459

    
460
def is_non_API(parser):
461
    nonAPIs = ('history', 'config')
462
    for term in parser.unparsed:
463
        if not term.startswith('-'):
464
            if term in nonAPIs:
465
                return True
466
            return False
467
    return False
468

    
469

    
470
def main():
471
    try:
472
        exe = basename(argv[0])
473
        parser = ArgumentParseManager(exe)
474

    
475
        if parser.arguments['version'].value:
476
            exit(0)
477

    
478
        log_file = parser.arguments['config'].get_global('log_file')
479
        if log_file:
480
            logger.set_log_filename(log_file)
481
        global filelog
482
        filelog = logger.add_file_logger(__name__.split('.')[0])
483
        filelog.info('* Initial Call *\n%s\n- - -' % ' '.join(argv))
484

    
485
        auth_base, cloud = _init_session(parser.arguments, is_non_API(parser))
486

    
487
        from kamaki.cli.utils import suggest_missing
488
        global _colors
489
        exclude = ['ansicolors'] if not _colors == 'on' else []
490
        suggest_missing(exclude=exclude)
491

    
492
        if parser.unparsed:
493
            run_one_cmd(exe, parser, auth_base, cloud)
494
        elif _help:
495
            parser.parser.print_help()
496
            _groups_help(parser.arguments)
497
        else:
498
            run_shell(exe, parser, auth_base, cloud)
499
    except CLIError as err:
500
        print_error_message(err)
501
        if _debug:
502
            raise err
503
        exit(1)
504
    except Exception as er:
505
        print('Unknown Error: %s' % er)
506
        if _debug:
507
            raise
508
        exit(1)