Home | History | Annotate | Download | only in skpdiff
      1 #!/usr/bin/python
      2 # -*- coding: utf-8 -*-
      3 
      4 from __future__ import print_function
      5 import argparse
      6 import BaseHTTPServer
      7 import json
      8 import os
      9 import os.path
     10 import re
     11 import subprocess
     12 import sys
     13 import tempfile
     14 import urllib2
     15 
     16 # Grab the script path because that is where all the static assets are
     17 SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
     18 
     19 # Find the tools directory for python imports
     20 TOOLS_DIR = os.path.dirname(SCRIPT_DIR)
     21 
     22 # Find the root of the skia trunk for finding skpdiff binary
     23 SKIA_ROOT_DIR = os.path.dirname(TOOLS_DIR)
     24 
     25 # Find the default location of gm expectations
     26 DEFAULT_GM_EXPECTATIONS_DIR = os.path.join(SKIA_ROOT_DIR, 'expectations', 'gm')
     27 
     28 # Imports from within Skia
     29 if TOOLS_DIR not in sys.path:
     30     sys.path.append(TOOLS_DIR)
     31 GM_DIR = os.path.join(SKIA_ROOT_DIR, 'gm')
     32 if GM_DIR not in sys.path:
     33     sys.path.append(GM_DIR)
     34 import gm_json
     35 import jsondiff
     36 
     37 # A simple dictionary of file name extensions to MIME types. The empty string
     38 # entry is used as the default when no extension was given or if the extension
     39 # has no entry in this dictionary.
     40 MIME_TYPE_MAP = {'': 'application/octet-stream',
     41                  'html': 'text/html',
     42                  'css': 'text/css',
     43                  'png': 'image/png',
     44                  'js': 'application/javascript',
     45                  'json': 'application/json'
     46                  }
     47 
     48 
     49 IMAGE_FILENAME_RE = re.compile(gm_json.IMAGE_FILENAME_PATTERN)
     50 
     51 SKPDIFF_INVOKE_FORMAT = '{} --jsonp=false -o {} -f {} {}'
     52 
     53 
     54 def get_skpdiff_path(user_path=None):
     55     """Find the skpdiff binary.
     56 
     57     @param user_path If none, searches in Release and Debug out directories of
     58            the skia root. If set, checks that the path is a real file and
     59            returns it.
     60     """
     61     skpdiff_path = None
     62     possible_paths = []
     63 
     64     # Use the user given path, or try out some good default paths.
     65     if user_path:
     66         possible_paths.append(user_path)
     67     else:
     68         possible_paths.append(os.path.join(SKIA_ROOT_DIR, 'out',
     69                                            'Release', 'skpdiff'))
     70         possible_paths.append(os.path.join(SKIA_ROOT_DIR, 'out',
     71                                            'Debug', 'skpdiff'))
     72     # Use the first path that actually points to the binary
     73     for possible_path in possible_paths:
     74         if os.path.isfile(possible_path):
     75             skpdiff_path = possible_path
     76             break
     77 
     78     # If skpdiff was not found, print out diagnostic info for the user.
     79     if skpdiff_path is None:
     80         print('Could not find skpdiff binary. Either build it into the ' +
     81               'default directory, or specify the path on the command line.')
     82         print('skpdiff paths tried:')
     83         for possible_path in possible_paths:
     84             print('   ', possible_path)
     85     return skpdiff_path
     86 
     87 
     88 def download_file(url, output_path):
     89     """Download the file at url and place it in output_path"""
     90     reader = urllib2.urlopen(url)
     91     with open(output_path, 'wb') as writer:
     92         writer.write(reader.read())
     93 
     94 
     95 def download_gm_image(image_name, image_path, hash_val):
     96     """Download the gm result into the given path.
     97 
     98     @param image_name The GM file name, for example imageblur_gpu.png.
     99     @param image_path Path to place the image.
    100     @param hash_val   The hash value of the image.
    101     """
    102     if hash_val is None:
    103         return
    104 
    105     # Separate the test name from a image name
    106     image_match = IMAGE_FILENAME_RE.match(image_name)
    107     test_name = image_match.group(1)
    108 
    109     # Calculate the URL of the requested image
    110     image_url = gm_json.CreateGmActualUrl(
    111         test_name, gm_json.JSONKEY_HASHTYPE_BITMAP_64BITMD5, hash_val)
    112 
    113     # Download the image as requested
    114     download_file(image_url, image_path)
    115 
    116 
    117 def get_image_set_from_skpdiff(skpdiff_records):
    118     """Get the set of all images references in the given records.
    119 
    120     @param skpdiff_records An array of records, which are dictionary objects.
    121     """
    122     expected_set = frozenset([r['baselinePath'] for r in skpdiff_records])
    123     actual_set = frozenset([r['testPath'] for r in skpdiff_records])
    124     return expected_set | actual_set
    125 
    126 
    127 def set_expected_hash_in_json(expected_results_json, image_name, hash_value):
    128     """Set the expected hash for the object extracted from
    129     expected-results.json. Note that this only work with bitmap-64bitMD5 hash
    130     types.
    131 
    132     @param expected_results_json The Python dictionary with the results to
    133     modify.
    134     @param image_name            The name of the image to set the hash of.
    135     @param hash_value            The hash to set for the image.
    136     """
    137     expected_results = expected_results_json[gm_json.JSONKEY_EXPECTEDRESULTS]
    138 
    139     if image_name in expected_results:
    140         expected_results[image_name][gm_json.JSONKEY_EXPECTEDRESULTS_ALLOWEDDIGESTS][0][1] = hash_value
    141     else:
    142         expected_results[image_name] = {
    143             gm_json.JSONKEY_EXPECTEDRESULTS_ALLOWEDDIGESTS:
    144             [
    145                 [
    146                     gm_json.JSONKEY_HASHTYPE_BITMAP_64BITMD5,
    147                     hash_value
    148                 ]
    149             ]
    150         }
    151 
    152 
    153 def get_head_version(path):
    154     """Get the version of the file at the given path stored inside the HEAD of
    155     the git repository. It is returned as a string.
    156 
    157     @param path The path of the file whose HEAD is returned. It is assumed the
    158     path is inside a git repo rooted at SKIA_ROOT_DIR.
    159     """
    160 
    161     # git-show will not work with absolute paths. This ensures we give it a path
    162     # relative to the skia root.
    163     git_path = os.path.relpath(path, SKIA_ROOT_DIR)
    164     git_show_proc = subprocess.Popen(['git', 'show', 'HEAD:' + git_path],
    165                                      stdout=subprocess.PIPE)
    166 
    167     # When invoked outside a shell, git will output the last committed version
    168     # of the file directly to stdout.
    169     git_version_content, _ = git_show_proc.communicate()
    170     return git_version_content
    171 
    172 
    173 class GMInstance:
    174     """Information about a GM test result on a specific device:
    175      - device_name = the name of the device that rendered it
    176      - image_name = the GM test name and config
    177      - expected_hash = the current expected hash value
    178      - actual_hash = the actual hash value
    179      - is_rebaselined = True if actual_hash is what is currently in the expected
    180                         results file, False otherwise.
    181     """
    182     def __init__(self,
    183                  device_name, image_name,
    184                  expected_hash, actual_hash,
    185                  is_rebaselined):
    186         self.device_name = device_name
    187         self.image_name = image_name
    188         self.expected_hash = expected_hash
    189         self.actual_hash = actual_hash
    190         self.is_rebaselined = is_rebaselined
    191 
    192 
    193 class ExpectationsManager:
    194     def __init__(self, expectations_dir, expected_name, updated_name,
    195                  skpdiff_path):
    196         """
    197         @param expectations_dir   The directory to traverse for results files.
    198                This should resemble expectations/gm in the Skia trunk.
    199         @param expected_name      The name of the expected result files. These
    200                are in the format of expected-results.json.
    201         @param updated_name       The name of the updated expected result files.
    202                Normally this matches --expectations-filename-output for the
    203                rebaseline.py tool.
    204         @param skpdiff_path       The path used to execute the skpdiff command.
    205         """
    206         self._expectations_dir = expectations_dir
    207         self._expected_name = expected_name
    208         self._updated_name = updated_name
    209         self._skpdiff_path = skpdiff_path
    210         self._generate_gm_comparison()
    211 
    212     def _generate_gm_comparison(self):
    213         """Generate all the data needed to compare GMs:
    214            - determine which GMs changed
    215            - download the changed images
    216            - compare them with skpdiff
    217         """
    218 
    219         # Get the expectations and compare them with actual hashes
    220         self._get_expectations()
    221 
    222 
    223         # Create a temporary file tree that makes sense for skpdiff to operate
    224         # on.
    225         image_output_dir = tempfile.mkdtemp('skpdiff')
    226         expected_image_dir = os.path.join(image_output_dir, 'expected')
    227         actual_image_dir = os.path.join(image_output_dir, 'actual')
    228         os.mkdir(expected_image_dir)
    229         os.mkdir(actual_image_dir)
    230 
    231         # Download expected and actual images that differed into the temporary
    232         # file tree.
    233         self._download_expectation_images(expected_image_dir, actual_image_dir)
    234 
    235         # Invoke skpdiff with our downloaded images and place its results in the
    236         # temporary directory.
    237         self._skpdiff_output_path = os.path.join(image_output_dir,
    238                                                 'skpdiff_output.json')
    239         skpdiff_cmd = SKPDIFF_INVOKE_FORMAT.format(self._skpdiff_path,
    240                                                    self._skpdiff_output_path,
    241                                                    expected_image_dir,
    242                                                    actual_image_dir)
    243         os.system(skpdiff_cmd)
    244         self._load_skpdiff_output()
    245 
    246 
    247     def _get_expectations(self):
    248         """Fills self._expectations with GMInstance objects for each test whose
    249         expectation is different between the following two files:
    250          - the local filesystem's updated results file
    251          - git's head version of the expected results file
    252         """
    253         differ = jsondiff.GMDiffer()
    254         self._expectations = []
    255         for root, dirs, files in os.walk(self._expectations_dir):
    256             for expectation_file in files:
    257                 # There are many files in the expectations directory. We only
    258                 # care about expected results.
    259                 if expectation_file != self._expected_name:
    260                     continue
    261 
    262                 # Get the name of the results file, and be sure there is an
    263                 # updated result to compare against. If there is not, there is
    264                 # no point in diffing this device.
    265                 expected_file_path = os.path.join(root, self._expected_name)
    266                 updated_file_path = os.path.join(root, self._updated_name)
    267                 if not os.path.isfile(updated_file_path):
    268                     continue
    269 
    270                 # Always get the expected results from git because we may have
    271                 # changed them in a previous instance of the server.
    272                 expected_contents = get_head_version(expected_file_path)
    273                 updated_contents = None
    274                 with open(updated_file_path, 'rb') as updated_file:
    275                     updated_contents = updated_file.read()
    276 
    277                 # Read the expected results on disk to determine what we've
    278                 # already rebaselined.
    279                 commited_contents = None
    280                 with open(expected_file_path, 'rb') as expected_file:
    281                     commited_contents = expected_file.read()
    282 
    283                 # Find all expectations that did not match.
    284                 expected_diff = differ.GenerateDiffDictFromStrings(
    285                     expected_contents,
    286                     updated_contents)
    287 
    288                 # Generate a set of images that have already been rebaselined
    289                 # onto disk.
    290                 rebaselined_diff = differ.GenerateDiffDictFromStrings(
    291                     expected_contents,
    292                     commited_contents)
    293 
    294                 rebaselined_set = set(rebaselined_diff.keys())
    295 
    296                 # The name of the device corresponds to the name of the folder
    297                 # we are in.
    298                 device_name = os.path.basename(root)
    299 
    300                 # Store old and new versions of the expectation for each GM
    301                 for image_name, hashes in expected_diff.iteritems():
    302                     self._expectations.append(
    303                         GMInstance(device_name, image_name,
    304                                    hashes['old'], hashes['new'],
    305                                    image_name in rebaselined_set))
    306 
    307     def _load_skpdiff_output(self):
    308         """Loads the results of skpdiff and annotates them with whether they
    309         have already been rebaselined or not. The resulting data is store in
    310         self.skpdiff_records."""
    311         self.skpdiff_records = None
    312         with open(self._skpdiff_output_path, 'rb') as skpdiff_output_file:
    313             self.skpdiff_records = json.load(skpdiff_output_file)['records']
    314             for record in self.skpdiff_records:
    315                 record['isRebaselined'] = self.image_map[record['baselinePath']][1].is_rebaselined
    316 
    317 
    318     def _download_expectation_images(self, expected_image_dir, actual_image_dir):
    319         """Download the expected and actual images for the _expectations array.
    320 
    321         @param expected_image_dir The directory to download expected images
    322                into.
    323         @param actual_image_dir   The directory to download actual images into.
    324         """
    325         image_map = {}
    326 
    327         # Look through expectations and download their images.
    328         for expectation in self._expectations:
    329             # Build appropriate paths to download the images into.
    330             expected_image_path = os.path.join(expected_image_dir,
    331                                                expectation.device_name + '-' +
    332                                                expectation.image_name)
    333 
    334             actual_image_path = os.path.join(actual_image_dir,
    335                                              expectation.device_name + '-' +
    336                                              expectation.image_name)
    337 
    338             print('Downloading %s for device %s' % (
    339                 expectation.image_name, expectation.device_name))
    340 
    341             # Download images
    342             download_gm_image(expectation.image_name,
    343                               expected_image_path,
    344                               expectation.expected_hash)
    345 
    346             download_gm_image(expectation.image_name,
    347                               actual_image_path,
    348                               expectation.actual_hash)
    349 
    350             # Annotate the expectations with where the images were downloaded
    351             # to.
    352             expectation.expected_image_path = expected_image_path
    353             expectation.actual_image_path = actual_image_path
    354 
    355             # Map the image paths back to the expectations.
    356             image_map[expected_image_path] = (False, expectation)
    357             image_map[actual_image_path] = (True, expectation)
    358 
    359         self.image_map = image_map
    360 
    361     def _set_expected_hash(self, device_name, image_name, hash_value):
    362         """Set the expected hash for the image of the given device. This always
    363         writes directly to the expected results file of the given device
    364 
    365         @param device_name The name of the device to write the hash to.
    366         @param image_name  The name of the image whose hash to set.
    367         @param hash_value  The value of the hash to set.
    368         """
    369 
    370         # Retrieve the expected results file as it is in the working tree
    371         json_path = os.path.join(self._expectations_dir, device_name,
    372                                  self._expected_name)
    373         expectations = gm_json.LoadFromFile(json_path)
    374 
    375         # Set the specified hash.
    376         set_expected_hash_in_json(expectations, image_name, hash_value)
    377 
    378         # Write it out to disk using gm_json to keep the formatting consistent.
    379         gm_json.WriteToFile(expectations, json_path)
    380 
    381     def commit_rebaselines(self, rebaselines):
    382         """Sets the expected results file to use the hashes of the images in
    383         the rebaselines list. If a expected result image is not in rebaselines
    384         at all, the old hash will be used.
    385 
    386         @param rebaselines A list of image paths to use the hash of.
    387         """
    388         # Reset all expectations to their old hashes because some of them may
    389         # have been set to the new hash by a previous call to this function.
    390         for expectation in self._expectations:
    391             expectation.is_rebaselined = False
    392             self._set_expected_hash(expectation.device_name,
    393                                     expectation.image_name,
    394                                     expectation.expected_hash)
    395 
    396         # Take all the images to rebaseline
    397         for image_path in rebaselines:
    398             # Get the metadata about the image at the path.
    399             is_actual, expectation = self.image_map[image_path]
    400 
    401             expectation.is_rebaselined = is_actual
    402             expectation_hash = expectation.actual_hash if is_actual else\
    403                                expectation.expected_hash
    404 
    405             # Write out that image's hash directly to the expected results file.
    406             self._set_expected_hash(expectation.device_name,
    407                                     expectation.image_name,
    408                                     expectation_hash)
    409 
    410         self._load_skpdiff_output()
    411 
    412 
    413 class SkPDiffHandler(BaseHTTPServer.BaseHTTPRequestHandler):
    414     def send_file(self, file_path):
    415         # Grab the extension if there is one
    416         extension = os.path.splitext(file_path)[1]
    417         if len(extension) >= 1:
    418             extension = extension[1:]
    419 
    420         # Determine the MIME type of the file from its extension
    421         mime_type = MIME_TYPE_MAP.get(extension, MIME_TYPE_MAP[''])
    422 
    423         # Open the file and send it over HTTP
    424         if os.path.isfile(file_path):
    425             with open(file_path, 'rb') as sending_file:
    426                 self.send_response(200)
    427                 self.send_header('Content-type', mime_type)
    428                 self.end_headers()
    429                 self.wfile.write(sending_file.read())
    430         else:
    431             self.send_error(404)
    432 
    433     def serve_if_in_dir(self, dir_path, file_path):
    434         # Determine if the file exists relative to the given dir_path AND exists
    435         # under the dir_path. This is to prevent accidentally serving files
    436         # outside the directory intended using symlinks, or '../'.
    437         real_path = os.path.normpath(os.path.join(dir_path, file_path))
    438         if os.path.commonprefix([real_path, dir_path]) == dir_path:
    439             if os.path.isfile(real_path):
    440                 self.send_file(real_path)
    441                 return True
    442         return False
    443 
    444     def do_GET(self):
    445         # Simple rewrite rule of the root path to 'viewer.html'
    446         if self.path == '' or self.path == '/':
    447             self.path = '/viewer.html'
    448 
    449         # The [1:] chops off the leading '/'
    450         file_path = self.path[1:]
    451 
    452         # Handle skpdiff_output.json manually because it is was processed by the
    453         # server when it was started and does not exist as a file.
    454         if file_path == 'skpdiff_output.json':
    455             self.send_response(200)
    456             self.send_header('Content-type', MIME_TYPE_MAP['json'])
    457             self.end_headers()
    458 
    459             # Add JSONP padding to the JSON because the web page expects it. It
    460             # expects it because it was designed to run with or without a web
    461             # server. Without a web server, the only way to load JSON is with
    462             # JSONP.
    463             skpdiff_records = self.server.expectations_manager.skpdiff_records
    464             self.wfile.write('var SkPDiffRecords = ')
    465             json.dump({'records': skpdiff_records}, self.wfile)
    466             self.wfile.write(';')
    467             return
    468 
    469         # Attempt to send static asset files first.
    470         if self.serve_if_in_dir(SCRIPT_DIR, file_path):
    471             return
    472 
    473         # WARNING: Serving any file the user wants is incredibly insecure. Its
    474         # redeeming quality is that we only serve gm files on a white list.
    475         if self.path in self.server.image_set:
    476             self.send_file(self.path)
    477             return
    478 
    479         # If no file to send was found, just give the standard 404
    480         self.send_error(404)
    481 
    482     def do_POST(self):
    483         if self.path == '/commit_rebaselines':
    484             content_length = int(self.headers['Content-length'])
    485             request_data = json.loads(self.rfile.read(content_length))
    486             rebaselines = request_data['rebaselines']
    487             self.server.expectations_manager.commit_rebaselines(rebaselines)
    488             self.send_response(200)
    489             self.send_header('Content-type', 'application/json')
    490             self.end_headers()
    491             self.wfile.write('{"success":true}')
    492             return
    493 
    494         # If the we have no handler for this path, give em' the 404
    495         self.send_error(404)
    496 
    497 
    498 def run_server(expectations_manager, port=8080):
    499     # It's important to parse the results file so that we can make a set of
    500     # images that the web page might request.
    501     skpdiff_records = expectations_manager.skpdiff_records
    502     image_set = get_image_set_from_skpdiff(skpdiff_records)
    503 
    504     # Do not bind to interfaces other than localhost because the server will
    505     # attempt to serve files relative to the root directory as a last resort
    506     # before 404ing. This means all of your files can be accessed from this
    507     # server, so DO NOT let this server listen to anything but localhost.
    508     server_address = ('127.0.0.1', port)
    509     http_server = BaseHTTPServer.HTTPServer(server_address, SkPDiffHandler)
    510     http_server.image_set = image_set
    511     http_server.expectations_manager = expectations_manager
    512     print('Navigate thine browser to: http://{}:{}/'.format(*server_address))
    513     http_server.serve_forever()
    514 
    515 
    516 def main():
    517     parser = argparse.ArgumentParser()
    518     parser.add_argument('--port', '-p', metavar='PORT',
    519                         type=int,
    520                         default=8080,
    521                         help='port to bind the server to; ' +
    522                         'defaults to %(default)s',
    523                         )
    524 
    525     parser.add_argument('--expectations-dir', metavar='EXPECTATIONS_DIR',
    526                         default=DEFAULT_GM_EXPECTATIONS_DIR,
    527                         help='path to the gm expectations; ' +
    528                         'defaults to %(default)s'
    529                         )
    530 
    531     parser.add_argument('--expected',
    532                         metavar='EXPECTATIONS_FILE_NAME',
    533                         default='expected-results.json',
    534                         help='the file name of the expectations JSON; ' +
    535                         'defaults to %(default)s'
    536                         )
    537 
    538     parser.add_argument('--updated',
    539                         metavar='UPDATED_FILE_NAME',
    540                         default='updated-results.json',
    541                         help='the file name of the updated expectations JSON;' +
    542                         ' defaults to %(default)s'
    543                         )
    544 
    545     parser.add_argument('--skpdiff-path', metavar='SKPDIFF_PATH',
    546                         default=None,
    547                         help='the path to the skpdiff binary to use; ' +
    548                         'defaults to out/Release/skpdiff or out/Default/skpdiff'
    549                         )
    550 
    551     args = vars(parser.parse_args())  # Convert args into a python dict
    552 
    553     # Make sure we have access to an skpdiff binary
    554     skpdiff_path = get_skpdiff_path(args['skpdiff_path'])
    555     if skpdiff_path is None:
    556         sys.exit(1)
    557 
    558     # Print out the paths of things for easier debugging
    559     print('script dir         :', SCRIPT_DIR)
    560     print('tools dir          :', TOOLS_DIR)
    561     print('root dir           :', SKIA_ROOT_DIR)
    562     print('expectations dir   :', args['expectations_dir'])
    563     print('skpdiff path       :', skpdiff_path)
    564 
    565     expectations_manager = ExpectationsManager(args['expectations_dir'],
    566                                                args['expected'],
    567                                                args['updated'],
    568                                                skpdiff_path)
    569 
    570     run_server(expectations_manager, port=args['port'])
    571 
    572 if __name__ == '__main__':
    573     main()
    574