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