Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (21.6 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

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

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

    
71
        self.default = default or None
72

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

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

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

    
89

    
90
class ConfigArgument(Argument):
91
    """Manage a kamaki configuration (file)"""
92

    
93
    def __init__(self, help, parsed_name=('-c', '--config')):
94
        super(ConfigArgument, self).__init__(1, help, parsed_name, None)
95
        self.file_path = None
96

    
97
    @property
98
    def value(self):
99
        return super(ConfigArgument, self).value
100

    
101
    @value.setter
102
    def value(self, config_file):
103
        if config_file:
104
            self._value = Config(config_file)
105
            self.file_path = config_file
106
        elif self.file_path:
107
            self._value = Config(self.file_path)
108
        else:
109
            self._value = Config()
110

    
111
    def get(self, group, term):
112
        """Get a configuration setting from the Config object"""
113
        return self.value.get(group, term)
114

    
115
    @property
116
    def groups(self):
117
        suffix = '_cli'
118
        slen = len(suffix)
119
        return [term[:-slen] for term in self.value.keys('global') if (
120
            term.endswith(suffix))]
121

    
122
    @property
123
    def cli_specs(self):
124
        suffix = '_cli'
125
        slen = len(suffix)
126
        return [(k[:-slen], v) for k, v in self.value.items('global') if (
127
            k.endswith(suffix))]
128

    
129
    def get_global(self, option):
130
        return self.value.get('global', option)
131

    
132
    def get_cloud(self, cloud, option):
133
        return self.value.get_cloud(cloud, option)
134

    
135

    
136
_config_arg = ConfigArgument('Path to config file')
137

    
138

    
139
class RuntimeConfigArgument(Argument):
140
    """Set a run-time setting option (not persistent)"""
141

    
142
    def __init__(self, config_arg, help='', parsed_name=None, default=None):
143
        super(self.__class__, self).__init__(1, help, parsed_name, default)
144
        self._config_arg = config_arg
145

    
146
    @property
147
    def value(self):
148
        return super(RuntimeConfigArgument, self).value
149

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

    
173

    
174
class FlagArgument(Argument):
175
    """
176
    :value: true if set, false otherwise
177
    """
178

    
179
    def __init__(self, help='', parsed_name=None, default=None):
180
        super(FlagArgument, self).__init__(0, help, parsed_name, default)
181

    
182

    
183
class ValueArgument(Argument):
184
    """
185
    :value type: string
186
    :value returns: given value or default
187
    """
188

    
189
    def __init__(self, help='', parsed_name=None, default=None):
190
        super(ValueArgument, self).__init__(1, help, parsed_name, default)
191

    
192

    
193
class CommaSeparatedListArgument(ValueArgument):
194
    """
195
    :value type: string
196
    :value returns: list of the comma separated values
197
    """
198

    
199
    @property
200
    def value(self):
201
        return self._value or list()
202

    
203
    @value.setter
204
    def value(self, newvalue):
205
        self._value = newvalue.split(',') if newvalue else list()
206

    
207

    
208
class IntArgument(ValueArgument):
209

    
210
    @property
211
    def value(self):
212
        """integer (type checking)"""
213
        return getattr(self, '_value', self.default)
214

    
215
    @value.setter
216
    def value(self, newvalue):
217
        if newvalue == self.default:
218
            self._value = newvalue
219
            return
220
        try:
221
            if int(newvalue) == float(newvalue):
222
                self._value = int(newvalue)
223
            else:
224
                raise ValueError('Raise int argument error')
225
        except ValueError:
226
            raiseCLIError(CLISyntaxError(
227
                'IntArgument Error',
228
                details=['Value %s not an int' % newvalue]))
229

    
230

    
231
class DataSizeArgument(ValueArgument):
232
    """Input: a string of the form <number><unit>
233
    Output: the number of bytes
234
    Units: B, KiB, KB, MiB, MB, GiB, GB, TiB, TB
235
    """
236

    
237
    @property
238
    def value(self):
239
        return getattr(self, '_value', self.default)
240

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

    
264
    @value.setter
265
    def value(self, new_value):
266
        if new_value:
267
            self._value = self._calculate_limit(new_value)
268

    
269

    
270
class DateArgument(ValueArgument):
271

    
272
    DATE_FORMAT = '%a %b %d %H:%M:%S %Y'
273

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

    
276
    @property
277
    def timestamp(self):
278
        v = getattr(self, '_value', self.default)
279
        return mktime(v.timetuple()) if v else None
280

    
281
    @property
282
    def formated(self):
283
        v = getattr(self, '_value', self.default)
284
        return v.strftime(self.DATE_FORMAT) if v else None
285

    
286
    @property
287
    def value(self):
288
        return self.timestamp
289

    
290
    @value.setter
291
    def value(self, newvalue):
292
        self._value = self.format_date(newvalue) if newvalue else self.default
293

    
294
    def format_date(self, datestr):
295
        for format in self.INPUT_FORMATS:
296
            try:
297
                t = dtm.strptime(datestr, format)
298
            except ValueError:
299
                continue
300
            return t  # .strftime(self.DATE_FORMAT)
301
        raiseCLIError(None, 'Date Argument Error', details=[
302
            '%s not a valid date' % datestr,
303
            'Correct formats:\n\t%s' % self.INPUT_FORMATS])
304

    
305

    
306
class VersionArgument(FlagArgument):
307
    """A flag argument with that prints current version"""
308

    
309
    @property
310
    def value(self):
311
        """bool"""
312
        return super(self.__class__, self).value
313

    
314
    @value.setter
315
    def value(self, newvalue):
316
        self._value = newvalue
317
        if newvalue:
318
            import kamaki
319
            print('kamaki %s' % kamaki.__version__)
320

    
321

    
322
class RepeatableArgument(Argument):
323
    """A value argument that can be repeated"""
324

    
325
    def __init__(self, help='', parsed_name=None, default=None):
326
        super(RepeatableArgument, self).__init__(
327
            -1, help, parsed_name, default)
328

    
329
    @property
330
    def value(self):
331
        return getattr(self, '_value', [])
332

    
333
    @value.setter
334
    def value(self, newvalue):
335
        self._value = newvalue
336

    
337

    
338
class KeyValueArgument(Argument):
339
    """A Key=Value Argument that can be repeated
340

341
    :syntax: --<arg> key1=value1 --<arg> key2=value2 ...
342
    """
343

    
344
    def __init__(self, help='', parsed_name=None, default=None):
345
        super(KeyValueArgument, self).__init__(-1, help, parsed_name, default)
346

    
347
    @property
348
    def value(self):
349
        """
350
        :returns: (dict) {key1: val1, key2: val2, ...}
351
        """
352
        return getattr(self, '_value', {})
353

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

    
370

    
371
class ProgressBarArgument(FlagArgument):
372
    """Manage a progress bar"""
373

    
374
    def __init__(self, help='', parsed_name='', default=True):
375
        self.suffix = '%(percent)d%%'
376
        super(ProgressBarArgument, self).__init__(help, parsed_name, default)
377

    
378
    def clone(self):
379
        """Get a modifiable copy of this bar"""
380
        newarg = ProgressBarArgument(
381
            self.help, self.parsed_name, self.default)
382
        newarg._value = self._value
383
        return newarg
384

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

    
410
        def progress_gen(n):
411
            for i in self.bar.iter(range(int(n))):
412
                yield
413
            yield
414
        return progress_gen
415

    
416
    def finish(self):
417
        """Stop progress bar, return terminal cursor to user"""
418
        if self.value:
419
            return
420
        mybar = getattr(self, 'bar', None)
421
        if mybar:
422
            mybar.finish()
423

    
424

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

    
439

    
440
#  Initial command line interface arguments
441

    
442

    
443
class ArgumentParseManager(object):
444
    """Manage (initialize and update) an ArgumentParser object"""
445

    
446
    def __init__(
447
            self, exe,
448
            arguments=None, required=None, syntax=None, description=None):
449
        """
450
        :param exe: (str) the basic command (e.g. 'kamaki')
451

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

    
485
    @staticmethod
486
    def required2list(required):
487
        if isinstance(required, list) or isinstance(required, tuple):
488
            terms = []
489
            for r in required:
490
                terms.append(ArgumentParseManager.required2list(r))
491
            return list(set(terms).union())
492
        return required
493

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

    
520
    @staticmethod
521
    def _patch_with_required_args(arguments, required):
522
        if isinstance(required, tuple):
523
            return ' '.join([ArgumentParseManager._patch_with_required_args(
524
                arguments, k) for k in required])
525
        elif isinstance(required, list):
526
            return '< %s >' % ' | '.join([
527
                ArgumentParseManager._patch_with_required_args(
528
                    arguments, k) for k in required])
529
        arg = arguments[required]
530
        return '/'.join(arg.parsed_name) + (
531
            ' %s [...]' % required.upper() if arg.arity < 0 else (
532
                ' %s' % required.upper() if arg.arity else ''))
533

    
534
    def print_help(self, out=stderr):
535
        if self.required:
536
            tmp_args = dict(self.arguments)
537
            for term in self.required2list(self.required):
538
                tmp_args.pop(term)
539
            tmp_parser = ArgumentParseManager(self._exe, tmp_args)
540
            tmp_parser.syntax = self.syntax + self._patch_with_required_args(
541
                self.arguments, self.required)
542
            tmp_parser.parser.description = '%s\n\nrequired arguments:\n%s' % (
543
                self.parser.description,
544
                self.required2str(self.required, self.arguments))
545
            tmp_parser.update_parser()
546
            tmp_parser.parser.print_help()
547
        else:
548
            self.parser.print_help()
549

    
550
    @property
551
    def syntax(self):
552
        """The command syntax (useful for help messages, descriptions, etc)"""
553
        return self.parser.prog
554

    
555
    @syntax.setter
556
    def syntax(self, new_syntax):
557
        self.parser.prog = new_syntax
558

    
559
    @property
560
    def arguments(self):
561
        """:returns: (dict) arguments the parser should be aware of"""
562
        return self._arguments
563

    
564
    @arguments.setter
565
    def arguments(self, new_arguments):
566
        assert isinstance(new_arguments, dict), 'Arguments must be in a dict'
567
        self._arguments = new_arguments
568
        self.update_parser()
569

    
570
    @property
571
    def parsed(self):
572
        """(Namespace) parser-matched terms"""
573
        if self._parser_modified:
574
            self.parse()
575
        return self._parsed
576

    
577
    @property
578
    def unparsed(self):
579
        """(list) parser-unmatched terms"""
580
        if self._parser_modified:
581
            self.parse()
582
        return self._unparsed
583

    
584
    def update_parser(self, arguments=None):
585
        """Load argument specifications to parser
586

587
        :param arguments: if not given, update self.arguments instead
588
        """
589
        arguments = arguments or self._arguments
590

    
591
        for name, arg in arguments.items():
592
            try:
593
                arg.update_parser(self.parser, name)
594
                self._parser_modified = True
595
            except ArgumentError:
596
                pass
597

    
598
    def update_arguments(self, new_arguments):
599
        """Add to / update existing arguments
600

601
        :param new_arguments: (dict)
602
        """
603
        if new_arguments:
604
            assert isinstance(new_arguments, dict), 'Arguments not in dict !!!'
605
            self._arguments.update(new_arguments)
606
            self.update_parser()
607

    
608
    def _parse_required_arguments(self, required, parsed_args):
609
        if not required:
610
            return True
611
        if isinstance(required, tuple):
612
            for item in required:
613
                if not self._parse_required_arguments(item, parsed_args):
614
                    return False
615
            return True
616
        if isinstance(required, list):
617
            for item in required:
618
                if self._parse_required_arguments(item, parsed_args):
619
                    return True
620
            return False
621
        return required in parsed_args
622

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