Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 1 | #!/usr/bin/env python |
Avi Drissman | dfd88085 | 2022-09-15 20:11:09 | [diff] [blame] | 2 | # Copyright 2016 The Chromium Authors |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 3 | # Use of this source code is governed by a BSD-style license that can be |
| 4 | # found in the LICENSE file. |
| 5 | |
| 6 | import copy |
Brian Sheedy | 822e0374 | 2024-08-09 18:48:14 | [diff] [blame] | 7 | import functools |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 8 | import json |
| 9 | import sys |
| 10 | |
| 11 | # These fields must appear in the test result output |
| 12 | REQUIRED = { |
| 13 | 'interrupted', |
| 14 | 'num_failures_by_type', |
| 15 | 'seconds_since_epoch', |
| 16 | 'tests', |
Ben Pastene | b5c6726 | 2024-05-15 21:24:01 | [diff] [blame] | 17 | } |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 18 | |
| 19 | # These fields are optional, but must have the same value on all shards |
Ben Pastene | b5c6726 | 2024-05-15 21:24:01 | [diff] [blame] | 20 | OPTIONAL_MATCHING = ('builder_name', 'build_number', 'chromium_revision', |
| 21 | 'has_pretty_patch', 'has_wdiff', 'path_delimiter', |
| 22 | 'pixel_tests_enabled', 'random_order_seed') |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 23 | |
Rakib M. Hasan | 69ea25ce | 2019-06-29 23:11:51 | [diff] [blame] | 24 | # The last shard's value for these fields will show up in the merged results |
Ben Pastene | b5c6726 | 2024-05-15 21:24:01 | [diff] [blame] | 25 | OPTIONAL_IGNORED = ('layout_tests_dir', 'metadata') |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 26 | |
| 27 | # These fields are optional and will be summed together |
| 28 | OPTIONAL_COUNTS = ( |
| 29 | 'fixable', |
| 30 | 'num_flaky', |
| 31 | 'num_passes', |
| 32 | 'num_regressions', |
| 33 | 'skipped', |
| 34 | 'skips', |
Ben Pastene | b5c6726 | 2024-05-15 21:24:01 | [diff] [blame] | 35 | ) |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 36 | |
| 37 | |
| 38 | class MergeException(Exception): |
| 39 | pass |
| 40 | |
| 41 | |
| 42 | def 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) |
Joshua Hood | bb3cd22 | 2022-03-03 00:40:48 | [diff] [blame] | 61 | |
| 62 | return _merge_simplified_json_format(shard_results_list) |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 63 | |
| 64 | |
| 65 | def _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 = { |
Ben Pastene | b5c6726 | 2024-05-15 21:24:01 | [diff] [blame] | 71 | 'successes': [], |
| 72 | 'failures': [], |
| 73 | 'valid': True, |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 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 | |
Ben Pastene | b5c6726 | 2024-05-15 21:24:01 | [diff] [blame] | 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 |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 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 | |
| 92 | def _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 = { |
Ben Pastene | b5c6726 | 2024-05-15 21:24:01 | [diff] [blame] | 98 | 'tests': {}, |
| 99 | 'interrupted': False, |
| 100 | 'version': 3, |
| 101 | 'seconds_since_epoch': float('inf'), |
| 102 | 'num_failures_by_type': {} |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 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: |
Darwin Huang | 663c17d | 2020-02-06 01:10:01 | [diff] [blame] | 108 | # TODO(tansell): check whether this deepcopy is actually necessary. |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 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 |
Ben Pastene | b5c6726 | 2024-05-15 21:24:01 | [diff] [blame] | 115 | # results_merger_unittest). |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 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 |
Ben Pastene | b5c6726 | 2024-05-15 21:24:01 | [diff] [blame] | 122 | # results_merger_unittest). |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 123 | 'Invalid json test results (missing %s)' % missing) |
| 124 | |
| 125 | # Curry merge_values for this result_json. |
Brian Sheedy | 822e0374 | 2024-08-09 18:48:14 | [diff] [blame] | 126 | merge = functools.partial(merge_value, result_json, merged_results) |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 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. |
Ben Pastene | b5c6726 | 2024-05-15 21:24:01 | [diff] [blame] | 133 | merge('interrupted', lambda x, y: x | y) |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 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) |
Ben Pastene | b5c6726 | 2024-05-15 21:24:01 | [diff] [blame] | 166 | merge(count_key, lambda a, b: a + b) |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 167 | |
| 168 | if result_json: |
| 169 | raise MergeException( # pragma: no cover (covered by |
Ben Pastene | b5c6726 | 2024-05-15 21:24:01 | [diff] [blame] | 170 | # results_merger_unittest). |
Wenbin Zhang | c56b125 | 2021-10-05 02:49:06 | [diff] [blame] | 171 | 'Unmergable values %s' % list(result_json.keys())) |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 172 | |
| 173 | return merged_results |
| 174 | |
| 175 | |
| 176 | def 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() |
Wenbin Zhang | c56b125 | 2021-10-05 02:49:06 | [diff] [blame] | 194 | for k, v in curr_node.items(): |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 195 | if k in dest_node: |
| 196 | if not isinstance(v, dict): |
| 197 | raise MergeException( |
Brian Sheedy | 0d2300f3 | 2024-08-13 23:14:41 | [diff] [blame] | 198 | '%s:%s: %r not mergable, curr_node: %r\ndest_node: %r' % |
Ben Pastene | b5c6726 | 2024-05-15 21:24:01 | [diff] [blame] | 199 | (prefix, k, v, curr_node, dest_node)) |
Brian Sheedy | 0d2300f3 | 2024-08-13 23:14:41 | [diff] [blame] | 200 | pending_nodes.append(('%s:%s' % (prefix, k), dest_node[k], v)) |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 201 | else: |
| 202 | dest_node[k] = v |
| 203 | return dest |
| 204 | |
| 205 | |
| 206 | def 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 |
Ben Pastene | b5c6726 | 2024-05-15 21:24:01 | [diff] [blame] | 216 | # results_merger_unittest). |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 217 | "Values don't match: %s, %s" % (source, dest)) |
| 218 | return source |
| 219 | |
| 220 | |
| 221 | def 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 | """ |
Wenbin Zhang | c56b125 | 2021-10-05 02:49:06 | [diff] [blame] | 226 | for k, v in source.items(): |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 227 | dest.setdefault(k, 0) |
| 228 | dest[k] += v |
| 229 | |
| 230 | return dest |
| 231 | |
| 232 | |
| 233 | def 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: |
Brian Sheedy | 0d2300f3 | 2024-08-13 23:14:41 | [diff] [blame] | 251 | message = 'MergeFailure for %s\n%s' % (key, e.args[0]) |
Ben Pastene | b5c6726 | 2024-05-15 21:24:01 | [diff] [blame] | 252 | e.args = (message, ) + e.args[1:] |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 253 | raise |
| 254 | del source[key] |
| 255 | |
| 256 | |
| 257 | def main(files): |
| 258 | if len(files) < 2: |
Brian Sheedy | 0d2300f3 | 2024-08-13 23:14:41 | [diff] [blame] | 259 | sys.stderr.write('Not enough JSON files to merge.\n') |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 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))]) |
Wenbin Zhang | c56b125 | 2021-10-05 02:49:06 | [diff] [blame] | 266 | print(json.dumps(result)) |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 267 | return 0 |
| 268 | |
| 269 | |
Brian Sheedy | 0d2300f3 | 2024-08-13 23:14:41 | [diff] [blame] | 270 | if __name__ == '__main__': |
Stephen Martinis | 3c3b0e5 | 2019-03-06 18:39:51 | [diff] [blame] | 271 | sys.exit(main(sys.argv[1:])) |