ae566c67fab716f526134ba05f610c8db5ab22a6
[csit.git] / resources / tools / presentation / specification_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 """Specification
15
16 Parsing of the specification YAML file.
17 """
18
19
20 import logging
21 from yaml import load, YAMLError
22 from pprint import pformat
23
24 from errors import PresentationError
25 from utils import get_last_successful_build_number
26 from utils import get_last_completed_build_number
27
28
29 class Specification(object):
30     """Specification of Presentation and analytics layer.
31
32     - based on specification specified in the specification YAML file
33     - presentation and analytics layer is model driven
34     """
35
36     # Tags are used in specification YAML file and replaced while the file is
37     # parsed.
38     TAG_OPENER = "{"
39     TAG_CLOSER = "}"
40
41     def __init__(self, cfg_file):
42         """Initialization.
43
44         :param cfg_file: File handler for the specification YAML file.
45         :type cfg_file: BinaryIO
46         """
47         self._cfg_file = cfg_file
48         self._cfg_yaml = None
49
50         self._specification = {"environment": dict(),
51                                "configuration": dict(),
52                                "static": dict(),
53                                "input": dict(),
54                                "output": dict(),
55                                "tables": list(),
56                                "plots": list(),
57                                "files": list(),
58                                "cpta": dict()}
59
60     @property
61     def specification(self):
62         """Getter - specification.
63
64         :returns: Specification.
65         :rtype: dict
66         """
67         return self._specification
68
69     @property
70     def environment(self):
71         """Getter - environment.
72
73         :returns: Environment specification.
74         :rtype: dict
75         """
76         return self._specification["environment"]
77
78     @property
79     def configuration(self):
80         """Getter - configuration.
81
82         :returns: Configuration of PAL.
83         :rtype: dict
84         """
85         return self._specification["configuration"]
86
87     @property
88     def static(self):
89         """Getter - static content.
90
91         :returns: Static content specification.
92         :rtype: dict
93         """
94         return self._specification["static"]
95
96     @property
97     def mapping(self):
98         """Getter - Mapping.
99
100         :returns: Mapping of the old names of test cases to the new (actual)
101             one.
102         :rtype: dict
103         """
104         return self._specification["configuration"]["mapping"]
105
106     @property
107     def ignore(self):
108         """Getter - Ignore list.
109
110         :returns: List of ignored test cases.
111         :rtype: list
112         """
113         return self._specification["configuration"]["ignore"]
114
115     @property
116     def alerting(self):
117         """Getter - Alerting.
118
119         :returns: Specification of alerts.
120         :rtype: dict
121         """
122         return self._specification["configuration"]["alerting"]
123
124     @property
125     def input(self):
126         """Getter - specification - inputs.
127         - jobs and builds.
128
129         :returns: Inputs.
130         :rtype: dict
131         """
132         return self._specification["input"]
133
134     @property
135     def builds(self):
136         """Getter - builds defined in specification.
137
138         :returns: Builds defined in the specification.
139         :rtype: dict
140         """
141         return self.input["builds"]
142
143     @property
144     def output(self):
145         """Getter - specification - output formats and versions to be generated.
146         - formats: html, pdf
147         - versions: full, ...
148
149         :returns: Outputs to be generated.
150         :rtype: dict
151         """
152         return self._specification["output"]
153
154     @property
155     def tables(self):
156         """Getter - tables to be generated.
157
158         :returns: List of specifications of tables to be generated.
159         :rtype: list
160         """
161         return self._specification["tables"]
162
163     @property
164     def plots(self):
165         """Getter - plots to be generated.
166
167         :returns: List of specifications of plots to be generated.
168         :rtype: list
169         """
170         return self._specification["plots"]
171
172     @property
173     def files(self):
174         """Getter - files to be generated.
175
176         :returns: List of specifications of files to be generated.
177         :rtype: list
178         """
179         return self._specification["files"]
180
181     @property
182     def cpta(self):
183         """Getter - Continuous Performance Trending and Analysis to be
184         generated.
185
186         :returns: List of specifications of Continuous Performance Trending and
187         Analysis to be generated.
188         :rtype: list
189         """
190         return self._specification["cpta"]
191
192     def set_input_state(self, job, build_nr, state):
193         """Set the state of input
194
195         :param job:
196         :param build_nr:
197         :param state:
198         :return:
199         """
200
201         try:
202             for build in self._specification["input"]["builds"][job]:
203                 if build["build"] == build_nr:
204                     build["status"] = state
205                     break
206             else:
207                 raise PresentationError("Build '{}' is not defined for job '{}'"
208                                         " in specification file.".
209                                         format(build_nr, job))
210         except KeyError:
211             raise PresentationError("Job '{}' and build '{}' is not defined in "
212                                     "specification file.".format(job, build_nr))
213
214     def set_input_file_name(self, job, build_nr, file_name):
215         """Set the state of input
216
217         :param job:
218         :param build_nr:
219         :param file_name:
220         :return:
221         """
222
223         try:
224             for build in self._specification["input"]["builds"][job]:
225                 if build["build"] == build_nr:
226                     build["file-name"] = file_name
227                     break
228             else:
229                 raise PresentationError("Build '{}' is not defined for job '{}'"
230                                         " in specification file.".
231                                         format(build_nr, job))
232         except KeyError:
233             raise PresentationError("Job '{}' and build '{}' is not defined in "
234                                     "specification file.".format(job, build_nr))
235
236     def _get_build_number(self, job, build_type):
237         """Get the number of the job defined by its name:
238          - lastSuccessfulBuild
239          - lastCompletedBuild
240
241         :param job: Job name.
242         :param build_type: Build type:
243          - lastSuccessfulBuild
244          - lastCompletedBuild
245         :type job" str
246         :raises PresentationError: If it is not possible to get the build
247         number.
248         :returns: The build number.
249         :rtype: int
250         """
251
252         # defined as a range <start, end>
253         if build_type == "lastSuccessfulBuild":
254             # defined as a range <start, lastSuccessfulBuild>
255             ret_code, build_nr, _ = get_last_successful_build_number(
256                 self.environment["urls"]["URL[JENKINS,CSIT]"], job)
257         elif build_type == "lastCompletedBuild":
258             # defined as a range <start, lastCompletedBuild>
259             ret_code, build_nr, _ = get_last_completed_build_number(
260                 self.environment["urls"]["URL[JENKINS,CSIT]"], job)
261         else:
262             raise PresentationError("Not supported build type: '{0}'".
263                                     format(build_type))
264         if ret_code != 0:
265             raise PresentationError("Not possible to get the number of the "
266                                     "build number.")
267         try:
268             build_nr = int(build_nr)
269             return build_nr
270         except ValueError as err:
271             raise PresentationError("Not possible to get the number of the "
272                                     "build number.\nReason: {0}".format(err))
273
274     def _get_type_index(self, item_type):
275         """Get index of item type (environment, input, output, ...) in
276         specification YAML file.
277
278         :param item_type: Item type: Top level items in specification YAML file,
279         e.g.: environment, input, output.
280         :type item_type: str
281         :returns: Index of the given item type.
282         :rtype: int
283         """
284
285         index = 0
286         for item in self._cfg_yaml:
287             if item["type"] == item_type:
288                 return index
289             index += 1
290         return None
291
292     def _find_tag(self, text):
293         """Find the first tag in the given text. The tag is enclosed by the
294         TAG_OPENER and TAG_CLOSER.
295
296         :param text: Text to be searched.
297         :type text: str
298         :returns: The tag, or None if not found.
299         :rtype: str
300         """
301         try:
302             start = text.index(self.TAG_OPENER)
303             end = text.index(self.TAG_CLOSER, start + 1) + 1
304             return text[start:end]
305         except ValueError:
306             return None
307
308     def _replace_tags(self, data, src_data=None):
309         """Replace tag(s) in the data by their values.
310
311         :param data: The data where the tags will be replaced by their values.
312         :param src_data: Data where the tags are defined. It is dictionary where
313         the key is the tag and the value is the tag value. If not given, 'data'
314         is used instead.
315         :type data: str or dict
316         :type src_data: dict
317         :returns: Data with the tags replaced.
318         :rtype: str or dict
319         :raises: PresentationError if it is not possible to replace the tag or
320         the data is not the supported data type (str, dict).
321         """
322
323         if src_data is None:
324             src_data = data
325
326         if isinstance(data, str):
327             tag = self._find_tag(data)
328             if tag is not None:
329                 data = data.replace(tag, src_data[tag[1:-1]])
330
331         elif isinstance(data, dict):
332             counter = 0
333             for key, value in data.items():
334                 tag = self._find_tag(value)
335                 if tag is not None:
336                     try:
337                         data[key] = value.replace(tag, src_data[tag[1:-1]])
338                         counter += 1
339                     except KeyError:
340                         raise PresentationError("Not possible to replace the "
341                                                 "tag '{}'".format(tag))
342             if counter:
343                 self._replace_tags(data, src_data)
344         else:
345             raise PresentationError("Replace tags: Not supported data type.")
346
347         return data
348
349     def _parse_env(self):
350         """Parse environment specification in the specification YAML file.
351         """
352
353         logging.info("Parsing specification file: environment ...")
354
355         idx = self._get_type_index("environment")
356         if idx is None:
357             return None
358
359         try:
360             self._specification["environment"]["configuration"] = \
361                 self._cfg_yaml[idx]["configuration"]
362         except KeyError:
363             self._specification["environment"]["configuration"] = None
364
365         try:
366             self._specification["environment"]["paths"] = \
367                 self._replace_tags(self._cfg_yaml[idx]["paths"])
368         except KeyError:
369             self._specification["environment"]["paths"] = None
370
371         try:
372             self._specification["environment"]["urls"] = \
373                 self._cfg_yaml[idx]["urls"]
374         except KeyError:
375             self._specification["environment"]["urls"] = None
376
377         try:
378             self._specification["environment"]["make-dirs"] = \
379                 self._cfg_yaml[idx]["make-dirs"]
380         except KeyError:
381             self._specification["environment"]["make-dirs"] = None
382
383         try:
384             self._specification["environment"]["remove-dirs"] = \
385                 self._cfg_yaml[idx]["remove-dirs"]
386         except KeyError:
387             self._specification["environment"]["remove-dirs"] = None
388
389         try:
390             self._specification["environment"]["build-dirs"] = \
391                 self._cfg_yaml[idx]["build-dirs"]
392         except KeyError:
393             self._specification["environment"]["build-dirs"] = None
394
395         try:
396             self._specification["environment"]["testbeds"] = \
397                 self._cfg_yaml[idx]["testbeds"]
398         except KeyError:
399             self._specification["environment"]["testbeds"] = None
400
401         logging.info("Done.")
402
403     def _parse_configuration(self):
404         """Parse configuration of PAL in the specification YAML file.
405         """
406
407         logging.info("Parsing specification file: configuration ...")
408
409         idx = self._get_type_index("configuration")
410         if idx is None:
411             logging.warning("No configuration information in the specification "
412                             "file.")
413             return None
414
415         try:
416             self._specification["configuration"] = self._cfg_yaml[idx]
417
418         except KeyError:
419             raise PresentationError("No configuration defined.")
420
421         # Data sets: Replace ranges by lists
422         for set_name, data_set in self.configuration["data-sets"].items():
423             if not isinstance(data_set, dict):
424                 continue
425             for job, builds in data_set.items():
426                 if builds:
427                     if isinstance(builds, dict):
428                         build_end = builds.get("end", None)
429                         try:
430                             build_end = int(build_end)
431                         except ValueError:
432                             # defined as a range <start, build_type>
433                             build_end = self._get_build_number(job, build_end)
434                         builds = [x for x in range(builds["start"], build_end+1)
435                                   if x not in builds.get("skip", list())]
436                         self.configuration["data-sets"][set_name][job] = builds
437
438         # Data sets: add sub-sets to sets (only one level):
439         for set_name, data_set in self.configuration["data-sets"].items():
440             if isinstance(data_set, list):
441                 new_set = dict()
442                 for item in data_set:
443                     try:
444                         for key, val in self.configuration["data-sets"][item].\
445                                 items():
446                             new_set[key] = val
447                     except KeyError:
448                         raise PresentationError(
449                             "Data set {0} is not defined in "
450                             "the configuration section.".format(item))
451                 self.configuration["data-sets"][set_name] = new_set
452
453         # Mapping table:
454         mapping = None
455         mapping_file_name = self._specification["configuration"].\
456             get("mapping-file", None)
457         if mapping_file_name:
458             logging.debug("Mapping file: '{0}'".format(mapping_file_name))
459             try:
460                 with open(mapping_file_name, 'r') as mfile:
461                     mapping = load(mfile)
462                 logging.debug("Loaded mapping table:\n{0}".format(mapping))
463             except (YAMLError, IOError) as err:
464                 raise PresentationError(
465                     msg="An error occurred while parsing the mapping file "
466                         "'{0}'.".format(mapping_file_name),
467                     details=repr(err))
468         # Make sure everything is lowercase
469         if mapping:
470             self._specification["configuration"]["mapping"] = \
471                 {key.lower(): val.lower() for key, val in mapping.iteritems()}
472         else:
473             self._specification["configuration"]["mapping"] = dict()
474
475         # Ignore list:
476         ignore = None
477         ignore_list_name = self._specification["configuration"].\
478             get("ignore-list", None)
479         if ignore_list_name:
480             logging.debug("Ignore list file: '{0}'".format(ignore_list_name))
481             try:
482                 with open(ignore_list_name, 'r') as ifile:
483                     ignore = load(ifile)
484                 logging.debug("Loaded ignore list:\n{0}".format(ignore))
485             except (YAMLError, IOError) as err:
486                 raise PresentationError(
487                     msg="An error occurred while parsing the ignore list file "
488                         "'{0}'.".format(ignore_list_name),
489                     details=repr(err))
490         # Make sure everything is lowercase
491         if ignore:
492             self._specification["configuration"]["ignore"] = \
493                 [item.lower() for item in ignore]
494         else:
495             self._specification["configuration"]["ignore"] = list()
496
497         logging.info("Done.")
498
499     def _parse_input(self):
500         """Parse input specification in the specification YAML file.
501
502         :raises: PresentationError if there are no data to process.
503         """
504
505         logging.info("Parsing specification file: input ...")
506
507         idx = self._get_type_index("input")
508         if idx is None:
509             raise PresentationError("No data to process.")
510
511         try:
512             for key, value in self._cfg_yaml[idx]["general"].items():
513                 self._specification["input"][key] = value
514             self._specification["input"]["builds"] = dict()
515
516             for job, builds in self._cfg_yaml[idx]["builds"].items():
517                 if builds:
518                     if isinstance(builds, dict):
519                         build_end = builds.get("end", None)
520                         try:
521                             build_end = int(build_end)
522                         except ValueError:
523                             # defined as a range <start, build_type>
524                             build_end = self._get_build_number(job, build_end)
525                         builds = [x for x in range(builds["start"], build_end+1)
526                                   if x not in builds.get("skip", list())]
527                     self._specification["input"]["builds"][job] = list()
528                     for build in builds:
529                         self._specification["input"]["builds"][job]. \
530                             append({"build": build, "status": None})
531
532                 else:
533                     logging.warning("No build is defined for the job '{}'. "
534                                     "Trying to continue without it.".
535                                     format(job))
536         except KeyError:
537             raise PresentationError("No data to process.")
538
539         logging.info("Done.")
540
541     def _parse_output(self):
542         """Parse output specification in the specification YAML file.
543
544         :raises: PresentationError if there is no output defined.
545         """
546
547         logging.info("Parsing specification file: output ...")
548
549         idx = self._get_type_index("output")
550         if idx is None:
551             raise PresentationError("No output defined.")
552
553         try:
554             self._specification["output"] = self._cfg_yaml[idx]
555         except (KeyError, IndexError):
556             raise PresentationError("No output defined.")
557
558         logging.info("Done.")
559
560     def _parse_static(self):
561         """Parse specification of the static content in the specification YAML
562         file.
563         """
564
565         logging.info("Parsing specification file: static content ...")
566
567         idx = self._get_type_index("static")
568         if idx is None:
569             logging.warning("No static content specified.")
570
571         for key, value in self._cfg_yaml[idx].items():
572             if isinstance(value, str):
573                 try:
574                     self._cfg_yaml[idx][key] = self._replace_tags(
575                         value, self._specification["environment"]["paths"])
576                 except KeyError:
577                     pass
578
579         self._specification["static"] = self._cfg_yaml[idx]
580
581         logging.info("Done.")
582
583     def _parse_elements(self):
584         """Parse elements (tables, plots) specification in the specification
585         YAML file.
586         """
587
588         logging.info("Parsing specification file: elements ...")
589
590         count = 1
591         for element in self._cfg_yaml:
592             try:
593                 element["output-file"] = self._replace_tags(
594                     element["output-file"],
595                     self._specification["environment"]["paths"])
596             except KeyError:
597                 pass
598
599             try:
600                 element["input-file"] = self._replace_tags(
601                     element["input-file"],
602                     self._specification["environment"]["paths"])
603             except KeyError:
604                 pass
605
606             # add data sets to the elements:
607             if isinstance(element.get("data", None), str):
608                 data_set = element["data"]
609                 try:
610                     element["data"] = self.configuration["data-sets"][data_set]
611                 except KeyError:
612                     raise PresentationError("Data set {0} is not defined in "
613                                             "the configuration section.".
614                                             format(data_set))
615
616             if element["type"] == "table":
617                 logging.info("  {:3d} Processing a table ...".format(count))
618                 try:
619                     element["template"] = self._replace_tags(
620                         element["template"],
621                         self._specification["environment"]["paths"])
622                 except KeyError:
623                     pass
624
625                 # add data sets
626                 try:
627                     for item in ("reference", "compare"):
628                         if element.get(item, None):
629                             data_set = element[item].get("data", None)
630                             if isinstance(data_set, str):
631                                 element[item]["data"] = \
632                                     self.configuration["data-sets"][data_set]
633
634                     if element.get("history", None):
635                         for i in range(len(element["history"])):
636                             data_set = element["history"][i].get("data", None)
637                             if isinstance(data_set, str):
638                                 element["history"][i]["data"] = \
639                                     self.configuration["data-sets"][data_set]
640
641                 except KeyError:
642                     raise PresentationError("Wrong data set used in {0}.".
643                                             format(element.get("title", "")))
644
645                 self._specification["tables"].append(element)
646                 count += 1
647
648             elif element["type"] == "plot":
649                 logging.info("  {:3d} Processing a plot ...".format(count))
650
651                 # Add layout to the plots:
652                 layout = element["layout"].get("layout", None)
653                 if layout is not None:
654                     element["layout"].pop("layout")
655                     try:
656                         for key, val in (self.configuration["plot-layouts"]
657                                          [layout].items()):
658                             element["layout"][key] = val
659                     except KeyError:
660                         raise PresentationError("Layout {0} is not defined in "
661                                                 "the configuration section.".
662                                                 format(layout))
663                 self._specification["plots"].append(element)
664                 count += 1
665
666             elif element["type"] == "file":
667                 logging.info("  {:3d} Processing a file ...".format(count))
668                 try:
669                     element["dir-tables"] = self._replace_tags(
670                         element["dir-tables"],
671                         self._specification["environment"]["paths"])
672                 except KeyError:
673                     pass
674                 self._specification["files"].append(element)
675                 count += 1
676
677             elif element["type"] == "cpta":
678                 logging.info("  {:3d} Processing Continuous Performance "
679                              "Trending and Analysis ...".format(count))
680
681                 for plot in element["plots"]:
682                     # Add layout to the plots:
683                     layout = plot.get("layout", None)
684                     if layout is not None:
685                         try:
686                             plot["layout"] = \
687                                 self.configuration["plot-layouts"][layout]
688                         except KeyError:
689                             raise PresentationError(
690                                 "Layout {0} is not defined in the "
691                                 "configuration section.".format(layout))
692                     # Add data sets:
693                     if isinstance(plot.get("data", None), str):
694                         data_set = plot["data"]
695                         try:
696                             plot["data"] = \
697                                 self.configuration["data-sets"][data_set]
698                         except KeyError:
699                             raise PresentationError(
700                                 "Data set {0} is not defined in "
701                                 "the configuration section.".
702                                 format(data_set))
703                 self._specification["cpta"] = element
704                 count += 1
705
706         logging.info("Done.")
707
708     def read_specification(self):
709         """Parse specification in the specification YAML file.
710
711         :raises: PresentationError if an error occurred while parsing the
712         specification file.
713         """
714         try:
715             self._cfg_yaml = load(self._cfg_file)
716         except YAMLError as err:
717             raise PresentationError(msg="An error occurred while parsing the "
718                                         "specification file.",
719                                     details=str(err))
720
721         self._parse_env()
722         self._parse_configuration()
723         self._parse_input()
724         self._parse_output()
725         self._parse_static()
726         self._parse_elements()
727
728         logging.debug("Specification: \n{}".
729                       format(pformat(self._specification)))