Upstream version 10.39.225.0
[platform/framework/web/crosswalk.git] / src / third_party / skia / gm / rebaseline_server / server.py
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()