Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(77)

Side by Side Diff: gm/rebaseline_server/server.py

Issue 86343002: rebaseline_server: make --reload work in git checkout (Closed) Base URL: http://skia.googlecode.com/svn/trunk/
Patch Set: ravi_suggestions Created 7 years ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch | Annotate | Revision Log
« no previous file with comments | « no previous file | no next file » | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
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
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
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
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
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()
OLDNEW
« no previous file with comments | « no previous file | no next file » | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698