Index: gm/rebaseline_server/results.py |
diff --git a/gm/rebaseline_server/results.py b/gm/rebaseline_server/results.py |
new file mode 100755 |
index 0000000000000000000000000000000000000000..b0027d22af3d19fd751217b067a0aa010cb2e784 |
--- /dev/null |
+++ b/gm/rebaseline_server/results.py |
@@ -0,0 +1,343 @@ |
+#!/usr/bin/python |
+ |
+""" |
+Copyright 2013 Google Inc. |
+ |
+Use of this source code is governed by a BSD-style license that can be |
+found in the LICENSE file. |
+ |
+Repackage expected/actual GM results as needed by our HTML rebaseline viewer. |
+""" |
+ |
+# System-level imports |
+import fnmatch |
+import os |
+import re |
+ |
+# Must fix up PYTHONPATH before importing from within Skia |
+import rs_fixpypath # pylint: disable=W0611 |
+ |
+# Imports from within Skia |
+import gm_json |
+import imagepairset |
+ |
+# Keys used to link an image to a particular GM test. |
+# NOTE: Keep these in sync with static/constants.js |
+VALUE__HEADER__SCHEMA_VERSION = 5 |
+KEY__EXPECTATIONS__BUGS = gm_json.JSONKEY_EXPECTEDRESULTS_BUGS |
+KEY__EXPECTATIONS__IGNOREFAILURE = gm_json.JSONKEY_EXPECTEDRESULTS_IGNOREFAILURE |
+KEY__EXPECTATIONS__REVIEWED = gm_json.JSONKEY_EXPECTEDRESULTS_REVIEWED |
+KEY__EXTRACOLUMNS__BUILDER = 'builder' |
+KEY__EXTRACOLUMNS__CONFIG = 'config' |
+KEY__EXTRACOLUMNS__RESULT_TYPE = 'resultType' |
+KEY__EXTRACOLUMNS__TEST = 'test' |
+KEY__HEADER__DATAHASH = 'dataHash' |
+KEY__HEADER__IS_EDITABLE = 'isEditable' |
+KEY__HEADER__IS_EXPORTED = 'isExported' |
+KEY__HEADER__IS_STILL_LOADING = 'resultsStillLoading' |
+KEY__HEADER__RESULTS_ALL = 'all' |
+KEY__HEADER__RESULTS_FAILURES = 'failures' |
+KEY__HEADER__SCHEMA_VERSION = 'schemaVersion' |
+KEY__HEADER__SET_A_DESCRIPTIONS = 'setA' |
+KEY__HEADER__SET_B_DESCRIPTIONS = 'setB' |
+KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE = 'timeNextUpdateAvailable' |
+KEY__HEADER__TIME_UPDATED = 'timeUpdated' |
+KEY__HEADER__TYPE = 'type' |
+KEY__RESULT_TYPE__FAILED = gm_json.JSONKEY_ACTUALRESULTS_FAILED |
+KEY__RESULT_TYPE__FAILUREIGNORED = gm_json.JSONKEY_ACTUALRESULTS_FAILUREIGNORED |
+KEY__RESULT_TYPE__NOCOMPARISON = gm_json.JSONKEY_ACTUALRESULTS_NOCOMPARISON |
+KEY__RESULT_TYPE__SUCCEEDED = gm_json.JSONKEY_ACTUALRESULTS_SUCCEEDED |
+KEY__SET_DESCRIPTIONS__DIR = 'dir' |
+KEY__SET_DESCRIPTIONS__REPO_REVISION = 'repoRevision' |
+KEY__SET_DESCRIPTIONS__SECTION = 'section' |
+ |
+IMAGE_FILENAME_RE = re.compile(gm_json.IMAGE_FILENAME_PATTERN) |
+IMAGE_FILENAME_FORMATTER = '%s_%s.png' # pass in (testname, config) |
+ |
+PARENT_DIRECTORY = os.path.dirname(os.path.realpath(__file__)) |
+DEFAULT_ACTUALS_DIR = '.gm-actuals' |
+DEFAULT_GENERATED_IMAGES_ROOT = os.path.join( |
+ PARENT_DIRECTORY, '.generated-images') |
+ |
+# Define the default set of builders we will process expectations/actuals for. |
+# This allows us to ignore builders for which we don't maintain expectations |
+# (trybots, Valgrind, ASAN, TSAN), and avoid problems like |
+# https://code.google.com/p/skia/issues/detail?id=2036 ('rebaseline_server |
+# produces error when trying to add baselines for ASAN/TSAN builders') |
+DEFAULT_MATCH_BUILDERS_PATTERN_LIST = ['.*'] |
+DEFAULT_SKIP_BUILDERS_PATTERN_LIST = [ |
+ '.*-Trybot', '.*Valgrind.*', '.*TSAN.*', '.*ASAN.*'] |
+ |
+ |
+class BaseComparisons(object): |
+ """Base class for generating summary of comparisons between two image sets. |
+ """ |
+ |
+ def __init__(self): |
+ """Base constructor; most subclasses will override.""" |
+ self._setA_descriptions = None |
+ self._setB_descriptions = None |
+ |
+ def get_results_of_type(self, results_type): |
+ """Return results of some/all tests (depending on 'results_type' parameter). |
+ |
+ Args: |
+ results_type: string describing which types of results to include; must |
+ be one of the RESULTS_* constants |
+ |
+ Results are returned in a dictionary as output by ImagePairSet.as_dict(). |
+ """ |
+ return self._results[results_type] |
+ |
+ def get_packaged_results_of_type(self, results_type, reload_seconds=None, |
+ is_editable=False, is_exported=True): |
+ """Package the results of some/all tests as a complete response_dict. |
+ |
+ Args: |
+ results_type: string indicating which set of results to return; |
+ must be one of the RESULTS_* constants |
+ reload_seconds: if specified, note that new results may be available once |
+ these results are reload_seconds old |
+ is_editable: whether clients are allowed to submit new baselines |
+ is_exported: whether these results are being made available to other |
+ network hosts |
+ """ |
+ response_dict = self._results[results_type] |
+ time_updated = self.get_timestamp() |
+ header_dict = { |
+ KEY__HEADER__SCHEMA_VERSION: ( |
+ VALUE__HEADER__SCHEMA_VERSION), |
+ |
+ # Timestamps: |
+ # 1. when this data was last updated |
+ # 2. when the caller should check back for new data (if ever) |
+ KEY__HEADER__TIME_UPDATED: time_updated, |
+ KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE: ( |
+ (time_updated+reload_seconds) if reload_seconds else None), |
+ |
+ # The type we passed to get_results_of_type() |
+ KEY__HEADER__TYPE: results_type, |
+ |
+ # Hash of dataset, which the client must return with any edits-- |
+ # this ensures that the edits were made to a particular dataset. |
+ KEY__HEADER__DATAHASH: str(hash(repr( |
+ response_dict[imagepairset.KEY__ROOT__IMAGEPAIRS]))), |
+ |
+ # Whether the server will accept edits back. |
+ KEY__HEADER__IS_EDITABLE: is_editable, |
+ |
+ # Whether the service is accessible from other hosts. |
+ KEY__HEADER__IS_EXPORTED: is_exported, |
+ } |
+ if self._setA_descriptions: |
+ header_dict[KEY__HEADER__SET_A_DESCRIPTIONS] = self._setA_descriptions |
+ if self._setB_descriptions: |
+ header_dict[KEY__HEADER__SET_B_DESCRIPTIONS] = self._setB_descriptions |
+ response_dict[imagepairset.KEY__ROOT__HEADER] = header_dict |
+ return response_dict |
+ |
+ def get_timestamp(self): |
+ """Return the time at which this object was created, in seconds past epoch |
+ (UTC). |
+ """ |
+ return self._timestamp |
+ |
+ _match_builders_pattern_list = [ |
+ re.compile(p) for p in DEFAULT_MATCH_BUILDERS_PATTERN_LIST] |
+ _skip_builders_pattern_list = [ |
+ re.compile(p) for p in DEFAULT_SKIP_BUILDERS_PATTERN_LIST] |
+ |
+ def set_match_builders_pattern_list(self, pattern_list): |
+ """Override the default set of builders we should process. |
+ |
+ The default is DEFAULT_MATCH_BUILDERS_PATTERN_LIST . |
+ |
+ Note that skip_builders_pattern_list overrides this; regardless of whether a |
+ builder is in the "match" list, if it's in the "skip" list, we will skip it. |
+ |
+ Args: |
+ pattern_list: list of regex patterns; process builders that match any |
+ entry within this list |
+ """ |
+ if pattern_list == None: |
+ pattern_list = [] |
+ self._match_builders_pattern_list = [re.compile(p) for p in pattern_list] |
+ |
+ def set_skip_builders_pattern_list(self, pattern_list): |
+ """Override the default set of builders we should skip while processing. |
+ |
+ The default is DEFAULT_SKIP_BUILDERS_PATTERN_LIST . |
+ |
+ This overrides match_builders_pattern_list; regardless of whether a |
+ builder is in the "match" list, if it's in the "skip" list, we will skip it. |
+ |
+ Args: |
+ pattern_list: list of regex patterns; skip builders that match any |
+ entry within this list |
+ """ |
+ if pattern_list == None: |
+ pattern_list = [] |
+ self._skip_builders_pattern_list = [re.compile(p) for p in pattern_list] |
+ |
+ def _ignore_builder(self, builder): |
+ """Returns True if we should skip processing this builder. |
+ |
+ Args: |
+ builder: name of this builder, as a string |
+ |
+ Returns: |
+ True if we should ignore expectations and actuals for this builder. |
+ """ |
+ for pattern in self._skip_builders_pattern_list: |
+ if pattern.match(builder): |
+ return True |
+ for pattern in self._match_builders_pattern_list: |
+ if pattern.match(builder): |
+ return False |
+ return True |
+ |
+ def _read_builder_dicts_from_root(self, root, pattern='*.json'): |
+ """Read all JSON dictionaries within a directory tree. |
+ |
+ Skips any dictionaries belonging to a builder we have chosen to ignore. |
+ |
+ Args: |
+ root: path to root of directory tree |
+ pattern: which files to read within root (fnmatch-style pattern) |
+ |
+ Returns: |
+ A meta-dictionary containing all the JSON dictionaries found within |
+ the directory tree, keyed by builder name (the basename of the directory |
+ where each JSON dictionary was found). |
+ |
+ Raises: |
+ IOError if root does not refer to an existing directory |
+ """ |
+ # I considered making this call read_dicts_from_root(), but I decided |
+ # it was better to prune out the ignored builders within the os.walk(). |
+ if not os.path.isdir(root): |
+ raise IOError('no directory found at path %s' % root) |
+ meta_dict = {} |
+ for dirpath, _, filenames in os.walk(root): |
+ for matching_filename in fnmatch.filter(filenames, pattern): |
+ builder = os.path.basename(dirpath) |
+ if self._ignore_builder(builder): |
+ continue |
+ full_path = os.path.join(dirpath, matching_filename) |
+ meta_dict[builder] = gm_json.LoadFromFile(full_path) |
+ return meta_dict |
+ |
+ @staticmethod |
+ def read_dicts_from_root(root, pattern='*.json'): |
+ """Read all JSON dictionaries within a directory tree. |
+ |
+ TODO(stephana): Factor this out into a utility module, as a standalone |
+ function (not part of a class). |
+ |
+ Args: |
+ root: path to root of directory tree |
+ pattern: which files to read within root (fnmatch-style pattern) |
+ |
+ Returns: |
+ A meta-dictionary containing all the JSON dictionaries found within |
+ the directory tree, keyed by the pathname (relative to root) of each JSON |
+ dictionary. |
+ |
+ Raises: |
+ IOError if root does not refer to an existing directory |
+ """ |
+ if not os.path.isdir(root): |
+ raise IOError('no directory found at path %s' % root) |
+ meta_dict = {} |
+ for abs_dirpath, _, filenames in os.walk(root): |
+ rel_dirpath = os.path.relpath(abs_dirpath, root) |
+ for matching_filename in fnmatch.filter(filenames, pattern): |
+ abs_path = os.path.join(abs_dirpath, matching_filename) |
+ rel_path = os.path.join(rel_dirpath, matching_filename) |
+ meta_dict[rel_path] = gm_json.LoadFromFile(abs_path) |
+ return meta_dict |
+ |
+ @staticmethod |
+ def _read_noncomment_lines(path): |
+ """Return a list of all noncomment lines within a file. |
+ |
+ (A "noncomment" line is one that does not start with a '#'.) |
+ |
+ Args: |
+ path: path to file |
+ """ |
+ lines = [] |
+ with open(path, 'r') as fh: |
+ for line in fh: |
+ if not line.startswith('#'): |
+ lines.append(line.strip()) |
+ return lines |
+ |
+ @staticmethod |
+ def _create_relative_url(hashtype_and_digest, test_name): |
+ """Returns the URL for this image, relative to GM_ACTUALS_ROOT_HTTP_URL. |
+ |
+ If we don't have a record of this image, returns None. |
+ |
+ Args: |
+ hashtype_and_digest: (hash_type, hash_digest) tuple, or None if we |
+ don't have a record of this image |
+ test_name: string; name of the GM test that created this image |
+ """ |
+ if not hashtype_and_digest: |
+ return None |
+ return gm_json.CreateGmRelativeUrl( |
+ test_name=test_name, |
+ hash_type=hashtype_and_digest[0], |
+ hash_digest=hashtype_and_digest[1]) |
+ |
+ @staticmethod |
+ def combine_subdicts(input_dict): |
+ """ Flatten out a dictionary structure by one level. |
+ |
+ Input: |
+ { |
+ KEY_A1 : { |
+ KEY_B1 : VALUE_B1, |
+ }, |
+ KEY_A2 : { |
+ KEY_B2 : VALUE_B2, |
+ } |
+ } |
+ |
+ Output: |
+ { |
+ KEY_B1 : VALUE_B1, |
+ KEY_B2 : VALUE_B2, |
+ } |
+ |
+ If this would result in any repeated keys, it will raise an Exception. |
+ """ |
+ output_dict = {} |
+ for subdict in input_dict.values(): |
+ for subdict_key, subdict_value in subdict.iteritems(): |
+ if subdict_key in output_dict: |
+ raise Exception('duplicate key %s in combine_subdicts' % subdict_key) |
+ output_dict[subdict_key] = subdict_value |
+ return output_dict |
+ |
+ @staticmethod |
+ def get_default(input_dict, default_value, *keys): |
+ """Returns input_dict[key1][key2][...], or default_value. |
+ |
+ If input_dict is None, or any key is missing along the way, this returns |
+ default_value. |
+ |
+ Args: |
+ input_dict: dictionary to look within |
+ key: key indicating which value to return from input_dict |
+ default_value: value to return if input_dict is None or any key cannot |
+ be found along the way |
+ """ |
+ if input_dict == None: |
+ return default_value |
+ for key in keys: |
+ input_dict = input_dict.get(key, None) |
+ if input_dict == None: |
+ return default_value |
+ return input_dict |