Statistics
| Branch: | Tag: | Revision:

root / kamaki / cli / argument / __init__.py @ 7b109aa7

History | View | Annotate | Download (21.4 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 if (default or self.arity) else False
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=False):
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=[]):
326
        super(RepeatableArgument, self).__init__(
327
            -1, help, parsed_name, default)
328

    
329

    
330
class KeyValueArgument(Argument):
331
    """A Key=Value Argument that can be repeated
332

333
    :syntax: --<arg> key1=value1 --<arg> key2=value2 ...
334
    """
335

    
336
    def __init__(self, help='', parsed_name=None, default=[]):
337
        super(KeyValueArgument, self).__init__(-1, help, parsed_name, default)
338

    
339
    @property
340
    def value(self):
341
        """
342
        :returns: (dict) {key1: val1, key2: val2, ...}
343
        """
344
        return super(KeyValueArgument, self).value
345

    
346
    @value.setter
347
    def value(self, keyvalue_pairs):
348
        """
349
        :param keyvalue_pairs: (str) ['key1=val1', 'key2=val2', ...]
350
        """
351
        self._value = getattr(self, '_value', self.value) or {}
352
        try:
353
            for pair in keyvalue_pairs:
354
                key, sep, val = pair.partition('=')
355
                assert sep, ' %s misses a "=" (usage: key1=val1 )\n' % (pair)
356
                self._value[key] = val
357
        except Exception as e:
358
            raiseCLIError(e, 'KeyValueArgument Syntax Error')
359

    
360

    
361
class ProgressBarArgument(FlagArgument):
362
    """Manage a progress bar"""
363

    
364
    def __init__(self, help='', parsed_name='', default=True):
365
        self.suffix = '%(percent)d%%'
366
        super(ProgressBarArgument, self).__init__(help, parsed_name, default)
367

    
368
    def clone(self):
369
        """Get a modifiable copy of this bar"""
370
        newarg = ProgressBarArgument(
371
            self.help, self.parsed_name, self.default)
372
        newarg._value = self._value
373
        return newarg
374

    
375
    def get_generator(
376
            self, message, message_len=25, countdown=False, timeout=100):
377
        """Get a generator to handle progress of the bar (gen.next())"""
378
        if self.value:
379
            return None
380
        try:
381
            self.bar = KamakiProgressBar()
382
        except NameError:
383
            self.value = None
384
            return self.value
385
        if countdown:
386
            bar_phases = list(self.bar.phases)
387
            self.bar.empty_fill, bar_phases[0] = bar_phases[-1], ''
388
            bar_phases.reverse()
389
            self.bar.phases = bar_phases
390
            self.bar.bar_prefix = ' '
391
            self.bar.bar_suffix = ' '
392
            self.bar.max = timeout or 100
393
            self.bar.suffix = '%(remaining)ds to timeout'
394
        else:
395
            self.bar.suffix = '%(percent)d%% - %(eta)ds'
396
        self.bar.eta = timeout or 100
397
        self.bar.message = message.ljust(message_len)
398
        self.bar.start()
399

    
400
        def progress_gen(n):
401
            for i in self.bar.iter(range(int(n))):
402
                yield
403
            yield
404
        return progress_gen
405

    
406
    def finish(self):
407
        """Stop progress bar, return terminal cursor to user"""
408
        if self.value:
409
            return
410
        mybar = getattr(self, 'bar', None)
411
        if mybar:
412
            mybar.finish()
413

    
414

    
415
_arguments = dict(
416
    config=_config_arg,
417
    cloud=ValueArgument('Chose a cloud to connect to', ('--cloud')),
418
    help=Argument(0, 'Show help message', ('-h', '--help')),
419
    debug=FlagArgument('Include debug output', ('-d', '--debug')),
420
    #include=FlagArgument(
421
    #    'Include raw connection data in the output', ('-i', '--include')),
422
    silent=FlagArgument('Do not output anything', ('-s', '--silent')),
423
    verbose=FlagArgument('More info at response', ('-v', '--verbose')),
424
    version=VersionArgument('Print current version', ('-V', '--version')),
425
    options=RuntimeConfigArgument(
426
        _config_arg, 'Override a config value', ('-o', '--options'))
427
)
428

    
429

    
430
#  Initial command line interface arguments
431

    
432

    
433
class ArgumentParseManager(object):
434
    """Manage (initialize and update) an ArgumentParser object"""
435

    
436
    def __init__(
437
            self, exe,
438
            arguments=None, required=None, syntax=None, description=None):
