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