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