Statistics
| Branch: | Tag: | Revision:

root / kamaki / cli / argument / __init__.py @ 9b3c8fd9

History | View | Annotate | Download (22.1 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 super(ConfigArgument, self).value
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
        except NameError:
400
            self.value = None
401
            return self.value
402
        if countdown:
403
            bar_phases = list(self.bar.phases)
404
            self.bar.empty_fill, bar_phases[0] = bar_phases[-1], ''
405
            bar_phases.reverse()
406
            self.bar.phases = bar_phases
407
            self.bar.bar_prefix = ' '
408
            self.bar.bar_suffix = ' '
409
            self.bar.max = timeout or 100
410
            self.bar.suffix = '%(remaining)ds to timeout'
411
        else:
412
            self.bar.suffix = '%(percent)d%% - %(eta)ds'
413
        self.bar.eta = timeout or 100
414
        self.bar.message = message.ljust(message_len)
415
        self.bar.start()
416

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

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

    
431

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

    
446

    
447
#  Initial command line interface arguments
448

    
449

    
450
class ArgumentParseManager(object):
451
    """Manage (initialize and update) an ArgumentParser object"""
452

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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