rebaseline_server: allow users to generate SKP diff patches on a shared instance
authorepoger <epoger@google.com>
Wed, 20 Aug 2014 15:00:28 +0000 (08:00 -0700)
committerCommit bot <commit-bot@chromium.org>
Wed, 20 Aug 2014 15:00:28 +0000 (08:00 -0700)
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

DEPS
gm/rebaseline_server/compare_rendered_pictures.py
gm/rebaseline_server/imagepair.py
gm/rebaseline_server/results.py
gm/rebaseline_server/server.py
gm/rebaseline_server/static/constants.js
gm/rebaseline_server/static/live-loader.js
gm/rebaseline_server/static/live-view.html
gm/rebaseline_server/testdata/outputs/expected/compare_rendered_pictures_test.CompareRenderedPicturesTest.test_endToEnd/compare_rendered_pictures.json
gm/rebaseline_server/testdata/outputs/expected/compare_rendered_pictures_test.CompareRenderedPicturesTest.test_repo_url/compare_rendered_pictures.json
gm/rebaseline_server/writable_expectations.py [new file with mode: 0644]

diff --git a/DEPS b/DEPS
index a3d7a3c04bbff3642f0a42b062ab00ac76b7395b..9fb9078d8bc28c00c4fb1af3ba656bbee8fb074c 100644 (file)
--- a/DEPS
+++ b/DEPS
@@ -3,7 +3,7 @@ use_relative_paths = True
 # Dependencies on outside packages.
 #
 deps = {
-  "common": "https://skia.googlesource.com/common.git@a7b0283c39dfdf5553859eb9790c970bb52cda47",
+  "common": "https://skia.googlesource.com/common.git@5dd2918f96b277ebe04eb66469ec3d5c7ba6952a",
 
   "third_party/externals/angle2" : "https://chromium.googlesource.com/angle/angle.git@23a8a433529d9db23882c702a29d5e594841563d",
   "third_party/externals/freetype" : "https://skia.googlesource.com/third_party/freetype2.git@VER-2-5-0-1",
index f1af2e02c7e59e24d95d8e584b09a6cfc38b1e5d..a6e0aa0bc54e3d33e84d522d44c690854c036d46 100755 (executable)
@@ -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(
index d9c4cb82b98bd13fb855d2ff9f6e1abf8d26c6cc..0ac0c4213883568a01fbbea8cbdae69fd1416f09 100644 (file)
@@ -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
index 11a7d6ecfdd7e9aa35a3e4d35185e207f7abcda0..b0027d22af3d19fd751217b067a0aa010cb2e784 100755 (executable)
@@ -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)
index 1bd596340b0e4366b47943f6a71a1ed4d6f671f2..6062aed641e1f9581ccc852eaf9f656265682bef 100755 (executable)
@@ -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.
index aa4b63aa9ee484363af800f6e227bd938aa04075..a9601ece719dc95c4074662398a13de4ef7fc142 100644 (file)
@@ -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',
index 611c2cd84d3b32f0c7dd5a110e15e63b6f0e3583..30d05061be6fa9dd4ab9b2a7a9e9d375de66553d 100644 (file)
@@ -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.");
index 44cbd0d337e1c250ad83dc5d3c8b40c0aa5ba044..6c158a6e7ec3d8b8a7c5c3373a3a70ec777b9f61 100644 (file)
 
       <!-- 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">
             </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>
index 81366205f10d1c357b9b6c16567aee932806f442..afa8978de88727f3c8dae0c261d5adc51bde606f 100644 (file)
     "renderModeB"
   ], 
   "header": {
-    "dataHash": "-1510211866509185075", 
+    "dataHash": "-5707186260478709107", 
     "isEditable": false, 
     "isExported": true, 
     "schemaVersion": 5, 
       }, 
       "imageAUrl": "changed_skp/bitmap-64bitMD5_3101044995537104462.png", 
       "imageBUrl": "changed_skp/bitmap-64bitMD5_13623922271964399662.png", 
-      "isDifferent": true
+      "isDifferent": true, 
+      "sourceJsonFile": "./summary.json"
     }, 
     {
       "extraColumns": {
       }, 
       "imageAUrl": null, 
       "imageBUrl": "only-in-after_skp/bitmap-64bitMD5_2320185040577047131.png", 
-      "isDifferent": true
+      "isDifferent": true, 
+      "sourceJsonFile": "./summary.json"
     }, 
     {
       "extraColumns": {
       }, 
       "imageAUrl": "only-in-before_skp/bitmap-64bitMD5_2320185040577047131.png", 
       "imageBUrl": null, 
-      "isDifferent": true
+      "isDifferent": true, 
+      "sourceJsonFile": "./summary.json"
     }, 
     {
       "extraColumns": {
       }, 
       "imageAUrl": "unchanged_skp/bitmap-64bitMD5_3322248763049618493.png", 
       "imageBUrl": "unchanged_skp/bitmap-64bitMD5_3322248763049618493.png", 
-      "isDifferent": false
+      "isDifferent": false, 
+      "sourceJsonFile": "./summary.json"
     }
   ], 
   "imageSets": {
index ada58a3bd1dceb3419edef4e30a806f06e67ca3a..5ee60925ab7b80641935578308ae0ed951121add 100644 (file)
     "renderModeB"
   ], 
   "header": {
-    "dataHash": "-1510211866509185075", 
+    "dataHash": "-5707186260478709107", 
     "isEditable": false, 
     "isExported": true, 
     "schemaVersion": 5, 
       }, 
       "imageAUrl": "changed_skp/bitmap-64bitMD5_3101044995537104462.png", 
       "imageBUrl": "changed_skp/bitmap-64bitMD5_13623922271964399662.png", 
-      "isDifferent": true
+      "isDifferent": true, 
+      "sourceJsonFile": "./summary.json"
     }, 
     {
       "extraColumns": {
       }, 
       "imageAUrl": null, 
       "imageBUrl": "only-in-after_skp/bitmap-64bitMD5_2320185040577047131.png", 
-      "isDifferent": true
+      "isDifferent": true, 
+      "sourceJsonFile": "./summary.json"
     }, 
     {
       "extraColumns": {
       }, 
       "imageAUrl": "only-in-before_skp/bitmap-64bitMD5_2320185040577047131.png", 
       "imageBUrl": null, 
-      "isDifferent": true
+      "isDifferent": true, 
+      "sourceJsonFile": "./summary.json"
     }, 
     {
       "extraColumns": {
       }, 
       "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 (file)
index 0000000..dada035
--- /dev/null
@@ -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