Statistics
| Branch: | Tag: | Revision:

root / kamaki / cli / argument / __init__.py @ 56d84a4e

History | View | Annotate | Download (18.7 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
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 DateArgument(ValueArgument):
232

    
233
    DATE_FORMAT = '%a %b %d %H:%M:%S %Y'
234

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

    
237
    @property
238
    def timestamp(self):
239
        v = getattr(self, '_value', self.default)
240
        return mktime(v.timetuple()) if v else None
241

    
242
    @property
243
    def formated(self):
244
        v = getattr(self, '_value', self.default)
245
        return v.strftime(self.DATE_FORMAT) if v else None
246

    
247
    @property
248
    def value(self):
249
        return self.timestamp
250

    
251
    @value.setter
252
    def value(self, newvalue):
253
        self._value = self.format_date(newvalue) if newvalue else self.default
254

    
255
    def format_date(self, datestr):
256
        for format in self.INPUT_FORMATS:
257
            try:
258
                t = dtm.strptime(datestr, format)
259
            except ValueError:
260
                continue
261
            return t  # .strftime(self.DATE_FORMAT)
262
        raiseCLIError(None, 'Date Argument Error', details=[
263
            '%s not a valid date' % datestr,
264
            'Correct formats:\n\t%s' % self.INPUT_FORMATS])
265

    
266

    
267
class VersionArgument(FlagArgument):
268
    """A flag argument with that prints current version"""
269

    
270
    @property
271
    def value(self):
272
        """bool"""
273
        return super(self.__class__, self).value
274

    
275
    @value.setter
276
    def value(self, newvalue):
277
        self._value = newvalue
278
        if newvalue:
279
            import kamaki
280
            print('kamaki %s' % kamaki.__version__)
281

    
282

    
283
class RepeatableArgument(Argument):
284
    """A value argument that can be repeated"""
285

    
286
    def __init__(self, help='', parsed_name=None, default=[]):
287
        super(RepeatableArgument, self).__init__(
288
            -1, help, parsed_name, default)
289

    
290

    
291
class KeyValueArgument(Argument):
292
    """A Key=Value Argument that can be repeated
293

294
    :syntax: --<arg> key1=value1 --<arg> key2=value2 ...
295
    """
296

    
297
    def __init__(self, help='', parsed_name=None, default=[]):
298
        super(KeyValueArgument, self).__init__(-1, help, parsed_name, default)
299

    
300
    @property
301
    def value(self):
302
        """
303
        :returns: (dict) {key1: val1, key2: val2, ...}
304
        """
305
        return super(KeyValueArgument, self).value
306

    
307
    @value.setter
308
    def value(self, keyvalue_pairs):
309
        """
310
        :param keyvalue_pairs: (str) ['key1=val1', 'key2=val2', ...]
311
        """
312
        self._value = getattr(self, '_value', self.value) or {}
313
        try:
314
            for pair in keyvalue_pairs:
315
                key, sep, val = pair.partition('=')
316
                assert sep, ' %s misses a "=" (usage: key1=val1 )\n' % (pair)
317
                self._value[key] = val
318
        except Exception as e:
319
            raiseCLIError(e, 'KeyValueArgument Syntax Error')
320

    
321

    
322
class ProgressBarArgument(FlagArgument):
323
    """Manage a progress bar"""
324

    
325
    def __init__(self, help='', parsed_name='', default=True):
326
        self.suffix = '%(percent)d%%'
327
        super(ProgressBarArgument, self).__init__(help, parsed_name, default)
328

    
329
    def clone(self):
330
        """Get a modifiable copy of this bar"""
331
        newarg = ProgressBarArgument(
332
            self.help, self.parsed_name, self.default)
333
        newarg._value = self._value
334
        return newarg
335

    
336
    def get_generator(
337
            self, message, message_len=25, countdown=False, timeout=100):
338
        """Get a generator to handle progress of the bar (gen.next())"""
339
        if self.value:
340
            return None
341
        try:
342
            self.bar = KamakiProgressBar()
343
        except NameError:
344
            self.value = None
345
            return self.value
346
        if countdown:
347
            bar_phases = list(self.bar.phases)
348
            self.bar.empty_fill, bar_phases[0] = bar_phases[-1], ''
349
            bar_phases.reverse()
350
            self.bar.phases = bar_phases
351
            self.bar.bar_prefix = ' '
352
            self.bar.bar_suffix = ' '
353
            self.bar.max = timeout or 100
354
            self.bar.suffix = '%(remaining)ds to timeout'
355
        else:
356
            self.bar.suffix = '%(percent)d%% - %(eta)ds'
357
        self.bar.eta = timeout or 100
358
        self.bar.message = message.ljust(message_len)
359
        self.bar.start()
360

    
361
        def progress_gen(n):
362
            for i in self.bar.iter(range(int(n))):
363
                yield
364
            yield
365
        return progress_gen
366

    
367
    def finish(self):
368
        """Stop progress bar, return terminal cursor to user"""
369
        if self.value:
370
            return
371
        mybar = getattr(self, 'bar', None)
372
        if mybar:
373
            mybar.finish()
374

    
375

    
376
_arguments = dict(
377
    config=_config_arg,
378
    cloud=ValueArgument('Chose a cloud to connect to', ('--cloud')),
379
    help=Argument(0, 'Show help message', ('-h', '--help')),
380
    debug=FlagArgument('Include debug output', ('-d', '--debug')),
381
    #include=FlagArgument(
382
    #    'Include raw connection data in the output', ('-i', '--include')),
383
    silent=FlagArgument('Do not output anything', ('-s', '--silent')),
384
    verbose=FlagArgument('More info at response', ('-v', '--verbose')),
385
    version=VersionArgument('Print current version', ('-V', '--version')),
386
    options=RuntimeConfigArgument(
387
        _config_arg, 'Override a config value', ('-o', '--options'))
388
)
389

    
390

    
391
#  Initial command line interface arguments
392

    
393

    
394
class ArgumentParseManager(object):
395
    """Manage (initialize and update) an ArgumentParser object"""
396

    
397
    def __init__(self, exe, arguments=None, required=None):
398
        """
399
        :param exe: (str) the basic command (e.g. 'kamaki')
400

401
        :param arguments: (dict) if given, overrides the global _argument as
402
            the parsers arguments specification
403
        :param required: (list or tuple) an iterable of argument keys, denoting
404
            which arguments are required. A tuple denoted an AND relation,
405
            while a list denotes an OR relation e.g., ['a', 'b'] means that
406
            either 'a' or 'b' is required, while ('a', 'b') means that both 'a'
407
            and 'b' ar required.
408
            Nesting is allowed e.g., ['a', ('b', 'c'), ['d', 'e']] means that
409
            this command required either 'a', or both 'b' and 'c', or one of
410
            'd', 'e'.
411
            Repeated arguments are also allowed e.g., [('a', 'b'), ('a', 'c'),
412
            ['b', 'c']] means that the command required either 'a' and 'b' or
413
            'a' and 'c' or at least one of 'b', 'c' and could be written as
414
            [('a', ['b', 'c']), ['b', 'c']]
415
        """
416
        self.parser = ArgumentParser(
417
            add_help=False, formatter_class=RawDescriptionHelpFormatter)
418
        self._exe = exe
419
        self.syntax = '%s <cmd_group> [<cmd_subbroup> ...] <cmd>' % exe
420
        self.required = required
421
        if arguments:
422
            self.arguments = arguments
423
        else:
424
            global _arguments
425
            self.arguments = _arguments
426
        self._parser_modified, self._parsed, self._unparsed = False, None, None
427
        self.parse()
428

    
429
    @staticmethod
430
    def required2list(required):
431
        if isinstance(required, list) or isinstance(required, tuple):
432
            terms = []
433
            for r in required:
434
                terms.append(ArgumentParseManager.required2list(r))
435
            return list(set(terms).union())
436
        return required
437

    
438
    @staticmethod
439
    def required2str(required, arguments, tab=''):
440
        if isinstance(required, list):
441
            return ' %sat least one:\n%s' % (tab, ''.join(
442
                [ArgumentParseManager.required2str(
443
                    r, arguments, tab + '  ') for r in required]))
444
        elif isinstance(required, tuple):
445
            return ' %sall:\n%s' % (tab, ''.join(
446
                [ArgumentParseManager.required2str(
447
                    r, arguments, tab + '  ') for r in required]))
448
        else:
449
            lt_pn, lt_all, arg = 23, 80, arguments[required]
450
            tab2 = ' ' * lt_pn
451
            ret = '%s%s' % (tab, ', '.join(arg.parsed_name))
452
            if arg.arity != 0:
453
                ret += ' %s' % required.upper()
454
            ret = ('{:<%s}' % lt_pn).format(ret)
455
            prefix = ('\n%s' % tab2) if len(ret) < lt_pn else ' '
456
            step, cur = (len(arg.help) / (lt_all - lt_pn)) or len(arg.help), 0
457
            while arg.help[cur:]:
458
                next = cur + step
459
                ret += prefix
460
                ret += ('{:<%s}' % (lt_all - lt_pn)).format(arg.help[cur:next])
461
                cur, finish = next, '\n%s' % tab2
462
            return ret + '\n'
463

    
464
    def print_help(self, out=stderr):
465
        if self.required:
466
            tmp_args = dict(self.arguments)
467
            for term in self.required2list(self.required):
468
                tmp_args.pop(term)
469
            tmp_parser = ArgumentParseManager(self._exe, tmp_args)
470
            tmp_parser.syntax = self.syntax
471
            tmp_parser.parser.description = '%s\n\nrequired arguments:\n%s' % (
472
                self.parser.description,
473
                self.required2str(self.required, self.arguments))
474
            tmp_parser.update_parser()
475
            tmp_parser.parser.print_help()
476
        else:
477
            self.parser.print_help()
478

    
479
    @property
480
    def syntax(self):
481
        """The command syntax (useful for help messages, descriptions, etc)"""
482
        return self.parser.prog
483

    
484
    @syntax.setter
485
    def syntax(self, new_syntax):
486
        self.parser.prog = new_syntax
487

    
488
    @property
489
    def arguments(self):
490
        """:returns: (dict) arguments the parser should be aware of"""
491
        return self._arguments
492

    
493
    @arguments.setter
494
    def arguments(self, new_arguments):
495
        assert isinstance(new_arguments, dict), 'Arguments must be in a dict'
496
        self._arguments = new_arguments
497
        self.update_parser()
498

    
499
    @property
500
    def parsed(self):
501
        """(Namespace) parser-matched terms"""
502
        if self._parser_modified:
503
            self.parse()
504
        return self._parsed
505

    
506
    @property
507
    def unparsed(self):
508
        """(list) parser-unmatched terms"""
509
        if self._parser_modified:
510
            self.parse()
511
        return self._unparsed
512

    
513
    def update_parser(self, arguments=None):
514
        """Load argument specifications to parser
515

516
        :param arguments: if not given, update self.arguments instead
517
        """
518
        arguments = arguments or self._arguments
519

    
520
        for name, arg in arguments.items():
521
            try:
522
                arg.update_parser(self.parser, name)
523
                self._parser_modified = True
524
            except ArgumentError:
525
                pass
526

    
527
    def update_arguments(self, new_arguments):
528
        """Add to / update existing arguments
529

530
        :param new_arguments: (dict)
531
        """
532
        if new_arguments:
533
            assert isinstance(new_arguments, dict), 'Arguments not in dict !!!'
534
            self._arguments.update(new_arguments)
535
            self.update_parser()
536

    
537
    def parse(self, new_args=None):
538
        """Parse user input"""
539
        try:
540
            pkargs = (new_args,) if new_args else ()
541
            self._parsed, unparsed = self.parser.parse_known_args(*pkargs)
542
            pdict = vars(self._parsed)
543
            diff = set(self.required or []).difference(
544
                [k for k in pdict if pdict[k] != None])
545
            if diff:
546
                self.print_help()
547
                miss = ['/'.join(self.arguments[k].parsed_name) for k in diff]
548
                raise CLISyntaxError(
549
                    'Missing required arguments (%s)' % ', '.join(miss))
550
        except SystemExit:
551
            raiseCLIError(CLISyntaxError('Argument Syntax Error'))
552
        for name, arg in self.arguments.items():
553
            arg.value = getattr(self._parsed, name, arg.default)
554
        self._unparsed = []
555
        for term in unparsed:
556
            self._unparsed += split_input(' \'%s\' ' % term)
557
        self._parser_modified = False