server.py revision dcb4e65998913bfb2cc7e331ffacf0965bdee0ea
1#!/usr/bin/python 2 3""" 4Copyright 2013 Google Inc. 5 6Use of this source code is governed by a BSD-style license that can be 7found in the LICENSE file. 8 9HTTP server for our HTML rebaseline viewer. 10""" 11 12# System-level imports 13import argparse 14import BaseHTTPServer 15import json 16import logging 17import os 18import posixpath 19import re 20import shutil 21import sys 22import urlparse 23 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. 30PARENT_DIRECTORY = os.path.dirname(os.path.realpath(__file__)) 31TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(PARENT_DIRECTORY)) 32TOOLS_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): 63 """ HTTP server for our HTML rebaseline viewer. """ 64 65 def __init__(self, 66 actuals_dir=DEFAULT_ACTUALS_DIR, 67 expectations_dir=DEFAULT_EXPECTATIONS_DIR, 68 port=DEFAULT_PORT, export=False): 69 """ 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 """ 78 self._actuals_dir = actuals_dir 79 self._expectations_dir = expectations_dir 80 self._port = port 81 self._export = export 82 83 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 88 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 """ 96 logging.info('Checking out latest actual GM results from %s into %s ...' % ( 97 ACTUALS_SVN_REPO, self._actuals_dir)) 98 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('.') 104 logging.info( 105 'Parsing results from actuals in %s and expectations in %s ...' % ( 106 self._actuals_dir, self._expectations_dir)) 107 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) 115 logging.warning('Running in "export" mode. Users on other machines will ' 116 'be able to modify your GM expectations!') 117 else: 118 server_address = ('127.0.0.1', self._port) 119 http_server = BaseHTTPServer.HTTPServer(server_address, HTTPRequestHandler) 120 logging.info('Ready for requests on http://%s:%d' % ( 121 http_server.server_name, http_server.server_port)) 122 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' : 133 self.redirect_to('/static/view.html?resultsToLoad=all') 134 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 141 # where 'dispatcher' indicates which do_GET_* dispatcher to run 142 # and 'remainder' is the remaining path sent to that dispatcher. 143 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 152 def do_GET_results(self, type): 153 """ Handle a GET request for GM results. 154 155 Args: 156 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) 166 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 } 180 self.send_json_dict(response_dict) 181 except: 182 self.send_error(404) 183 184 def do_GET_static(self, path): 185 """ Handle a GET request for a file under the 'static' directory. 186 Only allow serving of files within the 'static' directory that is a 187 filesystem sibling of this script. 188 189 Args: 190 path: path to file (under static directory) to retrieve 191 """ 192 # Strip arguments ('?resultsToLoad=all') from the path 193 path = urlparse.urlparse(path).path 194 195 logging.debug('do_GET_static: sending file "%s"' % path) 196 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: 201 logging.error( 202 'Attempted do_GET_static() of path [%s] outside of static dir [%s]' 203 % (full_path, static_dir)) 204 self.send_error(404) 205 206 def redirect_to(self, url): 207 """ Redirect the HTTP client to a different url. 208 209 Args: 210 url: URL to redirect the HTTP client to 211 """ 212 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 218 on the filename extension. 219 220 Args: 221 path: path of file whose contents to send to the HTTP client 222 """ 223 # 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 243 mimetype. 244 245 Args: 246 json_dict: dictionary to send 247 """ 248 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(): 255 logging.basicConfig(level=logging.INFO) 256 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!')) 272 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) 276 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() 284