Home | History | Annotate | Download | only in tools
      1 #!/usr/bin/python
      2 
      3 '''
      4 Copyright 2012 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 Rebaselines the given GM tests, on all bots and all configurations.
     12 '''
     13 
     14 # System-level imports
     15 import argparse
     16 import os
     17 import re
     18 import subprocess
     19 import sys
     20 import urllib2
     21 
     22 # Imports from within Skia
     23 #
     24 # We need to add the 'gm' directory, so that we can import gm_json.py within
     25 # that directory.  That script allows us to parse the actual-results.json file
     26 # written out by the GM tool.
     27 # Make sure that the 'gm' dir is in the PYTHONPATH, but add it at the *end*
     28 # so any dirs that are already in the PYTHONPATH will be preferred.
     29 #
     30 # This assumes that the 'gm' directory has been checked out as a sibling of
     31 # the 'tools' directory containing this script, which will be the case if
     32 # 'trunk' was checked out as a single unit.
     33 GM_DIRECTORY = os.path.realpath(
     34     os.path.join(os.path.dirname(os.path.dirname(__file__)), 'gm'))
     35 if GM_DIRECTORY not in sys.path:
     36   sys.path.append(GM_DIRECTORY)
     37 import gm_json
     38 
     39 # Mapping of expectations/gm subdir (under
     40 # https://skia.googlecode.com/svn/trunk/expectations/gm/ )
     41 # to builder name (see list at http://108.170.217.252:10117/builders )
     42 SUBDIR_MAPPING = {
     43    'base-shuttle-win7-intel-float':
     44     'Test-Win7-ShuttleA-HD2000-x86-Release',
     45    'base-shuttle-win7-intel-angle':
     46     'Test-Win7-ShuttleA-HD2000-x86-Release-ANGLE',
     47    'base-shuttle-win7-intel-directwrite':
     48     'Test-Win7-ShuttleA-HD2000-x86-Release-DirectWrite',
     49    'base-shuttle_ubuntu12_ati5770':
     50     'Test-Ubuntu12-ShuttleA-ATI5770-x86_64-Release',
     51    'base-macmini':
     52     'Test-Mac10.6-MacMini4.1-GeForce320M-x86-Release',
     53    'base-macmini-lion-float':
     54     'Test-Mac10.7-MacMini4.1-GeForce320M-x86-Release',
     55    'base-android-galaxy-nexus':
     56     'Test-Android-GalaxyNexus-SGX540-Arm7-Debug',
     57    'base-android-nexus-7':
     58     'Test-Android-Nexus7-Tegra3-Arm7-Release',
     59    'base-android-nexus-s':
     60     'Test-Android-NexusS-SGX540-Arm7-Release',
     61    'base-android-xoom':
     62     'Test-Android-Xoom-Tegra2-Arm7-Release',
     63    'base-android-nexus-10':
     64     'Test-Android-Nexus10-MaliT604-Arm7-Release',
     65    'base-android-nexus-4':
     66     'Test-Android-Nexus4-Adreno320-Arm7-Release',
     67 }
     68 
     69 
     70 class _InternalException(Exception):
     71   pass
     72 
     73 # Object that handles exceptions, either raising them immediately or collecting
     74 # them to display later on.
     75 class ExceptionHandler(object):
     76 
     77   # params:
     78   #  keep_going_on_failure: if False, report failures and quit right away;
     79   #                         if True, collect failures until
     80   #                         ReportAllFailures() is called
     81   def __init__(self, keep_going_on_failure=False):
     82     self._keep_going_on_failure = keep_going_on_failure
     83     self._failures_encountered = []
     84     self._exiting = False
     85 
     86   # Exit the program with the given status value.
     87   def _Exit(self, status=1):
     88     self._exiting = True
     89     sys.exit(status)
     90 
     91   # We have encountered an exception; either collect the info and keep going,
     92   # or exit the program right away.
     93   def RaiseExceptionOrContinue(self, e):
     94     # If we are already quitting the program, propagate any exceptions
     95     # so that the proper exit status will be communicated to the shell.
     96     if self._exiting:
     97       raise e
     98 
     99     if self._keep_going_on_failure:
    100       print >> sys.stderr, 'WARNING: swallowing exception %s' % e
    101       self._failures_encountered.append(e)
    102     else:
    103       print >> sys.stderr, e
    104       print >> sys.stderr, (
    105           'Halting at first exception; to keep going, re-run ' +
    106           'with the --keep-going-on-failure option set.')
    107       self._Exit()
    108 
    109   def ReportAllFailures(self):
    110     if self._failures_encountered:
    111       print >> sys.stderr, ('Encountered %d failures (see above).' %
    112                             len(self._failures_encountered))
    113       self._Exit()
    114 
    115 
    116 # Object that rebaselines a JSON expectations file (not individual image files).
    117 class JsonRebaseliner(object):
    118 
    119   # params:
    120   #  expectations_root: root directory of all expectations JSON files
    121   #  expectations_input_filename: filename (under expectations_root) of JSON
    122   #                               expectations file to read; typically
    123   #                               "expected-results.json"
    124   #  expectations_output_filename: filename (under expectations_root) to
    125   #                                which updated expectations should be
    126   #                                written; typically the same as
    127   #                                expectations_input_filename, to overwrite
    128   #                                the old content
    129   #  actuals_base_url: base URL from which to read actual-result JSON files
    130   #  actuals_filename: filename (under actuals_base_url) from which to read a
    131   #                    summary of results; typically "actual-results.json"
    132   #  exception_handler: reference to rebaseline.ExceptionHandler object
    133   #  tests: list of tests to rebaseline, or None if we should rebaseline
    134   #         whatever files the JSON results summary file tells us to
    135   #  configs: which configs to run for each test, or None if we should
    136   #           rebaseline whatever configs the JSON results summary file tells
    137   #           us to
    138   #  add_new: if True, add expectations for tests which don't have any yet
    139   def __init__(self, expectations_root, expectations_input_filename,
    140                expectations_output_filename, actuals_base_url,
    141                actuals_filename, exception_handler,
    142                tests=None, configs=None, add_new=False):
    143     self._expectations_root = expectations_root
    144     self._expectations_input_filename = expectations_input_filename
    145     self._expectations_output_filename = expectations_output_filename
    146     self._tests = tests
    147     self._configs = configs
    148     self._actuals_base_url = actuals_base_url
    149     self._actuals_filename = actuals_filename
    150     self._exception_handler = exception_handler
    151     self._add_new = add_new
    152     self._image_filename_re = re.compile(gm_json.IMAGE_FILENAME_PATTERN)
    153     self._using_svn = os.path.isdir(os.path.join(expectations_root, '.svn'))
    154 
    155   # Executes subprocess.call(cmd).
    156   # Raises an Exception if the command fails.
    157   def _Call(self, cmd):
    158     if subprocess.call(cmd) != 0:
    159       raise _InternalException('error running command: ' + ' '.join(cmd))
    160 
    161   # Returns the full contents of filepath, as a single string.
    162   # If filepath looks like a URL, try to read it that way instead of as
    163   # a path on local storage.
    164   #
    165   # Raises _InternalException if there is a problem.
    166   def _GetFileContents(self, filepath):
    167     if filepath.startswith('http:') or filepath.startswith('https:'):
    168       try:
    169         return urllib2.urlopen(filepath).read()
    170       except urllib2.HTTPError as e:
    171         raise _InternalException('unable to read URL %s: %s' % (
    172             filepath, e))
    173     else:
    174       return open(filepath, 'r').read()
    175 
    176   # Returns a dictionary of actual results from actual-results.json file.
    177   #
    178   # The dictionary returned has this format:
    179   # {
    180   #  u'imageblur_565.png': [u'bitmap-64bitMD5', 3359963596899141322],
    181   #  u'imageblur_8888.png': [u'bitmap-64bitMD5', 4217923806027861152],
    182   #  u'shadertext3_8888.png': [u'bitmap-64bitMD5', 3713708307125704716]
    183   # }
    184   #
    185   # If the JSON actual result summary file cannot be loaded, logs a warning
    186   # message and returns None.
    187   # If the JSON actual result summary file can be loaded, but we have
    188   # trouble parsing it, raises an Exception.
    189   #
    190   # params:
    191   #  json_url: URL pointing to a JSON actual result summary file
    192   #  sections: a list of section names to include in the results, e.g.
    193   #            [gm_json.JSONKEY_ACTUALRESULTS_FAILED,
    194   #             gm_json.JSONKEY_ACTUALRESULTS_NOCOMPARISON] ;
    195   #            if None, then include ALL sections.
    196   def _GetActualResults(self, json_url, sections=None):
    197     try:
    198       json_contents = self._GetFileContents(json_url)
    199     except _InternalException:
    200       print >> sys.stderr, (
    201           'could not read json_url %s ; skipping this platform.' %
    202           json_url)
    203       return None
    204     json_dict = gm_json.LoadFromString(json_contents)
    205     results_to_return = {}
    206     actual_results = json_dict[gm_json.JSONKEY_ACTUALRESULTS]
    207     if not sections:
    208       sections = actual_results.keys()
    209     for section in sections:
    210       section_results = actual_results[section]
    211       if section_results:
    212         results_to_return.update(section_results)
    213     return results_to_return
    214 
    215   # Rebaseline all tests/types we specified in the constructor,
    216   # within this expectations/gm subdir.
    217   #
    218   # params:
    219   #  subdir : e.g. 'base-shuttle-win7-intel-float'
    220   #  builder : e.g. 'Test-Win7-ShuttleA-HD2000-x86-Release'
    221   def RebaselineSubdir(self, subdir, builder):
    222     # Read in the actual result summary, and extract all the tests whose
    223     # results we need to update.
    224     actuals_url = '/'.join([self._actuals_base_url,
    225                             subdir, builder, subdir,
    226                             self._actuals_filename])
    227     # In most cases, we won't need to re-record results that are already
    228     # succeeding, but including the SUCCEEDED results will allow us to
    229     # re-record expectations if they somehow get out of sync.
    230     sections = [gm_json.JSONKEY_ACTUALRESULTS_FAILED,
    231                 gm_json.JSONKEY_ACTUALRESULTS_SUCCEEDED]
    232     if self._add_new:
    233       sections.append(gm_json.JSONKEY_ACTUALRESULTS_NOCOMPARISON)
    234     results_to_update = self._GetActualResults(json_url=actuals_url,
    235                                                sections=sections)
    236 
    237     # Read in current expectations.
    238     expectations_input_filepath = os.path.join(
    239         self._expectations_root, subdir, self._expectations_input_filename)
    240     expectations_dict = gm_json.LoadFromFile(expectations_input_filepath)
    241     expected_results = expectations_dict[gm_json.JSONKEY_EXPECTEDRESULTS]
    242 
    243     # Update the expectations in memory, skipping any tests/configs that
    244     # the caller asked to exclude.
    245     skipped_images = []
    246     if results_to_update:
    247       for (image_name, image_results) in results_to_update.iteritems():
    248         (test, config) = self._image_filename_re.match(image_name).groups()
    249         if self._tests:
    250           if test not in self._tests:
    251             skipped_images.append(image_name)
    252             continue
    253         if self._configs:
    254           if config not in self._configs:
    255             skipped_images.append(image_name)
    256             continue
    257         if not expected_results.get(image_name):
    258           expected_results[image_name] = {}
    259         expected_results[image_name][gm_json.JSONKEY_EXPECTEDRESULTS_ALLOWEDDIGESTS] = \
    260                         [image_results]
    261 
    262     # Write out updated expectations.
    263     expectations_output_filepath = os.path.join(
    264         self._expectations_root, subdir, self._expectations_output_filename)
    265     gm_json.WriteToFile(expectations_dict, expectations_output_filepath)
    266 
    267     # Mark the JSON file as plaintext, so text-style diffs can be applied.
    268     # Fixes https://code.google.com/p/skia/issues/detail?id=1442
    269     if self._using_svn:
    270       self._Call(['svn', 'propset', '--quiet', 'svn:mime-type',
    271                   'text/x-json', expectations_output_filepath])
    272 
    273 # main...
    274 
    275 parser = argparse.ArgumentParser()
    276 parser.add_argument('--actuals-base-url',
    277                     help='base URL from which to read files containing JSON ' +
    278                     'summaries of actual GM results; defaults to %(default)s',
    279                     default='http://skia-autogen.googlecode.com/svn/gm-actual')
    280 parser.add_argument('--actuals-filename',
    281                     help='filename (within platform-specific subdirectories ' +
    282                     'of ACTUALS_BASE_URL) to read a summary of results from; ' +
    283                     'defaults to %(default)s',
    284                     default='actual-results.json')
    285 # TODO(epoger): Add test that exercises --add-new argument.
    286 parser.add_argument('--add-new', action='store_true',
    287                     help='in addition to the standard behavior of ' +
    288                     'updating expectations for failing tests, add ' +
    289                     'expectations for tests which don\'t have expectations ' +
    290                     'yet.')
    291 # TODO(epoger): Add test that exercises --configs argument.
    292 parser.add_argument('--configs', metavar='CONFIG', nargs='+',
    293                     help='which configurations to rebaseline, e.g. ' +
    294                     '"--configs 565 8888", as a filter over the full set of ' +
    295                     'results in ACTUALS_FILENAME; if unspecified, rebaseline ' +
    296                     '*all* configs that are available.')
    297 parser.add_argument('--expectations-filename',
    298                     help='filename (under EXPECTATIONS_ROOT) to read ' +
    299                     'current expectations from, and to write new ' +
    300                     'expectations into (unless a separate ' +
    301                     'EXPECTATIONS_FILENAME_OUTPUT has been specified); ' +
    302                     'defaults to %(default)s',
    303                     default='expected-results.json')
    304 parser.add_argument('--expectations-filename-output',
    305                     help='filename (under EXPECTATIONS_ROOT) to write ' +
    306                     'updated expectations into; by default, overwrites the ' +
    307                     'input file (EXPECTATIONS_FILENAME)',
    308                     default='')
    309 parser.add_argument('--expectations-root',
    310                     help='root of expectations directory to update-- should ' +
    311                     'contain one or more base-* subdirectories. Defaults to ' +
    312                     '%(default)s',
    313                     default=os.path.join('expectations', 'gm'))
    314 parser.add_argument('--keep-going-on-failure', action='store_true',
    315                     help='instead of halting at the first error encountered, ' +
    316                     'keep going and rebaseline as many tests as possible, ' +
    317                     'and then report the full set of errors at the end')
    318 parser.add_argument('--subdirs', metavar='SUBDIR', nargs='+',
    319                     help='which platform subdirectories to rebaseline; ' +
    320                     'if unspecified, rebaseline all subdirs, same as ' +
    321                     '"--subdirs %s"' % ' '.join(sorted(SUBDIR_MAPPING.keys())))
    322 # TODO(epoger): Add test that exercises --tests argument.
    323 parser.add_argument('--tests', metavar='TEST', nargs='+',
    324                     help='which tests to rebaseline, e.g. ' +
    325                     '"--tests aaclip bigmatrix", as a filter over the full ' +
    326                     'set of results in ACTUALS_FILENAME; if unspecified, ' +
    327                     'rebaseline *all* tests that are available.')
    328 args = parser.parse_args()
    329 exception_handler = ExceptionHandler(
    330     keep_going_on_failure=args.keep_going_on_failure)
    331 if args.subdirs:
    332   subdirs = args.subdirs
    333   missing_json_is_fatal = True
    334 else:
    335   subdirs = sorted(SUBDIR_MAPPING.keys())
    336   missing_json_is_fatal = False
    337 for subdir in subdirs:
    338   if not subdir in SUBDIR_MAPPING.keys():
    339     raise Exception(('unrecognized platform subdir "%s"; ' +
    340                      'should be one of %s') % (
    341                          subdir, SUBDIR_MAPPING.keys()))
    342   builder = SUBDIR_MAPPING[subdir]
    343 
    344   # We instantiate different Rebaseliner objects depending
    345   # on whether we are rebaselining an expected-results.json file, or
    346   # individual image files.  Different expectations/gm subdirectories may move
    347   # from individual image files to JSON-format expectations at different
    348   # times, so we need to make this determination per subdirectory.
    349   #
    350   # See https://goto.google.com/ChecksumTransitionDetail
    351   expectations_json_file = os.path.join(args.expectations_root, subdir,
    352                                         args.expectations_filename)
    353   if os.path.isfile(expectations_json_file):
    354     rebaseliner = JsonRebaseliner(
    355         expectations_root=args.expectations_root,
    356         expectations_input_filename=args.expectations_filename,
    357         expectations_output_filename=(args.expectations_filename_output or
    358                                       args.expectations_filename),
    359         tests=args.tests, configs=args.configs,
    360         actuals_base_url=args.actuals_base_url,
    361         actuals_filename=args.actuals_filename,
    362         exception_handler=exception_handler,
    363         add_new=args.add_new)
    364     try:
    365       rebaseliner.RebaselineSubdir(subdir=subdir, builder=builder)
    366     except BaseException as e:
    367       exception_handler.RaiseExceptionOrContinue(e)
    368   else:
    369     exception_handler.RaiseExceptionOrContinue(_InternalException(
    370         'expectations_json_file %s not found' % expectations_json_file))
    371 
    372 exception_handler.ReportAllFailures()
    373