report.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. #
  2. # Copyright (c) 2017, Intel Corporation.
  3. #
  4. # This program is free software; you can redistribute it and/or modify it
  5. # under the terms and conditions of the GNU General Public License,
  6. # version 2, as published by the Free Software Foundation.
  7. #
  8. # This program is distributed in the hope it will be useful, but WITHOUT
  9. # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
  10. # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
  11. # more details.
  12. #
  13. """Handling of build perf test reports"""
  14. from collections import OrderedDict, Mapping, namedtuple
  15. from datetime import datetime, timezone
  16. from numbers import Number
  17. from statistics import mean, stdev, variance
  18. AggregateTestData = namedtuple('AggregateTestData', ['metadata', 'results'])
  19. def isofmt_to_timestamp(string):
  20. """Convert timestamp string in ISO 8601 format into unix timestamp"""
  21. if '.' in string:
  22. dt = datetime.strptime(string, '%Y-%m-%dT%H:%M:%S.%f')
  23. else:
  24. dt = datetime.strptime(string, '%Y-%m-%dT%H:%M:%S')
  25. return dt.replace(tzinfo=timezone.utc).timestamp()
  26. def metadata_xml_to_json(elem):
  27. """Convert metadata xml into JSON format"""
  28. assert elem.tag == 'metadata', "Invalid metadata file format"
  29. def _xml_to_json(elem):
  30. """Convert xml element to JSON object"""
  31. out = OrderedDict()
  32. for child in elem.getchildren():
  33. key = child.attrib.get('name', child.tag)
  34. if len(child):
  35. out[key] = _xml_to_json(child)
  36. else:
  37. out[key] = child.text
  38. return out
  39. return _xml_to_json(elem)
  40. def results_xml_to_json(elem):
  41. """Convert results xml into JSON format"""
  42. rusage_fields = ('ru_utime', 'ru_stime', 'ru_maxrss', 'ru_minflt',
  43. 'ru_majflt', 'ru_inblock', 'ru_oublock', 'ru_nvcsw',
  44. 'ru_nivcsw')
  45. iostat_fields = ('rchar', 'wchar', 'syscr', 'syscw', 'read_bytes',
  46. 'write_bytes', 'cancelled_write_bytes')
  47. def _read_measurement(elem):
  48. """Convert measurement to JSON"""
  49. data = OrderedDict()
  50. data['type'] = elem.tag
  51. data['name'] = elem.attrib['name']
  52. data['legend'] = elem.attrib['legend']
  53. values = OrderedDict()
  54. # SYSRES measurement
  55. if elem.tag == 'sysres':
  56. for subel in elem:
  57. if subel.tag == 'time':
  58. values['start_time'] = isofmt_to_timestamp(subel.attrib['timestamp'])
  59. values['elapsed_time'] = float(subel.text)
  60. elif subel.tag == 'rusage':
  61. rusage = OrderedDict()
  62. for field in rusage_fields:
  63. if 'time' in field:
  64. rusage[field] = float(subel.attrib[field])
  65. else:
  66. rusage[field] = int(subel.attrib[field])
  67. values['rusage'] = rusage
  68. elif subel.tag == 'iostat':
  69. values['iostat'] = OrderedDict([(f, int(subel.attrib[f]))
  70. for f in iostat_fields])
  71. elif subel.tag == 'buildstats_file':
  72. values['buildstats_file'] = subel.text
  73. else:
  74. raise TypeError("Unknown sysres value element '{}'".format(subel.tag))
  75. # DISKUSAGE measurement
  76. elif elem.tag == 'diskusage':
  77. values['size'] = int(elem.find('size').text)
  78. else:
  79. raise Exception("Unknown measurement tag '{}'".format(elem.tag))
  80. data['values'] = values
  81. return data
  82. def _read_testcase(elem):
  83. """Convert testcase into JSON"""
  84. assert elem.tag == 'testcase', "Expecting 'testcase' element instead of {}".format(elem.tag)
  85. data = OrderedDict()
  86. data['name'] = elem.attrib['name']
  87. data['description'] = elem.attrib['description']
  88. data['status'] = 'SUCCESS'
  89. data['start_time'] = isofmt_to_timestamp(elem.attrib['timestamp'])
  90. data['elapsed_time'] = float(elem.attrib['time'])
  91. measurements = OrderedDict()
  92. for subel in elem.getchildren():
  93. if subel.tag == 'error' or subel.tag == 'failure':
  94. data['status'] = subel.tag.upper()
  95. data['message'] = subel.attrib['message']
  96. data['err_type'] = subel.attrib['type']
  97. data['err_output'] = subel.text
  98. elif subel.tag == 'skipped':
  99. data['status'] = 'SKIPPED'
  100. data['message'] = subel.text
  101. else:
  102. measurements[subel.attrib['name']] = _read_measurement(subel)
  103. data['measurements'] = measurements
  104. return data
  105. def _read_testsuite(elem):
  106. """Convert suite to JSON"""
  107. assert elem.tag == 'testsuite', \
  108. "Expecting 'testsuite' element instead of {}".format(elem.tag)
  109. data = OrderedDict()
  110. if 'hostname' in elem.attrib:
  111. data['tester_host'] = elem.attrib['hostname']
  112. data['start_time'] = isofmt_to_timestamp(elem.attrib['timestamp'])
  113. data['elapsed_time'] = float(elem.attrib['time'])
  114. tests = OrderedDict()
  115. for case in elem.getchildren():
  116. tests[case.attrib['name']] = _read_testcase(case)
  117. data['tests'] = tests
  118. return data
  119. # Main function
  120. assert elem.tag == 'testsuites', "Invalid test report format"
  121. assert len(elem) == 1, "Too many testsuites"
  122. return _read_testsuite(elem.getchildren()[0])
  123. def aggregate_metadata(metadata):
  124. """Aggregate metadata into one, basically a sanity check"""
  125. mutable_keys = ('pretty_name', 'version_id')
  126. def aggregate_obj(aggregate, obj, assert_str=True):
  127. """Aggregate objects together"""
  128. assert type(aggregate) is type(obj), \
  129. "Type mismatch: {} != {}".format(type(aggregate), type(obj))
  130. if isinstance(obj, Mapping):
  131. assert set(aggregate.keys()) == set(obj.keys())
  132. for key, val in obj.items():
  133. aggregate_obj(aggregate[key], val, key not in mutable_keys)
  134. elif isinstance(obj, list):
  135. assert len(aggregate) == len(obj)
  136. for i, val in enumerate(obj):
  137. aggregate_obj(aggregate[i], val)
  138. elif not isinstance(obj, str) or (isinstance(obj, str) and assert_str):
  139. assert aggregate == obj, "Data mismatch {} != {}".format(aggregate, obj)
  140. if not metadata:
  141. return {}
  142. # Do the aggregation
  143. aggregate = metadata[0].copy()
  144. for testrun in metadata[1:]:
  145. aggregate_obj(aggregate, testrun)
  146. aggregate['testrun_count'] = len(metadata)
  147. return aggregate
  148. def aggregate_data(data):
  149. """Aggregate multiple test results JSON structures into one"""
  150. mutable_keys = ('status', 'message', 'err_type', 'err_output')
  151. class SampleList(list):
  152. """Container for numerical samples"""
  153. pass
  154. def new_aggregate_obj(obj):
  155. """Create new object for aggregate"""
  156. if isinstance(obj, Number):
  157. new_obj = SampleList()
  158. new_obj.append(obj)
  159. elif isinstance(obj, str):
  160. new_obj = obj
  161. else:
  162. # Lists and and dicts are kept as is
  163. new_obj = obj.__class__()
  164. aggregate_obj(new_obj, obj)
  165. return new_obj
  166. def aggregate_obj(aggregate, obj, assert_str=True):
  167. """Recursive "aggregation" of JSON objects"""
  168. if isinstance(obj, Number):
  169. assert isinstance(aggregate, SampleList)
  170. aggregate.append(obj)
  171. return
  172. assert type(aggregate) == type(obj), \
  173. "Type mismatch: {} != {}".format(type(aggregate), type(obj))
  174. if isinstance(obj, Mapping):
  175. for key, val in obj.items():
  176. if not key in aggregate:
  177. aggregate[key] = new_aggregate_obj(val)
  178. else:
  179. aggregate_obj(aggregate[key], val, key not in mutable_keys)
  180. elif isinstance(obj, list):
  181. for i, val in enumerate(obj):
  182. if i >= len(aggregate):
  183. aggregate[key] = new_aggregate_obj(val)
  184. else:
  185. aggregate_obj(aggregate[i], val)
  186. elif isinstance(obj, str):
  187. # Sanity check for data
  188. if assert_str:
  189. assert aggregate == obj, "Data mismatch {} != {}".format(aggregate, obj)
  190. else:
  191. raise Exception("BUG: unable to aggregate '{}' ({})".format(type(obj), str(obj)))
  192. if not data:
  193. return {}
  194. # Do the aggregation
  195. aggregate = data[0].__class__()
  196. for testrun in data:
  197. aggregate_obj(aggregate, testrun)
  198. return aggregate
  199. class MeasurementVal(float):
  200. """Base class representing measurement values"""
  201. gv_data_type = 'number'
  202. def gv_value(self):
  203. """Value formatting for visualization"""
  204. if self != self:
  205. return "null"
  206. else:
  207. return self
  208. class TimeVal(MeasurementVal):
  209. """Class representing time values"""
  210. quantity = 'time'
  211. gv_title = 'elapsed time'
  212. gv_data_type = 'timeofday'
  213. def hms(self):
  214. """Split time into hours, minutes and seconeds"""
  215. hhh = int(abs(self) / 3600)
  216. mmm = int((abs(self) % 3600) / 60)
  217. sss = abs(self) % 60
  218. return hhh, mmm, sss
  219. def __str__(self):
  220. if self != self:
  221. return "nan"
  222. hh, mm, ss = self.hms()
  223. sign = '-' if self < 0 else ''
  224. if hh > 0:
  225. return '{}{:d}:{:02d}:{:02.0f}'.format(sign, hh, mm, ss)
  226. elif mm > 0:
  227. return '{}{:d}:{:04.1f}'.format(sign, mm, ss)
  228. elif ss > 1:
  229. return '{}{:.1f} s'.format(sign, ss)
  230. else:
  231. return '{}{:.2f} s'.format(sign, ss)
  232. def gv_value(self):
  233. """Value formatting for visualization"""
  234. if self != self:
  235. return "null"
  236. hh, mm, ss = self.hms()
  237. return [hh, mm, int(ss), int(ss*1000) % 1000]
  238. class SizeVal(MeasurementVal):
  239. """Class representing time values"""
  240. quantity = 'size'
  241. gv_title = 'size in MiB'
  242. gv_data_type = 'number'
  243. def __str__(self):
  244. if self != self:
  245. return "nan"
  246. if abs(self) < 1024:
  247. return '{:.1f} kiB'.format(self)
  248. elif abs(self) < 1048576:
  249. return '{:.2f} MiB'.format(self / 1024)
  250. else:
  251. return '{:.2f} GiB'.format(self / 1048576)
  252. def gv_value(self):
  253. """Value formatting for visualization"""
  254. if self != self:
  255. return "null"
  256. return self / 1024
  257. def measurement_stats(meas, prefix=''):
  258. """Get statistics of a measurement"""
  259. if not meas:
  260. return {prefix + 'sample_cnt': 0,
  261. prefix + 'mean': MeasurementVal('nan'),
  262. prefix + 'stdev': MeasurementVal('nan'),
  263. prefix + 'variance': MeasurementVal('nan'),
  264. prefix + 'min': MeasurementVal('nan'),
  265. prefix + 'max': MeasurementVal('nan'),
  266. prefix + 'minus': MeasurementVal('nan'),
  267. prefix + 'plus': MeasurementVal('nan')}
  268. stats = {'name': meas['name']}
  269. if meas['type'] == 'sysres':
  270. val_cls = TimeVal
  271. values = meas['values']['elapsed_time']
  272. elif meas['type'] == 'diskusage':
  273. val_cls = SizeVal
  274. values = meas['values']['size']
  275. else:
  276. raise Exception("Unknown measurement type '{}'".format(meas['type']))
  277. stats['val_cls'] = val_cls
  278. stats['quantity'] = val_cls.quantity
  279. stats[prefix + 'sample_cnt'] = len(values)
  280. mean_val = val_cls(mean(values))
  281. min_val = val_cls(min(values))
  282. max_val = val_cls(max(values))
  283. stats[prefix + 'mean'] = mean_val
  284. if len(values) > 1:
  285. stats[prefix + 'stdev'] = val_cls(stdev(values))
  286. stats[prefix + 'variance'] = val_cls(variance(values))
  287. else:
  288. stats[prefix + 'stdev'] = float('nan')
  289. stats[prefix + 'variance'] = float('nan')
  290. stats[prefix + 'min'] = min_val
  291. stats[prefix + 'max'] = max_val
  292. stats[prefix + 'minus'] = val_cls(mean_val - min_val)
  293. stats[prefix + 'plus'] = val_cls(max_val - mean_val)
  294. return stats