PAL: Reverse download order for trending
[csit.git] / resources / tools / presentation / input_data_parser.py
index e4a7a0a..27db6a8 100644 (file)
@@ -1,4 +1,4 @@
-# Copyright (c) 2019 Cisco and/or its affiliates.
+# Copyright (c) 2020 Cisco and/or its affiliates.
 # 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:
@@ -25,10 +25,12 @@ import resource
 import logging
 
 from collections import OrderedDict
-from os import remove
+from os import remove, walk, listdir
+from os.path import isfile, isdir, join
 from datetime import datetime as dt
 from datetime import timedelta
 from json import loads
+from json.decoder import JSONDecodeError
 
 import hdrh.histogram
 import hdrh.codec
@@ -40,6 +42,7 @@ from robot import errors
 
 from resources.libraries.python import jumpavg
 from input_data_files import download_and_unzip_data_file
+from pal_errors import PresentationError
 
 
 # Separator used in file names
@@ -459,10 +462,10 @@ class ExecutionChecker(ResultVisitor):
 
         try:
             out_msg = (
-                f"1. {(data[u'ndr_low'] / 1e6):.2f}      "
-                f"{data[u'ndr_low_b']:.2f}"
-                f"\n2. {(data[u'pdr_low'] / 1e6):.2f}      "
-                f"{data[u'pdr_low_b']:.2f}"
+                f"1. {(data[u'ndr_low'] / 1e6):5.2f}      "
+                f"{data[u'ndr_low_b']:5.2f}"
+                f"\n2. {(data[u'pdr_low'] / 1e6):5.2f}      "
+                f"{data[u'pdr_low_b']:5.2f}"
             )
             latency = (
                 _process_lat(data[u'pdr_lat_10_1'], data[u'pdr_lat_10_2']),
@@ -471,16 +474,20 @@ class ExecutionChecker(ResultVisitor):
             )
             if all(latency):
                 max_len = len(str(max((max(item) for item in latency))))
+                max_len = 4 if max_len < 4 else max_len
 
                 for idx, lat in enumerate(latency):
                     if not idx:
                         out_msg += u"\n"
-                    out_msg += f"\n{idx + 3}. "
-                    for count, itm in enumerate(lat):
-                        if count == 3:
-                            out_msg += u" " * 6
-                        out_msg += u" " * (max_len - len(str(itm)) + 1)
-                        out_msg += str(itm)
+                    out_msg += (
+                        f"\n{idx + 3}. "
+                        f"{lat[0]:{max_len}d} "
+                        f"{lat[1]:{max_len}d} "
+                        f"{lat[2]:{max_len}d}      "
+                        f"{lat[3]:{max_len}d} "
+                        f"{lat[4]:{max_len}d} "
+                        f"{lat[5]:{max_len}d} "
+                    )
 
             return out_msg
 
@@ -867,6 +874,40 @@ class ExecutionChecker(ResultVisitor):
 
         return latency, u"FAIL"
 
