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 urlparse
27
28# Imports from within Skia
29import fix_pythonpath  # must do this first
30from pyutils import gs_utils
31import 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
38import compare_configs
39import compare_to_expectations
40import download_actuals
41import imagepairset
42import results as results_mod
43
44PATHSPLIT_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.
49MIME_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
59KEY__EDITS__MODIFICATIONS = 'modifications'
60KEY__EDITS__OLD_RESULTS_HASH = 'oldResultsHash'
61KEY__EDITS__OLD_RESULTS_TYPE = 'oldResultsType'
62
63DEFAULT_ACTUALS_DIR = results_mod.DEFAULT_ACTUALS_DIR
64DEFAULT_GM_SUMMARIES_BUCKET = download_actuals.GM_SUMMARIES_BUCKET
65DEFAULT_JSON_FILENAME = download_actuals.DEFAULT_JSON_FILENAME
66DEFAULT_PORT = 8888
67
68PARENT_DIRECTORY = os.path.dirname(os.path.realpath(__file__))
69TRUNK_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).
72RESULTS_SUBDIR = 'results'
73# Directory, relative to PARENT_DIRECTORY, within which the server will serve
74# out static files.
75STATIC_CONTENTS_SUBDIR = 'static'
76# All of the GENERATED_*_SUBDIRS are relative to STATIC_CONTENTS_SUBDIR
77GENERATED_HTML_SUBDIR = 'generated-html'
78GENERATED_IMAGES_SUBDIR = 'generated-images'
79GENERATED_JSON_SUBDIR = 'generated-json'
80
81# How often (in seconds) clients should reload while waiting for initial
82# results to load.
83RELOAD_INTERVAL_UNTIL_READY = 10
84
85SUMMARY_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.
90CONFIG_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
98def _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
120def _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
131def _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
182class 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
404class 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
622def 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
693if __name__ == '__main__':
694  main()
695