OLD | NEW |
1 #!/usr/bin/python | 1 #!/usr/bin/python |
2 | 2 |
3 """ | 3 """ |
4 Copyright 2013 Google Inc. | 4 Copyright 2013 Google Inc. |
5 | 5 |
6 Use of this source code is governed by a BSD-style license that can be | 6 Use of this source code is governed by a BSD-style license that can be |
7 found in the LICENSE file. | 7 found in the LICENSE file. |
8 | 8 |
9 HTTP server for our HTML rebaseline viewer. | 9 HTTP server for our HTML rebaseline viewer. |
10 """ | 10 """ |
11 | 11 |
12 # System-level imports | 12 # System-level imports |
13 import argparse | 13 import argparse |
14 import BaseHTTPServer | 14 import BaseHTTPServer |
15 import json | 15 import json |
16 import logging | 16 import logging |
17 import os | 17 import os |
18 import posixpath | 18 import posixpath |
19 import re | 19 import re |
20 import shutil | 20 import shutil |
21 import sys | 21 import sys |
| 22 import thread |
| 23 import time |
22 import urlparse | 24 import urlparse |
23 | 25 |
24 # Imports from within Skia | 26 # Imports from within Skia |
25 # | 27 # |
26 # We need to add the 'tools' directory, so that we can import svn.py within | 28 # We need to add the 'tools' directory, so that we can import svn.py within |
27 # that directory. | 29 # that directory. |
28 # Make sure that the 'tools' dir is in the PYTHONPATH, but add it at the *end* | 30 # 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. | 31 # so any dirs that are already in the PYTHONPATH will be preferred. |
30 PARENT_DIRECTORY = os.path.dirname(os.path.realpath(__file__)) | 32 PARENT_DIRECTORY = os.path.dirname(os.path.realpath(__file__)) |
31 TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(PARENT_DIRECTORY)) | 33 TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(PARENT_DIRECTORY)) |
32 TOOLS_DIRECTORY = os.path.join(TRUNK_DIRECTORY, 'tools') | 34 TOOLS_DIRECTORY = os.path.join(TRUNK_DIRECTORY, 'tools') |
33 if TOOLS_DIRECTORY not in sys.path: | 35 if TOOLS_DIRECTORY not in sys.path: |
34 sys.path.append(TOOLS_DIRECTORY) | 36 sys.path.append(TOOLS_DIRECTORY) |
35 import svn | 37 import svn |
36 | 38 |
37 # Imports from local dir | 39 # Imports from local dir |
38 import results | 40 import results |
39 | 41 |
40 ACTUALS_SVN_REPO = 'http://skia-autogen.googlecode.com/svn/gm-actual' | 42 ACTUALS_SVN_REPO = 'http://skia-autogen.googlecode.com/svn/gm-actual' |
| 43 EXPECTATIONS_SVN_REPO = 'http://skia.googlecode.com/svn/trunk/expectations/gm' |
41 PATHSPLIT_RE = re.compile('/([^/]+)/(.+)') | 44 PATHSPLIT_RE = re.compile('/([^/]+)/(.+)') |
42 TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(os.path.dirname( | 45 TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(os.path.dirname( |
43 os.path.realpath(__file__)))) | 46 os.path.realpath(__file__)))) |
44 | 47 |
45 # A simple dictionary of file name extensions to MIME types. The empty string | 48 # 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 | 49 # entry is used as the default when no extension was given or if the extension |
47 # has no entry in this dictionary. | 50 # has no entry in this dictionary. |
48 MIME_TYPE_MAP = {'': 'application/octet-stream', | 51 MIME_TYPE_MAP = {'': 'application/octet-stream', |
49 'html': 'text/html', | 52 'html': 'text/html', |
50 'css': 'text/css', | 53 'css': 'text/css', |
51 'png': 'image/png', | 54 'png': 'image/png', |
52 'js': 'application/javascript', | 55 'js': 'application/javascript', |
53 'json': 'application/json' | 56 'json': 'application/json' |
54 } | 57 } |
55 | 58 |
56 DEFAULT_ACTUALS_DIR = '.gm-actuals' | 59 DEFAULT_ACTUALS_DIR = '.gm-actuals' |
57 DEFAULT_EXPECTATIONS_DIR = os.path.join(TRUNK_DIRECTORY, 'expectations', 'gm') | 60 DEFAULT_EXPECTATIONS_DIR = os.path.join(TRUNK_DIRECTORY, 'expectations', 'gm') |
58 DEFAULT_PORT = 8888 | 61 DEFAULT_PORT = 8888 |
59 | 62 |
60 _SERVER = None # This gets filled in by main() | 63 _SERVER = None # This gets filled in by main() |
61 | 64 |
62 class Server(object): | 65 class Server(object): |
63 """ HTTP server for our HTML rebaseline viewer. """ | 66 """ HTTP server for our HTML rebaseline viewer. """ |
64 | 67 |
65 def __init__(self, | 68 def __init__(self, |
66 actuals_dir=DEFAULT_ACTUALS_DIR, | 69 actuals_dir=DEFAULT_ACTUALS_DIR, |
67 expectations_dir=DEFAULT_EXPECTATIONS_DIR, | 70 expectations_dir=DEFAULT_EXPECTATIONS_DIR, |
68 port=DEFAULT_PORT, export=False): | 71 port=DEFAULT_PORT, export=False, editable=True, |
| 72 reload_seconds=0): |
69 """ | 73 """ |
70 Args: | 74 Args: |
71 actuals_dir: directory under which we will check out the latest actual | 75 actuals_dir: directory under which we will check out the latest actual |
72 GM results | 76 GM results |
73 expectations_dir: directory under which to find GM expectations (they | 77 expectations_dir: directory under which to find GM expectations (they |
74 must already be in that directory) | 78 must already be in that directory) |
75 port: which TCP port to listen on for HTTP requests | 79 port: which TCP port to listen on for HTTP requests |
76 export: whether to allow HTTP clients on other hosts to access this server | 80 export: whether to allow HTTP clients on other hosts to access this server |
| 81 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 |
77 """ | 84 """ |
78 self._actuals_dir = actuals_dir | 85 self._actuals_dir = actuals_dir |
79 self._expectations_dir = expectations_dir | 86 self._expectations_dir = expectations_dir |
80 self._port = port | 87 self._port = port |
81 self._export = export | 88 self._export = export |
| 89 self._editable = editable |
| 90 self._reload_seconds = reload_seconds |
82 | 91 |
83 def is_exported(self): | 92 def is_exported(self): |
84 """ Returns true iff HTTP clients on other hosts are allowed to access | 93 """ Returns true iff HTTP clients on other hosts are allowed to access |
85 this server. """ | 94 this server. """ |
86 return self._export | 95 return self._export |
87 | 96 |
88 def fetch_results(self): | 97 def is_editable(self): |
89 """ Create self.results, based on the expectations in | 98 """ Returns true iff HTTP clients are allowed to submit new baselines. """ |
| 99 return self._editable |
| 100 |
| 101 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 |
90 self._expectations_dir and the latest actuals from skia-autogen. | 108 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 """ | 109 """ |
96 logging.info('Checking out latest actual GM results from %s into %s ...' % ( | 110 logging.info('Updating actual GM results in %s from SVN repo %s ...' % ( |
97 ACTUALS_SVN_REPO, self._actuals_dir)) | 111 self._actuals_dir, ACTUALS_SVN_REPO)) |
98 actuals_repo = svn.Svn(self._actuals_dir) | 112 actuals_repo = svn.Svn(self._actuals_dir) |
99 if not os.path.isdir(self._actuals_dir): | 113 if not os.path.isdir(self._actuals_dir): |
100 os.makedirs(self._actuals_dir) | 114 os.makedirs(self._actuals_dir) |
101 actuals_repo.Checkout(ACTUALS_SVN_REPO, '.') | 115 actuals_repo.Checkout(ACTUALS_SVN_REPO, '.') |
102 else: | 116 else: |
103 actuals_repo.Update('.') | 117 actuals_repo.Update('.') |
| 118 |
| 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 |
104 logging.info( | 135 logging.info( |
105 'Parsing results from actuals in %s and expectations in %s ...' % ( | 136 'Parsing results from actuals in %s and expectations in %s ...' % ( |
106 self._actuals_dir, self._expectations_dir)) | 137 self._actuals_dir, self._expectations_dir)) |
107 self.results = results.Results( | 138 self.results = results.Results( |
108 actuals_root=self._actuals_dir, | 139 actuals_root=self._actuals_dir, |
109 expected_root=self._expectations_dir) | 140 expected_root=self._expectations_dir) |
110 | 141 |
| 142 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 |
111 def run(self): | 151 def run(self): |
112 self.fetch_results() | 152 self._update_results() |
| 153 self.results_lock = thread.allocate_lock() |
| 154 thread.start_new_thread(self._result_reloader, ()) |
| 155 |
113 if self._export: | 156 if self._export: |
114 server_address = ('', self._port) | 157 server_address = ('', self._port) |
115 logging.warning('Running in "export" mode. Users on other machines will ' | 158 if self._editable: |
116 'be able to modify your GM expectations!') | 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!') |
117 else: | 162 else: |
118 server_address = ('127.0.0.1', self._port) | 163 server_address = ('127.0.0.1', self._port) |
119 http_server = BaseHTTPServer.HTTPServer(server_address, HTTPRequestHandler) | 164 http_server = BaseHTTPServer.HTTPServer(server_address, HTTPRequestHandler) |
120 logging.info('Ready for requests on http://%s:%d' % ( | 165 logging.info('Ready for requests on http://%s:%d' % ( |
121 http_server.server_name, http_server.server_port)) | 166 http_server.server_name, http_server.server_port)) |
122 http_server.serve_forever() | 167 http_server.serve_forever() |
123 | 168 |
124 | 169 |
125 class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): | 170 class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): |
126 """ HTTP request handlers for various types of queries this server knows | 171 """ HTTP request handlers for various types of queries this server knows |
(...skipping 28 matching lines...) Expand all Loading... |
155 Args: | 200 Args: |
156 type: string indicating which set of results to return; | 201 type: string indicating which set of results to return; |
157 must be one of the results.RESULTS_* constants | 202 must be one of the results.RESULTS_* constants |
158 """ | 203 """ |
159 logging.debug('do_GET_results: sending results of type "%s"' % type) | 204 logging.debug('do_GET_results: sending results of type "%s"' % type) |
160 try: | 205 try: |
161 # TODO(epoger): Rather than using a global variable for the handler | 206 # TODO(epoger): Rather than using a global variable for the handler |
162 # to refer to the Server object, make Server a subclass of | 207 # to refer to the Server object, make Server a subclass of |
163 # HTTPServer, and then it could be available to the handler via | 208 # HTTPServer, and then it could be available to the handler via |
164 # the handler's .server instance variable. | 209 # the handler's .server instance variable. |
165 response_dict = _SERVER.results.get_results_of_type(type) | 210 |
| 211 with _SERVER.results_lock: |
| 212 response_dict = _SERVER.results.get_results_of_type(type) |
| 213 time_updated = _SERVER.results.get_timestamp() |
166 response_dict['header'] = { | 214 response_dict['header'] = { |
| 215 # 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 |
167 # Hash of testData, which the client must return with any edits-- | 227 # Hash of testData, which the client must return with any edits-- |
168 # this ensures that the edits were made to a particular dataset. | 228 # this ensures that the edits were made to a particular dataset. |
169 'data-hash': str(hash(repr(response_dict['testData']))), | 229 'dataHash': str(hash(repr(response_dict['testData']))), |
170 | 230 |
171 # Whether the server will accept edits back. | 231 # Whether the server will accept edits back. |
172 # TODO(epoger): Not yet implemented, so hardcoding to False; | 232 'isEditable': _SERVER.is_editable(), |
173 # once we implement the 'browseonly' mode discussed in | |
174 # https://codereview.chromium.org/24274003/#msg6 , this value will vary. | |
175 'isEditable': False, | |
176 | 233 |
177 # Whether the service is accessible from other hosts. | 234 # Whether the service is accessible from other hosts. |
178 'isExported': _SERVER.is_exported(), | 235 'isExported': _SERVER.is_exported(), |
179 } | 236 } |
180 self.send_json_dict(response_dict) | 237 self.send_json_dict(response_dict) |
181 except: | 238 except: |
182 self.send_error(404) | 239 self.send_error(404) |
| 240 raise |
183 | 241 |
184 def do_GET_static(self, path): | 242 def do_GET_static(self, path): |
185 """ Handle a GET request for a file under the 'static' directory. | 243 """ Handle a GET request for a file under the 'static' directory. |
186 Only allow serving of files within the 'static' directory that is a | 244 Only allow serving of files within the 'static' directory that is a |
187 filesystem sibling of this script. | 245 filesystem sibling of this script. |
188 | 246 |
189 Args: | 247 Args: |
190 path: path to file (under static directory) to retrieve | 248 path: path to file (under static directory) to retrieve |
191 """ | 249 """ |
192 # Strip arguments ('?resultsToLoad=all') from the path | 250 # Strip arguments ('?resultsToLoad=all') from the path |
(...skipping 59 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
252 | 310 |
253 | 311 |
254 def main(): | 312 def main(): |
255 logging.basicConfig(level=logging.INFO) | 313 logging.basicConfig(level=logging.INFO) |
256 parser = argparse.ArgumentParser() | 314 parser = argparse.ArgumentParser() |
257 parser.add_argument('--actuals-dir', | 315 parser.add_argument('--actuals-dir', |
258 help=('Directory into which we will check out the latest ' | 316 help=('Directory into which we will check out the latest ' |
259 'actual GM results. If this directory does not ' | 317 'actual GM results. If this directory does not ' |
260 'exist, it will be created. Defaults to %(default)s'), | 318 'exist, it will be created. Defaults to %(default)s'), |
261 default=DEFAULT_ACTUALS_DIR) | 319 default=DEFAULT_ACTUALS_DIR) |
| 320 parser.add_argument('--editable', action='store_true', |
| 321 help=('TODO(epoger): NOT YET IMPLEMENTED. ' |
| 322 'Allow HTTP clients to submit new baselines.')) |
262 parser.add_argument('--expectations-dir', | 323 parser.add_argument('--expectations-dir', |
263 help=('Directory under which to find GM expectations; ' | 324 help=('Directory under which to find GM expectations; ' |
264 'defaults to %(default)s'), | 325 'defaults to %(default)s'), |
265 default=DEFAULT_EXPECTATIONS_DIR) | 326 default=DEFAULT_EXPECTATIONS_DIR) |
266 parser.add_argument('--export', action='store_true', | 327 parser.add_argument('--export', action='store_true', |
267 help=('Instead of only allowing access from HTTP clients ' | 328 help=('Instead of only allowing access from HTTP clients ' |
268 'on localhost, allow HTTP clients on other hosts ' | 329 'on localhost, allow HTTP clients on other hosts ' |
269 'to access this server. WARNING: doing so will ' | 330 'to access this server. WARNING: doing so will ' |
270 'allow users on other hosts to modify your ' | 331 'allow users on other hosts to modify your ' |
271 'GM expectations!')) | 332 'GM expectations, if combined with --editable.')) |
272 parser.add_argument('--port', type=int, | 333 parser.add_argument('--port', type=int, |
273 help=('Which TCP port to listen on for HTTP requests; ' | 334 help=('Which TCP port to listen on for HTTP requests; ' |
274 'defaults to %(default)s'), | 335 'defaults to %(default)s'), |
275 default=DEFAULT_PORT) | 336 default=DEFAULT_PORT) |
| 337 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) |
276 args = parser.parse_args() | 344 args = parser.parse_args() |
277 global _SERVER | 345 global _SERVER |
278 _SERVER = Server(expectations_dir=args.expectations_dir, | 346 _SERVER = Server(actuals_dir=args.actuals_dir, |
279 port=args.port, export=args.export) | 347 expectations_dir=args.expectations_dir, |
| 348 port=args.port, export=args.export, editable=args.editable, |
| 349 reload_seconds=args.reload) |
280 _SERVER.run() | 350 _SERVER.run() |
281 | 351 |
282 if __name__ == '__main__': | 352 if __name__ == '__main__': |
283 main() | 353 main() |
OLD | NEW |