blob: a15b9b95d23ccf8b049cc8e5266c86eb3b5b940b [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
21import sys
epoger@google.com542b65f2013-10-15 20:10:33 +000022import thread
23import time
epoger@google.comdcb4e652013-10-11 18:45:33 +000024import urlparse
epoger@google.comf9d134d2013-09-27 15:02:44 +000025
26# Imports from within Skia
27#
28# We need to add the 'tools' directory, so that we can import svn.py within
29# that directory.
30# Make sure that the 'tools' dir is in the PYTHONPATH, but add it at the *end*
31# so any dirs that are already in the PYTHONPATH will be preferred.
epoger@google.comcb55f112013-10-02 19:27:35 +000032PARENT_DIRECTORY = os.path.dirname(os.path.realpath(__file__))
33TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(PARENT_DIRECTORY))
epoger@google.comf9d134d2013-09-27 15:02:44 +000034TOOLS_DIRECTORY = os.path.join(TRUNK_DIRECTORY, 'tools')
35if TOOLS_DIRECTORY not in sys.path:
36 sys.path.append(TOOLS_DIRECTORY)
37import svn
38
39# Imports from local dir
40import results
41
42ACTUALS_SVN_REPO = 'http://skia-autogen.googlecode.com/svn/gm-actual'
epoger@google.com542b65f2013-10-15 20:10:33 +000043EXPECTATIONS_SVN_REPO = 'http://skia.googlecode.com/svn/trunk/expectations/gm'
epoger@google.comf9d134d2013-09-27 15:02:44 +000044PATHSPLIT_RE = re.compile('/([^/]+)/(.+)')
45TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(os.path.dirname(
46 os.path.realpath(__file__))))
47
48# A simple dictionary of file name extensions to MIME types. The empty string
49# entry is used as the default when no extension was given or if the extension
50# has no entry in this dictionary.
51MIME_TYPE_MAP = {'': 'application/octet-stream',
52 'html': 'text/html',
53 'css': 'text/css',
54 'png': 'image/png',
55 'js': 'application/javascript',
56 'json': 'application/json'
57 }
58
59DEFAULT_ACTUALS_DIR = '.gm-actuals'
60DEFAULT_EXPECTATIONS_DIR = os.path.join(TRUNK_DIRECTORY, 'expectations', 'gm')
61DEFAULT_PORT = 8888
62
epoger@google.comeb832592013-10-23 15:07:26 +000063_HTTP_HEADER_CONTENT_LENGTH = 'Content-Length'
64_HTTP_HEADER_CONTENT_TYPE = 'Content-Type'
65
epoger@google.comf9d134d2013-09-27 15:02:44 +000066_SERVER = None # This gets filled in by main()
67
68class Server(object):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +000069 """ HTTP server for our HTML rebaseline viewer. """
epoger@google.comf9d134d2013-09-27 15:02:44 +000070
epoger@google.comf9d134d2013-09-27 15:02:44 +000071 def __init__(self,
72 actuals_dir=DEFAULT_ACTUALS_DIR,
73 expectations_dir=DEFAULT_EXPECTATIONS_DIR,
epoger@google.com542b65f2013-10-15 20:10:33 +000074 port=DEFAULT_PORT, export=False, editable=True,
75 reload_seconds=0):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +000076 """
77 Args:
78 actuals_dir: directory under which we will check out the latest actual
79 GM results
80 expectations_dir: directory under which to find GM expectations (they
81 must already be in that directory)
82 port: which TCP port to listen on for HTTP requests
83 export: whether to allow HTTP clients on other hosts to access this server
epoger@google.com542b65f2013-10-15 20:10:33 +000084 editable: whether HTTP clients are allowed to submit new baselines
85 reload_seconds: polling interval with which to check for new results;
86 if 0, don't check for new results at all
epoger@google.com9fb6c8a2013-10-09 18:05:58 +000087 """
epoger@google.comf9d134d2013-09-27 15:02:44 +000088 self._actuals_dir = actuals_dir
89 self._expectations_dir = expectations_dir
90 self._port = port
91 self._export = export
epoger@google.com542b65f2013-10-15 20:10:33 +000092 self._editable = editable
93 self._reload_seconds = reload_seconds
epoger@google.comf9d134d2013-09-27 15:02:44 +000094
epoger@google.com9fb6c8a2013-10-09 18:05:58 +000095 def is_exported(self):
96 """ Returns true iff HTTP clients on other hosts are allowed to access
97 this server. """
98 return self._export
99
epoger@google.com542b65f2013-10-15 20:10:33 +0000100 def is_editable(self):
101 """ Returns true iff HTTP clients are allowed to submit new baselines. """
102 return self._editable
epoger@google.comf9d134d2013-09-27 15:02:44 +0000103
epoger@google.com542b65f2013-10-15 20:10:33 +0000104 def reload_seconds(self):
105 """ Returns the result reload period in seconds, or 0 if we don't reload
106 results. """
107 return self._reload_seconds
108
epoger@google.comeb832592013-10-23 15:07:26 +0000109 def update_results(self):
epoger@google.com542b65f2013-10-15 20:10:33 +0000110 """ Create or update self.results, based on the expectations in
111 self._expectations_dir and the latest actuals from skia-autogen.
epoger@google.comf9d134d2013-09-27 15:02:44 +0000112 """
epoger@google.comeb832592013-10-23 15:07:26 +0000113 with self.results_lock:
114 # self.results_lock prevents us from updating the actual GM results
115 # in multiple threads simultaneously
116 logging.info('Updating actual GM results in %s from SVN repo %s ...' % (
117 self._actuals_dir, ACTUALS_SVN_REPO))
118 actuals_repo = svn.Svn(self._actuals_dir)
119 if not os.path.isdir(self._actuals_dir):
120 os.makedirs(self._actuals_dir)
121 actuals_repo.Checkout(ACTUALS_SVN_REPO, '.')
epoger@google.com542b65f2013-10-15 20:10:33 +0000122 else:
epoger@google.comeb832592013-10-23 15:07:26 +0000123 actuals_repo.Update('.')
epoger@google.com542b65f2013-10-15 20:10:33 +0000124
epoger@google.comeb832592013-10-23 15:07:26 +0000125 # We only update the expectations dir if the server was run with a
126 # nonzero --reload argument; otherwise, we expect the user to maintain
127 # her own expectations as she sees fit.
128 #
129 # self.results_lock prevents us from updating the expected GM results
130 # in multiple threads simultaneously
131 #
132 # TODO(epoger): Use git instead of svn to check out expectations, since
133 # the Skia repo is moving to git.
134 if self._reload_seconds:
135 logging.info(
136 'Updating expected GM results in %s from SVN repo %s ...' % (
137 self._expectations_dir, EXPECTATIONS_SVN_REPO))
138 expectations_repo = svn.Svn(self._expectations_dir)
139 if not os.path.isdir(self._expectations_dir):
140 os.makedirs(self._expectations_dir)
141 expectations_repo.Checkout(EXPECTATIONS_SVN_REPO, '.')
142 else:
143 expectations_repo.Update('.')
144
145 logging.info(
146 'Parsing results from actuals in %s and expectations in %s ...' % (
147 self._actuals_dir, self._expectations_dir))
148 self.results = results.Results(
149 actuals_root=self._actuals_dir,
150 expected_root=self._expectations_dir)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000151
epoger@google.com542b65f2013-10-15 20:10:33 +0000152 def _result_reloader(self):
153 """ If --reload argument was specified, reload results at the appropriate
154 interval.
155 """
156 while self._reload_seconds:
157 time.sleep(self._reload_seconds)
epoger@google.comeb832592013-10-23 15:07:26 +0000158 self.update_results()
epoger@google.com542b65f2013-10-15 20:10:33 +0000159
epoger@google.comf9d134d2013-09-27 15:02:44 +0000160 def run(self):
epoger@google.com542b65f2013-10-15 20:10:33 +0000161 self.results_lock = thread.allocate_lock()
epoger@google.comeb832592013-10-23 15:07:26 +0000162 self.update_results()
epoger@google.com542b65f2013-10-15 20:10:33 +0000163 thread.start_new_thread(self._result_reloader, ())
164
epoger@google.comf9d134d2013-09-27 15:02:44 +0000165 if self._export:
166 server_address = ('', self._port)
epoger@google.com542b65f2013-10-15 20:10:33 +0000167 if self._editable:
168 logging.warning('Running with combination of "export" and "editable" '
169 'flags. Users on other machines will '
170 'be able to modify your GM expectations!')
epoger@google.comf9d134d2013-09-27 15:02:44 +0000171 else:
172 server_address = ('127.0.0.1', self._port)
173 http_server = BaseHTTPServer.HTTPServer(server_address, HTTPRequestHandler)
epoger@google.comdcb4e652013-10-11 18:45:33 +0000174 logging.info('Ready for requests on http://%s:%d' % (
175 http_server.server_name, http_server.server_port))
epoger@google.comf9d134d2013-09-27 15:02:44 +0000176 http_server.serve_forever()
177
178
179class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
180 """ HTTP request handlers for various types of queries this server knows
181 how to handle (static HTML and Javascript, expected/actual results, etc.)
182 """
183 def do_GET(self):
184 """ Handles all GET requests, forwarding them to the appropriate
185 do_GET_* dispatcher. """
186 if self.path == '' or self.path == '/' or self.path == '/index.html' :
epoger@google.comdcb4e652013-10-11 18:45:33 +0000187 self.redirect_to('/static/view.html?resultsToLoad=all')
epoger@google.comf9d134d2013-09-27 15:02:44 +0000188 return
189 if self.path == '/favicon.ico' :
190 self.redirect_to('/static/favicon.ico')
191 return
192
193 # All requests must be of this form:
194 # /dispatcher/remainder
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000195 # where 'dispatcher' indicates which do_GET_* dispatcher to run
196 # and 'remainder' is the remaining path sent to that dispatcher.
epoger@google.comf9d134d2013-09-27 15:02:44 +0000197 normpath = posixpath.normpath(self.path)
198 (dispatcher_name, remainder) = PATHSPLIT_RE.match(normpath).groups()
199 dispatchers = {
200 'results': self.do_GET_results,
201 'static': self.do_GET_static,
202 }
203 dispatcher = dispatchers[dispatcher_name]
204 dispatcher(remainder)
205
epoger@google.comdcb4e652013-10-11 18:45:33 +0000206 def do_GET_results(self, type):
epoger@google.comf9d134d2013-09-27 15:02:44 +0000207 """ Handle a GET request for GM results.
epoger@google.comf9d134d2013-09-27 15:02:44 +0000208
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000209 Args:
epoger@google.comdcb4e652013-10-11 18:45:33 +0000210 type: string indicating which set of results to return;
211 must be one of the results.RESULTS_* constants
212 """
213 logging.debug('do_GET_results: sending results of type "%s"' % type)
214 try:
215 # TODO(epoger): Rather than using a global variable for the handler
216 # to refer to the Server object, make Server a subclass of
217 # HTTPServer, and then it could be available to the handler via
218 # the handler's .server instance variable.
epoger@google.com542b65f2013-10-15 20:10:33 +0000219
220 with _SERVER.results_lock:
221 response_dict = _SERVER.results.get_results_of_type(type)
222 time_updated = _SERVER.results.get_timestamp()
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000223 response_dict['header'] = {
epoger@google.com542b65f2013-10-15 20:10:33 +0000224 # Timestamps:
225 # 1. when this data was last updated
226 # 2. when the caller should check back for new data (if ever)
227 #
228 # We only return these timestamps if the --reload argument was passed;
229 # otherwise, we have no idea when the expectations were last updated
230 # (we allow the user to maintain her own expectations as she sees fit).
231 'timeUpdated': time_updated if _SERVER.reload_seconds() else None,
232 'timeNextUpdateAvailable': (
233 (time_updated+_SERVER.reload_seconds()) if _SERVER.reload_seconds()
234 else None),
235
epoger@google.comeb832592013-10-23 15:07:26 +0000236 # The type we passed to get_results_of_type()
237 'type': type,
238
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000239 # Hash of testData, which the client must return with any edits--
240 # this ensures that the edits were made to a particular dataset.
epoger@google.com542b65f2013-10-15 20:10:33 +0000241 'dataHash': str(hash(repr(response_dict['testData']))),
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000242
243 # Whether the server will accept edits back.
epoger@google.com542b65f2013-10-15 20:10:33 +0000244 'isEditable': _SERVER.is_editable(),
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000245
246 # Whether the service is accessible from other hosts.
247 'isExported': _SERVER.is_exported(),
248 }
epoger@google.comf9d134d2013-09-27 15:02:44 +0000249 self.send_json_dict(response_dict)
epoger@google.comdcb4e652013-10-11 18:45:33 +0000250 except:
epoger@google.comf9d134d2013-09-27 15:02:44 +0000251 self.send_error(404)
epoger@google.com542b65f2013-10-15 20:10:33 +0000252 raise
epoger@google.comf9d134d2013-09-27 15:02:44 +0000253
254 def do_GET_static(self, path):
epoger@google.comcb55f112013-10-02 19:27:35 +0000255 """ Handle a GET request for a file under the 'static' directory.
256 Only allow serving of files within the 'static' directory that is a
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000257 filesystem sibling of this script.
258
259 Args:
260 path: path to file (under static directory) to retrieve
261 """
epoger@google.comdcb4e652013-10-11 18:45:33 +0000262 # Strip arguments ('?resultsToLoad=all') from the path
263 path = urlparse.urlparse(path).path
264
265 logging.debug('do_GET_static: sending file "%s"' % path)
epoger@google.comcb55f112013-10-02 19:27:35 +0000266 static_dir = os.path.realpath(os.path.join(PARENT_DIRECTORY, 'static'))
267 full_path = os.path.realpath(os.path.join(static_dir, path))
268 if full_path.startswith(static_dir):
269 self.send_file(full_path)
270 else:
epoger@google.comdcb4e652013-10-11 18:45:33 +0000271 logging.error(
272 'Attempted do_GET_static() of path [%s] outside of static dir [%s]'
273 % (full_path, static_dir))
epoger@google.comcb55f112013-10-02 19:27:35 +0000274 self.send_error(404)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000275
epoger@google.comeb832592013-10-23 15:07:26 +0000276 def do_POST(self):
277 """ Handles all POST requests, forwarding them to the appropriate
278 do_POST_* dispatcher. """
279 # All requests must be of this form:
280 # /dispatcher
281 # where 'dispatcher' indicates which do_POST_* dispatcher to run.
282 normpath = posixpath.normpath(self.path)
283 dispatchers = {
284 '/edits': self.do_POST_edits,
285 }
286 try:
287 dispatcher = dispatchers[normpath]
288 dispatcher()
289 self.send_response(200)
290 except:
291 self.send_error(404)
292 raise
293
294 def do_POST_edits(self):
295 """ Handle a POST request with modifications to GM expectations, in this
296 format:
297
298 {
299 'oldResultsType': 'all', # type of results that the client loaded
300 # and then made modifications to
301 'oldResultsHash': 39850913, # hash of results when the client loaded them
302 # (ensures that the client and server apply
303 # modifications to the same base)
304 'modifications': [
305 {
306 'builder': 'Test-Android-Nexus10-MaliT604-Arm7-Debug',
307 'test': 'strokerect',
308 'config': 'gpu',
309 'expectedHashType': 'bitmap-64bitMD5',
310 'expectedHashDigest': '1707359671708613629',
311 },
312 ...
313 ],
314 }
315
316 Raises an Exception if there were any problems.
317 """
318 if not _SERVER.is_editable():
319 raise Exception('this server is not running in --editable mode')
320
321 content_type = self.headers[_HTTP_HEADER_CONTENT_TYPE]
322 if content_type != 'application/json;charset=UTF-8':
323 raise Exception('unsupported %s [%s]' % (
324 _HTTP_HEADER_CONTENT_TYPE, content_type))
325
326 content_length = int(self.headers[_HTTP_HEADER_CONTENT_LENGTH])
327 json_data = self.rfile.read(content_length)
328 data = json.loads(json_data)
329 logging.debug('do_POST_edits: received new GM expectations data [%s]' %
330 data)
331
332 with _SERVER.results_lock:
333 oldResultsType = data['oldResultsType']
334 oldResults = _SERVER.results.get_results_of_type(oldResultsType)
335 oldResultsHash = str(hash(repr(oldResults['testData'])))
336 if oldResultsHash != data['oldResultsHash']:
337 raise Exception('results of type "%s" changed while the client was '
338 'making modifications. The client should reload the '
339 'results and submit the modifications again.' %
340 oldResultsType)
341 _SERVER.results.edit_expectations(data['modifications'])
342
343 # Now that the edits have been committed, update results to reflect them.
344 _SERVER.update_results()
345
epoger@google.comf9d134d2013-09-27 15:02:44 +0000346 def redirect_to(self, url):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000347 """ Redirect the HTTP client to a different url.
348
349 Args:
350 url: URL to redirect the HTTP client to
351 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000352 self.send_response(301)
353 self.send_header('Location', url)
354 self.end_headers()
355
356 def send_file(self, path):
357 """ Send the contents of the file at this path, with a mimetype based
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000358 on the filename extension.
359
360 Args:
361 path: path of file whose contents to send to the HTTP client
362 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000363 # Grab the extension if there is one
364 extension = os.path.splitext(path)[1]
365 if len(extension) >= 1:
366 extension = extension[1:]
367
368 # Determine the MIME type of the file from its extension
369 mime_type = MIME_TYPE_MAP.get(extension, MIME_TYPE_MAP[''])
370
371 # Open the file and send it over HTTP
372 if os.path.isfile(path):
373 with open(path, 'rb') as sending_file:
374 self.send_response(200)
375 self.send_header('Content-type', mime_type)
376 self.end_headers()
377 self.wfile.write(sending_file.read())
378 else:
379 self.send_error(404)
380
381 def send_json_dict(self, json_dict):
382 """ Send the contents of this dictionary in JSON format, with a JSON
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000383 mimetype.
384
385 Args:
386 json_dict: dictionary to send
387 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000388 self.send_response(200)
389 self.send_header('Content-type', 'application/json')
390 self.end_headers()
391 json.dump(json_dict, self.wfile)
392
393
394def main():
epoger@google.comdcb4e652013-10-11 18:45:33 +0000395 logging.basicConfig(level=logging.INFO)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000396 parser = argparse.ArgumentParser()
397 parser.add_argument('--actuals-dir',
398 help=('Directory into which we will check out the latest '
399 'actual GM results. If this directory does not '
400 'exist, it will be created. Defaults to %(default)s'),
401 default=DEFAULT_ACTUALS_DIR)
epoger@google.com542b65f2013-10-15 20:10:33 +0000402 parser.add_argument('--editable', action='store_true',
epoger@google.comeb832592013-10-23 15:07:26 +0000403 help=('Allow HTTP clients to submit new baselines.'))
epoger@google.comf9d134d2013-09-27 15:02:44 +0000404 parser.add_argument('--expectations-dir',
405 help=('Directory under which to find GM expectations; '
406 'defaults to %(default)s'),
407 default=DEFAULT_EXPECTATIONS_DIR)
408 parser.add_argument('--export', action='store_true',
409 help=('Instead of only allowing access from HTTP clients '
410 'on localhost, allow HTTP clients on other hosts '
411 'to access this server. WARNING: doing so will '
412 'allow users on other hosts to modify your '
epoger@google.com542b65f2013-10-15 20:10:33 +0000413 'GM expectations, if combined with --editable.'))
epoger@google.comafaad3d2013-09-30 15:06:25 +0000414 parser.add_argument('--port', type=int,
415 help=('Which TCP port to listen on for HTTP requests; '
416 'defaults to %(default)s'),
417 default=DEFAULT_PORT)
epoger@google.com542b65f2013-10-15 20:10:33 +0000418 parser.add_argument('--reload', type=int,
419 help=('How often (a period in seconds) to update the '
420 'results. If specified, both EXPECTATIONS_DIR and '
421 'ACTUAL_DIR will be updated. '
422 'By default, we do not reload at all, and you '
423 'must restart the server to pick up new data.'),
424 default=0)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000425 args = parser.parse_args()
426 global _SERVER
epoger@google.com542b65f2013-10-15 20:10:33 +0000427 _SERVER = Server(actuals_dir=args.actuals_dir,
428 expectations_dir=args.expectations_dir,
429 port=args.port, export=args.export, editable=args.editable,
430 reload_seconds=args.reload)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000431 _SERVER.run()
432
433if __name__ == '__main__':
434 main()