Seed code for hostcli
[ta/hostcli.git] / src / hostcli / helper.py
1 # Copyright 2019 Nokia
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 #     http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14 #
15
16 import sys
17 import re
18 from datetime import datetime
19 from dateutil import tz
20 from cliff.show import ShowOne
21 from cliff.lister import Lister
22 from cliff.command import Command
23
24
25 DEFAULT = 'default'
26 ALL = 'all'
27 DISPLAY = 'display'
28 HELP = 'help'
29 SORT = 'sort'
30 DATA = 'data'
31 CHOICES = 'choices'     # allowed values for an argument (coming from argparse 'choices' facility)
32 VALUES = 'values'       # allowed values for an argument, even if the argument supports adding multiple instances
33 FIELDS = 'fields'       # filtered list of expected columns in the response (translates to argparse's 'columns' of)
34 COLUMNS = 'columns'     # same as fields
35 DETAILED = 'detailed'   # Makes the command to show all accessible details of the requsted objects.
36                         # Should not be positional (so should not be the first in the arguments list)
37 TIME = 'time'
38 UTC = 'utc'
39
40
41 class HelperBase(object):
42     """Helper base class validating arguments and doing the business logic (send query and receive and process table in response)"""
43     def __init__(self):
44         self.operation = 'get'
45         self.endpoint = ''
46         self.arguments = []
47         self.columns = []
48         self.detailed = []
49         self.fieldmap = {}
50         self.message = ''
51         self.no_positional = False
52         self.mandatory_positional = False
53         self.usebody = False
54         self.resource_prefix = ''
55         self.default_sort = None
56         self.positional_count = 1 # how many mandatory arguments are
57
58     def get_parser_with_arguments(self, parser):
59         args = self.arguments[:]
60         if self.no_positional is False:
61             for i in range (0, self.positional_count):
62                 first = args.pop(0)
63                 parser.add_argument(first,
64                                     metavar=first.upper(),
65                                     nargs=None if self.mandatory_positional else '?',
66                                     default=self.fieldmap[first].get(DEFAULT, ALL),
67                                     help=self.fieldmap[first][HELP])
68         for e in args:
69             # This is very similar to 'choices' facility of argparse, however it allows multiple choices combined...
70             multichoices = ''
71             default = self.fieldmap[e].get(DEFAULT, ALL)
72             if e in [DETAILED, UTC]:
73                 parser.add_argument('--%s' % e, dest=e,  action='store_true', help=self.fieldmap[e][HELP])
74                 continue
75             if e == SORT:
76                 # ...and is needed here to list the allowed arguments in the help
77                 multichoices = ' [%s]' % ','.join([self.fieldmap[i][DISPLAY] for i in self.columns])
78                 if self.default_sort:
79                     default = '%s:%s' % (self.fieldmap[self.default_sort[0]][DISPLAY], self.default_sort[1])
80             elif VALUES in self.fieldmap[e]:
81                 multichoices = ' [%s]' % ','.join(self.fieldmap[e][VALUES])
82             parser.add_argument('--%s' % e,
83                                 dest=e,
84                                 metavar=e.upper(),
85                                 required=False,
86                                 default=default,
87                                 type=str,
88                                 choices=self.fieldmap[e].get(CHOICES, None),
89                                 help=self.fieldmap[e][HELP] + multichoices)
90         return parser
91
92     def send_receive(self, app, parsed_args):
93         parsed_args = self.validate_parameters(parsed_args)
94         if parsed_args.fields:
95             self.arguments.append(FIELDS)
96         arguments = {k: v for k, v in sorted(vars(parsed_args).items()) if k in self.arguments and
97                                                                            k != COLUMNS and
98                                                                            v != ALL and
99                                                                            v is not False}
100         if not arguments:
101             arguments = None
102         req = app.client_manager.resthandler
103         response = req._operation(self.operation,
104                                   '%s%s' %(self.resource_prefix, self.endpoint),
105                                   arguments if self.usebody else None,
106                                   None if self.usebody else arguments,
107                                   False)
108         if not response.ok:
109             raise Exception('Request response is not OK (%s)' % response.reason)
110         result = response.json()
111         if 0 != result['code']:
112             raise Exception(result['description'])
113         return result
114
115     def validate_parameters(self, args):
116         if 'starttime' in self.arguments:
117             args.starttime = HelperBase.convert_timezone_to_utc(args.starttime)
118         if 'endtime' in self.arguments:
119             args.endtime = HelperBase.convert_timezone_to_utc(args.endtime)
120         args.fields = ALL
121         if hasattr(args, COLUMNS):
122             args.columns = list(set(j for i in args.columns for j in i.split(',')))
123             if args.columns:
124                 args.fields = ','.join([self.get_key_by_value(k) for k in sorted(args.columns)])
125         for a in self.arguments:
126             argval = getattr(args, a)
127             if isinstance(argval, str):
128                 for p in argval.split(','):
129                     if argval != ALL and VALUES in self.fieldmap[a] and p not in self.fieldmap[a][VALUES]:
130                         raise Exception('%s is not supported by %s argument' % (p, a))
131         return args
132
133     @staticmethod
134     def validate_datetime(dt):
135         if dt == ALL:
136             return dt
137         formats = ['%Y-%m-%dT%H:%M:%S.%fZ',
138                    '%Y-%m-%dT%H:%M:%SZ',
139                    '%Y-%m-%dT%H:%MZ',
140                    '%Y-%m-%dZ']
141         for f in formats:
142             try:
143                 retval = dt
144                 if 'Z' != retval[-1]:
145                     retval += 'Z'
146                 retval = datetime.strptime(retval, f).__str__()
147                 retval = '%s.000' % retval if len(retval) <= 19 else retval[:-3]
148                 return retval.replace(' ', 'T') + 'Z'
149             except ValueError:
150                 pass
151         raise Exception('Datetime format (%s) is not supported' % dt)
152
153     @staticmethod
154     def convert_utc_to_timezone(timestr):
155         timestr = timestr.replace('Z', '')
156         # max resolution for strptime is microsec
157         if len(timestr) > 26:
158             timestr = timestr[:26]
159         from_zone = tz.tzutc()
160         to_zone = tz.tzlocal()
161         utc = datetime.strptime(HelperBase.validate_datetime(timestr + 'Z'), '%Y-%m-%dT%H:%M:%S.%fZ')
162         utc = utc.replace(tzinfo=from_zone)
163         ret = str(utc.astimezone(to_zone))
164         return ret[:23] if '.' in ret else ret[:19] + '.000'
165
166     @staticmethod
167     def convert_timezone_to_utc(timestr):
168         if timestr[-1] == 'Z' or timestr == ALL:
169             # we assume that UTC will always have a Z character at the end
170             return timestr
171         formats = ['%Y-%m-%dT%H:%M:%S.%f',
172                    '%Y-%m-%dT%H:%M:%S',
173                    '%Y-%m-%dT%H:%M',
174                    '%Y-%m-%d']
175         origstr = timestr
176         timestr = timestr.replace(' ', 'T')
177         if len(timestr) > 26:
178             timestr = timestr[:26]
179         from_zone = tz.tzlocal()
180         to_zone = tz.tzutc()
181         for f in formats:
182             try:
183                 localtime = datetime.strptime(timestr, f)
184                 localtime = localtime.replace(tzinfo=from_zone)
185                 ret = str(localtime.astimezone(to_zone))
186                 retval = ret[:23] if '.' in ret else ret[:19] + '.000'
187                 return retval.replace(' ', 'T') + 'Z'
188             except ValueError:
189                 pass
190         raise Exception('Datetime format (%s) is not supported' % origstr)
191
192     def filter_columns(self, args):
193         if getattr(args, DETAILED, False) is True:
194             self.columns.extend(self.detailed)
195         if ALL != args.fields:
196             for i in range(len(self.columns) - 1, -1, -1):
197                 if self.columns[i] not in args.fields:
198                     del self.columns[i]
199         return [self.fieldmap[f][DISPLAY] for f in self.columns]
200
201     def get_key_by_value(self, val):
202         for k, v in self.fieldmap.items():
203             if DISPLAY in v and val == v[DISPLAY]:
204                 return k
205         raise Exception('No column named %s' % val)
206
207     def get_sorted_keys(self, parsed_args, data):
208         keylist = data.keys()
209         if hasattr(parsed_args, SORT):
210             sortexp = parsed_args.sort
211             if sortexp != ALL:
212                 # The next one generates two lists, one with the field names (to be sorted),
213                 # and another with the directions. True if reversed, false otherwise
214                 # also if no direction is added for a field, then it adds an ':asc' by default.
215                 skeys, sdir = zip(*[(self.get_key_by_value(x[0]), False if 'asc' in x[1].lower() else True)
216                                     for x in (('%s:asc' % x).split(":") for x in reversed(sortexp.split(',')))])
217                 for k, d in zip(skeys, sdir):
218                     keylist.sort(key=lambda x: data[x][k], reverse=d)
219         return keylist
220
221     @staticmethod
222     def construct_message(text, result):
223         p = re.compile('\#\#(\w+)')
224         while True:
225             m = p.search(text)
226             if not m:
227                 break
228             text = p.sub(result[DATA][m.group(1)], text, 1)
229         return '%s\n' % text
230
231
232 class ListerHelper(Lister, HelperBase):
233     """Helper class for Lister"""
234     def __init__(self, app, app_args, cmd_name=None):
235         Lister.__init__(self, app, app_args, cmd_name)
236         HelperBase.__init__(self)
237
238     def get_parser(self, prog_name):
239         parser = super(ListerHelper, self).get_parser(prog_name)
240         return self.get_parser_with_arguments(parser)
241
242     def take_action(self, parsed_args):
243         try:
244             result = self.send_receive(self.app, parsed_args)
245             header = self.filter_columns(parsed_args)
246             data = []
247             for k in self.get_sorted_keys(parsed_args, result[DATA]):
248                 row = [HelperBase.convert_utc_to_timezone(result[DATA][k][i])
249                        if not getattr(parsed_args, UTC, False) and i == TIME
250                        else result[DATA][k][i] for i in self.columns]
251                 data.append(row)
252             if self.message:
253                 self.app.stdout.write(self.message + '\n')
254             return header, data
255         except Exception as exp:
256             self.app.stderr.write('Failed with error:\n%s\n' % str(exp))
257             sys.exit(1)
258
259
260 class ShowOneHelper(ShowOne, HelperBase):
261     """Helper class for ShowOne"""
262     def __init__(self, app, app_args, cmd_name=None):
263         ShowOne.__init__(self, app, app_args, cmd_name)
264         HelperBase.__init__(self)
265
266     def get_parser(self, prog_name):
267         parser = super(ShowOneHelper, self).get_parser(prog_name)
268         return self.get_parser_with_arguments(parser)
269
270     def take_action(self, parsed_args):
271         try:
272             result = self.send_receive(self.app, parsed_args)
273             header = self.filter_columns(parsed_args)
274             sorted_keys = self.get_sorted_keys(parsed_args, result[DATA])
275             if self.message:
276                 self.app.stdout.write(self.message + '\n')
277             for k in sorted_keys:
278                 data = [HelperBase.convert_utc_to_timezone(result[DATA][k][i])
279                         if not getattr(parsed_args, UTC, False) and i == TIME
280                         else result[DATA][k][i] for i in self.columns]
281                 if k != sorted_keys[-1]:
282                     self.formatter.emit_one(header, data, self.app.stdout, parsed_args)
283                     self.app.stdout.write('\n')
284             return header, data
285         except Exception as exp:
286             self.app.stderr.write('Failed with error:\n%s\n' % str(exp))
287             sys.exit(1)
288
289
290 class CommandHelper(Command, HelperBase):
291     """Helper class for Command"""
292     def __init__(self, app, app_args, cmd_name=None):
293         Command.__init__(self, app, app_args, cmd_name)
294         HelperBase.__init__(self)
295
296     def get_parser(self, prog_name):
297         parser = super(CommandHelper, self).get_parser(prog_name)
298         return self.get_parser_with_arguments(parser)
299
300     def take_action(self, parsed_args):
301         try:
302             result = self.send_receive(self.app, parsed_args)
303             if self.message:
304                 self.app.stdout.write(HelperBase.construct_message(self.message, result))
305         except Exception as exp:
306             self.app.stderr.write('Failed with error:\n%s\n' % str(exp))
307             sys.exit(1)