epoger@google.com | 53953b4 | 2013-07-02 20:22:27 +0000 | [diff] [blame] | 1 | #!/usr/bin/python |
| 2 | |
| 3 | ''' |
| 4 | Copyright 2013 Google Inc. |
| 5 | |
| 6 | Use of this source code is governed by a BSD-style license that can be |
| 7 | found in the LICENSE file. |
| 8 | ''' |
| 9 | |
| 10 | ''' |
| 11 | Gathers diffs between 2 JSON expectations files, or between actual and |
| 12 | expected results within a single JSON actual-results file, |
| 13 | and generates an old-vs-new diff dictionary. |
epoger@google.com | 61822a2 | 2013-07-16 18:56:32 +0000 | [diff] [blame] | 14 | |
| 15 | TODO(epoger): Fix indentation in this file (2-space indents, not 4-space). |
epoger@google.com | 53953b4 | 2013-07-02 20:22:27 +0000 | [diff] [blame] | 16 | ''' |
| 17 | |
| 18 | # System-level imports |
| 19 | import argparse |
| 20 | import json |
| 21 | import os |
| 22 | import sys |
| 23 | import urllib2 |
| 24 | |
| 25 | # Imports from within Skia |
| 26 | # |
| 27 | # We need to add the 'gm' directory, so that we can import gm_json.py within |
| 28 | # that directory. That script allows us to parse the actual-results.json file |
| 29 | # written out by the GM tool. |
| 30 | # Make sure that the 'gm' dir is in the PYTHONPATH, but add it at the *end* |
| 31 | # so any dirs that are already in the PYTHONPATH will be preferred. |
| 32 | # |
| 33 | # This assumes that the 'gm' directory has been checked out as a sibling of |
| 34 | # the 'tools' directory containing this script, which will be the case if |
| 35 | # 'trunk' was checked out as a single unit. |
| 36 | GM_DIRECTORY = os.path.realpath( |
| 37 | os.path.join(os.path.dirname(os.path.dirname(__file__)), 'gm')) |
| 38 | if GM_DIRECTORY not in sys.path: |
| 39 | sys.path.append(GM_DIRECTORY) |
| 40 | import gm_json |
| 41 | |
| 42 | |
| 43 | # Object that generates diffs between two JSON gm result files. |
| 44 | class GMDiffer(object): |
| 45 | |
| 46 | def __init__(self): |
| 47 | pass |
| 48 | |
| 49 | def _GetFileContentsAsString(self, filepath): |
| 50 | """Returns the full contents of a file, as a single string. |
zachr@google.com | 6f8e2c5 | 2013-08-07 15:43:04 +0000 | [diff] [blame] | 51 | If the filename looks like a URL, download its contents. |
| 52 | If the filename is None, return None.""" |
| 53 | if filepath is None: |
| 54 | return None |
| 55 | elif filepath.startswith('http:') or filepath.startswith('https:'): |
epoger@google.com | 53953b4 | 2013-07-02 20:22:27 +0000 | [diff] [blame] | 56 | return urllib2.urlopen(filepath).read() |
| 57 | else: |
| 58 | return open(filepath, 'r').read() |
| 59 | |
zachr@google.com | 6f8e2c5 | 2013-08-07 15:43:04 +0000 | [diff] [blame] | 60 | def _GetExpectedResults(self, contents): |
| 61 | """Returns the dictionary of expected results from a JSON string, |
epoger@google.com | 53953b4 | 2013-07-02 20:22:27 +0000 | [diff] [blame] | 62 | in this form: |
| 63 | |
| 64 | { |
| 65 | 'test1' : 14760033689012826769, |
| 66 | 'test2' : 9151974350149210736, |
| 67 | ... |
| 68 | } |
| 69 | |
| 70 | We make these simplifying assumptions: |
| 71 | 1. Each test has either 0 or 1 allowed results. |
| 72 | 2. All expectations are of type JSONKEY_HASHTYPE_BITMAP_64BITMD5. |
| 73 | |
| 74 | Any tests which violate those assumptions will cause an exception to |
| 75 | be raised. |
| 76 | |
| 77 | Any tests for which we have no expectations will be left out of the |
| 78 | returned dictionary. |
| 79 | """ |
| 80 | result_dict = {} |
epoger@google.com | 53953b4 | 2013-07-02 20:22:27 +0000 | [diff] [blame] | 81 | json_dict = gm_json.LoadFromString(contents) |
| 82 | all_expectations = json_dict[gm_json.JSONKEY_EXPECTEDRESULTS] |
epoger@google.com | d73531a | 2013-09-04 16:27:16 +0000 | [diff] [blame] | 83 | |
| 84 | # Prevent https://code.google.com/p/skia/issues/detail?id=1588 |
epoger@google.com | d73531a | 2013-09-04 16:27:16 +0000 | [diff] [blame] | 85 | if not all_expectations: |
| 86 | return result_dict |
| 87 | |
epoger@google.com | 53953b4 | 2013-07-02 20:22:27 +0000 | [diff] [blame] | 88 | for test_name in all_expectations.keys(): |
| 89 | test_expectations = all_expectations[test_name] |
| 90 | allowed_digests = test_expectations[ |
| 91 | gm_json.JSONKEY_EXPECTEDRESULTS_ALLOWEDDIGESTS] |
| 92 | if allowed_digests: |
| 93 | num_allowed_digests = len(allowed_digests) |
| 94 | if num_allowed_digests > 1: |
| 95 | raise ValueError( |
zachr@google.com | 6f8e2c5 | 2013-08-07 15:43:04 +0000 | [diff] [blame] | 96 | 'test %s has %d allowed digests' % ( |
| 97 | test_name, num_allowed_digests)) |
epoger@google.com | 53953b4 | 2013-07-02 20:22:27 +0000 | [diff] [blame] | 98 | digest_pair = allowed_digests[0] |
| 99 | if digest_pair[0] != gm_json.JSONKEY_HASHTYPE_BITMAP_64BITMD5: |
| 100 | raise ValueError( |
zachr@google.com | 6f8e2c5 | 2013-08-07 15:43:04 +0000 | [diff] [blame] | 101 | 'test %s has unsupported hashtype %s' % ( |
| 102 | test_name, digest_pair[0])) |
epoger@google.com | 53953b4 | 2013-07-02 20:22:27 +0000 | [diff] [blame] | 103 | result_dict[test_name] = digest_pair[1] |
| 104 | return result_dict |
| 105 | |
zachr@google.com | 6f8e2c5 | 2013-08-07 15:43:04 +0000 | [diff] [blame] | 106 | def _GetActualResults(self, contents): |
| 107 | """Returns the dictionary of actual results from a JSON string, |
epoger@google.com | 53953b4 | 2013-07-02 20:22:27 +0000 | [diff] [blame] | 108 | in this form: |
| 109 | |
| 110 | { |
| 111 | 'test1' : 14760033689012826769, |
| 112 | 'test2' : 9151974350149210736, |
| 113 | ... |
| 114 | } |
| 115 | |
| 116 | We make these simplifying assumptions: |
| 117 | 1. All results are of type JSONKEY_HASHTYPE_BITMAP_64BITMD5. |
| 118 | |
| 119 | Any tests which violate those assumptions will cause an exception to |
| 120 | be raised. |
| 121 | |
| 122 | Any tests for which we have no actual results will be left out of the |
| 123 | returned dictionary. |
| 124 | """ |
| 125 | result_dict = {} |
epoger@google.com | 53953b4 | 2013-07-02 20:22:27 +0000 | [diff] [blame] | 126 | json_dict = gm_json.LoadFromString(contents) |
| 127 | all_result_types = json_dict[gm_json.JSONKEY_ACTUALRESULTS] |
| 128 | for result_type in all_result_types.keys(): |
| 129 | results_of_this_type = all_result_types[result_type] |
| 130 | if results_of_this_type: |
| 131 | for test_name in results_of_this_type.keys(): |
| 132 | digest_pair = results_of_this_type[test_name] |
| 133 | if digest_pair[0] != gm_json.JSONKEY_HASHTYPE_BITMAP_64BITMD5: |
| 134 | raise ValueError( |
zachr@google.com | 6f8e2c5 | 2013-08-07 15:43:04 +0000 | [diff] [blame] | 135 | 'test %s has unsupported hashtype %s' % ( |
| 136 | test_name, digest_pair[0])) |
epoger@google.com | 53953b4 | 2013-07-02 20:22:27 +0000 | [diff] [blame] | 137 | result_dict[test_name] = digest_pair[1] |
| 138 | return result_dict |
| 139 | |
| 140 | def _DictionaryDiff(self, old_dict, new_dict): |
| 141 | """Generate a dictionary showing the diffs between old_dict and new_dict. |
| 142 | Any entries which are identical across them will be left out.""" |
| 143 | diff_dict = {} |
| 144 | all_keys = set(old_dict.keys() + new_dict.keys()) |
| 145 | for key in all_keys: |
| 146 | if old_dict.get(key) != new_dict.get(key): |
| 147 | new_entry = {} |
| 148 | new_entry['old'] = old_dict.get(key) |
| 149 | new_entry['new'] = new_dict.get(key) |
| 150 | diff_dict[key] = new_entry |
| 151 | return diff_dict |
| 152 | |
| 153 | def GenerateDiffDict(self, oldfile, newfile=None): |
| 154 | """Generate a dictionary showing the diffs: |
| 155 | old = expectations within oldfile |
| 156 | new = expectations within newfile |
| 157 | |
| 158 | If newfile is not specified, then 'new' is the actual results within |
| 159 | oldfile. |
| 160 | """ |
zachr@google.com | 6f8e2c5 | 2013-08-07 15:43:04 +0000 | [diff] [blame] | 161 | return self.GenerateDiffDictFromStrings(self._GetFileContentsAsString(oldfile), |
| 162 | self._GetFileContentsAsString(newfile)) |
| 163 | |
| 164 | def GenerateDiffDictFromStrings(self, oldjson, newjson=None): |
| 165 | """Generate a dictionary showing the diffs: |
| 166 | old = expectations within oldjson |
| 167 | new = expectations within newjson |
| 168 | |
| 169 | If newfile is not specified, then 'new' is the actual results within |
| 170 | oldfile. |
| 171 | """ |
| 172 | old_results = self._GetExpectedResults(oldjson) |
| 173 | if newjson: |
| 174 | new_results = self._GetExpectedResults(newjson) |
epoger@google.com | 53953b4 | 2013-07-02 20:22:27 +0000 | [diff] [blame] | 175 | else: |
zachr@google.com | 6f8e2c5 | 2013-08-07 15:43:04 +0000 | [diff] [blame] | 176 | new_results = self._GetActualResults(oldjson) |
epoger@google.com | 53953b4 | 2013-07-02 20:22:27 +0000 | [diff] [blame] | 177 | return self._DictionaryDiff(old_results, new_results) |
| 178 | |
| 179 | |
epoger@google.com | 61822a2 | 2013-07-16 18:56:32 +0000 | [diff] [blame] | 180 | def _Main(): |
| 181 | parser = argparse.ArgumentParser() |
| 182 | parser.add_argument( |
| 183 | 'old', |
| 184 | help='Path to JSON file whose expectations to display on ' + |
| 185 | 'the "old" side of the diff. This can be a filepath on ' + |
| 186 | 'local storage, or a URL.') |
| 187 | parser.add_argument( |
| 188 | 'new', nargs='?', |
| 189 | help='Path to JSON file whose expectations to display on ' + |
| 190 | 'the "new" side of the diff; if not specified, uses the ' + |
| 191 | 'ACTUAL results from the "old" JSON file. This can be a ' + |
| 192 | 'filepath on local storage, or a URL.') |
| 193 | args = parser.parse_args() |
| 194 | differ = GMDiffer() |
| 195 | diffs = differ.GenerateDiffDict(oldfile=args.old, newfile=args.new) |
| 196 | json.dump(diffs, sys.stdout, sort_keys=True, indent=2) |
| 197 | |
| 198 | |
| 199 | if __name__ == '__main__': |
| 200 | _Main() |