1#!/usr/bin/python 2 3""" 4Copyright 2013 Google Inc. 5 6Use of this source code is governed by a BSD-style license that can be 7found in the LICENSE file. 8 9HTTP server for our HTML rebaseline viewer. 10""" 11 12# System-level imports 13import argparse 14import BaseHTTPServer 15import json 16import logging 17import os 18import posixpath 19import re 20import shutil 21import socket 22import subprocess 23import thread 24import threading 25import time 26import urllib 27import urlparse 28 29# Must fix up PYTHONPATH before importing from within Skia 30import rs_fixpypath # pylint: disable=W0611 31 32# Imports from within Skia 33from py.utils import gs_utils 34import buildbot_globals 35import gm_json 36 37# Imports from local dir 38# 39# pylint: disable=C0301 40# Note: we import results under a different name, to avoid confusion with the 41# Server.results() property. See discussion at 42# https://codereview.chromium.org/195943004/diff/1/gm/rebaseline_server/server.py#newcode44 43# pylint: enable=C0301 44import compare_configs 45import compare_rendered_pictures 46import compare_to_expectations 47import download_actuals 48import imagediffdb 49import imagepairset 50import results as results_mod 51import writable_expectations as writable_expectations_mod 52 53 54PATHSPLIT_RE = re.compile('/([^/]+)/(.+)') 55 56# A simple dictionary of file name extensions to MIME types. The empty string 57# entry is used as the default when no extension was given or if the extension 58# has no entry in this dictionary. 59MIME_TYPE_MAP = {'': 'application/octet-stream', 60 'html': 'text/html', 61 'css': 'text/css', 62 'png': 'image/png', 63 'js': 'application/javascript', 64 'json': 'application/json' 65 } 66 67# Keys that server.py uses to create the toplevel content header. 68# NOTE: Keep these in sync with static/constants.js 69KEY__EDITS__MODIFICATIONS = 'modifications' 70KEY__EDITS__OLD_RESULTS_HASH = 'oldResultsHash' 71KEY__EDITS__OLD_RESULTS_TYPE = 'oldResultsType' 72KEY__LIVE_EDITS__MODIFICATIONS = 'modifications' 73KEY__LIVE_EDITS__SET_A_DESCRIPTIONS = 'setA' 74KEY__LIVE_EDITS__SET_B_DESCRIPTIONS = 'setB' 75 76DEFAULT_ACTUALS_DIR = results_mod.DEFAULT_ACTUALS_DIR 77DEFAULT_GM_SUMMARIES_BUCKET = download_actuals.GM_SUMMARIES_BUCKET 78DEFAULT_JSON_FILENAME = download_actuals.DEFAULT_JSON_FILENAME 79DEFAULT_PORT = 8888 80 81PARENT_DIRECTORY = os.path.dirname(os.path.realpath(__file__)) 82TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(PARENT_DIRECTORY)) 83 84# Directory, relative to PARENT_DIRECTORY, within which the server will serve 85# out static files. 86STATIC_CONTENTS_SUBDIR = 'static' 87# All of the GENERATED_*_SUBDIRS are relative to STATIC_CONTENTS_SUBDIR 88GENERATED_HTML_SUBDIR = 'generated-html' 89GENERATED_IMAGES_SUBDIR = 'generated-images' 90GENERATED_JSON_SUBDIR = 'generated-json' 91 92# Directives associated with various HTTP GET requests. 93GET__LIVE_RESULTS = 'live-results' 94GET__PRECOMPUTED_RESULTS = 'results' 95GET__PREFETCH_RESULTS = 'prefetch' 96GET__STATIC_CONTENTS = 'static' 97 98# Parameters we use within do_GET_live_results() and do_GET_prefetch_results() 99LIVE_PARAM__DOWNLOAD_ONLY_DIFFERING = 'downloadOnlyDifferingImages' 100LIVE_PARAM__SET_A_DIR = 'setADir' 101LIVE_PARAM__SET_A_SECTION = 'setASection' 102LIVE_PARAM__SET_B_DIR = 'setBDir' 103LIVE_PARAM__SET_B_SECTION = 'setBSection' 104 105# How often (in seconds) clients should reload while waiting for initial 106# results to load. 107RELOAD_INTERVAL_UNTIL_READY = 10 108 109_GM_SUMMARY_TYPES = [ 110 results_mod.KEY__HEADER__RESULTS_FAILURES, 111 results_mod.KEY__HEADER__RESULTS_ALL, 112] 113# If --compare-configs is specified, compare these configs. 114CONFIG_PAIRS_TO_COMPARE = [('8888', 'gpu')] 115 116# SKP results that are available to compare. 117# 118# TODO(stephana): We don't actually want to maintain this list of platforms. 119# We are just putting them in here for now, as "convenience" links for testing 120# SKP diffs. 121# Ultimately, we will depend on buildbot steps linking to their own diffs on 122# the shared rebaseline_server instance. 123_SKP_BASE_GS_URL = 'gs://' + buildbot_globals.Get('skp_summaries_bucket') 124_SKP_BASE_REPO_URL = ( 125 compare_rendered_pictures.REPO_URL_PREFIX + posixpath.join( 126 'expectations', 'skp')) 127_SKP_PLATFORMS = [ 128 'Test-Mac10.8-MacMini4.1-GeForce320M-x86_64-Debug', 129 'Test-Ubuntu12-ShuttleA-GTX660-x86-Release', 130] 131 132_HTTP_HEADER_CONTENT_LENGTH = 'Content-Length' 133_HTTP_HEADER_CONTENT_TYPE = 'Content-Type' 134 135_SERVER = None # This gets filled in by main() 136 137 138def _run_command(args, directory): 139 """Runs a command and returns stdout as a single string. 140 141 Args: 142 args: the command to run, as a list of arguments 143 directory: directory within which to run the command 144 145 Returns: stdout, as a string 146 147 Raises an Exception if the command failed (exited with nonzero return code). 148 """ 149 logging.debug('_run_command: %s in directory %s' % (args, directory)) 150 proc = subprocess.Popen(args, cwd=directory, 151 stdout=subprocess.PIPE, 152 stderr=subprocess.PIPE) 153 (stdout, stderr) = proc.communicate() 154 if proc.returncode is not 0: 155 raise Exception('command "%s" failed in dir "%s": %s' % 156 (args, directory, stderr)) 157 return stdout 158 159 160def _get_routable_ip_address(): 161 """Returns routable IP address of this host (the IP address of its network 162 interface that would be used for most traffic, not its localhost 163 interface). See http://stackoverflow.com/a/166589 """ 164 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 165 sock.connect(('8.8.8.8', 80)) 166 host = sock.getsockname()[0] 167 sock.close() 168 return host 169 170 171def _create_index(file_path, config_pairs): 172 """Creates an index file linking to all results available from this server. 173 174 Prior to https://codereview.chromium.org/215503002 , we had a static 175 index.html within our repo. But now that the results may or may not include 176 config comparisons, index.html needs to be generated differently depending 177 on which results are included. 178 179 TODO(epoger): Instead of including raw HTML within the Python code, 180 consider restoring the index.html file as a template and using django (or 181 similar) to fill in dynamic content. 182 183 Args: 184 file_path: path on local disk to write index to; any directory components 185 of this path that do not already exist will be created 186 config_pairs: what pairs of configs (if any) we compare actual results of 187 """ 188 dir_path = os.path.dirname(file_path) 189 if not os.path.isdir(dir_path): 190 os.makedirs(dir_path) 191 with open(file_path, 'w') as file_handle: 192 file_handle.write( 193 '<!DOCTYPE html><html>' 194 '<head><title>rebaseline_server</title></head>' 195 '<body><ul>') 196 197 if _GM_SUMMARY_TYPES: 198 file_handle.write('<li>GM Expectations vs Actuals</li><ul>') 199 for summary_type in _GM_SUMMARY_TYPES: 200 file_handle.write( 201 '\n<li><a href="/{static_directive}/view.html#/view.html?' 202 'resultsToLoad=/{results_directive}/{summary_type}">' 203 '{summary_type}</a></li>'.format( 204 results_directive=GET__PRECOMPUTED_RESULTS, 205 static_directive=GET__STATIC_CONTENTS, 206 summary_type=summary_type)) 207 file_handle.write('</ul>') 208 209 if config_pairs: 210 file_handle.write( 211 '\n<li>Comparing configs within actual GM results</li><ul>') 212 for config_pair in config_pairs: 213 file_handle.write('<li>%s vs %s:' % config_pair) 214 for summary_type in _GM_SUMMARY_TYPES: 215 file_handle.write( 216 ' <a href="/%s/view.html#/view.html?' 217 'resultsToLoad=/%s/%s/%s-vs-%s_%s.json">%s</a>' % ( 218 GET__STATIC_CONTENTS, GET__STATIC_CONTENTS, 219 GENERATED_JSON_SUBDIR, config_pair[0], config_pair[1], 220 summary_type, summary_type)) 221 file_handle.write('</li>') 222 file_handle.write('</ul>') 223 224 if _SKP_PLATFORMS: 225 file_handle.write('\n<li>Rendered SKPs:<ul>') 226 for builder in _SKP_PLATFORMS: 227 file_handle.write( 228 '\n<li><a href="../live-view.html#live-view.html?%s">' % 229 urllib.urlencode({ 230 LIVE_PARAM__SET_A_SECTION: 231 gm_json.JSONKEY_EXPECTEDRESULTS, 232 LIVE_PARAM__SET_A_DIR: 233 posixpath.join(_SKP_BASE_REPO_URL, builder), 234 LIVE_PARAM__SET_B_SECTION: 235 gm_json.JSONKEY_ACTUALRESULTS, 236 LIVE_PARAM__SET_B_DIR: 237 posixpath.join(_SKP_BASE_GS_URL, builder), 238 })) 239 file_handle.write('expected vs actuals on %s</a></li>' % builder) 240 file_handle.write( 241 '\n<li><a href="../live-view.html#live-view.html?%s">' % 242 urllib.urlencode({ 243 LIVE_PARAM__SET_A_SECTION: 244 gm_json.JSONKEY_ACTUALRESULTS, 245 LIVE_PARAM__SET_A_DIR: 246 posixpath.join(_SKP_BASE_GS_URL, _SKP_PLATFORMS[0]), 247 LIVE_PARAM__SET_B_SECTION: 248 gm_json.JSONKEY_ACTUALRESULTS, 249 LIVE_PARAM__SET_B_DIR: 250 posixpath.join(_SKP_BASE_GS_URL, _SKP_PLATFORMS[1]), 251 })) 252 file_handle.write('actuals on %s vs %s</a></li>' % ( 253 _SKP_PLATFORMS[0], _SKP_PLATFORMS[1])) 254 file_handle.write('</li>') 255 256 file_handle.write('\n</ul></body></html>') 257 258 259class Server(object): 260 """ HTTP server for our HTML rebaseline viewer. """ 261 262 def __init__(self, 263 actuals_dir=DEFAULT_ACTUALS_DIR, 264 json_filename=DEFAULT_JSON_FILENAME, 265 gm_summaries_bucket=DEFAULT_GM_SUMMARIES_BUCKET, 266 port=DEFAULT_PORT, export=False, editable=True, 267 reload_seconds=0, config_pairs=None, builder_regex_list=None, 268 boto_file_path=None, 269 imagediffdb_threads=imagediffdb.DEFAULT_NUM_WORKER_THREADS): 270 """ 271 Args: 272 actuals_dir: directory under which we will check out the latest actual 273 GM results 274 json_filename: basename of the JSON summary file to load for each builder 275 gm_summaries_bucket: Google Storage bucket to download json_filename 276 files from; if None or '', don't fetch new actual-results files 277 at all, just compare to whatever files are already in actuals_dir 278 port: which TCP port to listen on for HTTP requests 279 export: whether to allow HTTP clients on other hosts to access this server 280 editable: whether HTTP clients are allowed to submit new GM baselines 281 (SKP baseline modifications are performed using an entirely different 282 mechanism, not affected by this parameter) 283 reload_seconds: polling interval with which to check for new results; 284 if 0, don't check for new results at all 285 config_pairs: List of (string, string) tuples; for each tuple, compare 286 actual results of these two configs. If None or empty, 287 don't compare configs at all. 288 builder_regex_list: List of regular expressions specifying which builders 289 we will process. If None, process all builders. 290 boto_file_path: Path to .boto file giving us credentials to access 291 Google Storage buckets; if None, we will only be able to access 292 public GS buckets. 293 imagediffdb_threads: How many threads to spin up within imagediffdb. 294 """ 295 self._actuals_dir = actuals_dir 296 self._json_filename = json_filename 297 self._gm_summaries_bucket = gm_summaries_bucket 298 self._port = port 299 self._export = export 300 self._editable = editable 301 self._reload_seconds = reload_seconds 302 self._config_pairs = config_pairs or [] 303 self._builder_regex_list = builder_regex_list 304 self.truncate_results = False 305 306 if boto_file_path: 307 self._gs = gs_utils.GSUtils(boto_file_path=boto_file_path) 308 else: 309 self._gs = gs_utils.GSUtils() 310 311 _create_index( 312 file_path=os.path.join( 313 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR, GENERATED_HTML_SUBDIR, 314 "index.html"), 315 config_pairs=config_pairs) 316 317 # Reentrant lock that must be held whenever updating EITHER of: 318 # 1. self._results 319 # 2. the expected or actual results on local disk 320 self.results_rlock = threading.RLock() 321 322 # Create a single ImageDiffDB instance that is used by all our differs. 323 self._image_diff_db = imagediffdb.ImageDiffDB( 324 gs=self._gs, 325 storage_root=os.path.join( 326 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR, 327 GENERATED_IMAGES_SUBDIR), 328 num_worker_threads=imagediffdb_threads) 329 330 # This will be filled in by calls to update_results() 331 self._results = None 332 333 @property 334 def results(self): 335 """ Returns the most recently generated results, or None if we don't have 336 any valid results (update_results() has not completed yet). """ 337 return self._results 338 339 @property 340 def image_diff_db(self): 341 """ Returns reference to our ImageDiffDB object.""" 342 return self._image_diff_db 343 344 @property 345 def gs(self): 346 """ Returns reference to our GSUtils object.""" 347 return self._gs 348 349 @property 350 def is_exported(self): 351 """ Returns true iff HTTP clients on other hosts are allowed to access 352 this server. """ 353 return self._export 354 355 @property 356 def is_editable(self): 357 """ True iff HTTP clients are allowed to submit new GM baselines. 358 359 TODO(epoger): This only pertains to GM baselines; SKP baselines are 360 editable whenever expectations vs actuals are shown. 361 Once we move the GM baselines to use the same code as the SKP baselines, 362 we can delete this property. 363 """ 364 return self._editable 365 366 @property 367 def reload_seconds(self): 368 """ Returns the result reload period in seconds, or 0 if we don't reload 369 results. """ 370 return self._reload_seconds 371 372 def update_results(self, invalidate=False): 373 """ Create or update self._results, based on the latest expectations and 374 actuals. 375 376 We hold self.results_rlock while we do this, to guarantee that no other 377 thread attempts to update either self._results or the underlying files at 378 the same time. 379 380 Args: 381 invalidate: if True, invalidate self._results immediately upon entry; 382 otherwise, we will let readers see those results until we 383 replace them 384 """ 385 with self.results_rlock: 386 if invalidate: 387 self._results = None 388 if self._gm_summaries_bucket: 389 logging.info( 390 'Updating GM result summaries in %s from gm_summaries_bucket %s ...' 391 % (self._actuals_dir, self._gm_summaries_bucket)) 392 393 # Clean out actuals_dir first, in case some builders have gone away 394 # since we last ran. 395 if os.path.isdir(self._actuals_dir): 396 shutil.rmtree(self._actuals_dir) 397 398 # Get the list of builders we care about. 399 all_builders = download_actuals.get_builders_list( 400 summaries_bucket=self._gm_summaries_bucket) 401 if self._builder_regex_list: 402 matching_builders = [] 403 for builder in all_builders: 404 for regex in self._builder_regex_list: 405 if re.match(regex, builder): 406 matching_builders.append(builder) 407 break # go on to the next builder, no need to try more regexes 408 else: 409 matching_builders = all_builders 410 411 # Download the JSON file for each builder we care about. 412 # 413 # TODO(epoger): When this is a large number of builders, we would be 414 # better off downloading them in parallel! 415 for builder in matching_builders: 416 self._gs.download_file( 417 source_bucket=self._gm_summaries_bucket, 418 source_path=posixpath.join(builder, self._json_filename), 419 dest_path=os.path.join(self._actuals_dir, builder, 420 self._json_filename), 421 create_subdirs_if_needed=True) 422 423 # We only update the expectations dir if the server was run with a 424 # nonzero --reload argument; otherwise, we expect the user to maintain 425 # her own expectations as she sees fit. 426 # 427 # Because the Skia repo is hosted using git, and git does not 428 # support updating a single directory tree, we have to update the entire 429 # repo checkout. 430 # 431 # Because Skia uses depot_tools, we have to update using "gclient sync" 432 # instead of raw git commands. 433 # 434 # TODO(epoger): Fetch latest expectations in some other way. 435 # Eric points out that our official documentation recommends an 436 # unmanaged Skia checkout, so "gclient sync" will not bring down updated 437 # expectations from origin/master-- you'd have to do a "git pull" of 438 # some sort instead. 439 # However, the live rebaseline_server at 440 # http://skia-tree-status.appspot.com/redirect/rebaseline-server (which 441 # is probably the only user of the --reload flag!) uses a managed 442 # checkout, so "gclient sync" works in that case. 443 # Probably the best idea is to avoid all of this nonsense by fetching 444 # updated expectations into a temp directory, and leaving the rest of 445 # the checkout alone. This could be done using "git show", or by 446 # downloading individual expectation JSON files from 447 # skia.googlesource.com . 448 if self._reload_seconds: 449 logging.info( 450 'Updating expected GM results in %s by syncing Skia repo ...' % 451 compare_to_expectations.DEFAULT_EXPECTATIONS_DIR) 452 _run_command(['gclient', 'sync'], TRUNK_DIRECTORY) 453 454 self._results = compare_to_expectations.ExpectationComparisons( 455 image_diff_db=self._image_diff_db, 456 actuals_root=self._actuals_dir, 457 diff_base_url=posixpath.join( 458 os.pardir, STATIC_CONTENTS_SUBDIR, GENERATED_IMAGES_SUBDIR), 459 builder_regex_list=self._builder_regex_list) 460 461 json_dir = os.path.join( 462 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR, GENERATED_JSON_SUBDIR) 463 if not os.path.isdir(json_dir): 464 os.makedirs(json_dir) 465 466 for config_pair in self._config_pairs: 467 config_comparisons = compare_configs.ConfigComparisons( 468 configs=config_pair, 469 actuals_root=self._actuals_dir, 470 generated_images_root=os.path.join( 471 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR, 472 GENERATED_IMAGES_SUBDIR), 473 diff_base_url=posixpath.join( 474 os.pardir, GENERATED_IMAGES_SUBDIR), 475 builder_regex_list=self._builder_regex_list) 476 for summary_type in _GM_SUMMARY_TYPES: 477 gm_json.WriteToFile( 478 config_comparisons.get_packaged_results_of_type( 479 results_type=summary_type), 480 os.path.join( 481 json_dir, '%s-vs-%s_%s.json' % ( 482 config_pair[0], config_pair[1], summary_type))) 483 484 def _result_loader(self, reload_seconds=0): 485 """ Call self.update_results(), either once or periodically. 486 487 Params: 488 reload_seconds: integer; if nonzero, reload results at this interval 489 (in which case, this method will never return!) 490 """ 491 self.update_results() 492 logging.info('Initial results loaded. Ready for requests on %s' % self._url) 493 if reload_seconds: 494 while True: 495 time.sleep(reload_seconds) 496 self.update_results() 497 498 def run(self): 499 arg_tuple = (self._reload_seconds,) # start_new_thread needs a tuple, 500 # even though it holds just one param 501 thread.start_new_thread(self._result_loader, arg_tuple) 502 503 if self._export: 504 server_address = ('', self._port) 505 host = _get_routable_ip_address() 506 if self._editable: 507 logging.warning('Running with combination of "export" and "editable" ' 508 'flags. Users on other machines will ' 509 'be able to modify your GM expectations!') 510 else: 511 host = '127.0.0.1' 512 server_address = (host, self._port) 513 # pylint: disable=W0201 514 http_server = BaseHTTPServer.HTTPServer(server_address, HTTPRequestHandler) 515 self._url = 'http://%s:%d' % (host, http_server.server_port) 516 logging.info('Listening for requests on %s' % self._url) 517 http_server.serve_forever() 518 519 520class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): 521 """ HTTP request handlers for various types of queries this server knows 522 how to handle (static HTML and Javascript, expected/actual results, etc.) 523 """ 524 def do_GET(self): 525 """ 526 Handles all GET requests, forwarding them to the appropriate 527 do_GET_* dispatcher. 528 529 If we see any Exceptions, return a 404. This fixes http://skbug.com/2147 530 """ 531 try: 532 logging.debug('do_GET: path="%s"' % self.path) 533 if self.path == '' or self.path == '/' or self.path == '/index.html' : 534 self.redirect_to('/%s/%s/index.html' % ( 535 GET__STATIC_CONTENTS, GENERATED_HTML_SUBDIR)) 536 return 537 if self.path == '/favicon.ico' : 538 self.redirect_to('/%s/favicon.ico' % GET__STATIC_CONTENTS) 539 return 540 541 # All requests must be of this form: 542 # /dispatcher/remainder 543 # where 'dispatcher' indicates which do_GET_* dispatcher to run 544 # and 'remainder' is the remaining path sent to that dispatcher. 545 (dispatcher_name, remainder) = PATHSPLIT_RE.match(self.path).groups() 546 dispatchers = { 547 GET__LIVE_RESULTS: self.do_GET_live_results, 548 GET__PRECOMPUTED_RESULTS: self.do_GET_precomputed_results, 549 GET__PREFETCH_RESULTS: self.do_GET_prefetch_results, 550 GET__STATIC_CONTENTS: self.do_GET_static, 551 } 552 dispatcher = dispatchers[dispatcher_name] 553 dispatcher(remainder) 554 except: 555 self.send_error(404) 556 raise 557 558 def do_GET_precomputed_results(self, results_type): 559 """ Handle a GET request for part of the precomputed _SERVER.results object. 560 561 Args: 562 results_type: string indicating which set of results to return; 563 must be one of the results_mod.RESULTS_* constants 564 """ 565 logging.debug('do_GET_precomputed_results: sending results of type "%s"' % 566 results_type) 567 # Since we must make multiple calls to the ExpectationComparisons object, 568 # grab a reference to it in case it is updated to point at a new 569 # ExpectationComparisons object within another thread. 570 # 571 # TODO(epoger): Rather than using a global variable for the handler 572 # to refer to the Server object, make Server a subclass of 573 # HTTPServer, and then it could be available to the handler via 574 # the handler's .server instance variable. 575 results_obj = _SERVER.results 576 if results_obj: 577 response_dict = results_obj.get_packaged_results_of_type( 578 results_type=results_type, reload_seconds=_SERVER.reload_seconds, 579 is_editable=_SERVER.is_editable, is_exported=_SERVER.is_exported) 580 else: 581 now = int(time.time()) 582 response_dict = { 583 imagepairset.KEY__ROOT__HEADER: { 584 results_mod.KEY__HEADER__SCHEMA_VERSION: ( 585 results_mod.VALUE__HEADER__SCHEMA_VERSION), 586 results_mod.KEY__HEADER__IS_STILL_LOADING: True, 587 results_mod.KEY__HEADER__TIME_UPDATED: now, 588 results_mod.KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE: ( 589 now + RELOAD_INTERVAL_UNTIL_READY), 590 }, 591 } 592 self.send_json_dict(response_dict) 593 594 def _get_live_results_or_prefetch(self, url_remainder, prefetch_only=False): 595 """ Handle a GET request for live-generated image diff data. 596 597 Args: 598 url_remainder: string indicating which image diffs to generate 599 prefetch_only: if True, the user isn't waiting around for results 600 """ 601 param_dict = urlparse.parse_qs(url_remainder) 602 download_all_images = ( 603 param_dict.get(LIVE_PARAM__DOWNLOAD_ONLY_DIFFERING, [''])[0].lower() 604 not in ['1', 'true']) 605 setA_dir = param_dict[LIVE_PARAM__SET_A_DIR][0] 606 setB_dir = param_dict[LIVE_PARAM__SET_B_DIR][0] 607 setA_section = self._validate_summary_section( 608 param_dict.get(LIVE_PARAM__SET_A_SECTION, [None])[0]) 609 setB_section = self._validate_summary_section( 610 param_dict.get(LIVE_PARAM__SET_B_SECTION, [None])[0]) 611 612 # If the sets show expectations vs actuals, always show expectations on 613 # the left (setA). 614 if ((setA_section == gm_json.JSONKEY_ACTUALRESULTS) and 615 (setB_section == gm_json.JSONKEY_EXPECTEDRESULTS)): 616 setA_dir, setB_dir = setB_dir, setA_dir 617 setA_section, setB_section = setB_section, setA_section 618 619 # Are we comparing some actuals against expectations stored in the repo? 620 # If so, we can allow the user to submit new baselines. 621 is_editable = ( 622 (setA_section == gm_json.JSONKEY_EXPECTEDRESULTS) and 623 (setA_dir.startswith(compare_rendered_pictures.REPO_URL_PREFIX)) and 624 (setB_section == gm_json.JSONKEY_ACTUALRESULTS)) 625 626 results_obj = compare_rendered_pictures.RenderedPicturesComparisons( 627 setA_dir=setA_dir, setB_dir=setB_dir, 628 setA_section=setA_section, setB_section=setB_section, 629 image_diff_db=_SERVER.image_diff_db, 630 diff_base_url='/static/generated-images', 631 gs=_SERVER.gs, truncate_results=_SERVER.truncate_results, 632 prefetch_only=prefetch_only, download_all_images=download_all_images) 633 if prefetch_only: 634 self.send_response(200) 635 else: 636 self.send_json_dict(results_obj.get_packaged_results_of_type( 637 results_type=results_mod.KEY__HEADER__RESULTS_ALL, 638 is_editable=is_editable)) 639 640 def do_GET_live_results(self, url_remainder): 641 """ Handle a GET request for live-generated image diff data. 642 643 Args: 644 url_remainder: string indicating which image diffs to generate 645 """ 646 logging.debug('do_GET_live_results: url_remainder="%s"' % url_remainder) 647 self._get_live_results_or_prefetch( 648 url_remainder=url_remainder, prefetch_only=False) 649 650 def do_GET_prefetch_results(self, url_remainder): 651 """ Prefetch image diff data for a future do_GET_live_results() call. 652 653 Args: 654 url_remainder: string indicating which image diffs to generate 655 """ 656 logging.debug('do_GET_prefetch_results: url_remainder="%s"' % url_remainder) 657 self._get_live_results_or_prefetch( 658 url_remainder=url_remainder, prefetch_only=True) 659 660 def do_GET_static(self, path): 661 """ Handle a GET request for a file under STATIC_CONTENTS_SUBDIR . 662 Only allow serving of files within STATIC_CONTENTS_SUBDIR that is a 663 filesystem sibling of this script. 664 665 Args: 666 path: path to file (within STATIC_CONTENTS_SUBDIR) to retrieve 667 """ 668 # Strip arguments ('?resultsToLoad=all') from the path 669 path = urlparse.urlparse(path).path 670 671 logging.debug('do_GET_static: sending file "%s"' % path) 672 static_dir = os.path.realpath(os.path.join( 673 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR)) 674 full_path = os.path.realpath(os.path.join(static_dir, path)) 675 if full_path.startswith(static_dir): 676 self.send_file(full_path) 677 else: 678 logging.error( 679 'Attempted do_GET_static() of path [%s] outside of static dir [%s]' 680 % (full_path, static_dir)) 681 self.send_error(404) 682 683 def do_POST(self): 684 """ Handles all POST requests, forwarding them to the appropriate 685 do_POST_* dispatcher. """ 686 # All requests must be of this form: 687 # /dispatcher 688 # where 'dispatcher' indicates which do_POST_* dispatcher to run. 689 logging.debug('do_POST: path="%s"' % self.path) 690 normpath = posixpath.normpath(self.path) 691 dispatchers = { 692 '/edits': self.do_POST_edits, 693 '/live-edits': self.do_POST_live_edits, 694 } 695 try: 696 dispatcher = dispatchers[normpath] 697 dispatcher() 698 except: 699 self.send_error(404) 700 raise 701 702 def do_POST_edits(self): 703 """ Handle a POST request with modifications to GM expectations, in this 704 format: 705 706 { 707 KEY__EDITS__OLD_RESULTS_TYPE: 'all', # type of results that the client 708 # loaded and then made 709 # modifications to 710 KEY__EDITS__OLD_RESULTS_HASH: 39850913, # hash of results when the client 711 # loaded them (ensures that the 712 # client and server apply 713 # modifications to the same base) 714 KEY__EDITS__MODIFICATIONS: [ 715 # as needed by compare_to_expectations.edit_expectations() 716 ... 717 ], 718 } 719 720 Raises an Exception if there were any problems. 721 """ 722 if not _SERVER.is_editable: 723 raise Exception('this server is not running in --editable mode') 724 725 content_type = self.headers[_HTTP_HEADER_CONTENT_TYPE] 726 if content_type != 'application/json;charset=UTF-8': 727 raise Exception('unsupported %s [%s]' % ( 728 _HTTP_HEADER_CONTENT_TYPE, content_type)) 729 730 content_length = int(self.headers[_HTTP_HEADER_CONTENT_LENGTH]) 731 json_data = self.rfile.read(content_length) 732 data = json.loads(json_data) 733 logging.debug('do_POST_edits: received new GM expectations data [%s]' % 734 data) 735 736 # Update the results on disk with the information we received from the 737 # client. 738 # We must hold _SERVER.results_rlock while we do this, to guarantee that 739 # no other thread updates expectations (from the Skia repo) while we are 740 # updating them (using the info we received from the client). 741 with _SERVER.results_rlock: 742 oldResultsType = data[KEY__EDITS__OLD_RESULTS_TYPE] 743 oldResults = _SERVER.results.get_results_of_type(oldResultsType) 744 oldResultsHash = str(hash(repr( 745 oldResults[imagepairset.KEY__ROOT__IMAGEPAIRS]))) 746 if oldResultsHash != data[KEY__EDITS__OLD_RESULTS_HASH]: 747 raise Exception('results of type "%s" changed while the client was ' 748 'making modifications. The client should reload the ' 749 'results and submit the modifications again.' % 750 oldResultsType) 751 _SERVER.results.edit_expectations(data[KEY__EDITS__MODIFICATIONS]) 752 753 # Read the updated results back from disk. 754 # We can do this in a separate thread; we should return our success message 755 # to the UI as soon as possible. 756 thread.start_new_thread(_SERVER.update_results, (True,)) 757 self.send_response(200) 758 759 def do_POST_live_edits(self): 760 """ Handle a POST request with modifications to SKP expectations, in this 761 format: 762 763 { 764 KEY__LIVE_EDITS__SET_A_DESCRIPTIONS: { 765 # setA descriptions from the original data 766 }, 767 KEY__LIVE_EDITS__SET_B_DESCRIPTIONS: { 768 # setB descriptions from the original data 769 }, 770 KEY__LIVE_EDITS__MODIFICATIONS: [ 771 # as needed by writable_expectations.modify() 772 ], 773 } 774 775 Raises an Exception if there were any problems. 776 """ 777 content_type = self.headers[_HTTP_HEADER_CONTENT_TYPE] 778 if content_type != 'application/json;charset=UTF-8': 779 raise Exception('unsupported %s [%s]' % ( 780 _HTTP_HEADER_CONTENT_TYPE, content_type)) 781 782 content_length = int(self.headers[_HTTP_HEADER_CONTENT_LENGTH]) 783 json_data = self.rfile.read(content_length) 784 data = json.loads(json_data) 785 logging.debug('do_POST_live_edits: received new GM expectations data [%s]' % 786 data) 787 with writable_expectations_mod.WritableExpectations( 788 data[KEY__LIVE_EDITS__SET_A_DESCRIPTIONS]) as writable_expectations: 789 writable_expectations.modify(data[KEY__LIVE_EDITS__MODIFICATIONS]) 790 diffs = writable_expectations.get_diffs() 791 # TODO(stephana): Move to a simpler web framework so we don't have to 792 # call these functions. See http://skbug.com/2856 ('rebaseline_server: 793 # Refactor server to use a simple web framework') 794 self.send_response(200) 795 self.send_header('Content-type', 'text/plain') 796 self.end_headers() 797 self.wfile.write(diffs) 798 799 def redirect_to(self, url): 800 """ Redirect the HTTP client to a different url. 801 802 Args: 803 url: URL to redirect the HTTP client to 804 """ 805 self.send_response(301) 806 self.send_header('Location', url) 807 self.end_headers() 808 809 def send_file(self, path): 810 """ Send the contents of the file at this path, with a mimetype based 811 on the filename extension. 812 813 Args: 814 path: path of file whose contents to send to the HTTP client 815 """ 816 # Grab the extension if there is one 817 extension = os.path.splitext(path)[1] 818 if len(extension) >= 1: 819 extension = extension[1:] 820 821 # Determine the MIME type of the file from its extension 822 mime_type = MIME_TYPE_MAP.get(extension, MIME_TYPE_MAP['']) 823 824 # Open the file and send it over HTTP 825 if os.path.isfile(path): 826 with open(path, 'rb') as sending_file: 827 self.send_response(200) 828 self.send_header('Content-type', mime_type) 829 self.end_headers() 830 self.wfile.write(sending_file.read()) 831 else: 832 self.send_error(404) 833 834 def send_json_dict(self, json_dict): 835 """ Send the contents of this dictionary in JSON format, with a JSON 836 mimetype. 837 838 Args: 839 json_dict: dictionary to send 840 """ 841 self.send_response(200) 842 self.send_header('Content-type', 'application/json') 843 self.end_headers() 844 json.dump(json_dict, self.wfile) 845 846 def _validate_summary_section(self, section_name): 847 """Validates the section we have been requested to read within JSON summary. 848 849 Args: 850 section_name: which section of the JSON summary file has been requested 851 852 Returns: the validated section name 853 854 Raises: Exception if an invalid section_name was requested. 855 """ 856 if section_name not in compare_rendered_pictures.ALLOWED_SECTION_NAMES: 857 raise Exception('requested section name "%s" not in allowed list %s' % ( 858 section_name, compare_rendered_pictures.ALLOWED_SECTION_NAMES)) 859 return section_name 860 861 862def main(): 863 logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s', 864 datefmt='%m/%d/%Y %H:%M:%S', 865 level=logging.INFO) 866 parser = argparse.ArgumentParser() 867 parser.add_argument('--actuals-dir', 868 help=('Directory into which we will check out the latest ' 869 'actual GM results. If this directory does not ' 870 'exist, it will be created. Defaults to %(default)s'), 871 default=DEFAULT_ACTUALS_DIR) 872 parser.add_argument('--boto', 873 help=('Path to .boto file giving us credentials to access ' 874 'Google Storage buckets. If not specified, we will ' 875 'only be able to access public GS buckets (and thus ' 876 'won\'t be able to download SKP images).'), 877 default='') 878 # TODO(epoger): Before https://codereview.chromium.org/310093003 , 879 # when this tool downloaded the JSON summaries from skia-autogen, 880 # it had an --actuals-revision the caller could specify to download 881 # actual results as of a specific point in time. We should add similar 882 # functionality when retrieving the summaries from Google Storage. 883 parser.add_argument('--builders', metavar='BUILDER_REGEX', nargs='+', 884 help=('Only process builders matching these regular ' 885 'expressions. If unspecified, process all ' 886 'builders.')) 887 parser.add_argument('--compare-configs', action='store_true', 888 help=('In addition to generating differences between ' 889 'expectations and actuals, also generate ' 890 'differences between these config pairs: ' 891 + str(CONFIG_PAIRS_TO_COMPARE))) 892 parser.add_argument('--editable', action='store_true', 893 help=('Allow HTTP clients to submit new GM baselines; ' 894 'SKP baselines can be edited regardless of this ' 895 'setting.')) 896 parser.add_argument('--export', action='store_true', 897 help=('Instead of only allowing access from HTTP clients ' 898 'on localhost, allow HTTP clients on other hosts ' 899 'to access this server. WARNING: doing so will ' 900 'allow users on other hosts to modify your ' 901 'GM expectations, if combined with --editable.')) 902 parser.add_argument('--gm-summaries-bucket', 903 help=('Google Cloud Storage bucket to download ' 904 'JSON_FILENAME files from. ' 905 'Defaults to %(default)s ; if set to ' 906 'empty string, just compare to actual-results ' 907 'already found in ACTUALS_DIR.'), 908 default=DEFAULT_GM_SUMMARIES_BUCKET) 909 parser.add_argument('--json-filename', 910 help=('JSON summary filename to read for each builder; ' 911 'defaults to %(default)s.'), 912 default=DEFAULT_JSON_FILENAME) 913 parser.add_argument('--port', type=int, 914 help=('Which TCP port to listen on for HTTP requests; ' 915 'defaults to %(default)s'), 916 default=DEFAULT_PORT) 917 parser.add_argument('--reload', type=int, 918 help=('How often (a period in seconds) to update the ' 919 'results. If specified, both expected and actual ' 920 'results will be updated by running "gclient sync" ' 921 'on your Skia checkout as a whole. ' 922 'By default, we do not reload at all, and you ' 923 'must restart the server to pick up new data.'), 924 default=0) 925 parser.add_argument('--threads', type=int, 926 help=('How many parallel threads we use to download ' 927 'images and generate diffs; defaults to ' 928 '%(default)s'), 929 default=imagediffdb.DEFAULT_NUM_WORKER_THREADS) 930 parser.add_argument('--truncate', action='store_true', 931 help=('FOR TESTING ONLY: truncate the set of images we ' 932 'process, to speed up testing.')) 933 args = parser.parse_args() 934 if args.compare_configs: 935 config_pairs = CONFIG_PAIRS_TO_COMPARE 936 else: 937 config_pairs = None 938 939 global _SERVER 940 _SERVER = Server(actuals_dir=args.actuals_dir, 941 json_filename=args.json_filename, 942 gm_summaries_bucket=args.gm_summaries_bucket, 943 port=args.port, export=args.export, editable=args.editable, 944 reload_seconds=args.reload, config_pairs=config_pairs, 945 builder_regex_list=args.builders, boto_file_path=args.boto, 946 imagediffdb_threads=args.threads) 947 if args.truncate: 948 _SERVER.truncate_results = True 949 _SERVER.run() 950 951 952if __name__ == '__main__': 953 main() 954