# -*- coding: utf-8 -*- # Copyright 2013 The ChromiumOS Authors # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. """A module to handle the report format.""" import datetime import functools import itertools import json import os import re import time from column_chart import ColumnChart from cros_utils.tabulator import AmeanResult from cros_utils.tabulator import Cell from cros_utils.tabulator import CoeffVarFormat from cros_utils.tabulator import CoeffVarResult from cros_utils.tabulator import Column from cros_utils.tabulator import Format from cros_utils.tabulator import GmeanRatioResult from cros_utils.tabulator import IterationResult from cros_utils.tabulator import LiteralResult from cros_utils.tabulator import MaxResult from cros_utils.tabulator import MinResult from cros_utils.tabulator import PValueFormat from cros_utils.tabulator import PValueResult from cros_utils.tabulator import RatioFormat from cros_utils.tabulator import RawResult from cros_utils.tabulator import SamplesTableGenerator from cros_utils.tabulator import StdResult from cros_utils.tabulator import TableFormatter from cros_utils.tabulator import TableGenerator from cros_utils.tabulator import TablePrinter from results_organizer import OrganizeResults import results_report_templates as templates from update_telemetry_defaults import TelemetryDefaults def ParseChromeosImage(chromeos_image): """Parse the chromeos_image string for the image and version. The chromeos_image string will probably be in one of two formats: 1: /src/build/images//./ \ chromiumos_test_image.bin 2: /chroot/tmp/// \ chromiumos_test_image.bin We parse these strings to find the 'chromeos_version' to store in the json archive (without the .datatime bit in the first case); and also the 'chromeos_image', which would be all of the first case, but only the part after '/chroot/tmp' in the second case. Args: chromeos_image: string containing the path to the chromeos_image that crosperf used for the test. Returns: version, image: The results of parsing the input string, as explained above. """ # Find the Chromeos Version, e.g. R45-2345.0.0..... # chromeos_image should have been something like: # ///chromiumos_test_image.bin" if chromeos_image.endswith("/chromiumos_test_image.bin"): full_version = chromeos_image.split("/")[-2] # Strip the date and time off of local builds (which have the format # "R43-2345.0.0.date-and-time"). version, _ = os.path.splitext(full_version) else: version = "" # Find the chromeos image. If it's somewhere in .../chroot/tmp/..., then # it's an official image that got downloaded, so chop off the download path # to make the official image name more clear. official_image_path = "/chroot/tmp" if official_image_path in chromeos_image: image = chromeos_image.split(official_image_path, 1)[1] else: image = chromeos_image return version, image def _AppendUntilLengthIs(gen, the_list, target_len): """Appends to `list` until `list` is `target_len` elements long. Uses `gen` to generate elements. """ the_list.extend(gen() for _ in range(target_len - len(the_list))) return the_list def _FilterPerfReport(event_threshold, report): """Filters out entries with `< event_threshold` percent in a perf report.""" def filter_dict(m): return { fn_name: pct for fn_name, pct in m.items() if pct >= event_threshold } return {event: filter_dict(m) for event, m in report.items()} class _PerfTable(object): """Generates dicts from a perf table. Dicts look like: {'benchmark_name': {'perf_event_name': [LabelData]}} where LabelData is a list of perf dicts, each perf dict coming from the same label. Each perf dict looks like {'function_name': 0.10, ...} (where 0.10 is the percentage of time spent in function_name). """ def __init__( self, benchmark_names_and_iterations, label_names, read_perf_report, event_threshold=None, ): """Constructor. read_perf_report is a function that takes a label name, benchmark name, and benchmark iteration, and returns a dictionary describing the perf output for that given run. """ self.event_threshold = event_threshold self._label_indices = {name: i for i, name in enumerate(label_names)} self.perf_data = {} for label in label_names: for bench_name, bench_iterations in benchmark_names_and_iterations: for i in range(bench_iterations): report = read_perf_report(label, bench_name, i) self._ProcessPerfReport(report, label, bench_name, i) def _ProcessPerfReport(self, perf_report, label, benchmark_name, iteration): """Add the data from one run to the dict.""" perf_of_run = perf_report if self.event_threshold is not None: perf_of_run = _FilterPerfReport(self.event_threshold, perf_report) if benchmark_name not in self.perf_data: self.perf_data[benchmark_name] = { event: [] for event in perf_of_run } ben_data = self.perf_data[benchmark_name] label_index = self._label_indices[label] for event in ben_data: _AppendUntilLengthIs(list, ben_data[event], label_index + 1) data_for_label = ben_data[event][label_index] _AppendUntilLengthIs(dict, data_for_label, iteration + 1) data_for_label[iteration] = ( perf_of_run[event] if perf_of_run else {} ) def _GetResultsTableHeader(ben_name, iterations): benchmark_info = "Benchmark: {0}; Iterations: {1}".format( ben_name, iterations ) cell = Cell() cell.string_value = benchmark_info cell.header = True return [[cell]] def _GetDSOHeader(cwp_dso): info = "CWP_DSO: %s" % cwp_dso cell = Cell() cell.string_value = info cell.header = False return [[cell]] def _ParseColumn(columns, iteration): new_column = [] for column in columns: if column.result.__class__.__name__ != "RawResult": new_column.append(column) else: new_column.extend( Column(LiteralResult(i), Format(), str(i + 1)) for i in range(iteration) ) return new_column def _GetTables(benchmark_results, columns, table_type): iter_counts = benchmark_results.iter_counts result = benchmark_results.run_keyvals tables = [] for bench_name, runs in result.items(): iterations = iter_counts[bench_name] ben_table = _GetResultsTableHeader(bench_name, iterations) all_runs_empty = all(not dict for label in runs for dict in label) if all_runs_empty: cell = Cell() cell.string_value = ( "This benchmark contains no result." " Is the benchmark name valid?" ) cell_table = [[cell]] else: table = TableGenerator( runs, benchmark_results.label_names ).GetTable() parsed_columns = _ParseColumn(columns, iterations) tf = TableFormatter(table, parsed_columns) cell_table = tf.GetCellTable(table_type) tables.append(ben_table) tables.append(cell_table) return tables def _GetPerfTables(benchmark_results, columns, table_type): p_table = _PerfTable( benchmark_results.benchmark_names_and_iterations, benchmark_results.label_names, benchmark_results.read_perf_report, ) tables = [] for benchmark in p_table.perf_data: iterations = benchmark_results.iter_counts[benchmark] ben_table = _GetResultsTableHeader(benchmark, iterations) tables.append(ben_table) benchmark_data = p_table.perf_data[benchmark] table = [] for event in benchmark_data: tg = TableGenerator( benchmark_data[event], benchmark_results.label_names, sort=TableGenerator.SORT_BY_VALUES_DESC, ) table = tg.GetTable(ResultsReport.PERF_ROWS) parsed_columns = _ParseColumn(columns, iterations) tf = TableFormatter(table, parsed_columns) tf.GenerateCellTable(table_type) tf.AddColumnName() tf.AddLabelName() tf.AddHeader(str(event)) table = tf.GetCellTable(table_type, headers=False) tables.append(table) return tables def _GetSamplesTables(benchmark_results, columns, table_type): tables = [] dso_header_table = _GetDSOHeader(benchmark_results.cwp_dso) tables.append(dso_header_table) (table, new_keyvals, iter_counts) = SamplesTableGenerator( benchmark_results.run_keyvals, benchmark_results.label_names, benchmark_results.iter_counts, benchmark_results.weights, ).GetTable() parsed_columns = _ParseColumn(columns, 1) tf = TableFormatter(table, parsed_columns, samples_table=True) cell_table = tf.GetCellTable(table_type) tables.append(cell_table) return (tables, new_keyvals, iter_counts) class ResultsReport(object): """Class to handle the report format.""" MAX_COLOR_CODE = 255 PERF_ROWS = 5 def __init__(self, results): self.benchmark_results = results def _GetTablesWithColumns(self, columns, table_type, summary_type): if summary_type == "perf": get_tables = _GetPerfTables elif summary_type == "samples": get_tables = _GetSamplesTables else: get_tables = _GetTables ret = get_tables(self.benchmark_results, columns, table_type) # If we are generating a samples summary table, the return value of # get_tables will be a tuple, and we will update the benchmark_results for # composite benchmark so that full table can use it. if isinstance(ret, tuple): self.benchmark_results.run_keyvals = ret[1] self.benchmark_results.iter_counts = ret[2] ret = ret[0] return ret def GetFullTables(self, perf=False): ignore_min_max = self.benchmark_results.ignore_min_max columns = [ Column(RawResult(), Format()), Column(MinResult(), Format()), Column(MaxResult(), Format()), Column(AmeanResult(ignore_min_max), Format()), Column(StdResult(ignore_min_max), Format(), "StdDev"), Column( CoeffVarResult(ignore_min_max), CoeffVarFormat(), "StdDev/Mean" ), Column( GmeanRatioResult(ignore_min_max), RatioFormat(), "GmeanSpeedup" ), Column(PValueResult(ignore_min_max), PValueFormat(), "p-value"), ] return self._GetTablesWithColumns(columns, "full", perf) def GetSummaryTables(self, summary_type=""): ignore_min_max = self.benchmark_results.ignore_min_max columns = [] if summary_type == "samples": columns += [ Column(IterationResult(), Format(), "Iterations [Pass:Fail]") ] columns += [ Column( AmeanResult(ignore_min_max), Format(), "Weighted Samples Amean" if summary_type == "samples" else "", ), Column(StdResult(ignore_min_max), Format(), "StdDev"), Column( CoeffVarResult(ignore_min_max), CoeffVarFormat(), "StdDev/Mean" ), Column( GmeanRatioResult(ignore_min_max), RatioFormat(), "GmeanSpeedup" ), Column(PValueResult(ignore_min_max), PValueFormat(), "p-value"), ] return self._GetTablesWithColumns(columns, "summary", summary_type) def _PrintTable(tables, out_to): # tables may be None. if not tables: return "" if out_to == "HTML": out_type = TablePrinter.HTML elif out_to == "PLAIN": out_type = TablePrinter.PLAIN elif out_to == "CONSOLE": out_type = TablePrinter.CONSOLE elif out_to == "TSV": out_type = TablePrinter.TSV elif out_to == "EMAIL": out_type = TablePrinter.EMAIL else: raise ValueError("Invalid out_to value: %s" % (out_to,)) printers = (TablePrinter(table, out_type) for table in tables) return "".join(printer.Print() for printer in printers) class TextResultsReport(ResultsReport): """Class to generate text result report.""" H1_STR = "===========================================" H2_STR = "-------------------------------------------" def __init__(self, results, email=False, experiment=None): super(TextResultsReport, self).__init__(results) self.email = email self.experiment = experiment @staticmethod def _MakeTitle(title): header_line = TextResultsReport.H1_STR # '' at the end gives one newline. return "\n".join([header_line, title, header_line, ""]) @staticmethod def _MakeSection(title, body): header_line = TextResultsReport.H2_STR # '\n' at the end gives us two newlines. return "\n".join([header_line, title, header_line, body, "\n"]) @staticmethod def FromExperiment(experiment, email=False): results = BenchmarkResults.FromExperiment(experiment) return TextResultsReport(results, email, experiment) def GetStatusTable(self): """Generate the status table by the tabulator.""" table = [["", ""]] columns = [ Column(LiteralResult(iteration=0), Format(), "Status"), Column(LiteralResult(iteration=1), Format(), "Failing Reason"), ] for benchmark_run in self.experiment.benchmark_runs: status = [ benchmark_run.name, [ benchmark_run.timeline.GetLastEvent(), benchmark_run.failure_reason, ], ] table.append(status) cell_table = TableFormatter(table, columns).GetCellTable("status") return [cell_table] def GetTotalWaitCooldownTime(self): """Get cooldown wait time in seconds from experiment benchmark runs. Returns: Dictionary {'dut': int(wait_time_in_seconds)} """ waittime_dict = {} for dut in self.experiment.machine_manager.GetMachines(): waittime_dict[dut.name] = dut.GetCooldownWaitTime() return waittime_dict def GetReport(self): """Generate the report for email and console.""" output_type = "EMAIL" if self.email else "CONSOLE" experiment = self.experiment sections = [] if experiment is not None: title_contents = "Results report for '%s'" % (experiment.name,) else: title_contents = "Results report" sections.append(self._MakeTitle(title_contents)) if not self.benchmark_results.cwp_dso: summary_table = _PrintTable(self.GetSummaryTables(), output_type) else: summary_table = _PrintTable( self.GetSummaryTables(summary_type="samples"), output_type ) sections.append(self._MakeSection("Summary", summary_table)) if experiment is not None: table = _PrintTable(self.GetStatusTable(), output_type) sections.append(self._MakeSection("Benchmark Run Status", table)) if not self.benchmark_results.cwp_dso: perf_table = _PrintTable( self.GetSummaryTables(summary_type="perf"), output_type ) sections.append(self._MakeSection("Perf Data", perf_table)) if experiment is not None: experiment_file = experiment.experiment_file sections.append( self._MakeSection("Experiment File", experiment_file) ) cpu_info = experiment.machine_manager.GetAllCPUInfo( experiment.labels ) sections.append(self._MakeSection("CPUInfo", cpu_info)) totaltime = ( (time.time() - experiment.start_time) if experiment.start_time else 0 ) totaltime_str = "Total experiment time:\n%d min" % (totaltime // 60) cooldown_waittime_list = ["Cooldown wait time:"] # When running experiment on multiple DUTs cooldown wait time may vary # on different devices. In addition its combined time may exceed total # experiment time which will look weird but it is reasonable. # For this matter print cooldown time per DUT. for dut, waittime in sorted( self.GetTotalWaitCooldownTime().items() ): cooldown_waittime_list.append( "DUT %s: %d min" % (dut, waittime // 60) ) cooldown_waittime_str = "\n".join(cooldown_waittime_list) sections.append( self._MakeSection( "Duration", "\n\n".join([totaltime_str, cooldown_waittime_str]), ) ) return "\n".join(sections) def _GetHTMLCharts(label_names, test_results): charts = [] for item, runs in test_results.items(): # Fun fact: label_names is actually *entirely* useless as a param, since we # never add headers. We still need to pass it anyway. table = TableGenerator(runs, label_names).GetTable() columns = [ Column(AmeanResult(), Format()), Column(MinResult(), Format()), Column(MaxResult(), Format()), ] tf = TableFormatter(table, columns) data_table = tf.GetCellTable("full", headers=False) for cur_row_data in data_table: test_key = cur_row_data[0].string_value title = "{0}: {1}".format(item, test_key.replace("/", "")) chart = ColumnChart(title, 300, 200) chart.AddColumn("Label", "string") chart.AddColumn("Average", "number") chart.AddColumn("Min", "number") chart.AddColumn("Max", "number") chart.AddSeries("Min", "line", "black") chart.AddSeries("Max", "line", "black") cur_index = 1 for label in label_names: chart.AddRow( [ label, cur_row_data[cur_index].value, cur_row_data[cur_index + 1].value, cur_row_data[cur_index + 2].value, ] ) if isinstance(cur_row_data[cur_index].value, str): chart = None break cur_index += 3 if chart: charts.append(chart) return charts class HTMLResultsReport(ResultsReport): """Class to generate html result report.""" def __init__(self, benchmark_results, experiment=None): super(HTMLResultsReport, self).__init__(benchmark_results) self.experiment = experiment @staticmethod def FromExperiment(experiment): return HTMLResultsReport( BenchmarkResults.FromExperiment(experiment), experiment=experiment ) def GetReport(self): label_names = self.benchmark_results.label_names test_results = self.benchmark_results.run_keyvals charts = _GetHTMLCharts(label_names, test_results) chart_javascript = "".join(chart.GetJavascript() for chart in charts) chart_divs = "".join(chart.GetDiv() for chart in charts) if not self.benchmark_results.cwp_dso: summary_table = self.GetSummaryTables() perf_table = self.GetSummaryTables(summary_type="perf") else: summary_table = self.GetSummaryTables(summary_type="samples") perf_table = None full_table = self.GetFullTables() experiment_file = "" if self.experiment is not None: experiment_file = self.experiment.experiment_file # Use kwargs for code readability, and so that testing is a bit easier. return templates.GenerateHTMLPage( perf_table=perf_table, chart_js=chart_javascript, summary_table=summary_table, print_table=_PrintTable, chart_divs=chart_divs, full_table=full_table, experiment_file=experiment_file, ) def ParseStandardPerfReport(report_data): """Parses the output of `perf report`. It'll parse the following: {{garbage}} # Samples: 1234M of event 'foo' 1.23% command shared_object location function::name 1.22% command shared_object location function2::name # Samples: 999K of event 'bar' 0.23% command shared_object location function3::name {{etc.}} Into: {'foo': {'function::name': 1.23, 'function2::name': 1.22}, 'bar': {'function3::name': 0.23, etc.}} """ # This function fails silently on its if it's handed a string (as opposed to a # list of lines). So, auto-split if we do happen to get a string. if isinstance(report_data, str): report_data = report_data.splitlines() # When switching to python3 catch the case when bytes are passed. elif isinstance(report_data, bytes): raise TypeError() # Samples: N{K,M,G} of event 'event-name' samples_regex = re.compile(r"#\s+Samples: \d+\S? of event '([^']+)'") # We expect lines like: # N.NN% command samples shared_object [location] symbol # # Note that we're looking at stripped lines, so there is no space at the # start. perf_regex = re.compile( r"^(\d+(?:.\d*)?)%" # N.NN% r"\s*\d+" # samples count (ignored) r"\s*\S+" # command (ignored) r"\s*\S+" # shared_object (ignored) r"\s*\[.\]" # location (ignored) r"\s*(\S.+)" # function ) stripped_lines = (l.strip() for l in report_data) nonempty_lines = (l for l in stripped_lines if l) # Ignore all lines before we see samples_regex interesting_lines = itertools.dropwhile( lambda x: not samples_regex.match(x), nonempty_lines ) first_sample_line = next(interesting_lines, None) # Went through the entire file without finding a 'samples' header. Quit. if first_sample_line is None: return {} sample_name = samples_regex.match(first_sample_line).group(1) current_result = {} results = {sample_name: current_result} for line in interesting_lines: samples_match = samples_regex.match(line) if samples_match: sample_name = samples_match.group(1) current_result = {} results[sample_name] = current_result continue match = perf_regex.match(line) if not match: continue percentage_str, func_name = match.groups() try: percentage = float(percentage_str) except ValueError: # Couldn't parse it; try to be "resilient". continue current_result[func_name] = percentage return results def _ReadExperimentPerfReport( results_directory, label_name, benchmark_name, benchmark_iteration ): """Reads a perf report for the given benchmark. Returns {} on failure. The result should be a map of maps; it should look like: {perf_event_name: {function_name: pct_time_spent}}, e.g. {'cpu_cycles': {'_malloc': 10.0, '_free': 0.3, ...}} """ raw_dir_name = label_name + benchmark_name + str(benchmark_iteration + 1) dir_name = "".join(c for c in raw_dir_name if c.isalnum()) file_name = os.path.join(results_directory, dir_name, "perf.data.report.0") try: with open(file_name) as in_file: return ParseStandardPerfReport(in_file) except IOError: # Yes, we swallow any IO-related errors. return {} # Split out so that testing (specifically: mocking) is easier def _ExperimentToKeyvals(experiment, for_json_report): """Converts an experiment to keyvals.""" return OrganizeResults( experiment.benchmark_runs, experiment.labels, json_report=for_json_report, ) class BenchmarkResults(object): """The minimum set of fields that any ResultsReport will take.""" def __init__( self, label_names, benchmark_names_and_iterations, run_keyvals, ignore_min_max=False, read_perf_report=None, cwp_dso=None, weights=None, ): if read_perf_report is None: def _NoPerfReport(*_args, **_kwargs): return {} read_perf_report = _NoPerfReport self.label_names = label_names self.benchmark_names_and_iterations = benchmark_names_and_iterations self.iter_counts = dict(benchmark_names_and_iterations) self.run_keyvals = run_keyvals self.ignore_min_max = ignore_min_max self.read_perf_report = read_perf_report self.cwp_dso = cwp_dso self.weights = dict(weights) if weights else None @staticmethod def FromExperiment(experiment, for_json_report=False): label_names = [label.name for label in experiment.labels] benchmark_names_and_iterations = [ (benchmark.name, benchmark.iterations) for benchmark in experiment.benchmarks ] run_keyvals = _ExperimentToKeyvals(experiment, for_json_report) ignore_min_max = experiment.ignore_min_max read_perf_report = functools.partial( _ReadExperimentPerfReport, experiment.results_directory ) cwp_dso = experiment.cwp_dso weights = [ (benchmark.name, benchmark.weight) for benchmark in experiment.benchmarks ] return BenchmarkResults( label_names, benchmark_names_and_iterations, run_keyvals, ignore_min_max, read_perf_report, cwp_dso, weights, ) def _GetElemByName(name, from_list): """Gets an element from the given list by its name field. Raises an error if it doesn't find exactly one match. """ elems = [e for e in from_list if e.name == name] if len(elems) != 1: raise ValueError( "Expected 1 item named %s, found %d" % (name, len(elems)) ) return elems[0] def _Unlist(l): """If l is a list, extracts the first element of l. Otherwise, returns l.""" return l[0] if isinstance(l, list) else l class JSONResultsReport(ResultsReport): """Class that generates JSON reports for experiments.""" def __init__( self, benchmark_results, benchmark_date=None, benchmark_time=None, experiment=None, json_args=None, ): """Construct a JSONResultsReport. json_args is the dict of arguments we pass to json.dumps in GetReport(). """ super(JSONResultsReport, self).__init__(benchmark_results) defaults = TelemetryDefaults() defaults.ReadDefaultsFile() summary_field_defaults = defaults.GetDefault() if summary_field_defaults is None: summary_field_defaults = {} self.summary_field_defaults = summary_field_defaults if json_args is None: json_args = {} self.json_args = json_args self.experiment = experiment if not benchmark_date: timestamp = datetime.datetime.strftime( datetime.datetime.now(), "%Y-%m-%d %H:%M:%S" ) benchmark_date, benchmark_time = timestamp.split(" ") self.date = benchmark_date self.time = benchmark_time @staticmethod def FromExperiment( experiment, benchmark_date=None, benchmark_time=None, json_args=None ): benchmark_results = BenchmarkResults.FromExperiment( experiment, for_json_report=True ) return JSONResultsReport( benchmark_results, benchmark_date, benchmark_time, experiment, json_args, ) def GetReportObjectIgnoringExperiment(self): """Gets the JSON report object specifically for the output data. Ignores any experiment-specific fields (e.g. board, machine checksum, ...). """ benchmark_results = self.benchmark_results label_names = benchmark_results.label_names summary_field_defaults = self.summary_field_defaults final_results = [] for test, test_results in benchmark_results.run_keyvals.items(): for label_name, label_results in zip(label_names, test_results): for iter_results in label_results: passed = iter_results.get("retval") == 0 json_results = { "date": self.date, "time": self.time, "label": label_name, "test_name": test, "pass": passed, } final_results.append(json_results) if not passed: continue # Get overall results. summary_fields = summary_field_defaults.get(test) if summary_fields is not None: value = [] json_results["overall_result"] = value for f in summary_fields: v = iter_results.get(f) if v is None: continue # New telemetry results format: sometimes we get a list of lists # now. v = _Unlist(_Unlist(v)) value.append((f, float(v))) # Get detailed results. detail_results = {} json_results["detailed_results"] = detail_results for k, v in iter_results.items(): if ( k == "retval" or k == "PASS" or k == ["PASS"] or v == "PASS" ): continue v = _Unlist(v) if "machine" in k: json_results[k] = v elif v is not None: if isinstance(v, list): detail_results[k] = [float(d) for d in v] else: detail_results[k] = float(v) return final_results def GetReportObject(self): """Generate the JSON report, returning it as a python object.""" report_list = self.GetReportObjectIgnoringExperiment() if self.experiment is not None: self._AddExperimentSpecificFields(report_list) return report_list def _AddExperimentSpecificFields(self, report_list): """Add experiment-specific data to the JSON report.""" board = self.experiment.labels[0].board manager = self.experiment.machine_manager for report in report_list: label_name = report["label"] label = _GetElemByName(label_name, self.experiment.labels) img_path = os.path.realpath( os.path.expanduser(label.chromeos_image) ) ver, img = ParseChromeosImage(img_path) report.update( { "board": board, "chromeos_image": img, "chromeos_version": ver, "chrome_version": label.chrome_version, "compiler": label.compiler, } ) if not report["pass"]: continue if "machine_checksum" not in report: report["machine_checksum"] = manager.machine_checksum[ label_name ] if "machine_string" not in report: report["machine_string"] = manager.machine_checksum_string[ label_name ] def GetReport(self): """Dump the results of self.GetReportObject() to a string as JSON.""" # This exists for consistency with the other GetReport methods. # Specifically, they all return strings, so it's a bit awkward if the JSON # results reporter returns an object. return json.dumps(self.GetReportObject(), **self.json_args)