Statistics
| Branch: | Tag: | Revision:

root / kamaki / cli / argument / __init__.py @ 26413b0d

History | View | Annotate | Download (22 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 CLISyntaxError, raiseCLIError
36
from kamaki.cli.utils import split_input, to_bytes
37

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

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

    
47
log = getLogger(__name__)
48

    
49

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

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

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

    
72
        self.default = default or None
73

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

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

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

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

    
96

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

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

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

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

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

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

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

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

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

    
142

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

    
145

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

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

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

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

    
180

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

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

    
189

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

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

    
199

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

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

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

    
214

    
215
class IntArgument(ValueArgument):
216

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

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

    
237

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

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

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

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

    
276

    
277
class DateArgument(ValueArgument):
278

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

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

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

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

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

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

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

    
312

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

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

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

    
328

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

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

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

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

    
344

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

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

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

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

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

    
377

    
378
class ProgressBarArgument(FlagArgument):
379
    """Manage a progress bar"""
380

    
381
    def __init__(self, help='', parsed_name='', default=True):
382
        self.suffix = '%(percent)d%%'
383
        super(ProgressBarArgument, self).__init__(help, parsed_name, default)
384

    
385
    def clone(self):
386
        """Get a modifiable copy of this bar"""
387
        newarg = ProgressBarArgument(
388
            self.help, self.parsed_name, self.default)
389
        newarg._value = self._value
390
        return newarg
391

    
392
    def get_generator(
393
            self, message, message_len=25, countdown=False, timeout=100):
394
        """Get a generator to handle progress of the bar (gen.next())"""
395
        if self.value:
396
            return None
397
        try:
398
            self.bar = KamakiProgressBar(
399
                message.ljust(message_len), max=timeout or 100)
400
        except NameError:
401
            self.value = None
402
            return self.value
403
        if countdown:
404
            bar_phases = list(self.bar.phases)
405
            self.bar.empty_fill, bar_phases[0] = bar_phases[-1], ''
406
            bar_phases.reverse()
407
            self.bar.phases = bar_phases
408
            self.bar.bar_prefix = ' '
409
            self.bar.bar_suffix = ' '
410
            self.bar.suffix = '%(remaining)ds to timeout'
411
        else:
412
            self.bar.suffix = '%(percent)d%% - %(eta)ds'
413
        self.bar.start()
414

    
415
        def progress_gen(n):
416
            for i in self.bar.iter(range(int(n))):
417
                yield
418
            yield
419
        return progress_gen
420

    
421
    def finish(self):
422
        """Stop progress bar, return terminal cursor to user"""
423
        if self.value:
424
            return
425
        mybar = getattr(self, 'bar', None)
426
        if mybar:
427
            mybar.finish()
428

    
429

    
430
_arguments = dict(
431
    config=_config_arg,
432
    cloud=ValueArgument('Chose a cloud to connect to', ('--cloud')),
433
    help=Argument(0, 'Show help message', ('-h', '--help')),
434
    debug=FlagArgument('Include debug output', ('-d', '--debug')),
435
    #include=FlagArgument(
436
    #    'Include raw connection data in the output', ('-i', '--include')),
437
    silent=FlagArgument('Do not output anything', ('-s', '--silent')),
438
    verbose=FlagArgument('More info at response', ('-v', '--verbose')),
439
    version=VersionArgument('Print current version', ('-V', '--version')),
440
    options=RuntimeConfigArgument(
441
        _config_arg, 'Override a config value', ('-o', '--options'))
442
)
443

    
444

    
445
#  Initial command line interface arguments
446

    
447

    
448
class ArgumentParseManager(object):
449
    """Manage (initialize and update) an ArgumentParser object"""
450

    
451
    def __init__(
452
            self, exe,
453
            arguments=None, required=None, syntax=None, description=None,
454
            check_required=True):
455
        """
456
        :param exe: (str) the basic command (e.g. 'kamaki')
457

458
        :param arguments: (dict) if given, overrides the global _argument as
459
            the parsers arguments specification
460
        :param required: (list or tuple) an iterable of argument keys, denoting
461
            which arguments are required. A tuple denoted an AND relation,
462
            while a list denotes an OR relation e.g., ['a', 'b'] means that
463
            either 'a' or 'b' is required, while ('a', 'b') means that both 'a'
464
            and 'b' ar required.
465
            Nesting is allowed e.g., ['a', ('b', 'c'), ['d', 'e']] means that
466
            this command required either 'a', or both 'b' and 'c', or one of
467
            'd', 'e'.
468
            Repeated arguments are also allowed e.g., [('a', 'b'), ('a', 'c'),
469
            ['b', 'c']] means that the command required either 'a' and 'b' or
470
            'a' and 'c' or at least one of 'b', 'c' and could be written as
471
            [('a', ['b', 'c']), ['b', 'c']]
472
        :param syntax: (str) The basic syntax of the arguments. Default:
473
            exe <cmd_group> [<cmd_subbroup> ...] <cmd>
474
        :param description: (str) The description of the commands or ''
475
        :param check_required: (bool) Set to False inorder not to check for
476
            required argument values while parsing
477
        """
478
        self.parser = ArgumentParser(
479
            add_help=False, formatter_class=RawDescriptionHelpFormatter)
480
        self._exe = exe
481
        self.syntax = syntax or (
482
            '%s <cmd_group> [<cmd_subbroup> ...] <cmd>' % exe)
483
        self.required, self.check_required = required, check_required
484
        self.parser.description = description or ''
485
        if arguments:
486
            self.arguments = arguments
487
        else:
488
            global _arguments
489
            self.arguments = _arguments
490
        self._parser_modified, self._parsed, self._unparsed = False, None, None
491
        self.parse()
492

    
493
    @staticmethod
494
    def required2list(required):
495
        if isinstance(required, list) or isinstance(required, tuple):
496
            terms = []
497
            for r in required:
498
                terms.append(ArgumentParseManager.required2list(r))
499
            return list(set(terms).union())
500
        return required
501

    
502
    @staticmethod
503
    def required2str(required, arguments, tab=''):
504
        if isinstance(required, list):
505
            return ' %sat least one of the following:\n%s' % (tab, ''.join(
506
                [ArgumentParseManager.required2str(
507
                    r, arguments, tab + '  ') for r in required]))
508
        elif isinstance(required, tuple):
509
            return ' %sall of the following:\n%s' % (tab, ''.join(
510
                [ArgumentParseManager.required2str(
511
                    r, arguments, tab + '  ') for r in required]))
512
        else:
513
            lt_pn, lt_all, arg = 23, 80, arguments[required]
514
            tab2 = ' ' * lt_pn
515
            ret = '%s%s' % (tab, ', '.join(arg.parsed_name))
516
            if arg.arity != 0:
517
                ret += ' %s' % required.upper()
518
            ret = ('{:<%s}' % lt_pn).format(ret)
519
            prefix = ('\n%s' % tab2) if len(ret) > lt_pn else ' '
520
            cur = 0
521
            while arg.help[cur:]:
522
                next = cur + lt_all - lt_pn
523
                ret += prefix
524
                ret += ('{:<%s}' % (lt_all - lt_pn)).format(arg.help[cur:next])
525
                cur, finish = next, '\n%s' % tab2
526
            return ret + '\n'
527

    
528
    @staticmethod
529
    def _patch_with_required_args(arguments, required):
530
        if isinstance(required, tuple):
531
            return ' '.join([ArgumentParseManager._patch_with_required_args(
532
                arguments, k) for k in required])
533
        elif isinstance(required, list):
534
            return '< %s >' % ' | '.join([
535
                ArgumentParseManager._patch_with_required_args(
536
                    arguments, k) for k in required])
537
        arg = arguments[required]
538
        return '/'.join(arg.parsed_name) + (
539
            ' %s [...]' % required.upper() if arg.arity < 0 else (
540
                ' %s' % required.upper() if arg.arity else ''))
541

    
542
    def print_help(self, out=stderr):
543
        if self.required:
544
            tmp_args = dict(self.arguments)
545
            for term in self.required2list(self.required):
546
                tmp_args.pop(term)
547
            tmp_parser = ArgumentParseManager(self._exe, tmp_args)
548
            tmp_parser.syntax = self.syntax + self._patch_with_required_args(
549
                self.arguments, self.required)
550
            tmp_parser.parser.description = '%s\n\nrequired arguments:\n%s' % (
551
                self.parser.description,
552
                self.required2str(self.required, self.arguments))
553
            tmp_parser.update_parser()
554
            tmp_parser.parser.print_help()
555
        else:
556
            self.parser.print_help()
557

    
558
    @property
559
    def syntax(self):
560
        """The command syntax (useful for help messages, descriptions, etc)"""
561
        return self.parser.prog
562

    
563
    @syntax.setter
564
    def syntax(self, new_syntax):
565
        self.parser.prog = new_syntax
566

    
567
    @property
568
    def arguments(self):
569
        """:returns: (dict) arguments the parser should be aware of"""
570
        return self._arguments
571

    
572
    @arguments.setter
573
    def arguments(self, new_arguments):
574
        assert isinstance(new_arguments, dict), 'Arguments must be in a dict'
575
        self._arguments = new_arguments
576
        self.update_parser()
577

    
578
    @property
579
    def parsed(self):
580
        """(Namespace) parser-matched terms"""
581
        if self._parser_modified:
582
            self.parse()
583
        return self._parsed
584

    
585
    @property
586
    def unparsed(self):
587
        """(list) parser-unmatched terms"""
588
        if self._parser_modified:
589
            self.parse()
590
        return self._unparsed
591

    
592
    def update_parser(self, arguments=None):
593
        """Load argument specifications to parser
594

595
        :param arguments: if not given, update self.arguments instead
596
        """
597
        arguments = arguments or self._arguments
598

    
599
        for name, arg in arguments.items():
600
            try:
601
                arg.update_parser(self.parser, name)
602
                self._parser_modified = True
603
            except ArgumentError:
604
                pass
605

    
606
    def update_arguments(self, new_arguments):
607
        """Add to / update existing arguments
608

609
        :param new_arguments: (dict)
610
        """
611
        if new_arguments:
612
            assert isinstance(new_arguments, dict), 'Arguments not in dict !!!'
613
            self._arguments.update(new_arguments)
614
            self.update_parser()
615

    
616
    def _parse_required_arguments(self, required, parsed_args):
617
        if not (self.check_required and required):
618
            return True
619
        if isinstance(required, tuple):
620
            for item in required:
621
                if not self._parse_required_arguments(item, parsed_args):
622
                    return False
623
            return True
624
        if isinstance(required, list):
625
            for item in required:
626
                if self._parse_required_arguments(item, parsed_args):
627
                    return True
628
            return False
629
        return required in parsed_args
630

    
631
    def parse(self, new_args=None):
632
        """Parse user input"""
633
        try:
634
            pkargs = (new_args,) if new_args else ()
635
            self._parsed, unparsed = self.parser.parse_known_args(*pkargs)
636
            parsed_args = [
637
                k for k, v in vars(self._parsed).items() if v not in (None, )]
638
            if not self._parse_required_arguments(self.required, parsed_args):
639
                self.print_help()
640
                raise CLISyntaxError('Missing required arguments')
641
        except SystemExit:
642
            raiseCLIError(CLISyntaxError('Argument Syntax Error'))
643
        for name, arg in self.arguments.items():
644
            arg.value = getattr(self._parsed, name, arg.default)
645
        self._unparsed = []
646
        for term in unparsed:
647
            self._unparsed += split_input(' \'%s\' ' % term)
648
        self._parser_modified = False