blob: bfc690b570ff7ff51d7e61b5fc98183b566102f0 [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.comdcb4e652013-10-11 18:45:33 +000022import urlparse
epoger@google.comf9d134d2013-09-27 15:02:44 +000023
24# Imports from within Skia
25#
26# We need to add the 'tools' directory, so that we can import svn.py within
27# that directory.
28# Make sure that the 'tools' dir is in the PYTHONPATH, but add it at the *end*
29# so any dirs that are already in the PYTHONPATH will be preferred.
epoger@google.comcb55f112013-10-02 19:27:35 +000030PARENT_DIRECTORY = os.path.dirname(os.path.realpath(__file__))
31TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(PARENT_DIRECTORY))
epoger@google.comf9d134d2013-09-27 15:02:44 +000032TOOLS_DIRECTORY = os.path.join(TRUNK_DIRECTORY, 'tools')
33if TOOLS_DIRECTORY not in sys.path:
34 sys.path.append(TOOLS_DIRECTORY)
35import svn
36
37# Imports from local dir
38import results
39
40ACTUALS_SVN_REPO = 'http://skia-autogen.googlecode.com/svn/gm-actual'
41PATHSPLIT_RE = re.compile('/([^/]+)/(.+)')
42TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(os.path.dirname(
43 os.path.realpath(__file__))))
44
45# A simple dictionary of file name extensions to MIME types. The empty string
46# entry is used as the default when no extension was given or if the extension
47# has no entry in this dictionary.
48MIME_TYPE_MAP = {'': 'application/octet-stream',
49 'html': 'text/html',
50 'css': 'text/css',
51 'png': 'image/png',
52 'js': 'application/javascript',
53 'json': 'application/json'
54 }
55
56DEFAULT_ACTUALS_DIR = '.gm-actuals'
57DEFAULT_EXPECTATIONS_DIR = os.path.join(TRUNK_DIRECTORY, 'expectations', 'gm')
58DEFAULT_PORT = 8888
59
60_SERVER = None # This gets filled in by main()
61
62class Server(object):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +000063 """ HTTP server for our HTML rebaseline viewer. """
epoger@google.comf9d134d2013-09-27 15:02:44 +000064
epoger@google.comf9d134d2013-09-27 15:02:44 +000065 def __init__(self,
66 actuals_dir=DEFAULT_ACTUALS_DIR,
67 expectations_dir=DEFAULT_EXPECTATIONS_DIR,
68 port=DEFAULT_PORT, export=False):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +000069 """
70 Args:
71 actuals_dir: directory under which we will check out the latest actual
72 GM results
73 expectations_dir: directory under which to find GM expectations (they
74 must already be in that directory)
75 port: which TCP port to listen on for HTTP requests
76 export: whether to allow HTTP clients on other hosts to access this server
77 """
epoger@google.comf9d134d2013-09-27 15:02:44 +000078 self._actuals_dir = actuals_dir
79 self._expectations_dir = expectations_dir
80 self._port = port
81 self._export = export
82
epoger@google.com9fb6c8a2013-10-09 18:05:58 +000083 def is_exported(self):
84 """ Returns true iff HTTP clients on other hosts are allowed to access
85 this server. """
86 return self._export
87
epoger@google.comf9d134d2013-09-27 15:02:44 +000088 def fetch_results(self):
89 """ Create self.results, based on the expectations in
90 self._expectations_dir and the latest actuals from skia-autogen.
91
92 TODO(epoger): Add a new --browseonly mode setting. In that mode,
93 the gm-actuals and expectations will automatically be updated every few
94 minutes. See discussion in https://codereview.chromium.org/24274003/ .
95 """
epoger@google.comdcb4e652013-10-11 18:45:33 +000096 logging.info('Checking out latest actual GM results from %s into %s ...' % (
97 ACTUALS_SVN_REPO, self._actuals_dir))
epoger@google.comf9d134d2013-09-27 15:02:44 +000098 actuals_repo = svn.Svn(self._actuals_dir)
99 if not os.path.isdir(self._actuals_dir):
100 os.makedirs(self._actuals_dir)
101 actuals_repo.Checkout(ACTUALS_SVN_REPO, '.')
102 else:
103 actuals_repo.Update('.')
epoger@google.comdcb4e652013-10-11 18:45:33 +0000104 logging.info(
105 'Parsing results from actuals in %s and expectations in %s ...' % (
106 self._actuals_dir, self._expectations_dir))
epoger@google.comf9d134d2013-09-27 15:02:44 +0000107 self.results = results.Results(
108 actuals_root=self._actuals_dir,
109 expected_root=self._expectations_dir)
110
111 def run(self):
112 self.fetch_results()
113 if self._export:
114 server_address = ('', self._port)
epoger@google.comdcb4e652013-10-11 18:45:33 +0000115 logging.warning('Running in "export" mode. Users on other machines will '
116 'be able to modify your GM expectations!')
epoger@google.comf9d134d2013-09-27 15:02:44 +0000117 else:
118 server_address = ('127.0.0.1', self._port)
119 http_server = BaseHTTPServer.HTTPServer(server_address, HTTPRequestHandler)
epoger@google.comdcb4e652013-10-11 18:45:33 +0000120 logging.info('Ready for requests on http://%s:%d' % (
121 http_server.server_name, http_server.server_port))
epoger@google.comf9d134d2013-09-27 15:02:44 +0000122 http_server.serve_forever()
123
124
125class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
126 """ HTTP request handlers for various types of queries this server knows
127 how to handle (static HTML and Javascript, expected/actual results, etc.)
128 """
129 def do_GET(self):
130 """ Handles all GET requests, forwarding them to the appropriate
131 do_GET_* dispatcher. """
132 if self.path == '' or self.path == '/' or self.path == '/index.html' :
epoger@google.comdcb4e652013-10-11 18:45:33 +0000133 self.redirect_to('/static/view.html?resultsToLoad=all')
epoger@google.comf9d134d2013-09-27 15:02:44 +0000134 return
135 if self.path == '/favicon.ico' :
136 self.redirect_to('/static/favicon.ico')
137 return
138
139 # All requests must be of this form:
140 # /dispatcher/remainder
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000141 # where 'dispatcher' indicates which do_GET_* dispatcher to run
142 # and 'remainder' is the remaining path sent to that dispatcher.
epoger@google.comf9d134d2013-09-27 15:02:44 +0000143 normpath = posixpath.normpath(self.path)
144 (dispatcher_name, remainder) = PATHSPLIT_RE.match(normpath).groups()
145 dispatchers = {
146 'results': self.do_GET_results,
147 'static': self.do_GET_static,
148 }
149 dispatcher = dispatchers[dispatcher_name]
150 dispatcher(remainder)
151
epoger@google.comdcb4e652013-10-11 18:45:33 +0000152 def do_GET_results(self, type):
epoger@google.comf9d134d2013-09-27 15:02:44 +0000153 """ Handle a GET request for GM results.
epoger@google.comf9d134d2013-09-27 15:02:44 +0000154
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000155 Args:
epoger@google.comdcb4e652013-10-11 18:45:33 +0000156 type: string indicating which set of results to return;
157 must be one of the results.RESULTS_* constants
158 """
159 logging.debug('do_GET_results: sending results of type "%s"' % type)
160 try:
161 # TODO(epoger): Rather than using a global variable for the handler
162 # to refer to the Server object, make Server a subclass of
163 # HTTPServer, and then it could be available to the handler via
164 # the handler's .server instance variable.
165 response_dict = _SERVER.results.get_results_of_type(type)
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000166 response_dict['header'] = {
167 # Hash of testData, which the client must return with any edits--
168 # this ensures that the edits were made to a particular dataset.
169 'data-hash': str(hash(repr(response_dict['testData']))),
170
171 # Whether the server will accept edits back.
172 # TODO(epoger): Not yet implemented, so hardcoding to False;
173 # once we implement the 'browseonly' mode discussed in
174 # https://codereview.chromium.org/24274003/#msg6 , this value will vary.
175 'isEditable': False,
176
177 # Whether the service is accessible from other hosts.
178 'isExported': _SERVER.is_exported(),
179 }
epoger@google.comf9d134d2013-09-27 15:02:44 +0000180 self.send_json_dict(response_dict)
epoger@google.comdcb4e652013-10-11 18:45:33 +0000181 except:
epoger@google.comf9d134d2013-09-27 15:02:44 +0000182 self.send_error(404)
183
184 def do_GET_static(self, path):
epoger@google.comcb55f112013-10-02 19:27:35 +0000185 """ Handle a GET request for a file under the 'static' directory.
186 Only allow serving of files within the 'static' directory that is a
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000187 filesystem sibling of this script.
188
189 Args:
190 path: path to file (under static directory) to retrieve
191 """
epoger@google.comdcb4e652013-10-11 18:45:33 +0000192 # Strip arguments ('?resultsToLoad=all') from the path
193 path = urlparse.urlparse(path).path
194
195 logging.debug('do_GET_static: sending file "%s"' % path)
epoger@google.comcb55f112013-10-02 19:27:35 +0000196 static_dir = os.path.realpath(os.path.join(PARENT_DIRECTORY, 'static'))
197 full_path = os.path.realpath(os.path.join(static_dir, path))
198 if full_path.startswith(static_dir):
199 self.send_file(full_path)
200 else:
epoger@google.comdcb4e652013-10-11 18:45:33 +0000201 logging.error(
202 'Attempted do_GET_static() of path [%s] outside of static dir [%s]'
203 % (full_path, static_dir))
epoger@google.comcb55f112013-10-02 19:27:35 +0000204 self.send_error(404)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000205
206 def redirect_to(self, url):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000207 """ Redirect the HTTP client to a different url.
208
209 Args:
210 url: URL to redirect the HTTP client to
211 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000212 self.send_response(301)
213 self.send_header('Location', url)
214 self.end_headers()
215
216 def send_file(self, path):
217 """ Send the contents of the file at this path, with a mimetype based
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000218 on the filename extension.
219
220 Args:
221 path: path of file whose contents to send to the HTTP client
222 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000223 # Grab the extension if there is one
224 extension = os.path.splitext(path)[1]
225 if len(extension) >= 1:
226 extension = extension[1:]
227
228 # Determine the MIME type of the file from its extension
229 mime_type = MIME_TYPE_MAP.get(extension, MIME_TYPE_MAP[''])
230
231 # Open the file and send it over HTTP
232 if os.path.isfile(path):
233 with open(path, 'rb') as sending_file:
234 self.send_response(200)
235 self.send_header('Content-type', mime_type)
236 self.end_headers()
237 self.wfile.write(sending_file.read())
238 else:
239 self.send_error(404)
240
241 def send_json_dict(self, json_dict):
242 """ Send the contents of this dictionary in JSON format, with a JSON
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000243 mimetype.
244
245 Args:
246 json_dict: dictionary to send
247 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000248 self.send_response(200)
249 self.send_header('Content-type', 'application/json')
250 self.end_headers()
251 json.dump(json_dict, self.wfile)
252
253
254def main():
epoger@google.comdcb4e652013-10-11 18:45:33 +0000255 logging.basicConfig(level=logging.INFO)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000256 parser = argparse.ArgumentParser()
257 parser.add_argument('--actuals-dir',
258 help=('Directory into which we will check out the latest '
259 'actual GM results. If this directory does not '
260 'exist, it will be created. Defaults to %(default)s'),
261 default=DEFAULT_ACTUALS_DIR)
262 parser.add_argument('--expectations-dir',
263 help=('Directory under which to find GM expectations; '
264 'defaults to %(default)s'),
265 default=DEFAULT_EXPECTATIONS_DIR)
266 parser.add_argument('--export', action='store_true',
267 help=('Instead of only allowing access from HTTP clients '
268 'on localhost, allow HTTP clients on other hosts '
269 'to access this server. WARNING: doing so will '
270 'allow users on other hosts to modify your '
271 'GM expectations!'))
epoger@google.comafaad3d2013-09-30 15:06:25 +0000272 parser.add_argument('--port', type=int,
273 help=('Which TCP port to listen on for HTTP requests; '
274 'defaults to %(default)s'),
275 default=DEFAULT_PORT)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000276 args = parser.parse_args()
277 global _SERVER
278 _SERVER = Server(expectations_dir=args.expectations_dir,
279 port=args.port, export=args.export)
280 _SERVER.run()
281
282if __name__ == '__main__':
283 main()