blob: bf412b3bc7a88fd2f7097fcc326e8237e7a4d027 [file] [log] [blame]
senorblanco@chromium.org782f3b42012-10-29 18:06:26 +00001#!/usr/bin/python
2
3'''
4Copyright 2012 Google Inc.
5
6Use of this source code is governed by a BSD-style license that can be
7found in the LICENSE file.
8'''
9
10'''
senorblanco@chromium.org123a0b52012-11-29 21:50:34 +000011Rebaselines the given GM tests, on all bots and all configurations.
senorblanco@chromium.org782f3b42012-10-29 18:06:26 +000012'''
13
epoger@google.com99ba65a2013-06-05 15:43:37 +000014# System-level imports
epoger@google.com9166bf52013-05-30 15:46:19 +000015import argparse
epoger@google.comfd040112013-08-20 16:21:55 +000016import json
epoger@google.comec3397b2013-05-29 17:09:43 +000017import os
epoger@google.come78d2072013-06-12 17:44:14 +000018import re
epoger@google.com27e1c002013-07-24 15:38:39 +000019import subprocess
epoger@google.comec3397b2013-05-29 17:09:43 +000020import sys
epoger@google.com99ba65a2013-06-05 15:43:37 +000021import urllib2
22
23# Imports from within Skia
24#
epoger@google.comdad53102013-06-12 14:25:30 +000025# We need to add the 'gm' directory, so that we can import gm_json.py within
26# that directory. That script allows us to parse the actual-results.json file
27# written out by the GM tool.
28# Make sure that the 'gm' dir is in the PYTHONPATH, but add it at the *end*
29# so any dirs that are already in the PYTHONPATH will be preferred.
30#
31# This assumes that the 'gm' directory has been checked out as a sibling of
32# the 'tools' directory containing this script, which will be the case if
33# 'trunk' was checked out as a single unit.
epoger@google.com99ba65a2013-06-05 15:43:37 +000034GM_DIRECTORY = os.path.realpath(
35 os.path.join(os.path.dirname(os.path.dirname(__file__)), 'gm'))
36if GM_DIRECTORY not in sys.path:
epoger@google.com2a192a82013-08-02 20:54:46 +000037 sys.path.append(GM_DIRECTORY)
epoger@google.comfd040112013-08-20 16:21:55 +000038import buildbot_globals
epoger@google.com99ba65a2013-06-05 15:43:37 +000039import gm_json
40
epoger@google.comc192aa42013-08-21 17:35:59 +000041MASTER_HOST_URL = 'http://%s:%s' % (
42 buildbot_globals.Get('public_master_host'),
43 buildbot_globals.Get('public_external_port'))
epoger@google.comfd040112013-08-20 16:21:55 +000044ALL_BUILDERS = list(json.load(urllib2.urlopen(
45 MASTER_HOST_URL + '/json/builders')))
46TEST_BUILDERS = filter(lambda x: 'Trybot' not in x and 'Test' in x,
47 ALL_BUILDERS)
epoger@google.com9166bf52013-05-30 15:46:19 +000048
epoger@google.com66ba9f92013-07-11 19:20:30 +000049class _InternalException(Exception):
epoger@google.com2a192a82013-08-02 20:54:46 +000050 pass
epoger@google.comdb29a312013-06-04 14:58:47 +000051
epoger@google.comffcbdbf2013-07-16 17:35:39 +000052# Object that handles exceptions, either raising them immediately or collecting
53# them to display later on.
54class ExceptionHandler(object):
55
epoger@google.com2a192a82013-08-02 20:54:46 +000056 # params:
57 # keep_going_on_failure: if False, report failures and quit right away;
58 # if True, collect failures until
59 # ReportAllFailures() is called
60 def __init__(self, keep_going_on_failure=False):
61 self._keep_going_on_failure = keep_going_on_failure
62 self._failures_encountered = []
63 self._exiting = False
epoger@google.comffcbdbf2013-07-16 17:35:39 +000064
epoger@google.com2a192a82013-08-02 20:54:46 +000065 # Exit the program with the given status value.
66 def _Exit(self, status=1):
67 self._exiting = True
68 sys.exit(status)
epoger@google.comffcbdbf2013-07-16 17:35:39 +000069
epoger@google.com2a192a82013-08-02 20:54:46 +000070 # We have encountered an exception; either collect the info and keep going,
71 # or exit the program right away.
72 def RaiseExceptionOrContinue(self, e):
73 # If we are already quitting the program, propagate any exceptions
74 # so that the proper exit status will be communicated to the shell.
75 if self._exiting:
76 raise e
epoger@google.comffcbdbf2013-07-16 17:35:39 +000077
epoger@google.com2a192a82013-08-02 20:54:46 +000078 if self._keep_going_on_failure:
79 print >> sys.stderr, 'WARNING: swallowing exception %s' % e
80 self._failures_encountered.append(e)
81 else:
82 print >> sys.stderr, e
83 print >> sys.stderr, (
84 'Halting at first exception; to keep going, re-run ' +
85 'with the --keep-going-on-failure option set.')
86 self._Exit()
epoger@google.comffcbdbf2013-07-16 17:35:39 +000087
epoger@google.com2a192a82013-08-02 20:54:46 +000088 def ReportAllFailures(self):
89 if self._failures_encountered:
90 print >> sys.stderr, ('Encountered %d failures (see above).' %
91 len(self._failures_encountered))
92 self._Exit()
epoger@google.comffcbdbf2013-07-16 17:35:39 +000093
94
epoger@google.com99a8ec92013-06-19 18:56:59 +000095# Object that rebaselines a JSON expectations file (not individual image files).
epoger@google.com99a8ec92013-06-19 18:56:59 +000096class JsonRebaseliner(object):
epoger@google.com9166bf52013-05-30 15:46:19 +000097
epoger@google.com2a192a82013-08-02 20:54:46 +000098 # params:
99 # expectations_root: root directory of all expectations JSON files
100 # expectations_input_filename: filename (under expectations_root) of JSON
101 # expectations file to read; typically
102 # "expected-results.json"
103 # expectations_output_filename: filename (under expectations_root) to
104 # which updated expectations should be
105 # written; typically the same as
106 # expectations_input_filename, to overwrite
107 # the old content
108 # actuals_base_url: base URL from which to read actual-result JSON files
109 # actuals_filename: filename (under actuals_base_url) from which to read a
110 # summary of results; typically "actual-results.json"
111 # exception_handler: reference to rebaseline.ExceptionHandler object
112 # tests: list of tests to rebaseline, or None if we should rebaseline
113 # whatever files the JSON results summary file tells us to
114 # configs: which configs to run for each test, or None if we should
115 # rebaseline whatever configs the JSON results summary file tells
116 # us to
117 # add_new: if True, add expectations for tests which don't have any yet
118 def __init__(self, expectations_root, expectations_input_filename,
119 expectations_output_filename, actuals_base_url,
120 actuals_filename, exception_handler,
121 tests=None, configs=None, add_new=False):
122 self._expectations_root = expectations_root
123 self._expectations_input_filename = expectations_input_filename
124 self._expectations_output_filename = expectations_output_filename
125 self._tests = tests
126 self._configs = configs
127 self._actuals_base_url = actuals_base_url
128 self._actuals_filename = actuals_filename
129 self._exception_handler = exception_handler
130 self._add_new = add_new
131 self._image_filename_re = re.compile(gm_json.IMAGE_FILENAME_PATTERN)
132 self._using_svn = os.path.isdir(os.path.join(expectations_root, '.svn'))
epoger@google.com27e1c002013-07-24 15:38:39 +0000133
epoger@google.com2a192a82013-08-02 20:54:46 +0000134 # Executes subprocess.call(cmd).
135 # Raises an Exception if the command fails.
136 def _Call(self, cmd):
137 if subprocess.call(cmd) != 0:
138 raise _InternalException('error running command: ' + ' '.join(cmd))
epoger@google.com9166bf52013-05-30 15:46:19 +0000139
epoger@google.com2a192a82013-08-02 20:54:46 +0000140 # Returns the full contents of filepath, as a single string.
141 # If filepath looks like a URL, try to read it that way instead of as
142 # a path on local storage.
143 #
144 # Raises _InternalException if there is a problem.
145 def _GetFileContents(self, filepath):
146 if filepath.startswith('http:') or filepath.startswith('https:'):
147 try:
148 return urllib2.urlopen(filepath).read()
149 except urllib2.HTTPError as e:
150 raise _InternalException('unable to read URL %s: %s' % (
151 filepath, e))
152 else:
153 return open(filepath, 'r').read()
epoger@google.com99ba65a2013-06-05 15:43:37 +0000154
epoger@google.com2a192a82013-08-02 20:54:46 +0000155 # Returns a dictionary of actual results from actual-results.json file.
156 #
157 # The dictionary returned has this format:
158 # {
159 # u'imageblur_565.png': [u'bitmap-64bitMD5', 3359963596899141322],
160 # u'imageblur_8888.png': [u'bitmap-64bitMD5', 4217923806027861152],
161 # u'shadertext3_8888.png': [u'bitmap-64bitMD5', 3713708307125704716]
162 # }
163 #
164 # If the JSON actual result summary file cannot be loaded, logs a warning
165 # message and returns None.
166 # If the JSON actual result summary file can be loaded, but we have
167 # trouble parsing it, raises an Exception.
168 #
169 # params:
170 # json_url: URL pointing to a JSON actual result summary file
171 # sections: a list of section names to include in the results, e.g.
172 # [gm_json.JSONKEY_ACTUALRESULTS_FAILED,
173 # gm_json.JSONKEY_ACTUALRESULTS_NOCOMPARISON] ;
174 # if None, then include ALL sections.
175 def _GetActualResults(self, json_url, sections=None):
176 try:
177 json_contents = self._GetFileContents(json_url)
178 except _InternalException:
179 print >> sys.stderr, (
180 'could not read json_url %s ; skipping this platform.' %
181 json_url)
182 return None
183 json_dict = gm_json.LoadFromString(json_contents)
184 results_to_return = {}
185 actual_results = json_dict[gm_json.JSONKEY_ACTUALRESULTS]
186 if not sections:
187 sections = actual_results.keys()
188 for section in sections:
189 section_results = actual_results[section]
190 if section_results:
191 results_to_return.update(section_results)
192 return results_to_return
epoger@google.come78d2072013-06-12 17:44:14 +0000193
epoger@google.com2a192a82013-08-02 20:54:46 +0000194 # Rebaseline all tests/types we specified in the constructor,
epoger@google.comfd040112013-08-20 16:21:55 +0000195 # within this builder's subdirectory in expectations/gm .
epoger@google.com2a192a82013-08-02 20:54:46 +0000196 #
197 # params:
epoger@google.com2a192a82013-08-02 20:54:46 +0000198 # builder : e.g. 'Test-Win7-ShuttleA-HD2000-x86-Release'
epoger@google.comfd040112013-08-20 16:21:55 +0000199 def RebaselineSubdir(self, builder):
epoger@google.com2a192a82013-08-02 20:54:46 +0000200 # Read in the actual result summary, and extract all the tests whose
201 # results we need to update.
202 actuals_url = '/'.join([self._actuals_base_url,
epoger@google.comfd040112013-08-20 16:21:55 +0000203 builder, self._actuals_filename])
epoger@google.com2a192a82013-08-02 20:54:46 +0000204 # In most cases, we won't need to re-record results that are already
205 # succeeding, but including the SUCCEEDED results will allow us to
206 # re-record expectations if they somehow get out of sync.
207 sections = [gm_json.JSONKEY_ACTUALRESULTS_FAILED,
208 gm_json.JSONKEY_ACTUALRESULTS_SUCCEEDED]
209 if self._add_new:
210 sections.append(gm_json.JSONKEY_ACTUALRESULTS_NOCOMPARISON)
211 results_to_update = self._GetActualResults(json_url=actuals_url,
212 sections=sections)
epoger@google.come78d2072013-06-12 17:44:14 +0000213
epoger@google.com2a192a82013-08-02 20:54:46 +0000214 # Read in current expectations.
215 expectations_input_filepath = os.path.join(
epoger@google.comfd040112013-08-20 16:21:55 +0000216 self._expectations_root, builder, self._expectations_input_filename)
epoger@google.com2a192a82013-08-02 20:54:46 +0000217 expectations_dict = gm_json.LoadFromFile(expectations_input_filepath)
218 expected_results = expectations_dict[gm_json.JSONKEY_EXPECTEDRESULTS]
epoger@google.coma783f2b2013-07-08 17:51:58 +0000219
epoger@google.com2a192a82013-08-02 20:54:46 +0000220 # Update the expectations in memory, skipping any tests/configs that
221 # the caller asked to exclude.
222 skipped_images = []
223 if results_to_update:
224 for (image_name, image_results) in results_to_update.iteritems():
225 (test, config) = self._image_filename_re.match(image_name).groups()
226 if self._tests:
227 if test not in self._tests:
228 skipped_images.append(image_name)
229 continue
230 if self._configs:
231 if config not in self._configs:
232 skipped_images.append(image_name)
233 continue
234 if not expected_results.get(image_name):
235 expected_results[image_name] = {}
236 expected_results[image_name][gm_json.JSONKEY_EXPECTEDRESULTS_ALLOWEDDIGESTS] = \
epoger@google.com61822a22013-07-16 18:56:32 +0000237 [image_results]
epoger@google.coma783f2b2013-07-08 17:51:58 +0000238
epoger@google.com2a192a82013-08-02 20:54:46 +0000239 # Write out updated expectations.
240 expectations_output_filepath = os.path.join(
epoger@google.comfd040112013-08-20 16:21:55 +0000241 self._expectations_root, builder, self._expectations_output_filename)
epoger@google.com2a192a82013-08-02 20:54:46 +0000242 gm_json.WriteToFile(expectations_dict, expectations_output_filepath)
epoger@google.coma783f2b2013-07-08 17:51:58 +0000243
epoger@google.com2a192a82013-08-02 20:54:46 +0000244 # Mark the JSON file as plaintext, so text-style diffs can be applied.
245 # Fixes https://code.google.com/p/skia/issues/detail?id=1442
246 if self._using_svn:
247 self._Call(['svn', 'propset', '--quiet', 'svn:mime-type',
248 'text/x-json', expectations_output_filepath])
epoger@google.comec3397b2013-05-29 17:09:43 +0000249
epoger@google.com9166bf52013-05-30 15:46:19 +0000250# main...
epoger@google.comec3397b2013-05-29 17:09:43 +0000251
epoger@google.com9166bf52013-05-30 15:46:19 +0000252parser = argparse.ArgumentParser()
epoger@google.coma783f2b2013-07-08 17:51:58 +0000253parser.add_argument('--actuals-base-url',
254 help='base URL from which to read files containing JSON ' +
255 'summaries of actual GM results; defaults to %(default)s',
256 default='http://skia-autogen.googlecode.com/svn/gm-actual')
257parser.add_argument('--actuals-filename',
epoger@google.comfd040112013-08-20 16:21:55 +0000258 help='filename (within builder-specific subdirectories ' +
epoger@google.coma783f2b2013-07-08 17:51:58 +0000259 'of ACTUALS_BASE_URL) to read a summary of results from; ' +
260 'defaults to %(default)s',
261 default='actual-results.json')
262# TODO(epoger): Add test that exercises --add-new argument.
epoger@google.comdad53102013-06-12 14:25:30 +0000263parser.add_argument('--add-new', action='store_true',
264 help='in addition to the standard behavior of ' +
265 'updating expectations for failing tests, add ' +
266 'expectations for tests which don\'t have expectations ' +
267 'yet.')
epoger@google.comfd040112013-08-20 16:21:55 +0000268parser.add_argument('--builders', metavar='BUILDER', nargs='+',
269 help='which platforms to rebaseline; ' +
270 'if unspecified, rebaseline all platforms, same as ' +
271 '"--builders %s"' % ' '.join(sorted(TEST_BUILDERS)))
epoger@google.coma783f2b2013-07-08 17:51:58 +0000272# TODO(epoger): Add test that exercises --configs argument.
epoger@google.com9166bf52013-05-30 15:46:19 +0000273parser.add_argument('--configs', metavar='CONFIG', nargs='+',
274 help='which configurations to rebaseline, e.g. ' +
epoger@google.com9de25e32013-07-10 15:27:18 +0000275 '"--configs 565 8888", as a filter over the full set of ' +
276 'results in ACTUALS_FILENAME; if unspecified, rebaseline ' +
277 '*all* configs that are available.')
epoger@google.coma783f2b2013-07-08 17:51:58 +0000278parser.add_argument('--expectations-filename',
279 help='filename (under EXPECTATIONS_ROOT) to read ' +
280 'current expectations from, and to write new ' +
epoger@google.comc60e7452013-07-24 19:36:51 +0000281 'expectations into (unless a separate ' +
282 'EXPECTATIONS_FILENAME_OUTPUT has been specified); ' +
283 'defaults to %(default)s',
epoger@google.coma783f2b2013-07-08 17:51:58 +0000284 default='expected-results.json')
epoger@google.comc60e7452013-07-24 19:36:51 +0000285parser.add_argument('--expectations-filename-output',
286 help='filename (under EXPECTATIONS_ROOT) to write ' +
287 'updated expectations into; by default, overwrites the ' +
288 'input file (EXPECTATIONS_FILENAME)',
289 default='')
epoger@google.com99a8ec92013-06-19 18:56:59 +0000290parser.add_argument('--expectations-root',
291 help='root of expectations directory to update-- should ' +
epoger@google.comfd040112013-08-20 16:21:55 +0000292 'contain one or more builder subdirectories. Defaults to ' +
epoger@google.com99a8ec92013-06-19 18:56:59 +0000293 '%(default)s',
epoger@google.come94a7d22013-07-23 19:37:03 +0000294 default=os.path.join('expectations', 'gm'))
epoger@google.comffcbdbf2013-07-16 17:35:39 +0000295parser.add_argument('--keep-going-on-failure', action='store_true',
296 help='instead of halting at the first error encountered, ' +
297 'keep going and rebaseline as many tests as possible, ' +
298 'and then report the full set of errors at the end')
epoger@google.coma783f2b2013-07-08 17:51:58 +0000299# TODO(epoger): Add test that exercises --tests argument.
epoger@google.com99ba65a2013-06-05 15:43:37 +0000300parser.add_argument('--tests', metavar='TEST', nargs='+',
epoger@google.com9166bf52013-05-30 15:46:19 +0000301 help='which tests to rebaseline, e.g. ' +
epoger@google.com9de25e32013-07-10 15:27:18 +0000302 '"--tests aaclip bigmatrix", as a filter over the full ' +
303 'set of results in ACTUALS_FILENAME; if unspecified, ' +
304 'rebaseline *all* tests that are available.')
epoger@google.com9166bf52013-05-30 15:46:19 +0000305args = parser.parse_args()
epoger@google.comffcbdbf2013-07-16 17:35:39 +0000306exception_handler = ExceptionHandler(
307 keep_going_on_failure=args.keep_going_on_failure)
epoger@google.comfd040112013-08-20 16:21:55 +0000308if args.builders:
309 builders = args.builders
epoger@google.com2a192a82013-08-02 20:54:46 +0000310 missing_json_is_fatal = True
epoger@google.com99a8ec92013-06-19 18:56:59 +0000311else:
epoger@google.comfd040112013-08-20 16:21:55 +0000312 builders = sorted(TEST_BUILDERS)
epoger@google.com2a192a82013-08-02 20:54:46 +0000313 missing_json_is_fatal = False
epoger@google.comfd040112013-08-20 16:21:55 +0000314for builder in builders:
315 if not builder in TEST_BUILDERS:
316 raise Exception(('unrecognized builder "%s"; ' +
epoger@google.com2a192a82013-08-02 20:54:46 +0000317 'should be one of %s') % (
epoger@google.comfd040112013-08-20 16:21:55 +0000318 builder, TEST_BUILDERS))
epoger@google.com99a8ec92013-06-19 18:56:59 +0000319
epoger@google.comfd040112013-08-20 16:21:55 +0000320 expectations_json_file = os.path.join(args.expectations_root, builder,
epoger@google.com2a192a82013-08-02 20:54:46 +0000321 args.expectations_filename)
322 if os.path.isfile(expectations_json_file):
323 rebaseliner = JsonRebaseliner(
324 expectations_root=args.expectations_root,
325 expectations_input_filename=args.expectations_filename,
326 expectations_output_filename=(args.expectations_filename_output or
327 args.expectations_filename),
328 tests=args.tests, configs=args.configs,
329 actuals_base_url=args.actuals_base_url,
330 actuals_filename=args.actuals_filename,
331 exception_handler=exception_handler,
332 add_new=args.add_new)
epoger@google.com3e7399f2013-07-10 17:23:47 +0000333 try:
epoger@google.comfd040112013-08-20 16:21:55 +0000334 rebaseliner.RebaselineSubdir(builder=builder)
epoger@google.com3e7399f2013-07-10 17:23:47 +0000335 except BaseException as e:
epoger@google.com2a192a82013-08-02 20:54:46 +0000336 exception_handler.RaiseExceptionOrContinue(e)
337 else:
338 exception_handler.RaiseExceptionOrContinue(_InternalException(
339 'expectations_json_file %s not found' % expectations_json_file))
epoger@google.comffcbdbf2013-07-16 17:35:39 +0000340
341exception_handler.ReportAllFailures()