diff options
author | 2014-08-20 08:00:28 -0700 | |
---|---|---|
committer | 2014-08-20 08:00:28 -0700 | |
commit | 2c4352bb9db02db70b829f96f397faa80fade220 (patch) | |
tree | b18ff06d99190d6dccd81d6cf241f233f1da7ddc /gm/rebaseline_server | |
parent | 802aa8c15b7e88f5c702c2d10b33b3787dd38f47 (diff) |
rebaseline_server: allow users to generate SKP diff patches on a shared instance
This will allow developers to maintain SKP baselines without ever running their
own rebaseline_server instance!
For now, the developer must manually apply the resulting patchset to his local
Skia checkout to actually modify expectations; in the near future, we hope to
make the UI upload the patchset to Rietveld on the user's behalf.
BUG=skia:1918
NOTRY=true
R=stephana@google.com, rmistry@google.com
Author: epoger@google.com
Review URL: https://codereview.chromium.org/487853004
Diffstat (limited to 'gm/rebaseline_server')
10 files changed, 295 insertions, 68 deletions
diff --git a/gm/rebaseline_server/compare_rendered_pictures.py b/gm/rebaseline_server/compare_rendered_pictures.py index f1af2e02c7..a6e0aa0bc5 100755 --- a/gm/rebaseline_server/compare_rendered_pictures.py +++ b/gm/rebaseline_server/compare_rendered_pictures.py @@ -51,6 +51,9 @@ COLUMN__BUILDER_A = 'builderA' COLUMN__RENDER_MODE_A = 'renderModeA' COLUMN__BUILDER_B = 'builderB' COLUMN__RENDER_MODE_B = 'renderModeB' +# Known values for some of those columns. +COLUMN__TILED_OR_WHOLE__TILED = 'tiled' +COLUMN__TILED_OR_WHOLE__WHOLE = 'whole' FREEFORM_COLUMN_IDS = [ COLUMN__SOURCE_SKP, @@ -213,8 +216,8 @@ class RenderedPicturesComparisons(results.BaseComparisons): """ logging.info('Reading JSON image summaries from dirs %s and %s...' % ( setA_root, setB_root)) - setA_dicts = self._read_dicts_from_root(setA_root) - setB_dicts = self._read_dicts_from_root(setB_root) + setA_dicts = self.read_dicts_from_root(setA_root) + setB_dicts = self.read_dicts_from_root(setB_root) logging.info('Comparing summary dicts...') all_image_pairs = imagepairset.ImagePairSet( @@ -296,6 +299,7 @@ class RenderedPicturesComparisons(results.BaseComparisons): image_dict_A=whole_image_A, image_dict_B=whole_image_B, builder_A=builder_A, render_mode_A=render_mode_A, builder_B=builder_B, render_mode_B=render_mode_B, + source_json_file=dict_path, source_skp_name=skp_name, tilenum=None)) tiled_images_A = self.get_default( @@ -316,6 +320,7 @@ class RenderedPicturesComparisons(results.BaseComparisons): if tile_num < num_tiles_B else None), builder_A=builder_A, render_mode_A=render_mode_A, builder_B=builder_B, render_mode_B=render_mode_B, + source_json_file=dict_path, source_skp_name=skp_name, tilenum=tile_num)) for one_imagepair in imagepairs_for_this_skp: @@ -364,8 +369,8 @@ class RenderedPicturesComparisons(results.BaseComparisons): def _create_image_pair(self, image_dict_A, image_dict_B, builder_A, render_mode_A, builder_B, render_mode_B, - source_skp_name, - tilenum): + source_json_file, + source_skp_name, tilenum): """Creates an ImagePair object for this pair of images. Args: @@ -377,6 +382,8 @@ class RenderedPicturesComparisons(results.BaseComparisons): builder_B: builder that created image set A or None if unknow render_mode_B: render mode used to generate image set A or None if unknown. + source_json_file: string; relative path of the JSON file where this + result came from, within setA and setB. source_skp_name: string; name of the source SKP file tilenum: which tile, or None if a wholeimage @@ -418,10 +425,10 @@ class RenderedPicturesComparisons(results.BaseComparisons): COLUMN__RENDER_MODE_B: render_mode_B, } if tilenum == None: - extra_columns_dict[COLUMN__TILED_OR_WHOLE] = 'whole' + extra_columns_dict[COLUMN__TILED_OR_WHOLE] = COLUMN__TILED_OR_WHOLE__WHOLE extra_columns_dict[COLUMN__TILENUM] = 'N/A' else: - extra_columns_dict[COLUMN__TILED_OR_WHOLE] = 'tiled' + extra_columns_dict[COLUMN__TILED_OR_WHOLE] = COLUMN__TILED_OR_WHOLE__TILED extra_columns_dict[COLUMN__TILENUM] = str(tilenum) try: @@ -431,6 +438,7 @@ class RenderedPicturesComparisons(results.BaseComparisons): imageA_relative_url=imageA_relative_url, imageB_relative_url=imageB_relative_url, extra_columns=extra_columns_dict, + source_json_file=source_json_file, download_all_images=self._download_all_images) except (KeyError, TypeError): logging.exception( diff --git a/gm/rebaseline_server/imagepair.py b/gm/rebaseline_server/imagepair.py index d9c4cb82b9..0ac0c42138 100644 --- a/gm/rebaseline_server/imagepair.py +++ b/gm/rebaseline_server/imagepair.py @@ -20,6 +20,7 @@ KEY__IMAGEPAIRS__EXTRACOLUMNS = 'extraColumns' KEY__IMAGEPAIRS__IMAGE_A_URL = 'imageAUrl' KEY__IMAGEPAIRS__IMAGE_B_URL = 'imageBUrl' KEY__IMAGEPAIRS__IS_DIFFERENT = 'isDifferent' +KEY__IMAGEPAIRS__SOURCE_JSON_FILE = 'sourceJsonFile' # If self._diff_record is set to this, we haven't asked ImageDiffDB for the # image diff details yet. @@ -32,7 +33,7 @@ class ImagePair(object): def __init__(self, image_diff_db, base_url, imageA_relative_url, imageB_relative_url, - expectations=None, extra_columns=None, + expectations=None, extra_columns=None, source_json_file=None, download_all_images=False): """ Args: @@ -46,6 +47,9 @@ class ImagePair(object): metadata (ignore-failure, bug numbers, etc.) extra_columns: optional dictionary containing more metadata (test name, builder name, etc.) + source_json_file: relative path of the JSON file where each image came + from; this will be the same for both imageA and imageB, within their + respective directories download_all_images: if True, download any images associated with this image pair, even if we don't need them to generate diffs (imageA == imageB, or one of them is missing) @@ -56,6 +60,7 @@ class ImagePair(object): self.imageB_relative_url = imageB_relative_url self.expectations_dict = expectations self.extra_columns_dict = extra_columns + self.source_json_file = source_json_file if not imageA_relative_url or not imageB_relative_url: self._is_different = True self._diff_record = None @@ -89,6 +94,8 @@ class ImagePair(object): asdict[KEY__IMAGEPAIRS__EXPECTATIONS] = self.expectations_dict if self.extra_columns_dict: asdict[KEY__IMAGEPAIRS__EXTRACOLUMNS] = self.extra_columns_dict + if self.source_json_file: + asdict[KEY__IMAGEPAIRS__SOURCE_JSON_FILE] = self.source_json_file if self._diff_record is _DIFF_RECORD_STILL_LOADING: # We have waited as long as we can to ask ImageDiffDB for details of # this image diff. Now we must block until ImageDiffDB can provide diff --git a/gm/rebaseline_server/results.py b/gm/rebaseline_server/results.py index 11a7d6ecfd..b0027d22af 100755 --- a/gm/rebaseline_server/results.py +++ b/gm/rebaseline_server/results.py @@ -213,7 +213,7 @@ class BaseComparisons(object): Raises: IOError if root does not refer to an existing directory """ - # I considered making this call _read_dicts_from_root(), but I decided + # 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) @@ -227,9 +227,13 @@ class BaseComparisons(object): meta_dict[builder] = gm_json.LoadFromFile(full_path) return meta_dict - def _read_dicts_from_root(self, root, pattern='*.json'): + @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) diff --git a/gm/rebaseline_server/server.py b/gm/rebaseline_server/server.py index 1bd596340b..6062aed641 100755 --- a/gm/rebaseline_server/server.py +++ b/gm/rebaseline_server/server.py @@ -48,6 +48,8 @@ import download_actuals import imagediffdb import imagepairset import results as results_mod +import writable_expectations as writable_expectations_mod + PATHSPLIT_RE = re.compile('/([^/]+)/(.+)') @@ -67,6 +69,9 @@ MIME_TYPE_MAP = {'': 'application/octet-stream', KEY__EDITS__MODIFICATIONS = 'modifications' KEY__EDITS__OLD_RESULTS_HASH = 'oldResultsHash' KEY__EDITS__OLD_RESULTS_TYPE = 'oldResultsType' +KEY__LIVE_EDITS__MODIFICATIONS = 'modifications' +KEY__LIVE_EDITS__SET_A_DESCRIPTIONS = 'setA' +KEY__LIVE_EDITS__SET_B_DESCRIPTIONS = 'setB' DEFAULT_ACTUALS_DIR = results_mod.DEFAULT_ACTUALS_DIR DEFAULT_GM_SUMMARIES_BUCKET = download_actuals.GM_SUMMARIES_BUCKET @@ -685,11 +690,11 @@ class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): normpath = posixpath.normpath(self.path) dispatchers = { '/edits': self.do_POST_edits, + '/live-edits': self.do_POST_live_edits, } try: dispatcher = dispatchers[normpath] dispatcher() - self.send_response(200) except: self.send_error(404) raise @@ -749,6 +754,47 @@ class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): # We can do this in a separate thread; we should return our success message # to the UI as soon as possible. thread.start_new_thread(_SERVER.update_results, (True,)) + self.send_response(200) + + def do_POST_live_edits(self): + """ Handle a POST request with modifications to SKP expectations, in this + format: + + { + KEY__LIVE_EDITS__SET_A_DESCRIPTIONS: { + # setA descriptions from the original data + }, + KEY__LIVE_EDITS__SET_B_DESCRIPTIONS: { + # setB descriptions from the original data + }, + KEY__LIVE_EDITS__MODIFICATIONS: [ + # as needed by writable_expectations.modify() + ], + } + + Raises an Exception if there were any problems. + """ + content_type = self.headers[_HTTP_HEADER_CONTENT_TYPE] + if content_type != 'application/json;charset=UTF-8': + raise Exception('unsupported %s [%s]' % ( + _HTTP_HEADER_CONTENT_TYPE, content_type)) + + content_length = int(self.headers[_HTTP_HEADER_CONTENT_LENGTH]) + json_data = self.rfile.read(content_length) + data = json.loads(json_data) + logging.debug('do_POST_live_edits: received new GM expectations data [%s]' % + data) + with writable_expectations_mod.WritableExpectations( + data[KEY__LIVE_EDITS__SET_A_DESCRIPTIONS]) as writable_expectations: + writable_expectations.modify(data[KEY__LIVE_EDITS__MODIFICATIONS]) + diffs = writable_expectations.get_diffs() + # TODO(stephana): Move to a simpler web framework so we don't have to + # call these functions. See http://skbug.com/2856 ('rebaseline_server: + # Refactor server to use a simple web framework') + self.send_response(200) + self.send_header('Content-type', 'text/plain') + self.end_headers() + self.wfile.write(diffs) def redirect_to(self, url): """ Redirect the HTTP client to a different url. diff --git a/gm/rebaseline_server/static/constants.js b/gm/rebaseline_server/static/constants.js index aa4b63aa9e..a9601ece71 100644 --- a/gm/rebaseline_server/static/constants.js +++ b/gm/rebaseline_server/static/constants.js @@ -31,6 +31,7 @@ module.constant('constants', (function() { KEY__IMAGEPAIRS__IMAGE_A_URL: 'imageAUrl', KEY__IMAGEPAIRS__IMAGE_B_URL: 'imageBUrl', KEY__IMAGEPAIRS__IS_DIFFERENT: 'isDifferent', + KEY__IMAGEPAIRS__SOURCE_JSON_FILE: 'sourceJsonFile', // NOTE: Keep these in sync with ../imagepairset.py KEY__ROOT__EXTRACOLUMNHEADERS: 'extraColumnHeaders', @@ -83,6 +84,9 @@ module.constant('constants', (function() { KEY__EDITS__MODIFICATIONS: 'modifications', KEY__EDITS__OLD_RESULTS_HASH: 'oldResultsHash', KEY__EDITS__OLD_RESULTS_TYPE: 'oldResultsType', + KEY__LIVE_EDITS__MODIFICATIONS: 'modifications', + KEY__LIVE_EDITS__SET_A_DESCRIPTIONS: 'setA', + KEY__LIVE_EDITS__SET_B_DESCRIPTIONS: 'setB', // These are just used on the client side, no need to sync with server code. KEY__IMAGEPAIRS__ROWSPAN: 'rowspan', diff --git a/gm/rebaseline_server/static/live-loader.js b/gm/rebaseline_server/static/live-loader.js index 611c2cd84d..30d05061be 100644 --- a/gm/rebaseline_server/static/live-loader.js +++ b/gm/rebaseline_server/static/live-loader.js @@ -776,14 +776,17 @@ Loader.controller( * Tell the server that the actual results of these particular tests * are acceptable. * - * TODO(epoger): This assumes that the original expectations are in - * imageSetA, and the actuals are in imageSetB. + * This assumes that the original expectations are in imageSetA, and the + * new expectations are in imageSetB. That's fine, because the server + * mandates that anyway (it will swap the sets if the user requests them + * in the opposite order). * * @param imagePairsSubset an array of test results, most likely a subset of * $scope.imagePairs (perhaps with some modifications) */ $scope.submitApprovals = function(imagePairsSubset) { $scope.submitPending = true; + $scope.diffResults = ""; // Convert bug text field to null or 1-item array. var bugs = null; @@ -792,13 +795,6 @@ Loader.controller( bugs = [bugNumber]; } - // TODO(epoger): This is a suboptimal way to prevent users from - // rebaselining failures in alternative renderModes, but it does work. - // For a better solution, see - // https://code.google.com/p/skia/issues/detail?id=1748 ('gm: add new - // result type, RenderModeMismatch') - var encounteredComparisonConfig = false; - var updatedExpectations = []; for (var i = 0; i < imagePairsSubset.length; i++) { var imagePair = imagePairsSubset[i]; @@ -807,14 +803,11 @@ Loader.controller( imagePair[constants.KEY__IMAGEPAIRS__EXPECTATIONS]; updatedExpectation[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS] = imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS]; + updatedExpectation[constants.KEY__IMAGEPAIRS__SOURCE_JSON_FILE] = + imagePair[constants.KEY__IMAGEPAIRS__SOURCE_JSON_FILE]; // IMAGE_B_URL contains the actual image (which is now the expectation) updatedExpectation[constants.KEY__IMAGEPAIRS__IMAGE_B_URL] = imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL]; - if (0 == updatedExpectation[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS] - [constants.KEY__EXTRACOLUMNS__CONFIG] - .indexOf('comparison-')) { - encounteredComparisonConfig = true; - } // Advanced settings... if (null == updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS]) { @@ -835,41 +828,20 @@ Loader.controller( updatedExpectations.push(updatedExpectation); } - if (encounteredComparisonConfig) { - alert("Approval failed -- you cannot approve results with config " + - "type comparison-*"); - $scope.submitPending = false; - return; - } var modificationData = {}; - modificationData[constants.KEY__EDITS__MODIFICATIONS] = + modificationData[constants.KEY__LIVE_EDITS__MODIFICATIONS] = updatedExpectations; - modificationData[constants.KEY__EDITS__OLD_RESULTS_HASH] = - $scope.header[constants.KEY__HEADER__DATAHASH]; - modificationData[constants.KEY__EDITS__OLD_RESULTS_TYPE] = - $scope.header[constants.KEY__HEADER__TYPE]; + modificationData[constants.KEY__LIVE_EDITS__SET_A_DESCRIPTIONS] = + $scope.header[constants.KEY__HEADER__SET_A_DESCRIPTIONS]; + modificationData[constants.KEY__LIVE_EDITS__SET_B_DESCRIPTIONS] = + $scope.header[constants.KEY__HEADER__SET_B_DESCRIPTIONS]; $http({ method: "POST", - url: "/edits", + url: "/live-edits", data: modificationData }).success(function(data, status, headers, config) { - var imagePairIndicesToMove = []; - for (var i = 0; i < imagePairsSubset.length; i++) { - imagePairIndicesToMove.push(imagePairsSubset[i].index); - } - $scope.moveImagePairsToTab(imagePairIndicesToMove, - "HackToMakeSureThisImagePairDisappears"); - $scope.updateResults(); - alert("New baselines submitted successfully!\n\n" + - "You still need to commit the updated expectations files on " + - "the server side to the Skia repo.\n\n" + - "When you click OK, your web UI will reload; after that " + - "completes, you will see the updated data (once the server has " + - "finished loading the update results into memory!) and you can " + - "submit more baselines if you want."); - // I don't know why, but if I just call reload() here it doesn't work. - // Making a timer call it fixes the problem. - $timeout(function(){location.reload();}, 1); + $scope.diffResults = data; + $scope.submitPending = false; }).error(function(data, status, headers, config) { alert("There was an error submitting your baselines.\n\n" + "Please see server-side log for details."); diff --git a/gm/rebaseline_server/static/live-view.html b/gm/rebaseline_server/static/live-view.html index 44cbd0d337..6c158a6e7e 100644 --- a/gm/rebaseline_server/static/live-view.html +++ b/gm/rebaseline_server/static/live-view.html @@ -170,14 +170,11 @@ <!-- Submission UI that we only show in the Pending Approval tab. --> <div ng-show="'Pending Approval' == viewingTab"> - <div style="font-size:20px"> - TODO(epoger): We don't yet support submitting new SKP expectations. - </div> <div style="display:inline-block"> <button style="font-size:20px" ng-click="submitApprovals(filteredImagePairs)" - ng-disabled="true || submitPending || (filteredImagePairs.length == 0)"> - Update these {{filteredImagePairs.length}} expectations on the server + ng-disabled="submitPending || (filteredImagePairs.length == 0)"> + Get a patchfile to update these {{filteredImagePairs.length}} expectations </button> </div> <div style="display:inline-block"> @@ -201,6 +198,12 @@ </li> </ul> </div> + <div ng-show="diffResults"> + <p> + Here is the patch to apply to your local checkout: + <br> + <textarea rows="8" cols="50">{{diffResults}}</textarea> + </div> </div> <p> diff --git a/gm/rebaseline_server/testdata/outputs/expected/compare_rendered_pictures_test.CompareRenderedPicturesTest.test_endToEnd/compare_rendered_pictures.json b/gm/rebaseline_server/testdata/outputs/expected/compare_rendered_pictures_test.CompareRenderedPicturesTest.test_endToEnd/compare_rendered_pictures.json index 81366205f1..afa8978de8 100644 --- a/gm/rebaseline_server/testdata/outputs/expected/compare_rendered_pictures_test.CompareRenderedPicturesTest.test_endToEnd/compare_rendered_pictures.json +++ b/gm/rebaseline_server/testdata/outputs/expected/compare_rendered_pictures_test.CompareRenderedPicturesTest.test_endToEnd/compare_rendered_pictures.json @@ -128,7 +128,7 @@ "renderModeB" ], "header": { - "dataHash": "-1510211866509185075", + "dataHash": "-5707186260478709107", "isEditable": false, "isExported": true, "schemaVersion": 5, @@ -164,7 +164,8 @@ }, "imageAUrl": "changed_skp/bitmap-64bitMD5_3101044995537104462.png", "imageBUrl": "changed_skp/bitmap-64bitMD5_13623922271964399662.png", - "isDifferent": true + "isDifferent": true, + "sourceJsonFile": "./summary.json" }, { "extraColumns": { @@ -179,7 +180,8 @@ }, "imageAUrl": null, "imageBUrl": "only-in-after_skp/bitmap-64bitMD5_2320185040577047131.png", - "isDifferent": true + "isDifferent": true, + "sourceJsonFile": "./summary.json" }, { "extraColumns": { @@ -194,7 +196,8 @@ }, "imageAUrl": "only-in-before_skp/bitmap-64bitMD5_2320185040577047131.png", "imageBUrl": null, - "isDifferent": true + "isDifferent": true, + "sourceJsonFile": "./summary.json" }, { "extraColumns": { @@ -209,7 +212,8 @@ }, "imageAUrl": "unchanged_skp/bitmap-64bitMD5_3322248763049618493.png", "imageBUrl": "unchanged_skp/bitmap-64bitMD5_3322248763049618493.png", - "isDifferent": false + "isDifferent": false, + "sourceJsonFile": "./summary.json" } ], "imageSets": { diff --git a/gm/rebaseline_server/testdata/outputs/expected/compare_rendered_pictures_test.CompareRenderedPicturesTest.test_repo_url/compare_rendered_pictures.json b/gm/rebaseline_server/testdata/outputs/expected/compare_rendered_pictures_test.CompareRenderedPicturesTest.test_repo_url/compare_rendered_pictures.json index ada58a3bd1..5ee60925ab 100644 --- a/gm/rebaseline_server/testdata/outputs/expected/compare_rendered_pictures_test.CompareRenderedPicturesTest.test_repo_url/compare_rendered_pictures.json +++ b/gm/rebaseline_server/testdata/outputs/expected/compare_rendered_pictures_test.CompareRenderedPicturesTest.test_repo_url/compare_rendered_pictures.json @@ -128,7 +128,7 @@ "renderModeB" ], "header": { - "dataHash": "-1510211866509185075", + "dataHash": "-5707186260478709107", "isEditable": false, "isExported": true, "schemaVersion": 5, @@ -160,7 +160,8 @@ }, "imageAUrl": "changed_skp/bitmap-64bitMD5_3101044995537104462.png", "imageBUrl": "changed_skp/bitmap-64bitMD5_13623922271964399662.png", - "isDifferent": true + "isDifferent": true, + "sourceJsonFile": "./summary.json" }, { "extraColumns": { @@ -175,7 +176,8 @@ }, "imageAUrl": null, "imageBUrl": "only-in-after_skp/bitmap-64bitMD5_2320185040577047131.png", - "isDifferent": true + "isDifferent": true, + "sourceJsonFile": "./summary.json" }, { "extraColumns": { @@ -190,7 +192,8 @@ }, "imageAUrl": "only-in-before_skp/bitmap-64bitMD5_2320185040577047131.png", "imageBUrl": null, - "isDifferent": true + "isDifferent": true, + "sourceJsonFile": "./summary.json" }, { "extraColumns": { @@ -205,7 +208,8 @@ }, "imageAUrl": "unchanged_skp/bitmap-64bitMD5_3322248763049618493.png", "imageBUrl": "unchanged_skp/bitmap-64bitMD5_3322248763049618493.png", - "isDifferent": false + "isDifferent": false, + "sourceJsonFile": "./summary.json" } ], "imageSets": { diff --git a/gm/rebaseline_server/writable_expectations.py b/gm/rebaseline_server/writable_expectations.py new file mode 100644 index 0000000000..dada0358c7 --- /dev/null +++ b/gm/rebaseline_server/writable_expectations.py @@ -0,0 +1,175 @@ +#!/usr/bin/python + +""" +Copyright 2014 Google Inc. + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. + +Expectations on local disk that we can modify. +""" + +# System-level imports +import logging +import os +import re + +# Must fix up PYTHONPATH before importing from within Skia +import rs_fixpypath # pylint: disable=W0611 + +# Imports from within Skia +from py.utils import git_utils +import compare_rendered_pictures +import gm_json +import imagepair +import results + +FILEPATH_RE = re.compile('.+/' + gm_json.IMAGE_FILENAME_PATTERN) + +SKIA_REPO = os.path.abspath(os.path.join( + os.path.dirname(__file__), os.pardir, os.pardir, '.git')) + + +class WritableExpectations(git_utils.NewGitCheckout): + """Expectations on local disk that we can modify.""" + + def __init__(self, set_descriptions): + """Creates a sandbox on local disk containing writable expectations. + + You must use the 'with' statement to create this object in such a way that + it cleans up after itself: + + with WritableExpectations(*args) as writable_expectations: + # make modifications + # use the modified results + # the sandbox on local disk is automatically cleaned up here + + Args: + set_descriptions: SET_DESCRIPTIONS dict describing the set we want to + update expectations within; this tells us the subdirectory within the + Skia repo where we keep these expectations, and the commithash at + which the user evaluated new baselines. + """ + file_section = set_descriptions[results.KEY__SET_DESCRIPTIONS__SECTION] + assert file_section == gm_json.JSONKEY_EXPECTEDRESULTS + + source_dir = _unicode_to_ascii( + set_descriptions[results.KEY__SET_DESCRIPTIONS__DIR]) + assert source_dir.startswith(compare_rendered_pictures.REPO_URL_PREFIX) + repo_subdir = source_dir[len(compare_rendered_pictures.REPO_URL_PREFIX):] + repo_revision = _unicode_to_ascii( + set_descriptions[results.KEY__SET_DESCRIPTIONS__REPO_REVISION]) + + logging.info('Creating a writable Skia checkout at revision "%s"...' % + repo_revision) + super(WritableExpectations, self).__init__( + repository=SKIA_REPO, commit=repo_revision, subdir=repo_subdir) + + def modify(self, modifications): + """Modify the contents of the checkout, using modifications from the UI. + + Args: + modifications: data[KEY__LIVE_EDITS__MODIFICATIONS] coming back from the + rebaseline_server UI frontend + """ + logging.info('Reading in dicts from writable Skia checkout in %s ...' % + self.root) + dicts = results.BaseComparisons.read_dicts_from_root(self.root) + + # Make sure we have expected-results sections in all our output dicts. + for pathname, adict in dicts.iteritems(): + if not adict: + adict = {} + if not adict.get(gm_json.JSONKEY_EXPECTEDRESULTS, None): + adict[gm_json.JSONKEY_EXPECTEDRESULTS] = {} + dicts[pathname] = adict + + for modification in modifications: + expectations = modification[imagepair.KEY__IMAGEPAIRS__EXPECTATIONS] + _add_image_info_to_expectations( + expectations=expectations, + filepath=modification[imagepair.KEY__IMAGEPAIRS__IMAGE_B_URL]) + extra_columns = modification[imagepair.KEY__IMAGEPAIRS__EXTRACOLUMNS] + dictname = modification[imagepair.KEY__IMAGEPAIRS__SOURCE_JSON_FILE] + dict_to_modify = dicts[dictname][gm_json.JSONKEY_EXPECTEDRESULTS] + test_name = extra_columns[compare_rendered_pictures.COLUMN__SOURCE_SKP] + test_record = dict_to_modify.get(test_name, {}) + if (extra_columns[compare_rendered_pictures.COLUMN__TILED_OR_WHOLE] == + compare_rendered_pictures.COLUMN__TILED_OR_WHOLE__TILED): + test_tiles_list = test_record.get( + gm_json.JSONKEY_SOURCE_TILEDIMAGES, []) + tilenum = int(extra_columns[compare_rendered_pictures.COLUMN__TILENUM]) + _replace_list_item(test_tiles_list, tilenum, expectations) + test_record[gm_json.JSONKEY_SOURCE_TILEDIMAGES] = test_tiles_list + else: + test_record[gm_json.JSONKEY_SOURCE_WHOLEIMAGE] = expectations + dict_to_modify[test_name] = test_record + + # Write the modified files back to disk. + self._write_dicts_to_root(meta_dict=dicts, root=self.root) + + def get_diffs(self): + """Return patchfile describing any modifications to this checkout.""" + return self._run_in_git_root(args=[git_utils.GIT, 'diff']) + + @staticmethod + def _write_dicts_to_root(meta_dict, root): + """Write out multiple dictionaries in JSON format. + + Args: + meta_dict: a builder-keyed meta-dictionary containing all the JSON + dictionaries we want to write out + root: path to root of directory tree within which to write files + """ + if not os.path.isdir(root): + raise IOError('no directory found at path %s' % root) + + for rel_path in meta_dict.keys(): + full_path = os.path.join(root, rel_path) + gm_json.WriteToFile(meta_dict[rel_path], full_path) + + +def _unicode_to_ascii(unicode_string): + """Returns the plain ASCII form of a unicode string. + + TODO(stephana): We created this because we get unicode strings out of the + JSON file, while the git filenames and revision tags are plain ASCII. + There may be a better way to handle this... maybe set the JSON util to just + return ASCII strings? + """ + return unicode_string.encode('ascii', 'ignore') + + +def _replace_list_item(a_list, index, value): + """Replaces value at index "index" within a_list. + + Args: + a_list: a list + index: index indicating which item in a_list to replace + value: value to set a_list[index] to + + If a_list does not contain this index, it will be extended with None entries + to that length. + """ + length = len(a_list) + while index >= length: + a_list.append(None) + length += 1 + a_list[index] = value + + +def _add_image_info_to_expectations(expectations, filepath): + """Add JSONKEY_IMAGE_* info to an existing expectations dictionary. + + TODO(stephana): This assumes that the checksumAlgorithm and checksumValue + can be derived from the filepath, which is currently true but may not always + be true. + + Args: + expectations: the expectations dict to augment + filepath: relative path to the image file + """ + (checksum_algorithm, checksum_value) = FILEPATH_RE.match(filepath).groups() + expectations[gm_json.JSONKEY_IMAGE_CHECKSUMALGORITHM] = checksum_algorithm + expectations[gm_json.JSONKEY_IMAGE_CHECKSUMVALUE] = checksum_value + expectations[gm_json.JSONKEY_IMAGE_FILEPATH] = filepath |