Statistics
| Branch: | Tag: | Revision:

root / kamaki / cli / argument / __init__.py @ 94370709

History | View | Annotate | Download (22.8 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.
33

    
34
from kamaki.cli.config import Config
35
from kamaki.cli.errors import (
36
    CLISyntaxError, raiseCLIError, CLIInvalidArgument)
37
from kamaki.cli.utils import split_input, to_bytes
38

    
39
from datetime import datetime as dtm
40
from time import mktime
41
from sys import stderr
42

    
43
from logging import getLogger
44
from argparse import ArgumentParser, ArgumentError
45
from argparse import RawDescriptionHelpFormatter
46
from progress.bar import ShadyBar as KamakiProgressBar
47

    
48
log = getLogger(__name__)
49

    
50

    
51
class Argument(object):
52
    """An argument that can be parsed from command line or otherwise.
53
    This is the top-level Argument class. It is suggested to extent this
54
    class into more specific argument types.
55
    """
56
    lvalue_delimiter = '/'
57

    
58
    def __init__(self, arity, help=None, parsed_name=None, default=None):
59
        self.arity = int(arity)
60
        self.help = '%s' % help or ''
61

    
62
        assert parsed_name, 'No parsed name for argument %s' % self
63
        self.parsed_name = list(parsed_name) if isinstance(
64
            parsed_name, list) or isinstance(parsed_name, tuple) else (
65
                '%s' % parsed_name).split()
66
        for name in self.parsed_name:
67
            assert name.count(' ') == 0, '%s: Invalid parse name "%s"' % (
68
                self, name)
69
            msg = '%s: Invalid parse name "%s" should start with a "-"' % (
70
                    self, name)
71
            assert name.startswith('-'), msg
72

    
73
        self.default = default or None
74

    
75
    @property
76
    def value(self):
77
        return getattr(self, '_value', self.default)
78

    
79
    @value.setter
80
    def value(self, newvalue):
81
        self._value = newvalue
82

    
83
    def update_parser(self, parser, name):
84
        """Update argument parser with self info"""
85
        action = 'append' if self.arity < 0 else (
86
            'store' if self.arity else 'store_true')
87
        parser.add_argument(
88
            *self.parsed_name,
89
            dest=name, action=action, default=self.default, help=self.help)
90

    
91
    @property
92
    def lvalue(self):
93
        """A printable form of the left value when calling an argument e.g.,
94
        --left-value=right-value"""
95
        return (self.lvalue_delimiter or ' ').join(self.parsed_name or [])
96

    
97

    
98
class ConfigArgument(Argument):
99
    """Manage a kamaki configuration (file)"""
100

    
101
    def __init__(self, help, parsed_name=('-c', '--config')):
102
        super(ConfigArgument, self).__init__(1, help, parsed_name, None)
103
        self.file_path = None
104

    
105
    @property
106
    def value(self):
107
        return getattr(self, '_value', None)
108

    
109
    @value.setter
110
    def value(self, config_file):
111
        if config_file:
112
            self._value = Config(config_file)
113
            self.file_path = config_file
114
        elif self.file_path:
115
            self._value = Config(self.file_path)
116
        else:
117
            self._value = Config()
118

    
119
    def get(self, group, term):
120
        """Get a configuration setting from the Config object"""
121
        return self.value.get(group, term)
122

    
123
    @property
124
    def groups(self):
125
        suffix = '_cli'
126
        slen = len(suffix)
127
        return [term[:-slen] for term in self.value.keys('global') if (
128
            term.endswith(suffix))]
129

    
130
    @property
131
    def cli_specs(self):
132
        suffix = '_cli'
133
        slen = len(suffix)
134
        return [(k[:-slen], v) for k, v in self.value.items('global') if (
135
            k.endswith(suffix))]
136

    
137
    def get_global(self, option):
138
        return self.value.get('global', option)
139

    
140
    def get_cloud(self, cloud, option):
141
        return self.value.get_cloud(cloud, option)
142

    
143

    
144
_config_arg = ConfigArgument('Path to config file')
145

    
146

    
147
class RuntimeConfigArgument(Argument):
148
    """Set a run-time setting option (not persistent)"""
149

    
150
    def __init__(self, config_arg, help='', parsed_name=None, default=None):
151
        super(self.__class__, self).__init__(1, help, parsed_name, default)
152
        self._config_arg = config_arg
153

    
154
    @property
155
    def value(self):
156
        return super(RuntimeConfigArgument, self).value
157

    
158
    @value.setter
159
    def value(self, options):
160
        if options == self.default:
161
            return
162
        if not isinstance(options, list):
163
            options = ['%s' % options]
164
        for option in options:
165
            keypath, sep, val = option.partition('=')
166
            if not sep:
167
                raiseCLIError(
168
                    CLISyntaxError('Argument Syntax Error '),
169
                    details=[
170
                        '%s is missing a "="',
171
                        ' (usage: -o section.key=val)' % option])
172
            section, sep, key = keypath.partition('.')
173
        if not sep:
174
            key = section
175
            section = 'global'
176
        self._config_arg.value.override(
177
            section.strip(),
178
            key.strip(),
179
            val.strip())
180

    
181

    
182
class FlagArgument(Argument):
183
    """
184
    :value: true if set, false otherwise
185
    """
186

    
187
    def __init__(self, help='', parsed_name=None, default=None):
188
        super(FlagArgument, self).__init__(0, help, parsed_name, default)
189

    
190

    
191
class ValueArgument(Argument):
192
    """
193
    :value type: string
194
    :value returns: given value or default
195
    """
196

    
197
    def __init__(self, help='', parsed_name=None, default=None):
198
        super(ValueArgument, self).__init__(1, help, parsed_name, default)
199

    
200

    
201
class CommaSeparatedListArgument(ValueArgument):
202
    """
203
    :value type: string
204
    :value returns: list of the comma separated values
205
    """
206

    
207
    @property
208
    def value(self):
209
        return self._value or list()
210

    
211
    @value.setter
212
    def value(self, newvalue):
213
        self._value = newvalue.split(',') if newvalue else list()
214

    
215

    
216
class IntArgument(ValueArgument):
217

    
218
    @property
219
    def value(self):
220
        """integer (type checking)"""
221
        return getattr(self, '_value', self.default)
222

    
223
    @value.setter
224
    def value(self, newvalue):
225
        if newvalue == self.default:
226
            self._value = newvalue
227
            return
228
        try:
229
            if int(newvalue) == float(newvalue):
230
                self._value = int(newvalue)
231
            else:
232
                raise ValueError('Raise int argument error')
233
        except ValueError:
234
            raiseCLIError(CLISyntaxError(
235
                'IntArgument Error',
236
                details=['Value %s not an int' % newvalue]))
237

    
238

    
239
class DataSizeArgument(ValueArgument):
240
    """Input: a string of the form <number><unit>
241
    Output: the number of bytes
242
    Units: B, KiB, KB, MiB, MB, GiB, GB, TiB, TB
243
    """
244

    
245
    @property
246
    def value(self):
247
        return getattr(self, '_value', self.default)
248

    
249
    def _calculate_limit(self, user_input):
250
        limit = 0
251
        try:
252
            limit = int(user_input)
253
        except ValueError:
254
            index = 0
255
            digits = [str(num) for num in range(0, 10)] + ['.']
256
            while user_input[index] in digits:
257
                index += 1
258
            limit = user_input[:index]
259
            format = user_input[index:]
260
            try:
261
                return to_bytes(limit, format)
262
            except Exception as qe:
263
                msg = 'Failed to convert %s to bytes' % user_input,
264
                raiseCLIError(qe, msg, details=[
265
                    'Syntax: containerlimit set <limit>[format] [container]',
266
                    'e.g.,: containerlimit set 2.3GB mycontainer',
267
                    'Valid formats:',
268
                    '(*1024): B, KiB, MiB, GiB, TiB',
269
                    '(*1000): B, KB, MB, GB, TB'])
270
        return limit
271

    
272
    @value.setter
273
    def value(self, new_value):
274
        if new_value:
275
            self._value = self._calculate_limit(new_value)
276

    
277

    
278
class DateArgument(ValueArgument):
279

    
280
    DATE_FORMAT = '%a %b %d %H:%M:%S %Y'
281

    
282
    INPUT_FORMATS = [DATE_FORMAT, '%d-%m-%Y', '%H:%M:%S %d-%m-%Y']
283

    
284
    @property
285
    def timestamp(self):
286
        v = getattr(self, '_value', self.default)
287
        return mktime(v.timetuple()) if v else None
288

    
289
    @property
290
    def formated(self):
291
        v = getattr(self, '_value', self.default)
292
        return v.strftime(self.DATE_FORMAT) if v else None
293

    
294
    @property
295
    def value(self):
296
        return self.timestamp
297

    
298
    @value.setter
299
    def value(self, newvalue):
300
        self._value = self.format_date(newvalue) if newvalue else self.default
301

    
302
    def format_date(self, datestr):
303
        for format in self.INPUT_FORMATS:
304
            try:
305
                t = dtm.strptime(datestr, format)
306
            except ValueError:
307
                continue
308
            return t  # .strftime(self.DATE_FORMAT)
309
        raiseCLIError(None, 'Date Argument Error', details=[
310
            '%s not a valid date' % datestr,
311
            'Correct formats:\n\t%s' % self.INPUT_FORMATS])
312

    
313

    
314
class VersionArgument(FlagArgument):
315
    """A flag argument with that prints current version"""
316

    
317
    @property
318
    def value(self):
319
        """bool"""
320
        return super(self.__class__, self).value
321

    
322
    @value.setter
323
    def value(self, newvalue):
324
        self._value = newvalue
325
        if newvalue:
326
            import kamaki
327
            print('kamaki %s' % kamaki.__version__)
328

    
329

    
330
class RepeatableArgument(Argument):
331
    """A value argument that can be repeated"""
332

    
333
    def __init__(self, help='', parsed_name=None, default=None):
334
        super(RepeatableArgument, self).__init__(
335
            -1, help, parsed_name, default)
336

    
337
    @property
338
    def value(self):
339
        return getattr(self, '_value', [])
340

    
341
    @value.setter
342
    def value(self, newvalue):
343
        self._value = newvalue
344

    
345

    
346
class KeyValueArgument(Argument):
347
    """A Key=Value Argument that can be repeated
348

349
    :syntax: --<arg> key1=value1 --<arg> key2=value2 ...
350
    """
351

    
352
    def __init__(self, help='', parsed_name=None, default=None):
353
        super(KeyValueArgument, self).__init__(-1, help, parsed_name, default)
354

    
355
    @property
356
    def value(self):
357
        """
358
        :returns: (dict) {key1: val1, key2: val2, ...}
359
        """
360
        return getattr(self, '_value', {})
361

    
362
    @value.setter
363
    def value(self, keyvalue_pairs):
364
        """
365
        :param keyvalue_pairs: (str) ['key1=val1', 'key2=val2', ...]
366
        """
367
        if keyvalue_pairs:
368
            self._value = self.value
369
            try:
370
                for pair in keyvalue_pairs:
371
                    key, sep, val = pair.partition('=')
372
                    assert sep, ' %s misses a "=" (usage: key1=val1 )\n' % (
373
                        pair)
374
                    self._value[key] = val
375
            except Exception as e:
376
                raiseCLIError(e, 'KeyValueArgument Syntax Error')
377

    
378

    
379
class StatusArgument(ValueArgument):
380
    """Initialize with valid_states=['list', 'of', 'states']
381
    First state is the default"""
382

    
383
    def __init__(self, *args, **kwargs):
384
        self.valid_states = kwargs.pop('valid_states', ['BUILD', ])
385
        super(StatusArgument, self).__init__(*args, **kwargs)
386

    
387
    @property
388
    def value(self):
389
        return getattr(self, '_value', None)
390

    
391
    @value.setter
392
    def value(self, new_status):
393
        if new_status:
394
            new_status = new_status.upper()
395
            if new_status not in self.valid_states:
396
                raise CLIInvalidArgument(
397
                    'Invalid argument %s' % new_status, details=[
398
                    'Usage: '
399
                    '%s=[%s]' % (self.lvalue, '|'.join(self.valid_states))])
400
            self._value = new_status
401

    
402

    
403
class ProgressBarArgument(FlagArgument):
404
    """Manage a progress bar"""
405

    
406
    def __init__(self, help='', parsed_name='', default=True):
407
        self.suffix = '%(percent)d%%'
408
        super(ProgressBarArgument, self).__init__(help, parsed_name, default)
409

    
410
    def clone(self):
411
        """Get a modifiable copy of this bar"""
412
        newarg = ProgressBarArgument(
413
            self.help, self.parsed_name, self.default)
414
        newarg._value = self._value
415
        return newarg
416

    
417
    def get_generator(
418
            self, message, message_len=25, countdown=False, timeout=100):
419
        """Get a generator to handle progress of the bar (gen.next())"""
420
        if self.value:
421
            return None
422
        try:
423
            self.bar = KamakiProgressBar(
424
                message.ljust(message_len), max=timeout or 100)
425
        except NameError:
426
            self.value = None
427
            return self.value
428
        if countdown:
429
            bar_phases = list(self.bar.phases)
430
            self.bar.empty_fill, bar_phases[0] = bar_phases[-1], ''
431
            bar_phases.reverse()
432
            self.bar.phases = bar_phases
433
            self.bar.bar_prefix = ' '
434
            self.bar.bar_suffix = ' '
435
            self.bar.suffix = '%(remaining)ds to timeout'
436
        else:
437
            self.bar.suffix = '%(percent)d%% - %(eta)ds'
438
        self.bar.start()
439

    
440
        def progress_gen(n):
441
            for i in self.bar.iter(range(int(n))):
442
                yield
443
            yield
444
        return progress_gen
445

    
446
    def finish(self):
447
        """Stop progress bar, return terminal cursor to user"""
448
        if self.value:
449
            return
450
        mybar = getattr(self, 'bar', None)
451
        if mybar:
452
            mybar.finish()
453

    
454

    
455
_arguments = dict(
456
    config=_config_arg,
457
    cloud=ValueArgument('Chose a cloud to connect to', ('--cloud')),
458
    help=Argument(0, 'Show help message', ('-h', '--help')),
459
    debug=FlagArgument('Include debug output', ('-d', '--debug')),
460
    #include=FlagArgument(
461
    #    'Include raw connection data in the output', ('-i', '--include')),
462
    silent=FlagArgument('Do not output anything', ('-s', '--silent')),
463
    verbose=FlagArgument('More info at response', ('-v', '--verbose')),
464
    version=VersionArgument('Print current version', ('-V', '--version')),
465
    options=RuntimeConfigArgument(
466
        _config_arg, 'Override a config value', ('-o', '--options'))
467
)
468

    
469

    
470
#  Initial command line interface arguments
471

    
472

    
473
class ArgumentParseManager(object):
474
    """Manage (initialize and update) an ArgumentParser object"""
475

    
476
    def __init__(
477
            self, exe,
478
            arguments=None, required=None, syntax=None, description=None,
479
            check_required=True):
480
        """
481
        :param exe: (str) the basic command (e.g. 'kamaki')
482

483
        :param arguments: (dict) if given, overrides the global _argument as
484
            the parsers arguments specification
485
        :param required: (list or tuple) an iterable of argument keys, denoting
486
            which arguments are required. A tuple denoted an AND relation,
487
            while a list denotes an OR relation e.g., ['a', 'b'] means that
488
            either 'a' or 'b' is required, while ('a', 'b') means that both 'a'
489
            and 'b' ar required.
490
            Nesting is allowed e.g., ['a', ('b', 'c'), ['d', 'e']] means that
491
            this command required either 'a', or both 'b' and 'c', or one of
492
            'd', 'e'.
493
            Repeated arguments are also allowed e.g., [('a', 'b'), ('a', 'c'),
494
            ['b', 'c']] means that the command required either 'a' and 'b' or
495
            'a' and 'c' or at least one of 'b', 'c' and could be written as
496
            [('a', ['b', 'c']), ['b', 'c']]
497
        :param syntax: (str) The basic syntax of the arguments. Default:
498
            exe <cmd_group> [<cmd_subbroup> ...] <cmd>
499
        :param description: (str) The description of the commands or ''
500
        :param check_required: (bool) Set to False inorder not to check for
501
            required argument values while parsing
502
        """
503
        self.parser = ArgumentParser(
504
            add_help=False, formatter_class=RawDescriptionHelpFormatter)
505
        self._exe = exe
506
        self.syntax = syntax or (
507
            '%s <cmd_group> [<cmd_subbroup> ...] <cmd>' % exe)
508
        self.required, self.check_required = required, check_required
509
        self.parser.description = description or ''
510
        if arguments:
511
            self.arguments = arguments
512
        else:
513
            global _arguments
514
            self.arguments = _arguments
515
        self._parser_modified, self._parsed, self._unparsed = False, None, None
516
        self.parse()
517

    
518
    @staticmethod
519
    def required2list(required):
520
        if isinstance(required, list) or isinstance(required, tuple):
521
            terms = []
522
            for r in required:
523
                terms.append(ArgumentParseManager.required2list(r))
524
            return list(set(terms).union())
525
        return required
526

    
527
    @staticmethod
528
    def required2str(required, arguments, tab=''):
529
        if isinstance(required, list):
530
            return ' %sat least one of the following:\n%s' % (tab, ''.join(
531
                [ArgumentParseManager.required2str(
532
                    r, arguments, tab + '  ') for r in required]))
533
        elif isinstance(required, tuple):
534
            return ' %sall of the following:\n%s' % (tab, ''.join(
535
                [ArgumentParseManager.required2str(
536
                    r, arguments, tab + '  ') for r in required]))
537
        else:
538
            lt_pn, lt_all, arg = 23, 80, arguments[required]
539
            tab2 = ' ' * lt_pn
540
            ret = '%s%s' % (tab, ', '.join(arg.parsed_name))
541
            if arg.arity != 0:
542
                ret += ' %s' % required.upper()
543
            ret = ('{:<%s}' % lt_pn).format(ret)
544
            prefix = ('\n%s' % tab2) if len(ret) > lt_pn else ' '
545
            cur = 0
546
            while arg.help[cur:]:
547
                next = cur + lt_all - lt_pn
548
                ret += prefix
549
                ret += ('{:<%s}' % (lt_all - lt_pn)).format(arg.help[cur:next])
550
                cur, finish = next, '\n%s' % tab2
551
            return ret + '\n'
552

    
553
    @staticmethod
554
    def _patch_with_required_args(arguments, required):
555
        if isinstance(required, tuple):
556
            return ' '.join([ArgumentParseManager._patch_with_required_args(
557
                arguments, k) for k in required])
558
        elif isinstance(required, list):
559
            return '< %s >' % ' | '.join([
560
                ArgumentParseManager._patch_with_required_args(
561
                    arguments, k) for k in required])
562
        arg = arguments[required]
563
        return '/'.join(arg.parsed_name) + (
564
            ' %s [...]' % required.upper() if arg.arity < 0 else (
565
                ' %s' % required.upper() if arg.arity else ''))
566

    
567
    def print_help(self, out=stderr):
568
        if self.required:
569
            tmp_args = dict(self.arguments)
570
            for term in self.required2list(self.required):
571
                tmp_args.pop(term)
572
            tmp_parser = ArgumentParseManager(self._exe, tmp_args)
573
            tmp_parser.syntax = self.syntax + self._patch_with_required_args(
574
                self.arguments, self.required)
575
            tmp_parser.parser.description = '%s\n\nrequired arguments:\n%s' % (
576
                self.parser.description,
577
                self.required2str(self.required, self.arguments))
578
            tmp_parser.update_parser()
579
            tmp_parser.parser.print_help()
580
        else:
581
            self.parser.print_help()
582

    
583
    @property
584
    def syntax(self):
585
        """The command syntax (useful for help messages, descriptions, etc)"""
586
        return self.parser.prog
587

    
588
    @syntax.setter
589
    def syntax(self, new_syntax):
590
        self.parser.prog = new_syntax
591

    
592
    @property
593
    def arguments(self):
594
        """:returns: (dict) arguments the parser should be aware of"""
595
        return self._arguments
596

    
597
    @arguments.setter
598
    def arguments(self, new_arguments):
599
        assert isinstance(new_arguments, dict), 'Arguments must be in a dict'
600
        self._arguments = new_arguments
601
        self.update_parser()
602

    
603
    @property
604
    def parsed(self):
605
        """(Namespace) parser-matched terms"""
606
        if self._parser_modified:
607
            self.parse()
608
        return self._parsed
609

    
610
    @property
611
    def unparsed(self):
612
        """(list) parser-unmatched terms"""
613
        if self._parser_modified:
614
            self.parse()
615
        return self._unparsed
616

    
617
    def update_parser(self, arguments=None):
618
        """Load argument specifications to parser
619

620
        :param arguments: if not given, update self.arguments instead
621
        """
622
        arguments = arguments or self._arguments
623

    
624
        for name, arg in arguments.items():
625
            try:
626
                arg.update_parser(self.parser, name)
627
                self._parser_modified = True
628
            except ArgumentError:
629
                pass
630

    
631
    def update_arguments(self, new_arguments):
632
        """Add to / update existing arguments
633

634
        :param new_arguments: (dict)
635
        """
636
        if new_arguments:
637
            assert isinstance(new_arguments, dict), 'Arguments not in dict !!!'
638
            self._arguments.update(new_arguments)
639
            self.update_parser()
640

    
641
    def _parse_required_arguments(self, required, parsed_args):
642
        if not (self.check_required and required):
643
            return True
644
        if isinstance(required, tuple):
645
            for item in required:
646
                if not self._parse_required_arguments(item, parsed_args):
647
                    return False
648
            return True
649
        elif isinstance(required, list):
650
            for item in required:
651
                if self._parse_required_arguments(item, parsed_args):
652
                    return True
653
            return False
654
        return required in parsed_args
655

    
656
    def parse(self, new_args=None):
657
        """Parse user input"""
658
        try:
659
            pkargs = (new_args,) if new_args else ()
660
            self._parsed, unparsed = self.parser.parse_known_args(*pkargs)
661
            parsed_args = [
662
                k for k, v in vars(self._parsed).items() if v not in (None, )]
663
            if not self._parse_required_arguments(self.required, parsed_args):
664
                self.print_help()
665
                raise CLISyntaxError('Missing required arguments')
666
        except SystemExit:
667
            raiseCLIError(CLISyntaxError('Argument Syntax Error'))
668
        for name, arg in self.arguments.items():
669
            arg.value = getattr(self._parsed, name, arg.default)
670
        self._unparsed = []
671
        for term in unparsed:
672
            self._unparsed += split_input(' \'%s\' ' % term)
673
        self._parser_modified = False