1#!/usr/bin/env python 2# Copyright 2016 The Chromium Authors 3# Use of this source code is governed by a BSD-style license that can be 4# found in the LICENSE file. 5 6import copy 7import functools 8import json 9import sys 10 11# These fields must appear in the test result output 12REQUIRED = { 13 'interrupted', 14 'num_failures_by_type', 15 'seconds_since_epoch', 16 'tests', 17} 18 19# These fields are optional, but must have the same value on all shards 20OPTIONAL_MATCHING = ('builder_name', 'build_number', 'chromium_revision', 21 'has_pretty_patch', 'has_wdiff', 'path_delimiter', 22 'pixel_tests_enabled', 'random_order_seed') 23 24# The last shard's value for these fields will show up in the merged results 25OPTIONAL_IGNORED = ('layout_tests_dir', 'metadata') 26 27# These fields are optional and will be summed together 28OPTIONAL_COUNTS = ( 29 'fixable', 30 'num_flaky', 31 'num_passes', 32 'num_regressions', 33 'skipped', 34 'skips', 35) 36 37 38class MergeException(Exception): 39 pass 40 41 42def merge_test_results(shard_results_list): 43 """ Merge list of results. 44 45 Args: 46 shard_results_list: list of results to merge. All the results must have the 47 same format. Supported format are simplified JSON format & Chromium JSON 48 test results format version 3 (see 49 https://www.chromium.org/developers/the-json-test-results-format) 50 51 Returns: 52 a dictionary that represent the merged results. Its format follow the same 53 format of all results in |shard_results_list|. 54 """ 55 shard_results_list = [x for x in shard_results_list if x] 56 if not shard_results_list: 57 return {} 58 59 if 'seconds_since_epoch' in shard_results_list[0]: 60 return _merge_json_test_result_format(shard_results_list) 61 62 return _merge_simplified_json_format(shard_results_list) 63 64 65def _merge_simplified_json_format(shard_results_list): 66 # This code is specialized to the "simplified" JSON format that used to be 67 # the standard for recipes. 68 69 # These are the only keys we pay attention to in the output JSON. 70 merged_results = { 71 'successes': [], 72 'failures': [], 73 'valid': True, 74 } 75 76 for result_json in shard_results_list: 77 successes = result_json.get('successes', []) 78 failures = result_json.get('failures', []) 79 valid = result_json.get('valid', True) 80 81 if (not isinstance(successes, list) or not isinstance(failures, list) 82 or not isinstance(valid, bool)): 83 raise MergeException('Unexpected value type in %s' % 84 result_json) # pragma: no cover 85 86 merged_results['successes'].extend(successes) 87 merged_results['failures'].extend(failures) 88 merged_results['valid'] = merged_results['valid'] and valid 89 return merged_results 90 91 92def _merge_json_test_result_format(shard_results_list): 93 # This code is specialized to the Chromium JSON test results format version 3: 94 # https://www.chromium.org/developers/the-json-test-results-format 95 96 # These are required fields for the JSON test result format version 3. 97 merged_results = { 98 'tests': {}, 99 'interrupted': False, 100 'version': 3, 101 'seconds_since_epoch': float('inf'), 102 'num_failures_by_type': {} 103 } 104 105 # To make sure that we don't mutate existing shard_results_list. 106 shard_results_list = copy.deepcopy(shard_results_list) 107 for result_json in shard_results_list: 108 # TODO(tansell): check whether this deepcopy is actually necessary. 109 result_json = copy.deepcopy(result_json) 110 111 # Check the version first 112 version = result_json.pop('version', -1) 113 if version != 3: 114 raise MergeException( # pragma: no cover (covered by 115 # results_merger_unittest). 116 'Unsupported version %s. Only version 3 is supported' % version) 117 118 # Check the results for each shard have the required keys 119 missing = REQUIRED - set(result_json) 120 if missing: 121 raise MergeException( # pragma: no cover (covered by 122 # results_merger_unittest). 123 'Invalid json test results (missing %s)' % missing) 124 125 # Curry merge_values for this result_json. 126 merge = functools.partial(merge_value, result_json, merged_results) 127 128 # Traverse the result_json's test trie & merged_results's test tries in 129 # DFS order & add the n to merged['tests']. 130 merge('tests', merge_tries) 131 132 # If any were interrupted, we are interrupted. 133 merge('interrupted', lambda x, y: x | y) 134 135 # Use the earliest seconds_since_epoch value 136 merge('seconds_since_epoch', min) 137 138 # Sum the number of failure types 139 merge('num_failures_by_type', sum_dicts) 140 141 # Optional values must match 142 for optional_key in OPTIONAL_MATCHING: 143 if optional_key not in result_json: 144 continue 145 146 if optional_key not in merged_results: 147 # Set this value to None, then blindly copy over it. 148 merged_results[optional_key] = None 149 merge(optional_key, lambda src, dst: src) 150 else: 151 merge(optional_key, ensure_match) 152 153 # Optional values ignored 154 for optional_key in OPTIONAL_IGNORED: 155 if optional_key in result_json: 156 merged_results[optional_key] = result_json.pop( 157 # pragma: no cover (covered by 158 # results_merger_unittest). 159 optional_key) 160 161 # Sum optional value counts 162 for count_key in OPTIONAL_COUNTS: 163 if count_key in result_json: # pragma: no cover 164 # TODO(mcgreevy): add coverage. 165 merged_results.setdefault(count_key, 0) 166 merge(count_key, lambda a, b: a + b) 167 168 if result_json: 169 raise MergeException( # pragma: no cover (covered by 170 # results_merger_unittest). 171 'Unmergable values %s' % list(result_json.keys())) 172 173 return merged_results 174 175 176def merge_tries(source, dest): 177 """ Merges test tries. 178 179 This is intended for use as a merge_func parameter to merge_value. 180 181 Args: 182 source: A result json test trie. 183 dest: A json test trie merge destination. 184 """ 185 # merge_tries merges source into dest by performing a lock-step depth-first 186 # traversal of dest and source. 187 # pending_nodes contains a list of all sub-tries which have been reached but 188 # need further merging. 189 # Each element consists of a trie prefix, and a sub-trie from each of dest 190 # and source which is reached via that prefix. 191 pending_nodes = [('', dest, source)] 192 while pending_nodes: 193 prefix, dest_node, curr_node = pending_nodes.pop() 194 for k, v in curr_node.items(): 195 if k in dest_node: 196 if not isinstance(v, dict): 197 raise MergeException( 198 '%s:%s: %r not mergable, curr_node: %r\ndest_node: %r' % 199 (prefix, k, v, curr_node, dest_node)) 200 pending_nodes.append(('%s:%s' % (prefix, k), dest_node[k], v)) 201 else: 202 dest_node[k] = v 203 return dest 204 205 206def ensure_match(source, dest): 207 """ Returns source if it matches dest. 208 209 This is intended for use as a merge_func parameter to merge_value. 210 211 Raises: 212 MergeException if source != dest 213 """ 214 if source != dest: 215 raise MergeException( # pragma: no cover (covered by 216 # results_merger_unittest). 217 "Values don't match: %s, %s" % (source, dest)) 218 return source 219 220 221def sum_dicts(source, dest): 222 """ Adds values from source to corresponding values in dest. 223 224 This is intended for use as a merge_func parameter to merge_value. 225 """ 226 for k, v in source.items(): 227 dest.setdefault(k, 0) 228 dest[k] += v 229 230 return dest 231 232 233def merge_value(source, dest, key, merge_func): 234 """ Merges a value from source to dest. 235 236 The value is deleted from source. 237 238 Args: 239 source: A dictionary from which to pull a value, identified by key. 240 dest: The dictionary into to which the value is to be merged. 241 key: The key which identifies the value to be merged. 242 merge_func(src, dst): A function which merges its src into dst, 243 and returns the result. May modify dst. May raise a MergeException. 244 245 Raises: 246 MergeException if the values can not be merged. 247 """ 248 try: 249 dest[key] = merge_func(source[key], dest[key]) 250 except MergeException as e: 251 message = 'MergeFailure for %s\n%s' % (key, e.args[0]) 252 e.args = (message, ) + e.args[1:] 253 raise 254 del source[key] 255 256 257def main(files): 258 if len(files) < 2: 259 sys.stderr.write('Not enough JSON files to merge.\n') 260 return 1 261 sys.stderr.write('Starting with %s\n' % files[0]) 262 result = json.load(open(files[0])) 263 for f in files[1:]: 264 sys.stderr.write('Merging %s\n' % f) 265 result = merge_test_results([result, json.load(open(f))]) 266 print(json.dumps(result)) 267 return 0 268 269 270if __name__ == '__main__': 271 sys.exit(main(sys.argv[1:])) 272