1 #!/usr/bin/python 2 3 """ 4 Copyright 2013 Google Inc. 5 6 Use of this source code is governed by a BSD-style license that can be 7 found in the LICENSE file. 8 9 HTTP server for our HTML rebaseline viewer. 10 """ 11 12 # System-level imports 13 import argparse 14 import BaseHTTPServer 15 import json 16 import logging 17 import os 18 import posixpath 19 import re 20 import shutil 21 import socket 22 import subprocess 23 import thread 24 import threading 25 import time 26 import urlparse 27 28 # Imports from within Skia 29 import fix_pythonpath # must do this first 30 from pyutils import gs_utils 31 import gm_json 32 33 # Imports from local dir 34 # 35 # Note: we import results under a different name, to avoid confusion with the 36 # Server.results() property. See discussion at 37 # https://codereview.chromium.org/195943004/diff/1/gm/rebaseline_server/server.py#newcode44 38 import compare_configs 39 import compare_to_expectations 40 import download_actuals 41 import imagepairset 42 import results as results_mod 43 44 PATHSPLIT_RE = re.compile('/([^/]+)/(.+)') 45 46 # A simple dictionary of file name extensions to MIME types. The empty string 47 # entry is used as the default when no extension was given or if the extension 48 # has no entry in this dictionary. 49 MIME_TYPE_MAP = {'': 'application/octet-stream', 50 'html': 'text/html', 51 'css': 'text/css', 52 'png': 'image/png', 53 'js': 'application/javascript', 54 'json': 'application/json' 55 } 56 57 # Keys that server.py uses to create the toplevel content header. 58 # NOTE: Keep these in sync with static/constants.js 59 KEY__EDITS__MODIFICATIONS = 'modifications' 60 KEY__EDITS__OLD_RESULTS_HASH = 'oldResultsHash' 61 KEY__EDITS__OLD_RESULTS_TYPE = 'oldResultsType' 62 63 DEFAULT_ACTUALS_DIR = results_mod.DEFAULT_ACTUALS_DIR 64 DEFAULT_GM_SUMMARIES_BUCKET = download_actuals.GM_SUMMARIES_BUCKET 65 DEFAULT_JSON_FILENAME = download_actuals.DEFAULT_JSON_FILENAME 66 DEFAULT_PORT = 8888 67 68 PARENT_DIRECTORY = os.path.dirname(os.path.realpath(__file__)) 69 TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(PARENT_DIRECTORY)) 70 # Directory, relative to PARENT_DIRECTORY, within which the server will serve 71 # out live results (not static files). 72 RESULTS_SUBDIR = 'results' 73 # Directory, relative to PARENT_DIRECTORY, within which the server will serve 74 # out static files. 75 STATIC_CONTENTS_SUBDIR = 'static' 76 # All of the GENERATED_*_SUBDIRS are relative to STATIC_CONTENTS_SUBDIR 77 GENERATED_HTML_SUBDIR = 'generated-html' 78 GENERATED_IMAGES_SUBDIR = 'generated-images' 79 GENERATED_JSON_SUBDIR = 'generated-json' 80 81 # How often (in seconds) clients should reload while waiting for initial 82 # results to load. 83 RELOAD_INTERVAL_UNTIL_READY = 10 84 85 SUMMARY_TYPES = [ 86 results_mod.KEY__HEADER__RESULTS_FAILURES, 87 results_mod.KEY__HEADER__RESULTS_ALL, 88 ] 89 # If --compare-configs is specified, compare these configs. 90 CONFIG_PAIRS_TO_COMPARE = [('8888', 'gpu')] 91 92 _HTTP_HEADER_CONTENT_LENGTH = 'Content-Length' 93 _HTTP_HEADER_CONTENT_TYPE = 'Content-Type' 94 95 _SERVER = None # This gets filled in by main() 96 97 98 def _run_command(args, directory): 99 """Runs a command and returns stdout as a single string. 100 101 Args: 102 args: the command to run, as a list of arguments 103 directory: directory within which to run the command 104 105 Returns: stdout, as a string 106 107 Raises an Exception if the command failed (exited with nonzero return code). 108 """ 109 logging.debug('_run_command: %s in directory %s' % (args, directory)) 110 proc = subprocess.Popen(args, cwd=directory, 111 stdout=subprocess.PIPE, 112 stderr=subprocess.PIPE) 113 (stdout, stderr) = proc.communicate() 114 if proc.returncode is not 0: 115 raise Exception('command "%s" failed in dir "%s": %s' % 116 (args, directory, stderr)) 117 return stdout 118 119 120 def _get_routable_ip_address(): 121 """Returns routable IP address of this host (the IP address of its network 122 interface that would be used for most traffic, not its localhost 123 interface). See http://stackoverflow.com/a/166589 """ 124 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 125 sock.connect(('8.8.8.8', 80)) 126 host = sock.getsockname()[0] 127 sock.close() 128 return host 129 130 131 def _create_index(file_path, config_pairs): 132 """Creates an index file linking to all results available from this server. 133 134 Prior to https://codereview.chromium.org/215503002 , we had a static 135 index.html within our repo. But now that the results may or may not include 136 config comparisons, index.html needs to be generated differently depending 137 on which results are included. 138 139 TODO(epoger): Instead of including raw HTML within the Python code, 140 consider restoring the index.html file as a template and using django (or 141 similar) to fill in dynamic content. 142 143 Args: 144 file_path: path on local disk to write index to; any directory components 145 of this path that do not already exist will be created 146 config_pairs: what pairs of configs (if any) we compare actual results of 147 """ 148 dir_path = os.path.dirname(file_path) 149 if not os.path.isdir(dir_path): 150 os.makedirs(dir_path) 151 with open(file_path, 'w') as file_handle: 152 file_handle.write( 153 '<!DOCTYPE html><html>' 154 '<head><title>rebaseline_server</title></head>' 155 '<body><ul>') 156 if SUMMARY_TYPES: 157 file_handle.write('<li>Expectations vs Actuals</li><ul>') 158 for summary_type in SUMMARY_TYPES: 159 file_handle.write( 160 '<li>' 161 '<a href="/%s/view.html#/view.html?resultsToLoad=/%s/%s">' 162 '%s</a></li>' % ( 163 STATIC_CONTENTS_SUBDIR, RESULTS_SUBDIR, 164 summary_type, summary_type)) 165 file_handle.write('</ul>') 166 if config_pairs: 167 file_handle.write('<li>Comparing configs within actual results</li><ul>') 168 for config_pair in config_pairs: 169 file_handle.write('<li>%s vs %s:' % config_pair) 170 for summary_type in SUMMARY_TYPES: 171 file_handle.write( 172 ' <a href="/%s/view.html#/view.html?' 173 'resultsToLoad=/%s/%s/%s-vs-%s_%s.json">%s</a>' % ( 174 STATIC_CONTENTS_SUBDIR, STATIC_CONTENTS_SUBDIR, 175 GENERATED_JSON_SUBDIR, config_pair[0], config_pair[1], 176 summary_type, summary_type)) 177 file_handle.write('</li>') 178 file_handle.write('</ul>') 179 file_handle.write('</ul></body></html>') 180 181 182 class Server(object): 183 """ HTTP server for our HTML rebaseline viewer. """ 184 185 def __init__(self, 186 actuals_dir=DEFAULT_ACTUALS_DIR, 187 json_filename=DEFAULT_JSON_FILENAME, 188 gm_summaries_bucket=DEFAULT_GM_SUMMARIES_BUCKET, 189 port=DEFAULT_PORT, export=False, editable=True, 190 reload_seconds=0, config_pairs=None, builder_regex_list=None): 191 """ 192 Args: 193 actuals_dir: directory under which we will check out the latest actual 194 GM results 195 json_filename: basename of the JSON summary file to load for each builder 196 gm_summaries_bucket: Google Storage bucket to download json_filename 197 files from; if None or '', don't fetch new actual-results files 198 at all, just compare to whatever files are already in actuals_dir 199 port: which TCP port to listen on for HTTP requests 200 export: whether to allow HTTP clients on other hosts to access this server 201 editable: whether HTTP clients are allowed to submit new baselines 202 reload_seconds: polling interval with which to check for new results; 203 if 0, don't check for new results at all 204 config_pairs: List of (string, string) tuples; for each tuple, compare 205 actual results of these two configs. If None or empty, 206 don't compare configs at all. 207 builder_regex_list: List of regular expressions specifying which builders 208 we will process. If None, process all builders. 209 """ 210 self._actuals_dir = actuals_dir 211 self._json_filename = json_filename 212 self._gm_summaries_bucket = gm_summaries_bucket 213 self._port = port 214 self._export = export 215 self._editable = editable 216 self._reload_seconds = reload_seconds 217 self._config_pairs = config_pairs or [] 218 self._builder_regex_list = builder_regex_list 219 _create_index( 220 file_path=os.path.join( 221 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR, GENERATED_HTML_SUBDIR, 222 "index.html"), 223 config_pairs=config_pairs) 224 225 # Reentrant lock that must be held whenever updating EITHER of: 226 # 1. self._results 227 # 2. the expected or actual results on local disk 228 self.results_rlock = threading.RLock() 229 # self._results will be filled in by calls to update_results() 230 self._results = None 231 232 @property 233 def results(self): 234 """ Returns the most recently generated results, or None if we don't have 235 any valid results (update_results() has not completed yet). """ 236 return self._results 237 238 @property 239 def is_exported(self): 240 """ Returns true iff HTTP clients on other hosts are allowed to access 241 this server. """ 242 return self._export 243 244 @property 245 def is_editable(self): 246 """ Returns true iff HTTP clients are allowed to submit new baselines. """ 247 return self._editable 248 249 @property 250 def reload_seconds(self): 251 """ Returns the result reload period in seconds, or 0 if we don't reload 252 results. """ 253 return self._reload_seconds 254 255 def update_results(self, invalidate=False): 256 """ Create or update self._results, based on the latest expectations and 257 actuals. 258 259 We hold self.results_rlock while we do this, to guarantee that no other 260 thread attempts to update either self._results or the underlying files at 261 the same time. 262 263 Args: 264 invalidate: if True, invalidate self._results immediately upon entry; 265 otherwise, we will let readers see those results until we 266 replace them 267 """ 268 with self.results_rlock: 269 if invalidate: 270 self._results = None 271 if self._gm_summaries_bucket: 272 logging.info( 273 'Updating GM result summaries in %s from gm_summaries_bucket %s ...' 274 % (self._actuals_dir, self._gm_summaries_bucket)) 275 276 # Clean out actuals_dir first, in case some builders have gone away 277 # since we last ran. 278 if os.path.isdir(self._actuals_dir): 279 shutil.rmtree(self._actuals_dir) 280 281 # Get the list of builders we care about. 282 all_builders = download_actuals.get_builders_list( 283 summaries_bucket=self._gm_summaries_bucket) 284 if self._builder_regex_list: 285 matching_builders = [] 286 for builder in all_builders: 287 for regex in self._builder_regex_list: 288 if re.match(regex, builder): 289 matching_builders.append(builder) 290 break # go on to the next builder, no need to try more regexes 291 else: 292 matching_builders = all_builders 293 294 # Download the JSON file for each builder we care about. 295 # 296 # TODO(epoger): When this is a large number of builders, we would be 297 # better off downloading them in parallel! 298 for builder in matching_builders: 299 gs_utils.download_file( 300 source_bucket=self._gm_summaries_bucket, 301 source_path=posixpath.join(builder, self._json_filename), 302 dest_path=os.path.join(self._actuals_dir, builder, 303 self._json_filename), 304 create_subdirs_if_needed=True) 305 306 # We only update the expectations dir if the server was run with a 307 # nonzero --reload argument; otherwise, we expect the user to maintain 308 # her own expectations as she sees fit. 309 # 310 # Because the Skia repo is hosted using git, and git does not 311 # support updating a single directory tree, we have to update the entire 312 # repo checkout. 313 # 314 # Because Skia uses depot_tools, we have to update using "gclient sync" 315 # instead of raw git commands. 316 # 317 # TODO(epoger): Fetch latest expectations in some other way. 318 # Eric points out that our official documentation recommends an 319 # unmanaged Skia checkout, so "gclient sync" will not bring down updated 320 # expectations from origin/master-- you'd have to do a "git pull" of 321 # some sort instead. 322 # However, the live rebaseline_server at 323 # http://skia-tree-status.appspot.com/redirect/rebaseline-server (which 324 # is probably the only user of the --reload flag!) uses a managed 325 # checkout, so "gclient sync" works in that case. 326 # Probably the best idea is to avoid all of this nonsense by fetching 327 # updated expectations into a temp directory, and leaving the rest of 328 # the checkout alone. This could be done using "git show", or by 329 # downloading individual expectation JSON files from 330 # skia.googlesource.com . 331 if self._reload_seconds: 332 logging.info( 333 'Updating expected GM results in %s by syncing Skia repo ...' % 334 compare_to_expectations.DEFAULT_EXPECTATIONS_DIR) 335 _run_command(['gclient', 'sync'], TRUNK_DIRECTORY) 336 337 self._results = compare_to_expectations.ExpectationComparisons( 338 actuals_root=self._actuals_dir, 339 generated_images_root=os.path.join( 340 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR, 341 GENERATED_IMAGES_SUBDIR), 342 diff_base_url=posixpath.join( 343 os.pardir, STATIC_CONTENTS_SUBDIR, GENERATED_IMAGES_SUBDIR), 344 builder_regex_list=self._builder_regex_list) 345 346 json_dir = os.path.join( 347 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR, GENERATED_JSON_SUBDIR) 348 if not os.path.isdir(json_dir): 349 os.makedirs(json_dir) 350 351 for config_pair in self._config_pairs: 352 config_comparisons = compare_configs.ConfigComparisons( 353 configs=config_pair, 354 actuals_root=self._actuals_dir, 355 generated_images_root=os.path.join( 356 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR, 357 GENERATED_IMAGES_SUBDIR), 358 diff_base_url=posixpath.join( 359 os.pardir, GENERATED_IMAGES_SUBDIR), 360 builder_regex_list=self._builder_regex_list) 361 for summary_type in SUMMARY_TYPES: 362 gm_json.WriteToFile( 363 config_comparisons.get_packaged_results_of_type( 364 results_type=summary_type), 365 os.path.join( 366 json_dir, '%s-vs-%s_%s.json' % ( 367 config_pair[0], config_pair[1], summary_type))) 368 369 def _result_loader(self, reload_seconds=0): 370 """ Call self.update_results(), either once or periodically. 371 372 Params: 373 reload_seconds: integer; if nonzero, reload results at this interval 374 (in which case, this method will never return!) 375 """ 376 self.update_results() 377 logging.info('Initial results loaded. Ready for requests on %s' % self._url) 378 if reload_seconds: 379 while True: 380 time.sleep(reload_seconds) 381 self.update_results() 382 383 def run(self): 384 arg_tuple = (self._reload_seconds,) # start_new_thread needs a tuple, 385 # even though it holds just one param 386 thread.start_new_thread(self._result_loader, arg_tuple) 387 388 if self._export: 389 server_address = ('', self._port) 390 host = _get_routable_ip_address() 391 if self._editable: 392 logging.warning('Running with combination of "export" and "editable" ' 393 'flags. Users on other machines will ' 394 'be able to modify your GM expectations!') 395 else: 396 host = '127.0.0.1' 397 server_address = (host, self._port) 398 http_server = BaseHTTPServer.HTTPServer(server_address, HTTPRequestHandler) 399 self._url = 'http://%s:%d' % (host, http_server.server_port) 400 logging.info('Listening for requests on %s' % self._url) 401 http_server.serve_forever() 402 403 404 class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): 405 """ HTTP request handlers for various types of queries this server knows 406 how to handle (static HTML and Javascript, expected/actual results, etc.) 407 """ 408 def do_GET(self): 409 """ 410 Handles all GET requests, forwarding them to the appropriate 411 do_GET_* dispatcher. 412 413 If we see any Exceptions, return a 404. This fixes http://skbug.com/2147 414 """ 415 try: 416 logging.debug('do_GET: path="%s"' % self.path) 417 if self.path == '' or self.path == '/' or self.path == '/index.html' : 418 self.redirect_to('/%s/%s/index.html' % ( 419 STATIC_CONTENTS_SUBDIR, GENERATED_HTML_SUBDIR)) 420 return 421 if self.path == '/favicon.ico' : 422 self.redirect_to('/%s/favicon.ico' % STATIC_CONTENTS_SUBDIR) 423 return 424 425 # All requests must be of this form: 426 # /dispatcher/remainder 427 # where 'dispatcher' indicates which do_GET_* dispatcher to run 428 # and 'remainder' is the remaining path sent to that dispatcher. 429 normpath = posixpath.normpath(self.path) 430 (dispatcher_name, remainder) = PATHSPLIT_RE.match(normpath).groups() 431 dispatchers = { 432 RESULTS_SUBDIR: self.do_GET_results, 433 STATIC_CONTENTS_SUBDIR: self.do_GET_static, 434 } 435 dispatcher = dispatchers[dispatcher_name] 436 dispatcher(remainder) 437 except: 438 self.send_error(404) 439 raise 440 441 def do_GET_results(self, results_type): 442 """ Handle a GET request for GM results. 443 444 Args: 445 results_type: string indicating which set of results to return; 446 must be one of the results_mod.RESULTS_* constants 447 """ 448 logging.debug('do_GET_results: sending results of type "%s"' % results_type) 449 # Since we must make multiple calls to the ExpectationComparisons object, 450 # grab a reference to it in case it is updated to point at a new 451 # ExpectationComparisons object within another thread. 452 # 453 # TODO(epoger): Rather than using a global variable for the handler 454 # to refer to the Server object, make Server a subclass of 455 # HTTPServer, and then it could be available to the handler via 456 # the handler's .server instance variable. 457 results_obj = _SERVER.results 458 if results_obj: 459 response_dict = results_obj.get_packaged_results_of_type( 460 results_type=results_type, reload_seconds=_SERVER.reload_seconds, 461 is_editable=_SERVER.is_editable, is_exported=_SERVER.is_exported) 462 else: 463 now = int(time.time()) 464 response_dict = { 465 imagepairset.KEY__ROOT__HEADER: { 466 results_mod.KEY__HEADER__SCHEMA_VERSION: ( 467 results_mod.VALUE__HEADER__SCHEMA_VERSION), 468 results_mod.KEY__HEADER__IS_STILL_LOADING: True, 469 results_mod.KEY__HEADER__TIME_UPDATED: now, 470 results_mod.KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE: ( 471 now + RELOAD_INTERVAL_UNTIL_READY), 472 }, 473 } 474 self.send_json_dict(response_dict) 475 476 def do_GET_static(self, path): 477 """ Handle a GET request for a file under STATIC_CONTENTS_SUBDIR . 478 Only allow serving of files within STATIC_CONTENTS_SUBDIR that is a 479 filesystem sibling of this script. 480 481 Args: 482 path: path to file (within STATIC_CONTENTS_SUBDIR) to retrieve 483 """ 484 # Strip arguments ('?resultsToLoad=all') from the path 485 path = urlparse.urlparse(path).path 486 487 logging.debug('do_GET_static: sending file "%s"' % path) 488 static_dir = os.path.realpath(os.path.join( 489 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR)) 490 full_path = os.path.realpath(os.path.join(static_dir, path)) 491 if full_path.startswith(static_dir): 492 self.send_file(full_path) 493 else: 494 logging.error( 495 'Attempted do_GET_static() of path [%s] outside of static dir [%s]' 496 % (full_path, static_dir)) 497 self.send_error(404) 498 499 def do_POST(self): 500 """ Handles all POST requests, forwarding them to the appropriate 501 do_POST_* dispatcher. """ 502 # All requests must be of this form: 503 # /dispatcher 504 # where 'dispatcher' indicates which do_POST_* dispatcher to run. 505 logging.debug('do_POST: path="%s"' % self.path) 506 normpath = posixpath.normpath(self.path) 507 dispatchers = { 508 '/edits': self.do_POST_edits, 509 } 510 try: 511 dispatcher = dispatchers[normpath] 512 dispatcher() 513 self.send_response(200) 514 except: 515 self.send_error(404) 516 raise 517 518 def do_POST_edits(self): 519 """ Handle a POST request with modifications to GM expectations, in this 520 format: 521 522 { 523 KEY__EDITS__OLD_RESULTS_TYPE: 'all', # type of results that the client 524 # loaded and then made 525 # modifications to 526 KEY__EDITS__OLD_RESULTS_HASH: 39850913, # hash of results when the client 527 # loaded them (ensures that the 528 # client and server apply 529 # modifications to the same base) 530 KEY__EDITS__MODIFICATIONS: [ 531 # as needed by compare_to_expectations.edit_expectations() 532 ... 533 ], 534 } 535 536 Raises an Exception if there were any problems. 537 """ 538 if not _SERVER.is_editable: 539 raise Exception('this server is not running in --editable mode') 540 541 content_type = self.headers[_HTTP_HEADER_CONTENT_TYPE] 542 if content_type != 'application/json;charset=UTF-8': 543 raise Exception('unsupported %s [%s]' % ( 544 _HTTP_HEADER_CONTENT_TYPE, content_type)) 545 546 content_length = int(self.headers[_HTTP_HEADER_CONTENT_LENGTH]) 547 json_data = self.rfile.read(content_length) 548 data = json.loads(json_data) 549 logging.debug('do_POST_edits: received new GM expectations data [%s]' % 550 data) 551 552 # Update the results on disk with the information we received from the 553 # client. 554 # We must hold _SERVER.results_rlock while we do this, to guarantee that 555 # no other thread updates expectations (from the Skia repo) while we are 556 # updating them (using the info we received from the client). 557 with _SERVER.results_rlock: 558 oldResultsType = data[KEY__EDITS__OLD_RESULTS_TYPE] 559 oldResults = _SERVER.results.get_results_of_type(oldResultsType) 560 oldResultsHash = str(hash(repr( 561 oldResults[imagepairset.KEY__ROOT__IMAGEPAIRS]))) 562 if oldResultsHash != data[KEY__EDITS__OLD_RESULTS_HASH]: 563 raise Exception('results of type "%s" changed while the client was ' 564 'making modifications. The client should reload the ' 565 'results and submit the modifications again.' % 566 oldResultsType) 567 _SERVER.results.edit_expectations(data[KEY__EDITS__MODIFICATIONS]) 568 569 # Read the updated results back from disk. 570 # We can do this in a separate thread; we should return our success message 571 # to the UI as soon as possible. 572 thread.start_new_thread(_SERVER.update_results, (True,)) 573 574 def redirect_to(self, url): 575 """ Redirect the HTTP client to a different url. 576 577 Args: 578 url: URL to redirect the HTTP client to 579 """ 580 self.send_response(301) 581 self.send_header('Location', url) 582 self.end_headers() 583 584 def send_file(self, path): 585 """ Send the contents of the file at this path, with a mimetype based 586 on the filename extension. 587 588 Args: 589 path: path of file whose contents to send to the HTTP client 590 """ 591 # Grab the extension if there is one 592 extension = os.path.splitext(path)[1] 593 if len(extension) >= 1: 594 extension = extension[1:] 595 596 # Determine the MIME type of the file from its extension 597 mime_type = MIME_TYPE_MAP.get(extension, MIME_TYPE_MAP['']) 598 599 # Open the file and send it over HTTP 600 if os.path.isfile(path): 601 with open(path, 'rb') as sending_file: 602 self.send_response(200) 603 self.send_header('Content-type', mime_type) 604 self.end_headers() 605 self.wfile.write(sending_file.read()) 606 else: 607 self.send_error(404) 608 609 def send_json_dict(self, json_dict): 610 """ Send the contents of this dictionary in JSON format, with a JSON 611 mimetype. 612 613 Args: 614 json_dict: dictionary to send 615 """ 616 self.send_response(200) 617 self.send_header('Content-type', 'application/json') 618 self.end_headers() 619 json.dump(json_dict, self.wfile) 620 621 622 def main(): 623 logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s', 624 datefmt='%m/%d/%Y %H:%M:%S', 625 level=logging.INFO) 626 parser = argparse.ArgumentParser() 627 parser.add_argument('--actuals-dir', 628 help=('Directory into which we will check out the latest ' 629 'actual GM results. If this directory does not ' 630 'exist, it will be created. Defaults to %(default)s'), 631 default=DEFAULT_ACTUALS_DIR) 632 # TODO(epoger): Before https://codereview.chromium.org/310093003 , 633 # when this tool downloaded the JSON summaries from skia-autogen, 634 # it had an --actuals-revision the caller could specify to download 635 # actual results as of a specific point in time. We should add similar 636 # functionality when retrieving the summaries from Google Storage. 637 parser.add_argument('--builders', metavar='BUILDER_REGEX', nargs='+', 638 help=('Only process builders matching these regular ' 639 'expressions. If unspecified, process all ' 640 'builders.')) 641 parser.add_argument('--compare-configs', action='store_true', 642 help=('In addition to generating differences between ' 643 'expectations and actuals, also generate ' 644 'differences between these config pairs: ' 645 + str(CONFIG_PAIRS_TO_COMPARE))) 646 parser.add_argument('--editable', action='store_true', 647 help=('Allow HTTP clients to submit new baselines.')) 648 parser.add_argument('--export', action='store_true', 649 help=('Instead of only allowing access from HTTP clients ' 650 'on localhost, allow HTTP clients on other hosts ' 651 'to access this server. WARNING: doing so will ' 652 'allow users on other hosts to modify your ' 653 'GM expectations, if combined with --editable.')) 654 parser.add_argument('--gm-summaries-bucket', 655 help=('Google Cloud Storage bucket to download ' 656 'JSON_FILENAME files from. ' 657 'Defaults to %(default)s ; if set to ' 658 'empty string, just compare to actual-results ' 659 'already found in ACTUALS_DIR.'), 660 default=DEFAULT_GM_SUMMARIES_BUCKET) 661 parser.add_argument('--json-filename', 662 help=('JSON summary filename to read for each builder; ' 663 'defaults to %(default)s.'), 664 default=DEFAULT_JSON_FILENAME) 665 parser.add_argument('--port', type=int, 666 help=('Which TCP port to listen on for HTTP requests; ' 667 'defaults to %(default)s'), 668 default=DEFAULT_PORT) 669 parser.add_argument('--reload', type=int, 670 help=('How often (a period in seconds) to update the ' 671 'results. If specified, both expected and actual ' 672 'results will be updated by running "gclient sync" ' 673 'on your Skia checkout as a whole. ' 674 'By default, we do not reload at all, and you ' 675 'must restart the server to pick up new data.'), 676 default=0) 677 args = parser.parse_args() 678 if args.compare_configs: 679 config_pairs = CONFIG_PAIRS_TO_COMPARE 680 else: 681 config_pairs = None 682 683 global _SERVER 684 _SERVER = Server(actuals_dir=args.actuals_dir, 685 json_filename=args.json_filename, 686 gm_summaries_bucket=args.gm_summaries_bucket, 687 port=args.port, export=args.export, editable=args.editable, 688 reload_seconds=args.reload, config_pairs=config_pairs, 689 builder_regex_list=args.builders) 690 _SERVER.run() 691 692 693 if __name__ == '__main__': 694 main() 695