blob: a370379f5c97c7bac80eec65348f8c41e3c2e29e [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
63_SERVER = None # This gets filled in by main()
64
65class Server(object):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +000066 """ HTTP server for our HTML rebaseline viewer. """
epoger@google.comf9d134d2013-09-27 15:02:44 +000067
epoger@google.comf9d134d2013-09-27 15:02:44 +000068 def __init__(self,
69 actuals_dir=DEFAULT_ACTUALS_DIR,
70 expectations_dir=DEFAULT_EXPECTATIONS_DIR,
epoger@google.com542b65f2013-10-15 20:10:33 +000071 port=DEFAULT_PORT, export=False, editable=True,
72 reload_seconds=0):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +000073 """
74 Args:
75 actuals_dir: directory under which we will check out the latest actual
76 GM results
77 expectations_dir: directory under which to find GM expectations (they
78 must already be in that directory)
79 port: which TCP port to listen on for HTTP requests
80 export: whether to allow HTTP clients on other hosts to access this server
epoger@google.com542b65f2013-10-15 20:10:33 +000081 editable: whether HTTP clients are allowed to submit new baselines
82 reload_seconds: polling interval with which to check for new results;
83 if 0, don't check for new results at all
epoger@google.com9fb6c8a2013-10-09 18:05:58 +000084 """
epoger@google.comf9d134d2013-09-27 15:02:44 +000085 self._actuals_dir = actuals_dir
86 self._expectations_dir = expectations_dir
87 self._port = port
88 self._export = export
epoger@google.com542b65f2013-10-15 20:10:33 +000089 self._editable = editable
90 self._reload_seconds = reload_seconds
epoger@google.comf9d134d2013-09-27 15:02:44 +000091
epoger@google.com9fb6c8a2013-10-09 18:05:58 +000092 def is_exported(self):
93 """ Returns true iff HTTP clients on other hosts are allowed to access
94 this server. """
95 return self._export
96
epoger@google.com542b65f2013-10-15 20:10:33 +000097 def is_editable(self):
98 """ Returns true iff HTTP clients are allowed to submit new baselines. """
99 return self._editable
epoger@google.comf9d134d2013-09-27 15:02:44 +0000100
epoger@google.com542b65f2013-10-15 20:10:33 +0000101 def reload_seconds(self):
102 """ Returns the result reload period in seconds, or 0 if we don't reload
103 results. """
104 return self._reload_seconds
105
106 def _update_results(self):
107 """ Create or update self.results, based on the expectations in
108 self._expectations_dir and the latest actuals from skia-autogen.
epoger@google.comf9d134d2013-09-27 15:02:44 +0000109 """
epoger@google.com542b65f2013-10-15 20:10:33 +0000110 logging.info('Updating actual GM results in %s from SVN repo %s ...' % (
111 self._actuals_dir, ACTUALS_SVN_REPO))
epoger@google.comf9d134d2013-09-27 15:02:44 +0000112 actuals_repo = svn.Svn(self._actuals_dir)
113 if not os.path.isdir(self._actuals_dir):
114 os.makedirs(self._actuals_dir)
115 actuals_repo.Checkout(ACTUALS_SVN_REPO, '.')
116 else:
117 actuals_repo.Update('.')
epoger@google.com542b65f2013-10-15 20:10:33 +0000118
119 # We only update the expectations dir if the server was run with a nonzero
120 # --reload argument; otherwise, we expect the user to maintain her own
121 # expectations as she sees fit.
122 #
123 # TODO(epoger): Use git instead of svn to check out expectations, since
124 # the Skia repo is moving to git.
125 if self._reload_seconds:
126 logging.info('Updating expected GM results in %s from SVN repo %s ...' % (
127 self._expectations_dir, EXPECTATIONS_SVN_REPO))
128 expectations_repo = svn.Svn(self._expectations_dir)
129 if not os.path.isdir(self._expectations_dir):
130 os.makedirs(self._expectations_dir)
131 expectations_repo.Checkout(EXPECTATIONS_SVN_REPO, '.')
132 else:
133 expectations_repo.Update('.')
134
epoger@google.comdcb4e652013-10-11 18:45:33 +0000135 logging.info(
136 'Parsing results from actuals in %s and expectations in %s ...' % (
137 self._actuals_dir, self._expectations_dir))
epoger@google.comf9d134d2013-09-27 15:02:44 +0000138 self.results = results.Results(
139 actuals_root=self._actuals_dir,
140 expected_root=self._expectations_dir)
141
epoger@google.com542b65f2013-10-15 20:10:33 +0000142 def _result_reloader(self):
143 """ If --reload argument was specified, reload results at the appropriate
144 interval.
145 """
146 while self._reload_seconds:
147 time.sleep(self._reload_seconds)
148 with self.results_lock:
149 self._update_results()
150
epoger@google.comf9d134d2013-09-27 15:02:44 +0000151 def run(self):
epoger@google.com542b65f2013-10-15 20:10:33 +0000152 self._update_results()
153 self.results_lock = thread.allocate_lock()
154 thread.start_new_thread(self._result_reloader, ())
155
epoger@google.comf9d134d2013-09-27 15:02:44 +0000156 if self._export:
157 server_address = ('', self._port)
epoger@google.com542b65f2013-10-15 20:10:33 +0000158 if self._editable:
159 logging.warning('Running with combination of "export" and "editable" '
160 'flags. Users on other machines will '
161 'be able to modify your GM expectations!')
epoger@google.comf9d134d2013-09-27 15:02:44 +0000162 else:
163 server_address = ('127.0.0.1', self._port)
164 http_server = BaseHTTPServer.HTTPServer(server_address, HTTPRequestHandler)
epoger@google.comdcb4e652013-10-11 18:45:33 +0000165 logging.info('Ready for requests on http://%s:%d' % (
166 http_server.server_name, http_server.server_port))
epoger@google.comf9d134d2013-09-27 15:02:44 +0000167 http_server.serve_forever()
168
169
170class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
171 """ HTTP request handlers for various types of queries this server knows
172 how to handle (static HTML and Javascript, expected/actual results, etc.)
173 """
174 def do_GET(self):
175 """ Handles all GET requests, forwarding them to the appropriate
176 do_GET_* dispatcher. """
177 if self.path == '' or self.path == '/' or self.path == '/index.html' :
epoger@google.comdcb4e652013-10-11 18:45:33 +0000178 self.redirect_to('/static/view.html?resultsToLoad=all')
epoger@google.comf9d134d2013-09-27 15:02:44 +0000179 return
180 if self.path == '/favicon.ico' :
181 self.redirect_to('/static/favicon.ico')
182 return
183
184 # All requests must be of this form:
185 # /dispatcher/remainder
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000186 # where 'dispatcher' indicates which do_GET_* dispatcher to run
187 # and 'remainder' is the remaining path sent to that dispatcher.
epoger@google.comf9d134d2013-09-27 15:02:44 +0000188 normpath = posixpath.normpath(self.path)
189 (dispatcher_name, remainder) = PATHSPLIT_RE.match(normpath).groups()
190 dispatchers = {
191 'results': self.do_GET_results,
192 'static': self.do_GET_static,
193 }
194 dispatcher = dispatchers[dispatcher_name]
195 dispatcher(remainder)
196
epoger@google.comdcb4e652013-10-11 18:45:33 +0000197 def do_GET_results(self, type):
epoger@google.comf9d134d2013-09-27 15:02:44 +0000198 """ Handle a GET request for GM results.
epoger@google.comf9d134d2013-09-27 15:02:44 +0000199
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000200 Args:
epoger@google.comdcb4e652013-10-11 18:45:33 +0000201 type: string indicating which set of results to return;
202 must be one of the results.RESULTS_* constants
203 """
204 logging.debug('do_GET_results: sending results of type "%s"' % type)
205 try:
206 # TODO(epoger): Rather than using a global variable for the handler
207 # to refer to the Server object, make Server a subclass of
208 # HTTPServer, and then it could be available to the handler via
209 # the handler's .server instance variable.
epoger@google.com542b65f2013-10-15 20:10:33 +0000210
211 with _SERVER.results_lock:
212 response_dict = _SERVER.results.get_results_of_type(type)
213 time_updated = _SERVER.results.get_timestamp()
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000214 response_dict['header'] = {
epoger@google.com542b65f2013-10-15 20:10:33 +0000215 # Timestamps:
216 # 1. when this data was last updated
217 # 2. when the caller should check back for new data (if ever)
218 #
219 # We only return these timestamps if the --reload argument was passed;
220 # otherwise, we have no idea when the expectations were last updated
221 # (we allow the user to maintain her own expectations as she sees fit).
222 'timeUpdated': time_updated if _SERVER.reload_seconds() else None,
223 'timeNextUpdateAvailable': (
224 (time_updated+_SERVER.reload_seconds()) if _SERVER.reload_seconds()
225 else None),
226
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000227 # Hash of testData, which the client must return with any edits--
228 # this ensures that the edits were made to a particular dataset.
epoger@google.com542b65f2013-10-15 20:10:33 +0000229 'dataHash': str(hash(repr(response_dict['testData']))),
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000230
231 # Whether the server will accept edits back.
epoger@google.com542b65f2013-10-15 20:10:33 +0000232 'isEditable': _SERVER.is_editable(),
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000233
234 # Whether the service is accessible from other hosts.
235 'isExported': _SERVER.is_exported(),
236 }
epoger@google.comf9d134d2013-09-27 15:02:44 +0000237 self.send_json_dict(response_dict)
epoger@google.comdcb4e652013-10-11 18:45:33 +0000238 except:
epoger@google.comf9d134d2013-09-27 15:02:44 +0000239 self.send_error(404)
epoger@google.com542b65f2013-10-15 20:10:33 +0000240 raise
epoger@google.comf9d134d2013-09-27 15:02:44 +0000241
242 def do_GET_static(self, path):
epoger@google.comcb55f112013-10-02 19:27:35 +0000243 """ Handle a GET request for a file under the 'static' directory.
244 Only allow serving of files within the 'static' directory that is a
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000245 filesystem sibling of this script.
246
247 Args:
248 path: path to file (under static directory) to retrieve
249 """
epoger@google.comdcb4e652013-10-11 18:45:33 +0000250 # Strip arguments ('?resultsToLoad=all') from the path
251 path = urlparse.urlparse(path).path
252
253 logging.debug('do_GET_static: sending file "%s"' % path)
epoger@google.comcb55f112013-10-02 19:27:35 +0000254 static_dir = os.path.realpath(os.path.join(PARENT_DIRECTORY, 'static'))
255 full_path = os.path.realpath(os.path.join(static_dir, path))
256 if full_path.startswith(static_dir):
257 self.send_file(full_path)
258 else:
epoger@google.comdcb4e652013-10-11 18:45:33 +0000259 logging.error(
260 'Attempted do_GET_static() of path [%s] outside of static dir [%s]'
261 % (full_path, static_dir))
epoger@google.comcb55f112013-10-02 19:27:35 +0000262 self.send_error(404)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000263
264 def redirect_to(self, url):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000265 """ Redirect the HTTP client to a different url.
266
267 Args:
268 url: URL to redirect the HTTP client to
269 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000270 self.send_response(301)
271 self.send_header('Location', url)
272 self.end_headers()
273
274 def send_file(self, path):
275 """ Send the contents of the file at this path, with a mimetype based
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000276 on the filename extension.
277
278 Args:
279 path: path of file whose contents to send to the HTTP client
280 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000281 # Grab the extension if there is one
282 extension = os.path.splitext(path)[1]
283 if len(extension) >= 1:
284 extension = extension[1:]
285
286 # Determine the MIME type of the file from its extension
287 mime_type = MIME_TYPE_MAP.get(extension, MIME_TYPE_MAP[''])
288
289 # Open the file and send it over HTTP
290 if os.path.isfile(path):
291 with open(path, 'rb') as sending_file:
292 self.send_response(200)
293 self.send_header('Content-type', mime_type)
294 self.end_headers()
295 self.wfile.write(sending_file.read())
296 else:
297 self.send_error(404)
298
299 def send_json_dict(self, json_dict):
300 """ Send the contents of this dictionary in JSON format, with a JSON
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000301 mimetype.
302
303 Args:
304 json_dict: dictionary to send
305 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000306 self.send_response(200)
307 self.send_header('Content-type', 'application/json')
308 self.end_headers()
309 json.dump(json_dict, self.wfile)
310
311
312def main():
epoger@google.comdcb4e652013-10-11 18:45:33 +0000313 logging.basicConfig(level=logging.INFO)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000314 parser = argparse.ArgumentParser()
315 parser.add_argument('--actuals-dir',
316 help=('Directory into which we will check out the latest '
317 'actual GM results. If this directory does not '
318 'exist, it will be created. Defaults to %(default)s'),
319 default=DEFAULT_ACTUALS_DIR)
epoger@google.com542b65f2013-10-15 20:10:33 +0000320 parser.add_argument('--editable', action='store_true',
321 help=('TODO(epoger): NOT YET IMPLEMENTED. '
322 'Allow HTTP clients to submit new baselines.'))
epoger@google.comf9d134d2013-09-27 15:02:44 +0000323 parser.add_argument('--expectations-dir',
324 help=('Directory under which to find GM expectations; '
325 'defaults to %(default)s'),
326 default=DEFAULT_EXPECTATIONS_DIR)
327 parser.add_argument('--export', action='store_true',
328 help=('Instead of only allowing access from HTTP clients '
329 'on localhost, allow HTTP clients on other hosts '
330 'to access this server. WARNING: doing so will '
331 'allow users on other hosts to modify your '
epoger@google.com542b65f2013-10-15 20:10:33 +0000332 'GM expectations, if combined with --editable.'))
epoger@google.comafaad3d2013-09-30 15:06:25 +0000333 parser.add_argument('--port', type=int,
334 help=('Which TCP port to listen on for HTTP requests; '
335 'defaults to %(default)s'),
336 default=DEFAULT_PORT)
epoger@google.com542b65f2013-10-15 20:10:33 +0000337 parser.add_argument('--reload', type=int,
338 help=('How often (a period in seconds) to update the '
339 'results. If specified, both EXPECTATIONS_DIR and '
340 'ACTUAL_DIR will be updated. '
341 'By default, we do not reload at all, and you '
342 'must restart the server to pick up new data.'),
343 default=0)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000344 args = parser.parse_args()
345 global _SERVER
epoger@google.com542b65f2013-10-15 20:10:33 +0000346 _SERVER = Server(actuals_dir=args.actuals_dir,
347 expectations_dir=args.expectations_dir,
348 port=args.port, export=args.export, editable=args.editable,
349 reload_seconds=args.reload)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000350 _SERVER.run()
351
352if __name__ == '__main__':
353 main()