4 Copyright 2013 Google Inc.
6 Use of this source code is governed by a BSD-style license that can be
7 found in the LICENSE file.
9 HTTP server for our HTML rebaseline viewer.
12 # System-level imports
29 # Must fix up PYTHONPATH before importing from within Skia
30 import rs_fixpypath # pylint: disable=W0611
32 # Imports from within Skia
33 from py.utils import gs_utils
34 import buildbot_globals
37 # Imports from local dir
39 # pylint: disable=C0301
40 # Note: we import results under a different name, to avoid confusion with the
41 # Server.results() property. See discussion at
42 # https://codereview.chromium.org/195943004/diff/1/gm/rebaseline_server/server.py#newcode44
43 # pylint: enable=C0301
44 import compare_configs
45 import compare_rendered_pictures
46 import compare_to_expectations
47 import download_actuals
50 import results as results_mod
51 import writable_expectations as writable_expectations_mod
54 PATHSPLIT_RE = re.compile('/([^/]+)/(.+)')
56 # A simple dictionary of file name extensions to MIME types. The empty string
57 # entry is used as the default when no extension was given or if the extension
58 # has no entry in this dictionary.
59 MIME_TYPE_MAP = {'': 'application/octet-stream',
63 'js': 'application/javascript',
64 'json': 'application/json'
67 # Keys that server.py uses to create the toplevel content header.
68 # NOTE: Keep these in sync with static/constants.js
69 KEY__EDITS__MODIFICATIONS = 'modifications'
70 KEY__EDITS__OLD_RESULTS_HASH = 'oldResultsHash'
71 KEY__EDITS__OLD_RESULTS_TYPE = 'oldResultsType'
72 KEY__LIVE_EDITS__MODIFICATIONS = 'modifications'
73 KEY__LIVE_EDITS__SET_A_DESCRIPTIONS = 'setA'
74 KEY__LIVE_EDITS__SET_B_DESCRIPTIONS = 'setB'
76 DEFAULT_ACTUALS_DIR = results_mod.DEFAULT_ACTUALS_DIR
77 DEFAULT_GM_SUMMARIES_BUCKET = download_actuals.GM_SUMMARIES_BUCKET
78 DEFAULT_JSON_FILENAME = download_actuals.DEFAULT_JSON_FILENAME
81 PARENT_DIRECTORY = os.path.dirname(os.path.realpath(__file__))
82 TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(PARENT_DIRECTORY))
84 # Directory, relative to PARENT_DIRECTORY, within which the server will serve
86 STATIC_CONTENTS_SUBDIR = 'static'
87 # All of the GENERATED_*_SUBDIRS are relative to STATIC_CONTENTS_SUBDIR
88 GENERATED_HTML_SUBDIR = 'generated-html'
89 GENERATED_IMAGES_SUBDIR = 'generated-images'
90 GENERATED_JSON_SUBDIR = 'generated-json'
92 # Directives associated with various HTTP GET requests.
93 GET__LIVE_RESULTS = 'live-results'
94 GET__PRECOMPUTED_RESULTS = 'results'
95 GET__PREFETCH_RESULTS = 'prefetch'
96 GET__STATIC_CONTENTS = 'static'
98 # Parameters we use within do_GET_live_results() and do_GET_prefetch_results()
99 LIVE_PARAM__DOWNLOAD_ONLY_DIFFERING = 'downloadOnlyDifferingImages'
100 LIVE_PARAM__SET_A_DIR = 'setADir'
101 LIVE_PARAM__SET_A_SECTION = 'setASection'
102 LIVE_PARAM__SET_B_DIR = 'setBDir'
103 LIVE_PARAM__SET_B_SECTION = 'setBSection'
105 # How often (in seconds) clients should reload while waiting for initial
107 RELOAD_INTERVAL_UNTIL_READY = 10
109 _GM_SUMMARY_TYPES = [
110 results_mod.KEY__HEADER__RESULTS_FAILURES,
111 results_mod.KEY__HEADER__RESULTS_ALL,
113 # If --compare-configs is specified, compare these configs.
114 CONFIG_PAIRS_TO_COMPARE = [('8888', 'gpu')]
116 # SKP results that are available to compare.
118 # TODO(stephana): We don't actually want to maintain this list of platforms.
119 # We are just putting them in here for now, as "convenience" links for testing
121 # Ultimately, we will depend on buildbot steps linking to their own diffs on
122 # the shared rebaseline_server instance.
123 _SKP_BASE_GS_URL = 'gs://' + buildbot_globals.Get('skp_summaries_bucket')
124 _SKP_BASE_REPO_URL = (
125 compare_rendered_pictures.REPO_URL_PREFIX + posixpath.join(
126 'expectations', 'skp'))
128 'Test-Mac10.8-MacMini4.1-GeForce320M-x86_64-Debug',
129 'Test-Ubuntu12-ShuttleA-GTX660-x86-Release',
132 _HTTP_HEADER_CONTENT_LENGTH = 'Content-Length'
133 _HTTP_HEADER_CONTENT_TYPE = 'Content-Type'
135 _SERVER = None # This gets filled in by main()
138 def _run_command(args, directory):
139 """Runs a command and returns stdout as a single string.
142 args: the command to run, as a list of arguments
143 directory: directory within which to run the command
145 Returns: stdout, as a string
147 Raises an Exception if the command failed (exited with nonzero return code).
149 logging.debug('_run_command: %s in directory %s' % (args, directory))
150 proc = subprocess.Popen(args, cwd=directory,
151 stdout=subprocess.PIPE,
152 stderr=subprocess.PIPE)
153 (stdout, stderr) = proc.communicate()
154 if proc.returncode is not 0:
155 raise Exception('command "%s" failed in dir "%s": %s' %
156 (args, directory, stderr))
160 def _get_routable_ip_address():
161 """Returns routable IP address of this host (the IP address of its network
162 interface that would be used for most traffic, not its localhost
163 interface). See http://stackoverflow.com/a/166589 """
164 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
165 sock.connect(('8.8.8.8', 80))
166 host = sock.getsockname()[0]
171 def _create_index(file_path, config_pairs):
172 """Creates an index file linking to all results available from this server.
174 Prior to https://codereview.chromium.org/215503002 , we had a static
175 index.html within our repo. But now that the results may or may not include
176 config comparisons, index.html needs to be generated differently depending
177 on which results are included.
179 TODO(epoger): Instead of including raw HTML within the Python code,
180 consider restoring the index.html file as a template and using django (or
181 similar) to fill in dynamic content.
184 file_path: path on local disk to write index to; any directory components
185 of this path that do not already exist will be created
186 config_pairs: what pairs of configs (if any) we compare actual results of
188 dir_path = os.path.dirname(file_path)
189 if not os.path.isdir(dir_path):
190 os.makedirs(dir_path)
191 with open(file_path, 'w') as file_handle:
193 '<!DOCTYPE html><html>'
194 '<head><title>rebaseline_server</title></head>'
197 if _GM_SUMMARY_TYPES:
198 file_handle.write('<li>GM Expectations vs Actuals</li><ul>')
199 for summary_type in _GM_SUMMARY_TYPES:
201 '\n<li><a href="/{static_directive}/view.html#/view.html?'
202 'resultsToLoad=/{results_directive}/{summary_type}">'
203 '{summary_type}</a></li>'.format(
204 results_directive=GET__PRECOMPUTED_RESULTS,
205 static_directive=GET__STATIC_CONTENTS,
206 summary_type=summary_type))
207 file_handle.write('</ul>')
211 '\n<li>Comparing configs within actual GM results</li><ul>')
212 for config_pair in config_pairs:
213 file_handle.write('<li>%s vs %s:' % config_pair)
214 for summary_type in _GM_SUMMARY_TYPES:
216 ' <a href="/%s/view.html#/view.html?'
217 'resultsToLoad=/%s/%s/%s-vs-%s_%s.json">%s</a>' % (
218 GET__STATIC_CONTENTS, GET__STATIC_CONTENTS,
219 GENERATED_JSON_SUBDIR, config_pair[0], config_pair[1],
220 summary_type, summary_type))
221 file_handle.write('</li>')
222 file_handle.write('</ul>')
225 file_handle.write('\n<li>Rendered SKPs:<ul>')
226 for builder in _SKP_PLATFORMS:
228 '\n<li><a href="../live-view.html#live-view.html?%s">' %
230 LIVE_PARAM__SET_A_SECTION:
231 gm_json.JSONKEY_EXPECTEDRESULTS,
232 LIVE_PARAM__SET_A_DIR:
233 posixpath.join(_SKP_BASE_REPO_URL, builder),
234 LIVE_PARAM__SET_B_SECTION:
235 gm_json.JSONKEY_ACTUALRESULTS,
236 LIVE_PARAM__SET_B_DIR:
237 posixpath.join(_SKP_BASE_GS_URL, builder),
239 file_handle.write('expected vs actuals on %s</a></li>' % builder)
241 '\n<li><a href="../live-view.html#live-view.html?%s">' %
243 LIVE_PARAM__SET_A_SECTION:
244 gm_json.JSONKEY_ACTUALRESULTS,
245 LIVE_PARAM__SET_A_DIR:
246 posixpath.join(_SKP_BASE_GS_URL, _SKP_PLATFORMS[0]),
247 LIVE_PARAM__SET_B_SECTION:
248 gm_json.JSONKEY_ACTUALRESULTS,
249 LIVE_PARAM__SET_B_DIR:
250 posixpath.join(_SKP_BASE_GS_URL, _SKP_PLATFORMS[1]),
252 file_handle.write('actuals on %s vs %s</a></li>' % (
253 _SKP_PLATFORMS[0], _SKP_PLATFORMS[1]))
254 file_handle.write('</li>')
256 file_handle.write('\n</ul></body></html>')
259 class Server(object):
260 """ HTTP server for our HTML rebaseline viewer. """
263 actuals_dir=DEFAULT_ACTUALS_DIR,
264 json_filename=DEFAULT_JSON_FILENAME,
265 gm_summaries_bucket=DEFAULT_GM_SUMMARIES_BUCKET,
266 port=DEFAULT_PORT, export=False, editable=True,
267 reload_seconds=0, config_pairs=None, builder_regex_list=None,
269 imagediffdb_threads=imagediffdb.DEFAULT_NUM_WORKER_THREADS):
272 actuals_dir: directory under which we will check out the latest actual
274 json_filename: basename of the JSON summary file to load for each builder
275 gm_summaries_bucket: Google Storage bucket to download json_filename
276 files from; if None or '', don't fetch new actual-results files
277 at all, just compare to whatever files are already in actuals_dir
278 port: which TCP port to listen on for HTTP requests
279 export: whether to allow HTTP clients on other hosts to access this server
280 editable: whether HTTP clients are allowed to submit new GM baselines
281 (SKP baseline modifications are performed using an entirely different
282 mechanism, not affected by this parameter)
283 reload_seconds: polling interval with which to check for new results;
284 if 0, don't check for new results at all
285 config_pairs: List of (string, string) tuples; for each tuple, compare
286 actual results of these two configs. If None or empty,
287 don't compare configs at all.
288 builder_regex_list: List of regular expressions specifying which builders
289 we will process. If None, process all builders.
290 boto_file_path: Path to .boto file giving us credentials to access
291 Google Storage buckets; if None, we will only be able to access
293 imagediffdb_threads: How many threads to spin up within imagediffdb.
295 self._actuals_dir = actuals_dir
296 self._json_filename = json_filename
297 self._gm_summaries_bucket = gm_summaries_bucket
299 self._export = export
300 self._editable = editable
301 self._reload_seconds = reload_seconds
302 self._config_pairs = config_pairs or []
303 self._builder_regex_list = builder_regex_list
304 self.truncate_results = False
307 self._gs = gs_utils.GSUtils(boto_file_path=boto_file_path)
309 self._gs = gs_utils.GSUtils()
312 file_path=os.path.join(
313 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR, GENERATED_HTML_SUBDIR,
315 config_pairs=config_pairs)
317 # Reentrant lock that must be held whenever updating EITHER of:
319 # 2. the expected or actual results on local disk
320 self.results_rlock = threading.RLock()
322 # Create a single ImageDiffDB instance that is used by all our differs.
323 self._image_diff_db = imagediffdb.ImageDiffDB(
325 storage_root=os.path.join(
326 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR,
327 GENERATED_IMAGES_SUBDIR),
328 num_worker_threads=imagediffdb_threads)
330 # This will be filled in by calls to update_results()
335 """ Returns the most recently generated results, or None if we don't have
336 any valid results (update_results() has not completed yet). """
340 def image_diff_db(self):
341 """ Returns reference to our ImageDiffDB object."""
342 return self._image_diff_db
346 """ Returns reference to our GSUtils object."""
350 def is_exported(self):
351 """ Returns true iff HTTP clients on other hosts are allowed to access
356 def is_editable(self):
357 """ True iff HTTP clients are allowed to submit new GM baselines.
359 TODO(epoger): This only pertains to GM baselines; SKP baselines are
360 editable whenever expectations vs actuals are shown.
361 Once we move the GM baselines to use the same code as the SKP baselines,
362 we can delete this property.
364 return self._editable
367 def reload_seconds(self):
368 """ Returns the result reload period in seconds, or 0 if we don't reload
370 return self._reload_seconds
372 def update_results(self, invalidate=False):
373 """ Create or update self._results, based on the latest expectations and
376 We hold self.results_rlock while we do this, to guarantee that no other
377 thread attempts to update either self._results or the underlying files at
381 invalidate: if True, invalidate self._results immediately upon entry;
382 otherwise, we will let readers see those results until we
385 with self.results_rlock:
388 if self._gm_summaries_bucket:
390 'Updating GM result summaries in %s from gm_summaries_bucket %s ...'
391 % (self._actuals_dir, self._gm_summaries_bucket))
393 # Clean out actuals_dir first, in case some builders have gone away
395 if os.path.isdir(self._actuals_dir):
396 shutil.rmtree(self._actuals_dir)
398 # Get the list of builders we care about.
399 all_builders = download_actuals.get_builders_list(
400 summaries_bucket=self._gm_summaries_bucket)
401 if self._builder_regex_list:
402 matching_builders = []
403 for builder in all_builders:
404 for regex in self._builder_regex_list:
405 if re.match(regex, builder):
406 matching_builders.append(builder)
407 break # go on to the next builder, no need to try more regexes
409 matching_builders = all_builders
411 # Download the JSON file for each builder we care about.
413 # TODO(epoger): When this is a large number of builders, we would be
414 # better off downloading them in parallel!
415 for builder in matching_builders:
416 self._gs.download_file(
417 source_bucket=self._gm_summaries_bucket,
418 source_path=posixpath.join(builder, self._json_filename),
419 dest_path=os.path.join(self._actuals_dir, builder,
420 self._json_filename),
421 create_subdirs_if_needed=True)
423 # We only update the expectations dir if the server was run with a
424 # nonzero --reload argument; otherwise, we expect the user to maintain
425 # her own expectations as she sees fit.
427 # Because the Skia repo is hosted using git, and git does not
428 # support updating a single directory tree, we have to update the entire
431 # Because Skia uses depot_tools, we have to update using "gclient sync"
432 # instead of raw git commands.
434 # TODO(epoger): Fetch latest expectations in some other way.
435 # Eric points out that our official documentation recommends an
436 # unmanaged Skia checkout, so "gclient sync" will not bring down updated
437 # expectations from origin/master-- you'd have to do a "git pull" of
439 # However, the live rebaseline_server at
440 # http://skia-tree-status.appspot.com/redirect/rebaseline-server (which
441 # is probably the only user of the --reload flag!) uses a managed
442 # checkout, so "gclient sync" works in that case.
443 # Probably the best idea is to avoid all of this nonsense by fetching
444 # updated expectations into a temp directory, and leaving the rest of
445 # the checkout alone. This could be done using "git show", or by
446 # downloading individual expectation JSON files from
447 # skia.googlesource.com .
448 if self._reload_seconds:
450 'Updating expected GM results in %s by syncing Skia repo ...' %
451 compare_to_expectations.DEFAULT_EXPECTATIONS_DIR)
452 _run_command(['gclient', 'sync'], TRUNK_DIRECTORY)
454 self._results = compare_to_expectations.ExpectationComparisons(
455 image_diff_db=self._image_diff_db,
456 actuals_root=self._actuals_dir,
457 diff_base_url=posixpath.join(
458 os.pardir, STATIC_CONTENTS_SUBDIR, GENERATED_IMAGES_SUBDIR),
459 builder_regex_list=self._builder_regex_list)
461 json_dir = os.path.join(
462 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR, GENERATED_JSON_SUBDIR)
463 if not os.path.isdir(json_dir):
464 os.makedirs(json_dir)
466 for config_pair in self._config_pairs:
467 config_comparisons = compare_configs.ConfigComparisons(
469 actuals_root=self._actuals_dir,
470 generated_images_root=os.path.join(
471 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR,
472 GENERATED_IMAGES_SUBDIR),
473 diff_base_url=posixpath.join(
474 os.pardir, GENERATED_IMAGES_SUBDIR),
475 builder_regex_list=self._builder_regex_list)
476 for summary_type in _GM_SUMMARY_TYPES:
478 config_comparisons.get_packaged_results_of_type(
479 results_type=summary_type),
481 json_dir, '%s-vs-%s_%s.json' % (
482 config_pair[0], config_pair[1], summary_type)))
484 def _result_loader(self, reload_seconds=0):
485 """ Call self.update_results(), either once or periodically.
488 reload_seconds: integer; if nonzero, reload results at this interval
489 (in which case, this method will never return!)
491 self.update_results()
492 logging.info('Initial results loaded. Ready for requests on %s' % self._url)
495 time.sleep(reload_seconds)
496 self.update_results()
499 arg_tuple = (self._reload_seconds,) # start_new_thread needs a tuple,
500 # even though it holds just one param
501 thread.start_new_thread(self._result_loader, arg_tuple)
504 server_address = ('', self._port)
505 host = _get_routable_ip_address()
507 logging.warning('Running with combination of "export" and "editable" '
508 'flags. Users on other machines will '
509 'be able to modify your GM expectations!')
512 server_address = (host, self._port)
513 # pylint: disable=W0201
514 http_server = BaseHTTPServer.HTTPServer(server_address, HTTPRequestHandler)
515 self._url = 'http://%s:%d' % (host, http_server.server_port)
516 logging.info('Listening for requests on %s' % self._url)
517 http_server.serve_forever()
520 class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
521 """ HTTP request handlers for various types of queries this server knows
522 how to handle (static HTML and Javascript, expected/actual results, etc.)
526 Handles all GET requests, forwarding them to the appropriate
529 If we see any Exceptions, return a 404. This fixes http://skbug.com/2147
532 logging.debug('do_GET: path="%s"' % self.path)
533 if self.path == '' or self.path == '/' or self.path == '/index.html' :
534 self.redirect_to('/%s/%s/index.html' % (
535 GET__STATIC_CONTENTS, GENERATED_HTML_SUBDIR))
537 if self.path == '/favicon.ico' :
538 self.redirect_to('/%s/favicon.ico' % GET__STATIC_CONTENTS)
541 # All requests must be of this form:
542 # /dispatcher/remainder
543 # where 'dispatcher' indicates which do_GET_* dispatcher to run
544 # and 'remainder' is the remaining path sent to that dispatcher.
545 (dispatcher_name, remainder) = PATHSPLIT_RE.match(self.path).groups()
547 GET__LIVE_RESULTS: self.do_GET_live_results,
548 GET__PRECOMPUTED_RESULTS: self.do_GET_precomputed_results,
549 GET__PREFETCH_RESULTS: self.do_GET_prefetch_results,
550 GET__STATIC_CONTENTS: self.do_GET_static,
552 dispatcher = dispatchers[dispatcher_name]
553 dispatcher(remainder)
558 def do_GET_precomputed_results(self, results_type):
559 """ Handle a GET request for part of the precomputed _SERVER.results object.
562 results_type: string indicating which set of results to return;
563 must be one of the results_mod.RESULTS_* constants
565 logging.debug('do_GET_precomputed_results: sending results of type "%s"' %
567 # Since we must make multiple calls to the ExpectationComparisons object,
568 # grab a reference to it in case it is updated to point at a new
569 # ExpectationComparisons object within another thread.
571 # TODO(epoger): Rather than using a global variable for the handler
572 # to refer to the Server object, make Server a subclass of
573 # HTTPServer, and then it could be available to the handler via
574 # the handler's .server instance variable.
575 results_obj = _SERVER.results
577 response_dict = results_obj.get_packaged_results_of_type(
578 results_type=results_type, reload_seconds=_SERVER.reload_seconds,
579 is_editable=_SERVER.is_editable, is_exported=_SERVER.is_exported)
581 now = int(time.time())
583 imagepairset.KEY__ROOT__HEADER: {
584 results_mod.KEY__HEADER__SCHEMA_VERSION: (
585 results_mod.VALUE__HEADER__SCHEMA_VERSION),
586 results_mod.KEY__HEADER__IS_STILL_LOADING: True,
587 results_mod.KEY__HEADER__TIME_UPDATED: now,
588 results_mod.KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE: (
589 now + RELOAD_INTERVAL_UNTIL_READY),
592 self.send_json_dict(response_dict)
594 def _get_live_results_or_prefetch(self, url_remainder, prefetch_only=False):
595 """ Handle a GET request for live-generated image diff data.
598 url_remainder: string indicating which image diffs to generate
599 prefetch_only: if True, the user isn't waiting around for results
601 param_dict = urlparse.parse_qs(url_remainder)
602 download_all_images = (
603 param_dict.get(LIVE_PARAM__DOWNLOAD_ONLY_DIFFERING, [''])[0].lower()
604 not in ['1', 'true'])
605 setA_dir = param_dict[LIVE_PARAM__SET_A_DIR][0]
606 setB_dir = param_dict[LIVE_PARAM__SET_B_DIR][0]
607 setA_section = self._validate_summary_section(
608 param_dict.get(LIVE_PARAM__SET_A_SECTION, [None])[0])
609 setB_section = self._validate_summary_section(
610 param_dict.get(LIVE_PARAM__SET_B_SECTION, [None])[0])
612 # If the sets show expectations vs actuals, always show expectations on
614 if ((setA_section == gm_json.JSONKEY_ACTUALRESULTS) and
615 (setB_section == gm_json.JSONKEY_EXPECTEDRESULTS)):
616 setA_dir, setB_dir = setB_dir, setA_dir
617 setA_section, setB_section = setB_section, setA_section
619 # Are we comparing some actuals against expectations stored in the repo?
620 # If so, we can allow the user to submit new baselines.
622 (setA_section == gm_json.JSONKEY_EXPECTEDRESULTS) and
623 (setA_dir.startswith(compare_rendered_pictures.REPO_URL_PREFIX)) and
624 (setB_section == gm_json.JSONKEY_ACTUALRESULTS))
626 results_obj = compare_rendered_pictures.RenderedPicturesComparisons(
627 setA_dir=setA_dir, setB_dir=setB_dir,
628 setA_section=setA_section, setB_section=setB_section,
629 image_diff_db=_SERVER.image_diff_db,
630 diff_base_url='/static/generated-images',
631 gs=_SERVER.gs, truncate_results=_SERVER.truncate_results,
632 prefetch_only=prefetch_only, download_all_images=download_all_images)
634 self.send_response(200)
636 self.send_json_dict(results_obj.get_packaged_results_of_type(
637 results_type=results_mod.KEY__HEADER__RESULTS_ALL,
638 is_editable=is_editable))
640 def do_GET_live_results(self, url_remainder):
641 """ Handle a GET request for live-generated image diff data.
644 url_remainder: string indicating which image diffs to generate
646 logging.debug('do_GET_live_results: url_remainder="%s"' % url_remainder)
647 self._get_live_results_or_prefetch(
648 url_remainder=url_remainder, prefetch_only=False)
650 def do_GET_prefetch_results(self, url_remainder):
651 """ Prefetch image diff data for a future do_GET_live_results() call.
654 url_remainder: string indicating which image diffs to generate
656 logging.debug('do_GET_prefetch_results: url_remainder="%s"' % url_remainder)
657 self._get_live_results_or_prefetch(
658 url_remainder=url_remainder, prefetch_only=True)
660 def do_GET_static(self, path):
661 """ Handle a GET request for a file under STATIC_CONTENTS_SUBDIR .
662 Only allow serving of files within STATIC_CONTENTS_SUBDIR that is a
663 filesystem sibling of this script.
666 path: path to file (within STATIC_CONTENTS_SUBDIR) to retrieve
668 # Strip arguments ('?resultsToLoad=all') from the path
669 path = urlparse.urlparse(path).path
671 logging.debug('do_GET_static: sending file "%s"' % path)
672 static_dir = os.path.realpath(os.path.join(
673 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR))
674 full_path = os.path.realpath(os.path.join(static_dir, path))
675 if full_path.startswith(static_dir):
676 self.send_file(full_path)
679 'Attempted do_GET_static() of path [%s] outside of static dir [%s]'
680 % (full_path, static_dir))
684 """ Handles all POST requests, forwarding them to the appropriate
685 do_POST_* dispatcher. """
686 # All requests must be of this form:
688 # where 'dispatcher' indicates which do_POST_* dispatcher to run.
689 logging.debug('do_POST: path="%s"' % self.path)
690 normpath = posixpath.normpath(self.path)
692 '/edits': self.do_POST_edits,
693 '/live-edits': self.do_POST_live_edits,
696 dispatcher = dispatchers[normpath]
702 def do_POST_edits(self):
703 """ Handle a POST request with modifications to GM expectations, in this
707 KEY__EDITS__OLD_RESULTS_TYPE: 'all', # type of results that the client
708 # loaded and then made
710 KEY__EDITS__OLD_RESULTS_HASH: 39850913, # hash of results when the client
711 # loaded them (ensures that the
712 # client and server apply
713 # modifications to the same base)
714 KEY__EDITS__MODIFICATIONS: [
715 # as needed by compare_to_expectations.edit_expectations()
720 Raises an Exception if there were any problems.
722 if not _SERVER.is_editable:
723 raise Exception('this server is not running in --editable mode')
725 content_type = self.headers[_HTTP_HEADER_CONTENT_TYPE]
726 if content_type != 'application/json;charset=UTF-8':
727 raise Exception('unsupported %s [%s]' % (
728 _HTTP_HEADER_CONTENT_TYPE, content_type))
730 content_length = int(self.headers[_HTTP_HEADER_CONTENT_LENGTH])
731 json_data = self.rfile.read(content_length)
732 data = json.loads(json_data)
733 logging.debug('do_POST_edits: received new GM expectations data [%s]' %
736 # Update the results on disk with the information we received from the
738 # We must hold _SERVER.results_rlock while we do this, to guarantee that
739 # no other thread updates expectations (from the Skia repo) while we are
740 # updating them (using the info we received from the client).
741 with _SERVER.results_rlock:
742 oldResultsType = data[KEY__EDITS__OLD_RESULTS_TYPE]
743 oldResults = _SERVER.results.get_results_of_type(oldResultsType)
744 oldResultsHash = str(hash(repr(
745 oldResults[imagepairset.KEY__ROOT__IMAGEPAIRS])))
746 if oldResultsHash != data[KEY__EDITS__OLD_RESULTS_HASH]:
747 raise Exception('results of type "%s" changed while the client was '
748 'making modifications. The client should reload the '
749 'results and submit the modifications again.' %
751 _SERVER.results.edit_expectations(data[KEY__EDITS__MODIFICATIONS])
753 # Read the updated results back from disk.
754 # We can do this in a separate thread; we should return our success message
755 # to the UI as soon as possible.
756 thread.start_new_thread(_SERVER.update_results, (True,))
757 self.send_response(200)
759 def do_POST_live_edits(self):
760 """ Handle a POST request with modifications to SKP expectations, in this
764 KEY__LIVE_EDITS__SET_A_DESCRIPTIONS: {
765 # setA descriptions from the original data
767 KEY__LIVE_EDITS__SET_B_DESCRIPTIONS: {
768 # setB descriptions from the original data
770 KEY__LIVE_EDITS__MODIFICATIONS: [
771 # as needed by writable_expectations.modify()
775 Raises an Exception if there were any problems.
777 content_type = self.headers[_HTTP_HEADER_CONTENT_TYPE]
778 if content_type != 'application/json;charset=UTF-8':
779 raise Exception('unsupported %s [%s]' % (
780 _HTTP_HEADER_CONTENT_TYPE, content_type))
782 content_length = int(self.headers[_HTTP_HEADER_CONTENT_LENGTH])
783 json_data = self.rfile.read(content_length)
784 data = json.loads(json_data)
785 logging.debug('do_POST_live_edits: received new GM expectations data [%s]' %
787 with writable_expectations_mod.WritableExpectations(
788 data[KEY__LIVE_EDITS__SET_A_DESCRIPTIONS]) as writable_expectations:
789 writable_expectations.modify(data[KEY__LIVE_EDITS__MODIFICATIONS])
790 diffs = writable_expectations.get_diffs()
791 # TODO(stephana): Move to a simpler web framework so we don't have to
792 # call these functions. See http://skbug.com/2856 ('rebaseline_server:
793 # Refactor server to use a simple web framework')
794 self.send_response(200)
795 self.send_header('Content-type', 'text/plain')
797 self.wfile.write(diffs)
799 def redirect_to(self, url):
800 """ Redirect the HTTP client to a different url.
803 url: URL to redirect the HTTP client to
805 self.send_response(301)
806 self.send_header('Location', url)
809 def send_file(self, path):
810 """ Send the contents of the file at this path, with a mimetype based
811 on the filename extension.
814 path: path of file whose contents to send to the HTTP client
816 # Grab the extension if there is one
817 extension = os.path.splitext(path)[1]
818 if len(extension) >= 1:
819 extension = extension[1:]
821 # Determine the MIME type of the file from its extension
822 mime_type = MIME_TYPE_MAP.get(extension, MIME_TYPE_MAP[''])
824 # Open the file and send it over HTTP
825 if os.path.isfile(path):
826 with open(path, 'rb') as sending_file:
827 self.send_response(200)
828 self.send_header('Content-type', mime_type)
830 self.wfile.write(sending_file.read())
834 def send_json_dict(self, json_dict):
835 """ Send the contents of this dictionary in JSON format, with a JSON
839 json_dict: dictionary to send
841 self.send_response(200)
842 self.send_header('Content-type', 'application/json')
844 json.dump(json_dict, self.wfile)
846 def _validate_summary_section(self, section_name):
847 """Validates the section we have been requested to read within JSON summary.
850 section_name: which section of the JSON summary file has been requested
852 Returns: the validated section name
854 Raises: Exception if an invalid section_name was requested.
856 if section_name not in compare_rendered_pictures.ALLOWED_SECTION_NAMES:
857 raise Exception('requested section name "%s" not in allowed list %s' % (
858 section_name, compare_rendered_pictures.ALLOWED_SECTION_NAMES))
863 logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s',
864 datefmt='%m/%d/%Y %H:%M:%S',
866 parser = argparse.ArgumentParser()
867 parser.add_argument('--actuals-dir',
868 help=('Directory into which we will check out the latest '
869 'actual GM results. If this directory does not '
870 'exist, it will be created. Defaults to %(default)s'),
871 default=DEFAULT_ACTUALS_DIR)
872 parser.add_argument('--boto',
873 help=('Path to .boto file giving us credentials to access '
874 'Google Storage buckets. If not specified, we will '
875 'only be able to access public GS buckets (and thus '
876 'won\'t be able to download SKP images).'),
878 # TODO(epoger): Before https://codereview.chromium.org/310093003 ,
879 # when this tool downloaded the JSON summaries from skia-autogen,
880 # it had an --actuals-revision the caller could specify to download
881 # actual results as of a specific point in time. We should add similar
882 # functionality when retrieving the summaries from Google Storage.
883 parser.add_argument('--builders', metavar='BUILDER_REGEX', nargs='+',
884 help=('Only process builders matching these regular '
885 'expressions. If unspecified, process all '
887 parser.add_argument('--compare-configs', action='store_true',
888 help=('In addition to generating differences between '
889 'expectations and actuals, also generate '
890 'differences between these config pairs: '
891 + str(CONFIG_PAIRS_TO_COMPARE)))
892 parser.add_argument('--editable', action='store_true',
893 help=('Allow HTTP clients to submit new GM baselines; '
894 'SKP baselines can be edited regardless of this '
896 parser.add_argument('--export', action='store_true',
897 help=('Instead of only allowing access from HTTP clients '
898 'on localhost, allow HTTP clients on other hosts '
899 'to access this server. WARNING: doing so will '
900 'allow users on other hosts to modify your '
901 'GM expectations, if combined with --editable.'))
902 parser.add_argument('--gm-summaries-bucket',
903 help=('Google Cloud Storage bucket to download '
904 'JSON_FILENAME files from. '
905 'Defaults to %(default)s ; if set to '
906 'empty string, just compare to actual-results '
907 'already found in ACTUALS_DIR.'),
908 default=DEFAULT_GM_SUMMARIES_BUCKET)
909 parser.add_argument('--json-filename',
910 help=('JSON summary filename to read for each builder; '
911 'defaults to %(default)s.'),
912 default=DEFAULT_JSON_FILENAME)
913 parser.add_argument('--port', type=int,
914 help=('Which TCP port to listen on for HTTP requests; '
915 'defaults to %(default)s'),
916 default=DEFAULT_PORT)
917 parser.add_argument('--reload', type=int,
918 help=('How often (a period in seconds) to update the '
919 'results. If specified, both expected and actual '
920 'results will be updated by running "gclient sync" '
921 'on your Skia checkout as a whole. '
922 'By default, we do not reload at all, and you '
923 'must restart the server to pick up new data.'),
925 parser.add_argument('--threads', type=int,
926 help=('How many parallel threads we use to download '
927 'images and generate diffs; defaults to '
929 default=imagediffdb.DEFAULT_NUM_WORKER_THREADS)
930 parser.add_argument('--truncate', action='store_true',
931 help=('FOR TESTING ONLY: truncate the set of images we '
932 'process, to speed up testing.'))
933 args = parser.parse_args()
934 if args.compare_configs:
935 config_pairs = CONFIG_PAIRS_TO_COMPARE
940 _SERVER = Server(actuals_dir=args.actuals_dir,
941 json_filename=args.json_filename,
942 gm_summaries_bucket=args.gm_summaries_bucket,
943 port=args.port, export=args.export, editable=args.editable,
944 reload_seconds=args.reload, config_pairs=config_pairs,
945 builder_regex_list=args.builders, boto_file_path=args.boto,
946 imagediffdb_threads=args.threads)
948 _SERVER.truncate_results = True
952 if __name__ == '__main__':