+    @staticmethod
+    def _get_hoststack_data(msg, tags):
+        """Get data from the hoststack test message.
+
+        :param msg: The test message to be parsed.
+        :param tags: Test tags.
+        :type msg: str
+        :type tags: list
+        :returns: Parsed data as a JSON dict and the status (PASS/FAIL).
+        :rtype: tuple(dict, str)
+        """
+        result = dict()
+        status = u"FAIL"
+
+        msg = msg.replace(u"'", u'"').replace(u" ", u"")
+        if u"LDPRELOAD" in tags:
+            try:
+                result = loads(msg)
+                status = u"PASS"
+            except JSONDecodeError:
+                pass
+        elif u"VPPECHO" in tags:
+            try:
+                msg_lst = msg.replace(u"}{", u"} {").split(u" ")
+                result = dict(
+                    client=loads(msg_lst[0]),
+                    server=loads(msg_lst[1])
+                )
+                status = u"PASS"
+            except (JSONDecodeError, IndexError):
+                pass
+
+        return result, status
+
     def visit_suite(self, suite):
         """Implements traversing through the suite and its direct children.
 
@@ -974,13 +1015,24 @@ class ExecutionChecker(ResultVisitor):
             replace(u'\r', u'').\
             replace(u'[', u' |br| [').\
             replace(u' |br| [', u'[', 1)
-        test_result[u"msg"] = test.message.\
-            replace(u'\n', u' |br| ').\
-            replace(u'\r', u'').\
-            replace(u'"', u"'")
         test_result[u"type"] = u"FUNC"
         test_result[u"status"] = test.status
 
+        if test.status == u"PASS":
+            if u"NDRPDR" in tags:
+                test_result[u"msg"] = self._get_data_from_perf_test_msg(
+                    test.message).replace(u'\n', u' |br| ').\
+                    replace(u'\r', u'').replace(u'"', u"'")
+            elif u"MRR" in tags or u"FRMOBL" in tags or u"BMRR" in tags:
+                test_result[u"msg"] = self._get_data_from_mrr_test_msg(
+                    test.message).replace(u'\n', u' |br| ').\
+                    replace(u'\r', u'').replace(u'"', u"'")
+            else:
+                test_result[u"msg"] = test.message.replace(u'\n', u' |br| ').\
+                    replace(u'\r', u'').replace(u'"', u"'")
+        else:
+            test_result[u"msg"] = u"Test Failed."
+
         if u"PERFTEST" in tags:
             # Replace info about cores (e.g. -1c-) with the info about threads
             # and cores (e.g. -1t1c-) in the long test case names and in the
@@ -996,14 +1048,14 @@ class ExecutionChecker(ResultVisitor):
                         tag_tc = tag
 
                 if tag_count == 1:
-                    self._test_id = re.sub(self.REGEX_TC_NAME_NEW,
-                                           f"-{tag_tc.lower()}-",
-                                           self._test_id,
-                                           count=1)
-                    test_result[u"name"] = re.sub(self.REGEX_TC_NAME_NEW,
-                                                  f"-{tag_tc.lower()}-",
-                                                  test_result["name"],
-                                                  count=1)
+                    self._test_id = re.sub(
+                        self.REGEX_TC_NAME_NEW, f"-{tag_tc.lower()}-",
+                        self._test_id, count=1
+                    )
+                    test_result[u"name"] = re.sub(
+                        self.REGEX_TC_NAME_NEW, f"-{tag_tc.lower()}-",
+                        test_result["name"], count=1
+                    )
                 else:
                     test_result[u"status"] = u"FAIL"
                     self._data[u"tests"][self._test_id] = test_result
@@ -1016,11 +1068,6 @@ class ExecutionChecker(ResultVisitor):
 
         if test.status == u"PASS":
             if u"NDRPDR" in tags:
-                test_result[u"msg"] = self._get_data_from_perf_test_msg(
-                    test.message). \
-                    replace(u'\n', u' |br| '). \
-                    replace(u'\r', u''). \
-                    replace(u'"', u"'")
                 test_result[u"type"] = u"NDRPDR"
                 test_result[u"throughput"], test_result[u"status"] = \
                     self._get_ndrpdr_throughput(test.message)
@@ -1030,16 +1077,15 @@ class ExecutionChecker(ResultVisitor):
                 test_result[u"type"] = u"SOAK"
                 test_result[u"throughput"], test_result[u"status"] = \
                     self._get_plr_throughput(test.message)
+            elif u"HOSTSTACK" in tags:
+                test_result[u"type"] = u"HOSTSTACK"
+                test_result[u"result"], test_result[u"status"] = \
+                    self._get_hoststack_data(test.message, tags)
             elif u"TCP" in tags:
                 test_result[u"type"] = u"TCP"
                 groups = re.search(self.REGEX_TCP, test.message)
                 test_result[u"result"] = int(groups.group(2))
             elif u"MRR" in tags or u"FRMOBL" in tags or u"BMRR" in tags:
-                test_result[u"msg"] = self._get_data_from_mrr_test_msg(
-                    test.message). \
-                    replace(u'\n', u' |br| '). \
-                    replace(u'\r', u''). \
-                    replace(u'"', u"'")
                 if u"MRR" in tags:
                     test_result[u"type"] = u"MRR"
                 else:
@@ -1054,6 +1100,7 @@ class ExecutionChecker(ResultVisitor):
                     # Use whole list in CSIT-1180.
                     stats = jumpavg.AvgStdevStats.for_runs(items_float)
                     test_result[u"result"][u"receive-rate"] = stats.avg
+                    test_result[u"result"][u"receive-stdev"] = stats.stdev
                 else:
                     groups = re.search(self.REGEX_MRR, test.message)
                     test_result[u"result"][u"receive-rate"] = \
@@ -1147,7 +1194,8 @@ class ExecutionChecker(ResultVisitor):
                 test_kw.name.count(u"Show Runtime Counters On All Duts"):
             self._msg_type = u"test-show-runtime"
             self._sh_run_counter += 1
-        elif test_kw.name.count(u"Install Dpdk Test") and not self._version:
+        elif test_kw.name.count(u"Install Dpdk Test On All Duts") and \
+                not self._version:
             self._msg_type = u"dpdk-version"
         else:
             return
@@ -1259,7 +1307,6 @@ class ExecutionChecker(ResultVisitor):
         :type msg: Message
         :returns: Nothing.
         """
