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 socket | 21 import socket |
| 22 import subprocess |
22 import sys | 23 import sys |
23 import thread | 24 import thread |
| 25 import threading |
24 import time | 26 import time |
25 import urlparse | 27 import urlparse |
26 | 28 |
27 # Imports from within Skia | 29 # Imports from within Skia |
28 # | 30 # |
29 # We need to add the 'tools' directory, so that we can import svn.py within | 31 # We need to add the 'tools' directory, so that we can import svn.py within |
30 # that directory. | 32 # that directory. |
31 # Make sure that the 'tools' dir is in the PYTHONPATH, but add it at the *end* | 33 # Make sure that the 'tools' dir is in the PYTHONPATH, but add it at the *end* |
32 # so any dirs that are already in the PYTHONPATH will be preferred. | 34 # so any dirs that are already in the PYTHONPATH will be preferred. |
33 PARENT_DIRECTORY = os.path.dirname(os.path.realpath(__file__)) | 35 PARENT_DIRECTORY = os.path.dirname(os.path.realpath(__file__)) |
34 TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(PARENT_DIRECTORY)) | 36 TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(PARENT_DIRECTORY)) |
35 TOOLS_DIRECTORY = os.path.join(TRUNK_DIRECTORY, 'tools') | 37 TOOLS_DIRECTORY = os.path.join(TRUNK_DIRECTORY, 'tools') |
36 if TOOLS_DIRECTORY not in sys.path: | 38 if TOOLS_DIRECTORY not in sys.path: |
37 sys.path.append(TOOLS_DIRECTORY) | 39 sys.path.append(TOOLS_DIRECTORY) |
38 import svn | 40 import svn |
39 | 41 |
40 # Imports from local dir | 42 # Imports from local dir |
41 import results | 43 import results |
42 | 44 |
43 ACTUALS_SVN_REPO = 'http://skia-autogen.googlecode.com/svn/gm-actual' | 45 ACTUALS_SVN_REPO = 'http://skia-autogen.googlecode.com/svn/gm-actual' |
44 PATHSPLIT_RE = re.compile('/([^/]+)/(.+)') | 46 PATHSPLIT_RE = re.compile('/([^/]+)/(.+)') |
45 TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(os.path.dirname( | |
46 os.path.realpath(__file__)))) | |
47 EXPECTATIONS_DIR = os.path.join(TRUNK_DIRECTORY, 'expectations', 'gm') | 47 EXPECTATIONS_DIR = os.path.join(TRUNK_DIRECTORY, 'expectations', 'gm') |
48 GENERATED_IMAGES_ROOT = os.path.join(PARENT_DIRECTORY, 'static', | 48 GENERATED_IMAGES_ROOT = os.path.join(PARENT_DIRECTORY, 'static', |
49 'generated-images') | 49 'generated-images') |
50 | 50 |
51 # A simple dictionary of file name extensions to MIME types. The empty string | 51 # A simple dictionary of file name extensions to MIME types. The empty string |
52 # entry is used as the default when no extension was given or if the extension | 52 # entry is used as the default when no extension was given or if the extension |
53 # has no entry in this dictionary. | 53 # has no entry in this dictionary. |
54 MIME_TYPE_MAP = {'': 'application/octet-stream', | 54 MIME_TYPE_MAP = {'': 'application/octet-stream', |
55 'html': 'text/html', | 55 'html': 'text/html', |
56 'css': 'text/css', | 56 'css': 'text/css', |
57 'png': 'image/png', | 57 'png': 'image/png', |
58 'js': 'application/javascript', | 58 'js': 'application/javascript', |
59 'json': 'application/json' | 59 'json': 'application/json' |
60 } | 60 } |
61 | 61 |
62 DEFAULT_ACTUALS_DIR = '.gm-actuals' | 62 DEFAULT_ACTUALS_DIR = '.gm-actuals' |
63 DEFAULT_PORT = 8888 | 63 DEFAULT_PORT = 8888 |
64 | 64 |
65 _HTTP_HEADER_CONTENT_LENGTH = 'Content-Length' | 65 _HTTP_HEADER_CONTENT_LENGTH = 'Content-Length' |
66 _HTTP_HEADER_CONTENT_TYPE = 'Content-Type' | 66 _HTTP_HEADER_CONTENT_TYPE = 'Content-Type' |
67 | 67 |
68 _SERVER = None # This gets filled in by main() | 68 _SERVER = None # This gets filled in by main() |
69 | 69 |
| 70 |
| 71 def _run_command(args, directory): |
| 72 """Runs a command and returns stdout as a single string. |
| 73 |
| 74 Args: |
| 75 args: the command to run, as a list of arguments |
| 76 directory: directory within which to run the command |
| 77 |
| 78 Returns: stdout, as a string |
| 79 |
| 80 Raises an Exception if the command failed (exited with nonzero return code). |
| 81 """ |
| 82 logging.debug('_run_command: %s in directory %s' % (args, directory)) |
| 83 proc = subprocess.Popen(args, cwd=directory, |
| 84 stdout=subprocess.PIPE, |
| 85 stderr=subprocess.PIPE) |
| 86 (stdout, stderr) = proc.communicate() |
| 87 if proc.returncode is not 0: |
| 88 raise Exception('command "%s" failed in dir "%s": %s' % |
| 89 (args, directory, stderr)) |
| 90 return stdout |
| 91 |
| 92 |
70 def _get_routable_ip_address(): | 93 def _get_routable_ip_address(): |
71 """Returns routable IP address of this host (the IP address of its network | 94 """Returns routable IP address of this host (the IP address of its network |
72 interface that would be used for most traffic, not its localhost | 95 interface that would be used for most traffic, not its localhost |
73 interface). See http://stackoverflow.com/a/166589 """ | 96 interface). See http://stackoverflow.com/a/166589 """ |
74 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) | 97 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) |
75 sock.connect(('8.8.8.8', 80)) | 98 sock.connect(('8.8.8.8', 80)) |
76 host = sock.getsockname()[0] | 99 host = sock.getsockname()[0] |
77 sock.close() | 100 sock.close() |
78 return host | 101 return host |
79 | 102 |
| 103 |
80 def _create_svn_checkout(dir_path, repo_url): | 104 def _create_svn_checkout(dir_path, repo_url): |
81 """Creates local checkout of an SVN repository at the specified directory | 105 """Creates local checkout of an SVN repository at the specified directory |
82 path, returning an svn.Svn object referring to the local checkout. | 106 path, returning an svn.Svn object referring to the local checkout. |
83 | 107 |
84 Args: | 108 Args: |
85 dir_path: path to the local checkout; if this directory does not yet exist, | 109 dir_path: path to the local checkout; if this directory does not yet exist, |
86 it will be created and the repo will be checked out into it | 110 it will be created and the repo will be checked out into it |
87 repo_url: URL of SVN repo to check out into dir_path (unless the local | 111 repo_url: URL of SVN repo to check out into dir_path (unless the local |
88 checkout already exists) | 112 checkout already exists) |
89 Returns: an svn.Svn object referring to the local checkout. | 113 Returns: an svn.Svn object referring to the local checkout. |
(...skipping 23 matching lines...) Expand all Loading... |
113 if 0, don't check for new results at all | 137 if 0, don't check for new results at all |
114 """ | 138 """ |
115 self._actuals_dir = actuals_dir | 139 self._actuals_dir = actuals_dir |
116 self._port = port | 140 self._port = port |
117 self._export = export | 141 self._export = export |
118 self._editable = editable | 142 self._editable = editable |
119 self._reload_seconds = reload_seconds | 143 self._reload_seconds = reload_seconds |
120 self._actuals_repo = _create_svn_checkout( | 144 self._actuals_repo = _create_svn_checkout( |
121 dir_path=actuals_dir, repo_url=ACTUALS_SVN_REPO) | 145 dir_path=actuals_dir, repo_url=ACTUALS_SVN_REPO) |
122 | 146 |
123 # We only update the expectations dir if the server was run with a | 147 # Reentrant lock that must be held whenever updating EITHER of: |
124 # nonzero --reload argument; otherwise, we expect the user to maintain | 148 # 1. self._results |
125 # her own expectations as she sees fit. | 149 # 2. the expected or actual results on local disk |
126 # | 150 self.results_rlock = threading.RLock() |
127 # TODO(epoger): Use git instead of svn to update the expectations dir, since | 151 # self._results will be filled in by calls to update_results() |
128 # the Skia repo is moving to git. | 152 self._results = None |
129 # When we make that change, we will have to update the entire workspace, | |
130 # not just the expectations dir, because git only operates on the repo | |
131 # as a whole. | |
132 # And since Skia uses depot_tools to manage its dependencies, we will have | |
133 # to run "gclient sync" rather than a raw "git pull". | |
134 if reload_seconds: | |
135 self._expectations_repo = svn.Svn(EXPECTATIONS_DIR) | |
136 else: | |
137 self._expectations_repo = None | |
138 | 153 |
| 154 @property |
| 155 def results(self): |
| 156 """ Returns the most recently generated results, or None if update_results() |
| 157 has not been called yet. """ |
| 158 return self._results |
| 159 |
| 160 @property |
139 def is_exported(self): | 161 def is_exported(self): |
140 """ Returns true iff HTTP clients on other hosts are allowed to access | 162 """ Returns true iff HTTP clients on other hosts are allowed to access |
141 this server. """ | 163 this server. """ |
142 return self._export | 164 return self._export |
143 | 165 |
| 166 @property |
144 def is_editable(self): | 167 def is_editable(self): |
145 """ Returns true iff HTTP clients are allowed to submit new baselines. """ | 168 """ Returns true iff HTTP clients are allowed to submit new baselines. """ |
146 return self._editable | 169 return self._editable |
147 | 170 |
| 171 @property |
148 def reload_seconds(self): | 172 def reload_seconds(self): |
149 """ Returns the result reload period in seconds, or 0 if we don't reload | 173 """ Returns the result reload period in seconds, or 0 if we don't reload |
150 results. """ | 174 results. """ |
151 return self._reload_seconds | 175 return self._reload_seconds |
152 | 176 |
153 def update_results(self): | 177 def update_results(self): |
154 """ Create or update self.results, based on the expectations in | 178 """ Create or update self._results, based on the expectations in |
155 EXPECTATIONS_DIR and the latest actuals from skia-autogen. | 179 EXPECTATIONS_DIR and the latest actuals from skia-autogen. |
| 180 |
| 181 We hold self.results_rlock while we do this, to guarantee that no other |
| 182 thread attempts to update either self._results or the underlying files at |
| 183 the same time. |
156 """ | 184 """ |
157 logging.info('Updating actual GM results in %s from SVN repo %s ...' % ( | 185 with self.results_rlock: |
158 self._actuals_dir, ACTUALS_SVN_REPO)) | 186 logging.info('Updating actual GM results in %s from SVN repo %s ...' % ( |
159 self._actuals_repo.Update('.') | 187 self._actuals_dir, ACTUALS_SVN_REPO)) |
| 188 self._actuals_repo.Update('.') |
160 | 189 |
161 if self._expectations_repo: | 190 # We only update the expectations dir if the server was run with a |
| 191 # nonzero --reload argument; otherwise, we expect the user to maintain |
| 192 # her own expectations as she sees fit. |
| 193 # |
| 194 # Because the Skia repo is moving from SVN to git, and git does not |
| 195 # support updating a single directory tree, we have to update the entire |
| 196 # repo checkout. |
| 197 # |
| 198 # Because Skia uses depot_tools, we have to update using "gclient sync" |
| 199 # instead of raw git (or SVN) update. Happily, this will work whether |
| 200 # the checkout was created using git or SVN. |
| 201 if self._reload_seconds: |
| 202 logging.info( |
| 203 'Updating expected GM results in %s by syncing Skia repo ...' % |
| 204 EXPECTATIONS_DIR) |
| 205 _run_command(['gclient', 'sync'], TRUNK_DIRECTORY) |
| 206 |
162 logging.info( | 207 logging.info( |
163 'Updating expected GM results in %s ...' % EXPECTATIONS_DIR) | |
164 self._expectations_repo.Update('.') | |
165 | |
166 logging.info( | |
167 ('Parsing results from actuals in %s and expectations in %s, ' | 208 ('Parsing results from actuals in %s and expectations in %s, ' |
168 + 'and generating pixel diffs (may take a while) ...') % ( | 209 + 'and generating pixel diffs (may take a while) ...') % ( |
169 self._actuals_dir, EXPECTATIONS_DIR)) | 210 self._actuals_dir, EXPECTATIONS_DIR)) |
170 self.results = results.Results( | 211 self._results = results.Results( |
171 actuals_root=self._actuals_dir, | 212 actuals_root=self._actuals_dir, |
172 expected_root=EXPECTATIONS_DIR, | 213 expected_root=EXPECTATIONS_DIR, |
173 generated_images_root=GENERATED_IMAGES_ROOT) | 214 generated_images_root=GENERATED_IMAGES_ROOT) |
174 | 215 |
175 def _result_reloader(self): | 216 def _result_reloader(self): |
176 """ If --reload argument was specified, reload results at the appropriate | 217 """ Reload results at the appropriate interval. This never exits, so it |
177 interval. | 218 should be run in its own thread. |
178 """ | 219 """ |
179 while self._reload_seconds: | 220 while True: |
180 time.sleep(self._reload_seconds) | 221 time.sleep(self._reload_seconds) |
181 self.update_results() | 222 self.update_results() |
182 | 223 |
183 def run(self): | 224 def run(self): |
184 self.update_results() | 225 self.update_results() |
185 thread.start_new_thread(self._result_reloader, ()) | 226 if self._reload_seconds: |
| 227 thread.start_new_thread(self._result_reloader, ()) |
186 | 228 |
187 if self._export: | 229 if self._export: |
188 server_address = ('', self._port) | 230 server_address = ('', self._port) |
189 host = _get_routable_ip_address() | 231 host = _get_routable_ip_address() |
190 if self._editable: | 232 if self._editable: |
191 logging.warning('Running with combination of "export" and "editable" ' | 233 logging.warning('Running with combination of "export" and "editable" ' |
192 'flags. Users on other machines will ' | 234 'flags. Users on other machines will ' |
193 'be able to modify your GM expectations!') | 235 'be able to modify your GM expectations!') |
194 else: | 236 else: |
195 host = '127.0.0.1' | 237 host = '127.0.0.1' |
(...skipping 53 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
249 time_updated = results_obj.get_timestamp() | 291 time_updated = results_obj.get_timestamp() |
250 | 292 |
251 response_dict['header'] = { | 293 response_dict['header'] = { |
252 # Timestamps: | 294 # Timestamps: |
253 # 1. when this data was last updated | 295 # 1. when this data was last updated |
254 # 2. when the caller should check back for new data (if ever) | 296 # 2. when the caller should check back for new data (if ever) |
255 # | 297 # |
256 # We only return these timestamps if the --reload argument was passed; | 298 # We only return these timestamps if the --reload argument was passed; |
257 # otherwise, we have no idea when the expectations were last updated | 299 # otherwise, we have no idea when the expectations were last updated |
258 # (we allow the user to maintain her own expectations as she sees fit). | 300 # (we allow the user to maintain her own expectations as she sees fit). |
259 'timeUpdated': time_updated if _SERVER.reload_seconds() else None, | 301 'timeUpdated': time_updated if _SERVER.reload_seconds else None, |
260 'timeNextUpdateAvailable': ( | 302 'timeNextUpdateAvailable': ( |
261 (time_updated+_SERVER.reload_seconds()) if _SERVER.reload_seconds() | 303 (time_updated+_SERVER.reload_seconds) if _SERVER.reload_seconds |
262 else None), | 304 else None), |
263 | 305 |
264 # The type we passed to get_results_of_type() | 306 # The type we passed to get_results_of_type() |
265 'type': type, | 307 'type': type, |
266 | 308 |
267 # Hash of testData, which the client must return with any edits-- | 309 # Hash of testData, which the client must return with any edits-- |
268 # this ensures that the edits were made to a particular dataset. | 310 # this ensures that the edits were made to a particular dataset. |
269 'dataHash': str(hash(repr(response_dict['testData']))), | 311 'dataHash': str(hash(repr(response_dict['testData']))), |
270 | 312 |
271 # Whether the server will accept edits back. | 313 # Whether the server will accept edits back. |
272 'isEditable': _SERVER.is_editable(), | 314 'isEditable': _SERVER.is_editable, |
273 | 315 |
274 # Whether the service is accessible from other hosts. | 316 # Whether the service is accessible from other hosts. |
275 'isExported': _SERVER.is_exported(), | 317 'isExported': _SERVER.is_exported, |
276 } | 318 } |
277 self.send_json_dict(response_dict) | 319 self.send_json_dict(response_dict) |
278 except: | 320 except: |
279 self.send_error(404) | 321 self.send_error(404) |
280 raise | 322 raise |
281 | 323 |
282 def do_GET_static(self, path): | 324 def do_GET_static(self, path): |
283 """ Handle a GET request for a file under the 'static' directory. | 325 """ Handle a GET request for a file under the 'static' directory. |
284 Only allow serving of files within the 'static' directory that is a | 326 Only allow serving of files within the 'static' directory that is a |
285 filesystem sibling of this script. | 327 filesystem sibling of this script. |
(...skipping 50 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
336 'config': 'gpu', | 378 'config': 'gpu', |
337 'expectedHashType': 'bitmap-64bitMD5', | 379 'expectedHashType': 'bitmap-64bitMD5', |
338 'expectedHashDigest': '1707359671708613629', | 380 'expectedHashDigest': '1707359671708613629', |
339 }, | 381 }, |
340 ... | 382 ... |
341 ], | 383 ], |
342 } | 384 } |
343 | 385 |
344 Raises an Exception if there were any problems. | 386 Raises an Exception if there were any problems. |
345 """ | 387 """ |
346 if not _SERVER.is_editable(): | 388 if not _SERVER.is_editable: |
347 raise Exception('this server is not running in --editable mode') | 389 raise Exception('this server is not running in --editable mode') |
348 | 390 |
349 content_type = self.headers[_HTTP_HEADER_CONTENT_TYPE] | 391 content_type = self.headers[_HTTP_HEADER_CONTENT_TYPE] |
350 if content_type != 'application/json;charset=UTF-8': | 392 if content_type != 'application/json;charset=UTF-8': |
351 raise Exception('unsupported %s [%s]' % ( | 393 raise Exception('unsupported %s [%s]' % ( |
352 _HTTP_HEADER_CONTENT_TYPE, content_type)) | 394 _HTTP_HEADER_CONTENT_TYPE, content_type)) |
353 | 395 |
354 content_length = int(self.headers[_HTTP_HEADER_CONTENT_LENGTH]) | 396 content_length = int(self.headers[_HTTP_HEADER_CONTENT_LENGTH]) |
355 json_data = self.rfile.read(content_length) | 397 json_data = self.rfile.read(content_length) |
356 data = json.loads(json_data) | 398 data = json.loads(json_data) |
357 logging.debug('do_POST_edits: received new GM expectations data [%s]' % | 399 logging.debug('do_POST_edits: received new GM expectations data [%s]' % |
358 data) | 400 data) |
359 | 401 |
360 # Since we must make multiple calls to the Results object, grab a | 402 # Update the results on disk with the information we received from the |
361 # reference to it in case it is updated to point at a new Results | 403 # client. |
362 # object within another thread. | 404 # We must hold _SERVER.results_rlock while we do this, to guarantee that |
363 results_obj = _SERVER.results | 405 # no other thread updates expectations (from the Skia repo) while we are |
364 oldResultsType = data['oldResultsType'] | 406 # updating them (using the info we received from the client). |
365 oldResults = results_obj.get_results_of_type(oldResultsType) | 407 with _SERVER.results_rlock: |
366 oldResultsHash = str(hash(repr(oldResults['testData']))) | 408 oldResultsType = data['oldResultsType'] |
367 if oldResultsHash != data['oldResultsHash']: | 409 oldResults = _SERVER.results.get_results_of_type(oldResultsType) |
368 raise Exception('results of type "%s" changed while the client was ' | 410 oldResultsHash = str(hash(repr(oldResults['testData']))) |
369 'making modifications. The client should reload the ' | 411 if oldResultsHash != data['oldResultsHash']: |
370 'results and submit the modifications again.' % | 412 raise Exception('results of type "%s" changed while the client was ' |
371 oldResultsType) | 413 'making modifications. The client should reload the ' |
372 results_obj.edit_expectations(data['modifications']) | 414 'results and submit the modifications again.' % |
373 | 415 oldResultsType) |
374 # Now that the edits have been committed, update results to reflect them. | 416 _SERVER.results.edit_expectations(data['modifications']) |
375 _SERVER.update_results() | 417 # Read the updated results back from disk. |
| 418 _SERVER.update_results() |
376 | 419 |
377 def redirect_to(self, url): | 420 def redirect_to(self, url): |
378 """ Redirect the HTTP client to a different url. | 421 """ Redirect the HTTP client to a different url. |
379 | 422 |
380 Args: | 423 Args: |
381 url: URL to redirect the HTTP client to | 424 url: URL to redirect the HTTP client to |
382 """ | 425 """ |
383 self.send_response(301) | 426 self.send_response(301) |
384 self.send_header('Location', url) | 427 self.send_header('Location', url) |
385 self.end_headers() | 428 self.end_headers() |
(...skipping 52 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
438 'to access this server. WARNING: doing so will ' | 481 'to access this server. WARNING: doing so will ' |
439 'allow users on other hosts to modify your ' | 482 'allow users on other hosts to modify your ' |
440 'GM expectations, if combined with --editable.')) | 483 'GM expectations, if combined with --editable.')) |
441 parser.add_argument('--port', type=int, | 484 parser.add_argument('--port', type=int, |
442 help=('Which TCP port to listen on for HTTP requests; ' | 485 help=('Which TCP port to listen on for HTTP requests; ' |
443 'defaults to %(default)s'), | 486 'defaults to %(default)s'), |
444 default=DEFAULT_PORT) | 487 default=DEFAULT_PORT) |
445 parser.add_argument('--reload', type=int, | 488 parser.add_argument('--reload', type=int, |
446 help=('How often (a period in seconds) to update the ' | 489 help=('How often (a period in seconds) to update the ' |
447 'results. If specified, both expected and actual ' | 490 'results. If specified, both expected and actual ' |
448 'results will be updated. ' | 491 'results will be updated by running "gclient sync" ' |
| 492 'on your Skia checkout as a whole. ' |
449 'By default, we do not reload at all, and you ' | 493 'By default, we do not reload at all, and you ' |
450 'must restart the server to pick up new data.'), | 494 'must restart the server to pick up new data.'), |
451 default=0) | 495 default=0) |
452 args = parser.parse_args() | 496 args = parser.parse_args() |
453 global _SERVER | 497 global _SERVER |
454 _SERVER = Server(actuals_dir=args.actuals_dir, | 498 _SERVER = Server(actuals_dir=args.actuals_dir, |
455 port=args.port, export=args.export, editable=args.editable, | 499 port=args.port, export=args.export, editable=args.editable, |
456 reload_seconds=args.reload) | 500 reload_seconds=args.reload) |
457 _SERVER.run() | 501 _SERVER.run() |
458 | 502 |
| 503 |
459 if __name__ == '__main__': | 504 if __name__ == '__main__': |
460 main() | 505 main() |
OLD | NEW |