blob: 0079ec55fffd0438756a4e7fc94b32f0c99490bb [file] [log] [blame]
epoger@google.comf9d134d2013-09-27 15:02:44 +00001#!/usr/bin/python
2
epoger@google.com9fb6c8a2013-10-09 18:05:58 +00003"""
epoger@google.comf9d134d2013-09-27 15:02:44 +00004Copyright 2013 Google Inc.
5
6Use of this source code is governed by a BSD-style license that can be
7found in the LICENSE file.
epoger@google.comf9d134d2013-09-27 15:02:44 +00008
epoger@google.comf9d134d2013-09-27 15:02:44 +00009HTTP server for our HTML rebaseline viewer.
epoger@google.com9fb6c8a2013-10-09 18:05:58 +000010"""
epoger@google.comf9d134d2013-09-27 15:02:44 +000011
12# System-level imports
13import argparse
14import BaseHTTPServer
15import json
epoger@google.comdcb4e652013-10-11 18:45:33 +000016import logging
epoger@google.comf9d134d2013-09-27 15:02:44 +000017import os
18import posixpath
19import re
20import shutil
epoger@google.comb08c7072013-10-30 14:09:04 +000021import socket
rmistry@google.comd6bab022013-12-02 13:50:38 +000022import subprocess
epoger@google.com542b65f2013-10-15 20:10:33 +000023import thread
rmistry@google.comd6bab022013-12-02 13:50:38 +000024import threading
epoger@google.com542b65f2013-10-15 20:10:33 +000025import time
epoger@google.comdcb4e652013-10-11 18:45:33 +000026import urlparse
epoger@google.comf9d134d2013-09-27 15:02:44 +000027
28# Imports from within Skia
epogerf4666042014-06-04 10:02:58 -070029import fix_pythonpath # must do this first
30from pyutils import gs_utils
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +000031import gm_json
epoger@google.comf9d134d2013-09-27 15:02:44 +000032
33# Imports from local dir
commit-bot@chromium.org7498d952014-03-13 14:56:29 +000034#
35# Note: we import results under a different name, to avoid confusion with the
36# Server.results() property. See discussion at
37# https://codereview.chromium.org/195943004/diff/1/gm/rebaseline_server/server.py#newcode44
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +000038import compare_configs
commit-bot@chromium.orgb463d562014-03-21 17:54:14 +000039import compare_to_expectations
epogerf4666042014-06-04 10:02:58 -070040import download_actuals
commit-bot@chromium.org16f41802014-02-26 19:05:20 +000041import imagepairset
commit-bot@chromium.org7498d952014-03-13 14:56:29 +000042import results as results_mod
epoger@google.comf9d134d2013-09-27 15:02:44 +000043
epoger@google.comf9d134d2013-09-27 15:02:44 +000044PATHSPLIT_RE = re.compile('/([^/]+)/(.+)')
epoger@google.comf9d134d2013-09-27 15:02:44 +000045
46# A simple dictionary of file name extensions to MIME types. The empty string
47# entry is used as the default when no extension was given or if the extension
48# has no entry in this dictionary.
49MIME_TYPE_MAP = {'': 'application/octet-stream',
50 'html': 'text/html',
51 'css': 'text/css',
52 'png': 'image/png',
53 'js': 'application/javascript',
54 'json': 'application/json'
55 }
56
commit-bot@chromium.org16f41802014-02-26 19:05:20 +000057# Keys that server.py uses to create the toplevel content header.
58# NOTE: Keep these in sync with static/constants.js
59KEY__EDITS__MODIFICATIONS = 'modifications'
60KEY__EDITS__OLD_RESULTS_HASH = 'oldResultsHash'
61KEY__EDITS__OLD_RESULTS_TYPE = 'oldResultsType'
commit-bot@chromium.org16f41802014-02-26 19:05:20 +000062
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +000063DEFAULT_ACTUALS_DIR = results_mod.DEFAULT_ACTUALS_DIR
epogerf4666042014-06-04 10:02:58 -070064DEFAULT_GM_SUMMARIES_BUCKET = download_actuals.GM_SUMMARIES_BUCKET
65DEFAULT_JSON_FILENAME = download_actuals.DEFAULT_JSON_FILENAME
epoger@google.comf9d134d2013-09-27 15:02:44 +000066DEFAULT_PORT = 8888
67
epogerf4666042014-06-04 10:02:58 -070068PARENT_DIRECTORY = os.path.dirname(os.path.realpath(__file__))
69TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(PARENT_DIRECTORY))
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +000070# Directory, relative to PARENT_DIRECTORY, within which the server will serve
71# out live results (not static files).
72RESULTS_SUBDIR = 'results'
73# Directory, relative to PARENT_DIRECTORY, within which the server will serve
74# out static files.
75STATIC_CONTENTS_SUBDIR = 'static'
76# All of the GENERATED_*_SUBDIRS are relative to STATIC_CONTENTS_SUBDIR
77GENERATED_HTML_SUBDIR = 'generated-html'
78GENERATED_IMAGES_SUBDIR = 'generated-images'
79GENERATED_JSON_SUBDIR = 'generated-json'
commit-bot@chromium.org57994232014-03-20 17:27:46 +000080
epoger@google.com2682c902013-12-05 16:05:16 +000081# How often (in seconds) clients should reload while waiting for initial
82# results to load.
83RELOAD_INTERVAL_UNTIL_READY = 10
84
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +000085SUMMARY_TYPES = [
86 results_mod.KEY__HEADER__RESULTS_FAILURES,
87 results_mod.KEY__HEADER__RESULTS_ALL,
88]
89# If --compare-configs is specified, compare these configs.
90CONFIG_PAIRS_TO_COMPARE = [('8888', 'gpu')]
91
epoger@google.comeb832592013-10-23 15:07:26 +000092_HTTP_HEADER_CONTENT_LENGTH = 'Content-Length'
93_HTTP_HEADER_CONTENT_TYPE = 'Content-Type'
94
epoger@google.comf9d134d2013-09-27 15:02:44 +000095_SERVER = None # This gets filled in by main()
96
rmistry@google.comd6bab022013-12-02 13:50:38 +000097
98def _run_command(args, directory):
99 """Runs a command and returns stdout as a single string.
100
101 Args:
102 args: the command to run, as a list of arguments
103 directory: directory within which to run the command
104
105 Returns: stdout, as a string
106
107 Raises an Exception if the command failed (exited with nonzero return code).
108 """
109 logging.debug('_run_command: %s in directory %s' % (args, directory))
110 proc = subprocess.Popen(args, cwd=directory,
111 stdout=subprocess.PIPE,
112 stderr=subprocess.PIPE)
113 (stdout, stderr) = proc.communicate()
114 if proc.returncode is not 0:
115 raise Exception('command "%s" failed in dir "%s": %s' %
116 (args, directory, stderr))
117 return stdout
118
119
epoger@google.com591469b2013-11-20 19:58:06 +0000120def _get_routable_ip_address():
epoger@google.comb08c7072013-10-30 14:09:04 +0000121 """Returns routable IP address of this host (the IP address of its network
122 interface that would be used for most traffic, not its localhost
123 interface). See http://stackoverflow.com/a/166589 """
124 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
125 sock.connect(('8.8.8.8', 80))
126 host = sock.getsockname()[0]
127 sock.close()
128 return host
129
rmistry@google.comd6bab022013-12-02 13:50:38 +0000130
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000131def _create_index(file_path, config_pairs):
132 """Creates an index file linking to all results available from this server.
133
134 Prior to https://codereview.chromium.org/215503002 , we had a static
135 index.html within our repo. But now that the results may or may not include
136 config comparisons, index.html needs to be generated differently depending
137 on which results are included.
138
139 TODO(epoger): Instead of including raw HTML within the Python code,
140 consider restoring the index.html file as a template and using django (or
141 similar) to fill in dynamic content.
142
143 Args:
144 file_path: path on local disk to write index to; any directory components
145 of this path that do not already exist will be created
146 config_pairs: what pairs of configs (if any) we compare actual results of
147 """
148 dir_path = os.path.dirname(file_path)
149 if not os.path.isdir(dir_path):
150 os.makedirs(dir_path)
151 with open(file_path, 'w') as file_handle:
152 file_handle.write(
153 '<!DOCTYPE html><html>'
154 '<head><title>rebaseline_server</title></head>'
155 '<body><ul>')
156 if SUMMARY_TYPES:
157 file_handle.write('<li>Expectations vs Actuals</li><ul>')
158 for summary_type in SUMMARY_TYPES:
159 file_handle.write(
160 '<li>'
161 '<a href="/%s/view.html#/view.html?resultsToLoad=/%s/%s">'
162 '%s</a></li>' % (
163 STATIC_CONTENTS_SUBDIR, RESULTS_SUBDIR,
164 summary_type, summary_type))
165 file_handle.write('</ul>')
166 if config_pairs:
167 file_handle.write('<li>Comparing configs within actual results</li><ul>')
168 for config_pair in config_pairs:
169 file_handle.write('<li>%s vs %s:' % config_pair)
170 for summary_type in SUMMARY_TYPES:
171 file_handle.write(
172 ' <a href="/%s/view.html#/view.html?'
173 'resultsToLoad=/%s/%s/%s-vs-%s_%s.json">%s</a>' % (
174 STATIC_CONTENTS_SUBDIR, STATIC_CONTENTS_SUBDIR,
175 GENERATED_JSON_SUBDIR, config_pair[0], config_pair[1],
176 summary_type, summary_type))
177 file_handle.write('</li>')
178 file_handle.write('</ul>')
179 file_handle.write('</ul></body></html>')
180
181
epoger@google.comf9d134d2013-09-27 15:02:44 +0000182class Server(object):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000183 """ HTTP server for our HTML rebaseline viewer. """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000184
epoger@google.comf9d134d2013-09-27 15:02:44 +0000185 def __init__(self,
186 actuals_dir=DEFAULT_ACTUALS_DIR,
epogerf4666042014-06-04 10:02:58 -0700187 json_filename=DEFAULT_JSON_FILENAME,
188 gm_summaries_bucket=DEFAULT_GM_SUMMARIES_BUCKET,
epoger@google.com542b65f2013-10-15 20:10:33 +0000189 port=DEFAULT_PORT, export=False, editable=True,
commit-bot@chromium.orgdefe6fd2014-04-10 15:05:24 +0000190 reload_seconds=0, config_pairs=None, builder_regex_list=None):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000191 """
192 Args:
193 actuals_dir: directory under which we will check out the latest actual
commit-bot@chromium.orgc0df2fb2014-03-28 14:28:04 +0000194 GM results
epogerf4666042014-06-04 10:02:58 -0700195 json_filename: basename of the JSON summary file to load for each builder
196 gm_summaries_bucket: Google Storage bucket to download json_filename
197 files from; if None or '', don't fetch new actual-results files
198 at all, just compare to whatever files are already in actuals_dir
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000199 port: which TCP port to listen on for HTTP requests
200 export: whether to allow HTTP clients on other hosts to access this server
epoger@google.com542b65f2013-10-15 20:10:33 +0000201 editable: whether HTTP clients are allowed to submit new baselines
202 reload_seconds: polling interval with which to check for new results;
commit-bot@chromium.orgc0df2fb2014-03-28 14:28:04 +0000203 if 0, don't check for new results at all
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000204 config_pairs: List of (string, string) tuples; for each tuple, compare
205 actual results of these two configs. If None or empty,
206 don't compare configs at all.
commit-bot@chromium.orgdefe6fd2014-04-10 15:05:24 +0000207 builder_regex_list: List of regular expressions specifying which builders
208 we will process. If None, process all builders.
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000209 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000210 self._actuals_dir = actuals_dir
epogerf4666042014-06-04 10:02:58 -0700211 self._json_filename = json_filename
212 self._gm_summaries_bucket = gm_summaries_bucket
epoger@google.comf9d134d2013-09-27 15:02:44 +0000213 self._port = port
214 self._export = export
epoger@google.com542b65f2013-10-15 20:10:33 +0000215 self._editable = editable
216 self._reload_seconds = reload_seconds
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000217 self._config_pairs = config_pairs or []
commit-bot@chromium.orgdefe6fd2014-04-10 15:05:24 +0000218 self._builder_regex_list = builder_regex_list
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000219 _create_index(
220 file_path=os.path.join(
221 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR, GENERATED_HTML_SUBDIR,
222 "index.html"),
223 config_pairs=config_pairs)
epoger@google.com591469b2013-11-20 19:58:06 +0000224
rmistry@google.comd6bab022013-12-02 13:50:38 +0000225 # Reentrant lock that must be held whenever updating EITHER of:
226 # 1. self._results
227 # 2. the expected or actual results on local disk
228 self.results_rlock = threading.RLock()
229 # self._results will be filled in by calls to update_results()
230 self._results = None
epoger@google.comf9d134d2013-09-27 15:02:44 +0000231
rmistry@google.comd6bab022013-12-02 13:50:38 +0000232 @property
233 def results(self):
commit-bot@chromium.org50ad8e42013-12-17 18:06:13 +0000234 """ Returns the most recently generated results, or None if we don't have
235 any valid results (update_results() has not completed yet). """
rmistry@google.comd6bab022013-12-02 13:50:38 +0000236 return self._results
237
238 @property
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000239 def is_exported(self):
240 """ Returns true iff HTTP clients on other hosts are allowed to access
241 this server. """
242 return self._export
243
rmistry@google.comd6bab022013-12-02 13:50:38 +0000244 @property
epoger@google.com542b65f2013-10-15 20:10:33 +0000245 def is_editable(self):
246 """ Returns true iff HTTP clients are allowed to submit new baselines. """
247 return self._editable
epoger@google.comf9d134d2013-09-27 15:02:44 +0000248
rmistry@google.comd6bab022013-12-02 13:50:38 +0000249 @property
epoger@google.com542b65f2013-10-15 20:10:33 +0000250 def reload_seconds(self):
251 """ Returns the result reload period in seconds, or 0 if we don't reload
252 results. """
253 return self._reload_seconds
254
commit-bot@chromium.org50ad8e42013-12-17 18:06:13 +0000255 def update_results(self, invalidate=False):
commit-bot@chromium.org7498d952014-03-13 14:56:29 +0000256 """ Create or update self._results, based on the latest expectations and
257 actuals.
rmistry@google.comd6bab022013-12-02 13:50:38 +0000258
259 We hold self.results_rlock while we do this, to guarantee that no other
260 thread attempts to update either self._results or the underlying files at
261 the same time.
commit-bot@chromium.org50ad8e42013-12-17 18:06:13 +0000262
263 Args:
264 invalidate: if True, invalidate self._results immediately upon entry;
265 otherwise, we will let readers see those results until we
266 replace them
epoger@google.comf9d134d2013-09-27 15:02:44 +0000267 """
rmistry@google.comd6bab022013-12-02 13:50:38 +0000268 with self.results_rlock:
commit-bot@chromium.org50ad8e42013-12-17 18:06:13 +0000269 if invalidate:
270 self._results = None
epogerf4666042014-06-04 10:02:58 -0700271 if self._gm_summaries_bucket:
commit-bot@chromium.orgc0df2fb2014-03-28 14:28:04 +0000272 logging.info(
epogerf4666042014-06-04 10:02:58 -0700273 'Updating GM result summaries in %s from gm_summaries_bucket %s ...'
274 % (self._actuals_dir, self._gm_summaries_bucket))
275
276 # Clean out actuals_dir first, in case some builders have gone away
277 # since we last ran.
278 if os.path.isdir(self._actuals_dir):
279 shutil.rmtree(self._actuals_dir)
280
281 # Get the list of builders we care about.
282 all_builders = download_actuals.get_builders_list(
283 summaries_bucket=self._gm_summaries_bucket)
284 if self._builder_regex_list:
285 matching_builders = []
286 for builder in all_builders:
287 for regex in self._builder_regex_list:
288 if re.match(regex, builder):
289 matching_builders.append(builder)
290 break # go on to the next builder, no need to try more regexes
291 else:
292 matching_builders = all_builders
293
294 # Download the JSON file for each builder we care about.
295 #
296 # TODO(epoger): When this is a large number of builders, we would be
297 # better off downloading them in parallel!
298 for builder in matching_builders:
299 gs_utils.download_file(
300 source_bucket=self._gm_summaries_bucket,
301 source_path=posixpath.join(builder, self._json_filename),
302 dest_path=os.path.join(self._actuals_dir, builder,
303 self._json_filename),
304 create_subdirs_if_needed=True)
epoger@google.com591469b2013-11-20 19:58:06 +0000305
rmistry@google.comd6bab022013-12-02 13:50:38 +0000306 # We only update the expectations dir if the server was run with a
307 # nonzero --reload argument; otherwise, we expect the user to maintain
308 # her own expectations as she sees fit.
309 #
epogerf4666042014-06-04 10:02:58 -0700310 # Because the Skia repo is hosted using git, and git does not
rmistry@google.comd6bab022013-12-02 13:50:38 +0000311 # support updating a single directory tree, we have to update the entire
312 # repo checkout.
313 #
314 # Because Skia uses depot_tools, we have to update using "gclient sync"
epogerf4666042014-06-04 10:02:58 -0700315 # instead of raw git commands.
316 #
317 # TODO(epoger): Fetch latest expectations in some other way.
318 # Eric points out that our official documentation recommends an
319 # unmanaged Skia checkout, so "gclient sync" will not bring down updated
320 # expectations from origin/master-- you'd have to do a "git pull" of
321 # some sort instead.
322 # However, the live rebaseline_server at
323 # http://skia-tree-status.appspot.com/redirect/rebaseline-server (which
324 # is probably the only user of the --reload flag!) uses a managed
325 # checkout, so "gclient sync" works in that case.
326 # Probably the best idea is to avoid all of this nonsense by fetching
327 # updated expectations into a temp directory, and leaving the rest of
328 # the checkout alone. This could be done using "git show", or by
329 # downloading individual expectation JSON files from
330 # skia.googlesource.com .
rmistry@google.comd6bab022013-12-02 13:50:38 +0000331 if self._reload_seconds:
332 logging.info(
333 'Updating expected GM results in %s by syncing Skia repo ...' %
commit-bot@chromium.orgb463d562014-03-21 17:54:14 +0000334 compare_to_expectations.DEFAULT_EXPECTATIONS_DIR)
rmistry@google.comd6bab022013-12-02 13:50:38 +0000335 _run_command(['gclient', 'sync'], TRUNK_DIRECTORY)
336
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000337 self._results = compare_to_expectations.ExpectationComparisons(
commit-bot@chromium.org57994232014-03-20 17:27:46 +0000338 actuals_root=self._actuals_dir,
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000339 generated_images_root=os.path.join(
340 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR,
341 GENERATED_IMAGES_SUBDIR),
342 diff_base_url=posixpath.join(
commit-bot@chromium.orgdefe6fd2014-04-10 15:05:24 +0000343 os.pardir, STATIC_CONTENTS_SUBDIR, GENERATED_IMAGES_SUBDIR),
344 builder_regex_list=self._builder_regex_list)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000345
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000346 json_dir = os.path.join(
347 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR, GENERATED_JSON_SUBDIR)
348 if not os.path.isdir(json_dir):
349 os.makedirs(json_dir)
350
351 for config_pair in self._config_pairs:
352 config_comparisons = compare_configs.ConfigComparisons(
353 configs=config_pair,
354 actuals_root=self._actuals_dir,
355 generated_images_root=os.path.join(
356 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR,
357 GENERATED_IMAGES_SUBDIR),
358 diff_base_url=posixpath.join(
commit-bot@chromium.orgdefe6fd2014-04-10 15:05:24 +0000359 os.pardir, GENERATED_IMAGES_SUBDIR),
360 builder_regex_list=self._builder_regex_list)
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000361 for summary_type in SUMMARY_TYPES:
362 gm_json.WriteToFile(
363 config_comparisons.get_packaged_results_of_type(
364 results_type=summary_type),
365 os.path.join(
366 json_dir, '%s-vs-%s_%s.json' % (
367 config_pair[0], config_pair[1], summary_type)))
368
epoger@google.com2682c902013-12-05 16:05:16 +0000369 def _result_loader(self, reload_seconds=0):
370 """ Call self.update_results(), either once or periodically.
371
372 Params:
373 reload_seconds: integer; if nonzero, reload results at this interval
374 (in which case, this method will never return!)
epoger@google.com542b65f2013-10-15 20:10:33 +0000375 """
epoger@google.com2682c902013-12-05 16:05:16 +0000376 self.update_results()
377 logging.info('Initial results loaded. Ready for requests on %s' % self._url)
378 if reload_seconds:
379 while True:
380 time.sleep(reload_seconds)
381 self.update_results()
epoger@google.com542b65f2013-10-15 20:10:33 +0000382
epoger@google.comf9d134d2013-09-27 15:02:44 +0000383 def run(self):
epoger@google.com2682c902013-12-05 16:05:16 +0000384 arg_tuple = (self._reload_seconds,) # start_new_thread needs a tuple,
385 # even though it holds just one param
386 thread.start_new_thread(self._result_loader, arg_tuple)
epoger@google.com542b65f2013-10-15 20:10:33 +0000387
epoger@google.comf9d134d2013-09-27 15:02:44 +0000388 if self._export:
389 server_address = ('', self._port)
epoger@google.com591469b2013-11-20 19:58:06 +0000390 host = _get_routable_ip_address()
epoger@google.com542b65f2013-10-15 20:10:33 +0000391 if self._editable:
392 logging.warning('Running with combination of "export" and "editable" '
393 'flags. Users on other machines will '
394 'be able to modify your GM expectations!')
epoger@google.comf9d134d2013-09-27 15:02:44 +0000395 else:
epoger@google.comb08c7072013-10-30 14:09:04 +0000396 host = '127.0.0.1'
397 server_address = (host, self._port)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000398 http_server = BaseHTTPServer.HTTPServer(server_address, HTTPRequestHandler)
epoger@google.com2682c902013-12-05 16:05:16 +0000399 self._url = 'http://%s:%d' % (host, http_server.server_port)
400 logging.info('Listening for requests on %s' % self._url)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000401 http_server.serve_forever()
402
403
404class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
405 """ HTTP request handlers for various types of queries this server knows
406 how to handle (static HTML and Javascript, expected/actual results, etc.)
407 """
408 def do_GET(self):
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000409 """
410 Handles all GET requests, forwarding them to the appropriate
411 do_GET_* dispatcher.
epoger@google.comf9d134d2013-09-27 15:02:44 +0000412
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000413 If we see any Exceptions, return a 404. This fixes http://skbug.com/2147
414 """
415 try:
416 logging.debug('do_GET: path="%s"' % self.path)
417 if self.path == '' or self.path == '/' or self.path == '/index.html' :
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000418 self.redirect_to('/%s/%s/index.html' % (
419 STATIC_CONTENTS_SUBDIR, GENERATED_HTML_SUBDIR))
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000420 return
421 if self.path == '/favicon.ico' :
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000422 self.redirect_to('/%s/favicon.ico' % STATIC_CONTENTS_SUBDIR)
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000423 return
424
425 # All requests must be of this form:
426 # /dispatcher/remainder
427 # where 'dispatcher' indicates which do_GET_* dispatcher to run
428 # and 'remainder' is the remaining path sent to that dispatcher.
429 normpath = posixpath.normpath(self.path)
430 (dispatcher_name, remainder) = PATHSPLIT_RE.match(normpath).groups()
431 dispatchers = {
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000432 RESULTS_SUBDIR: self.do_GET_results,
433 STATIC_CONTENTS_SUBDIR: self.do_GET_static,
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000434 }
435 dispatcher = dispatchers[dispatcher_name]
436 dispatcher(remainder)
437 except:
438 self.send_error(404)
439 raise
epoger@google.comf9d134d2013-09-27 15:02:44 +0000440
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000441 def do_GET_results(self, results_type):
442 """ Handle a GET request for GM results.
443
444 Args:
445 results_type: string indicating which set of results to return;
446 must be one of the results_mod.RESULTS_* constants
447 """
448 logging.debug('do_GET_results: sending results of type "%s"' % results_type)
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000449 # Since we must make multiple calls to the ExpectationComparisons object,
450 # grab a reference to it in case it is updated to point at a new
451 # ExpectationComparisons object within another thread.
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000452 #
453 # TODO(epoger): Rather than using a global variable for the handler
454 # to refer to the Server object, make Server a subclass of
455 # HTTPServer, and then it could be available to the handler via
456 # the handler's .server instance variable.
457 results_obj = _SERVER.results
458 if results_obj:
459 response_dict = results_obj.get_packaged_results_of_type(
460 results_type=results_type, reload_seconds=_SERVER.reload_seconds,
461 is_editable=_SERVER.is_editable, is_exported=_SERVER.is_exported)
462 else:
463 now = int(time.time())
464 response_dict = {
commit-bot@chromium.org68a38152014-05-12 20:40:29 +0000465 imagepairset.KEY__ROOT__HEADER: {
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000466 results_mod.KEY__HEADER__SCHEMA_VERSION: (
commit-bot@chromium.org68a38152014-05-12 20:40:29 +0000467 results_mod.VALUE__HEADER__SCHEMA_VERSION),
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000468 results_mod.KEY__HEADER__IS_STILL_LOADING: True,
469 results_mod.KEY__HEADER__TIME_UPDATED: now,
470 results_mod.KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE: (
471 now + RELOAD_INTERVAL_UNTIL_READY),
472 },
473 }
474 self.send_json_dict(response_dict)
475
epoger@google.comf9d134d2013-09-27 15:02:44 +0000476 def do_GET_static(self, path):
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000477 """ Handle a GET request for a file under STATIC_CONTENTS_SUBDIR .
478 Only allow serving of files within STATIC_CONTENTS_SUBDIR that is a
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000479 filesystem sibling of this script.
480
481 Args:
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000482 path: path to file (within STATIC_CONTENTS_SUBDIR) to retrieve
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000483 """
epoger@google.comdcb4e652013-10-11 18:45:33 +0000484 # Strip arguments ('?resultsToLoad=all') from the path
485 path = urlparse.urlparse(path).path
486
487 logging.debug('do_GET_static: sending file "%s"' % path)
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000488 static_dir = os.path.realpath(os.path.join(
489 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR))
490 full_path = os.path.realpath(os.path.join(static_dir, path))
491 if full_path.startswith(static_dir):
epoger@google.comcb55f112013-10-02 19:27:35 +0000492 self.send_file(full_path)
493 else:
epoger@google.comdcb4e652013-10-11 18:45:33 +0000494 logging.error(
495 'Attempted do_GET_static() of path [%s] outside of static dir [%s]'
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000496 % (full_path, static_dir))
epoger@google.comcb55f112013-10-02 19:27:35 +0000497 self.send_error(404)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000498
epoger@google.comeb832592013-10-23 15:07:26 +0000499 def do_POST(self):
500 """ Handles all POST requests, forwarding them to the appropriate
501 do_POST_* dispatcher. """
502 # All requests must be of this form:
503 # /dispatcher
504 # where 'dispatcher' indicates which do_POST_* dispatcher to run.
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000505 logging.debug('do_POST: path="%s"' % self.path)
epoger@google.comeb832592013-10-23 15:07:26 +0000506 normpath = posixpath.normpath(self.path)
507 dispatchers = {
508 '/edits': self.do_POST_edits,
509 }
510 try:
511 dispatcher = dispatchers[normpath]
512 dispatcher()
513 self.send_response(200)
514 except:
515 self.send_error(404)
516 raise
517
518 def do_POST_edits(self):
519 """ Handle a POST request with modifications to GM expectations, in this
520 format:
521
522 {
commit-bot@chromium.org16f41802014-02-26 19:05:20 +0000523 KEY__EDITS__OLD_RESULTS_TYPE: 'all', # type of results that the client
524 # loaded and then made
525 # modifications to
526 KEY__EDITS__OLD_RESULTS_HASH: 39850913, # hash of results when the client
527 # loaded them (ensures that the
528 # client and server apply
529 # modifications to the same base)
530 KEY__EDITS__MODIFICATIONS: [
commit-bot@chromium.orgb463d562014-03-21 17:54:14 +0000531 # as needed by compare_to_expectations.edit_expectations()
epoger@google.comeb832592013-10-23 15:07:26 +0000532 ...
533 ],
534 }
535
536 Raises an Exception if there were any problems.
537 """
rmistry@google.comd6bab022013-12-02 13:50:38 +0000538 if not _SERVER.is_editable:
epoger@google.comeb832592013-10-23 15:07:26 +0000539 raise Exception('this server is not running in --editable mode')
540
541 content_type = self.headers[_HTTP_HEADER_CONTENT_TYPE]
542 if content_type != 'application/json;charset=UTF-8':
543 raise Exception('unsupported %s [%s]' % (
544 _HTTP_HEADER_CONTENT_TYPE, content_type))
545
546 content_length = int(self.headers[_HTTP_HEADER_CONTENT_LENGTH])
547 json_data = self.rfile.read(content_length)
548 data = json.loads(json_data)
549 logging.debug('do_POST_edits: received new GM expectations data [%s]' %
550 data)
551
rmistry@google.comd6bab022013-12-02 13:50:38 +0000552 # Update the results on disk with the information we received from the
553 # client.
554 # We must hold _SERVER.results_rlock while we do this, to guarantee that
555 # no other thread updates expectations (from the Skia repo) while we are
556 # updating them (using the info we received from the client).
557 with _SERVER.results_rlock:
commit-bot@chromium.org16f41802014-02-26 19:05:20 +0000558 oldResultsType = data[KEY__EDITS__OLD_RESULTS_TYPE]
rmistry@google.comd6bab022013-12-02 13:50:38 +0000559 oldResults = _SERVER.results.get_results_of_type(oldResultsType)
commit-bot@chromium.org68a38152014-05-12 20:40:29 +0000560 oldResultsHash = str(hash(repr(
561 oldResults[imagepairset.KEY__ROOT__IMAGEPAIRS])))
commit-bot@chromium.org16f41802014-02-26 19:05:20 +0000562 if oldResultsHash != data[KEY__EDITS__OLD_RESULTS_HASH]:
rmistry@google.comd6bab022013-12-02 13:50:38 +0000563 raise Exception('results of type "%s" changed while the client was '
564 'making modifications. The client should reload the '
565 'results and submit the modifications again.' %
566 oldResultsType)
commit-bot@chromium.org16f41802014-02-26 19:05:20 +0000567 _SERVER.results.edit_expectations(data[KEY__EDITS__MODIFICATIONS])
commit-bot@chromium.org50ad8e42013-12-17 18:06:13 +0000568
569 # Read the updated results back from disk.
570 # We can do this in a separate thread; we should return our success message
571 # to the UI as soon as possible.
572 thread.start_new_thread(_SERVER.update_results, (True,))
epoger@google.comeb832592013-10-23 15:07:26 +0000573
epoger@google.comf9d134d2013-09-27 15:02:44 +0000574 def redirect_to(self, url):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000575 """ Redirect the HTTP client to a different url.
576
577 Args:
578 url: URL to redirect the HTTP client to
579 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000580 self.send_response(301)
581 self.send_header('Location', url)
582 self.end_headers()
583
584 def send_file(self, path):
585 """ Send the contents of the file at this path, with a mimetype based
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000586 on the filename extension.
587
588 Args:
589 path: path of file whose contents to send to the HTTP client
590 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000591 # Grab the extension if there is one
592 extension = os.path.splitext(path)[1]
593 if len(extension) >= 1:
594 extension = extension[1:]
595
596 # Determine the MIME type of the file from its extension
597 mime_type = MIME_TYPE_MAP.get(extension, MIME_TYPE_MAP[''])
598
599 # Open the file and send it over HTTP
600 if os.path.isfile(path):
601 with open(path, 'rb') as sending_file:
602 self.send_response(200)
603 self.send_header('Content-type', mime_type)
604 self.end_headers()
605 self.wfile.write(sending_file.read())
606 else:
607 self.send_error(404)
608
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000609 def send_json_dict(self, json_dict):
610 """ Send the contents of this dictionary in JSON format, with a JSON
611 mimetype.
612
613 Args:
614 json_dict: dictionary to send
615 """
616 self.send_response(200)
617 self.send_header('Content-type', 'application/json')
618 self.end_headers()
619 json.dump(json_dict, self.wfile)
620
epoger@google.comf9d134d2013-09-27 15:02:44 +0000621
622def main():
commit-bot@chromium.orga6ecbb82013-12-19 19:08:31 +0000623 logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s',
624 datefmt='%m/%d/%Y %H:%M:%S',
625 level=logging.INFO)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000626 parser = argparse.ArgumentParser()
627 parser.add_argument('--actuals-dir',
628 help=('Directory into which we will check out the latest '
629 'actual GM results. If this directory does not '
630 'exist, it will be created. Defaults to %(default)s'),
631 default=DEFAULT_ACTUALS_DIR)
epogerf4666042014-06-04 10:02:58 -0700632 # TODO(epoger): Before https://codereview.chromium.org/310093003 ,
633 # when this tool downloaded the JSON summaries from skia-autogen,
634 # it had an --actuals-revision the caller could specify to download
635 # actual results as of a specific point in time. We should add similar
636 # functionality when retrieving the summaries from Google Storage.
commit-bot@chromium.orgdefe6fd2014-04-10 15:05:24 +0000637 parser.add_argument('--builders', metavar='BUILDER_REGEX', nargs='+',
638 help=('Only process builders matching these regular '
639 'expressions. If unspecified, process all '
640 'builders.'))
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000641 parser.add_argument('--compare-configs', action='store_true',
642 help=('In addition to generating differences between '
643 'expectations and actuals, also generate '
644 'differences between these config pairs: '
645 + str(CONFIG_PAIRS_TO_COMPARE)))
epoger@google.com542b65f2013-10-15 20:10:33 +0000646 parser.add_argument('--editable', action='store_true',
epoger@google.comeb832592013-10-23 15:07:26 +0000647 help=('Allow HTTP clients to submit new baselines.'))
epoger@google.comf9d134d2013-09-27 15:02:44 +0000648 parser.add_argument('--export', action='store_true',
649 help=('Instead of only allowing access from HTTP clients '
650 'on localhost, allow HTTP clients on other hosts '
651 'to access this server. WARNING: doing so will '
652 'allow users on other hosts to modify your '
epoger@google.com542b65f2013-10-15 20:10:33 +0000653 'GM expectations, if combined with --editable.'))
epogerf4666042014-06-04 10:02:58 -0700654 parser.add_argument('--gm-summaries-bucket',
655 help=('Google Cloud Storage bucket to download '
656 'JSON_FILENAME files from. '
657 'Defaults to %(default)s ; if set to '
658 'empty string, just compare to actual-results '
659 'already found in ACTUALS_DIR.'),
660 default=DEFAULT_GM_SUMMARIES_BUCKET)
661 parser.add_argument('--json-filename',
662 help=('JSON summary filename to read for each builder; '
663 'defaults to %(default)s.'),
664 default=DEFAULT_JSON_FILENAME)
epoger@google.comafaad3d2013-09-30 15:06:25 +0000665 parser.add_argument('--port', type=int,
666 help=('Which TCP port to listen on for HTTP requests; '
667 'defaults to %(default)s'),
668 default=DEFAULT_PORT)
epoger@google.com542b65f2013-10-15 20:10:33 +0000669 parser.add_argument('--reload', type=int,
670 help=('How often (a period in seconds) to update the '
epoger@google.comb063e132013-11-25 18:06:29 +0000671 'results. If specified, both expected and actual '
rmistry@google.comd6bab022013-12-02 13:50:38 +0000672 'results will be updated by running "gclient sync" '
673 'on your Skia checkout as a whole. '
epoger@google.com542b65f2013-10-15 20:10:33 +0000674 'By default, we do not reload at all, and you '
675 'must restart the server to pick up new data.'),
676 default=0)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000677 args = parser.parse_args()
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000678 if args.compare_configs:
679 config_pairs = CONFIG_PAIRS_TO_COMPARE
680 else:
681 config_pairs = None
682
epoger@google.comf9d134d2013-09-27 15:02:44 +0000683 global _SERVER
epoger@google.com542b65f2013-10-15 20:10:33 +0000684 _SERVER = Server(actuals_dir=args.actuals_dir,
epogerf4666042014-06-04 10:02:58 -0700685 json_filename=args.json_filename,
686 gm_summaries_bucket=args.gm_summaries_bucket,
epoger@google.com542b65f2013-10-15 20:10:33 +0000687 port=args.port, export=args.export, editable=args.editable,
commit-bot@chromium.orgdefe6fd2014-04-10 15:05:24 +0000688 reload_seconds=args.reload, config_pairs=config_pairs,
689 builder_regex_list=args.builders)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000690 _SERVER.run()
691
rmistry@google.comd6bab022013-12-02 13:50:38 +0000692
epoger@google.comf9d134d2013-09-27 15:02:44 +0000693if __name__ == '__main__':
694 main()