CSIT-755: Presentation and analytics layer
[csit.git] / resources / tools / presentation / data.py
1 # Copyright (c) 2017 Cisco and/or its affiliates.
2 # Licensed under the Apache License, Version 2.0 (the "License");
3 # you may not use this file except in compliance with the License.
4 # You may obtain a copy of the License at:
5 #
6 #     http://www.apache.org/licenses/LICENSE-2.0
7 #
8 # Unless required by applicable law or agreed to in writing, software
9 # distributed under the License is distributed on an "AS IS" BASIS,
10 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11 # See the License for the specific language governing permissions and
12 # limitations under the License.
13
14 """Data pre-processing
15
16 - extract data from output.xml files generated by Jenkins jobs and store in
17   pandas' Series,
18 - provide access to the data.
19 """
20
21 import re
22 import pandas as pd
23 import logging
24
25 from robot.api import ExecutionResult, ResultVisitor
26
27 from errors import PresentationError
28
29
30 class ExecutionChecker(ResultVisitor):
31     """Class to traverse through the test suite structure.
32
33     The functionality implemented in this class generates a json structure:
34
35     {
36         "metadata": {  # Optional
37             "version": "VPP version",
38             "job": "Jenkins job name"
39             "build": "Information about the build"
40         },
41         "suites": {
42             "Suite name 1": {
43                 "doc": "Suite 1 documentation"
44             }
45             "Suite name N": {
46                 "doc": "Suite N documentation"
47             }
48         }
49         "tests": {
50             "ID": {
51                 "name": "Test name",
52                 "parent": "Name of the parent of the test",
53                 "tags": ["tag 1", "tag 2", "tag n"],
54                 "type": "PDR" | "NDR",
55                 "throughput": {
56                     "value": int,
57                     "unit": "pps" | "bps" | "percentage"
58                 },
59                 "latency": {
60                     "direction1": {
61                         "100": {
62                             "min": int,
63                             "avg": int,
64                             "max": int
65                         },
66                         "50": {  # Only for NDR
67                             "min": int,
68                             "avg": int,
69                             "max": int
70                         },
71                         "10": {  # Only for NDR
72                             "min": int,
73                             "avg": int,
74                             "max": int
75                         }
76                     },
77                     "direction2": {
78                         "100": {
79                             "min": int,
80                             "avg": int,
81                             "max": int
82                         },
83                         "50": {  # Only for NDR
84                             "min": int,
85                             "avg": int,
86                             "max": int
87                         },
88                         "10": {  # Only for NDR
89                             "min": int,
90                             "avg": int,
91                             "max": int
92                         }
93                     }
94                 },
95                 "lossTolerance": "lossTolerance",  # Only for PDR
96                 "vat-history": {
97                     "DUT1": " DUT1 VAT History",
98                     "DUT2": " DUT2 VAT History"
99                 },
100                 "show-run": "Show Run"
101             },
102             "ID" {
103                 # next test
104             }
105         }
106     }
107
108     .. note:: ID is the lowercase full path to the test.
109     """
110
111     REGEX_RATE = re.compile(r'^[\D\d]*FINAL_RATE:\s(\d+\.\d+)\s(\w+)')
112
113     REGEX_LAT_NDR = re.compile(r'^[\D\d]*'
114                                r'LAT_\d+%NDR:\s\[\'(-?\d+\/-?\d+/-?\d+)\','
115                                r'\s\'(-?\d+/-?\d+/-?\d+)\'\]\s\n'
116                                r'LAT_\d+%NDR:\s\[\'(-?\d+/-?\d+/-?\d+)\','
117                                r'\s\'(-?\d+/-?\d+/-?\d+)\'\]\s\n'
118                                r'LAT_\d+%NDR:\s\[\'(-?\d+/-?\d+/-?\d+)\','
119                                r'\s\'(-?\d+/-?\d+/-?\d+)\'\]')
120
121     REGEX_LAT_PDR = re.compile(r'^[\D\d]*'
122                                r'LAT_\d+%PDR:\s\[\'(-?\d+/-?\d+/-?\d+)\','
123                                r'\s\'(-?\d+/-?\d+/-?\d+)\'\][\D\d]*')
124
125     REGEX_TOLERANCE = re.compile(r'^[\D\d]*LOSS_ACCEPTANCE:\s(\d*\.\d*)\s'
126                                  r'[\D\d]*')
127
128     REGEX_VERSION = re.compile(r"(stdout: 'vat# vat# Version:)(\s*)(.*)")
129
130     def __init__(self, **metadata):
131         """Initialisation.
132
133         :param metadata: Key-value pairs to be included to "metadata" part of
134         JSON structure.
135         :type metadata: dict
136         """
137
138         # Type of message to parse out from the test messages
139         self._msg_type = None
140
141         # VPP version
142         self._version = None
143
144         # Number of VAT History messages found:
145         # 0 - no message
146         # 1 - VAT History of DUT1
147         # 2 - VAT History of DUT2
148         self._vat_history_lookup_nr = 0
149
150         # Number of Show Running messages found
151         # 0 - no message
152         # 1 - Show run message found
153         self._show_run_lookup_nr = 0
154
155         # Test ID of currently processed test- the lowercase full path to the
156         # test
157         self._test_ID = None
158
159         # The main data structure
160         self._data = {
161             "metadata": {
162             },
163             "suites": {
164             },
165             "tests": {
166             }
167         }
168
169         # Save the provided metadata
170         for key, val in metadata.items():
171             self._data["metadata"][key] = val
172
173         # Dictionary defining the methods used to parse different types of
174         # messages
175         self.parse_msg = {
176             "setup-version": self._get_version,
177             "teardown-vat-history": self._get_vat_history,
178             "teardown-show-runtime": self._get_show_run
179         }
180
181     @property
182     def data(self):
183         """Getter - Data parsed from the XML file.
184
185         :returns: Data parsed from the XML file.
186         :rtype: dict
187         """
188         return self._data
189
190     def _get_version(self, msg):
191         """Called when extraction of VPP version is required.
192
193         :param msg: Message to process.
194         :type msg: Message
195         :returns: Nothing.
196         """
197
198         if msg.message.count("stdout: 'vat# vat# Version:"):
199             self._version = str(re.search(self.REGEX_VERSION, msg.message).
200                                 group(3))
201             self._data["metadata"]["version"] = self._version
202             self._msg_type = None
203
204             logging.debug("    VPP version: {0}".format(self._version))
205
206     def _get_vat_history(self, msg):
207         """Called when extraction of VAT command history is required.
208
209         :param msg: Message to process.
210         :type msg: Message
211         :returns: Nothing.
212         """
213         if msg.message.count("VAT command history:"):
214             self._vat_history_lookup_nr += 1
215             text = re.sub("[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3} "
216                           "VAT command history:", "", msg.message, count=1).\
217                 replace('"', "'")
218
219             if self._vat_history_lookup_nr == 1:
220                 self._data["tests"][self._test_ID]["vat-history"] = dict()
221                 self._data["tests"][self._test_ID]["vat-history"]["DUT1"] = text
222             elif self._vat_history_lookup_nr == 2:
223                 self._data["tests"][self._test_ID]["vat-history"]["DUT2"] = text
224             self._msg_type = None
225
226     def _get_show_run(self, msg):
227         """Called when extraction of VPP operational data (output of CLI command
228         Show Runtime) is required.
229
230         :param msg: Message to process.
231         :type msg: Message
232         :returns: Nothing.
233         """
234         if msg.message.count("vat# Thread "):
235             self._show_run_lookup_nr += 1
236             text = msg.message.replace("vat# ", "").\
237                 replace("return STDOUT ", "").replace('"', "'")
238             if self._show_run_lookup_nr == 1:
239                 self._data["tests"][self._test_ID]["show-run"] = text
240             self._msg_type = None
241
242     def _get_latency(self, msg, test_type):
243         """Get the latency data from the test message.
244
245         :param msg: Message to be parsed.
246         :param test_type: Type of the test - NDR or PDR.
247         :type msg: str
248         :type test_type: str
249         :returns: Latencies parsed from the message.
250         :rtype: dict
251         """
252
253         if test_type == "NDR":
254             groups = re.search(self.REGEX_LAT_NDR, msg)
255             groups_range = range(1, 7)
256         elif test_type == "PDR":
257             groups = re.search(self.REGEX_LAT_PDR, msg)
258             groups_range = range(1, 3)
259         else:
260             return {}
261
262         latencies = list()
263         for idx in groups_range:
264             try:
265                 lat = [int(item) for item in str(groups.group(idx)).split('/')]
266             except (AttributeError, ValueError):
267                 lat = [-1, -1, -1]
268             latencies.append(lat)
269
270         keys = ("min", "avg", "max")
271         latency = {
272             "direction1": {
273             },
274             "direction2": {
275             }
276         }
277
278         latency["direction1"]["100"] = dict(zip(keys, latencies[0]))
279         latency["direction2"]["100"] = dict(zip(keys, latencies[1]))
280         if test_type == "NDR":
281             latency["direction1"]["50"] = dict(zip(keys, latencies[2]))
282             latency["direction2"]["50"] = dict(zip(keys, latencies[3]))
283             latency["direction1"]["10"] = dict(zip(keys, latencies[4]))
284             latency["direction2"]["10"] = dict(zip(keys, latencies[5]))
285
286         return latency
287
288     def visit_suite(self, suite):
289         """Implements traversing through the suite and its direct children.
290
291         :param suite: Suite to process.
292         :type suite: Suite
293         :returns: Nothing.
294         """
295         if self.start_suite(suite) is not False:
296             suite.suites.visit(self)
297             suite.tests.visit(self)
298             self.end_suite(suite)
299
300     def start_suite(self, suite):
301         """Called when suite starts.
302
303         :param suite: Suite to process.
304         :type suite: Suite
305         :returns: Nothing.
306         """
307
308         suite_name = suite.name.lower().replace('"', "'")
309         self._data["suites"][suite_name] = \
310             {"doc": suite.doc.replace('"', "'").replace('\n', ' ').
311                     replace('\r', '').replace('*[', '\n *[')}
312
313         suite.keywords.visit(self)
314
315     def end_suite(self, suite):
316         """Called when suite ends.
317
318         :param suite: Suite to process.
319         :type suite: Suite
320         :returns: Nothing.
321         """
322         pass
323
324     def visit_test(self, test):
325         """Implements traversing through the test.
326
327         :param test: Test to process.
328         :type test: Test
329         :returns: Nothing.
330         """
331         if self.start_test(test) is not False:
332             test.keywords.visit(self)
333             self.end_test(test)
334
335     def start_test(self, test):
336         """Called when test starts.
337
338         :param test: Test to process.
339         :type test: Test
340         :returns: Nothing.
341         """
342
343         tags = [str(tag) for tag in test.tags]
344         if test.status == "PASS" and "NDRPDRDISC" in tags:
345
346             if "NDRDISC" in tags:
347                 test_type = "NDR"
348             elif "PDRDISC" in tags:
349                 test_type = "PDR"
350             else:
351                 return
352
353             try:
354                 rate_value = str(re.search(
355                     self.REGEX_RATE, test.message).group(1))
356             except AttributeError:
357                 rate_value = "-1"
358             try:
359                 rate_unit = str(re.search(
360                     self.REGEX_RATE, test.message).group(2))
361             except AttributeError:
362                 rate_unit = "-1"
363
364             test_result = dict()
365             test_result["name"] = test.name.lower()
366             test_result["parent"] = test.parent.name.lower()
367             test_result["tags"] = tags
368             test_result["type"] = test_type
369             test_result["throughput"] = dict()
370             test_result["throughput"]["value"] = int(rate_value.split('.')[0])
371             test_result["throughput"]["unit"] = rate_unit
372             test_result["latency"] = self._get_latency(test.message, test_type)
373             if test_type == "PDR":
374                 test_result["lossTolerance"] = str(re.search(
375                     self.REGEX_TOLERANCE, test.message).group(1))
376
377             self._test_ID = test.longname.lower()
378
379             self._data["tests"][self._test_ID] = test_result
380
381     def end_test(self, test):
382         """Called when test ends.
383
384         :param test: Test to process.
385         :type test: Test
386         :returns: Nothing.
387         """
388         pass
389
390     def visit_keyword(self, keyword):
391         """Implements traversing through the keyword and its child keywords.
392
393         :param keyword: Keyword to process.
394         :type keyword: Keyword
395         :returns: Nothing.
396         """
397         if self.start_keyword(keyword) is not False:
398             self.end_keyword(keyword)
399
400     def start_keyword(self, keyword):
401         """Called when keyword starts. Default implementation does nothing.
402
403         :param keyword: Keyword to process.
404         :type keyword: Keyword
405         :returns: Nothing.
406         """
407         try:
408             if keyword.type == "setup":
409                 self.visit_setup_kw(keyword)
410             elif keyword.type == "teardown":
411                 self.visit_teardown_kw(keyword)
412         except AttributeError:
413             pass
414
415     def end_keyword(self, keyword):
416         """Called when keyword ends. Default implementation does nothing.
417
418         :param keyword: Keyword to process.
419         :type keyword: Keyword
420         :returns: Nothing.
421         """
422         pass
423
424     def visit_setup_kw(self, setup_kw):
425         """Implements traversing through the teardown keyword and its child
426         keywords.
427
428         :param setup_kw: Keyword to process.
429         :type setup_kw: Keyword
430         :returns: Nothing.
431         """
432         for keyword in setup_kw.keywords:
433             if self.start_setup_kw(keyword) is not False:
434                 self.visit_setup_kw(keyword)
435                 self.end_setup_kw(keyword)
436
437     def start_setup_kw(self, setup_kw):
438         """Called when teardown keyword starts. Default implementation does
439         nothing.
440
441         :param setup_kw: Keyword to process.
442         :type setup_kw: Keyword
443         :returns: Nothing.
444         """
445         if setup_kw.name.count("Vpp Show Version Verbose") \
446                 and not self._version:
447             self._msg_type = "setup-version"
448             setup_kw.messages.visit(self)
449
450     def end_setup_kw(self, setup_kw):
451         """Called when keyword ends. Default implementation does nothing.
452
453         :param setup_kw: Keyword to process.
454         :type setup_kw: Keyword
455         :returns: Nothing.
456         """
457         pass
458
459     def visit_teardown_kw(self, teardown_kw):
460         """Implements traversing through the teardown keyword and its child
461         keywords.
462
463         :param teardown_kw: Keyword to process.
464         :type teardown_kw: Keyword
465         :returns: Nothing.
466         """
467         for keyword in teardown_kw.keywords:
468             if self.start_teardown_kw(keyword) is not False:
469                 self.visit_teardown_kw(keyword)
470                 self.end_teardown_kw(keyword)
471
472     def start_teardown_kw(self, teardown_kw):
473         """Called when teardown keyword starts. Default implementation does
474         nothing.
475
476         :param teardown_kw: Keyword to process.
477         :type teardown_kw: Keyword
478         :returns: Nothing.
479         """
480
481         if teardown_kw.name.count("Show Vat History On All Duts"):
482             self._vat_history_lookup_nr = 0
483             self._msg_type = "teardown-vat-history"
484         elif teardown_kw.name.count("Vpp Show Runtime"):
485             self._show_run_lookup_nr = 0
486             self._msg_type = "teardown-show-runtime"
487
488         if self._msg_type:
489             teardown_kw.messages.visit(self)
490
491     def end_teardown_kw(self, teardown_kw):
492         """Called when keyword ends. Default implementation does nothing.
493
494         :param teardown_kw: Keyword to process.
495         :type teardown_kw: Keyword
496         :returns: Nothing.
497         """
498         pass
499
500     def visit_message(self, msg):
501         """Implements visiting the message.
502
503         :param msg: Message to process.
504         :type msg: Message
505         :returns: Nothing.
506         """
507         if self.start_message(msg) is not False:
508             self.end_message(msg)
509
510     def start_message(self, msg):
511         """Called when message starts. Get required information from messages:
512         - VPP version.
513
514         :param msg: Message to process.
515         :type msg: Message
516         :returns: Nothing.
517         """
518
519         if self._msg_type:
520             self.parse_msg[self._msg_type](msg)
521
522     def end_message(self, msg):
523         """Called when message ends. Default implementation does nothing.
524
525         :param msg: Message to process.
526         :type msg: Message
527         :returns: Nothing.
528         """
529         pass
530
531
532 class InputData(object):
533     """Input data
534
535     The data is extracted from output.xml files generated by Jenkins jobs and
536     stored in pandas' DataFrames.
537
538     The data structure:
539     - job name
540       - build number
541         - metadata
542           - job
543           - build
544           - vpp version
545         - suites
546         - tests
547           - ID: test data (as described in ExecutionChecker documentation)
548     """
549
550     def __init__(self, config):
551         """Initialization.
552         """
553
554         # Configuration:
555         self._cfg = config
556
557         # Data store:
558         self._input_data = None
559
560     @property
561     def data(self):
562         """Getter - Input data.
563
564         :returns: Input data
565         :rtype: pandas.Series
566         """
567         return self._input_data
568
569     def metadata(self, job, build):
570         """Getter - metadata
571
572         :param job: Job which metadata we want.
573         :param build: Build which metadata we want.
574         :type job: str
575         :type build: str
576         :returns: Metadata
577         :rtype: pandas.Series
578         """
579
580         return self.data[job][build]["metadata"]
581
582     def suites(self, job, build):
583         """Getter - suites
584
585         :param job: Job which suites we want.
586         :param build: Build which suites we want.
587         :type job: str
588         :type build: str
589         :returns: Suites.
590         :rtype: pandas.Series
591         """
592
593         return self.data[job][build]["suites"]
594
595     def tests(self, job, build):
596         """Getter - tests
597
598         :param job: Job which tests we want.
599         :param build: Build which tests we want.
600         :type job: str
601         :type build: str
602         :returns: Tests.
603         :rtype: pandas.Series
604         """
605
606         return self.data[job][build]["tests"]
607
608     @staticmethod
609     def _parse_tests(job, build):
610         """Process data from robot output.xml file and return JSON structured
611         data.
612
613         :param job: The name of job which build output data will be processed.
614         :param build: The build which output data will be processed.
615         :type job: str
616         :type build: dict
617         :returns: JSON data structure.
618         :rtype: dict
619         """
620
621         with open(build["file-name"], 'r') as data_file:
622             result = ExecutionResult(data_file)
623         checker = ExecutionChecker(job=job, build=build)
624         result.visit(checker)
625
626         return checker.data
627
628     def parse_input_data(self):
629         """Parse input data from input files and store in pandas' Series.
630         """
631
632         logging.info("Parsing input files ...")
633
634         job_data = dict()
635         for job, builds in self._cfg.builds.items():
636             logging.info("  Extracting data from the job '{0}' ...'".
637                          format(job))
638             builds_data = dict()
639             for build in builds:
640                 logging.info("    Extracting data from the build '{0}'".
641                              format(build["build"]))
642                 logging.info("    Processing the file '{0}'".
643                              format(build["file-name"]))
644                 data = InputData._parse_tests(job, build)
645
646                 build_data = pd.Series({
647                     "metadata": pd.Series(data["metadata"].values(),
648                                           index=data["metadata"].keys()),
649                     "suites": pd.Series(data["suites"].values(),
650                                         index=data["suites"].keys()),
651                     "tests": pd.Series(data["tests"].values(),
652                                        index=data["tests"].keys()),
653                     })
654                 builds_data[str(build["build"])] = build_data
655                 logging.info("    Done.")
656
657             job_data[job] = pd.Series(builds_data.values(),
658                                       index=builds_data.keys())
659             logging.info("  Done.")
660
661         self._input_data = pd.Series(job_data.values(), index=job_data.keys())
662         logging.info("Done.")
663
664     @staticmethod
665     def _end_of_tag(tag_filter, start=0, closer="'"):
666         """Return the index of character in the string which is the end of tag.
667
668         :param tag_filter: The string where the end of tag is being searched.
669         :param start: The index where the searching is stated.
670         :param closer: The character which is the tag closer.
671         :type tag_filter: str
672         :type start: int
673         :type closer: str
674         :returns: The index of the tag closer.
675         :rtype: int
676         """
677
678         try:
679             idx_opener = tag_filter.index(closer, start)
680             return tag_filter.index(closer, idx_opener + 1)
681         except ValueError:
682             return None
683
684     @staticmethod
685     def _condition(tag_filter):
686         """Create a conditional statement from the given tag filter.
687
688         :param tag_filter: Filter based on tags from the element specification.
689         :type tag_filter: str
690         :returns: Conditional statement which can be evaluated.
691         :rtype: str
692         """
693
694         index = 0
695         while True:
696             index = InputData._end_of_tag(tag_filter, index)
697             if index is None:
698                 return tag_filter
699             index += 1
700             tag_filter = tag_filter[:index] + " in tags" + tag_filter[index:]
701
702     def filter_tests_data(self, element, params=None):
703         """Filter required data from the given jobs and builds.
704
705         The output data structure is:
706
707         - job 1
708           - build 1
709             - test 1 ID:
710               - param 1
711               - param 2
712               ...
713               - param n
714             ...
715             - test n ID:
716             ...
717           ...
718           - build n
719         ...
720         - job n
721
722         :param element: Element which will use the filtered data.
723         :param params: Parameters which will be included in the output.
724         :type element: pandas.Series
725         :type params: list
726         :returns: Filtered data.
727         :rtype pandas.Series
728         """
729
730         logging.info("  Creating the data set for the {0} '{1}'.".
731                      format(element["type"], element.get("title", "")))
732
733         cond = InputData._condition(element.get("filter", ""))
734         if cond:
735             logging.debug("  Filter: {0}".format(cond))
736         else:
737             logging.error("  No filter defined.")
738             return None
739
740         if params is None:
741             try:
742                 params = element["parameters"]
743             except KeyError:
744                 params = None
745
746         data = pd.Series()
747         try:
748             for job, builds in element["data"].items():
749                 data[job] = pd.Series()
750                 for build in builds:
751                     data[job][str(build)] = pd.Series()
752
753                     for test_ID, test_data in self.tests(job, str(build)).\
754                             iteritems():
755                         if eval(cond, {"tags": test_data["tags"]}):
756                             data[job][str(build)][test_ID] = pd.Series()
757                             if params is None:
758                                 for param, val in test_data.items():
759                                     data[job][str(build)][test_ID][param] = val
760                             else:
761                                 for param in params:
762                                     data[job][str(build)][test_ID][param] = \
763                                         test_data[param]
764             return data
765
766         except (KeyError, IndexError, ValueError) as err:
767             raise PresentationError("Missing mandatory parameter in the "
768                                     "element specification.", err)