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 urllib
     27 import urlparse
     28 
     29 # Must fix up PYTHONPATH before importing from within Skia
     30 import rs_fixpypath  # pylint: disable=W0611
     31 
     32 # Imports from within Skia
     33 from py.utils import gs_utils
     34 import buildbot_globals
     35 import 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
     44 import compare_configs
     45 import compare_rendered_pictures
     46 import compare_to_expectations
     47 import download_actuals
     48 import imagediffdb
     49 import imagepairset
     50 import results as results_mod
     51 import writable_expectations as writable_expectations_mod
     52 
     53 
     54 PATHSPLIT_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.
     59 MIME_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
     69 KEY__EDITS__MODIFICATIONS = 'modifications'
     70 KEY__EDITS__OLD_RESULTS_HASH = 'oldResultsHash'
     71 KEY__EDITS__OLD_RESULTS_TYPE = 'oldResultsType'
     72 KEY__LIVE_EDITS__MODIFICATIONS = 'modifications'
     73 KEY__LIVE_EDITS__SET_A_DESCRIPTIONS = 'setA'
     74 KEY__LIVE_EDITS__SET_B_DESCRIPTIONS = 'setB'
     75 
     76 DEFAULT_ACTUALS_DIR = results_mod.DEFAULT_ACTUALS_DIR
     77 DEFAULT_GM_SUMMARIES_BUCKET = download_actuals.GM_SUMMARIES_BUCKET
     78 DEFAULT_JSON_FILENAME = download_actuals.DEFAULT_JSON_FILENAME
     79 DEFAULT_PORT = 8888
     80 
     81 PARENT_DIRECTORY = os.path.dirname(os.path.realpath(__file__))
     82 TRUNK_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.
     86 STATIC_CONTENTS_SUBDIR = 'static'
     87 # All of the GENERATED_*_SUBDIRS are relative to STATIC_CONTENTS_SUBDIR
     88 GENERATED_HTML_SUBDIR = 'generated-html'
     89 GENERATED_IMAGES_SUBDIR = 'generated-images'
     90 GENERATED_JSON_SUBDIR = 'generated-json'
     91 
     92 # Directives associated with various HTTP GET requests.
     93 GET__LIVE_RESULTS = 'live-results'
     94 GET__PRECOMPUTED_RESULTS = 'results'
     95 GET__PREFETCH_RESULTS = 'prefetch'
     96 GET__STATIC_CONTENTS = 'static'
     97 
     98 # Parameters we use within do_GET_live_results() and do_GET_prefetch_results()
     99 LIVE_PARAM__DOWNLOAD_ONLY_DIFFERING = 'downloadOnlyDifferingImages'
    100 LIVE_PARAM__SET_A_DIR = 'setADir'
    101 LIVE_PARAM__SET_A_SECTION = 'setASection'
    102 LIVE_PARAM__SET_B_DIR = 'setBDir'
    103 LIVE_PARAM__SET_B_SECTION = 'setBSection'
    104 
    105 # How often (in seconds) clients should reload while waiting for initial
    106 # results to load.
    107 RELOAD_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.
    114 CONFIG_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 
    138 def _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 
    160 def _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 
    171 def _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 
    259 class 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 
    520 class 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 
    862 def 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 
    952 if __name__ == '__main__':
    953   main()
    954