Statistics
| Branch: | Tag: | Revision:

root / kamaki / cli / argument / __init__.py @ 264a13f7

History | View | Annotate | Download (20.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
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__(
398
            self, exe,
399
            arguments=None, required=None, syntax=None, description=None):
400
        """
401
        :param exe: (str) the basic command (e.g. 'kamaki')
402

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

    
436
    @staticmethod
437
    def required2list(required):
438
        if isinstance(required, list) or isinstance(required, tuple):
439
            terms = []
440
            for r in required:
441
                terms.append(ArgumentParseManager.required2list(r))
442
            return list(set(terms).union())
443
        return required
444

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

    
471
    @staticmethod
472
    def _patch_with_required_args(arguments, required):
473
        if isinstance(required, tuple):
474
            return ' '.join([ArgumentParseManager._patch_with_required_args(
475
                arguments, k) for k in required])
476
        elif isinstance(required, list):
477
            return '< %s >' % ' | '.join([
478
                ArgumentParseManager._patch_with_required_args(
479
                    arguments, k) for k in required])
480
        arg = arguments[required]
481
        return '/'.join(arg.parsed_name) + (
482
            ' %s [...]' % required.upper() if arg.arity < 0 else (
483
                ' %s' % required.upper() if arg.arity else ''))
484

    
485
    def print_help(self, out=stderr):
486
        if self.required:
487
            tmp_args = dict(self.arguments)
488
            for term in self.required2list(self.required):
489
                tmp_args.pop(term)
490
            tmp_parser = ArgumentParseManager(self._exe, tmp_args)
491
            tmp_parser.syntax = self.syntax + self._patch_with_required_args(
492
                self.arguments, self.required)
493
            tmp_parser.parser.description = '%s\n\nrequired arguments:\n%s' % (
494
                self.parser.description,
495
                self.required2str(self.required, self.arguments))
496
            tmp_parser.update_parser()
497
            tmp_parser.parser.print_help()
498
        else:
499
            self.parser.print_help()
500

    
501
    @property
502
    def syntax(self):
503
        """The command syntax (useful for help messages, descriptions, etc)"""
504
        return self.parser.prog
505

    
506
    @syntax.setter
507
    def syntax(self, new_syntax):
508
        self.parser.prog = new_syntax
509

    
510
    @property
511
    def arguments(self):
512
        """:returns: (dict) arguments the parser should be aware of"""
513
        return self._arguments
514

    
515
    @arguments.setter
516
    def arguments(self, new_arguments):
517
        assert isinstance(new_arguments, dict), 'Arguments must be in a dict'
518
        self._arguments = new_arguments
519
        self.update_parser()
520

    
521
    @property
522
    def parsed(self):
523
        """(Namespace) parser-matched terms"""
524
        if self._parser_modified:
525
            self.parse()
526
        return self._parsed
527

    
528
    @property
529
    def unparsed(self):
530
        """(list) parser-unmatched terms"""
531
        if self._parser_modified:
532
            self.parse()
533
        return self._unparsed
534

    
535
    def update_parser(self, arguments=None):
536
        """Load argument specifications to parser
537

538
        :param arguments: if not given, update self.arguments instead
539
        """
540
        arguments = arguments or self._arguments
541

    
542
        for name, arg in arguments.items():
543
            try:
544
                arg.update_parser(self.parser, name)
545
                self._parser_modified = True
546
            except ArgumentError:
547
                pass
548

    
549
    def update_arguments(self, new_arguments):
550
        """Add to / update existing arguments
551

552
        :param new_arguments: (dict)
553
        """
554
        if new_arguments:
555
            assert isinstance(new_arguments, dict), 'Arguments not in dict !!!'
556
            self._arguments.update(new_arguments)
557
            self.update_parser()
558

    
559
    def _parse_required_arguments(self, required, parsed_args):
560
        if not required:
561
            return True
562
        if isinstance(required, tuple):
563
            for item in required:
564
                if not self._parse_required_arguments(item, parsed_args):
565
                    return False
566
            return True
567
        if isinstance(required, list):
568
            for item in required:
569
                if self._parse_required_arguments(item, parsed_args):
570
                    return True
571
            return False
572
        return required in parsed_args
573

    
574
    def parse(self, new_args=None):
575
        """Parse user input"""
576
        try:
577
            pkargs = (new_args,) if new_args else ()
578
            self._parsed, unparsed = self.parser.parse_known_args(*pkargs)
579
            parsed_args = [
580
                k for k, v in vars(self._parsed).items() if v not in (None, )]
581
            if not self._parse_required_arguments(self.required, parsed_args):
582
                self.print_help()
583
                raise CLISyntaxError('Missing required arguments')
584
        except SystemExit:
585
            raiseCLIError(CLISyntaxError('Argument Syntax Error'))
586
        for name, arg in self.arguments.items():
587
            arg.value = getattr(self._parsed, name, arg.default)
588
        self._unparsed = []
589
        for term in unparsed:
590
            self._unparsed += split_input(' \'%s\' ' % term)
591
        self._parser_modified = False