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