Home | History | Annotate | Download | only in rebaseline_server
      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 argparse
     14 import fnmatch
     15 import json
     16 import logging
     17 import os
     18 import re
     19 import sys
     20 import time
     21 
     22 # Imports from within Skia
     23 import fix_pythonpath  # must do this first
     24 from pyutils import url_utils
     25 import gm_json
     26 import imagediffdb
     27 import imagepair
     28 import imagepairset
     29 import results
     30 
     31 EXPECTATION_FIELDS_PASSED_THRU_VERBATIM = [
     32     results.KEY__EXPECTATIONS__BUGS,
     33     results.KEY__EXPECTATIONS__IGNOREFAILURE,
     34     results.KEY__EXPECTATIONS__REVIEWED,
     35 ]
     36 TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
     37 DEFAULT_EXPECTATIONS_DIR = os.path.join(TRUNK_DIRECTORY, 'expectations', 'gm')
     38 DEFAULT_IGNORE_FAILURES_FILE = 'ignored-tests.txt'
     39 
     40 IMAGEPAIR_SET_DESCRIPTIONS = ('expected image', 'actual image')
     41 
     42 
     43 class ExpectationComparisons(results.BaseComparisons):
     44   """Loads actual and expected GM results into an ImagePairSet.
     45 
     46   Loads actual and expected results from all builders, except for those skipped
     47   by _ignore_builder().
     48 
     49   Once this object has been constructed, the results (in self._results[])
     50   are immutable.  If you want to update the results based on updated JSON
     51   file contents, you will need to create a new ExpectationComparisons object."""
     52 
     53   def __init__(self, actuals_root=results.DEFAULT_ACTUALS_DIR,
     54                expected_root=DEFAULT_EXPECTATIONS_DIR,
     55                ignore_failures_file=DEFAULT_IGNORE_FAILURES_FILE,
     56                generated_images_root=results.DEFAULT_GENERATED_IMAGES_ROOT,
     57                diff_base_url=None, builder_regex_list=None):
     58     """
     59     Args:
     60       actuals_root: root directory containing all actual-results.json files
     61       expected_root: root directory containing all expected-results.json files
     62       ignore_failures_file: if a file with this name is found within
     63           expected_root, ignore failures for any tests listed in the file
     64       generated_images_root: directory within which to create all pixel diffs;
     65           if this directory does not yet exist, it will be created
     66       diff_base_url: base URL within which the client should look for diff
     67           images; if not specified, defaults to a "file:///" URL representation
     68           of generated_images_root
     69       builder_regex_list: List of regular expressions specifying which builders
     70           we will process. If None, process all builders.
     71     """
     72     time_start = int(time.time())
     73     if builder_regex_list != None:
     74       self.set_match_builders_pattern_list(builder_regex_list)
     75     self._image_diff_db = imagediffdb.ImageDiffDB(generated_images_root)
     76     self._diff_base_url = (
     77         diff_base_url or
     78         url_utils.create_filepath_url(generated_images_root))
     79     self._actuals_root = actuals_root
     80     self._expected_root = expected_root
     81     self._ignore_failures_on_these_tests = []
     82     if ignore_failures_file:
     83       self._ignore_failures_on_these_tests = (
     84           ExpectationComparisons._read_noncomment_lines(
     85               os.path.join(expected_root, ignore_failures_file)))
     86     self._load_actual_and_expected()
     87     self._timestamp = int(time.time())
     88     logging.info('Results complete; took %d seconds.' %
     89                  (self._timestamp - time_start))
     90 
     91   def edit_expectations(self, modifications):
     92     """Edit the expectations stored within this object and write them back
     93     to disk.
     94 
     95     Note that this will NOT update the results stored in self._results[] ;
     96     in order to see those updates, you must instantiate a new
     97     ExpectationComparisons object based on the (now updated) files on disk.
     98 
     99     Args:
    100       modifications: a list of dictionaries, one for each expectation to update:
    101 
    102          [
    103            {
    104              imagepair.KEY__IMAGEPAIRS__EXPECTATIONS: {
    105                results.KEY__EXPECTATIONS__BUGS: [123, 456],
    106                results.KEY__EXPECTATIONS__IGNOREFAILURE: false,
    107                results.KEY__EXPECTATIONS__REVIEWED: true,
    108              },
    109              imagepair.KEY__IMAGEPAIRS__EXTRACOLUMNS: {
    110                results.KEY__EXTRACOLUMNS__BUILDER: 'Test-Mac10.6-MacMini4.1-GeForce320M-x86-Debug',
    111                results.KEY__EXTRACOLUMNS__CONFIG: '8888',
    112                results.KEY__EXTRACOLUMNS__TEST: 'bigmatrix',
    113              },
    114              results.KEY__IMAGEPAIRS__IMAGE_B_URL: 'bitmap-64bitMD5/bigmatrix/10894408024079689926.png',
    115            },
    116            ...
    117          ]
    118 
    119     """
    120     expected_builder_dicts = self._read_builder_dicts_from_root(
    121         self._expected_root)
    122     for mod in modifications:
    123       image_name = results.IMAGE_FILENAME_FORMATTER % (
    124           mod[imagepair.KEY__IMAGEPAIRS__EXTRACOLUMNS]
    125              [results.KEY__EXTRACOLUMNS__TEST],
    126           mod[imagepair.KEY__IMAGEPAIRS__EXTRACOLUMNS]
    127              [results.KEY__EXTRACOLUMNS__CONFIG])
    128       _, hash_type, hash_digest = gm_json.SplitGmRelativeUrl(
    129           mod[imagepair.KEY__IMAGEPAIRS__IMAGE_B_URL])
    130       allowed_digests = [[hash_type, int(hash_digest)]]
    131       new_expectations = {
    132           gm_json.JSONKEY_EXPECTEDRESULTS_ALLOWEDDIGESTS: allowed_digests,
    133       }
    134       for field in EXPECTATION_FIELDS_PASSED_THRU_VERBATIM:
    135         value = mod[imagepair.KEY__IMAGEPAIRS__EXPECTATIONS].get(field)
    136         if value is not None:
    137           new_expectations[field] = value
    138       builder_dict = expected_builder_dicts[
    139           mod[imagepair.KEY__IMAGEPAIRS__EXTRACOLUMNS]
    140              [results.KEY__EXTRACOLUMNS__BUILDER]]
    141       builder_expectations = builder_dict.get(gm_json.JSONKEY_EXPECTEDRESULTS)
    142       if not builder_expectations:
    143         builder_expectations = {}
    144         builder_dict[gm_json.JSONKEY_EXPECTEDRESULTS] = builder_expectations
    145       builder_expectations[image_name] = new_expectations
    146     ExpectationComparisons._write_dicts_to_root(
    147         expected_builder_dicts, self._expected_root)
    148 
    149   @staticmethod
    150   def _write_dicts_to_root(meta_dict, root, pattern='*.json'):
    151     """Write all per-builder dictionaries within meta_dict to files under
    152     the root path.
    153 
    154     Security note: this will only write to files that already exist within
    155     the root path (as found by os.walk() within root), so we don't need to
    156     worry about malformed content writing to disk outside of root.
    157     However, the data written to those files is not double-checked, so it
    158     could contain poisonous data.
    159 
    160     Args:
    161       meta_dict: a builder-keyed meta-dictionary containing all the JSON
    162                  dictionaries we want to write out
    163       root: path to root of directory tree within which to write files
    164       pattern: which files to write within root (fnmatch-style pattern)
    165 
    166     Raises:
    167       IOError if root does not refer to an existing directory
    168       KeyError if the set of per-builder dictionaries written out was
    169                different than expected
    170     """
    171     if not os.path.isdir(root):
    172       raise IOError('no directory found at path %s' % root)
    173     actual_builders_written = []
    174     for dirpath, dirnames, filenames in os.walk(root):
    175       for matching_filename in fnmatch.filter(filenames, pattern):
    176         builder = os.path.basename(dirpath)
    177         per_builder_dict = meta_dict.get(builder)
    178         if per_builder_dict is not None:
    179           fullpath = os.path.join(dirpath, matching_filename)
    180           gm_json.WriteToFile(per_builder_dict, fullpath)
    181           actual_builders_written.append(builder)
    182 
    183     # Check: did we write out the set of per-builder dictionaries we
    184     # expected to?
    185     expected_builders_written = sorted(meta_dict.keys())
    186     actual_builders_written.sort()
    187     if expected_builders_written != actual_builders_written:
    188       raise KeyError(
    189           'expected to write dicts for builders %s, but actually wrote them '
    190           'for builders %s' % (
    191               expected_builders_written, actual_builders_written))
    192 
    193   def _load_actual_and_expected(self):
    194     """Loads the results of all tests, across all builders (based on the
    195     files within self._actuals_root and self._expected_root),
    196     and stores them in self._results.
    197     """
    198     logging.info('Reading actual-results JSON files from %s...' %
    199                  self._actuals_root)
    200     actual_builder_dicts = self._read_builder_dicts_from_root(
    201         self._actuals_root)
    202     logging.info('Reading expected-results JSON files from %s...' %
    203                  self._expected_root)
    204     expected_builder_dicts = self._read_builder_dicts_from_root(
    205         self._expected_root)
    206 
    207     all_image_pairs = imagepairset.ImagePairSet(
    208         descriptions=IMAGEPAIR_SET_DESCRIPTIONS,
    209         diff_base_url=self._diff_base_url)
    210     failing_image_pairs = imagepairset.ImagePairSet(
    211         descriptions=IMAGEPAIR_SET_DESCRIPTIONS,
    212         diff_base_url=self._diff_base_url)
    213 
    214     all_image_pairs.ensure_extra_column_values_in_summary(
    215         column_id=results.KEY__EXTRACOLUMNS__RESULT_TYPE, values=[
    216             results.KEY__RESULT_TYPE__FAILED,
    217             results.KEY__RESULT_TYPE__FAILUREIGNORED,
    218             results.KEY__RESULT_TYPE__NOCOMPARISON,
    219             results.KEY__RESULT_TYPE__SUCCEEDED,
    220         ])
    221     failing_image_pairs.ensure_extra_column_values_in_summary(
    222         column_id=results.KEY__EXTRACOLUMNS__RESULT_TYPE, values=[
    223             results.KEY__RESULT_TYPE__FAILED,
    224             results.KEY__RESULT_TYPE__FAILUREIGNORED,
    225             results.KEY__RESULT_TYPE__NOCOMPARISON,
    226         ])
    227 
    228     # Only consider builders we have both expected and actual results for.
    229     # Fixes http://skbug.com/2486 ('rebaseline_server shows actual results
    230     # (but not expectations) for Test-Ubuntu12-ShuttleA-NoGPU-x86_64-Debug
    231     # builder')
    232     actual_builder_set = set(actual_builder_dicts.keys())
    233     expected_builder_set = set(expected_builder_dicts.keys())
    234     builders = sorted(actual_builder_set.intersection(expected_builder_set))
    235 
    236     num_builders = len(builders)
    237     builder_num = 0
    238     for builder in builders:
    239       builder_num += 1
    240       logging.info('Generating pixel diffs for builder #%d of %d, "%s"...' %
    241                    (builder_num, num_builders, builder))
    242       actual_results_for_this_builder = (
    243           actual_builder_dicts[builder][gm_json.JSONKEY_ACTUALRESULTS])
    244       for result_type in sorted(actual_results_for_this_builder.keys()):
    245         results_of_this_type = actual_results_for_this_builder[result_type]
    246         if not results_of_this_type:
    247           continue
    248         for image_name in sorted(results_of_this_type.keys()):
    249           (test, config) = results.IMAGE_FILENAME_RE.match(image_name).groups()
    250           actual_image_relative_url = (
    251               ExpectationComparisons._create_relative_url(
    252                   hashtype_and_digest=results_of_this_type[image_name],
    253                   test_name=test))
    254 
    255           # Default empty expectations; overwrite these if we find any real ones
    256           expectations_per_test = None
    257           expected_image_relative_url = None
    258           expectations_dict = None
    259           try:
    260             expectations_per_test = (
    261                 expected_builder_dicts
    262                 [builder][gm_json.JSONKEY_EXPECTEDRESULTS][image_name])
    263             # TODO(epoger): assumes a single allowed digest per test, which is
    264             # fine; see https://code.google.com/p/skia/issues/detail?id=1787
    265             expected_image_hashtype_and_digest = (
    266                 expectations_per_test
    267                 [gm_json.JSONKEY_EXPECTEDRESULTS_ALLOWEDDIGESTS][0])
    268             expected_image_relative_url = (
    269                 ExpectationComparisons._create_relative_url(
    270                     hashtype_and_digest=expected_image_hashtype_and_digest,
    271                     test_name=test))
    272             expectations_dict = {}
    273             for field in EXPECTATION_FIELDS_PASSED_THRU_VERBATIM:
    274               expectations_dict[field] = expectations_per_test.get(field)
    275           except (KeyError, TypeError):
    276             # There are several cases in which we would expect to find
    277             # no expectations for a given test:
    278             #
    279             # 1. result_type == NOCOMPARISON
    280             #   There are no expectations for this test yet!
    281             #
    282             # 2. alternate rendering mode failures (e.g. serialized)
    283             #   In cases like
    284             #   https://code.google.com/p/skia/issues/detail?id=1684
    285             #   ('tileimagefilter GM test failing in serialized render mode'),
    286             #   the gm-actuals will list a failure for the alternate
    287             #   rendering mode even though we don't have explicit expectations
    288             #   for the test (the implicit expectation is that it must
    289             #   render the same in all rendering modes).
    290             #
    291             # Don't log type 1, because it is common.
    292             # Log other types, because they are rare and we should know about
    293             # them, but don't throw an exception, because we need to keep our
    294             # tools working in the meanwhile!
    295             if result_type != results.KEY__RESULT_TYPE__NOCOMPARISON:
    296               logging.warning('No expectations found for test: %s' % {
    297                   results.KEY__EXTRACOLUMNS__BUILDER: builder,
    298                   results.KEY__EXTRACOLUMNS__RESULT_TYPE: result_type,
    299                   'image_name': image_name,
    300                   })
    301 
    302           # If this test was recently rebaselined, it will remain in
    303           # the 'failed' set of actuals until all the bots have
    304           # cycled (although the expectations have indeed been set
    305           # from the most recent actuals).  Treat these as successes
    306           # instead of failures.
    307           #
    308           # TODO(epoger): Do we need to do something similar in
    309           # other cases, such as when we have recently marked a test
    310           # as ignoreFailure but it still shows up in the 'failed'
    311           # category?  Maybe we should not rely on the result_type
    312           # categories recorded within the gm_actuals AT ALL, and
    313           # instead evaluate the result_type ourselves based on what
    314           # we see in expectations vs actual checksum?
    315           if expected_image_relative_url == actual_image_relative_url:
    316             updated_result_type = results.KEY__RESULT_TYPE__SUCCEEDED
    317           elif ((result_type == results.KEY__RESULT_TYPE__FAILED) and
    318                 (test in self._ignore_failures_on_these_tests)):
    319             updated_result_type = results.KEY__RESULT_TYPE__FAILUREIGNORED
    320           else:
    321             updated_result_type = result_type
    322           extra_columns_dict = {
    323               results.KEY__EXTRACOLUMNS__RESULT_TYPE: updated_result_type,
    324               results.KEY__EXTRACOLUMNS__BUILDER: builder,
    325               results.KEY__EXTRACOLUMNS__TEST: test,
    326               results.KEY__EXTRACOLUMNS__CONFIG: config,
    327           }
    328           try:
    329             image_pair = imagepair.ImagePair(
    330                 image_diff_db=self._image_diff_db,
    331                 base_url=gm_json.GM_ACTUALS_ROOT_HTTP_URL,
    332                 imageA_relative_url=expected_image_relative_url,
    333                 imageB_relative_url=actual_image_relative_url,
    334                 expectations=expectations_dict,
    335                 extra_columns=extra_columns_dict)
    336             all_image_pairs.add_image_pair(image_pair)
    337             if updated_result_type != results.KEY__RESULT_TYPE__SUCCEEDED:
    338               failing_image_pairs.add_image_pair(image_pair)
    339           except Exception:
    340             logging.exception('got exception while creating new ImagePair')
    341 
    342     self._results = {
    343       results.KEY__HEADER__RESULTS_ALL: all_image_pairs.as_dict(),
    344       results.KEY__HEADER__RESULTS_FAILURES: failing_image_pairs.as_dict(),
    345     }
    346 
    347 
    348 def main():
    349   logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s',
    350                       datefmt='%m/%d/%Y %H:%M:%S',
    351                       level=logging.INFO)
    352   parser = argparse.ArgumentParser()
    353   parser.add_argument(
    354       '--actuals', default=results.DEFAULT_ACTUALS_DIR,
    355       help='Directory containing all actual-result JSON files; defaults to '
    356       '\'%(default)s\' .')
    357   parser.add_argument(
    358       '--expectations', default=DEFAULT_EXPECTATIONS_DIR,
    359       help='Directory containing all expected-result JSON files; defaults to '
    360       '\'%(default)s\' .')
    361   parser.add_argument(
    362       '--ignore-failures-file', default=DEFAULT_IGNORE_FAILURES_FILE,
    363       help='If a file with this name is found within the EXPECTATIONS dir, '
    364       'ignore failures for any tests listed in the file; defaults to '
    365       '\'%(default)s\' .')
    366   parser.add_argument(
    367       '--outfile', required=True,
    368       help='File to write result summary into, in JSON format.')
    369   parser.add_argument(
    370       '--results', default=results.KEY__HEADER__RESULTS_FAILURES,
    371       help='Which result types to include. Defaults to \'%(default)s\'; '
    372       'must be one of ' +
    373       str([results.KEY__HEADER__RESULTS_FAILURES,
    374            results.KEY__HEADER__RESULTS_ALL]))
    375   parser.add_argument(
    376       '--workdir', default=results.DEFAULT_GENERATED_IMAGES_ROOT,
    377       help='Directory within which to download images and generate diffs; '
    378       'defaults to \'%(default)s\' .')
    379   args = parser.parse_args()
    380   results_obj = ExpectationComparisons(
    381       actuals_root=args.actuals,
    382       expected_root=args.expectations,
    383       ignore_failures_file=args.ignore_failures_file,
    384       generated_images_root=args.workdir)
    385   gm_json.WriteToFile(
    386       results_obj.get_packaged_results_of_type(results_type=args.results),
    387       args.outfile)
    388 
    389 
    390 if __name__ == '__main__':
    391   main()
    392