-
         if self._msg_type:
             self.parse_msg[self._msg_type](msg)
 
@@ -1321,7 +1368,6 @@ class InputData:
         :returns: Metadata
         :rtype: pandas.Series
         """
-
         return self.data[job][build][u"metadata"]
 
     def suites(self, job, build):
@@ -1334,7 +1380,6 @@ class InputData:
         :returns: Suites.
         :rtype: pandas.Series
         """
-
         return self.data[job][str(build)][u"suites"]
 
     def tests(self, job, build):
@@ -1347,19 +1392,16 @@ class InputData:
         :returns: Tests.
         :rtype: pandas.Series
         """
-
         return self.data[job][build][u"tests"]
 
-    def _parse_tests(self, job, build, log):
+    def _parse_tests(self, job, build):
         """Process data from robot output.xml file and return JSON structured
         data.
 
         :param job: The name of job which build output data will be processed.
         :param build: The build which output data will be processed.
-        :param log: List of log messages.
         :type job: str
         :type build: dict
-        :type log: list of tuples (severity, msg)
         :returns: JSON data structure.
         :rtype: dict
         """
@@ -1373,9 +1415,8 @@ class InputData:
             try:
                 result = ExecutionResult(data_file)
             except errors.DataError as err:
-                log.append(
-                    (u"ERROR", f"Error occurred while parsing output.xml: "
-                               f"{repr(err)}")
+                logging.error(
+                    f"Error occurred while parsing output.xml: {repr(err)}"
                 )
                 return None
         checker = ExecutionChecker(metadata, self._cfg.mapping,
@@ -1400,40 +1441,30 @@ class InputData:
         :type repeat: int
         """
 
-        logs = list()
-
-        logs.append(
-            (u"INFO", f"  Processing the job/build: {job}: {build[u'build']}")
-        )
+        logging.info(f"  Processing the job/build: {job}: {build[u'build']}")
 
         state = u"failed"
         success = False
         data = None
         do_repeat = repeat
         while do_repeat:
-            success = download_and_unzip_data_file(self._cfg, job, build, pid,
-                                                   logs)
+            success = download_and_unzip_data_file(self._cfg, job, build, pid)
             if success:
                 break
             do_repeat -= 1
         if not success:
