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 def _run_command(args, directory): | |
71 """Runs a command and returns stdout as a single string. | |
72 | |
73 Args: | |
74 args: the command to run, as a list of arguments | |
75 directory: directory within which to run the command | |
76 | |
77 Returns: stdout, as a string | |
78 | |
79 Raises an Exception if the command failed (exited with nonzero return code). | |
80 """ | |
81 logging.debug('_run_command: %s in directory %s' % (args, directory)) | |
82 proc = subprocess.Popen(args, cwd=directory, | |
83 stdout=subprocess.PIPE, | |
84 stderr=subprocess.PIPE) | |
85 (stdout, stderr) = proc.communicate() | |
86 if proc.returncode is not 0: | |
87 raise Exception('command "%s" failed in dir "%s": %s' % | |
88 (args, directory, stderr)) | |
89 return stdout | |
90 | |
70 def _get_routable_ip_address(): | 91 def _get_routable_ip_address(): |
71 """Returns routable IP address of this host (the IP address of its network | 92 """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 | 93 interface that would be used for most traffic, not its localhost |
73 interface). See http://stackoverflow.com/a/166589 """ | 94 interface). See http://stackoverflow.com/a/166589 """ |
74 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) | 95 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) |
75 sock.connect(('8.8.8.8', 80)) | 96 sock.connect(('8.8.8.8', 80)) |
76 host = sock.getsockname()[0] | 97 host = sock.getsockname()[0] |
77 sock.close() | 98 sock.close() |
78 return host | 99 return host |
79 | 100 |
(...skipping 33 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
113 if 0, don't check for new results at all | 134 if 0, don't check for new results at all |
114 """ | 135 """ |
115 self._actuals_dir = actuals_dir | 136 self._actuals_dir = actuals_dir |
116 self._port = port | 137 self._port = port |
117 self._export = export | 138 self._export = export |
118 self._editable = editable | 139 self._editable = editable |
119 self._reload_seconds = reload_seconds | 140 self._reload_seconds = reload_seconds |
120 self._actuals_repo = _create_svn_checkout( | 141 self._actuals_repo = _create_svn_checkout( |
121 dir_path=actuals_dir, repo_url=ACTUALS_SVN_REPO) | 142 dir_path=actuals_dir, repo_url=ACTUALS_SVN_REPO) |
122 | 143 |
123 # We only update the expectations dir if the server was run with a | 144 # Reentrant lock that must be held whenever updating EITHER of: |
epoger
2013/11/25 19:43:47
We didn't need this lock before, because the self.
| |
124 # nonzero --reload argument; otherwise, we expect the user to maintain | 145 # 1. self.results |
125 # her own expectations as she sees fit. | 146 # 2. the expected or actual results on local disk |
126 # | 147 self.results_rlock = threading.RLock() |
127 # TODO(epoger): Use git instead of svn to update the expectations dir, since | |
128 # the Skia repo is moving to git. | |
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 | 148 |
139 def is_exported(self): | 149 def is_exported(self): |
140 """ Returns true iff HTTP clients on other hosts are allowed to access | 150 """ Returns true iff HTTP clients on other hosts are allowed to access |
141 this server. """ | 151 this server. """ |
142 return self._export | 152 return self._export |
143 | 153 |
144 def is_editable(self): | 154 def is_editable(self): |
145 """ Returns true iff HTTP clients are allowed to submit new baselines. """ | 155 """ Returns true iff HTTP clients are allowed to submit new baselines. """ |
146 return self._editable | 156 return self._editable |
147 | 157 |
148 def reload_seconds(self): | 158 def reload_seconds(self): |
149 """ Returns the result reload period in seconds, or 0 if we don't reload | 159 """ Returns the result reload period in seconds, or 0 if we don't reload |
150 results. """ | 160 results. """ |
151 return self._reload_seconds | 161 return self._reload_seconds |
152 | 162 |
153 def update_results(self): | 163 def update_results(self): |
154 """ Create or update self.results, based on the expectations in | 164 """ Create or update self.results, based on the expectations in |
155 EXPECTATIONS_DIR and the latest actuals from skia-autogen. | 165 EXPECTATIONS_DIR and the latest actuals from skia-autogen. |
166 | |
167 We hold self.results_rlock while we do this, to guarantee that no other | |
168 thread attempts to update either self.results or the underlying files at | |
169 the same time. | |
156 """ | 170 """ |
157 logging.info('Updating actual GM results in %s from SVN repo %s ...' % ( | 171 with self.results_rlock: |
158 self._actuals_dir, ACTUALS_SVN_REPO)) | 172 logging.info('Updating actual GM results in %s from SVN repo %s ...' % ( |
159 self._actuals_repo.Update('.') | 173 self._actuals_dir, ACTUALS_SVN_REPO)) |
174 self._actuals_repo.Update('.') | |
160 | 175 |
161 if self._expectations_repo: | 176 # We only update the expectations dir if the server was run with a |
177 # nonzero --reload argument; otherwise, we expect the user to maintain | |
178 # her own expectations as she sees fit. | |
179 # | |
180 # Because the Skia repo is moving from SVN to git, and git does not | |
181 # support updating a single directory tree, we have to update the entire | |
182 # repo checkout. | |
183 # | |
184 # Because Skia uses depot_tools, we have to update using "gclient sync" | |
185 # instead of raw git (or SVN) update. Happily, this will work whether | |
186 # the checkout was created using git or SVN. | |
187 if self._reload_seconds: | |
188 logging.info( | |
189 'Updating expected GM results in %s by syncing Skia repo ...' % | |
190 EXPECTATIONS_DIR) | |
191 _run_command(['gclient', 'sync'], TRUNK_DIRECTORY) | |
192 | |
162 logging.info( | 193 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, ' | 194 ('Parsing results from actuals in %s and expectations in %s, ' |
168 + 'and generating pixel diffs (may take a while) ...') % ( | 195 + 'and generating pixel diffs (may take a while) ...') % ( |
169 self._actuals_dir, EXPECTATIONS_DIR)) | 196 self._actuals_dir, EXPECTATIONS_DIR)) |
170 self.results = results.Results( | 197 self.results = results.Results( |
171 actuals_root=self._actuals_dir, | 198 actuals_root=self._actuals_dir, |
172 expected_root=EXPECTATIONS_DIR, | 199 expected_root=EXPECTATIONS_DIR, |
173 generated_images_root=GENERATED_IMAGES_ROOT) | 200 generated_images_root=GENERATED_IMAGES_ROOT) |
174 | 201 |
175 def _result_reloader(self): | 202 def _result_reloader(self): |
176 """ If --reload argument was specified, reload results at the appropriate | 203 """ If --reload argument was specified, reload results at the appropriate |
177 interval. | 204 interval. |
178 """ | 205 """ |
179 while self._reload_seconds: | 206 while self._reload_seconds: |
180 time.sleep(self._reload_seconds) | 207 time.sleep(self._reload_seconds) |
181 self.update_results() | 208 self.update_results() |
182 | 209 |
183 def run(self): | 210 def run(self): |
(...skipping 166 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
350 if content_type != 'application/json;charset=UTF-8': | 377 if content_type != 'application/json;charset=UTF-8': |
351 raise Exception('unsupported %s [%s]' % ( | 378 raise Exception('unsupported %s [%s]' % ( |
352 _HTTP_HEADER_CONTENT_TYPE, content_type)) | 379 _HTTP_HEADER_CONTENT_TYPE, content_type)) |
353 | 380 |
354 content_length = int(self.headers[_HTTP_HEADER_CONTENT_LENGTH]) | 381 content_length = int(self.headers[_HTTP_HEADER_CONTENT_LENGTH]) |
355 json_data = self.rfile.read(content_length) | 382 json_data = self.rfile.read(content_length) |
356 data = json.loads(json_data) | 383 data = json.loads(json_data) |
357 logging.debug('do_POST_edits: received new GM expectations data [%s]' % | 384 logging.debug('do_POST_edits: received new GM expectations data [%s]' % |
358 data) | 385 data) |
359 | 386 |
360 # Since we must make multiple calls to the Results object, grab a | 387 # 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 | 388 # client. |
362 # object within another thread. | 389 # We must hold _SERVER.results_rlock while we do this, to guarantee that |
363 results_obj = _SERVER.results | 390 # no other thread updates expectations (from the Skia repo) while we are |
364 oldResultsType = data['oldResultsType'] | 391 # updating them (using the info we received from the client). |
365 oldResults = results_obj.get_results_of_type(oldResultsType) | 392 with _SERVER.results_rlock: |
366 oldResultsHash = str(hash(repr(oldResults['testData']))) | 393 oldResultsType = data['oldResultsType'] |
367 if oldResultsHash != data['oldResultsHash']: | 394 oldResults = _SERVER.results.get_results_of_type(oldResultsType) |
368 raise Exception('results of type "%s" changed while the client was ' | 395 oldResultsHash = str(hash(repr(oldResults['testData']))) |
369 'making modifications. The client should reload the ' | 396 if oldResultsHash != data['oldResultsHash']: |
370 'results and submit the modifications again.' % | 397 raise Exception('results of type "%s" changed while the client was ' |
371 oldResultsType) | 398 'making modifications. The client should reload the ' |
372 results_obj.edit_expectations(data['modifications']) | 399 'results and submit the modifications again.' % |
373 | 400 oldResultsType) |
374 # Now that the edits have been committed, update results to reflect them. | 401 _SERVER.results.edit_expectations(data['modifications']) |
375 _SERVER.update_results() | 402 # Read the updated results back from disk. |
403 _SERVER.update_results() | |
376 | 404 |
377 def redirect_to(self, url): | 405 def redirect_to(self, url): |
378 """ Redirect the HTTP client to a different url. | 406 """ Redirect the HTTP client to a different url. |
379 | 407 |
380 Args: | 408 Args: |
381 url: URL to redirect the HTTP client to | 409 url: URL to redirect the HTTP client to |
382 """ | 410 """ |
383 self.send_response(301) | 411 self.send_response(301) |
384 self.send_header('Location', url) | 412 self.send_header('Location', url) |
385 self.end_headers() | 413 self.end_headers() |
(...skipping 65 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
451 default=0) | 479 default=0) |
452 args = parser.parse_args() | 480 args = parser.parse_args() |
453 global _SERVER | 481 global _SERVER |
454 _SERVER = Server(actuals_dir=args.actuals_dir, | 482 _SERVER = Server(actuals_dir=args.actuals_dir, |
455 port=args.port, export=args.export, editable=args.editable, | 483 port=args.port, export=args.export, editable=args.editable, |
456 reload_seconds=args.reload) | 484 reload_seconds=args.reload) |
457 _SERVER.run() | 485 _SERVER.run() |
458 | 486 |
459 if __name__ == '__main__': | 487 if __name__ == '__main__': |
460 main() | 488 main() |
OLD | NEW |