c383b4bff61797623f02f3eabdd4658b1a6ac58c
[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
37
38 from datetime import datetime as dtm
39 from time import mktime
40
41 from logging import getLogger
42 from argparse import ArgumentParser, ArgumentError
43 from argparse import RawDescriptionHelpFormatter
44 from progress.bar import ShadyBar as KamakiProgressBar
45
46 log = getLogger(__name__)
47
48
49 class Argument(object):
50     """An argument that can be parsed from command line or otherwise.
51     This is the top-level Argument class. It is suggested to extent this
52     class into more specific argument types.
53     """
54
55     def __init__(self, arity, help=None, parsed_name=None, default=None):
56         self.arity = int(arity)
57         self.help = '%s' % help or ''
58
59         assert parsed_name, 'No parsed name for argument %s' % self
60         self.parsed_name = list(parsed_name) if isinstance(
61             parsed_name, list) or isinstance(parsed_name, tuple) else (
62                 '%s' % parsed_name).split()
63         for name in self.parsed_name:
64             assert name.count(' ') == 0, '%s: Invalid parse name "%s"' % (
65                 self, name)
66             msg = '%s: Invalid parse name "%s" should start with a "-"' % (
67                     self, name)
68             assert name.startswith('-'), msg
69
70         self.default = default if (default or self.arity) else False
71
72     @property
73     def value(self):
74         return getattr(self, '_value', self.default)
75
76     @value.setter
77     def value(self, newvalue):
78         self._value = newvalue
79
80     def update_parser(self, parser, name):
81         """Update argument parser with self info"""
82         action = 'append' if self.arity < 0 else (
83             'store' if self.arity else 'store_true')
84         parser.add_argument(
85             *self.parsed_name,
86             dest=name, action=action, default=self.default, help=self.help)
87
88
89 class ConfigArgument(Argument):
90     """Manage a kamaki configuration (file)"""
91
92     def __init__(self, help, parsed_name=('-c', '--config')):
93         super(ConfigArgument, self).__init__(1, help, parsed_name, None)
94         self.file_path = None
95
96     @property
97     def value(self):
98         return super(ConfigArgument, self).value
99
100     @value.setter
101     def value(self, config_file):
102         if config_file:
103             self._value = Config(config_file)
104             self.file_path = config_file
105         elif self.file_path:
106             self._value = Config(self.file_path)
107         else:
108             self._value = Config()
109
110     def get(self, group, term):
111         """Get a configuration setting from the Config object"""
112         return self.value.get(group, term)
113
114     @property
115     def groups(self):
116         suffix = '_cli'
117         slen = len(suffix)
118         return [term[:-slen] for term in self.value.keys('global') if (
119             term.endswith(suffix))]
120
121     @property
122     def cli_specs(self):
123         suffix = '_cli'
124         slen = len(suffix)
125         return [(k[:-slen], v) for k, v in self.value.items('global') if (
126             k.endswith(suffix))]
127
128     def get_global(self, option):
129         return self.value.get_global(option)
130
131     def get_cloud(self, cloud, option):
132         return self.value.get_cloud(cloud, option)
133
134
135 _config_arg = ConfigArgument('Path to config file')
136
137
138 class RuntimeConfigArgument(Argument):
139     """Set a run-time setting option (not persistent)"""
140
141     def __init__(self, config_arg, help='', parsed_name=None, default=None):
142         super(self.__class__, self).__init__(1, help, parsed_name, default)
143         self._config_arg = config_arg
144
145     @property
146     def value(self):
147         return super(RuntimeConfigArgument, self).value
148
149     @value.setter
150     def value(self, options):
151         if options == self.default:
152             return
153         if not isinstance(options, list):
154             options = ['%s' % options]
155         for option in options:
156             keypath, sep, val = option.partition('=')
157             if not sep:
158                 raiseCLIError(
159                     CLISyntaxError('Argument Syntax Error '),
160                     details=[
161                         '%s is missing a "="',
162                         ' (usage: -o section.key=val)' % option])
163             section, sep, key = keypath.partition('.')
164         if not sep:
165             key = section
166             section = 'global'
167         self._config_arg.value.override(
168             section.strip(),
169             key.strip(),
170             val.strip())
171
172
173 class FlagArgument(Argument):
174     """
175     :value: true if set, false otherwise
176     """
177
178     def __init__(self, help='', parsed_name=None, default=False):
179         super(FlagArgument, self).__init__(0, help, parsed_name, default)
180
181
182 class ValueArgument(Argument):
183     """
184     :value type: string
185     :value returns: given value or default
186     """
187
188     def __init__(self, help='', parsed_name=None, default=None):
189         super(ValueArgument, self).__init__(1, help, parsed_name, default)
190
191
192 class CommaSeparatedListArgument(ValueArgument):
193     """
194     :value type: string
195     :value returns: list of the comma separated values
196     """
197
198     @property
199     def value(self):
200         return self._value or list()
201
202     @value.setter
203     def value(self, newvalue):
204         self._value = newvalue.split(',') if newvalue else list()
205
206
207 class IntArgument(ValueArgument):
208
209     @property
210     def value(self):
211         """integer (type checking)"""
212         return getattr(self, '_value', self.default)
213
214     @value.setter
215     def value(self, newvalue):
216         try:
217             self._value = self.default if (
218                 newvalue == self.default) else int(newvalue)
219         except ValueError:
220             raiseCLIError(CLISyntaxError(
221                 'IntArgument Error',
222                 details=['Value %s not an int' % newvalue]))
223
224
225 class DateArgument(ValueArgument):
226
227     DATE_FORMAT = '%a %b %d %H:%M:%S %Y'
228
229     INPUT_FORMATS = [DATE_FORMAT, '%d-%m-%Y', '%H:%M:%S %d-%m-%Y']
230
231     @property
232     def timestamp(self):
233         v = getattr(self, '_value', self.default)
234         return mktime(v.timetuple()) if v else None
235
236     @property
237     def formated(self):
238         v = getattr(self, '_value', self.default)
239         return v.strftime(self.DATE_FORMAT) if v else None
240
241     @property
242     def value(self):
243         return self.timestamp
244
245     @value.setter
246     def value(self, newvalue):
247         self._value = self.format_date(newvalue) if newvalue else self.default
248
249     def format_date(self, datestr):
250         for format in self.INPUT_FORMATS:
251             try:
252                 t = dtm.strptime(datestr, format)
253             except ValueError:
254                 continue
255             return t  # .strftime(self.DATE_FORMAT)
256         raiseCLIError(None, 'Date Argument Error', details=[
257             '%s not a valid date' % datestr,
258             'Correct formats:\n\t%s' % self.INPUT_FORMATS])
259
260
261 class VersionArgument(FlagArgument):
262     """A flag argument with that prints current version"""
263
264     @property
265     def value(self):
266         """bool"""
267         return super(self.__class__, self).value
268
269     @value.setter
270     def value(self, newvalue):
271         self._value = newvalue
272         if newvalue:
273             import kamaki
274             print('kamaki %s' % kamaki.__version__)
275
276
277 class RepeatableArgument(Argument):
278     """A value argument that can be repeated"""
279
280     def __init__(self, help='', parsed_name=None, default=[]):
281         super(RepeatableArgument, self).__init__(
282             -1, help, parsed_name, default)
283
284
285 class KeyValueArgument(Argument):
286     """A Key=Value Argument that can be repeated
287
288     :syntax: --<arg> key1=value1 --<arg> key2=value2 ...
289     """
290
291     def __init__(self, help='', parsed_name=None, default=[]):
292         super(KeyValueArgument, self).__init__(-1, help, parsed_name, default)
293
294     @property
295     def value(self):
296         """
297         :returns: (dict) {key1: val1, key2: val2, ...}
298         """
299         return super(KeyValueArgument, self).value
300
301     @value.setter
302     def value(self, keyvalue_pairs):
303         """
304         :param keyvalue_pairs: (str) ['key1=val1', 'key2=val2', ...]
305         """
306         self._value = getattr(self, '_value', self.value) or {}
307         try:
308             for pair in keyvalue_pairs:
309                 key, sep, val = pair.partition('=')
310                 assert sep, ' %s misses a "=" (usage: key1=val1 )\n' % (pair)
311                 self._value[key] = val
312         except Exception as e:
313             raiseCLIError(e, 'KeyValueArgument Syntax Error')
314
315
316 class ProgressBarArgument(FlagArgument):
317     """Manage a progress bar"""
318
319     def __init__(self, help='', parsed_name='', default=True):
320         self.suffix = '%(percent)d%%'
321         super(ProgressBarArgument, self).__init__(help, parsed_name, default)
322
323     def clone(self):
324         """Get a modifiable copy of this bar"""
325         newarg = ProgressBarArgument(
326             self.help, self.parsed_name, self.default)
327         newarg._value = self._value
328         return newarg
329
330     def get_generator(self, message, message_len=25):
331         """Get a generator to handle progress of the bar (gen.next())"""
332         if self.value:
333             return None
334         try:
335             self.bar = KamakiProgressBar()
336         except NameError:
337             self.value = None
338             return self.value
339         self.bar.message = message.ljust(message_len)
340         self.bar.suffix = '%(percent)d%% - %(eta)ds'
341         self.bar.start()
342
343         def progress_gen(n):
344             for i in self.bar.iter(range(int(n))):
345                 yield
346             yield
347         return progress_gen
348
349     def finish(self):
350         """Stop progress bar, return terminal cursor to user"""
351         if self.value:
352             return
353         mybar = getattr(self, 'bar', None)
354         if mybar:
355             mybar.finish()
356
357
358 _arguments = dict(
359     config=_config_arg,
360     cloud=ValueArgument('Chose a cloud to connect to', ('--cloud')),
361     help=Argument(0, 'Show help message', ('-h', '--help')),
362     debug=FlagArgument('Include debug output', ('-d', '--debug')),
363     include=FlagArgument(
364         'Include raw connection data in the output', ('-i', '--include')),
365     silent=FlagArgument('Do not output anything', ('-s', '--silent')),
366     verbose=FlagArgument('More info at response', ('-v', '--verbose')),
367     version=VersionArgument('Print current version', ('-V', '--version')),
368     options=RuntimeConfigArgument(
369         _config_arg, 'Override a config value', ('-o', '--options'))
370 )
371
372
373 #  Initial command line interface arguments
374
375
376 class ArgumentParseManager(object):
377     """Manage (initialize and update) an ArgumentParser object"""
378
379     def __init__(self, exe, arguments=None):
380         """
381         :param exe: (str) the basic command (e.g. 'kamaki')
382
383         :param arguments: (dict) if given, overrides the global _argument as
384             the parsers arguments specification
385         """
386         self.parser = ArgumentParser(
387             add_help=False, formatter_class=RawDescriptionHelpFormatter)
388         self.syntax = '%s <cmd_group> [<cmd_subbroup> ...] <cmd>' % exe
389         if arguments:
390             self.arguments = arguments
391         else:
392             global _arguments
393             self.arguments = _arguments
394         self._parser_modified, self._parsed, self._unparsed = False, None, None
395         self.parse()
396
397     @property
398     def syntax(self):
399         """The command syntax (useful for help messages, descriptions, etc)"""
400         return self.parser.prog
401
402     @syntax.setter
403     def syntax(self, new_syntax):
404         self.parser.prog = new_syntax
405
406     @property
407     def arguments(self):
408         """:returns: (dict) arguments the parser should be aware of"""
409         return self._arguments
410
411     @arguments.setter
412     def arguments(self, new_arguments):
413         assert isinstance(new_arguments, dict), 'Arguments must be in a dict'
414         self._arguments = new_arguments
415         self.update_parser()
416
417     @property
418     def parsed(self):
419         """(Namespace) parser-matched terms"""
420         if self._parser_modified:
421             self.parse()
422         return self._parsed
423
424     @property
425     def unparsed(self):
426         """(list) parser-unmatched terms"""
427         if self._parser_modified:
428             self.parse()
429         return self._unparsed
430
431     def update_parser(self, arguments=None):
432         """Load argument specifications to parser
433
434         :param arguments: if not given, update self.arguments instead
435         """
436         arguments = arguments or self._arguments
437
438         for name, arg in arguments.items():
439             try:
440                 arg.update_parser(self.parser, name)
441                 self._parser_modified = True
442             except ArgumentError:
443                 pass
444
445     def update_arguments(self, new_arguments):
446         """Add to / update existing arguments
447
448         :param new_arguments: (dict)
449         """
450         if new_arguments:
451             assert isinstance(new_arguments, dict)
452             self._arguments.update(new_arguments)
453             self.update_parser()
454
455     def parse(self, new_args=None):
456         """Parse user input"""
457         try:
458             pkargs = (new_args,) if new_args else ()
459             self._parsed, unparsed = self.parser.parse_known_args(*pkargs)
460         except SystemExit:
461             raiseCLIError(CLISyntaxError('Argument Syntax Error'))
462         for name, arg in self.arguments.items():
463             arg.value = getattr(self._parsed, name, arg.default)
464         self._unparsed = []
465         for term in unparsed:
466             self._unparsed += split_input(' \'%s\' ' % term)
467         self._parser_modified = False