OLD | NEW |
| (Empty) |
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 Repackage expected/actual GM results as needed by our HTML rebaseline viewer. | |
10 """ | |
11 | |
12 # System-level imports | |
13 import fnmatch | |
14 import os | |
15 import re | |
16 | |
17 # Must fix up PYTHONPATH before importing from within Skia | |
18 import rs_fixpypath # pylint: disable=W0611 | |
19 | |
20 # Imports from within Skia | |
21 import gm_json | |
22 import imagepairset | |
23 | |
24 # Keys used to link an image to a particular GM test. | |
25 # NOTE: Keep these in sync with static/constants.js | |
26 VALUE__HEADER__SCHEMA_VERSION = 5 | |
27 KEY__EXPECTATIONS__BUGS = gm_json.JSONKEY_EXPECTEDRESULTS_BUGS | |
28 KEY__EXPECTATIONS__IGNOREFAILURE = gm_json.JSONKEY_EXPECTEDRESULTS_IGNOREFAILURE | |
29 KEY__EXPECTATIONS__REVIEWED = gm_json.JSONKEY_EXPECTEDRESULTS_REVIEWED | |
30 KEY__EXTRACOLUMNS__BUILDER = 'builder' | |
31 KEY__EXTRACOLUMNS__CONFIG = 'config' | |
32 KEY__EXTRACOLUMNS__RESULT_TYPE = 'resultType' | |
33 KEY__EXTRACOLUMNS__TEST = 'test' | |
34 KEY__HEADER__DATAHASH = 'dataHash' | |
35 KEY__HEADER__IS_EDITABLE = 'isEditable' | |
36 KEY__HEADER__IS_EXPORTED = 'isExported' | |
37 KEY__HEADER__IS_STILL_LOADING = 'resultsStillLoading' | |
38 KEY__HEADER__RESULTS_ALL = 'all' | |
39 KEY__HEADER__RESULTS_FAILURES = 'failures' | |
40 KEY__HEADER__SCHEMA_VERSION = 'schemaVersion' | |
41 KEY__HEADER__SET_A_DESCRIPTIONS = 'setA' | |
42 KEY__HEADER__SET_B_DESCRIPTIONS = 'setB' | |
43 KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE = 'timeNextUpdateAvailable' | |
44 KEY__HEADER__TIME_UPDATED = 'timeUpdated' | |
45 KEY__HEADER__TYPE = 'type' | |
46 KEY__RESULT_TYPE__FAILED = gm_json.JSONKEY_ACTUALRESULTS_FAILED | |
47 KEY__RESULT_TYPE__FAILUREIGNORED = gm_json.JSONKEY_ACTUALRESULTS_FAILUREIGNORED | |
48 KEY__RESULT_TYPE__NOCOMPARISON = gm_json.JSONKEY_ACTUALRESULTS_NOCOMPARISON | |
49 KEY__RESULT_TYPE__SUCCEEDED = gm_json.JSONKEY_ACTUALRESULTS_SUCCEEDED | |
50 KEY__SET_DESCRIPTIONS__DIR = 'dir' | |
51 KEY__SET_DESCRIPTIONS__REPO_REVISION = 'repoRevision' | |
52 KEY__SET_DESCRIPTIONS__SECTION = 'section' | |
53 | |
54 IMAGE_FILENAME_RE = re.compile(gm_json.IMAGE_FILENAME_PATTERN) | |
55 IMAGE_FILENAME_FORMATTER = '%s_%s.png' # pass in (testname, config) | |
56 | |
57 PARENT_DIRECTORY = os.path.dirname(os.path.realpath(__file__)) | |
58 DEFAULT_ACTUALS_DIR = '.gm-actuals' | |
59 DEFAULT_GENERATED_IMAGES_ROOT = os.path.join( | |
60 PARENT_DIRECTORY, '.generated-images') | |
61 | |
62 # Define the default set of builders we will process expectations/actuals for. | |
63 # This allows us to ignore builders for which we don't maintain expectations | |
64 # (trybots, Valgrind, ASAN, TSAN), and avoid problems like | |
65 # https://code.google.com/p/skia/issues/detail?id=2036 ('rebaseline_server | |
66 # produces error when trying to add baselines for ASAN/TSAN builders') | |
67 DEFAULT_MATCH_BUILDERS_PATTERN_LIST = ['.*'] | |
68 DEFAULT_SKIP_BUILDERS_PATTERN_LIST = [ | |
69 '.*-Trybot', '.*Valgrind.*', '.*TSAN.*', '.*ASAN.*'] | |
70 | |
71 | |
72 class BaseComparisons(object): | |
73 """Base class for generating summary of comparisons between two image sets. | |
74 """ | |
75 | |
76 def __init__(self): | |
77 """Base constructor; most subclasses will override.""" | |
78 self._setA_descriptions = None | |
79 self._setB_descriptions = None | |
80 | |
81 def get_results_of_type(self, results_type): | |
82 """Return results of some/all tests (depending on 'results_type' parameter). | |
83 | |
84 Args: | |
85 results_type: string describing which types of results to include; must | |
86 be one of the RESULTS_* constants | |
87 | |
88 Results are returned in a dictionary as output by ImagePairSet.as_dict(). | |
89 """ | |
90 return self._results[results_type] | |
91 | |
92 def get_packaged_results_of_type(self, results_type, reload_seconds=None, | |
93 is_editable=False, is_exported=True): | |
94 """Package the results of some/all tests as a complete response_dict. | |
95 | |
96 Args: | |
97 results_type: string indicating which set of results to return; | |
98 must be one of the RESULTS_* constants | |
99 reload_seconds: if specified, note that new results may be available once | |
100 these results are reload_seconds old | |
101 is_editable: whether clients are allowed to submit new baselines | |
102 is_exported: whether these results are being made available to other | |
103 network hosts | |
104 """ | |
105 response_dict = self._results[results_type] | |
106 time_updated = self.get_timestamp() | |
107 header_dict = { | |
108 KEY__HEADER__SCHEMA_VERSION: ( | |
109 VALUE__HEADER__SCHEMA_VERSION), | |
110 | |
111 # Timestamps: | |
112 # 1. when this data was last updated | |
113 # 2. when the caller should check back for new data (if ever) | |
114 KEY__HEADER__TIME_UPDATED: time_updated, | |
115 KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE: ( | |
116 (time_updated+reload_seconds) if reload_seconds else None), | |
117 | |
118 # The type we passed to get_results_of_type() | |
119 KEY__HEADER__TYPE: results_type, | |
120 | |
121 # Hash of dataset, which the client must return with any edits-- | |
122 # this ensures that the edits were made to a particular dataset. | |
123 KEY__HEADER__DATAHASH: str(hash(repr( | |
124 response_dict[imagepairset.KEY__ROOT__IMAGEPAIRS]))), | |
125 | |
126 # Whether the server will accept edits back. | |
127 KEY__HEADER__IS_EDITABLE: is_editable, | |
128 | |
129 # Whether the service is accessible from other hosts. | |
130 KEY__HEADER__IS_EXPORTED: is_exported, | |
131 } | |
132 if self._setA_descriptions: | |
133 header_dict[KEY__HEADER__SET_A_DESCRIPTIONS] = self._setA_descriptions | |
134 if self._setB_descriptions: | |
135 header_dict[KEY__HEADER__SET_B_DESCRIPTIONS] = self._setB_descriptions | |
136 response_dict[imagepairset.KEY__ROOT__HEADER] = header_dict | |
137 return response_dict | |
138 | |
139 def get_timestamp(self): | |
140 """Return the time at which this object was created, in seconds past epoch | |
141 (UTC). | |
142 """ | |
143 return self._timestamp | |
144 | |
145 _match_builders_pattern_list = [ | |
146 re.compile(p) for p in DEFAULT_MATCH_BUILDERS_PATTERN_LIST] | |
147 _skip_builders_pattern_list = [ | |
148 re.compile(p) for p in DEFAULT_SKIP_BUILDERS_PATTERN_LIST] | |
149 | |
150 def set_match_builders_pattern_list(self, pattern_list): | |
151 """Override the default set of builders we should process. | |
152 | |
153 The default is DEFAULT_MATCH_BUILDERS_PATTERN_LIST . | |
154 | |
155 Note that skip_builders_pattern_list overrides this; regardless of whether a | |
156 builder is in the "match" list, if it's in the "skip" list, we will skip it. | |
157 | |
158 Args: | |
159 pattern_list: list of regex patterns; process builders that match any | |
160 entry within this list | |
161 """ | |
162 if pattern_list == None: | |
163 pattern_list = [] | |
164 self._match_builders_pattern_list = [re.compile(p) for p in pattern_list] | |
165 | |
166 def set_skip_builders_pattern_list(self, pattern_list): | |
167 """Override the default set of builders we should skip while processing. | |
168 | |
169 The default is DEFAULT_SKIP_BUILDERS_PATTERN_LIST . | |
170 | |
171 This overrides match_builders_pattern_list; regardless of whether a | |
172 builder is in the "match" list, if it's in the "skip" list, we will skip it. | |
173 | |
174 Args: | |
175 pattern_list: list of regex patterns; skip builders that match any | |
176 entry within this list | |
177 """ | |
178 if pattern_list == None: | |
179 pattern_list = [] | |
180 self._skip_builders_pattern_list = [re.compile(p) for p in pattern_list] | |
181 | |
182 def _ignore_builder(self, builder): | |
183 """Returns True if we should skip processing this builder. | |
184 | |
185 Args: | |
186 builder: name of this builder, as a string | |
187 | |
188 Returns: | |
189 True if we should ignore expectations and actuals for this builder. | |
190 """ | |
191 for pattern in self._skip_builders_pattern_list: | |
192 if pattern.match(builder): | |
193 return True | |
194 for pattern in self._match_builders_pattern_list: | |
195 if pattern.match(builder): | |
196 return False | |
197 return True | |
198 | |
199 def _read_builder_dicts_from_root(self, root, pattern='*.json'): | |
200 """Read all JSON dictionaries within a directory tree. | |
201 | |
202 Skips any dictionaries belonging to a builder we have chosen to ignore. | |
203 | |
204 Args: | |
205 root: path to root of directory tree | |
206 pattern: which files to read within root (fnmatch-style pattern) | |
207 | |
208 Returns: | |
209 A meta-dictionary containing all the JSON dictionaries found within | |
210 the directory tree, keyed by builder name (the basename of the directory | |
211 where each JSON dictionary was found). | |
212 | |
213 Raises: | |
214 IOError if root does not refer to an existing directory | |
215 """ | |
216 # I considered making this call read_dicts_from_root(), but I decided | |
217 # it was better to prune out the ignored builders within the os.walk(). | |
218 if not os.path.isdir(root): | |
219 raise IOError('no directory found at path %s' % root) | |
220 meta_dict = {} | |
221 for dirpath, _, filenames in os.walk(root): | |
222 for matching_filename in fnmatch.filter(filenames, pattern): | |
223 builder = os.path.basename(dirpath) | |
224 if self._ignore_builder(builder): | |
225 continue | |
226 full_path = os.path.join(dirpath, matching_filename) | |
227 meta_dict[builder] = gm_json.LoadFromFile(full_path) | |
228 return meta_dict | |
229 | |
230 @staticmethod | |
231 def read_dicts_from_root(root, pattern='*.json'): | |
232 """Read all JSON dictionaries within a directory tree. | |
233 | |
234 TODO(stephana): Factor this out into a utility module, as a standalone | |
235 function (not part of a class). | |
236 | |
237 Args: | |
238 root: path to root of directory tree | |
239 pattern: which files to read within root (fnmatch-style pattern) | |
240 | |
241 Returns: | |
242 A meta-dictionary containing all the JSON dictionaries found within | |
243 the directory tree, keyed by the pathname (relative to root) of each JSON | |
244 dictionary. | |
245 | |
246 Raises: | |
247 IOError if root does not refer to an existing directory | |
248 """ | |
249 if not os.path.isdir(root): | |
250 raise IOError('no directory found at path %s' % root) | |
251 meta_dict = {} | |
252 for abs_dirpath, _, filenames in os.walk(root): | |
253 rel_dirpath = os.path.relpath(abs_dirpath, root) | |
254 for matching_filename in fnmatch.filter(filenames, pattern): | |
255 abs_path = os.path.join(abs_dirpath, matching_filename) | |
256 rel_path = os.path.join(rel_dirpath, matching_filename) | |
257 meta_dict[rel_path] = gm_json.LoadFromFile(abs_path) | |
258 return meta_dict | |
259 | |
260 @staticmethod | |
261 def _read_noncomment_lines(path): | |
262 """Return a list of all noncomment lines within a file. | |
263 | |
264 (A "noncomment" line is one that does not start with a '#'.) | |
265 | |
266 Args: | |
267 path: path to file | |
268 """ | |
269 lines = [] | |
270 with open(path, 'r') as fh: | |
271 for line in fh: | |
272 if not line.startswith('#'): | |
273 lines.append(line.strip()) | |
274 return lines | |
275 | |
276 @staticmethod | |
277 def _create_relative_url(hashtype_and_digest, test_name): | |
278 """Returns the URL for this image, relative to GM_ACTUALS_ROOT_HTTP_URL. | |
279 | |
280 If we don't have a record of this image, returns None. | |
281 | |
282 Args: | |
283 hashtype_and_digest: (hash_type, hash_digest) tuple, or None if we | |
284 don't have a record of this image | |
285 test_name: string; name of the GM test that created this image | |
286 """ | |
287 if not hashtype_and_digest: | |
288 return None | |
289 return gm_json.CreateGmRelativeUrl( | |
290 test_name=test_name, | |
291 hash_type=hashtype_and_digest[0], | |
292 hash_digest=hashtype_and_digest[1]) | |
293 | |
294 @staticmethod | |
295 def combine_subdicts(input_dict): | |
296 """ Flatten out a dictionary structure by one level. | |
297 | |
298 Input: | |
299 { | |
300 KEY_A1 : { | |
301 KEY_B1 : VALUE_B1, | |
302 }, | |
303 KEY_A2 : { | |
304 KEY_B2 : VALUE_B2, | |
305 } | |
306 } | |
307 | |
308 Output: | |
309 { | |
310 KEY_B1 : VALUE_B1, | |
311 KEY_B2 : VALUE_B2, | |
312 } | |
313 | |
314 If this would result in any repeated keys, it will raise an Exception. | |
315 """ | |
316 output_dict = {} | |
317 for subdict in input_dict.values(): | |
318 for subdict_key, subdict_value in subdict.iteritems(): | |
319 if subdict_key in output_dict: | |
320 raise Exception('duplicate key %s in combine_subdicts' % subdict_key) | |
321 output_dict[subdict_key] = subdict_value | |
322 return output_dict | |
323 | |
324 @staticmethod | |
325 def get_default(input_dict, default_value, *keys): | |
326 """Returns input_dict[key1][key2][...], or default_value. | |
327 | |
328 If input_dict is None, or any key is missing along the way, this returns | |
329 default_value. | |
330 | |
331 Args: | |
332 input_dict: dictionary to look within | |
333 key: key indicating which value to return from input_dict | |
334 default_value: value to return if input_dict is None or any key cannot | |
335 be found along the way | |
336 """ | |
337 if input_dict == None: | |
338 return default_value | |
339 for key in keys: | |
340 input_dict = input_dict.get(key, None) | |
341 if input_dict == None: | |
342 return default_value | |
343 return input_dict | |
OLD | NEW |