384e3c43cbfb592093c61232b087d448145bd68f
[kamaki] / kamaki / cli / argument / __init__.py
1 # Copyright 2012-2013 GRNET S.A. All rights reserved.
2 #
3 # Redistribution and use in source and binary forms, with or
4 # without modification, are permitted provided that the following
5 # conditions are met:
6 #
7 #   1. Redistributions of source code must retain the above
8 #     copyright notice, this list of conditions and the following
9 #     disclaimer.
10 #
11 #   2. Redistributions in binary form must reproduce the above
12 #     copyright notice, this list of conditions and the following
13 #     disclaimer in the documentation and/or other materials
14 #     provided with the distribution.
15 #
16 # THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
17 # OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
19 # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
20 # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
23 # USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
24 # AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
25 # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
26 # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27 # POSSIBILITY OF SUCH DAMAGE.
28 #
29 # The views and conclusions contained in the software and
30 # documentation are those of the authors and should not be
31 # interpreted as representing official policies, either expressed
32 # or implied, of GRNET S.A.
33
34 from kamaki.cli.config import Config
35 from kamaki.cli.errors import CLISyntaxError, raiseCLIError
36 from kamaki.cli.utils import split_input, to_bytes
37
38 from datetime import datetime as dtm
39 from time import mktime
40 from sys import stderr
41
42 from logging import getLogger
43 from argparse import ArgumentParser, ArgumentError
44 from argparse import RawDescriptionHelpFormatter
45 from progress.bar import ShadyBar as KamakiProgressBar
46
47 log = getLogger(__name__)
48
49
50 class Argument(object):
51     """An argument that can be parsed from command line or otherwise.
52     This is the top-level Argument class. It is suggested to extent this
53     class into more specific argument types.
54     """
55     lvalue_delimiter = '/'
56
57     def __init__(self, arity, help=None, parsed_name=None, default=None):
58         self.arity = int(arity)
59         self.help = '%s' % help or ''
60
61         assert parsed_name, 'No parsed name for argument %s' % self
62         self.parsed_name = list(parsed_name) if isinstance(
63             parsed_name, list) or isinstance(parsed_name, tuple) else (
64                 '%s' % parsed_name).split()
65         for name in self.parsed_name:
66             assert name.count(' ') == 0, '%s: Invalid parse name "%s"' % (
67                 self, name)
68             msg = '%s: Invalid parse name "%s" should start with a "-"' % (
69                     self, name)
70             assert name.startswith('-'), msg
71
72         self.default = default or None
73
74     @property
75     def value(self):
76         return getattr(self, '_value', self.default)
77
78     @value.setter
79     def value(self, newvalue):
80         self._value = newvalue
81
82     def update_parser(self, parser, name):
83         """Update argument parser with self info"""
84         action = 'append' if self.arity < 0 else (
85             'store' if self.arity else 'store_true')
86         parser.add_argument(
87             *self.parsed_name,
88             dest=name, action=action, default=self.default, help=self.help)
89
90     @property
91     def lvalue(self):
92         """A printable form of the left value when calling an argument e.g.,
93         --left-value=right-value"""
94         return (self.lvalue_delimiter or ' ').join(self.parsed_name or [])
95
96
97 class ConfigArgument(Argument):
98     """Manage a kamaki configuration (file)"""
99
100     def __init__(self, help, parsed_name=('-c', '--config')):
101         super(ConfigArgument, self).__init__(1, help, parsed_name, None)
102         self.file_path = None
103
104     @property
105     def value(self):
106         return getattr(self, '_value', None)
107
108     @value.setter
109     def value(self, config_file):
110         if config_file:
111             self._value = Config(config_file)
112             self.file_path = config_file
113         elif self.file_path:
114             self._value = Config(self.file_path)
115         else:
116             self._value = Config()
117
118     def get(self, group, term):
119         """Get a configuration setting from the Config object"""
120         return self.value.get(group, term)
121
122     @property
123     def groups(self):
124         suffix = '_cli'
125         slen = len(suffix)
126         return [term[:-slen] for term in self.value.keys('global') if (
127             term.endswith(suffix))]
128
129     @property
130     def cli_specs(self):
131         suffix = '_cli'
132         slen = len(suffix)
133         return [(k[:-slen], v) for k, v in self.value.items('global') if (
134             k.endswith(suffix))]
135
136     def get_global(self, option):
137         return self.value.get('global', option)
138
139     def get_cloud(self, cloud, option):
140         return self.value.get_cloud(cloud, option)
141
142
143 _config_arg = ConfigArgument('Path to config file')
144
145
146 class RuntimeConfigArgument(Argument):
147     """Set a run-time setting option (not persistent)"""
148
149     def __init__(self, config_arg, help='', parsed_name=None, default=None):
150         super(self.__class__, self).__init__(1, help, parsed_name, default)
151         self._config_arg = config_arg
152
153     @property
154     def value(self):
155         return super(RuntimeConfigArgument, self).value
156
157     @value.setter
158     def value(self, options):
159         if options == self.default:
160             return
161         if not isinstance(options, list):
162             options = ['%s' % options]
163         for option in options:
164             keypath, sep, val = option.partition('=')
165             if not sep:
166                 raiseCLIError(
167                     CLISyntaxError('Argument Syntax Error '),
168                     details=[
169                         '%s is missing a "="',
170                         ' (usage: -o section.key=val)' % option])
171             section, sep, key = keypath.partition('.')
172         if not sep:
173             key = section
174             section = 'global'
175         self._config_arg.value.override(
176             section.strip(),
177             key.strip(),
178             val.strip())
179
180
181 class FlagArgument(Argument):
182     """
183     :value: true if set, false otherwise
184     """
185
186     def __init__(self, help='', parsed_name=None, default=None):
187         super(FlagArgument, self).__init__(0, help, parsed_name, default)
188
189
190 class ValueArgument(Argument):
191     """
192     :value type: string
193     :value returns: given value or default
194     """
195
196     def __init__(self, help='', parsed_name=None, default=None):
197         super(ValueArgument, self).__init__(1, help, parsed_name, default)
198
199
200 class CommaSeparatedListArgument(ValueArgument):
201     """
202     :value type: string
203     :value returns: list of the comma separated values
204     """
205
206     @property
207     def value(self):
208         return self._value or list()
209
210     @value.setter
211     def value(self, newvalue):
212         self._value = newvalue.split(',') if newvalue else list()
213
214
215 class IntArgument(ValueArgument):
216
217     @property
218     def value(self):
219         """integer (type checking)"""
220         return getattr(self, '_value', self.default)
221
222     @value.setter
223     def value(self, newvalue):
224         if newvalue == self.default:
225             self._value = newvalue
226             return
227         try:
228             if int(newvalue) == float(newvalue):
229                 self._value = int(newvalue)
230             else:
231                 raise ValueError('Raise int argument error')
232         except ValueError:
233             raiseCLIError(CLISyntaxError(
234                 'IntArgument Error',
235                 details=['Value %s not an int' % newvalue]))
236
237
238 class DataSizeArgument(ValueArgument):
239     """Input: a string of the form <number><unit>
240     Output: the number of bytes
241     Units: B, KiB, KB, MiB, MB, GiB, GB, TiB, TB
242     """
243
244     @property
245     def value(self):
246         return getattr(self, '_value', self.default)
247
248     def _calculate_limit(self, user_input):
249         limit = 0
250         try:
251             limit = int(user_input)
252         except ValueError:
253             index = 0
254             digits = [str(num) for num in range(0, 10)] + ['.']
255             while user_input[index] in digits:
256                 index += 1
257             limit = user_input[:index]
258             format = user_input[index:]
259             try:
260                 return to_bytes(limit, format)
261             except Exception as qe:
262                 msg = 'Failed to convert %s to bytes' % user_input,
263                 raiseCLIError(qe, msg, details=[
264                     'Syntax: containerlimit set <limit>[format] [container]',
265                     'e.g.,: containerlimit set 2.3GB mycontainer',
266                     'Valid formats:',
267                     '(*1024): B, KiB, MiB, GiB, TiB',
268                     '(*1000): B, KB, MB, GB, TB'])
269         return limit
270
271     @value.setter
272     def value(self, new_value):
273         if new_value:
274             self._value = self._calculate_limit(new_value)
275
276
277 class DateArgument(ValueArgument):
278
279     DATE_FORMAT = '%a %b %d %H:%M:%S %Y'
280
281     INPUT_FORMATS = [DATE_FORMAT, '%d-%m-%Y', '%H:%M:%S %d-%m-%Y']
282
283     @property
284     def timestamp(self):
285         v = getattr(self, '_value', self.default)
286         return mktime(v.timetuple()) if v else None
287
288     @property
289     def formated(self):
290         v = getattr(self, '_value', self.default)
291         return v.strftime(self.DATE_FORMAT) if v else None
292
293     @property
294     def value(self):
295         return self.timestamp
296
297     @value.setter
298     def value(self, newvalue):
299         self._value = self.format_date(newvalue) if newvalue else self.default
300
301     def format_date(self, datestr):
302         for format in self.INPUT_FORMATS:
303             try:
304                 t = dtm.strptime(datestr, format)
305             except ValueError:
306                 continue
307             return t  # .strftime(self.DATE_FORMAT)
308         raiseCLIError(None, 'Date Argument Error', details=[
309             '%s not a valid date' % datestr,
310             'Correct formats:\n\t%s' % self.INPUT_FORMATS])
311
312
313 class VersionArgument(FlagArgument):
314     """A flag argument with that prints current version"""
315
316     @property
317     def value(self):
318         """bool"""
319         return super(self.__class__, self).value
320
321     @value.setter
322     def value(self, newvalue):
323         self._value = newvalue
324         if newvalue:
325             import kamaki
326             print('kamaki %s' % kamaki.__version__)
327
328
329 class RepeatableArgument(Argument):
330     """A value argument that can be repeated"""
331
332     def __init__(self, help='', parsed_name=None, default=None):
333         super(RepeatableArgument, self).__init__(
334             -1, help, parsed_name, default)
335
336     @property
337     def value(self):
338         return getattr(self, '_value', [])
339
340     @value.setter
341     def value(self, newvalue):
342         self._value = newvalue
343
344
345 class KeyValueArgument(Argument):
346     """A Key=Value Argument that can be repeated
347
348     :syntax: --<arg> key1=value1 --<arg> key2=value2 ...
349     """
350
351     def __init__(self, help='', parsed_name=None, default=None):
352         super(KeyValueArgument, self).__init__(-1, help, parsed_name, default)
353
354     @property
355     def value(self):
356         """
357         :returns: (dict) {key1: val1, key2: val2, ...}
358         """
359         return getattr(self, '_value', {})
360
361     @value.setter
362     def value(self, keyvalue_pairs):
363         """
364         :param keyvalue_pairs: (str) ['key1=val1', 'key2=val2', ...]
365         """
366         if keyvalue_pairs:
367             self._value = self.value
368             try:
369                 for pair in keyvalue_pairs:
370                     key, sep, val = pair.partition('=')
371                     assert sep, ' %s misses a "=" (usage: key1=val1 )\n' % (
372                         pair)
373                     self._value[key] = val
374             except Exception as e:
375                 raiseCLIError(e, 'KeyValueArgument Syntax Error')
376
377
378 class ProgressBarArgument(FlagArgument):
379     """Manage a progress bar"""
380
381     def __init__(self, help='', parsed_name='', default=True):
382         self.suffix = '%(percent)d%%'
383         super(ProgressBarArgument, self).__init__(help, parsed_name, default)
384
385     def clone(self):
386         """Get a modifiable copy of this bar"""
387         newarg = ProgressBarArgument(
388             self.help, self.parsed_name, self.default)
389         newarg._value = self._value
390         return newarg
391
392     def get_generator(
393             self, message, message_len=25, countdown=False, timeout=100):
394         """Get a generator to handle progress of the bar (gen.next())"""
395         if self.value:
396             return None
397         try:
398             self.bar = KamakiProgressBar(
399                 message.ljust(message_len), max=timeout or 100)
400         except NameError:
401             self.value = None
402             return self.value
403         if countdown:
404             bar_phases = list(self.bar.phases)
405             self.bar.empty_fill, bar_phases[0] = bar_phases[-1], ''
406             bar_phases.reverse()
407             self.bar.phases = bar_phases
408             self.bar.bar_prefix = ' '
409             self.bar.bar_suffix = ' '
410             self.bar.suffix = '%(remaining)ds to timeout'
411         else:
412             self.bar.suffix = '%(percent)d%% - %(eta)ds'
413         self.bar.start()
414
415         def progress_gen(n):
416             for i in self.bar.iter(range(int(n))):
417                 yield
418             yield
419         return progress_gen
420
421     def finish(self):
422         """Stop progress bar, return terminal cursor to user"""
423         if self.value:
424             return
425         mybar = getattr(self, 'bar', None)
426         if mybar:
427             mybar.finish()
428
429
430 _arguments = dict(
431     config=_config_arg,
432     cloud=ValueArgument('Chose a cloud to connect to', ('--cloud')),
433     help=Argument(0, 'Show help message', ('-h', '--help')),
434     debug=FlagArgument('Include debug output', ('-d', '--debug')),
435     #include=FlagArgument(
436     #    'Include raw connection data in the output', ('-i', '--include')),
437     silent=FlagArgument('Do not output anything', ('-s', '--silent')),
438     verbose=FlagArgument('More info at response', ('-v', '--verbose')),
439     version=VersionArgument('Print current version', ('-V', '--version')),
440     options=RuntimeConfigArgument(
441         _config_arg, 'Override a config value', ('-o', '--options'))
442 )
443
444
445 #  Initial command line interface arguments
446
447
448 class ArgumentParseManager(object):
449     """Manage (initialize and update) an ArgumentParser object"""
450
451     def __init__(
452             self, exe,
453             arguments=None, required=None, syntax=None, description=None,
454             check_required=True):
455         """
456         :param exe: (str) the basic command (e.g. 'kamaki')
457
458         :param arguments: (dict) if given, overrides the global _argument as
459             the parsers arguments specification
460         :param required: (list or tuple) an iterable of argument keys, denoting
461             which arguments are required. A tuple denoted an AND relation,
462             while a list denotes an OR relation e.g., ['a', 'b'] means that
463             either 'a' or 'b' is required, while ('a', 'b') means that both 'a'
464             and 'b' ar required.
465             Nesting is allowed e.g., ['a', ('b', 'c'), ['d', 'e']] means that
466             this command required either 'a', or both 'b' and 'c', or one of
467             'd', 'e'.
468             Repeated arguments are also allowed e.g., [('a', 'b'), ('a', 'c'),
469             ['b', 'c']] means that the command required either 'a' and 'b' or
470             'a' and 'c' or at least one of 'b', 'c' and could be written as
471             [('a', ['b', 'c']), ['b', 'c']]
472         :param syntax: (str) The basic syntax of the arguments. Default:
473             exe <cmd_group> [<cmd_subbroup> ...] <cmd>
474         :param description: (str) The description of the commands or ''
475         :param check_required: (bool) Set to False inorder not to check for
476             required argument values while parsing
477         """
478         self.parser = ArgumentParser(
479             add_help=False, formatter_class=RawDescriptionHelpFormatter)
480         self._exe = exe
481         self.syntax = syntax or (
482             '%s <cmd_group> [<cmd_subbroup> ...] <cmd>' % exe)
483         self.required, self.check_required = required, check_required
484         self.parser.description = description or ''
485         if arguments:
486             self.arguments = arguments
487         else:
488             global _arguments
489             self.arguments = _arguments
490         self._parser_modified, self._parsed, self._unparsed = False, None, None
491         self.parse()
492
493     @staticmethod
494     def required2list(required):
495         if isinstance(required, list) or isinstance(required, tuple):
496             terms = []
497             for r in required:
498                 terms.append(ArgumentParseManager.required2list(r))
499             return list(set(terms).union())
500         return required
501
502     @staticmethod
503     def required2str(required, arguments, tab=''):
504         if isinstance(required, list):
505             return ' %sat least one of the following:\n%s' % (tab, ''.join(
506                 [ArgumentParseManager.required2str(
507                     r, arguments, tab + '  ') for r in required]))
508         elif isinstance(required, tuple):
509             return ' %sall of the following:\n%s' % (tab, ''.join(
510                 [ArgumentParseManager.required2str(
511                     r, arguments, tab + '  ') for r in required]))
512         else:
513             lt_pn, lt_all, arg = 23, 80, arguments[required]
514             tab2 = ' ' * lt_pn
515             ret = '%s%s' % (tab, ', '.join(arg.parsed_name))
516             if arg.arity != 0:
517                 ret += ' %s' % required.upper()
518             ret = ('{:<%s}' % lt_pn).format(ret)
519             prefix = ('\n%s' % tab2) if len(ret) > lt_pn else ' '
520             cur = 0
521             while arg.help[cur:]:
522                 next = cur + lt_all - lt_pn
523                 ret += prefix
524                 ret += ('{:<%s}' % (lt_all - lt_pn)).format(arg.help[cur:next])
525                 cur, finish = next, '\n%s' % tab2
526             return ret + '\n'
527
528     @staticmethod
529     def _patch_with_required_args(arguments, required):
530         if isinstance(required, tuple):
531             return ' '.join([ArgumentParseManager._patch_with_required_args(
532                 arguments, k) for k in required])
533         elif isinstance(required, list):
534             return '< %s >' % ' | '.join([
535                 ArgumentParseManager._patch_with_required_args(
536                     arguments, k) for k in required])
537         arg = arguments[required]
538         return '/'.join(arg.parsed_name) + (
539             ' %s [...]' % required.upper() if arg.arity < 0 else (
540                 ' %s' % required.upper() if arg.arity else ''))
541
542     def print_help(self, out=stderr):
543         if self.required:
544             tmp_args = dict(self.arguments)
545             for term in self.required2list(self.required):
546                 tmp_args.pop(term)
547             tmp_parser = ArgumentParseManager(self._exe, tmp_args)
548             tmp_parser.syntax = self.syntax + self._patch_with_required_args(
549                 self.arguments, self.required)
550             tmp_parser.parser.description = '%s\n\nrequired arguments:\n%s' % (
551                 self.parser.description,
552                 self.required2str(self.required, self.arguments))
553             tmp_parser.update_parser()
554             tmp_parser.parser.print_help()
555         else:
556             self.parser.print_help()
557
558     @property
559     def syntax(self):
560         """The command syntax (useful for help messages, descriptions, etc)"""
561         return self.parser.prog
562
563     @syntax.setter
564     def syntax(self, new_syntax):
565         self.parser.prog = new_syntax
566
567     @property
568     def arguments(self):
569         """:returns: (dict) arguments the parser should be aware of"""
570         return self._arguments
571
572     @arguments.setter
573     def arguments(self, new_arguments):
574         assert isinstance(new_arguments, dict), 'Arguments must be in a dict'
575         self._arguments = new_arguments
576         self.update_parser()
577
578     @property
579     def parsed(self):
580         """(Namespace) parser-matched terms"""
581         if self._parser_modified:
582             self.parse()
583         return self._parsed
584
585     @property
586     def unparsed(self):
587         """(list) parser-unmatched terms"""
588         if self._parser_modified:
589             self.parse()
590         return self._unparsed
591
592     def update_parser(self, arguments=None):
593         """Load argument specifications to parser
594
595         :param arguments: if not given, update self.arguments instead
596         """
597         arguments = arguments or self._arguments
598
599         for name, arg in arguments.items():
600             try:
601                 arg.update_parser(self.parser, name)
602                 self._parser_modified = True
603             except ArgumentError:
604                 pass
605
606     def update_arguments(self, new_arguments):
607         """Add to / update existing arguments
608
609         :param new_arguments: (dict)
610         """
611         if new_arguments:
612             assert isinstance(new_arguments, dict), 'Arguments not in dict !!!'
613             self._arguments.update(new_arguments)
614             self.update_parser()
615
616     def _parse_required_arguments(self, required, parsed_args):
617         if not (self.check_required and required):
618             return True
619         if isinstance(required, tuple):
620             for item in required:
621                 if not self._parse_required_arguments(item, parsed_args):
622                     return False
623             return True
624         if isinstance(required, list):
625             for item in required:
626                 if self._parse_required_arguments(item, parsed_args):
627                     return True
628             return False
629         return required in parsed_args
630
631     def parse(self, new_args=None):
632         """Parse user input"""
633         try:
634             pkargs = (new_args,) if new_args else ()
635             self._parsed, unparsed = self.parser.parse_known_args(*pkargs)
636             parsed_args = [
637                 k for k, v in vars(self._parsed).items() if v not in (None, )]
638             if not self._parse_required_arguments(self.required, parsed_args):
639                 self.print_help()
640                 raise CLISyntaxError('Missing required arguments')
641         except SystemExit:
642             raiseCLIError(CLISyntaxError('Argument Syntax Error'))
643         for name, arg in self.arguments.items():
644             arg.value = getattr(self._parsed, name, arg.default)
645         self._unparsed = []
646         for term in unparsed:
647             self._unparsed += split_input(' \'%s\' ' % term)
648         self._parser_modified = False