blob: 7b87d6f07cf0abbaef2e8106122488e409b3623a [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
16import os
17import posixpath
18import re
19import shutil
20import sys
21
22# Imports from within Skia
23#
24# We need to add the 'tools' directory, so that we can import svn.py within
25# that directory.
26# Make sure that the 'tools' dir is in the PYTHONPATH, but add it at the *end*
27# so any dirs that are already in the PYTHONPATH will be preferred.
epoger@google.comcb55f112013-10-02 19:27:35 +000028PARENT_DIRECTORY = os.path.dirname(os.path.realpath(__file__))
29TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(PARENT_DIRECTORY))
epoger@google.comf9d134d2013-09-27 15:02:44 +000030TOOLS_DIRECTORY = os.path.join(TRUNK_DIRECTORY, 'tools')
31if TOOLS_DIRECTORY not in sys.path:
32 sys.path.append(TOOLS_DIRECTORY)
33import svn
34
35# Imports from local dir
36import results
37
38ACTUALS_SVN_REPO = 'http://skia-autogen.googlecode.com/svn/gm-actual'
39PATHSPLIT_RE = re.compile('/([^/]+)/(.+)')
40TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(os.path.dirname(
41 os.path.realpath(__file__))))
42
43# A simple dictionary of file name extensions to MIME types. The empty string
44# entry is used as the default when no extension was given or if the extension
45# has no entry in this dictionary.
46MIME_TYPE_MAP = {'': 'application/octet-stream',
47 'html': 'text/html',
48 'css': 'text/css',
49 'png': 'image/png',
50 'js': 'application/javascript',
51 'json': 'application/json'
52 }
53
54DEFAULT_ACTUALS_DIR = '.gm-actuals'
55DEFAULT_EXPECTATIONS_DIR = os.path.join(TRUNK_DIRECTORY, 'expectations', 'gm')
56DEFAULT_PORT = 8888
57
58_SERVER = None # This gets filled in by main()
59
60class Server(object):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +000061 """ HTTP server for our HTML rebaseline viewer. """
epoger@google.comf9d134d2013-09-27 15:02:44 +000062
epoger@google.comf9d134d2013-09-27 15:02:44 +000063 def __init__(self,
64 actuals_dir=DEFAULT_ACTUALS_DIR,
65 expectations_dir=DEFAULT_EXPECTATIONS_DIR,
66 port=DEFAULT_PORT, export=False):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +000067 """
68 Args:
69 actuals_dir: directory under which we will check out the latest actual
70 GM results
71 expectations_dir: directory under which to find GM expectations (they
72 must already be in that directory)
73 port: which TCP port to listen on for HTTP requests
74 export: whether to allow HTTP clients on other hosts to access this server
75 """
epoger@google.comf9d134d2013-09-27 15:02:44 +000076 self._actuals_dir = actuals_dir
77 self._expectations_dir = expectations_dir
78 self._port = port
79 self._export = export
80
epoger@google.com9fb6c8a2013-10-09 18:05:58 +000081 def is_exported(self):
82 """ Returns true iff HTTP clients on other hosts are allowed to access
83 this server. """
84 return self._export
85
epoger@google.comf9d134d2013-09-27 15:02:44 +000086 def fetch_results(self):
87 """ Create self.results, based on the expectations in
88 self._expectations_dir and the latest actuals from skia-autogen.
89
90 TODO(epoger): Add a new --browseonly mode setting. In that mode,
91 the gm-actuals and expectations will automatically be updated every few
92 minutes. See discussion in https://codereview.chromium.org/24274003/ .
93 """
94 print 'Checking out latest actual GM results from %s into %s ...' % (
95 ACTUALS_SVN_REPO, self._actuals_dir)
96 actuals_repo = svn.Svn(self._actuals_dir)
97 if not os.path.isdir(self._actuals_dir):
98 os.makedirs(self._actuals_dir)
99 actuals_repo.Checkout(ACTUALS_SVN_REPO, '.')
100 else:
101 actuals_repo.Update('.')
102 print 'Parsing results from actuals in %s and expectations in %s ...' % (
103 self._actuals_dir, self._expectations_dir)
104 self.results = results.Results(
105 actuals_root=self._actuals_dir,
106 expected_root=self._expectations_dir)
107
108 def run(self):
109 self.fetch_results()
110 if self._export:
111 server_address = ('', self._port)
112 print ('WARNING: Running in "export" mode. Users on other machines will '
113 'be able to modify your GM expectations!')
114 else:
115 server_address = ('127.0.0.1', self._port)
116 http_server = BaseHTTPServer.HTTPServer(server_address, HTTPRequestHandler)
117 print 'Ready for requests on http://%s:%d' % (
118 http_server.server_name, http_server.server_port)
119 http_server.serve_forever()
120
121
122class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
123 """ HTTP request handlers for various types of queries this server knows
124 how to handle (static HTML and Javascript, expected/actual results, etc.)
125 """
126 def do_GET(self):
127 """ Handles all GET requests, forwarding them to the appropriate
128 do_GET_* dispatcher. """
129 if self.path == '' or self.path == '/' or self.path == '/index.html' :
130 self.redirect_to('/static/view.html')
131 return
132 if self.path == '/favicon.ico' :
133 self.redirect_to('/static/favicon.ico')
134 return
135
136 # All requests must be of this form:
137 # /dispatcher/remainder
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000138 # where 'dispatcher' indicates which do_GET_* dispatcher to run
139 # and 'remainder' is the remaining path sent to that dispatcher.
epoger@google.comf9d134d2013-09-27 15:02:44 +0000140 normpath = posixpath.normpath(self.path)
141 (dispatcher_name, remainder) = PATHSPLIT_RE.match(normpath).groups()
142 dispatchers = {
143 'results': self.do_GET_results,
144 'static': self.do_GET_static,
145 }
146 dispatcher = dispatchers[dispatcher_name]
147 dispatcher(remainder)
148
149 def do_GET_results(self, result_type):
150 """ Handle a GET request for GM results.
151 For now, we ignore the remaining path info, because we only know how to
152 return all results.
153
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000154 Args:
155 result_type: currently unused
156
epoger@google.comf9d134d2013-09-27 15:02:44 +0000157 TODO(epoger): Unless we start making use of result_type, remove that
158 parameter."""
159 print 'do_GET_results: sending results of type "%s"' % result_type
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000160 # TODO(epoger): Cache response_dict rather than the results object, to save
161 # time on subsequent fetches (no need to regenerate the header, etc.)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000162 response_dict = _SERVER.results.GetAll()
163 if response_dict:
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000164 response_dict['header'] = {
165 # Hash of testData, which the client must return with any edits--
166 # this ensures that the edits were made to a particular dataset.
167 'data-hash': str(hash(repr(response_dict['testData']))),
168
169 # Whether the server will accept edits back.
170 # TODO(epoger): Not yet implemented, so hardcoding to False;
171 # once we implement the 'browseonly' mode discussed in
172 # https://codereview.chromium.org/24274003/#msg6 , this value will vary.
173 'isEditable': False,
174
175 # Whether the service is accessible from other hosts.
176 'isExported': _SERVER.is_exported(),
177 }
epoger@google.comf9d134d2013-09-27 15:02:44 +0000178 self.send_json_dict(response_dict)
179 else:
180 self.send_error(404)
181
182 def do_GET_static(self, path):
epoger@google.comcb55f112013-10-02 19:27:35 +0000183 """ Handle a GET request for a file under the 'static' directory.
184 Only allow serving of files within the 'static' directory that is a
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000185 filesystem sibling of this script.
186
187 Args:
188 path: path to file (under static directory) to retrieve
189 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000190 print 'do_GET_static: sending file "%s"' % path
epoger@google.comcb55f112013-10-02 19:27:35 +0000191 static_dir = os.path.realpath(os.path.join(PARENT_DIRECTORY, 'static'))
192 full_path = os.path.realpath(os.path.join(static_dir, path))
193 if full_path.startswith(static_dir):
194 self.send_file(full_path)
195 else:
196 print ('Attempted do_GET_static() of path [%s] outside of static dir [%s]'
197 % (full_path, static_dir))
198 self.send_error(404)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000199
200 def redirect_to(self, url):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000201 """ Redirect the HTTP client to a different url.
202
203 Args:
204 url: URL to redirect the HTTP client to
205 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000206 self.send_response(301)
207 self.send_header('Location', url)
208 self.end_headers()
209
210 def send_file(self, path):
211 """ Send the contents of the file at this path, with a mimetype based
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000212 on the filename extension.
213
214 Args:
215 path: path of file whose contents to send to the HTTP client
216 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000217 # Grab the extension if there is one
218 extension = os.path.splitext(path)[1]
219 if len(extension) >= 1:
220 extension = extension[1:]
221
222 # Determine the MIME type of the file from its extension
223 mime_type = MIME_TYPE_MAP.get(extension, MIME_TYPE_MAP[''])
224
225 # Open the file and send it over HTTP
226 if os.path.isfile(path):
227 with open(path, 'rb') as sending_file:
228 self.send_response(200)
229 self.send_header('Content-type', mime_type)
230 self.end_headers()
231 self.wfile.write(sending_file.read())
232 else:
233 self.send_error(404)
234
235 def send_json_dict(self, json_dict):
236 """ Send the contents of this dictionary in JSON format, with a JSON
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000237 mimetype.
238
239 Args:
240 json_dict: dictionary to send
241 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000242 self.send_response(200)
243 self.send_header('Content-type', 'application/json')
244 self.end_headers()
245 json.dump(json_dict, self.wfile)
246
247
248def main():
249 parser = argparse.ArgumentParser()
250 parser.add_argument('--actuals-dir',
251 help=('Directory into which we will check out the latest '
252 'actual GM results. If this directory does not '
253 'exist, it will be created. Defaults to %(default)s'),
254 default=DEFAULT_ACTUALS_DIR)
255 parser.add_argument('--expectations-dir',
256 help=('Directory under which to find GM expectations; '
257 'defaults to %(default)s'),
258 default=DEFAULT_EXPECTATIONS_DIR)
259 parser.add_argument('--export', action='store_true',
260 help=('Instead of only allowing access from HTTP clients '
261 'on localhost, allow HTTP clients on other hosts '
262 'to access this server. WARNING: doing so will '
263 'allow users on other hosts to modify your '
264 'GM expectations!'))
epoger@google.comafaad3d2013-09-30 15:06:25 +0000265 parser.add_argument('--port', type=int,
266 help=('Which TCP port to listen on for HTTP requests; '
267 'defaults to %(default)s'),
268 default=DEFAULT_PORT)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000269 args = parser.parse_args()
270 global _SERVER
271 _SERVER = Server(expectations_dir=args.expectations_dir,
272 port=args.port, export=args.export)
273 _SERVER.run()
274
275if __name__ == '__main__':
276 main()