439
        """
440
        :param exe: (str) the basic command (e.g. 'kamaki')
441

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

    
475
    @staticmethod
476
    def required2list(required):
477
        if isinstance(required, list) or isinstance(required, tuple):
478
            terms = []
479
            for r in required:
480
                terms.append(ArgumentParseManager.required2list(r))
481
            return list(set(terms).union())
482
        return required
483

    
484
    @staticmethod
485
    def required2str(required, arguments, tab=''):
486
        if isinstance(required, list):
487
            return ' %sat least one:\n%s' % (tab, ''.join(
488
                [ArgumentParseManager.required2str(
489
                    r, arguments, tab + '  ') for r in required]))
490
        elif isinstance(required, tuple):
491
            return ' %sall:\n%s' % (tab, ''.join(
492
                [ArgumentParseManager.required2str(
493
                    r, arguments, tab + '  ') for r in required]))
494
        else:
495
            lt_pn, lt_all, arg = 23, 80, arguments[required]
496
            tab2 = ' ' * lt_pn
497
            ret = '%s%s' % (tab, ', '.join(arg.parsed_name))
498
            if arg.arity != 0:
499
                ret += ' %s' % required.upper()
500
            ret = ('{:<%s}' % lt_pn).format(ret)
501
            prefix = ('\n%s' % tab2) if len(ret) > lt_pn else ' '
502
            cur = 0
503
            while arg.help[cur:]:
504
                next = cur + lt_all - lt_pn
505
                ret += prefix
506
                ret += ('{:<%s}' % (lt_all - lt_pn)).format(arg.help[cur:next])
507
                cur, finish = next, '\n%s' % tab2
508
            return ret + '\n'
509

    
510
    @staticmethod
511
    def _patch_with_required_args(arguments, required):
512
        if isinstance(required, tuple):
513
            return ' '.join([ArgumentParseManager._patch_with_required_args(
514
                arguments, k) for k in required])
515
        elif isinstance(required, list):
516
            return '< %s >' % ' | '.join([
517
                ArgumentParseManager._patch_with_required_args(
518
                    arguments, k) for k in required])
519
        arg = arguments[required]
520
        return '/'.join(arg.parsed_name) + (
521
            ' %s [...]' % required.upper() if arg.arity < 0 else (
522
                ' %s' % required.upper() if arg.arity else ''))
523

    
524
    def print_help(self, out=stderr):
525
        if self.required:
526
            tmp_args = dict(self.arguments)
527
            for term in self.required2list(self.required):
528
                tmp_args.pop(term)
529
            tmp_parser = ArgumentParseManager(self._exe, tmp_args)
530
            tmp_parser.syntax = self.syntax + self._patch_with_required_args(
531
                self.arguments, self.required)
532
            tmp_parser.parser.description = '%s\n\nrequired arguments:\n%s' % (
533
                self.parser.description,
534
                self.required2str(self.required, self.arguments))
535
            tmp_parser.update_parser()
536
            tmp_parser.parser.print_help()
537
        else:
538
            self.parser.print_help()
539

    
540
    @property
541
    def syntax(self):
542
        """The command syntax (useful for help messages, descriptions, etc)"""
543
        return self.parser.prog
544

    
545
    @syntax.setter
546
    def syntax(self, new_syntax):
547
        self.parser.prog = new_syntax
548

    
549
    @property
550
    def arguments(self):
551
        """:returns: (dict) arguments the parser should be aware of"""
552
        return self._arguments
553

    
554
    @arguments.setter
555
    def arguments(self, new_arguments):
556
        assert isinstance(new_arguments, dict), 'Arguments must be in a dict'
557
        self._arguments = new_arguments
558
        self.update_parser()
559

    
560
    @property
561
    def parsed(self):
562
        """(Namespace) parser-matched terms"""
563
        if self._parser_modified:
564
            self.parse()
565
        return self._parsed
566

    
567
    @property
568
    def unparsed(self):
569
        """(list) parser-unmatched terms"""
570
        if self._parser_modified:
571
            self.parse()
572
        return self._unparsed
573

    
574
    def update_parser(self, arguments=None):
575
        """Load argument specifications to parser
576

577
        :param arguments: if not given, update self.arguments instead
578
        """
579
        arguments = arguments or self._arguments
580

    
581
        for name, arg in arguments.items():
582
            try:
583
                arg.update_parser(self.parser, name)
584
                self._parser_modified = True
585
            except ArgumentError:
586
                pass
587

    
588
    def update_arguments(self, new_arguments):
589
        """Add to / update existing arguments
590

591
        :param new_arguments: (dict)
592
        """
593
        if new_arguments:
594
            assert isinstance(new_arguments, dict), 'Arguments not in dict !!!'
595
            self._arguments.update(new_arguments)
596
            self.update_parser()
597

    
598
    def _parse_required_arguments(self, required, parsed_args):
599
        if not required:
600
            return True
601
        if isinstance(required, tuple):
602
            for item in required:
603
                if not self._parse_required_arguments(item, parsed_args):
604
                    return False
605
            return True
606
        if isinstance(required, list):
607
            for item in required:
608
                if self._parse_required_arguments(item, parsed_args):
609
                    return True
610
            return False
611
        return required in parsed_args
612

    
613
    def parse(self, new_args=None):
614
        """Parse user input"""
615
        try:
616
            pkargs = (new_args,) if new_args else ()
617
            self._parsed, unparsed = self.parser.parse_known_args(*pkargs)
618
            parsed_args = [
619
                k for k, v in vars(self._parsed).items() if v not in (None, )]
620
            if not self._parse_required_arguments(self.required, parsed_args):
621
                self.print_help()
622
                raise CLISyntaxError('Missing required arguments')
623
        except SystemExit:
624
            raiseCLIError(CLISyntaxError('Argument Syntax Error'))
625
        for name, arg in self.arguments.items():
626
            arg.value = getattr(self._parsed, name, arg.default)
627
        self._unparsed = []
628
        for term in unparsed:
629
            self._unparsed += split_input(' \'%s\' ' % term)
630
        self._parser_modified = False