blob: 73cfbef21e1cc8f1545fadbcc85037b497c40ddf [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)
commit-bot@chromium.orgc0df2fb2014-03-28 14:28:04 +0000253 if actuals_repo_url:
254 self._actuals_repo = _create_svn_checkout(
255 dir_path=actuals_dir, repo_url=actuals_repo_url)
epoger@google.com591469b2013-11-20 19:58:06 +0000256
rmistry@google.comd6bab022013-12-02 13:50:38 +0000257 # Reentrant lock that must be held whenever updating EITHER of:
258 # 1. self._results
259 # 2. the expected or actual results on local disk
260 self.results_rlock = threading.RLock()
261 # self._results will be filled in by calls to update_results()
262 self._results = None
epoger@google.comf9d134d2013-09-27 15:02:44 +0000263
rmistry@google.comd6bab022013-12-02 13:50:38 +0000264 @property
265 def results(self):
commit-bot@chromium.org50ad8e42013-12-17 18:06:13 +0000266 """ Returns the most recently generated results, or None if we don't have
267 any valid results (update_results() has not completed yet). """
rmistry@google.comd6bab022013-12-02 13:50:38 +0000268 return self._results
269
270 @property
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000271 def is_exported(self):
272 """ Returns true iff HTTP clients on other hosts are allowed to access
273 this server. """
274 return self._export
275
rmistry@google.comd6bab022013-12-02 13:50:38 +0000276 @property
epoger@google.com542b65f2013-10-15 20:10:33 +0000277 def is_editable(self):
278 """ Returns true iff HTTP clients are allowed to submit new baselines. """
279 return self._editable
epoger@google.comf9d134d2013-09-27 15:02:44 +0000280
rmistry@google.comd6bab022013-12-02 13:50:38 +0000281 @property
epoger@google.com542b65f2013-10-15 20:10:33 +0000282 def reload_seconds(self):
283 """ Returns the result reload period in seconds, or 0 if we don't reload
284 results. """
285 return self._reload_seconds
286
commit-bot@chromium.org50ad8e42013-12-17 18:06:13 +0000287 def update_results(self, invalidate=False):
commit-bot@chromium.org7498d952014-03-13 14:56:29 +0000288 """ Create or update self._results, based on the latest expectations and
289 actuals.
rmistry@google.comd6bab022013-12-02 13:50:38 +0000290
291 We hold self.results_rlock while we do this, to guarantee that no other
292 thread attempts to update either self._results or the underlying files at
293 the same time.
commit-bot@chromium.org50ad8e42013-12-17 18:06:13 +0000294
295 Args:
296 invalidate: if True, invalidate self._results immediately upon entry;
297 otherwise, we will let readers see those results until we
298 replace them
epoger@google.comf9d134d2013-09-27 15:02:44 +0000299 """
rmistry@google.comd6bab022013-12-02 13:50:38 +0000300 with self.results_rlock:
commit-bot@chromium.org50ad8e42013-12-17 18:06:13 +0000301 if invalidate:
302 self._results = None
commit-bot@chromium.orgc0df2fb2014-03-28 14:28:04 +0000303 if self._actuals_repo_url:
304 logging.info(
305 'Updating actual GM results in %s to revision %s from repo %s ...'
306 % (
307 self._actuals_dir, self._actuals_repo_revision,
308 self._actuals_repo_url))
309 self._actuals_repo.Update(
310 path='.', revision=self._actuals_repo_revision)
epoger@google.com591469b2013-11-20 19:58:06 +0000311
rmistry@google.comd6bab022013-12-02 13:50:38 +0000312 # We only update the expectations dir if the server was run with a
313 # nonzero --reload argument; otherwise, we expect the user to maintain
314 # her own expectations as she sees fit.
315 #
316 # Because the Skia repo is moving from SVN to git, and git does not
317 # support updating a single directory tree, we have to update the entire
318 # repo checkout.
319 #
320 # Because Skia uses depot_tools, we have to update using "gclient sync"
321 # instead of raw git (or SVN) update. Happily, this will work whether
322 # the checkout was created using git or SVN.
323 if self._reload_seconds:
324 logging.info(
325 'Updating expected GM results in %s by syncing Skia repo ...' %
commit-bot@chromium.orgb463d562014-03-21 17:54:14 +0000326 compare_to_expectations.DEFAULT_EXPECTATIONS_DIR)
rmistry@google.comd6bab022013-12-02 13:50:38 +0000327 _run_command(['gclient', 'sync'], TRUNK_DIRECTORY)
328
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000329 self._results = compare_to_expectations.ExpectationComparisons(
commit-bot@chromium.org57994232014-03-20 17:27:46 +0000330 actuals_root=self._actuals_dir,
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000331 generated_images_root=os.path.join(
332 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR,
333 GENERATED_IMAGES_SUBDIR),
334 diff_base_url=posixpath.join(
commit-bot@chromium.orgdefe6fd2014-04-10 15:05:24 +0000335 os.pardir, STATIC_CONTENTS_SUBDIR, GENERATED_IMAGES_SUBDIR),
336 builder_regex_list=self._builder_regex_list)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000337
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000338 json_dir = os.path.join(
339 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR, GENERATED_JSON_SUBDIR)
340 if not os.path.isdir(json_dir):
341 os.makedirs(json_dir)
342
343 for config_pair in self._config_pairs:
344 config_comparisons = compare_configs.ConfigComparisons(
345 configs=config_pair,
346 actuals_root=self._actuals_dir,
347 generated_images_root=os.path.join(
348 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR,
349 GENERATED_IMAGES_SUBDIR),
350 diff_base_url=posixpath.join(
commit-bot@chromium.orgdefe6fd2014-04-10 15:05:24 +0000351 os.pardir, GENERATED_IMAGES_SUBDIR),
352 builder_regex_list=self._builder_regex_list)
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000353 for summary_type in SUMMARY_TYPES:
354 gm_json.WriteToFile(
355 config_comparisons.get_packaged_results_of_type(
356 results_type=summary_type),
357 os.path.join(
358 json_dir, '%s-vs-%s_%s.json' % (
359 config_pair[0], config_pair[1], summary_type)))
360
epoger@google.com2682c902013-12-05 16:05:16 +0000361 def _result_loader(self, reload_seconds=0):
362 """ Call self.update_results(), either once or periodically.
363
364 Params:
365 reload_seconds: integer; if nonzero, reload results at this interval
366 (in which case, this method will never return!)
epoger@google.com542b65f2013-10-15 20:10:33 +0000367 """
epoger@google.com2682c902013-12-05 16:05:16 +0000368 self.update_results()
369 logging.info('Initial results loaded. Ready for requests on %s' % self._url)
370 if reload_seconds:
371 while True:
372 time.sleep(reload_seconds)
373 self.update_results()
epoger@google.com542b65f2013-10-15 20:10:33 +0000374
epoger@google.comf9d134d2013-09-27 15:02:44 +0000375 def run(self):
epoger@google.com2682c902013-12-05 16:05:16 +0000376 arg_tuple = (self._reload_seconds,) # start_new_thread needs a tuple,
377 # even though it holds just one param
378 thread.start_new_thread(self._result_loader, arg_tuple)
epoger@google.com542b65f2013-10-15 20:10:33 +0000379
epoger@google.comf9d134d2013-09-27 15:02:44 +0000380 if self._export:
381 server_address = ('', self._port)
epoger@google.com591469b2013-11-20 19:58:06 +0000382 host = _get_routable_ip_address()
epoger@google.com542b65f2013-10-15 20:10:33 +0000383 if self._editable:
384 logging.warning('Running with combination of "export" and "editable" '
385 'flags. Users on other machines will '
386 'be able to modify your GM expectations!')
epoger@google.comf9d134d2013-09-27 15:02:44 +0000387 else:
epoger@google.comb08c7072013-10-30 14:09:04 +0000388 host = '127.0.0.1'
389 server_address = (host, self._port)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000390 http_server = BaseHTTPServer.HTTPServer(server_address, HTTPRequestHandler)
epoger@google.com2682c902013-12-05 16:05:16 +0000391 self._url = 'http://%s:%d' % (host, http_server.server_port)
392 logging.info('Listening for requests on %s' % self._url)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000393 http_server.serve_forever()
394
395
396class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
397 """ HTTP request handlers for various types of queries this server knows
398 how to handle (static HTML and Javascript, expected/actual results, etc.)
399 """
400 def do_GET(self):
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000401 """
402 Handles all GET requests, forwarding them to the appropriate
403 do_GET_* dispatcher.
epoger@google.comf9d134d2013-09-27 15:02:44 +0000404
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000405 If we see any Exceptions, return a 404. This fixes http://skbug.com/2147
406 """
407 try:
408 logging.debug('do_GET: path="%s"' % self.path)
409 if self.path == '' or self.path == '/' or self.path == '/index.html' :
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000410 self.redirect_to('/%s/%s/index.html' % (
411 STATIC_CONTENTS_SUBDIR, GENERATED_HTML_SUBDIR))
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000412 return
413 if self.path == '/favicon.ico' :
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000414 self.redirect_to('/%s/favicon.ico' % STATIC_CONTENTS_SUBDIR)
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000415 return
416
417 # All requests must be of this form:
418 # /dispatcher/remainder
419 # where 'dispatcher' indicates which do_GET_* dispatcher to run
420 # and 'remainder' is the remaining path sent to that dispatcher.
421 normpath = posixpath.normpath(self.path)
422 (dispatcher_name, remainder) = PATHSPLIT_RE.match(normpath).groups()
423 dispatchers = {
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000424 RESULTS_SUBDIR: self.do_GET_results,
425 STATIC_CONTENTS_SUBDIR: self.do_GET_static,
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000426 }
427 dispatcher = dispatchers[dispatcher_name]
428 dispatcher(remainder)
429 except:
430 self.send_error(404)
431 raise
epoger@google.comf9d134d2013-09-27 15:02:44 +0000432
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000433 def do_GET_results(self, results_type):
434 """ Handle a GET request for GM results.
435
436 Args:
437 results_type: string indicating which set of results to return;
438 must be one of the results_mod.RESULTS_* constants
439 """
440 logging.debug('do_GET_results: sending results of type "%s"' % results_type)
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000441 # Since we must make multiple calls to the ExpectationComparisons object,
442 # grab a reference to it in case it is updated to point at a new
443 # ExpectationComparisons object within another thread.
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000444 #
445 # TODO(epoger): Rather than using a global variable for the handler
446 # to refer to the Server object, make Server a subclass of
447 # HTTPServer, and then it could be available to the handler via
448 # the handler's .server instance variable.
449 results_obj = _SERVER.results
450 if results_obj:
451 response_dict = results_obj.get_packaged_results_of_type(
452 results_type=results_type, reload_seconds=_SERVER.reload_seconds,
453 is_editable=_SERVER.is_editable, is_exported=_SERVER.is_exported)
454 else:
455 now = int(time.time())
456 response_dict = {
457 results_mod.KEY__HEADER: {
458 results_mod.KEY__HEADER__SCHEMA_VERSION: (
459 results_mod.REBASELINE_SERVER_SCHEMA_VERSION_NUMBER),
460 results_mod.KEY__HEADER__IS_STILL_LOADING: True,
461 results_mod.KEY__HEADER__TIME_UPDATED: now,
462 results_mod.KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE: (
463 now + RELOAD_INTERVAL_UNTIL_READY),
464 },
465 }
466 self.send_json_dict(response_dict)
467
epoger@google.comf9d134d2013-09-27 15:02:44 +0000468 def do_GET_static(self, path):
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000469 """ Handle a GET request for a file under STATIC_CONTENTS_SUBDIR .
470 Only allow serving of files within STATIC_CONTENTS_SUBDIR that is a
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000471 filesystem sibling of this script.
472
473 Args:
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000474 path: path to file (within STATIC_CONTENTS_SUBDIR) to retrieve
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000475 """
epoger@google.comdcb4e652013-10-11 18:45:33 +0000476 # Strip arguments ('?resultsToLoad=all') from the path
477 path = urlparse.urlparse(path).path
478
479 logging.debug('do_GET_static: sending file "%s"' % path)
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000480 static_dir = os.path.realpath(os.path.join(
481 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR))
482 full_path = os.path.realpath(os.path.join(static_dir, path))
483 if full_path.startswith(static_dir):
epoger@google.comcb55f112013-10-02 19:27:35 +0000484 self.send_file(full_path)
485 else:
epoger@google.comdcb4e652013-10-11 18:45:33 +0000486 logging.error(
487 'Attempted do_GET_static() of path [%s] outside of static dir [%s]'
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000488 % (full_path, static_dir))
epoger@google.comcb55f112013-10-02 19:27:35 +0000489 self.send_error(404)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000490
epoger@google.comeb832592013-10-23 15:07:26 +0000491 def do_POST(self):
492 """ Handles all POST requests, forwarding them to the appropriate
493 do_POST_* dispatcher. """
494 # All requests must be of this form:
495 # /dispatcher
496 # where 'dispatcher' indicates which do_POST_* dispatcher to run.
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000497 logging.debug('do_POST: path="%s"' % self.path)
epoger@google.comeb832592013-10-23 15:07:26 +0000498 normpath = posixpath.normpath(self.path)
499 dispatchers = {
500 '/edits': self.do_POST_edits,
501 }
502 try:
503 dispatcher = dispatchers[normpath]
504 dispatcher()
505 self.send_response(200)
506 except:
507 self.send_error(404)
508 raise
509
510 def do_POST_edits(self):
511 """ Handle a POST request with modifications to GM expectations, in this
512 format:
513
514 {
commit-bot@chromium.org16f41802014-02-26 19:05:20 +0000515 KEY__EDITS__OLD_RESULTS_TYPE: 'all', # type of results that the client
516 # loaded and then made
517 # modifications to
518 KEY__EDITS__OLD_RESULTS_HASH: 39850913, # hash of results when the client
519 # loaded them (ensures that the
520 # client and server apply
521 # modifications to the same base)
522 KEY__EDITS__MODIFICATIONS: [
commit-bot@chromium.orgb463d562014-03-21 17:54:14 +0000523 # as needed by compare_to_expectations.edit_expectations()
epoger@google.comeb832592013-10-23 15:07:26 +0000524 ...
525 ],
526 }
527
528 Raises an Exception if there were any problems.
529 """
rmistry@google.comd6bab022013-12-02 13:50:38 +0000530 if not _SERVER.is_editable:
epoger@google.comeb832592013-10-23 15:07:26 +0000531 raise Exception('this server is not running in --editable mode')
532
533 content_type = self.headers[_HTTP_HEADER_CONTENT_TYPE]
534 if content_type != 'application/json;charset=UTF-8':
535 raise Exception('unsupported %s [%s]' % (
536 _HTTP_HEADER_CONTENT_TYPE, content_type))
537
538 content_length = int(self.headers[_HTTP_HEADER_CONTENT_LENGTH])
539 json_data = self.rfile.read(content_length)
540 data = json.loads(json_data)
541 logging.debug('do_POST_edits: received new GM expectations data [%s]' %
542 data)
543
rmistry@google.comd6bab022013-12-02 13:50:38 +0000544 # Update the results on disk with the information we received from the
545 # client.
546 # We must hold _SERVER.results_rlock while we do this, to guarantee that
547 # no other thread updates expectations (from the Skia repo) while we are
548 # updating them (using the info we received from the client).
549 with _SERVER.results_rlock:
commit-bot@chromium.org16f41802014-02-26 19:05:20 +0000550 oldResultsType = data[KEY__EDITS__OLD_RESULTS_TYPE]
rmistry@google.comd6bab022013-12-02 13:50:38 +0000551 oldResults = _SERVER.results.get_results_of_type(oldResultsType)
commit-bot@chromium.org16f41802014-02-26 19:05:20 +0000552 oldResultsHash = str(hash(repr(oldResults[imagepairset.KEY__IMAGEPAIRS])))
553 if oldResultsHash != data[KEY__EDITS__OLD_RESULTS_HASH]:
rmistry@google.comd6bab022013-12-02 13:50:38 +0000554 raise Exception('results of type "%s" changed while the client was '
555 'making modifications. The client should reload the '
556 'results and submit the modifications again.' %
557 oldResultsType)
commit-bot@chromium.org16f41802014-02-26 19:05:20 +0000558 _SERVER.results.edit_expectations(data[KEY__EDITS__MODIFICATIONS])
commit-bot@chromium.org50ad8e42013-12-17 18:06:13 +0000559
560 # Read the updated results back from disk.
561 # We can do this in a separate thread; we should return our success message
562 # to the UI as soon as possible.
563 thread.start_new_thread(_SERVER.update_results, (True,))
epoger@google.comeb832592013-10-23 15:07:26 +0000564
epoger@google.comf9d134d2013-09-27 15:02:44 +0000565 def redirect_to(self, url):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000566 """ Redirect the HTTP client to a different url.
567
568 Args:
569 url: URL to redirect the HTTP client to
570 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000571 self.send_response(301)
572 self.send_header('Location', url)
573 self.end_headers()
574
575 def send_file(self, path):
576 """ Send the contents of the file at this path, with a mimetype based
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000577 on the filename extension.
578
579 Args:
580 path: path of file whose contents to send to the HTTP client
581 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000582 # Grab the extension if there is one
583 extension = os.path.splitext(path)[1]
584 if len(extension) >= 1:
585 extension = extension[1:]
586
587 # Determine the MIME type of the file from its extension
588 mime_type = MIME_TYPE_MAP.get(extension, MIME_TYPE_MAP[''])
589
590 # Open the file and send it over HTTP
591 if os.path.isfile(path):
592 with open(path, 'rb') as sending_file:
593 self.send_response(200)
594 self.send_header('Content-type', mime_type)
595 self.end_headers()
596 self.wfile.write(sending_file.read())
597 else:
598 self.send_error(404)
599
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000600 def send_json_dict(self, json_dict):
601 """ Send the contents of this dictionary in JSON format, with a JSON
602 mimetype.
603
604 Args:
605 json_dict: dictionary to send
606 """
607 self.send_response(200)
608 self.send_header('Content-type', 'application/json')
609 self.end_headers()
610 json.dump(json_dict, self.wfile)
611
epoger@google.comf9d134d2013-09-27 15:02:44 +0000612
613def main():
commit-bot@chromium.orga6ecbb82013-12-19 19:08:31 +0000614 logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s',
615 datefmt='%m/%d/%Y %H:%M:%S',
616 level=logging.INFO)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000617 parser = argparse.ArgumentParser()
618 parser.add_argument('--actuals-dir',
619 help=('Directory into which we will check out the latest '
620 'actual GM results. If this directory does not '
621 'exist, it will be created. Defaults to %(default)s'),
622 default=DEFAULT_ACTUALS_DIR)
commit-bot@chromium.org5865ec52014-03-10 18:09:25 +0000623 parser.add_argument('--actuals-repo',
624 help=('URL of SVN repo to download actual-results.json '
commit-bot@chromium.orgc0df2fb2014-03-28 14:28:04 +0000625 'files from. Defaults to %(default)s ; if set to '
626 'empty string, just compare to actual-results '
627 'already found in ACTUALS_DIR.'),
commit-bot@chromium.org5865ec52014-03-10 18:09:25 +0000628 default=DEFAULT_ACTUALS_REPO_URL)
629 parser.add_argument('--actuals-revision',
630 help=('revision of actual-results.json files to process. '
631 'Defaults to %(default)s . Beware of setting this '
632 'argument in conjunction with --editable; you '
633 'probably only want to edit results at HEAD.'),
634 default=DEFAULT_ACTUALS_REPO_REVISION)
commit-bot@chromium.orgdefe6fd2014-04-10 15:05:24 +0000635 parser.add_argument('--builders', metavar='BUILDER_REGEX', nargs='+',
636 help=('Only process builders matching these regular '
637 'expressions. If unspecified, process all '
638 'builders.'))
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000639 parser.add_argument('--compare-configs', action='store_true',
640 help=('In addition to generating differences between '
641 'expectations and actuals, also generate '
642 'differences between these config pairs: '
643 + str(CONFIG_PAIRS_TO_COMPARE)))
epoger@google.com542b65f2013-10-15 20:10:33 +0000644 parser.add_argument('--editable', action='store_true',
epoger@google.comeb832592013-10-23 15:07:26 +0000645 help=('Allow HTTP clients to submit new baselines.'))
epoger@google.comf9d134d2013-09-27 15:02:44 +0000646 parser.add_argument('--export', action='store_true',
647 help=('Instead of only allowing access from HTTP clients '
648 'on localhost, allow HTTP clients on other hosts '
649 'to access this server. WARNING: doing so will '
650 'allow users on other hosts to modify your '
epoger@google.com542b65f2013-10-15 20:10:33 +0000651 'GM expectations, if combined with --editable.'))
epoger@google.comafaad3d2013-09-30 15:06:25 +0000652 parser.add_argument('--port', type=int,
653 help=('Which TCP port to listen on for HTTP requests; '
654 'defaults to %(default)s'),
655 default=DEFAULT_PORT)
epoger@google.com542b65f2013-10-15 20:10:33 +0000656 parser.add_argument('--reload', type=int,
657 help=('How often (a period in seconds) to update the '
epoger@google.comb063e132013-11-25 18:06:29 +0000658 'results. If specified, both expected and actual '
rmistry@google.comd6bab022013-12-02 13:50:38 +0000659 'results will be updated by running "gclient sync" '
660 'on your Skia checkout as a whole. '
epoger@google.com542b65f2013-10-15 20:10:33 +0000661 'By default, we do not reload at all, and you '
662 'must restart the server to pick up new data.'),
663 default=0)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000664 args = parser.parse_args()
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000665 if args.compare_configs:
666 config_pairs = CONFIG_PAIRS_TO_COMPARE
667 else:
668 config_pairs = None
669
epoger@google.comf9d134d2013-09-27 15:02:44 +0000670 global _SERVER
epoger@google.com542b65f2013-10-15 20:10:33 +0000671 _SERVER = Server(actuals_dir=args.actuals_dir,
commit-bot@chromium.org5865ec52014-03-10 18:09:25 +0000672 actuals_repo_revision=args.actuals_revision,
673 actuals_repo_url=args.actuals_repo,
epoger@google.com542b65f2013-10-15 20:10:33 +0000674 port=args.port, export=args.export, editable=args.editable,
commit-bot@chromium.orgdefe6fd2014-04-10 15:05:24 +0000675 reload_seconds=args.reload, config_pairs=config_pairs,
676 builder_regex_list=args.builders)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000677 _SERVER.run()
678
rmistry@google.comd6bab022013-12-02 13:50:38 +0000679
epoger@google.comf9d134d2013-09-27 15:02:44 +0000680if __name__ == '__main__':
681 main()