Trending: Remove multiprocessing, part 1
[csit.git] / resources / tools / presentation / input_data_parser.py
1 # Copyright (c) 2019 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 - filter the data using tags,
20 """
21
22 import multiprocessing
23 import os
24 import re
25 import pandas as pd
26 import logging
27
28 from robot.api import ExecutionResult, ResultVisitor
29 from robot import errors
30 from collections import OrderedDict
31 from string import replace
32 from os import remove
33 from os.path import join
34 from datetime import datetime as dt
35 from datetime import timedelta
36 from json import loads
37 from jumpavg.AvgStdevMetadataFactory import AvgStdevMetadataFactory
38
39 from input_data_files import download_and_unzip_data_file
40 from utils import Worker
41
42
43 # Separator used in file names
44 SEPARATOR = "__"
45
46
47 class ExecutionChecker(ResultVisitor):
48     """Class to traverse through the test suite structure.
49
50     The functionality implemented in this class generates a json structure:
51
52     Performance tests:
53
54     {
55         "metadata": {
56             "generated": "Timestamp",
57             "version": "SUT version",
58             "job": "Jenkins job name",
59             "build": "Information about the build"
60         },
61         "suites": {
62             "Suite long name 1": {
63                 "name": Suite name,
64                 "doc": "Suite 1 documentation",
65                 "parent": "Suite 1 parent",
66                 "level": "Level of the suite in the suite hierarchy"
67             }
68             "Suite long name N": {
69                 "name": Suite name,
70                 "doc": "Suite N documentation",
71                 "parent": "Suite 2 parent",
72                 "level": "Level of the suite in the suite hierarchy"
73             }
74         }
75         "tests": {
76             # NDRPDR tests:
77             "ID": {
78                 "name": "Test name",
79                 "parent": "Name of the parent of the test",
80                 "doc": "Test documentation",
81                 "msg": "Test message",
82                 "conf-history": "DUT1 and DUT2 VAT History",
83                 "show-run": "Show Run",
84                 "tags": ["tag 1", "tag 2", "tag n"],
85                 "type": "NDRPDR",
86                 "status": "PASS" | "FAIL",
87                 "throughput": {
88                     "NDR": {
89                         "LOWER": float,
90                         "UPPER": float
91                     },
92                     "PDR": {
93                         "LOWER": float,
94                         "UPPER": float
95                     }
96                 },
97                 "latency": {
98                     "NDR": {
99                         "direction1": {
100                             "min": float,
101                             "avg": float,
102                             "max": float
103                         },
104                         "direction2": {
105                             "min": float,
106                             "avg": float,
107                             "max": float
108                         }
109                     },
110                     "PDR": {
111                         "direction1": {
112                             "min": float,
113                             "avg": float,
114                             "max": float
115                         },
116                         "direction2": {
117                             "min": float,
118                             "avg": float,
119                             "max": float
120                         }
121                     }
122                 }
123             }
124
125             # TCP tests:
126             "ID": {
127                 "name": "Test name",
128                 "parent": "Name of the parent of the test",
129                 "doc": "Test documentation",
130                 "msg": "Test message",
131                 "tags": ["tag 1", "tag 2", "tag n"],
132                 "type": "TCP",
133                 "status": "PASS" | "FAIL",
134                 "result": int
135             }
136
137             # MRR, BMRR tests:
138             "ID": {
139                 "name": "Test name",
140                 "parent": "Name of the parent of the test",
141                 "doc": "Test documentation",
142                 "msg": "Test message",
143                 "tags": ["tag 1", "tag 2", "tag n"],
144                 "type": "MRR" | "BMRR",
145                 "status": "PASS" | "FAIL",
146                 "result": {
147                     "receive-rate": AvgStdevMetadata,
148                 }
149             }
150
151             # TODO: Remove when definitely no NDRPDRDISC tests are used:
152             # NDRPDRDISC tests:
153             "ID": {
154                 "name": "Test name",
155                 "parent": "Name of the parent of the test",
156                 "doc": "Test documentation",
157                 "msg": "Test message",
158                 "tags": ["tag 1", "tag 2", "tag n"],
159                 "type": "PDR" | "NDR",
160                 "status": "PASS" | "FAIL",
161                 "throughput": {  # Only type: "PDR" | "NDR"
162                     "value": int,
163                     "unit": "pps" | "bps" | "percentage"
164                 },
165                 "latency": {  # Only type: "PDR" | "NDR"
166                     "direction1": {
167                         "100": {
168                             "min": int,
169                             "avg": int,
170                             "max": int
171                         },
172                         "50": {  # Only for NDR
173                             "min": int,
174                             "avg": int,
175                             "max": int
176                         },
177                         "10": {  # Only for NDR
178                             "min": int,
179                             "avg": int,
180                             "max": int
181                         }
182                     },
183                     "direction2": {
184                         "100": {
185                             "min": int,
186                             "avg": int,
187                             "max": int
188                         },
189                         "50": {  # Only for NDR
190                             "min": int,
191                             "avg": int,
192                             "max": int
193                         },
194                         "10": {  # Only for NDR
195                             "min": int,
196                             "avg": int,
197                             "max": int
198                         }
199                     }
200                 },
201                 "lossTolerance": "lossTolerance",  # Only type: "PDR"
202                 "conf-history": "DUT1 and DUT2 VAT History"
203                 "show-run": "Show Run"
204             },
205             "ID" {
206                 # next test
207             }
208         }
209     }
210
211
212     Functional tests:
213
214     {
215         "metadata": {  # Optional
216             "version": "VPP version",
217             "job": "Jenkins job name",
218             "build": "Information about the build"
219         },
220         "suites": {
221             "Suite name 1": {
222                 "doc": "Suite 1 documentation",
223                 "parent": "Suite 1 parent",
224                 "level": "Level of the suite in the suite hierarchy"
225             }
226             "Suite name N": {
227                 "doc": "Suite N documentation",
228                 "parent": "Suite 2 parent",
229                 "level": "Level of the suite in the suite hierarchy"
230             }
231         }
232         "tests": {
233             "ID": {
234                 "name": "Test name",
235                 "parent": "Name of the parent of the test",
236                 "doc": "Test documentation"
237                 "msg": "Test message"
238                 "tags": ["tag 1", "tag 2", "tag n"],
239                 "conf-history": "DUT1 and DUT2 VAT History"
240                 "show-run": "Show Run"
241                 "status": "PASS" | "FAIL"
242             },
243             "ID" {
244                 # next test
245             }
246         }
247     }
248
249     .. note:: ID is the lowercase full path to the test.
250     """
251
252     # TODO: Remove when definitely no NDRPDRDISC tests are used:
253     REGEX_RATE = re.compile(r'^[\D\d]*FINAL_RATE:\s(\d+\.\d+)\s(\w+)')
254
255     REGEX_PLR_RATE = re.compile(r'PLRsearch lower bound::\s(\d+.\d+).*\n'
256                                 r'PLRsearch upper bound::\s(\d+.\d+)')
257
258     REGEX_NDRPDR_RATE = re.compile(r'NDR_LOWER:\s(\d+.\d+).*\n.*\n'
259                                    r'NDR_UPPER:\s(\d+.\d+).*\n'
260                                    r'PDR_LOWER:\s(\d+.\d+).*\n.*\n'
261                                    r'PDR_UPPER:\s(\d+.\d+)')
262
263     # TODO: Remove when definitely no NDRPDRDISC tests are used:
264     REGEX_LAT_NDR = re.compile(r'^[\D\d]*'
265                                r'LAT_\d+%NDR:\s\[\'(-?\d+/-?\d+/-?\d+)\','
266                                r'\s\'(-?\d+/-?\d+/-?\d+)\'\]\s\n'
267                                r'LAT_\d+%NDR:\s\[\'(-?\d+/-?\d+/-?\d+)\','
268                                r'\s\'(-?\d+/-?\d+/-?\d+)\'\]\s\n'
269                                r'LAT_\d+%NDR:\s\[\'(-?\d+/-?\d+/-?\d+)\','
270                                r'\s\'(-?\d+/-?\d+/-?\d+)\'\]')
271
272     REGEX_LAT_PDR = re.compile(r'^[\D\d]*'
273                                r'LAT_\d+%PDR:\s\[\'(-?\d+/-?\d+/-?\d+)\','
274                                r'\s\'(-?\d+/-?\d+/-?\d+)\'\][\D\d]*')
275
276     REGEX_NDRPDR_LAT = re.compile(r'LATENCY.*\[\'(.*)\', \'(.*)\'\]\s\n.*\n.*\n'
277                                   r'LATENCY.*\[\'(.*)\', \'(.*)\'\]')
278
279     REGEX_TOLERANCE = re.compile(r'^[\D\d]*LOSS_ACCEPTANCE:\s(\d*\.\d*)\s'
280                                  r'[\D\d]*')
281
282     REGEX_VERSION_VPP = re.compile(r"(return STDOUT Version:\s*|"
283                                    r"VPP Version:\s*|VPP version:\s*)(.*)")
284
285     REGEX_VERSION_DPDK = re.compile(r"(return STDOUT testpmd)([\d\D\n]*)"
286                                     r"(RTE Version: 'DPDK )(.*)(')")
287
288     REGEX_TCP = re.compile(r'Total\s(rps|cps|throughput):\s([0-9]*).*$')
289
290     REGEX_MRR = re.compile(r'MaxReceivedRate_Results\s\[pkts/(\d*)sec\]:\s'
291                            r'tx\s(\d*),\srx\s(\d*)')
292
293     REGEX_BMRR = re.compile(r'Maximum Receive Rate trial results'
294                             r' in packets per second: \[(.*)\]')
295
296     REGEX_TC_TAG = re.compile(r'\d+[tT]\d+[cC]')
297
298     REGEX_TC_NAME_OLD = re.compile(r'-\d+[tT]\d+[cC]-')
299
300     REGEX_TC_NAME_NEW = re.compile(r'-\d+[cC]-')
301
302     REGEX_TC_NUMBER = re.compile(r'tc[0-9]{2}-')
303
304     def __init__(self, metadata, mapping, ignore):
305         """Initialisation.
306
307         :param metadata: Key-value pairs to be included in "metadata" part of
308             JSON structure.
309         :param mapping: Mapping of the old names of test cases to the new
310             (actual) one.
311         :param ignore: List of TCs to be ignored.
312         :type metadata: dict
313         :type mapping: dict
314         :type ignore: list
315         """
316
317         # Type of message to parse out from the test messages
318         self._msg_type = None
319
320         # VPP version
321         self._version = None
322
323         # Timestamp
324         self._timestamp = None
325
326         # Testbed. The testbed is identified by TG node IP address.
327         self._testbed = None
328
329         # Mapping of TCs long names
330         self._mapping = mapping
331
332         # Ignore list
333         self._ignore = ignore
334
335         # Number of VAT History messages found:
336         # 0 - no message
337         # 1 - VAT History of DUT1
338         # 2 - VAT History of DUT2
339         self._lookup_kw_nr = 0
340         self._conf_history_lookup_nr = 0
341
342         # Number of Show Running messages found
343         # 0 - no message
344         # 1 - Show run message found
345         self._show_run_lookup_nr = 0
346
347         # Test ID of currently processed test- the lowercase full path to the
348         # test
349         self._test_ID = None
350
351         # The main data structure
352         self._data = {
353             "metadata": OrderedDict(),
354             "suites": OrderedDict(),
355             "tests": OrderedDict()
356         }
357
358         # Save the provided metadata
359         for key, val in metadata.items():
360             self._data["metadata"][key] = val
361
362         # Dictionary defining the methods used to parse different types of
363         # messages
364         self.parse_msg = {
365             "timestamp": self._get_timestamp,
366             "vpp-version": self._get_vpp_version,
367             "dpdk-version": self._get_dpdk_version,
368             "teardown-vat-history": self._get_vat_history,
369             "teardown-papi-history": self._get_papi_history,
370             "test-show-runtime": self._get_show_run,
371             "testbed": self._get_testbed
372         }
373
374     @property
375     def data(self):
376         """Getter - Data parsed from the XML file.
377
378         :returns: Data parsed from the XML file.
379         :rtype: dict
380         """
381         return self._data
382
383     def _get_testbed(self, msg):
384         """Called when extraction of testbed IP is required.
385         The testbed is identified by TG node IP address.
386
387         :param msg: Message to process.
388         :type msg: Message
389         :returns: Nothing.
390         """
391
392         if msg.message.count("Arguments:"):
393             message = str(msg.message).replace(' ', '').replace('\n', '').\
394                 replace("'", '"').replace('b"', '"').\
395                 replace("honeycom", "honeycomb")
396             message = loads(message[11:-1])
397             try:
398                 self._testbed = message["TG"]["host"]
399             except (KeyError, ValueError):
400                 pass
401             finally:
402                 self._data["metadata"]["testbed"] = self._testbed
403                 self._msg_type = None
404
405     def _get_vpp_version(self, msg):
406         """Called when extraction of VPP version is required.
407
408         :param msg: Message to process.
409         :type msg: Message
410         :returns: Nothing.
411         """
412
413         if msg.message.count("return STDOUT Version:") or \
414             msg.message.count("VPP Version:") or \
415             msg.message.count("VPP version:"):
416             self._version = str(re.search(self.REGEX_VERSION_VPP, msg.message).
417                                 group(2))
418             self._data["metadata"]["version"] = self._version
419             self._msg_type = None
420
421     def _get_dpdk_version(self, msg):
422         """Called when extraction of DPDK version is required.
423
424         :param msg: Message to process.
425         :type msg: Message
426         :returns: Nothing.
427         """
428
429         if msg.message.count("return STDOUT testpmd"):
430             try:
431                 self._version = str(re.search(
432                     self.REGEX_VERSION_DPDK, msg.message). group(4))
433                 self._data["metadata"]["version"] = self._version
434             except IndexError:
435                 pass
436             finally:
437                 self._msg_type = None
438
439     def _get_timestamp(self, msg):
440         """Called when extraction of timestamp is required.
441
442         :param msg: Message to process.
443         :type msg: Message
444         :returns: Nothing.
445         """
446
447         self._timestamp = msg.timestamp[:14]
448         self._data["metadata"]["generated"] = self._timestamp
449         self._msg_type = None
450
451     def _get_vat_history(self, msg):
452         """Called when extraction of VAT command history is required.
453
454         :param msg: Message to process.
455         :type msg: Message
456         :returns: Nothing.
457         """
458         if msg.message.count("VAT command history:"):
459             self._conf_history_lookup_nr += 1
460             if self._conf_history_lookup_nr == 1:
461                 self._data["tests"][self._test_ID]["conf-history"] = str()
462             else:
463                 self._msg_type = None
464             text = re.sub("[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3} "
465                           "VAT command history:", "", msg.message, count=1). \
466                 replace("\n\n", "\n").replace('\n', ' |br| ').\
467                 replace('\r', '').replace('"', "'")
468
469             self._data["tests"][self._test_ID]["conf-history"] += " |br| "
470             self._data["tests"][self._test_ID]["conf-history"] += \
471                 "**DUT" + str(self._conf_history_lookup_nr) + ":** " + text
472
473     def _get_papi_history(self, msg):
474         """Called when extraction of PAPI command history is required.
475
476         :param msg: Message to process.
477         :type msg: Message
478         :returns: Nothing.
479         """
480         if msg.message.count("PAPI command history:"):
481             self._conf_history_lookup_nr += 1
482             if self._conf_history_lookup_nr == 1:
483                 self._data["tests"][self._test_ID]["conf-history"] = str()
484             else:
485                 self._msg_type = None
486             text = re.sub("[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3} "
487                           "PAPI command history:", "", msg.message, count=1). \
488                 replace("\n\n", "\n").replace('\n', ' |br| ').\
489                 replace('\r', '').replace('"', "'")
490
491             self._data["tests"][self._test_ID]["conf-history"] += " |br| "
492             self._data["tests"][self._test_ID]["conf-history"] += \
493                 "**DUT" + str(self._conf_history_lookup_nr) + ":** " + text
494
495     def _get_show_run(self, msg):
496         """Called when extraction of VPP operational data (output of CLI command
497         Show Runtime) is required.
498
499         :param msg: Message to process.
500         :type msg: Message
501         :returns: Nothing.
502         """
503         if msg.message.count("Thread 0 vpp_main"):
504             self._show_run_lookup_nr += 1
505             if self._lookup_kw_nr == 1 and self._show_run_lookup_nr == 1:
506                 self._data["tests"][self._test_ID]["show-run"] = str()
507             if self._lookup_kw_nr > 1:
508                 self._msg_type = None
509             if self._show_run_lookup_nr == 1:
510                 text = msg.message.replace("vat# ", "").\
511                     replace("return STDOUT ", "").replace("\n\n", "\n").\
512                     replace('\n', ' |br| ').\
513                     replace('\r', '').replace('"', "'")
514                 try:
515                     self._data["tests"][self._test_ID]["show-run"] += " |br| "
516                     self._data["tests"][self._test_ID]["show-run"] += \
517                         "**DUT" + str(self._lookup_kw_nr) + ":** |br| " + text
518                 except KeyError:
519                     pass
520
521     # TODO: Remove when definitely no NDRPDRDISC tests are used:
522     def _get_latency(self, msg, test_type):
523         """Get the latency data from the test message.
524
525         :param msg: Message to be parsed.
526         :param test_type: Type of the test - NDR or PDR.
527         :type msg: str
528         :type test_type: str
529         :returns: Latencies parsed from the message.
530         :rtype: dict
531         """
532
533         if test_type == "NDR":
534             groups = re.search(self.REGEX_LAT_NDR, msg)
535             groups_range = range(1, 7)
536         elif test_type == "PDR":
537             groups = re.search(self.REGEX_LAT_PDR, msg)
538             groups_range = range(1, 3)
539         else:
540             return {}
541
542         latencies = list()
543         for idx in groups_range:
544             try:
545                 lat = [int(item) for item in str(groups.group(idx)).split('/')]
546             except (AttributeError, ValueError):
547                 lat = [-1, -1, -1]
548             latencies.append(lat)
549
550         keys = ("min", "avg", "max")
551         latency = {
552             "direction1": {
553             },
554             "direction2": {
555             }
556         }
557
558         latency["direction1"]["100"] = dict(zip(keys, latencies[0]))
559         latency["direction2"]["100"] = dict(zip(keys, latencies[1]))
560         if test_type == "NDR":
561             latency["direction1"]["50"] = dict(zip(keys, latencies[2]))
562             latency["direction2"]["50"] = dict(zip(keys, latencies[3]))
563             latency["direction1"]["10"] = dict(zip(keys, latencies[4]))
564             latency["direction2"]["10"] = dict(zip(keys, latencies[5]))
565
566         return latency
567
568     def _get_ndrpdr_throughput(self, msg):
569         """Get NDR_LOWER, NDR_UPPER, PDR_LOWER and PDR_UPPER from the test
570         message.
571
572         :param msg: The test message to be parsed.
573         :type msg: str
574         :returns: Parsed data as a dict and the status (PASS/FAIL).
575         :rtype: tuple(dict, str)
576         """
577
578         throughput = {
579             "NDR": {"LOWER": -1.0, "UPPER": -1.0},
580             "PDR": {"LOWER": -1.0, "UPPER": -1.0}
581         }
582         status = "FAIL"
583         groups = re.search(self.REGEX_NDRPDR_RATE, msg)
584
585         if groups is not None:
586             try:
587                 throughput["NDR"]["LOWER"] = float(groups.group(1))
588                 throughput["NDR"]["UPPER"] = float(groups.group(2))
589                 throughput["PDR"]["LOWER"] = float(groups.group(3))
590                 throughput["PDR"]["UPPER"] = float(groups.group(4))
591                 status = "PASS"
592             except (IndexError, ValueError):
593                 pass
594
595         return throughput, status
596
597     def _get_plr_throughput(self, msg):
598         """Get PLRsearch lower bound and PLRsearch upper bound from the test
599         message.
600
601         :param msg: The test message to be parsed.
602         :type msg: str
603         :returns: Parsed data as a dict and the status (PASS/FAIL).
604         :rtype: tuple(dict, str)
605         """
606
607         throughput = {
608             "LOWER": -1.0,
609             "UPPER": -1.0
610         }
611         status = "FAIL"
612         groups = re.search(self.REGEX_PLR_RATE, msg)
613
614         if groups is not None:
615             try:
616                 throughput["LOWER"] = float(groups.group(1))
617                 throughput["UPPER"] = float(groups.group(2))
618                 status = "PASS"
619             except (IndexError, ValueError):
620                 pass
621
622         return throughput, status
623
624     def _get_ndrpdr_latency(self, msg):
625         """Get LATENCY from the test message.
626
627         :param msg: The test message to be parsed.
628         :type msg: str
629         :returns: Parsed data as a dict and the status (PASS/FAIL).
630         :rtype: tuple(dict, str)
631         """
632
633         latency = {
634             "NDR": {
635                 "direction1": {"min": -1.0, "avg": -1.0, "max": -1.0},
636                 "direction2": {"min": -1.0, "avg": -1.0, "max": -1.0}
637             },
638             "PDR": {
639                 "direction1": {"min": -1.0, "avg": -1.0, "max": -1.0},
640                 "direction2": {"min": -1.0, "avg": -1.0, "max": -1.0}
641             }
642         }
643         status = "FAIL"
644         groups = re.search(self.REGEX_NDRPDR_LAT, msg)
645
646         if groups is not None:
647             keys = ("min", "avg", "max")
648             try:
649                 latency["NDR"]["direction1"] = dict(
650                     zip(keys, [float(l) for l in groups.group(1).split('/')]))
651                 latency["NDR"]["direction2"] = dict(
652                     zip(keys, [float(l) for l in groups.group(2).split('/')]))
653                 latency["PDR"]["direction1"] = dict(
654                     zip(keys, [float(l) for l in groups.group(3).split('/')]))
655                 latency["PDR"]["direction2"] = dict(
656                     zip(keys, [float(l) for l in groups.group(4).split('/')]))
657                 status = "PASS"
658             except (IndexError, ValueError):
659                 pass
660
661         return latency, status
662
663     def visit_suite(self, suite):
664         """Implements traversing through the suite and its direct children.
665
666         :param suite: Suite to process.
667         :type suite: Suite
668         :returns: Nothing.
669         """
670         if self.start_suite(suite) is not False:
671             suite.suites.visit(self)
672             suite.tests.visit(self)
673             self.end_suite(suite)
674
675     def start_suite(self, suite):
676         """Called when suite starts.
677
678         :param suite: Suite to process.
679         :type suite: Suite
680         :returns: Nothing.
681         """
682
683         try:
684             parent_name = suite.parent.name
685         except AttributeError:
686             return
687
688         doc_str = suite.doc.replace('"', "'").replace('\n', ' ').\
689             replace('\r', '').replace('*[', ' |br| *[').replace("*", "**")
690         doc_str = replace(doc_str, ' |br| *[', '*[', maxreplace=1)
691
692         self._data["suites"][suite.longname.lower().replace('"', "'").
693             replace(" ", "_")] = {
694                 "name": suite.name.lower(),
695                 "doc": doc_str,
696                 "parent": parent_name,
697                 "level": len(suite.longname.split("."))
698             }
699
700         suite.keywords.visit(self)
701
702     def end_suite(self, suite):
703         """Called when suite ends.
704
705         :param suite: Suite to process.
706         :type suite: Suite
707         :returns: Nothing.
708         """
709         pass
710
711     def visit_test(self, test):
712         """Implements traversing through the test.
713
714         :param test: Test to process.
715         :type test: Test
716         :returns: Nothing.
717         """
718         if self.start_test(test) is not False:
719             test.keywords.visit(self)
720             self.end_test(test)
721
722     def start_test(self, test):
723         """Called when test starts.
724
725         :param test: Test to process.
726         :type test: Test
727         :returns: Nothing.
728         """
729
730         longname_orig = test.longname.lower()
731
732         # Check the ignore list
733         if longname_orig in self._ignore:
734             return
735
736         tags = [str(tag) for tag in test.tags]
737         test_result = dict()
738
739         # Change the TC long name and name if defined in the mapping table
740         longname = self._mapping.get(longname_orig, None)
741         if longname is not None:
742             name = longname.split('.')[-1]
743             logging.debug("{0}\n{1}\n{2}\n{3}".format(
744                 self._data["metadata"], longname_orig, longname, name))
745         else:
746             longname = longname_orig
747             name = test.name.lower()
748
749         # Remove TC number from the TC long name (backward compatibility):
750         self._test_ID = re.sub(self.REGEX_TC_NUMBER, "", longname)
751         # Remove TC number from the TC name (not needed):
752         test_result["name"] = re.sub(self.REGEX_TC_NUMBER, "", name)
753
754         test_result["parent"] = test.parent.name.lower()
755         test_result["tags"] = tags
756         doc_str = test.doc.replace('"', "'").replace('\n', ' '). \
757             replace('\r', '').replace('[', ' |br| [')
758         test_result["doc"] = replace(doc_str, ' |br| [', '[', maxreplace=1)
759         test_result["msg"] = test.message.replace('\n', ' |br| '). \
760             replace('\r', '').replace('"', "'")
761         test_result["type"] = "FUNC"
762         test_result["status"] = test.status
763
764         if "PERFTEST" in tags:
765             # Replace info about cores (e.g. -1c-) with the info about threads
766             # and cores (e.g. -1t1c-) in the long test case names and in the
767             # test case names if necessary.
768             groups = re.search(self.REGEX_TC_NAME_OLD, self._test_ID)
769             if not groups:
770                 tag_count = 0
771                 for tag in test_result["tags"]:
772                     groups = re.search(self.REGEX_TC_TAG, tag)
773                     if groups:
774                         tag_count += 1
775                         tag_tc = tag
776
777                 if tag_count == 1:
778                     self._test_ID = re.sub(self.REGEX_TC_NAME_NEW,
779                                            "-{0}-".format(tag_tc.lower()),
780                                            self._test_ID,
781                                            count=1)
782                     test_result["name"] = re.sub(self.REGEX_TC_NAME_NEW,
783                                                  "-{0}-".format(tag_tc.lower()),
784                                                  test_result["name"],
785                                                  count=1)
786                 else:
787                     test_result["status"] = "FAIL"
788                     self._data["tests"][self._test_ID] = test_result
789                     logging.debug("The test '{0}' has no or more than one "
790                                   "multi-threading tags.".format(self._test_ID))
791                     logging.debug("Tags: {0}".format(test_result["tags"]))
792                     return
793
794         if test.status == "PASS" and ("NDRPDRDISC" in tags or
795                                       "NDRPDR" in tags or
796                                       "SOAK" in tags or
797                                       "TCP" in tags or
798                                       "MRR" in tags or
799                                       "BMRR" in tags):
800             # TODO: Remove when definitely no NDRPDRDISC tests are used:
801             if "NDRDISC" in tags:
802                 test_result["type"] = "NDR"
803             # TODO: Remove when definitely no NDRPDRDISC tests are used:
804             elif "PDRDISC" in tags:
805                 test_result["type"] = "PDR"
806             elif "NDRPDR" in tags:
807                 test_result["type"] = "NDRPDR"
808             elif "SOAK" in tags:
809                 test_result["type"] = "SOAK"
810             elif "TCP" in tags:
811                 test_result["type"] = "TCP"
812             elif "MRR" in tags:
813                 test_result["type"] = "MRR"
814             elif "FRMOBL" in tags or "BMRR" in tags:
815                 test_result["type"] = "BMRR"
816             else:
817                 test_result["status"] = "FAIL"
818                 self._data["tests"][self._test_ID] = test_result
819                 return
820
821             # TODO: Remove when definitely no NDRPDRDISC tests are used:
822             if test_result["type"] in ("NDR", "PDR"):
823                 try:
824                     rate_value = str(re.search(
825                         self.REGEX_RATE, test.message).group(1))
826                 except AttributeError:
827                     rate_value = "-1"
828                 try:
829                     rate_unit = str(re.search(
830                         self.REGEX_RATE, test.message).group(2))
831                 except AttributeError:
832                     rate_unit = "-1"
833
834                 test_result["throughput"] = dict()
835                 test_result["throughput"]["value"] = \
836                     int(rate_value.split('.')[0])
837                 test_result["throughput"]["unit"] = rate_unit
838                 test_result["latency"] = \
839                     self._get_latency(test.message, test_result["type"])
840                 if test_result["type"] == "PDR":
841                     test_result["lossTolerance"] = str(re.search(
842                         self.REGEX_TOLERANCE, test.message).group(1))
843
844             elif test_result["type"] in ("NDRPDR", ):
845                 test_result["throughput"], test_result["status"] = \
846                     self._get_ndrpdr_throughput(test.message)
847                 test_result["latency"], test_result["status"] = \
848                     self._get_ndrpdr_latency(test.message)
849
850             elif test_result["type"] in ("SOAK", ):
851                 test_result["throughput"], test_result["status"] = \
852                     self._get_plr_throughput(test.message)
853
854             elif test_result["type"] in ("TCP", ):
855                 groups = re.search(self.REGEX_TCP, test.message)
856                 test_result["result"] = int(groups.group(2))
857
858             elif test_result["type"] in ("MRR", "BMRR"):
859                 test_result["result"] = dict()
860                 groups = re.search(self.REGEX_BMRR, test.message)
861                 if groups is not None:
862                     items_str = groups.group(1)
863                     items_float = [float(item.strip()) for item
864                                    in items_str.split(",")]
865                     metadata = AvgStdevMetadataFactory.from_data(items_float)
866                     # Next two lines have been introduced in CSIT-1179,
867                     # to be removed in CSIT-1180.
868                     metadata.size = 1
869                     metadata.stdev = 0.0
870                     test_result["result"]["receive-rate"] = metadata
871                 else:
872                     groups = re.search(self.REGEX_MRR, test.message)
873                     test_result["result"]["receive-rate"] = \
874                         AvgStdevMetadataFactory.from_data([
875                             float(groups.group(3)) / float(groups.group(1)), ])
876
877         self._data["tests"][self._test_ID] = test_result
878
879     def end_test(self, test):
880         """Called when test ends.
881
882         :param test: Test to process.
883         :type test: Test
884         :returns: Nothing.
885         """
886         pass
887
888     def visit_keyword(self, keyword):
889         """Implements traversing through the keyword and its child keywords.
890
891         :param keyword: Keyword to process.
892         :type keyword: Keyword
893         :returns: Nothing.
894         """
895         if self.start_keyword(keyword) is not False:
896             self.end_keyword(keyword)
897
898     def start_keyword(self, keyword):
899         """Called when keyword starts. Default implementation does nothing.
900
901         :param keyword: Keyword to process.
902         :type keyword: Keyword
903         :returns: Nothing.
904         """
905         try:
906             if keyword.type == "setup":
907                 self.visit_setup_kw(keyword)
908             elif keyword.type == "teardown":
909                 self._lookup_kw_nr = 0
910                 self.visit_teardown_kw(keyword)
911             else:
912                 self._lookup_kw_nr = 0
913                 self.visit_test_kw(keyword)
914         except AttributeError:
915             pass
916
917     def end_keyword(self, keyword):
918         """Called when keyword ends. Default implementation does nothing.
919
920         :param keyword: Keyword to process.
921         :type keyword: Keyword
922         :returns: Nothing.
923         """
924         pass
925
926     def visit_test_kw(self, test_kw):
927         """Implements traversing through the test keyword and its child
928         keywords.
929
930         :param test_kw: Keyword to process.
931         :type test_kw: Keyword
932         :returns: Nothing.
933         """
934         for keyword in test_kw.keywords:
935             if self.start_test_kw(keyword) is not False:
936                 self.visit_test_kw(keyword)
937                 self.end_test_kw(keyword)
938
939     def start_test_kw(self, test_kw):
940         """Called when test keyword starts. Default implementation does
941         nothing.
942
943         :param test_kw: Keyword to process.
944         :type test_kw: Keyword
945         :returns: Nothing.
946         """
947         if test_kw.name.count("Show Runtime Counters On All Duts"):
948             self._lookup_kw_nr += 1
949             self._show_run_lookup_nr = 0
950             self._msg_type = "test-show-runtime"
951         elif test_kw.name.count("Start The L2fwd Test") and not self._version:
952             self._msg_type = "dpdk-version"
953         else:
954             return
955         test_kw.messages.visit(self)
956
957     def end_test_kw(self, test_kw):
958         """Called when keyword ends. Default implementation does nothing.
959
960         :param test_kw: Keyword to process.
961         :type test_kw: Keyword
962         :returns: Nothing.
963         """
964         pass
965
966     def visit_setup_kw(self, setup_kw):
967         """Implements traversing through the teardown keyword and its child
968         keywords.
969
970         :param setup_kw: Keyword to process.
971         :type setup_kw: Keyword
972         :returns: Nothing.
973         """
974         for keyword in setup_kw.keywords:
975             if self.start_setup_kw(keyword) is not False:
976                 self.visit_setup_kw(keyword)
977                 self.end_setup_kw(keyword)
978
979     def start_setup_kw(self, setup_kw):
980         """Called when teardown keyword starts. Default implementation does
981         nothing.
982
983         :param setup_kw: Keyword to process.
984         :type setup_kw: Keyword
985         :returns: Nothing.
986         """
987         if setup_kw.name.count("Show Vpp Version On All Duts") \
988                 and not self._version:
989             self._msg_type = "vpp-version"
990         elif setup_kw.name.count("Set Global Variable") \
991                 and not self._timestamp:
992             self._msg_type = "timestamp"
993         elif setup_kw.name.count("Setup Framework") and not self._testbed:
994             self._msg_type = "testbed"
995         else:
996             return
997         setup_kw.messages.visit(self)
998
999     def end_setup_kw(self, setup_kw):
1000         """Called when keyword ends. Default implementation does nothing.
1001
1002         :param setup_kw: Keyword to process.
1003         :type setup_kw: Keyword
1004         :returns: Nothing.
1005         """
1006         pass
1007
1008     def visit_teardown_kw(self, teardown_kw):
1009         """Implements traversing through the teardown keyword and its child
1010         keywords.
1011
1012         :param teardown_kw: Keyword to process.
1013         :type teardown_kw: Keyword
1014         :returns: Nothing.
1015         """
1016         for keyword in teardown_kw.keywords:
1017             if self.start_teardown_kw(keyword) is not False:
1018                 self.visit_teardown_kw(keyword)
1019                 self.end_teardown_kw(keyword)
1020
1021     def start_teardown_kw(self, teardown_kw):
1022         """Called when teardown keyword starts. Default implementation does
1023         nothing.
1024
1025         :param teardown_kw: Keyword to process.
1026         :type teardown_kw: Keyword
1027         :returns: Nothing.
1028         """
1029
1030         if teardown_kw.name.count("Show Vat History On All Duts"):
1031             self._conf_history_lookup_nr = 0
1032             self._msg_type = "teardown-vat-history"
1033             teardown_kw.messages.visit(self)
1034         elif teardown_kw.name.count("Show Papi History On All Duts"):
1035             self._conf_history_lookup_nr = 0
1036             self._msg_type = "teardown-papi-history"
1037             teardown_kw.messages.visit(self)
1038
1039     def end_teardown_kw(self, teardown_kw):
1040         """Called when keyword ends. Default implementation does nothing.
1041
1042         :param teardown_kw: Keyword to process.
1043         :type teardown_kw: Keyword
1044         :returns: Nothing.
1045         """
1046         pass
1047
1048     def visit_message(self, msg):
1049         """Implements visiting the message.
1050
1051         :param msg: Message to process.
1052         :type msg: Message
1053         :returns: Nothing.
1054         """
1055         if self.start_message(msg) is not False:
1056             self.end_message(msg)
1057
1058     def start_message(self, msg):
1059         """Called when message starts. Get required information from messages:
1060         - VPP version.
1061
1062         :param msg: Message to process.
1063         :type msg: Message
1064         :returns: Nothing.
1065         """
1066
1067         if self._msg_type:
1068             self.parse_msg[self._msg_type](msg)
1069
1070     def end_message(self, msg):
1071         """Called when message ends. Default implementation does nothing.
1072
1073         :param msg: Message to process.
1074         :type msg: Message
1075         :returns: Nothing.
1076         """
1077         pass
1078
1079
1080 class InputData(object):
1081     """Input data
1082
1083     The data is extracted from output.xml files generated by Jenkins jobs and
1084     stored in pandas' DataFrames.
1085
1086     The data structure:
1087     - job name
1088       - build number
1089         - metadata
1090           (as described in ExecutionChecker documentation)
1091         - suites
1092           (as described in ExecutionChecker documentation)
1093         - tests
1094           (as described in ExecutionChecker documentation)
1095     """
1096
1097     def __init__(self, spec):
1098         """Initialization.
1099
1100         :param spec: Specification.
1101         :type spec: Specification
1102         """
1103
1104         # Specification:
1105         self._cfg = spec
1106
1107         # Data store:
1108         self._input_data = pd.Series()
1109
1110     @property
1111     def data(self):
1112         """Getter - Input data.
1113
1114         :returns: Input data
1115         :rtype: pandas.Series
1116         """
1117         return self._input_data
1118
1119     def metadata(self, job, build):
1120         """Getter - metadata
1121
1122         :param job: Job which metadata we want.
1123         :param build: Build which metadata we want.
1124         :type job: str
1125         :type build: str
1126         :returns: Metadata
1127         :rtype: pandas.Series
1128         """
1129
1130         return self.data[job][build]["metadata"]
1131
1132     def suites(self, job, build):
1133         """Getter - suites
1134
1135         :param job: Job which suites we want.
1136         :param build: Build which suites we want.
1137         :type job: str
1138         :type build: str
1139         :returns: Suites.
1140         :rtype: pandas.Series
1141         """
1142
1143         return self.data[job][str(build)]["suites"]
1144
1145     def tests(self, job, build):
1146         """Getter - tests
1147
1148         :param job: Job which tests we want.
1149         :param build: Build which tests we want.
1150         :type job: str
1151         :type build: str
1152         :returns: Tests.
1153         :rtype: pandas.Series
1154         """
1155
1156         return self.data[job][build]["tests"]
1157
1158     def _parse_tests(self, job, build, log):
1159         """Process data from robot output.xml file and return JSON structured
1160         data.
1161
1162         :param job: The name of job which build output data will be processed.
1163         :param build: The build which output data will be processed.
1164         :param log: List of log messages.
1165         :type job: str
1166         :type build: dict
1167         :type log: list of tuples (severity, msg)
1168         :returns: JSON data structure.
1169         :rtype: dict
1170         """
1171
1172         metadata = {
1173             "job": job,
1174             "build": build
1175         }
1176
1177         with open(build["file-name"], 'r') as data_file:
1178             try:
1179                 result = ExecutionResult(data_file)
1180             except errors.DataError as err:
1181                 log.append(("ERROR", "Error occurred while parsing output.xml: "
1182                                      "{0}".format(err)))
1183                 return None
1184         checker = ExecutionChecker(metadata, self._cfg.mapping,
1185                                    self._cfg.ignore)
1186         result.visit(checker)
1187
1188         return checker.data
1189
1190     def _download_and_parse_build(self, job, build, repeat, pid=10000):
1191         """Download and parse the input data file.
1192
1193         :param pid: PID of the process executing this method.
1194         :param job: Name of the Jenkins job which generated the processed input
1195             file.
1196         :param build: Information about the Jenkins build which generated the
1197             processed input file.
1198         :param repeat: Repeat the download specified number of times if not
1199             successful.
1200         :type pid: int
1201         :type job: str
1202         :type build: dict
1203         :type repeat: int
1204         """
1205
1206         logs = list()
1207
1208         logs.append(("INFO", "  Processing the job/build: {0}: {1}".
1209                      format(job, build["build"])))
1210
1211         state = "failed"
1212         success = False
1213         data = None
1214         do_repeat = repeat
1215         while do_repeat:
1216             success = download_and_unzip_data_file(self._cfg, job, build, pid,
1217                                                    logs)
1218             if success:
1219                 break
1220             do_repeat -= 1
1221         if not success:
1222             logs.append(("ERROR", "It is not possible to download the input "
1223                                   "data file from the job '{job}', build "
1224                                   "'{build}', or it is damaged. Skipped.".
1225                          format(job=job, build=build["build"])))
1226         if success:
1227             logs.append(("INFO", "    Processing data from the build '{0}' ...".
1228                          format(build["build"])))
1229             data = self._parse_tests(job, build, logs)
1230             if data is None:
1231                 logs.append(("ERROR", "Input data file from the job '{job}', "
1232                                       "build '{build}' is damaged. Skipped.".
1233                              format(job=job, build=build["build"])))
1234             else:
1235                 state = "processed"
1236
1237             try:
1238                 remove(build["file-name"])
1239             except OSError as err:
1240                 logs.append(("ERROR", "Cannot remove the file '{0}': {1}".
1241                              format(build["file-name"], repr(err))))
1242
1243         # If the time-period is defined in the specification file, remove all
1244         # files which are outside the time period.
1245         timeperiod = self._cfg.input.get("time-period", None)
1246         if timeperiod and data:
1247             now = dt.utcnow()
1248             timeperiod = timedelta(int(timeperiod))
1249             metadata = data.get("metadata", None)
1250             if metadata:
1251                 generated = metadata.get("generated", None)
1252                 if generated:
1253                     generated = dt.strptime(generated, "%Y%m%d %H:%M")
1254                     if (now - generated) > timeperiod:
1255                         # Remove the data and the file:
1256                         state = "removed"
1257                         data = None
1258                         logs.append(
1259                             ("INFO",
1260                              "    The build {job}/{build} is outdated, will be "
1261                              "removed".format(job=job, build=build["build"])))
1262                         file_name = self._cfg.input["file-name"]
1263                         full_name = join(
1264                             self._cfg.environment["paths"]["DIR[WORKING,DATA]"],
1265                             "{job}{sep}{build}{sep}{name}".
1266                                 format(job=job,
1267                                        sep=SEPARATOR,
1268                                        build=build["build"],
1269                                        name=file_name))
1270                         try:
1271                             remove(full_name)
1272                             logs.append(("INFO",
1273                                          "    The file {name} has been removed".
1274                                          format(name=full_name)))
1275                         except OSError as err:
1276                             logs.append(("ERROR",
1277                                          "Cannot remove the file '{0}': {1}".
1278                                          format(full_name, repr(err))))
1279         logs.append(("INFO", "  Done."))
1280
1281         for level, line in logs:
1282             if level == "INFO":
1283                 logging.info(line)
1284             elif level == "ERROR":
1285                 logging.error(line)
1286             elif level == "DEBUG":
1287                 logging.debug(line)
1288             elif level == "CRITICAL":
1289                 logging.critical(line)
1290             elif level == "WARNING":
1291                 logging.warning(line)
1292
1293         return {"data": data, "state": state, "job": job, "build": build}
1294
1295     def download_and_parse_data(self, repeat=1):
1296         """Download the input data files, parse input data from input files and
1297         store in pandas' Series.
1298
1299         :param repeat: Repeat the download specified number of times if not
1300             successful.
1301         :type repeat: int
1302         """
1303
1304         logging.info("Downloading and parsing input files ...")
1305
1306         for job, builds in self._cfg.builds.items():
1307             for build in builds:
1308
1309                 result = self._download_and_parse_build(job, build, repeat)
1310
1311                 job = result["job"]
1312                 build_nr = result["build"]["build"]
1313
1314                 logging.info("  {job}-{build}".format(job=job,
1315                                                       build=build_nr))
1316                 if result["data"]:
1317                     data = result["data"]
1318                     build_data = pd.Series({
1319                         "metadata": pd.Series(
1320                             data["metadata"].values(),
1321                             index=data["metadata"].keys()),
1322                         "suites": pd.Series(data["suites"].values(),
1323                                             index=data["suites"].keys()),
1324                         "tests": pd.Series(data["tests"].values(),
1325                                            index=data["tests"].keys())})
1326
1327                     if self._input_data.get(job, None) is None:
1328                         self._input_data[job] = pd.Series()
1329                     self._input_data[job][str(build_nr)] = build_data
1330
1331                     self._cfg.set_input_file_name(
1332                         job, build_nr, result["build"]["file-name"])
1333
1334                 self._cfg.set_input_state(job, build_nr, result["state"])
1335
1336         logging.info("Done.")
1337
1338     @staticmethod
1339     def _end_of_tag(tag_filter, start=0, closer="'"):
1340         """Return the index of character in the string which is the end of tag.
1341
1342         :param tag_filter: The string where the end of tag is being searched.
1343         :param start: The index where the searching is stated.
1344         :param closer: The character which is the tag closer.
1345         :type tag_filter: str
1346         :type start: int
1347         :type closer: str
1348         :returns: The index of the tag closer.
1349         :rtype: int
1350         """
1351
1352         try:
1353             idx_opener = tag_filter.index(closer, start)
1354             return tag_filter.index(closer, idx_opener + 1)
1355         except ValueError:
1356             return None
1357
1358     @staticmethod
1359     def _condition(tag_filter):
1360         """Create a conditional statement from the given tag filter.
1361
1362         :param tag_filter: Filter based on tags from the element specification.
1363         :type tag_filter: str
1364         :returns: Conditional statement which can be evaluated.
1365         :rtype: str
1366         """
1367
1368         index = 0
1369         while True:
1370             index = InputData._end_of_tag(tag_filter, index)
1371             if index is None:
1372                 return tag_filter
1373             index += 1
1374             tag_filter = tag_filter[:index] + " in tags" + tag_filter[index:]
1375
1376     def filter_data(self, element, params=None, data_set="tests",
1377                     continue_on_error=False):
1378         """Filter required data from the given jobs and builds.
1379
1380         The output data structure is:
1381
1382         - job 1
1383           - build 1
1384             - test (or suite) 1 ID:
1385               - param 1
1386               - param 2
1387               ...
1388               - param n
1389             ...
1390             - test (or suite) n ID:
1391             ...
1392           ...
1393           - build n
1394         ...
1395         - job n
1396
1397         :param element: Element which will use the filtered data.
1398         :param params: Parameters which will be included in the output. If None,
1399         all parameters are included.
1400         :param data_set: The set of data to be filtered: tests, suites,
1401         metadata.
1402         :param continue_on_error: Continue if there is error while reading the
1403         data. The Item will be empty then
1404         :type element: pandas.Series
1405         :type params: list
1406         :type data_set: str
1407         :type continue_on_error: bool
1408         :returns: Filtered data.
1409         :rtype pandas.Series
1410         """
1411
1412         try:
1413             if element["filter"] in ("all", "template"):
1414                 cond = "True"
1415             else:
1416                 cond = InputData._condition(element["filter"])
1417             logging.debug("   Filter: {0}".format(cond))
1418         except KeyError:
1419             logging.error("  No filter defined.")
1420             return None
1421
1422         if params is None:
1423             params = element.get("parameters", None)
1424             if params:
1425                 params.append("type")
1426
1427         data = pd.Series()
1428         try:
1429             for job, builds in element["data"].items():
1430                 data[job] = pd.Series()
1431                 for build in builds:
1432                     data[job][str(build)] = pd.Series()
1433                     try:
1434                         data_iter = self.data[job][str(build)][data_set].\
1435                             iteritems()
1436                     except KeyError:
1437                         if continue_on_error:
1438                             continue
1439                         else:
1440                             return None
1441                     for test_ID, test_data in data_iter:
1442                         if eval(cond, {"tags": test_data.get("tags", "")}):
1443                             data[job][str(build)][test_ID] = pd.Series()
1444                             if params is None:
1445                                 for param, val in test_data.items():
1446                                     data[job][str(build)][test_ID][param] = val
1447                             else:
1448                                 for param in params:
1449                                     try:
1450                                         data[job][str(build)][test_ID][param] =\
1451                                             test_data[param]
1452                                     except KeyError:
1453                                         data[job][str(build)][test_ID][param] =\
1454                                             "No Data"
1455             return data
1456
1457         except (KeyError, IndexError, ValueError) as err:
1458             logging.error("   Missing mandatory parameter in the element "
1459                           "specification: {0}".format(err))
1460             return None
1461         except AttributeError:
1462             return None
1463         except SyntaxError:
1464             logging.error("   The filter '{0}' is not correct. Check if all "
1465                           "tags are enclosed by apostrophes.".format(cond))
1466             return None
1467
1468     @staticmethod
1469     def merge_data(data):
1470         """Merge data from more jobs and builds to a simple data structure.
1471
1472         The output data structure is:
1473
1474         - test (suite) 1 ID:
1475           - param 1
1476           - param 2
1477           ...
1478           - param n
1479         ...
1480         - test (suite) n ID:
1481         ...
1482
1483         :param data: Data to merge.
1484         :type data: pandas.Series
1485         :returns: Merged data.
1486         :rtype: pandas.Series
1487         """
1488
1489         logging.info("    Merging data ...")
1490
1491         merged_data = pd.Series()
1492         for _, builds in data.iteritems():
1493             for _, item in builds.iteritems():
1494                 for ID, item_data in item.iteritems():
1495                     merged_data[ID] = item_data
1496
1497         return merged_data