Line length: Fix recent merges
[csit.git] / resources / tools / presentation / pal_utils.py
1 # Copyright (c) 2021 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 """General purpose utilities.
15 """
16
17 import subprocess
18 import math
19 import logging
20 import csv
21
22 from os import walk, makedirs, environ
23 from os.path import join, isdir
24 from shutil import move, Error
25 from datetime import datetime
26
27 import numpy as np
28 import prettytable
29
30 from pandas import Series
31
32 from resources.libraries.python import jumpavg
33
34 from pal_errors import PresentationError
35
36
37 def mean(items):
38     """Calculate mean value from the items.
39
40     :param items: Mean value is calculated from these items.
41     :type items: list
42     :returns: MEan value.
43     :rtype: float
44     """
45
46     return float(sum(items)) / len(items)
47
48
49 def stdev(items):
50     """Calculate stdev from the items.
51
52     :param items: Stdev is calculated from these items.
53     :type items: list
54     :returns: Stdev.
55     :rtype: float
56     """
57     return Series.std(Series(items))
58
59
60 def relative_change(nr1, nr2):
61     """Compute relative change of two values.
62
63     :param nr1: The first number.
64     :param nr2: The second number.
65     :type nr1: float
66     :type nr2: float
67     :returns: Relative change of nr1.
68     :rtype: float
69     """
70
71     return float(((nr2 - nr1) / nr1) * 100)
72
73
74 def relative_change_stdev(mean1, mean2, std1, std2):
75     """Compute relative standard deviation of change of two values.
76
77     The "1" values are the base for comparison.
78     Results are returned as percentage (and percentual points for stdev).
79     Linearized theory is used, so results are wrong for relatively large stdev.
80
81     :param mean1: Mean of the first number.
82     :param mean2: Mean of the second number.
83     :param std1: Standard deviation estimate of the first number.
84     :param std2: Standard deviation estimate of the second number.
85     :type mean1: float
86     :type mean2: float
87     :type std1: float
88     :type std2: float
89     :returns: Relative change and its stdev.
90     :rtype: float
91     """
92     mean1, mean2 = float(mean1), float(mean2)
93     quotient = mean2 / mean1
94     first = std1 / mean1
95     second = std2 / mean2
96     std = quotient * math.sqrt(first * first + second * second)
97     return (quotient - 1) * 100, std * 100
98
99
100 def get_files(path, extension=None, full_path=True):
101     """Generates the list of files to process.
102
103     :param path: Path to files.
104     :param extension: Extension of files to process. If it is the empty string,
105         all files will be processed.
106     :param full_path: If True, the files with full path are generated.
107     :type path: str
108     :type extension: str
109     :type full_path: bool
110     :returns: List of files to process.
111     :rtype: list
112     """
113
114     file_list = list()
115     for root, _, files in walk(path):
116         for filename in files:
117             if extension:
118                 if filename.endswith(extension):
119                     if full_path:
120                         file_list.append(join(root, filename))
121                     else:
122                         file_list.append(filename)
123             else:
124                 file_list.append(join(root, filename))
125
126     return file_list
127
128
129 def get_rst_title_char(level):
130     """Return character used for the given title level in rst files.
131
132     :param level: Level of the title.
133     :type: int
134     :returns: Character used for the given title level in rst files.
135     :rtype: str
136     """
137     chars = (u'=', u'-', u'`', u"'", u'.', u'~', u'*', u'+', u'^')
138     if level < len(chars):
139         return chars[level]
140     return chars[-1]
141
142
143 def execute_command(cmd):
144     """Execute the command in a subprocess and log the stdout and stderr.
145
146     :param cmd: Command to execute.
147     :type cmd: str
148     :returns: Return code of the executed command, stdout and stderr.
149     :rtype: tuple(int, str, str)
150     """
151
152     env = environ.copy()
153     proc = subprocess.Popen(
154         [cmd],
155         stdout=subprocess.PIPE,
156         stderr=subprocess.PIPE,
157         shell=True,
158         env=env)
159
160     stdout, stderr = proc.communicate()
161
162     if stdout:
163         logging.info(stdout.decode())
164     if stderr:
165         logging.info(stderr.decode())
166
167     if proc.returncode != 0:
168         logging.error(u"    Command execution failed.")
169     return proc.returncode, stdout.decode(), stderr.decode()
170
171
172 def get_last_successful_build_nr(jenkins_url, job_name):
173     """Get the number of the last successful build of the given job.
174
175     :param jenkins_url: Jenkins URL.
176     :param job_name: Job name.
177     :type jenkins_url: str
178     :type job_name: str
179     :returns: The build number as a string.
180     :rtype: str
181     """
182     return execute_command(
183         f"wget -qO- {jenkins_url}/{job_name}/lastSuccessfulBuild/buildNumber"
184     )
185
186
187 def get_last_completed_build_number(jenkins_url, job_name):
188     """Get the number of the last completed build of the given job.
189
190     :param jenkins_url: Jenkins URL.
191     :param job_name: Job name.
192     :type jenkins_url: str
193     :type job_name: str
194     :returns: The build number as a string.
195     :rtype: str
196     """
197     return execute_command(
198         f"wget -qO- {jenkins_url}/{job_name}/lastCompletedBuild/buildNumber"
199     )
200
201
202 def get_build_timestamp(jenkins_url, job_name, build_nr):
203     """Get the timestamp of the build of the given job.
204
205     :param jenkins_url: Jenkins URL.
206     :param job_name: Job name.
207     :param build_nr: Build number.
208     :type jenkins_url: str
209     :type job_name: str
210     :type build_nr: int
211     :returns: The timestamp.
212     :rtype: datetime.datetime
213     """
214     timestamp = execute_command(
215         f"wget -qO- {jenkins_url}/{job_name}/{build_nr}"
216     )
217     return datetime.fromtimestamp(timestamp/1000)
218
219
220 def archive_input_data(spec):
221     """Archive the report.
222
223     :param spec: Specification read from the specification file.
224     :type spec: Specification
225     :raises PresentationError: If it is not possible to archive the input data.
226     """
227
228     logging.info(u"    Archiving the input data files ...")
229
230     extension = spec.output[u"arch-file-format"]
231     data_files = list()
232     for ext in extension:
233         data_files.extend(get_files(
234             spec.environment[u"paths"][u"DIR[WORKING,DATA]"], extension=ext))
235     dst = spec.environment[u"paths"][u"DIR[STATIC,ARCH]"]
236     logging.info(f"      Destination: {dst}")
237
238     try:
239         if not isdir(dst):
240             makedirs(dst)
241
242         for data_file in data_files:
243             logging.info(f"      Moving the file: {data_file} ...")
244             move(data_file, dst)
245
246     except (Error, OSError) as err:
247         raise PresentationError(
248             u"Not possible to archive the input data.",
249             repr(err)
250         )
251
252     logging.info(u"    Done.")
253
254
255 def classify_anomalies(data):
256     """Process the data and return anomalies and trending values.
257
258     Gather data into groups with average as trend value.
259     Decorate values within groups to be normal,
260     the first value of changed average as a regression, or a progression.
261
262     :param data: Full data set with unavailable samples replaced by nan.
263     :type data: OrderedDict
264     :returns: Classification and trend values
265     :rtype: 3-tuple, list of strings, list of floats and list of floats
266     """
267     # Nan means something went wrong.
268     # Use 0.0 to cause that being reported as a severe regression.
269     bare_data = [0.0 if np.isnan(sample) else sample
270                  for sample in data.values()]
271     # TODO: Make BitCountingGroupList a subclass of list again?
272     group_list = jumpavg.classify(bare_data).group_list
273     group_list.reverse()  # Just to use .pop() for FIFO.
274     classification = []
275     avgs = []
276     stdevs = []
277     active_group = None
278     values_left = 0
279     avg = 0.0
280     stdv = 0.0
281     for sample in data.values():
282         if np.isnan(sample):
283             classification.append(u"outlier")
284             avgs.append(sample)
285             stdevs.append(sample)
286             continue
287         if values_left < 1 or active_group is None:
288             values_left = 0
289             while values_left < 1:  # Ignore empty groups (should not happen).
290                 active_group = group_list.pop()
291                 values_left = len(active_group.run_list)
292             avg = active_group.stats.avg
293             stdv = active_group.stats.stdev
294             classification.append(active_group.comment)
295             avgs.append(avg)
296             stdevs.append(stdv)
297             values_left -= 1
298             continue
299         classification.append(u"normal")
300         avgs.append(avg)
301         stdevs.append(stdv)
302         values_left -= 1
303     return classification, avgs, stdevs
304
305
306 def convert_csv_to_pretty_txt(csv_file_name, txt_file_name, delimiter=u","):
307     """Convert the given csv table to pretty text table.
308
309     :param csv_file_name: The path to the input csv file.
310     :param txt_file_name: The path to the output pretty text file.
311     :param delimiter: Delimiter for csv file.
312     :type csv_file_name: str
313     :type txt_file_name: str
314     :type delimiter: str
315     """
316
317     txt_table = None
318     with open(csv_file_name, u"rt", encoding='utf-8') as csv_file:
319         csv_content = csv.reader(csv_file, delimiter=delimiter, quotechar=u'"')
320         for row in csv_content:
321             if txt_table is None:
322                 txt_table = prettytable.PrettyTable(row)
323             else:
324                 txt_table.add_row(
325                     [str(itm.replace(u"\u00B1", u"+-")) for itm in row]
326                 )
327     if not txt_table:
328         return
329
330     txt_table.align = u"r"
331     for itm in (u"Test Case", u"Build", u"Version", u"VPP Version"):
332         txt_table.align[itm] = u"l"
333
334     if txt_file_name.endswith(u".txt"):
335         with open(txt_file_name, u"wt", encoding='utf-8') as txt_file:
336             txt_file.write(str(txt_table))
337     elif txt_file_name.endswith(u".rst"):
338         with open(txt_file_name, u"wt") as txt_file:
339             txt_file.write(
340                 u"\n"
341                 u".. |br| raw:: html\n\n    <br />\n\n\n"
342                 u".. |prein| raw:: html\n\n    <pre>\n\n\n"
343                 u".. |preout| raw:: html\n\n    </pre>\n\n"
344             )
345             txt_file.write(
346                 u"\n.. only:: html\n\n"
347                 u"    .. csv-table::\n"
348                 u"        :header-rows: 1\n"
349                 u"        :widths: auto\n"
350                 u"        :align: center\n"
351                 f"        :file: {csv_file_name.split(u'/')[-1]}\n"
352             )