Home | History | Annotate | Download | only in rebaseline_server
      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