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 # 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
    344