-            logs.append(
-                (u"ERROR",
+            logging.error(
                  f"It is not possible to download the input data file from the "
                  f"job {job}, build {build[u'build']}, or it is damaged. "
-                 f"Skipped.")
+                 f"Skipped."
             )
         if success:
-            logs.append(
-                (u"INFO",
-                 f"    Processing data from the build {build[u'build']} ...")
-            )
-            data = self._parse_tests(job, build, logs)
+            logging.info(f"    Processing data from build {build[u'build']}")
+            data = self._parse_tests(job, build)
             if data is None:
-                logs.append(
-                    (u"ERROR",
+                logging.error(
                      f"Input data file from the job {job}, build "
-                     f"{build[u'build']} is damaged. Skipped.")
+                     f"{build[u'build']} is damaged. Skipped."
                 )
             else:
                 state = u"processed"
@@ -1441,13 +1472,13 @@ class InputData:
             try:
                 remove(build[u"file-name"])
             except OSError as err:
-                logs.append(
-                    ("ERROR", f"Cannot remove the file {build[u'file-name']}: "
-                              f"{repr(err)}")
+                logging.error(
+                    f"Cannot remove the file {build[u'file-name']}: {repr(err)}"
                 )
 
         # If the time-period is defined in the specification file, remove all
         # files which are outside the time period.
+        is_last = False
         timeperiod = self._cfg.input.get(u"time-period", None)
         if timeperiod and data:
             now = dt.utcnow()
@@ -1461,26 +1492,20 @@ class InputData:
                         # Remove the data and the file:
                         state = u"removed"
                         data = None
-                        logs.append(
-                            (u"INFO",
-                             f"    The build {job}/{build[u'build']} is "
-                             f"outdated, will be removed.")
+                        is_last = True
+                        logging.info(
+                            f"    The build {job}/{build[u'build']} is "
+                            f"outdated, will be removed."
                         )
-        logs.append((u"INFO", u"  Done."))
-
-        for level, line in logs:
-            if level == u"INFO":
-                logging.info(line)
-            elif level == u"ERROR":
-                logging.error(line)
-            elif level == u"DEBUG":
-                logging.debug(line)
-            elif level == u"CRITICAL":
-                logging.critical(line)
-            elif level == u"WARNING":
-                logging.warning(line)
-
-        return {u"data": data, u"state": state, u"job": job, u"build": build}
+        logging.info(u"  Done.")
+
+        return {
+            u"data": data,
+            u"state": state,
+            u"job": job,
+            u"build": build,
+            u"last": is_last
+        }
 
     def download_and_parse_data(self, repeat=1):
         """Download the input data files, parse input data from input files and
@@ -1497,6 +1522,8 @@ class InputData:
             for build in builds:
 
                 result = self._download_and_parse_build(job, build, repeat)
+                if result[u"last"]:
+                    break
                 build_nr = result[u"build"][u"build"]
 
                 if result[u"data"]:
@@ -1531,6 +1558,127 @@ class InputData:
 
         logging.info(u"Done.")
 
+    def process_local_file(self, local_file, job=u"local", build_nr=1,
+                           replace=True):
+        """Process local XML file given as a command-line parameter.
+
+        :param local_file: The file to process.
+        :param job: Job name.
+        :param build_nr: Build number.
+        :param replace: If True, the information about jobs and builds is
+            replaced by the new one, otherwise the new jobs and builds are
+            added.
+        :type local_file: str
+        :type job: str
+        :type build_nr: int
+        :type replace: bool
+        :raises: PresentationError if an error occurs.
+        """
+        if not isfile(local_file):
+            raise PresentationError(f"The file {local_file} does not exist.")
+
+        try:
+            build_nr = int(local_file.split(u"/")[-1].split(u".")[0])
+        except (IndexError, ValueError):
+            pass
+
+        build = {
+            u"build": build_nr,
+            u"status": u"failed",
+            u"file-name": local_file
+        }
+        if replace:
+            self._cfg.builds = dict()
+        self._cfg.add_build(job, build)
+
+        logging.info(f"Processing {job}: {build_nr:2d}: {local_file}")
+        data = self._parse_tests(job, build, list())
+        if data is None:
+            raise PresentationError(
+                f"Error occurred while parsing the file {local_file}"
+            )
+
+        build_data = pd.Series({
+            u"metadata": pd.Series(
+                list(data[u"metadata"].values()),
+                index=list(data[u"metadata"].keys())
+            ),
+            u"suites": pd.Series(
+                list(data[u"suites"].values()),
+                index=list(data[u"suites"].keys())
+            ),
+            u"tests": pd.Series(
+                list(data[u"tests"].values()),
+                index=list(data[u"tests"].keys())
+            )
+        })
+
+        if self._input_data.get(job, None) is None:
+            self._input_data[job] = pd.Series()
+        self._input_data[job][str(build_nr)] = build_data
+
+        self._cfg.set_input_state(job, build_nr, u"processed")
+
+    def process_local_directory(self, local_dir, replace=True):
+        """Process local directory with XML file(s). The directory is processed
+        as a 'job' and the XML files in it as builds.
+        If the given directory contains only sub-directories, these
+        sub-directories processed as jobs and corresponding XML files as builds
+        of their job.
+
+        :param local_dir: Local directory to process.
+        :param replace: If True, the information about jobs and builds is
+            replaced by the new one, otherwise the new jobs and builds are
+            added.
+        :type local_dir: str
+        :type replace: bool
+        """
+        if not isdir(local_dir):
+            raise PresentationError(
+                f"The directory {local_dir} does not exist."
+            )
+
+        # Check if the given directory includes only files, or only directories
+        _, dirnames, filenames = next(walk(local_dir))
+
+        if filenames and not dirnames:
+            filenames.sort()
+            # local_builds:
+            # key: dir (job) name, value: list of file names (builds)
+            local_builds = {
+                local_dir: [join(local_dir, name) for name in filenames]
+            }
+
+        elif dirnames and not filenames:
+            dirnames.sort()
+            # local_builds:
+            # key: dir (job) name, value: list of file names (builds)
+            local_builds = dict()
+            for dirname in dirnames:
+                builds = [
+                    join(local_dir, dirname, name)
+                    for name in listdir(join(local_dir, dirname))
+                    if isfile(join(local_dir, dirname, name))
+                ]
+                if builds:
+                    local_builds[dirname] = sorted(builds)
+
+        elif not filenames and not dirnames:
+            raise PresentationError(f"The directory {local_dir} is empty.")
+        else:
+            raise PresentationError(
+                f"The directory {local_dir} can include only files or only "
+                f"directories, not both.\nThe directory {local_dir} includes "
+                f"file(s):\n{filenames}\nand directories:\n{dirnames}"
+            )
+
+        if replace:
+            self._cfg.builds = dict()
+
+        for job, files in local_builds.items():
+            for idx, local_file in enumerate(files):
+                self.process_local_file(local_file, job, idx + 1, replace=False)
+
     @staticmethod
     def _end_of_tag(tag_filter, start=0, closer=u"'"):
         """Return the index of character in the string which is the end of tag.
@@ -1544,7 +1692,6 @@ class InputData:
         :returns: The index of the tag closer.
         :rtype: int
         """
-
         try:
             idx_opener = tag_filter.index(closer, start)
             return tag_filter.index(closer, idx_opener + 1)
@@ -1560,7 +1707,6 @@ class InputData:
         :returns: Conditional statement which can be evaluated.
         :rtype: str
         """
-
         index = 0
         while True:
             index = InputData._end_of_tag(tag_filter, index)
@@ -1574,7 +1720,6 @@ class InputData:
         """Filter required data from the given jobs and builds.
 
         The output data structure is:
-
         - job 1
           - build 1
             - test (or suite) 1 ID:
@@ -1677,7 +1822,6 @@ class InputData:
         """Filter required data from the given jobs and builds.
 
         The output data structure is:
-
         - job 1
           - build 1
             - test (or suite) 1 ID:
@@ -1747,9 +1891,10 @@ class InputData:
                                                 data[job][str(build)][
                                                     test_id][param] = u"No Data"
                         except KeyError as err:
-                            logging.error(repr(err))
                             if continue_on_error:
+                                logging.debug(repr(err))
                                 continue
+                            logging.error(repr(err))
                             return None
             return data
 
@@ -1791,7 +1936,6 @@ class InputData:
             for item in builds.values:
                 for item_id, item_data in item.items():
                     merged_data[item_id] = item_data
-
         return merged_data
 
     def print_all_oper_data(self):