blob: 06807796704a5322af1a8427fc432ed0947f66fe [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.comf9d134d2013-09-27 15:02:44 +000023import sys
epoger@google.com542b65f2013-10-15 20:10:33 +000024import thread
rmistry@google.comd6bab022013-12-02 13:50:38 +000025import threading
epoger@google.com542b65f2013-10-15 20:10:33 +000026import time
epoger@google.comdcb4e652013-10-11 18:45:33 +000027import urlparse
epoger@google.comf9d134d2013-09-27 15:02:44 +000028
29# Imports from within Skia
30#
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +000031# We need to add the 'tools' directory for svn.py, and the 'gm' directory for
32# gm_json.py .
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +000033# that directory.
34# Make sure that the 'tools' dir is in the PYTHONPATH, but add it at the *end*
epoger@google.comf9d134d2013-09-27 15:02:44 +000035# so any dirs that are already in the PYTHONPATH will be preferred.
epoger@google.comcb55f112013-10-02 19:27:35 +000036PARENT_DIRECTORY = os.path.dirname(os.path.realpath(__file__))
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +000037GM_DIRECTORY = os.path.dirname(PARENT_DIRECTORY)
38TRUNK_DIRECTORY = os.path.dirname(GM_DIRECTORY)
epoger@google.comf9d134d2013-09-27 15:02:44 +000039TOOLS_DIRECTORY = os.path.join(TRUNK_DIRECTORY, 'tools')
40if TOOLS_DIRECTORY not in sys.path:
41 sys.path.append(TOOLS_DIRECTORY)
42import svn
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +000043if GM_DIRECTORY not in sys.path:
44 sys.path.append(GM_DIRECTORY)
45import gm_json
epoger@google.comf9d134d2013-09-27 15:02:44 +000046
47# Imports from local dir
commit-bot@chromium.org7498d952014-03-13 14:56:29 +000048#
49# Note: we import results under a different name, to avoid confusion with the
50# Server.results() property. See discussion at
51# https://codereview.chromium.org/195943004/diff/1/gm/rebaseline_server/server.py#newcode44
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +000052import compare_configs
commit-bot@chromium.orgb463d562014-03-21 17:54:14 +000053import compare_to_expectations
commit-bot@chromium.org16f41802014-02-26 19:05:20 +000054import imagepairset
commit-bot@chromium.org7498d952014-03-13 14:56:29 +000055import results as results_mod
epoger@google.comf9d134d2013-09-27 15:02:44 +000056
epoger@google.comf9d134d2013-09-27 15:02:44 +000057PATHSPLIT_RE = re.compile('/([^/]+)/(.+)')
epoger@google.comf9d134d2013-09-27 15:02:44 +000058
59# A simple dictionary of file name extensions to MIME types. The empty string
60# entry is used as the default when no extension was given or if the extension
61# has no entry in this dictionary.
62MIME_TYPE_MAP = {'': 'application/octet-stream',
63 'html': 'text/html',
64 'css': 'text/css',
65 'png': 'image/png',
66 'js': 'application/javascript',
67 'json': 'application/json'
68 }
69
commit-bot@chromium.org16f41802014-02-26 19:05:20 +000070# Keys that server.py uses to create the toplevel content header.
71# NOTE: Keep these in sync with static/constants.js
72KEY__EDITS__MODIFICATIONS = 'modifications'
73KEY__EDITS__OLD_RESULTS_HASH = 'oldResultsHash'
74KEY__EDITS__OLD_RESULTS_TYPE = 'oldResultsType'
commit-bot@chromium.org16f41802014-02-26 19:05:20 +000075
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +000076DEFAULT_ACTUALS_DIR = results_mod.DEFAULT_ACTUALS_DIR
commit-bot@chromium.org5865ec52014-03-10 18:09:25 +000077DEFAULT_ACTUALS_REPO_REVISION = 'HEAD'
78DEFAULT_ACTUALS_REPO_URL = 'http://skia-autogen.googlecode.com/svn/gm-actual'
epoger@google.comf9d134d2013-09-27 15:02:44 +000079DEFAULT_PORT = 8888
80
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +000081# Directory, relative to PARENT_DIRECTORY, within which the server will serve
82# out live results (not static files).
83RESULTS_SUBDIR = 'results'
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'
commit-bot@chromium.org57994232014-03-20 17:27:46 +000091
epoger@google.com2682c902013-12-05 16:05:16 +000092# How often (in seconds) clients should reload while waiting for initial
93# results to load.
94RELOAD_INTERVAL_UNTIL_READY = 10
95
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +000096SUMMARY_TYPES = [
97 results_mod.KEY__HEADER__RESULTS_FAILURES,
98 results_mod.KEY__HEADER__RESULTS_ALL,
99]
100# If --compare-configs is specified, compare these configs.
101CONFIG_PAIRS_TO_COMPARE = [('8888', 'gpu')]
102
epoger@google.comeb832592013-10-23 15:07:26 +0000103_HTTP_HEADER_CONTENT_LENGTH = 'Content-Length'
104_HTTP_HEADER_CONTENT_TYPE = 'Content-Type'
105
epoger@google.comf9d134d2013-09-27 15:02:44 +0000106_SERVER = None # This gets filled in by main()
107
rmistry@google.comd6bab022013-12-02 13:50:38 +0000108
109def _run_command(args, directory):
110 """Runs a command and returns stdout as a single string.
111
112 Args:
113 args: the command to run, as a list of arguments
114 directory: directory within which to run the command
115
116 Returns: stdout, as a string
117
118 Raises an Exception if the command failed (exited with nonzero return code).
119 """
120 logging.debug('_run_command: %s in directory %s' % (args, directory))
121 proc = subprocess.Popen(args, cwd=directory,
122 stdout=subprocess.PIPE,
123 stderr=subprocess.PIPE)
124 (stdout, stderr) = proc.communicate()
125 if proc.returncode is not 0:
126 raise Exception('command "%s" failed in dir "%s": %s' %
127 (args, directory, stderr))
128 return stdout
129
130
epoger@google.com591469b2013-11-20 19:58:06 +0000131def _get_routable_ip_address():
epoger@google.comb08c7072013-10-30 14:09:04 +0000132 """Returns routable IP address of this host (the IP address of its network
133 interface that would be used for most traffic, not its localhost
134 interface). See http://stackoverflow.com/a/166589 """
135 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
136 sock.connect(('8.8.8.8', 80))
137 host = sock.getsockname()[0]
138 sock.close()
139 return host
140
rmistry@google.comd6bab022013-12-02 13:50:38 +0000141
epoger@google.com591469b2013-11-20 19:58:06 +0000142def _create_svn_checkout(dir_path, repo_url):
143 """Creates local checkout of an SVN repository at the specified directory
144 path, returning an svn.Svn object referring to the local checkout.
145
146 Args:
147 dir_path: path to the local checkout; if this directory does not yet exist,
148 it will be created and the repo will be checked out into it
149 repo_url: URL of SVN repo to check out into dir_path (unless the local
150 checkout already exists)
151 Returns: an svn.Svn object referring to the local checkout.
152 """
153 local_checkout = svn.Svn(dir_path)
154 if not os.path.isdir(dir_path):
155 os.makedirs(dir_path)
156 local_checkout.Checkout(repo_url, '.')
157 return local_checkout
158
epoger@google.comb08c7072013-10-30 14:09:04 +0000159
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000160def _create_index(file_path, config_pairs):
161 """Creates an index file linking to all results available from this server.
162
163 Prior to https://codereview.chromium.org/215503002 , we had a static
164 index.html within our repo. But now that the results may or may not include
165 config comparisons, index.html needs to be generated differently depending
166 on which results are included.
167
168 TODO(epoger): Instead of including raw HTML within the Python code,
169 consider restoring the index.html file as a template and using django (or
170 similar) to fill in dynamic content.
171
172 Args:
173 file_path: path on local disk to write index to; any directory components
174 of this path that do not already exist will be created
175 config_pairs: what pairs of configs (if any) we compare actual results of
176 """
177 dir_path = os.path.dirname(file_path)
178 if not os.path.isdir(dir_path):
179 os.makedirs(dir_path)
180 with open(file_path, 'w') as file_handle:
181 file_handle.write(
182 '<!DOCTYPE html><html>'
183 '<head><title>rebaseline_server</title></head>'
184 '<body><ul>')
185 if SUMMARY_TYPES:
186 file_handle.write('<li>Expectations vs Actuals</li><ul>')
187 for summary_type in SUMMARY_TYPES:
188 file_handle.write(
189 '<li>'
190 '<a href="/%s/view.html#/view.html?resultsToLoad=/%s/%s">'
191 '%s</a></li>' % (
192 STATIC_CONTENTS_SUBDIR, RESULTS_SUBDIR,
193 summary_type, summary_type))
194 file_handle.write('</ul>')
195 if config_pairs:
196 file_handle.write('<li>Comparing configs within actual results</li><ul>')
197 for config_pair in config_pairs:
198 file_handle.write('<li>%s vs %s:' % config_pair)
199 for summary_type in SUMMARY_TYPES:
200 file_handle.write(
201 ' <a href="/%s/view.html#/view.html?'
202 'resultsToLoad=/%s/%s/%s-vs-%s_%s.json">%s</a>' % (
203 STATIC_CONTENTS_SUBDIR, STATIC_CONTENTS_SUBDIR,
204 GENERATED_JSON_SUBDIR, config_pair[0], config_pair[1],
205 summary_type, summary_type))
206 file_handle.write('</li>')
207 file_handle.write('</ul>')
208 file_handle.write('</ul></body></html>')
209
210
epoger@google.comf9d134d2013-09-27 15:02:44 +0000211class Server(object):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000212 """ HTTP server for our HTML rebaseline viewer. """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000213
epoger@google.comf9d134d2013-09-27 15:02:44 +0000214 def __init__(self,
215 actuals_dir=DEFAULT_ACTUALS_DIR,
commit-bot@chromium.org5865ec52014-03-10 18:09:25 +0000216 actuals_repo_revision=DEFAULT_ACTUALS_REPO_REVISION,
217 actuals_repo_url=DEFAULT_ACTUALS_REPO_URL,
epoger@google.com542b65f2013-10-15 20:10:33 +0000218 port=DEFAULT_PORT, export=False, editable=True,
commit-bot@chromium.orgdefe6fd2014-04-10 15:05:24 +0000219 reload_seconds=0, config_pairs=None, builder_regex_list=None):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000220 """
221 Args:
222 actuals_dir: directory under which we will check out the latest actual
commit-bot@chromium.orgc0df2fb2014-03-28 14:28:04 +0000223 GM results
commit-bot@chromium.org5865ec52014-03-10 18:09:25 +0000224 actuals_repo_revision: revision of actual-results.json files to process
commit-bot@chromium.orgc0df2fb2014-03-28 14:28:04 +0000225 actuals_repo_url: SVN repo to download actual-results.json files from;
226 if None or '', don't fetch new actual-results files at all,
227 just compare to whatever files are already in actuals_dir
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000228 port: which TCP port to listen on for HTTP requests
229 export: whether to allow HTTP clients on other hosts to access this server
epoger@google.com542b65f2013-10-15 20:10:33 +0000230 editable: whether HTTP clients are allowed to submit new baselines
231 reload_seconds: polling interval with which to check for new results;
commit-bot@chromium.orgc0df2fb2014-03-28 14:28:04 +0000232 if 0, don't check for new results at all
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000233 config_pairs: List of (string, string) tuples; for each tuple, compare
234 actual results of these two configs. If None or empty,
235 don't compare configs at all.
commit-bot@chromium.orgdefe6fd2014-04-10 15:05:24 +0000236 builder_regex_list: List of regular expressions specifying which builders
237 we will process. If None, process all builders.
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000238 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000239 self._actuals_dir = actuals_dir
commit-bot@chromium.org5865ec52014-03-10 18:09:25 +0000240 self._actuals_repo_revision = actuals_repo_revision
241 self._actuals_repo_url = actuals_repo_url
epoger@google.comf9d134d2013-09-27 15:02:44 +0000242 self._port = port
243 self._export = export
epoger@google.com542b65f2013-10-15 20:10:33 +0000244 self._editable = editable
245 self._reload_seconds = reload_seconds
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000246 self._config_pairs = config_pairs or []
commit-bot@chromium.orgdefe6fd2014-04-10 15:05:24 +0000247 self._builder_regex_list = builder_regex_list
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000248 _create_index(
249 file_path=os.path.join(
250 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR, GENERATED_HTML_SUBDIR,
251 "index.html"),
252 config_pairs=config_pairs)
epoger55ada062014-06-03 10:35:28 -0700253 # TODO(epoger): Create shareable functions within download_actuals.py that
254 # we can use both there and here to download the actual image results.
commit-bot@chromium.orgc0df2fb2014-03-28 14:28:04 +0000255 if actuals_repo_url:
256 self._actuals_repo = _create_svn_checkout(
257 dir_path=actuals_dir, repo_url=actuals_repo_url)
epoger@google.com591469b2013-11-20 19:58:06 +0000258
rmistry@google.comd6bab022013-12-02 13:50:38 +0000259 # Reentrant lock that must be held whenever updating EITHER of:
260 # 1. self._results
261 # 2. the expected or actual results on local disk
262 self.results_rlock = threading.RLock()
263 # self._results will be filled in by calls to update_results()
264 self._results = None
epoger@google.comf9d134d2013-09-27 15:02:44 +0000265
rmistry@google.comd6bab022013-12-02 13:50:38 +0000266 @property
267 def results(self):
commit-bot@chromium.org50ad8e42013-12-17 18:06:13 +0000268 """ Returns the most recently generated results, or None if we don't have
269 any valid results (update_results() has not completed yet). """
rmistry@google.comd6bab022013-12-02 13:50:38 +0000270 return self._results
271
272 @property
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000273 def is_exported(self):
274 """ Returns true iff HTTP clients on other hosts are allowed to access
275 this server. """
276 return self._export
277
rmistry@google.comd6bab022013-12-02 13:50:38 +0000278 @property
epoger@google.com542b65f2013-10-15 20:10:33 +0000279 def is_editable(self):
280 """ Returns true iff HTTP clients are allowed to submit new baselines. """
281 return self._editable
epoger@google.comf9d134d2013-09-27 15:02:44 +0000282
rmistry@google.comd6bab022013-12-02 13:50:38 +0000283 @property
epoger@google.com542b65f2013-10-15 20:10:33 +0000284 def reload_seconds(self):
285 """ Returns the result reload period in seconds, or 0 if we don't reload
286 results. """
287 return self._reload_seconds
288
commit-bot@chromium.org50ad8e42013-12-17 18:06:13 +0000289 def update_results(self, invalidate=False):
commit-bot@chromium.org7498d952014-03-13 14:56:29 +0000290 """ Create or update self._results, based on the latest expectations and
291 actuals.
rmistry@google.comd6bab022013-12-02 13:50:38 +0000292
293 We hold self.results_rlock while we do this, to guarantee that no other
294 thread attempts to update either self._results or the underlying files at
295 the same time.
commit-bot@chromium.org50ad8e42013-12-17 18:06:13 +0000296
297 Args:
298 invalidate: if True, invalidate self._results immediately upon entry;
299 otherwise, we will let readers see those results until we
300 replace them
epoger@google.comf9d134d2013-09-27 15:02:44 +0000301 """
rmistry@google.comd6bab022013-12-02 13:50:38 +0000302 with self.results_rlock:
commit-bot@chromium.org50ad8e42013-12-17 18:06:13 +0000303 if invalidate:
304 self._results = None
commit-bot@chromium.orgc0df2fb2014-03-28 14:28:04 +0000305 if self._actuals_repo_url:
306 logging.info(
307 'Updating actual GM results in %s to revision %s from repo %s ...'
308 % (
309 self._actuals_dir, self._actuals_repo_revision,
310 self._actuals_repo_url))
311 self._actuals_repo.Update(
312 path='.', revision=self._actuals_repo_revision)
epoger@google.com591469b2013-11-20 19:58:06 +0000313
rmistry@google.comd6bab022013-12-02 13:50:38 +0000314 # We only update the expectations dir if the server was run with a
315 # nonzero --reload argument; otherwise, we expect the user to maintain
316 # her own expectations as she sees fit.
317 #
318 # Because the Skia repo is moving from SVN to git, and git does not
319 # support updating a single directory tree, we have to update the entire
320 # repo checkout.
321 #
322 # Because Skia uses depot_tools, we have to update using "gclient sync"
323 # instead of raw git (or SVN) update. Happily, this will work whether
324 # the checkout was created using git or SVN.
325 if self._reload_seconds:
326 logging.info(
327 'Updating expected GM results in %s by syncing Skia repo ...' %
commit-bot@chromium.orgb463d562014-03-21 17:54:14 +0000328 compare_to_expectations.DEFAULT_EXPECTATIONS_DIR)
rmistry@google.comd6bab022013-12-02 13:50:38 +0000329 _run_command(['gclient', 'sync'], TRUNK_DIRECTORY)
330
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000331 self._results = compare_to_expectations.ExpectationComparisons(
commit-bot@chromium.org57994232014-03-20 17:27:46 +0000332 actuals_root=self._actuals_dir,
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000333 generated_images_root=os.path.join(
334 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR,
335 GENERATED_IMAGES_SUBDIR),
336 diff_base_url=posixpath.join(
commit-bot@chromium.orgdefe6fd2014-04-10 15:05:24 +0000337 os.pardir, STATIC_CONTENTS_SUBDIR, GENERATED_IMAGES_SUBDIR),
338 builder_regex_list=self._builder_regex_list)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000339
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000340 json_dir = os.path.join(
341 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR, GENERATED_JSON_SUBDIR)
342 if not os.path.isdir(json_dir):
343 os.makedirs(json_dir)
344
345 for config_pair in self._config_pairs:
346 config_comparisons = compare_configs.ConfigComparisons(
347 configs=config_pair,
348 actuals_root=self._actuals_dir,
349 generated_images_root=os.path.join(
350 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR,
351 GENERATED_IMAGES_SUBDIR),
352 diff_base_url=posixpath.join(
commit-bot@chromium.orgdefe6fd2014-04-10 15:05:24 +0000353 os.pardir, GENERATED_IMAGES_SUBDIR),
354 builder_regex_list=self._builder_regex_list)
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000355 for summary_type in SUMMARY_TYPES:
356 gm_json.WriteToFile(
357 config_comparisons.get_packaged_results_of_type(
358 results_type=summary_type),
359 os.path.join(
360 json_dir, '%s-vs-%s_%s.json' % (
361 config_pair[0], config_pair[1], summary_type)))
362
epoger@google.com2682c902013-12-05 16:05:16 +0000363 def _result_loader(self, reload_seconds=0):
364 """ Call self.update_results(), either once or periodically.
365
366 Params:
367 reload_seconds: integer; if nonzero, reload results at this interval
368 (in which case, this method will never return!)
epoger@google.com542b65f2013-10-15 20:10:33 +0000369 """
epoger@google.com2682c902013-12-05 16:05:16 +0000370 self.update_results()
371 logging.info('Initial results loaded. Ready for requests on %s' % self._url)
372 if reload_seconds:
373 while True:
374 time.sleep(reload_seconds)
375 self.update_results()
epoger@google.com542b65f2013-10-15 20:10:33 +0000376
epoger@google.comf9d134d2013-09-27 15:02:44 +0000377 def run(self):
epoger@google.com2682c902013-12-05 16:05:16 +0000378 arg_tuple = (self._reload_seconds,) # start_new_thread needs a tuple,
379 # even though it holds just one param
380 thread.start_new_thread(self._result_loader, arg_tuple)
epoger@google.com542b65f2013-10-15 20:10:33 +0000381
epoger@google.comf9d134d2013-09-27 15:02:44 +0000382 if self._export:
383 server_address = ('', self._port)
epoger@google.com591469b2013-11-20 19:58:06 +0000384 host = _get_routable_ip_address()
epoger@google.com542b65f2013-10-15 20:10:33 +0000385 if self._editable:
386 logging.warning('Running with combination of "export" and "editable" '
387 'flags. Users on other machines will '
388 'be able to modify your GM expectations!')
epoger@google.comf9d134d2013-09-27 15:02:44 +0000389 else:
epoger@google.comb08c7072013-10-30 14:09:04 +0000390 host = '127.0.0.1'
391 server_address = (host, self._port)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000392 http_server = BaseHTTPServer.HTTPServer(server_address, HTTPRequestHandler)
epoger@google.com2682c902013-12-05 16:05:16 +0000393 self._url = 'http://%s:%d' % (host, http_server.server_port)
394 logging.info('Listening for requests on %s' % self._url)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000395 http_server.serve_forever()
396
397
398class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
399 """ HTTP request handlers for various types of queries this server knows
400 how to handle (static HTML and Javascript, expected/actual results, etc.)
401 """
402 def do_GET(self):
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000403 """
404 Handles all GET requests, forwarding them to the appropriate
405 do_GET_* dispatcher.
epoger@google.comf9d134d2013-09-27 15:02:44 +0000406
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000407 If we see any Exceptions, return a 404. This fixes http://skbug.com/2147
408 """
409 try:
410 logging.debug('do_GET: path="%s"' % self.path)
411 if self.path == '' or self.path == '/' or self.path == '/index.html' :
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000412 self.redirect_to('/%s/%s/index.html' % (
413 STATIC_CONTENTS_SUBDIR, GENERATED_HTML_SUBDIR))
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000414 return
415 if self.path == '/favicon.ico' :
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000416 self.redirect_to('/%s/favicon.ico' % STATIC_CONTENTS_SUBDIR)
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000417 return
418
419 # All requests must be of this form:
420 # /dispatcher/remainder
421 # where 'dispatcher' indicates which do_GET_* dispatcher to run
422 # and 'remainder' is the remaining path sent to that dispatcher.
423 normpath = posixpath.normpath(self.path)
424 (dispatcher_name, remainder) = PATHSPLIT_RE.match(normpath).groups()
425 dispatchers = {
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000426 RESULTS_SUBDIR: self.do_GET_results,
427 STATIC_CONTENTS_SUBDIR: self.do_GET_static,
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000428 }
429 dispatcher = dispatchers[dispatcher_name]
430 dispatcher(remainder)
431 except:
432 self.send_error(404)
433 raise
epoger@google.comf9d134d2013-09-27 15:02:44 +0000434
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000435 def do_GET_results(self, results_type):
436 """ Handle a GET request for GM results.
437
438 Args:
439 results_type: string indicating which set of results to return;
440 must be one of the results_mod.RESULTS_* constants
441 """
442 logging.debug('do_GET_results: sending results of type "%s"' % results_type)
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000443 # Since we must make multiple calls to the ExpectationComparisons object,
444 # grab a reference to it in case it is updated to point at a new
445 # ExpectationComparisons object within another thread.
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000446 #
447 # TODO(epoger): Rather than using a global variable for the handler
448 # to refer to the Server object, make Server a subclass of
449 # HTTPServer, and then it could be available to the handler via
450 # the handler's .server instance variable.
451 results_obj = _SERVER.results
452 if results_obj:
453 response_dict = results_obj.get_packaged_results_of_type(
454 results_type=results_type, reload_seconds=_SERVER.reload_seconds,
455 is_editable=_SERVER.is_editable, is_exported=_SERVER.is_exported)
456 else:
457 now = int(time.time())
458 response_dict = {
commit-bot@chromium.org68a38152014-05-12 20:40:29 +0000459 imagepairset.KEY__ROOT__HEADER: {
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000460 results_mod.KEY__HEADER__SCHEMA_VERSION: (
commit-bot@chromium.org68a38152014-05-12 20:40:29 +0000461 results_mod.VALUE__HEADER__SCHEMA_VERSION),
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000462 results_mod.KEY__HEADER__IS_STILL_LOADING: True,
463 results_mod.KEY__HEADER__TIME_UPDATED: now,
464 results_mod.KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE: (
465 now + RELOAD_INTERVAL_UNTIL_READY),
466 },
467 }
468 self.send_json_dict(response_dict)
469
epoger@google.comf9d134d2013-09-27 15:02:44 +0000470 def do_GET_static(self, path):
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000471 """ Handle a GET request for a file under STATIC_CONTENTS_SUBDIR .
472 Only allow serving of files within STATIC_CONTENTS_SUBDIR that is a
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000473 filesystem sibling of this script.
474
475 Args:
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000476 path: path to file (within STATIC_CONTENTS_SUBDIR) to retrieve
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000477 """
epoger@google.comdcb4e652013-10-11 18:45:33 +0000478 # Strip arguments ('?resultsToLoad=all') from the path
479 path = urlparse.urlparse(path).path
480
481 logging.debug('do_GET_static: sending file "%s"' % path)
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000482 static_dir = os.path.realpath(os.path.join(
483 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR))
484 full_path = os.path.realpath(os.path.join(static_dir, path))
485 if full_path.startswith(static_dir):
epoger@google.comcb55f112013-10-02 19:27:35 +0000486 self.send_file(full_path)
487 else:
epoger@google.comdcb4e652013-10-11 18:45:33 +0000488 logging.error(
489 'Attempted do_GET_static() of path [%s] outside of static dir [%s]'
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000490 % (full_path, static_dir))
epoger@google.comcb55f112013-10-02 19:27:35 +0000491 self.send_error(404)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000492
epoger@google.comeb832592013-10-23 15:07:26 +0000493 def do_POST(self):
494 """ Handles all POST requests, forwarding them to the appropriate
495 do_POST_* dispatcher. """
496 # All requests must be of this form:
497 # /dispatcher
498 # where 'dispatcher' indicates which do_POST_* dispatcher to run.
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000499 logging.debug('do_POST: path="%s"' % self.path)
epoger@google.comeb832592013-10-23 15:07:26 +0000500 normpath = posixpath.normpath(self.path)
501 dispatchers = {
502 '/edits': self.do_POST_edits,
503 }
504 try:
505 dispatcher = dispatchers[normpath]
506 dispatcher()
507 self.send_response(200)
508 except:
509 self.send_error(404)
510 raise
511
512 def do_POST_edits(self):
513 """ Handle a POST request with modifications to GM expectations, in this
514 format:
515
516 {
commit-bot@chromium.org16f41802014-02-26 19:05:20 +0000517 KEY__EDITS__OLD_RESULTS_TYPE: 'all', # type of results that the client
518 # loaded and then made
519 # modifications to
520 KEY__EDITS__OLD_RESULTS_HASH: 39850913, # hash of results when the client
521 # loaded them (ensures that the
522 # client and server apply
523 # modifications to the same base)
524 KEY__EDITS__MODIFICATIONS: [
commit-bot@chromium.orgb463d562014-03-21 17:54:14 +0000525 # as needed by compare_to_expectations.edit_expectations()
epoger@google.comeb832592013-10-23 15:07:26 +0000526 ...
527 ],
528 }
529
530 Raises an Exception if there were any problems.
531 """
rmistry@google.comd6bab022013-12-02 13:50:38 +0000532 if not _SERVER.is_editable:
epoger@google.comeb832592013-10-23 15:07:26 +0000533 raise Exception('this server is not running in --editable mode')
534
535 content_type = self.headers[_HTTP_HEADER_CONTENT_TYPE]
536 if content_type != 'application/json;charset=UTF-8':
537 raise Exception('unsupported %s [%s]' % (
538 _HTTP_HEADER_CONTENT_TYPE, content_type))
539
540 content_length = int(self.headers[_HTTP_HEADER_CONTENT_LENGTH])
541 json_data = self.rfile.read(content_length)
542 data = json.loads(json_data)
543 logging.debug('do_POST_edits: received new GM expectations data [%s]' %
544 data)
545
rmistry@google.comd6bab022013-12-02 13:50:38 +0000546 # Update the results on disk with the information we received from the
547 # client.
548 # We must hold _SERVER.results_rlock while we do this, to guarantee that
549 # no other thread updates expectations (from the Skia repo) while we are
550 # updating them (using the info we received from the client).
551 with _SERVER.results_rlock:
commit-bot@chromium.org16f41802014-02-26 19:05:20 +0000552 oldResultsType = data[KEY__EDITS__OLD_RESULTS_TYPE]
rmistry@google.comd6bab022013-12-02 13:50:38 +0000553 oldResults = _SERVER.results.get_results_of_type(oldResultsType)
commit-bot@chromium.org68a38152014-05-12 20:40:29 +0000554 oldResultsHash = str(hash(repr(
555 oldResults[imagepairset.KEY__ROOT__IMAGEPAIRS])))
commit-bot@chromium.org16f41802014-02-26 19:05:20 +0000556 if oldResultsHash != data[KEY__EDITS__OLD_RESULTS_HASH]:
rmistry@google.comd6bab022013-12-02 13:50:38 +0000557 raise Exception('results of type "%s" changed while the client was '
558 'making modifications. The client should reload the '
559 'results and submit the modifications again.' %
560 oldResultsType)
commit-bot@chromium.org16f41802014-02-26 19:05:20 +0000561 _SERVER.results.edit_expectations(data[KEY__EDITS__MODIFICATIONS])
commit-bot@chromium.org50ad8e42013-12-17 18:06:13 +0000562
563 # Read the updated results back from disk.
564 # We can do this in a separate thread; we should return our success message
565 # to the UI as soon as possible.
566 thread.start_new_thread(_SERVER.update_results, (True,))
epoger@google.comeb832592013-10-23 15:07:26 +0000567
epoger@google.comf9d134d2013-09-27 15:02:44 +0000568 def redirect_to(self, url):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000569 """ Redirect the HTTP client to a different url.
570
571 Args:
572 url: URL to redirect the HTTP client to
573 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000574 self.send_response(301)
575 self.send_header('Location', url)
576 self.end_headers()
577
578 def send_file(self, path):
579 """ Send the contents of the file at this path, with a mimetype based
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000580 on the filename extension.
581
582 Args:
583 path: path of file whose contents to send to the HTTP client
584 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000585 # Grab the extension if there is one
586 extension = os.path.splitext(path)[1]
587 if len(extension) >= 1:
588 extension = extension[1:]
589
590 # Determine the MIME type of the file from its extension
591 mime_type = MIME_TYPE_MAP.get(extension, MIME_TYPE_MAP[''])
592
593 # Open the file and send it over HTTP
594 if os.path.isfile(path):
595 with open(path, 'rb') as sending_file:
596 self.send_response(200)
597 self.send_header('Content-type', mime_type)
598 self.end_headers()
599 self.wfile.write(sending_file.read())
600 else:
601 self.send_error(404)
602
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000603 def send_json_dict(self, json_dict):
604 """ Send the contents of this dictionary in JSON format, with a JSON
605 mimetype.
606
607 Args:
608 json_dict: dictionary to send
609 """
610 self.send_response(200)
611 self.send_header('Content-type', 'application/json')
612 self.end_headers()
613 json.dump(json_dict, self.wfile)
614
epoger@google.comf9d134d2013-09-27 15:02:44 +0000615
616def main():
commit-bot@chromium.orga6ecbb82013-12-19 19:08:31 +0000617 logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s',
618 datefmt='%m/%d/%Y %H:%M:%S',
619 level=logging.INFO)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000620 parser = argparse.ArgumentParser()
621 parser.add_argument('--actuals-dir',
622 help=('Directory into which we will check out the latest '
623 'actual GM results. If this directory does not '
624 'exist, it will be created. Defaults to %(default)s'),
625 default=DEFAULT_ACTUALS_DIR)
commit-bot@chromium.org5865ec52014-03-10 18:09:25 +0000626 parser.add_argument('--actuals-repo',
627 help=('URL of SVN repo to download actual-results.json '
commit-bot@chromium.orgc0df2fb2014-03-28 14:28:04 +0000628 'files from. Defaults to %(default)s ; if set to '
629 'empty string, just compare to actual-results '
630 'already found in ACTUALS_DIR.'),
commit-bot@chromium.org5865ec52014-03-10 18:09:25 +0000631 default=DEFAULT_ACTUALS_REPO_URL)
632 parser.add_argument('--actuals-revision',
633 help=('revision of actual-results.json files to process. '
634 'Defaults to %(default)s . Beware of setting this '
635 'argument in conjunction with --editable; you '
636 'probably only want to edit results at HEAD.'),
637 default=DEFAULT_ACTUALS_REPO_REVISION)
commit-bot@chromium.orgdefe6fd2014-04-10 15:05:24 +0000638 parser.add_argument('--builders', metavar='BUILDER_REGEX', nargs='+',
639 help=('Only process builders matching these regular '
640 'expressions. If unspecified, process all '
641 'builders.'))
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000642 parser.add_argument('--compare-configs', action='store_true',
643 help=('In addition to generating differences between '
644 'expectations and actuals, also generate '
645 'differences between these config pairs: '
646 + str(CONFIG_PAIRS_TO_COMPARE)))
epoger@google.com542b65f2013-10-15 20:10:33 +0000647 parser.add_argument('--editable', action='store_true',
epoger@google.comeb832592013-10-23 15:07:26 +0000648 help=('Allow HTTP clients to submit new baselines.'))
epoger@google.comf9d134d2013-09-27 15:02:44 +0000649 parser.add_argument('--export', action='store_true',
650 help=('Instead of only allowing access from HTTP clients '
651 'on localhost, allow HTTP clients on other hosts '
652 'to access this server. WARNING: doing so will '
653 'allow users on other hosts to modify your '
epoger@google.com542b65f2013-10-15 20:10:33 +0000654 'GM expectations, if combined with --editable.'))
epoger@google.comafaad3d2013-09-30 15:06:25 +0000655 parser.add_argument('--port', type=int,
656 help=('Which TCP port to listen on for HTTP requests; '
657 'defaults to %(default)s'),
658 default=DEFAULT_PORT)
epoger@google.com542b65f2013-10-15 20:10:33 +0000659 parser.add_argument('--reload', type=int,
660 help=('How often (a period in seconds) to update the '
epoger@google.comb063e132013-11-25 18:06:29 +0000661 'results. If specified, both expected and actual '
rmistry@google.comd6bab022013-12-02 13:50:38 +0000662 'results will be updated by running "gclient sync" '
663 'on your Skia checkout as a whole. '
epoger@google.com542b65f2013-10-15 20:10:33 +0000664 'By default, we do not reload at all, and you '
665 'must restart the server to pick up new data.'),
666 default=0)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000667 args = parser.parse_args()
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000668 if args.compare_configs:
669 config_pairs = CONFIG_PAIRS_TO_COMPARE
670 else:
671 config_pairs = None
672
epoger@google.comf9d134d2013-09-27 15:02:44 +0000673 global _SERVER
epoger@google.com542b65f2013-10-15 20:10:33 +0000674 _SERVER = Server(actuals_dir=args.actuals_dir,
commit-bot@chromium.org5865ec52014-03-10 18:09:25 +0000675 actuals_repo_revision=args.actuals_revision,
676 actuals_repo_url=args.actuals_repo,
epoger@google.com542b65f2013-10-15 20:10:33 +0000677 port=args.port, export=args.export, editable=args.editable,
commit-bot@chromium.orgdefe6fd2014-04-10 15:05:24 +0000678 reload_seconds=args.reload, config_pairs=config_pairs,
679 builder_regex_list=args.builders)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000680 _SERVER.run()
681
rmistry@google.comd6bab022013-12-02 13:50:38 +0000682
epoger@google.comf9d134d2013-09-27 15:02:44 +0000683if __name__ == '__main__':
684 main()