Allow ports without device_id in lib + waits
[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 super(ConfigArgument, self).value
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         except NameError:
400             self.value = None
401             return self.value
402         if countdown:
403             bar_phases = list(self.bar.phases)
404             self.bar.empty_fill, bar_phases[0] = bar_phases[-1], ''
405             bar_phases.reverse()
406             self.bar.phases = bar_phases
407             self.bar.bar_prefix = ' '
408             self.bar.bar_suffix = ' '
409             self.bar.max = timeout or 100
410             self.bar.suffix = '%(remaining)ds to timeout'
411         else:
412             self.bar.suffix = '%(percent)d%% - %(eta)ds'
413         self.bar.eta = timeout or 100
414         self.bar.message = message.ljust(message_len)
415         self.bar.start()
416
417         def progress_gen(n):
418             for i in self.bar.iter(range(int(n))):
419                 yield
420             yield
421         return progress_gen
422
423     def finish(self):
424         """Stop progress bar, return terminal cursor to user"""
425         if self.value:
426             return
427         mybar = getattr(self, 'bar', None)
428         if mybar:
429             mybar.finish()
430
431
432 _arguments = dict(
433     config=_config_arg,
434     cloud=ValueArgument('Chose a cloud to connect to', ('--cloud')),
435     help=Argument(0, 'Show help message', ('-h', '--help')),
436     debug=FlagArgument('Include debug output', ('-d', '--debug')),
437     #include=FlagArgument(
438     #    'Include raw connection data in the output', ('-i', '--include')),
439     silent=FlagArgument('Do not output anything', ('-s', '--silent')),
440     verbose=FlagArgument('More info at response', ('-v', '--verbose')),
441     version=VersionArgument('Print current version', ('-V', '--version')),
442     options=RuntimeConfigArgument(
443         _config_arg, 'Override a config value', ('-o', '--options'))
444 )
445
446
447 #  Initial command line interface arguments
448
449
450 class ArgumentParseManager(object):
451     """Manage (initialize and update) an ArgumentParser object"""
452
453     def __init__(
454             self, exe,
455             arguments=None, required=None, syntax=None, description=None):
456         """
457         :param exe: (str) the basic command (e.g. 'kamaki')
458
459         :param arguments: (dict) if given, overrides the global _argument as
460             the parsers arguments specification
461         :param required: (list or tuple) an iterable of argument keys, denoting
462             which arguments are required. A tuple denoted an AND relation,
463             while a list denotes an OR relation e.g., ['a', 'b'] means that
464             either 'a' or 'b' is required, while ('a', 'b') means that both 'a'
465             and 'b' ar required.
466             Nesting is allowed e.g., ['a', ('b', 'c'), ['d', 'e']] means that
467             this command required either 'a', or both 'b' and 'c', or one of
468             'd', 'e'.
469             Repeated arguments are also allowed e.g., [('a', 'b'), ('a', 'c'),
470             ['b', 'c']] means that the command required either 'a' and 'b' or
471             'a' and 'c' or at least one of 'b', 'c' and could be written as
472             [('a', ['b', 'c']), ['b', 'c']]
473         :param syntax: (str) The basic syntax of the arguments. Default:
474             exe <cmd_group> [<cmd_subbroup> ...] <cmd>
475         :param description: (str) The description of the commands or ''
476         """
477         self.parser = ArgumentParser(
478             add_help=False, formatter_class=RawDescriptionHelpFormatter)
479         self._exe = exe
480         self.syntax = syntax or (
481             '%s <cmd_group> [<cmd_subbroup> ...] <cmd>' % exe)
482         self.required = required
483         self.parser.description = description or ''
484         if arguments:
485             self.arguments = arguments
486         else:
487             global _arguments
488             self.arguments = _arguments
489         self._parser_modified, self._parsed, self._unparsed = False, None, None
490         self.parse()
491
492     @staticmethod
493     def required2list(required):
494         if isinstance(required, list) or isinstance(required, tuple):
495             terms = []
496             for r in required:
497                 terms.append(ArgumentParseManager.required2list(r))
498             return list(set(terms).union())
499         return required
500
501     @staticmethod
502     def required2str(required, arguments, tab=''):
503         if isinstance(required, list):
504             return ' %sat least one of the following:\n%s' % (tab, ''.join(
505                 [ArgumentParseManager.required2str(
506                     r, arguments, tab + '  ') for r in required]))
507         elif isinstance(required, tuple):
508             return ' %sall of the following:\n%s' % (tab, ''.join(
509                 [ArgumentParseManager.required2str(
510                     r, arguments, tab + '  ') for r in required]))
511         else:
512             lt_pn, lt_all, arg = 23, 80, arguments[required]
513             tab2 = ' ' * lt_pn
514             ret = '%s%s' % (tab, ', '.join(arg.parsed_name))
515             if arg.arity != 0:
516                 ret += ' %s' % required.upper()
517             ret = ('{:<%s}' % lt_pn).format(ret)
518             prefix = ('\n%s' % tab2) if len(ret) > lt_pn else ' '
519             cur = 0
520             while arg.help[cur:]:
521                 next = cur + lt_all - lt_pn
522                 ret += prefix
523                 ret += ('{:<%s}' % (lt_all - lt_pn)).format(arg.help[cur:next])
524                 cur, finish = next, '\n%s' % tab2
525             return ret + '\n'
526
527     @staticmethod
528     def _patch_with_required_args(arguments, required):
529         if isinstance(required, tuple):
530             return ' '.join([ArgumentParseManager._patch_with_required_args(
531                 arguments, k) for k in required])
532         elif isinstance(required, list):
533             return '< %s >' % ' | '.join([
534                 ArgumentParseManager._patch_with_required_args(
535                     arguments, k) for k in required])
536         arg = arguments[required]
537         return '/'.join(arg.parsed_name) + (
538             ' %s [...]' % required.upper() if arg.arity < 0 else (
539                 ' %s' % required.upper() if arg.arity else ''))
540
541     def print_help(self, out=stderr):
542         if self.required:
543             tmp_args = dict(self.arguments)
544             for term in self.required2list(self.required):
545                 tmp_args.pop(term)
546             tmp_parser = ArgumentParseManager(self._exe, tmp_args)
547             tmp_parser.syntax = self.syntax + self._patch_with_required_args(
548                 self.arguments, self.required)
549             tmp_parser.parser.description = '%s\n\nrequired arguments:\n%s' % (
550                 self.parser.description,
551                 self.required2str(self.required, self.arguments))
552             tmp_parser.update_parser()
553             tmp_parser.parser.print_help()
554         else:
555             self.parser.print_help()
556
557     @property
558     def syntax(self):
559         """The command syntax (useful for help messages, descriptions, etc)"""
560         return self.parser.prog
561
562     @syntax.setter
563     def syntax(self, new_syntax):
564         self.parser.prog = new_syntax
565
566     @property
567     def arguments(self):
568         """:returns: (dict) arguments the parser should be aware of"""
569         return self._arguments
570
571     @arguments.setter
572     def arguments(self, new_arguments):
573         assert isinstance(new_arguments, dict), 'Arguments must be in a dict'
574         self._arguments = new_arguments
575         self.update_parser()
576
577     @property
578     def parsed(self):
579         """(Namespace) parser-matched terms"""
580         if self._parser_modified:
581             self.parse()
582         return self._parsed
583
584     @property
585     def unparsed(self):
586         """(list) parser-unmatched terms"""
587         if self._parser_modified:
588             self.parse()
589         return self._unparsed
590
591     def update_parser(self, arguments=None):
592         """Load argument specifications to parser
593
594         :param arguments: if not given, update self.arguments instead
595         """
596         arguments = arguments or self._arguments
597
598         for name, arg in arguments.items():
599             try:
600                 arg.update_parser(self.parser, name)
601                 self._parser_modified = True
602             except ArgumentError:
603                 pass
604
605     def update_arguments(self, new_arguments):
606         """Add to / update existing arguments
607
608         :param new_arguments: (dict)
609         """
610         if new_arguments:
611             assert isinstance(new_arguments, dict), 'Arguments not in dict !!!'
612             self._arguments.update(new_arguments)
613             self.update_parser()
614
615     def _parse_required_arguments(self, required, parsed_args):
616         if not required:
617             return True
618         if isinstance(required, tuple):
619             for item in required:
620                 if not self._parse_required_arguments(item, parsed_args):
621                     return False
622             return True
623         if isinstance(required, list):
624             for item in required:
625                 if self._parse_required_arguments(item, parsed_args):
626                     return True
627             return False
628         return required in parsed_args
629
630     def parse(self, new_args=None):
631         """Parse user input"""
632         try:
633             pkargs = (new_args,) if new_args else ()
634             self._parsed, unparsed = self.parser.parse_known_args(*pkargs)
635             parsed_args = [
636                 k for k, v in vars(self._parsed).items() if v not in (None, )]
637             if not self._parse_required_arguments(self.required, parsed_args):
638                 self.print_help()
639                 raise CLISyntaxError('Missing required arguments')
640         except SystemExit:
641             raiseCLIError(CLISyntaxError('Argument Syntax Error'))
642         for name, arg in self.arguments.items():
643             arg.value = getattr(self._parsed, name, arg.default)
644         self._unparsed = []
645         for term in unparsed:
646             self._unparsed += split_input(' \'%s\' ' % term)
647         self._parser_modified = False