| # |
| # Copyright (c) 2017, Intel Corporation. |
| # |
| # This program is free software; you can redistribute it and/or modify it |
| # under the terms and conditions of the GNU General Public License, |
| # version 2, as published by the Free Software Foundation. |
| # |
| # This program is distributed in the hope it will be useful, but WITHOUT |
| # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or |
| # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for |
| # more details. |
| # |
| """Handling of build perf test reports""" |
| from collections import OrderedDict, Mapping, namedtuple |
| from datetime import datetime, timezone |
| from numbers import Number |
| from statistics import mean, stdev, variance |
| |
| |
| AggregateTestData = namedtuple('AggregateTestData', ['metadata', 'results']) |
| |
| |
| def isofmt_to_timestamp(string): |
| """Convert timestamp string in ISO 8601 format into unix timestamp""" |
| if '.' in string: |
| dt = datetime.strptime(string, '%Y-%m-%dT%H:%M:%S.%f') |
| else: |
| dt = datetime.strptime(string, '%Y-%m-%dT%H:%M:%S') |
| return dt.replace(tzinfo=timezone.utc).timestamp() |
| |
| |
| def metadata_xml_to_json(elem): |
| """Convert metadata xml into JSON format""" |
| assert elem.tag == 'metadata', "Invalid metadata file format" |
| |
| def _xml_to_json(elem): |
| """Convert xml element to JSON object""" |
| out = OrderedDict() |
| for child in elem.getchildren(): |
| key = child.attrib.get('name', child.tag) |
| if len(child): |
| out[key] = _xml_to_json(child) |
| else: |
| out[key] = child.text |
| return out |
| return _xml_to_json(elem) |
| |
| |
| def results_xml_to_json(elem): |
| """Convert results xml into JSON format""" |
| rusage_fields = ('ru_utime', 'ru_stime', 'ru_maxrss', 'ru_minflt', |
| 'ru_majflt', 'ru_inblock', 'ru_oublock', 'ru_nvcsw', |
| 'ru_nivcsw') |
| iostat_fields = ('rchar', 'wchar', 'syscr', 'syscw', 'read_bytes', |
| 'write_bytes', 'cancelled_write_bytes') |
| |
| def _read_measurement(elem): |
| """Convert measurement to JSON""" |
| data = OrderedDict() |
| data['type'] = elem.tag |
| data['name'] = elem.attrib['name'] |
| data['legend'] = elem.attrib['legend'] |
| values = OrderedDict() |
| |
| # SYSRES measurement |
| if elem.tag == 'sysres': |
| for subel in elem: |
| if subel.tag == 'time': |
| values['start_time'] = isofmt_to_timestamp(subel.attrib['timestamp']) |
| values['elapsed_time'] = float(subel.text) |
| elif subel.tag == 'rusage': |
| rusage = OrderedDict() |
| for field in rusage_fields: |
| if 'time' in field: |
| rusage[field] = float(subel.attrib[field]) |
| else: |
| rusage[field] = int(subel.attrib[field]) |
| values['rusage'] = rusage |
| elif subel.tag == 'iostat': |
| values['iostat'] = OrderedDict([(f, int(subel.attrib[f])) |
| for f in iostat_fields]) |
| elif subel.tag == 'buildstats_file': |
| values['buildstats_file'] = subel.text |
| else: |
| raise TypeError("Unknown sysres value element '{}'".format(subel.tag)) |
| # DISKUSAGE measurement |
| elif elem.tag == 'diskusage': |
| values['size'] = int(elem.find('size').text) |
| else: |
| raise Exception("Unknown measurement tag '{}'".format(elem.tag)) |
| data['values'] = values |
| return data |
| |
| def _read_testcase(elem): |
| """Convert testcase into JSON""" |
| assert elem.tag == 'testcase', "Expecting 'testcase' element instead of {}".format(elem.tag) |
| |
| data = OrderedDict() |
| data['name'] = elem.attrib['name'] |
| data['description'] = elem.attrib['description'] |
| data['status'] = 'SUCCESS' |
| data['start_time'] = isofmt_to_timestamp(elem.attrib['timestamp']) |
| data['elapsed_time'] = float(elem.attrib['time']) |
| measurements = OrderedDict() |
| |
| for subel in elem.getchildren(): |
| if subel.tag == 'error' or subel.tag == 'failure': |
| data['status'] = subel.tag.upper() |
| data['message'] = subel.attrib['message'] |
| data['err_type'] = subel.attrib['type'] |
| data['err_output'] = subel.text |
| elif subel.tag == 'skipped': |
| data['status'] = 'SKIPPED' |
| data['message'] = subel.text |
| else: |
| measurements[subel.attrib['name']] = _read_measurement(subel) |
| data['measurements'] = measurements |
| return data |
| |
| def _read_testsuite(elem): |
| """Convert suite to JSON""" |
| assert elem.tag == 'testsuite', \ |
| "Expecting 'testsuite' element instead of {}".format(elem.tag) |
| |
| data = OrderedDict() |
| if 'hostname' in elem.attrib: |
| data['tester_host'] = elem.attrib['hostname'] |
| data['start_time'] = isofmt_to_timestamp(elem.attrib['timestamp']) |
| data['elapsed_time'] = float(elem.attrib['time']) |
| tests = OrderedDict() |
| |
| for case in elem.getchildren(): |
| tests[case.attrib['name']] = _read_testcase(case) |
| data['tests'] = tests |
| return data |
| |
| # Main function |
| assert elem.tag == 'testsuites', "Invalid test report format" |
| assert len(elem) == 1, "Too many testsuites" |
| |
| return _read_testsuite(elem.getchildren()[0]) |
| |
| |
| def aggregate_metadata(metadata): |
| """Aggregate metadata into one, basically a sanity check""" |
| mutable_keys = ('pretty_name', 'version_id') |
| |
| def aggregate_obj(aggregate, obj, assert_str=True): |
| """Aggregate objects together""" |
| assert type(aggregate) is type(obj), \ |
| "Type mismatch: {} != {}".format(type(aggregate), type(obj)) |
| if isinstance(obj, Mapping): |
| assert set(aggregate.keys()) == set(obj.keys()) |
| for key, val in obj.items(): |
| aggregate_obj(aggregate[key], val, key not in mutable_keys) |
| elif isinstance(obj, list): |
| assert len(aggregate) == len(obj) |
| for i, val in enumerate(obj): |
| aggregate_obj(aggregate[i], val) |
| elif not isinstance(obj, str) or (isinstance(obj, str) and assert_str): |
| assert aggregate == obj, "Data mismatch {} != {}".format(aggregate, obj) |
| |
| if not metadata: |
| return {} |
| |
| # Do the aggregation |
| aggregate = metadata[0].copy() |
| for testrun in metadata[1:]: |
| aggregate_obj(aggregate, testrun) |
| aggregate['testrun_count'] = len(metadata) |
| return aggregate |
| |
| |
| def aggregate_data(data): |
| """Aggregate multiple test results JSON structures into one""" |
| |
| mutable_keys = ('status', 'message', 'err_type', 'err_output') |
| |
| class SampleList(list): |
| """Container for numerical samples""" |
| pass |
| |
| def new_aggregate_obj(obj): |
| """Create new object for aggregate""" |
| if isinstance(obj, Number): |
| new_obj = SampleList() |
| new_obj.append(obj) |
| elif isinstance(obj, str): |
| new_obj = obj |
| else: |
| # Lists and and dicts are kept as is |
| new_obj = obj.__class__() |
| aggregate_obj(new_obj, obj) |
| return new_obj |
| |
| def aggregate_obj(aggregate, obj, assert_str=True): |
| """Recursive "aggregation" of JSON objects""" |
| if isinstance(obj, Number): |
| assert isinstance(aggregate, SampleList) |
| aggregate.append(obj) |
| return |
| |
| assert type(aggregate) == type(obj), \ |
| "Type mismatch: {} != {}".format(type(aggregate), type(obj)) |
| if isinstance(obj, Mapping): |
| for key, val in obj.items(): |
| if not key in aggregate: |
| aggregate[key] = new_aggregate_obj(val) |
| else: |
| aggregate_obj(aggregate[key], val, key not in mutable_keys) |
| elif isinstance(obj, list): |
| for i, val in enumerate(obj): |
| if i >= len(aggregate): |
| aggregate[key] = new_aggregate_obj(val) |
| else: |
| aggregate_obj(aggregate[i], val) |
| elif isinstance(obj, str): |
| # Sanity check for data |
| if assert_str: |
| assert aggregate == obj, "Data mismatch {} != {}".format(aggregate, obj) |
| else: |
| raise Exception("BUG: unable to aggregate '{}' ({})".format(type(obj), str(obj))) |
| |
| if not data: |
| return {} |
| |
| # Do the aggregation |
| aggregate = data[0].__class__() |
| for testrun in data: |
| aggregate_obj(aggregate, testrun) |
| return aggregate |
| |
| |
| class MeasurementVal(float): |
| """Base class representing measurement values""" |
| gv_data_type = 'number' |
| |
| def gv_value(self): |
| """Value formatting for visualization""" |
| if self != self: |
| return "null" |
| else: |
| return self |
| |
| |
| class TimeVal(MeasurementVal): |
| """Class representing time values""" |
| quantity = 'time' |
| gv_title = 'elapsed time' |
| gv_data_type = 'timeofday' |
| |
| def hms(self): |
| """Split time into hours, minutes and seconeds""" |
| hhh = int(abs(self) / 3600) |
| mmm = int((abs(self) % 3600) / 60) |
| sss = abs(self) % 60 |
| return hhh, mmm, sss |
| |
| def __str__(self): |
| if self != self: |
| return "nan" |
| hh, mm, ss = self.hms() |
| sign = '-' if self < 0 else '' |
| if hh > 0: |
| return '{}{:d}:{:02d}:{:02.0f}'.format(sign, hh, mm, ss) |
| elif mm > 0: |
| return '{}{:d}:{:04.1f}'.format(sign, mm, ss) |
| elif ss > 1: |
| return '{}{:.1f} s'.format(sign, ss) |
| else: |
| return '{}{:.2f} s'.format(sign, ss) |
| |
| def gv_value(self): |
| """Value formatting for visualization""" |
| if self != self: |
| return "null" |
| hh, mm, ss = self.hms() |
| return [hh, mm, int(ss), int(ss*1000) % 1000] |
| |
| |
| class SizeVal(MeasurementVal): |
| """Class representing time values""" |
| quantity = 'size' |
| gv_title = 'size in MiB' |
| gv_data_type = 'number' |
| |
| def __str__(self): |
| if self != self: |
| return "nan" |
| if abs(self) < 1024: |
| return '{:.1f} kiB'.format(self) |
| elif abs(self) < 1048576: |
| return '{:.2f} MiB'.format(self / 1024) |
| else: |
| return '{:.2f} GiB'.format(self / 1048576) |
| |
| def gv_value(self): |
| """Value formatting for visualization""" |
| if self != self: |
| return "null" |
| return self / 1024 |
| |
| def measurement_stats(meas, prefix=''): |
| """Get statistics of a measurement""" |
| if not meas: |
| return {prefix + 'sample_cnt': 0, |
| prefix + 'mean': MeasurementVal('nan'), |
| prefix + 'stdev': MeasurementVal('nan'), |
| prefix + 'variance': MeasurementVal('nan'), |
| prefix + 'min': MeasurementVal('nan'), |
| prefix + 'max': MeasurementVal('nan'), |
| prefix + 'minus': MeasurementVal('nan'), |
| prefix + 'plus': MeasurementVal('nan')} |
| |
| stats = {'name': meas['name']} |
| if meas['type'] == 'sysres': |
| val_cls = TimeVal |
| values = meas['values']['elapsed_time'] |
| elif meas['type'] == 'diskusage': |
| val_cls = SizeVal |
| values = meas['values']['size'] |
| else: |
| raise Exception("Unknown measurement type '{}'".format(meas['type'])) |
| stats['val_cls'] = val_cls |
| stats['quantity'] = val_cls.quantity |
| stats[prefix + 'sample_cnt'] = len(values) |
| |
| mean_val = val_cls(mean(values)) |
| min_val = val_cls(min(values)) |
| max_val = val_cls(max(values)) |
| |
| stats[prefix + 'mean'] = mean_val |
| if len(values) > 1: |
| stats[prefix + 'stdev'] = val_cls(stdev(values)) |
| stats[prefix + 'variance'] = val_cls(variance(values)) |
| else: |
| stats[prefix + 'stdev'] = float('nan') |
| stats[prefix + 'variance'] = float('nan') |
| stats[prefix + 'min'] = min_val |
| stats[prefix + 'max'] = max_val |
| stats[prefix + 'minus'] = val_cls(mean_val - min_val) |
| stats[prefix + 'plus'] = val_cls(max_val - mean_val) |
| |
| return stats |
| |