Seed code for hostcli
[ta/hostcli.git] / src / hostcli / helper.py
diff --git a/src/hostcli/helper.py b/src/hostcli/helper.py
new file mode 100644 (file)
index 0000000..9f569c4
--- /dev/null
@@ -0,0 +1,307 @@
+# Copyright 2019 Nokia
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+import sys
+import re
+from datetime import datetime
+from dateutil import tz
+from cliff.show import ShowOne
+from cliff.lister import Lister
+from cliff.command import Command
+
+
+DEFAULT = 'default'
+ALL = 'all'
+DISPLAY = 'display'
+HELP = 'help'
+SORT = 'sort'
+DATA = 'data'
+CHOICES = 'choices'     # allowed values for an argument (coming from argparse 'choices' facility)
+VALUES = 'values'       # allowed values for an argument, even if the argument supports adding multiple instances
+FIELDS = 'fields'       # filtered list of expected columns in the response (translates to argparse's 'columns' of)
+COLUMNS = 'columns'     # same as fields
+DETAILED = 'detailed'   # Makes the command to show all accessible details of the requsted objects.
+                        # Should not be positional (so should not be the first in the arguments list)
+TIME = 'time'
+UTC = 'utc'
+
+
+class HelperBase(object):
+    """Helper base class validating arguments and doing the business logic (send query and receive and process table in response)"""
+    def __init__(self):
+        self.operation = 'get'
+        self.endpoint = ''
+        self.arguments = []
+        self.columns = []
+        self.detailed = []
+        self.fieldmap = {}
+        self.message = ''
+        self.no_positional = False
+        self.mandatory_positional = False
+        self.usebody = False
+        self.resource_prefix = ''
+        self.default_sort = None
+        self.positional_count = 1 # how many mandatory arguments are
+
+    def get_parser_with_arguments(self, parser):
+        args = self.arguments[:]
+        if self.no_positional is False:
+            for i in range (0, self.positional_count):
+                first = args.pop(0)
+                parser.add_argument(first,
+                                    metavar=first.upper(),
+                                    nargs=None if self.mandatory_positional else '?',
+                                    default=self.fieldmap[first].get(DEFAULT, ALL),
+                                    help=self.fieldmap[first][HELP])
+        for e in args:
+            # This is very similar to 'choices' facility of argparse, however it allows multiple choices combined...
+            multichoices = ''
+            default = self.fieldmap[e].get(DEFAULT, ALL)
+            if e in [DETAILED, UTC]:
+                parser.add_argument('--%s' % e, dest=e,  action='store_true', help=self.fieldmap[e][HELP])
+                continue
+            if e == SORT:
+                # ...and is needed here to list the allowed arguments in the help
+                multichoices = ' [%s]' % ','.join([self.fieldmap[i][DISPLAY] for i in self.columns])
+                if self.default_sort:
+                    default = '%s:%s' % (self.fieldmap[self.default_sort[0]][DISPLAY], self.default_sort[1])
+            elif VALUES in self.fieldmap[e]:
+                multichoices = ' [%s]' % ','.join(self.fieldmap[e][VALUES])
+            parser.add_argument('--%s' % e,
+                                dest=e,
+                                metavar=e.upper(),
+                                required=False,
+                                default=default,
+                                type=str,
+                                choices=self.fieldmap[e].get(CHOICES, None),
+                                help=self.fieldmap[e][HELP] + multichoices)
+        return parser
+
+    def send_receive(self, app, parsed_args):
+        parsed_args = self.validate_parameters(parsed_args)
+        if parsed_args.fields:
+            self.arguments.append(FIELDS)
+        arguments = {k: v for k, v in sorted(vars(parsed_args).items()) if k in self.arguments and
+                                                                           k != COLUMNS and
+                                                                           v != ALL and
+                                                                           v is not False}
+        if not arguments:
+            arguments = None
+        req = app.client_manager.resthandler
+        response = req._operation(self.operation,
+                                  '%s%s' %(self.resource_prefix, self.endpoint),
+                                  arguments if self.usebody else None,
+                                  None if self.usebody else arguments,
+                                  False)
+        if not response.ok:
+            raise Exception('Request response is not OK (%s)' % response.reason)
+        result = response.json()
+        if 0 != result['code']:
+            raise Exception(result['description'])
+        return result
+
+    def validate_parameters(self, args):
+        if 'starttime' in self.arguments:
+            args.starttime = HelperBase.convert_timezone_to_utc(args.starttime)
+        if 'endtime' in self.arguments:
+            args.endtime = HelperBase.convert_timezone_to_utc(args.endtime)
+        args.fields = ALL
+        if hasattr(args, COLUMNS):
+            args.columns = list(set(j for i in args.columns for j in i.split(',')))
+            if args.columns:
+                args.fields = ','.join([self.get_key_by_value(k) for k in sorted(args.columns)])
+        for a in self.arguments:
+            argval = getattr(args, a)
+            if isinstance(argval, str):
+                for p in argval.split(','):
+                    if argval != ALL and VALUES in self.fieldmap[a] and p not in self.fieldmap[a][VALUES]:
+                        raise Exception('%s is not supported by %s argument' % (p, a))
+        return args
+
+    @staticmethod
+    def validate_datetime(dt):
+        if dt == ALL:
+            return dt
+        formats = ['%Y-%m-%dT%H:%M:%S.%fZ',
+                   '%Y-%m-%dT%H:%M:%SZ',
+                   '%Y-%m-%dT%H:%MZ',
+                   '%Y-%m-%dZ']
+        for f in formats:
+            try:
+                retval = dt
+                if 'Z' != retval[-1]:
+                    retval += 'Z'
+                retval = datetime.strptime(retval, f).__str__()
+                retval = '%s.000' % retval if len(retval) <= 19 else retval[:-3]
+                return retval.replace(' ', 'T') + 'Z'
+            except ValueError:
+                pass
+        raise Exception('Datetime format (%s) is not supported' % dt)
+
+    @staticmethod
+    def convert_utc_to_timezone(timestr):
+        timestr = timestr.replace('Z', '')
+        # max resolution for strptime is microsec
+        if len(timestr) > 26:
+            timestr = timestr[:26]
+        from_zone = tz.tzutc()
+        to_zone = tz.tzlocal()
+        utc = datetime.strptime(HelperBase.validate_datetime(timestr + 'Z'), '%Y-%m-%dT%H:%M:%S.%fZ')
+        utc = utc.replace(tzinfo=from_zone)
+        ret = str(utc.astimezone(to_zone))
+        return ret[:23] if '.' in ret else ret[:19] + '.000'
+
+    @staticmethod
+    def convert_timezone_to_utc(timestr):
+        if timestr[-1] == 'Z' or timestr == ALL:
+            # we assume that UTC will always have a Z character at the end
+            return timestr
+        formats = ['%Y-%m-%dT%H:%M:%S.%f',
+                   '%Y-%m-%dT%H:%M:%S',
+                   '%Y-%m-%dT%H:%M',
+                   '%Y-%m-%d']
+        origstr = timestr
+        timestr = timestr.replace(' ', 'T')
+        if len(timestr) > 26:
+            timestr = timestr[:26]
+        from_zone = tz.tzlocal()
+        to_zone = tz.tzutc()
+        for f in formats:
+            try:
+                localtime = datetime.strptime(timestr, f)
+                localtime = localtime.replace(tzinfo=from_zone)
+                ret = str(localtime.astimezone(to_zone))
+                retval = ret[:23] if '.' in ret else ret[:19] + '.000'
+                return retval.replace(' ', 'T') + 'Z'
+            except ValueError:
+                pass
+        raise Exception('Datetime format (%s) is not supported' % origstr)
+
+    def filter_columns(self, args):
+        if getattr(args, DETAILED, False) is True:
+            self.columns.extend(self.detailed)
+        if ALL != args.fields:
+            for i in range(len(self.columns) - 1, -1, -1):
+                if self.columns[i] not in args.fields:
+                    del self.columns[i]
+        return [self.fieldmap[f][DISPLAY] for f in self.columns]
+
+    def get_key_by_value(self, val):
+        for k, v in self.fieldmap.items():
+            if DISPLAY in v and val == v[DISPLAY]:
+                return k
+        raise Exception('No column named %s' % val)
+
+    def get_sorted_keys(self, parsed_args, data):
+        keylist = data.keys()
+        if hasattr(parsed_args, SORT):
+            sortexp = parsed_args.sort
+            if sortexp != ALL:
+                # The next one generates two lists, one with the field names (to be sorted),
+                # and another with the directions. True if reversed, false otherwise
+                # also if no direction is added for a field, then it adds an ':asc' by default.
+                skeys, sdir = zip(*[(self.get_key_by_value(x[0]), False if 'asc' in x[1].lower() else True)
+                                    for x in (('%s:asc' % x).split(":") for x in reversed(sortexp.split(',')))])
+                for k, d in zip(skeys, sdir):
+                    keylist.sort(key=lambda x: data[x][k], reverse=d)
+        return keylist
+
+    @staticmethod
+    def construct_message(text, result):
+        p = re.compile('\#\#(\w+)')
+        while True:
+            m = p.search(text)
+            if not m:
+                break
+            text = p.sub(result[DATA][m.group(1)], text, 1)
+        return '%s\n' % text
+
+
+class ListerHelper(Lister, HelperBase):
+    """Helper class for Lister"""
+    def __init__(self, app, app_args, cmd_name=None):
+        Lister.__init__(self, app, app_args, cmd_name)
+        HelperBase.__init__(self)
+
+    def get_parser(self, prog_name):
+        parser = super(ListerHelper, self).get_parser(prog_name)
+        return self.get_parser_with_arguments(parser)
+
+    def take_action(self, parsed_args):
+        try:
+            result = self.send_receive(self.app, parsed_args)
+            header = self.filter_columns(parsed_args)
+            data = []
+            for k in self.get_sorted_keys(parsed_args, result[DATA]):
+                row = [HelperBase.convert_utc_to_timezone(result[DATA][k][i])
+                       if not getattr(parsed_args, UTC, False) and i == TIME
+                       else result[DATA][k][i] for i in self.columns]
+                data.append(row)
+            if self.message:
+                self.app.stdout.write(self.message + '\n')
+            return header, data
+        except Exception as exp:
+            self.app.stderr.write('Failed with error:\n%s\n' % str(exp))
+            sys.exit(1)
+
+
+class ShowOneHelper(ShowOne, HelperBase):
+    """Helper class for ShowOne"""
+    def __init__(self, app, app_args, cmd_name=None):
+        ShowOne.__init__(self, app, app_args, cmd_name)
+        HelperBase.__init__(self)
+
+    def get_parser(self, prog_name):
+        parser = super(ShowOneHelper, self).get_parser(prog_name)
+        return self.get_parser_with_arguments(parser)
+
+    def take_action(self, parsed_args):
+        try:
+            result = self.send_receive(self.app, parsed_args)
+            header = self.filter_columns(parsed_args)
+            sorted_keys = self.get_sorted_keys(parsed_args, result[DATA])
+            if self.message:
+                self.app.stdout.write(self.message + '\n')
+            for k in sorted_keys:
+                data = [HelperBase.convert_utc_to_timezone(result[DATA][k][i])
+                        if not getattr(parsed_args, UTC, False) and i == TIME
+                        else result[DATA][k][i] for i in self.columns]
+                if k != sorted_keys[-1]:
+                    self.formatter.emit_one(header, data, self.app.stdout, parsed_args)
+                    self.app.stdout.write('\n')
+            return header, data
+        except Exception as exp:
+            self.app.stderr.write('Failed with error:\n%s\n' % str(exp))
+            sys.exit(1)
+
+
+class CommandHelper(Command, HelperBase):
+    """Helper class for Command"""
+    def __init__(self, app, app_args, cmd_name=None):
+        Command.__init__(self, app, app_args, cmd_name)
+        HelperBase.__init__(self)
+
+    def get_parser(self, prog_name):
+        parser = super(CommandHelper, self).get_parser(prog_name)
+        return self.get_parser_with_arguments(parser)
+
+    def take_action(self, parsed_args):
+        try:
+            result = self.send_receive(self.app, parsed_args)
+            if self.message:
+                self.app.stdout.write(HelperBase.construct_message(self.message, result))
+        except Exception as exp:
+            self.app.stderr.write('Failed with error:\n%s\n' % str(exp))
+            sys.exit(1)