blob: 04620f51ca020bcdb8e9e5dee9ec95a757b77d1e [file] [log] [blame]
epoger@google.comf9d134d2013-09-27 15:02:44 +00001#!/usr/bin/python
2
epoger@google.com9fb6c8a2013-10-09 18:05:58 +00003"""
epoger@google.comf9d134d2013-09-27 15:02:44 +00004Copyright 2013 Google Inc.
5
6Use of this source code is governed by a BSD-style license that can be
7found in the LICENSE file.
epoger@google.comf9d134d2013-09-27 15:02:44 +00008
epoger@google.comf9d134d2013-09-27 15:02:44 +00009HTTP server for our HTML rebaseline viewer.
epoger@google.com9fb6c8a2013-10-09 18:05:58 +000010"""
epoger@google.comf9d134d2013-09-27 15:02:44 +000011
12# System-level imports
13import argparse
14import BaseHTTPServer
15import json
epoger@google.comdcb4e652013-10-11 18:45:33 +000016import logging
epoger@google.comf9d134d2013-09-27 15:02:44 +000017import os
18import posixpath
19import re
20import shutil
epoger@google.comb08c7072013-10-30 14:09:04 +000021import socket
rmistry@google.comd6bab022013-12-02 13:50:38 +000022import subprocess
epoger@google.comf9d134d2013-09-27 15:02:44 +000023import sys
epoger@google.com542b65f2013-10-15 20:10:33 +000024import thread
rmistry@google.comd6bab022013-12-02 13:50:38 +000025import threading
epoger@google.com542b65f2013-10-15 20:10:33 +000026import time
epoger@google.comdcb4e652013-10-11 18:45:33 +000027import urlparse
epoger@google.comf9d134d2013-09-27 15:02:44 +000028
29# Imports from within Skia
30#
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +000031# We need to add the 'tools' directory for svn.py, and the 'gm' directory for
32# gm_json.py .
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +000033# that directory.
34# Make sure that the 'tools' dir is in the PYTHONPATH, but add it at the *end*
epoger@google.comf9d134d2013-09-27 15:02:44 +000035# so any dirs that are already in the PYTHONPATH will be preferred.
epoger@google.comcb55f112013-10-02 19:27:35 +000036PARENT_DIRECTORY = os.path.dirname(os.path.realpath(__file__))
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +000037GM_DIRECTORY = os.path.dirname(PARENT_DIRECTORY)
38TRUNK_DIRECTORY = os.path.dirname(GM_DIRECTORY)
epoger@google.comf9d134d2013-09-27 15:02:44 +000039TOOLS_DIRECTORY = os.path.join(TRUNK_DIRECTORY, 'tools')
40if TOOLS_DIRECTORY not in sys.path:
41 sys.path.append(TOOLS_DIRECTORY)
42import svn
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +000043if GM_DIRECTORY not in sys.path:
44 sys.path.append(GM_DIRECTORY)
45import gm_json
epoger@google.comf9d134d2013-09-27 15:02:44 +000046
47# Imports from local dir
commit-bot@chromium.org7498d952014-03-13 14:56:29 +000048#
49# Note: we import results under a different name, to avoid confusion with the
50# Server.results() property. See discussion at
51# https://codereview.chromium.org/195943004/diff/1/gm/rebaseline_server/server.py#newcode44
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +000052import compare_configs
commit-bot@chromium.orgb463d562014-03-21 17:54:14 +000053import compare_to_expectations
commit-bot@chromium.org16f41802014-02-26 19:05:20 +000054import imagepairset
commit-bot@chromium.org7498d952014-03-13 14:56:29 +000055import results as results_mod
epoger@google.comf9d134d2013-09-27 15:02:44 +000056
epoger@google.comf9d134d2013-09-27 15:02:44 +000057PATHSPLIT_RE = re.compile('/([^/]+)/(.+)')
epoger@google.comf9d134d2013-09-27 15:02:44 +000058
59# A simple dictionary of file name extensions to MIME types. The empty string
60# entry is used as the default when no extension was given or if the extension
61# has no entry in this dictionary.
62MIME_TYPE_MAP = {'': 'application/octet-stream',
63 'html': 'text/html',
64 'css': 'text/css',
65 'png': 'image/png',
66 'js': 'application/javascript',
67 'json': 'application/json'
68 }
69
commit-bot@chromium.org16f41802014-02-26 19:05:20 +000070# Keys that server.py uses to create the toplevel content header.
71# NOTE: Keep these in sync with static/constants.js
72KEY__EDITS__MODIFICATIONS = 'modifications'
73KEY__EDITS__OLD_RESULTS_HASH = 'oldResultsHash'
74KEY__EDITS__OLD_RESULTS_TYPE = 'oldResultsType'
commit-bot@chromium.org16f41802014-02-26 19:05:20 +000075
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +000076DEFAULT_ACTUALS_DIR = results_mod.DEFAULT_ACTUALS_DIR
commit-bot@chromium.org5865ec52014-03-10 18:09:25 +000077DEFAULT_ACTUALS_REPO_REVISION = 'HEAD'
78DEFAULT_ACTUALS_REPO_URL = 'http://skia-autogen.googlecode.com/svn/gm-actual'
epoger@google.comf9d134d2013-09-27 15:02:44 +000079DEFAULT_PORT = 8888
80
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +000081# Directory, relative to PARENT_DIRECTORY, within which the server will serve
82# out live results (not static files).
83RESULTS_SUBDIR = 'results'
84# Directory, relative to PARENT_DIRECTORY, within which the server will serve
85# out static files.
86STATIC_CONTENTS_SUBDIR = 'static'
87# All of the GENERATED_*_SUBDIRS are relative to STATIC_CONTENTS_SUBDIR
88GENERATED_HTML_SUBDIR = 'generated-html'
89GENERATED_IMAGES_SUBDIR = 'generated-images'
90GENERATED_JSON_SUBDIR = 'generated-json'
commit-bot@chromium.org57994232014-03-20 17:27:46 +000091
epoger@google.com2682c902013-12-05 16:05:16 +000092# How often (in seconds) clients should reload while waiting for initial
93# results to load.
94RELOAD_INTERVAL_UNTIL_READY = 10
95
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +000096SUMMARY_TYPES = [
97 results_mod.KEY__HEADER__RESULTS_FAILURES,
98 results_mod.KEY__HEADER__RESULTS_ALL,
99]
100# If --compare-configs is specified, compare these configs.
101CONFIG_PAIRS_TO_COMPARE = [('8888', 'gpu')]
102
epoger@google.comeb832592013-10-23 15:07:26 +0000103_HTTP_HEADER_CONTENT_LENGTH = 'Content-Length'
104_HTTP_HEADER_CONTENT_TYPE = 'Content-Type'
105
epoger@google.comf9d134d2013-09-27 15:02:44 +0000106_SERVER = None # This gets filled in by main()
107
rmistry@google.comd6bab022013-12-02 13:50:38 +0000108
109def _run_command(args, directory):
110 """Runs a command and returns stdout as a single string.
111
112 Args:
113 args: the command to run, as a list of arguments
114 directory: directory within which to run the command
115
116 Returns: stdout, as a string
117
118 Raises an Exception if the command failed (exited with nonzero return code).
119 """
120 logging.debug('_run_command: %s in directory %s' % (args, directory))
121 proc = subprocess.Popen(args, cwd=directory,
122 stdout=subprocess.PIPE,
123 stderr=subprocess.PIPE)
124 (stdout, stderr) = proc.communicate()
125 if proc.returncode is not 0:
126 raise Exception('command "%s" failed in dir "%s": %s' %
127 (args, directory, stderr))
128 return stdout
129
130
epoger@google.com591469b2013-11-20 19:58:06 +0000131def _get_routable_ip_address():
epoger@google.comb08c7072013-10-30 14:09:04 +0000132 """Returns routable IP address of this host (the IP address of its network
133 interface that would be used for most traffic, not its localhost
134 interface). See http://stackoverflow.com/a/166589 """
135 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
136 sock.connect(('8.8.8.8', 80))
137 host = sock.getsockname()[0]
138 sock.close()
139 return host
140
rmistry@google.comd6bab022013-12-02 13:50:38 +0000141
epoger@google.com591469b2013-11-20 19:58:06 +0000142def _create_svn_checkout(dir_path, repo_url):
143 """Creates local checkout of an SVN repository at the specified directory
144 path, returning an svn.Svn object referring to the local checkout.
145
146 Args:
147 dir_path: path to the local checkout; if this directory does not yet exist,
148 it will be created and the repo will be checked out into it
149 repo_url: URL of SVN repo to check out into dir_path (unless the local
150 checkout already exists)
151 Returns: an svn.Svn object referring to the local checkout.
152 """
153 local_checkout = svn.Svn(dir_path)
154 if not os.path.isdir(dir_path):
155 os.makedirs(dir_path)
156 local_checkout.Checkout(repo_url, '.')
157 return local_checkout
158
epoger@google.comb08c7072013-10-30 14:09:04 +0000159
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000160def _create_index(file_path, config_pairs):
161 """Creates an index file linking to all results available from this server.
162
163 Prior to https://codereview.chromium.org/215503002 , we had a static
164 index.html within our repo. But now that the results may or may not include
165 config comparisons, index.html needs to be generated differently depending
166 on which results are included.
167
168 TODO(epoger): Instead of including raw HTML within the Python code,
169 consider restoring the index.html file as a template and using django (or
170 similar) to fill in dynamic content.
171
172 Args:
173 file_path: path on local disk to write index to; any directory components
174 of this path that do not already exist will be created
175 config_pairs: what pairs of configs (if any) we compare actual results of
176 """
177 dir_path = os.path.dirname(file_path)
178 if not os.path.isdir(dir_path):
179 os.makedirs(dir_path)
180 with open(file_path, 'w') as file_handle:
181 file_handle.write(
182 '<!DOCTYPE html><html>'
183 '<head><title>rebaseline_server</title></head>'
184 '<body><ul>')
185 if SUMMARY_TYPES:
186 file_handle.write('<li>Expectations vs Actuals</li><ul>')
187 for summary_type in SUMMARY_TYPES:
188 file_handle.write(
189 '<li>'
190 '<a href="/%s/view.html#/view.html?resultsToLoad=/%s/%s">'
191 '%s</a></li>' % (
192 STATIC_CONTENTS_SUBDIR, RESULTS_SUBDIR,
193 summary_type, summary_type))
194 file_handle.write('</ul>')
195 if config_pairs:
196 file_handle.write('<li>Comparing configs within actual results</li><ul>')
197 for config_pair in config_pairs:
198 file_handle.write('<li>%s vs %s:' % config_pair)
199 for summary_type in SUMMARY_TYPES:
200 file_handle.write(
201 ' <a href="/%s/view.html#/view.html?'
202 'resultsToLoad=/%s/%s/%s-vs-%s_%s.json">%s</a>' % (
203 STATIC_CONTENTS_SUBDIR, STATIC_CONTENTS_SUBDIR,
204 GENERATED_JSON_SUBDIR, config_pair[0], config_pair[1],
205 summary_type, summary_type))
206 file_handle.write('</li>')
207 file_handle.write('</ul>')
208 file_handle.write('</ul></body></html>')
209
210
epoger@google.comf9d134d2013-09-27 15:02:44 +0000211class Server(object):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000212 """ HTTP server for our HTML rebaseline viewer. """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000213
epoger@google.comf9d134d2013-09-27 15:02:44 +0000214 def __init__(self,
215 actuals_dir=DEFAULT_ACTUALS_DIR,
commit-bot@chromium.org5865ec52014-03-10 18:09:25 +0000216 actuals_repo_revision=DEFAULT_ACTUALS_REPO_REVISION,
217 actuals_repo_url=DEFAULT_ACTUALS_REPO_URL,
epoger@google.com542b65f2013-10-15 20:10:33 +0000218 port=DEFAULT_PORT, export=False, editable=True,
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000219 reload_seconds=0, config_pairs=None):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000220 """
221 Args:
222 actuals_dir: directory under which we will check out the latest actual
commit-bot@chromium.orgc0df2fb2014-03-28 14:28:04 +0000223 GM results
commit-bot@chromium.org5865ec52014-03-10 18:09:25 +0000224 actuals_repo_revision: revision of actual-results.json files to process
commit-bot@chromium.orgc0df2fb2014-03-28 14:28:04 +0000225 actuals_repo_url: SVN repo to download actual-results.json files from;
226 if None or '', don't fetch new actual-results files at all,
227 just compare to whatever files are already in actuals_dir
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000228 port: which TCP port to listen on for HTTP requests
229 export: whether to allow HTTP clients on other hosts to access this server
epoger@google.com542b65f2013-10-15 20:10:33 +0000230 editable: whether HTTP clients are allowed to submit new baselines
231 reload_seconds: polling interval with which to check for new results;
commit-bot@chromium.orgc0df2fb2014-03-28 14:28:04 +0000232 if 0, don't check for new results at all
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000233 config_pairs: List of (string, string) tuples; for each tuple, compare
234 actual results of these two configs. If None or empty,
235 don't compare configs at all.
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000236 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000237 self._actuals_dir = actuals_dir
commit-bot@chromium.org5865ec52014-03-10 18:09:25 +0000238 self._actuals_repo_revision = actuals_repo_revision
239 self._actuals_repo_url = actuals_repo_url
epoger@google.comf9d134d2013-09-27 15:02:44 +0000240 self._port = port
241 self._export = export
epoger@google.com542b65f2013-10-15 20:10:33 +0000242 self._editable = editable
243 self._reload_seconds = reload_seconds
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000244 self._config_pairs = config_pairs or []
245 _create_index(
246 file_path=os.path.join(
247 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR, GENERATED_HTML_SUBDIR,
248 "index.html"),
249 config_pairs=config_pairs)
commit-bot@chromium.orgc0df2fb2014-03-28 14:28:04 +0000250 if actuals_repo_url:
251 self._actuals_repo = _create_svn_checkout(
252 dir_path=actuals_dir, repo_url=actuals_repo_url)
epoger@google.com591469b2013-11-20 19:58:06 +0000253
rmistry@google.comd6bab022013-12-02 13:50:38 +0000254 # Reentrant lock that must be held whenever updating EITHER of:
255 # 1. self._results
256 # 2. the expected or actual results on local disk
257 self.results_rlock = threading.RLock()
258 # self._results will be filled in by calls to update_results()
259 self._results = None
epoger@google.comf9d134d2013-09-27 15:02:44 +0000260
rmistry@google.comd6bab022013-12-02 13:50:38 +0000261 @property
262 def results(self):
commit-bot@chromium.org50ad8e42013-12-17 18:06:13 +0000263 """ Returns the most recently generated results, or None if we don't have
264 any valid results (update_results() has not completed yet). """
rmistry@google.comd6bab022013-12-02 13:50:38 +0000265 return self._results
266
267 @property
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000268 def is_exported(self):
269 """ Returns true iff HTTP clients on other hosts are allowed to access
270 this server. """
271 return self._export
272
rmistry@google.comd6bab022013-12-02 13:50:38 +0000273 @property
epoger@google.com542b65f2013-10-15 20:10:33 +0000274 def is_editable(self):
275 """ Returns true iff HTTP clients are allowed to submit new baselines. """
276 return self._editable
epoger@google.comf9d134d2013-09-27 15:02:44 +0000277
rmistry@google.comd6bab022013-12-02 13:50:38 +0000278 @property
epoger@google.com542b65f2013-10-15 20:10:33 +0000279 def reload_seconds(self):
280 """ Returns the result reload period in seconds, or 0 if we don't reload
281 results. """
282 return self._reload_seconds
283
commit-bot@chromium.org50ad8e42013-12-17 18:06:13 +0000284 def update_results(self, invalidate=False):
commit-bot@chromium.org7498d952014-03-13 14:56:29 +0000285 """ Create or update self._results, based on the latest expectations and
286 actuals.
rmistry@google.comd6bab022013-12-02 13:50:38 +0000287
288 We hold self.results_rlock while we do this, to guarantee that no other
289 thread attempts to update either self._results or the underlying files at
290 the same time.
commit-bot@chromium.org50ad8e42013-12-17 18:06:13 +0000291
292 Args:
293 invalidate: if True, invalidate self._results immediately upon entry;
294 otherwise, we will let readers see those results until we
295 replace them
epoger@google.comf9d134d2013-09-27 15:02:44 +0000296 """
rmistry@google.comd6bab022013-12-02 13:50:38 +0000297 with self.results_rlock:
commit-bot@chromium.org50ad8e42013-12-17 18:06:13 +0000298 if invalidate:
299 self._results = None
commit-bot@chromium.orgc0df2fb2014-03-28 14:28:04 +0000300 if self._actuals_repo_url:
301 logging.info(
302 'Updating actual GM results in %s to revision %s from repo %s ...'
303 % (
304 self._actuals_dir, self._actuals_repo_revision,
305 self._actuals_repo_url))
306 self._actuals_repo.Update(
307 path='.', revision=self._actuals_repo_revision)
epoger@google.com591469b2013-11-20 19:58:06 +0000308
rmistry@google.comd6bab022013-12-02 13:50:38 +0000309 # We only update the expectations dir if the server was run with a
310 # nonzero --reload argument; otherwise, we expect the user to maintain
311 # her own expectations as she sees fit.
312 #
313 # Because the Skia repo is moving from SVN to git, and git does not
314 # support updating a single directory tree, we have to update the entire
315 # repo checkout.
316 #
317 # Because Skia uses depot_tools, we have to update using "gclient sync"
318 # instead of raw git (or SVN) update. Happily, this will work whether
319 # the checkout was created using git or SVN.
320 if self._reload_seconds:
321 logging.info(
322 'Updating expected GM results in %s by syncing Skia repo ...' %
commit-bot@chromium.orgb463d562014-03-21 17:54:14 +0000323 compare_to_expectations.DEFAULT_EXPECTATIONS_DIR)
rmistry@google.comd6bab022013-12-02 13:50:38 +0000324 _run_command(['gclient', 'sync'], TRUNK_DIRECTORY)
325
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000326 self._results = compare_to_expectations.ExpectationComparisons(
commit-bot@chromium.org57994232014-03-20 17:27:46 +0000327 actuals_root=self._actuals_dir,
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000328 generated_images_root=os.path.join(
329 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR,
330 GENERATED_IMAGES_SUBDIR),
331 diff_base_url=posixpath.join(
332 os.pardir, STATIC_CONTENTS_SUBDIR, GENERATED_IMAGES_SUBDIR))
epoger@google.comf9d134d2013-09-27 15:02:44 +0000333
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000334 json_dir = os.path.join(
335 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR, GENERATED_JSON_SUBDIR)
336 if not os.path.isdir(json_dir):
337 os.makedirs(json_dir)
338
339 for config_pair in self._config_pairs:
340 config_comparisons = compare_configs.ConfigComparisons(
341 configs=config_pair,
342 actuals_root=self._actuals_dir,
343 generated_images_root=os.path.join(
344 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR,
345 GENERATED_IMAGES_SUBDIR),
346 diff_base_url=posixpath.join(
347 os.pardir, GENERATED_IMAGES_SUBDIR))
348 for summary_type in SUMMARY_TYPES:
349 gm_json.WriteToFile(
350 config_comparisons.get_packaged_results_of_type(
351 results_type=summary_type),
352 os.path.join(
353 json_dir, '%s-vs-%s_%s.json' % (
354 config_pair[0], config_pair[1], summary_type)))
355
epoger@google.com2682c902013-12-05 16:05:16 +0000356 def _result_loader(self, reload_seconds=0):
357 """ Call self.update_results(), either once or periodically.
358
359 Params:
360 reload_seconds: integer; if nonzero, reload results at this interval
361 (in which case, this method will never return!)
epoger@google.com542b65f2013-10-15 20:10:33 +0000362 """
epoger@google.com2682c902013-12-05 16:05:16 +0000363 self.update_results()
364 logging.info('Initial results loaded. Ready for requests on %s' % self._url)
365 if reload_seconds:
366 while True:
367 time.sleep(reload_seconds)
368 self.update_results()
epoger@google.com542b65f2013-10-15 20:10:33 +0000369
epoger@google.comf9d134d2013-09-27 15:02:44 +0000370 def run(self):
epoger@google.com2682c902013-12-05 16:05:16 +0000371 arg_tuple = (self._reload_seconds,) # start_new_thread needs a tuple,
372 # even though it holds just one param
373 thread.start_new_thread(self._result_loader, arg_tuple)
epoger@google.com542b65f2013-10-15 20:10:33 +0000374
epoger@google.comf9d134d2013-09-27 15:02:44 +0000375 if self._export:
376 server_address = ('', self._port)
epoger@google.com591469b2013-11-20 19:58:06 +0000377 host = _get_routable_ip_address()
epoger@google.com542b65f2013-10-15 20:10:33 +0000378 if self._editable:
379 logging.warning('Running with combination of "export" and "editable" '
380 'flags. Users on other machines will '
381 'be able to modify your GM expectations!')
epoger@google.comf9d134d2013-09-27 15:02:44 +0000382 else:
epoger@google.comb08c7072013-10-30 14:09:04 +0000383 host = '127.0.0.1'
384 server_address = (host, self._port)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000385 http_server = BaseHTTPServer.HTTPServer(server_address, HTTPRequestHandler)
epoger@google.com2682c902013-12-05 16:05:16 +0000386 self._url = 'http://%s:%d' % (host, http_server.server_port)
387 logging.info('Listening for requests on %s' % self._url)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000388 http_server.serve_forever()
389
390
391class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
392 """ HTTP request handlers for various types of queries this server knows
393 how to handle (static HTML and Javascript, expected/actual results, etc.)
394 """
395 def do_GET(self):
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000396 """
397 Handles all GET requests, forwarding them to the appropriate
398 do_GET_* dispatcher.
epoger@google.comf9d134d2013-09-27 15:02:44 +0000399
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000400 If we see any Exceptions, return a 404. This fixes http://skbug.com/2147
401 """
402 try:
403 logging.debug('do_GET: path="%s"' % self.path)
404 if self.path == '' or self.path == '/' or self.path == '/index.html' :
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000405 self.redirect_to('/%s/%s/index.html' % (
406 STATIC_CONTENTS_SUBDIR, GENERATED_HTML_SUBDIR))
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000407 return
408 if self.path == '/favicon.ico' :
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000409 self.redirect_to('/%s/favicon.ico' % STATIC_CONTENTS_SUBDIR)
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000410 return
411
412 # All requests must be of this form:
413 # /dispatcher/remainder
414 # where 'dispatcher' indicates which do_GET_* dispatcher to run
415 # and 'remainder' is the remaining path sent to that dispatcher.
416 normpath = posixpath.normpath(self.path)
417 (dispatcher_name, remainder) = PATHSPLIT_RE.match(normpath).groups()
418 dispatchers = {
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000419 RESULTS_SUBDIR: self.do_GET_results,
420 STATIC_CONTENTS_SUBDIR: self.do_GET_static,
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000421 }
422 dispatcher = dispatchers[dispatcher_name]
423 dispatcher(remainder)
424 except:
425 self.send_error(404)
426 raise
epoger@google.comf9d134d2013-09-27 15:02:44 +0000427
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000428 def do_GET_results(self, results_type):
429 """ Handle a GET request for GM results.
430
431 Args:
432 results_type: string indicating which set of results to return;
433 must be one of the results_mod.RESULTS_* constants
434 """
435 logging.debug('do_GET_results: sending results of type "%s"' % results_type)
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000436 # Since we must make multiple calls to the ExpectationComparisons object,
437 # grab a reference to it in case it is updated to point at a new
438 # ExpectationComparisons object within another thread.
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000439 #
440 # TODO(epoger): Rather than using a global variable for the handler
441 # to refer to the Server object, make Server a subclass of
442 # HTTPServer, and then it could be available to the handler via
443 # the handler's .server instance variable.
444 results_obj = _SERVER.results
445 if results_obj:
446 response_dict = results_obj.get_packaged_results_of_type(
447 results_type=results_type, reload_seconds=_SERVER.reload_seconds,
448 is_editable=_SERVER.is_editable, is_exported=_SERVER.is_exported)
449 else:
450 now = int(time.time())
451 response_dict = {
452 results_mod.KEY__HEADER: {
453 results_mod.KEY__HEADER__SCHEMA_VERSION: (
454 results_mod.REBASELINE_SERVER_SCHEMA_VERSION_NUMBER),
455 results_mod.KEY__HEADER__IS_STILL_LOADING: True,
456 results_mod.KEY__HEADER__TIME_UPDATED: now,
457 results_mod.KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE: (
458 now + RELOAD_INTERVAL_UNTIL_READY),
459 },
460 }
461 self.send_json_dict(response_dict)
462
epoger@google.comf9d134d2013-09-27 15:02:44 +0000463 def do_GET_static(self, path):
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000464 """ Handle a GET request for a file under STATIC_CONTENTS_SUBDIR .
465 Only allow serving of files within STATIC_CONTENTS_SUBDIR that is a
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000466 filesystem sibling of this script.
467
468 Args:
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000469 path: path to file (within STATIC_CONTENTS_SUBDIR) to retrieve
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000470 """
epoger@google.comdcb4e652013-10-11 18:45:33 +0000471 # Strip arguments ('?resultsToLoad=all') from the path
472 path = urlparse.urlparse(path).path
473
474 logging.debug('do_GET_static: sending file "%s"' % path)
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000475 static_dir = os.path.realpath(os.path.join(
476 PARENT_DIRECTORY, STATIC_CONTENTS_SUBDIR))
477 full_path = os.path.realpath(os.path.join(static_dir, path))
478 if full_path.startswith(static_dir):
epoger@google.comcb55f112013-10-02 19:27:35 +0000479 self.send_file(full_path)
480 else:
epoger@google.comdcb4e652013-10-11 18:45:33 +0000481 logging.error(
482 'Attempted do_GET_static() of path [%s] outside of static dir [%s]'
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000483 % (full_path, static_dir))
epoger@google.comcb55f112013-10-02 19:27:35 +0000484 self.send_error(404)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000485
epoger@google.comeb832592013-10-23 15:07:26 +0000486 def do_POST(self):
487 """ Handles all POST requests, forwarding them to the appropriate
488 do_POST_* dispatcher. """
489 # All requests must be of this form:
490 # /dispatcher
491 # where 'dispatcher' indicates which do_POST_* dispatcher to run.
commit-bot@chromium.orge6af4fb2014-02-07 18:21:59 +0000492 logging.debug('do_POST: path="%s"' % self.path)
epoger@google.comeb832592013-10-23 15:07:26 +0000493 normpath = posixpath.normpath(self.path)
494 dispatchers = {
495 '/edits': self.do_POST_edits,
496 }
497 try:
498 dispatcher = dispatchers[normpath]
499 dispatcher()
500 self.send_response(200)
501 except:
502 self.send_error(404)
503 raise
504
505 def do_POST_edits(self):
506 """ Handle a POST request with modifications to GM expectations, in this
507 format:
508
509 {
commit-bot@chromium.org16f41802014-02-26 19:05:20 +0000510 KEY__EDITS__OLD_RESULTS_TYPE: 'all', # type of results that the client
511 # loaded and then made
512 # modifications to
513 KEY__EDITS__OLD_RESULTS_HASH: 39850913, # hash of results when the client
514 # loaded them (ensures that the
515 # client and server apply
516 # modifications to the same base)
517 KEY__EDITS__MODIFICATIONS: [
commit-bot@chromium.orgb463d562014-03-21 17:54:14 +0000518 # as needed by compare_to_expectations.edit_expectations()
epoger@google.comeb832592013-10-23 15:07:26 +0000519 ...
520 ],
521 }
522
523 Raises an Exception if there were any problems.
524 """
rmistry@google.comd6bab022013-12-02 13:50:38 +0000525 if not _SERVER.is_editable:
epoger@google.comeb832592013-10-23 15:07:26 +0000526 raise Exception('this server is not running in --editable mode')
527
528 content_type = self.headers[_HTTP_HEADER_CONTENT_TYPE]
529 if content_type != 'application/json;charset=UTF-8':
530 raise Exception('unsupported %s [%s]' % (
531 _HTTP_HEADER_CONTENT_TYPE, content_type))
532
533 content_length = int(self.headers[_HTTP_HEADER_CONTENT_LENGTH])
534 json_data = self.rfile.read(content_length)
535 data = json.loads(json_data)
536 logging.debug('do_POST_edits: received new GM expectations data [%s]' %
537 data)
538
rmistry@google.comd6bab022013-12-02 13:50:38 +0000539 # Update the results on disk with the information we received from the
540 # client.
541 # We must hold _SERVER.results_rlock while we do this, to guarantee that
542 # no other thread updates expectations (from the Skia repo) while we are
543 # updating them (using the info we received from the client).
544 with _SERVER.results_rlock:
commit-bot@chromium.org16f41802014-02-26 19:05:20 +0000545 oldResultsType = data[KEY__EDITS__OLD_RESULTS_TYPE]
rmistry@google.comd6bab022013-12-02 13:50:38 +0000546 oldResults = _SERVER.results.get_results_of_type(oldResultsType)
commit-bot@chromium.org16f41802014-02-26 19:05:20 +0000547 oldResultsHash = str(hash(repr(oldResults[imagepairset.KEY__IMAGEPAIRS])))
548 if oldResultsHash != data[KEY__EDITS__OLD_RESULTS_HASH]:
rmistry@google.comd6bab022013-12-02 13:50:38 +0000549 raise Exception('results of type "%s" changed while the client was '
550 'making modifications. The client should reload the '
551 'results and submit the modifications again.' %
552 oldResultsType)
commit-bot@chromium.org16f41802014-02-26 19:05:20 +0000553 _SERVER.results.edit_expectations(data[KEY__EDITS__MODIFICATIONS])
commit-bot@chromium.org50ad8e42013-12-17 18:06:13 +0000554
555 # Read the updated results back from disk.
556 # We can do this in a separate thread; we should return our success message
557 # to the UI as soon as possible.
558 thread.start_new_thread(_SERVER.update_results, (True,))
epoger@google.comeb832592013-10-23 15:07:26 +0000559
epoger@google.comf9d134d2013-09-27 15:02:44 +0000560 def redirect_to(self, url):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000561 """ Redirect the HTTP client to a different url.
562
563 Args:
564 url: URL to redirect the HTTP client to
565 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000566 self.send_response(301)
567 self.send_header('Location', url)
568 self.end_headers()
569
570 def send_file(self, path):
571 """ Send the contents of the file at this path, with a mimetype based
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000572 on the filename extension.
573
574 Args:
575 path: path of file whose contents to send to the HTTP client
576 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000577 # Grab the extension if there is one
578 extension = os.path.splitext(path)[1]
579 if len(extension) >= 1:
580 extension = extension[1:]
581
582 # Determine the MIME type of the file from its extension
583 mime_type = MIME_TYPE_MAP.get(extension, MIME_TYPE_MAP[''])
584
585 # Open the file and send it over HTTP
586 if os.path.isfile(path):
587 with open(path, 'rb') as sending_file:
588 self.send_response(200)
589 self.send_header('Content-type', mime_type)
590 self.end_headers()
591 self.wfile.write(sending_file.read())
592 else:
593 self.send_error(404)
594
commit-bot@chromium.orga25c4e42014-03-21 17:30:12 +0000595 def send_json_dict(self, json_dict):
596 """ Send the contents of this dictionary in JSON format, with a JSON
597 mimetype.
598
599 Args:
600 json_dict: dictionary to send
601 """
602 self.send_response(200)
603 self.send_header('Content-type', 'application/json')
604 self.end_headers()
605 json.dump(json_dict, self.wfile)
606
epoger@google.comf9d134d2013-09-27 15:02:44 +0000607
608def main():
commit-bot@chromium.orga6ecbb82013-12-19 19:08:31 +0000609 logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s',
610 datefmt='%m/%d/%Y %H:%M:%S',
611 level=logging.INFO)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000612 parser = argparse.ArgumentParser()
613 parser.add_argument('--actuals-dir',
614 help=('Directory into which we will check out the latest '
615 'actual GM results. If this directory does not '
616 'exist, it will be created. Defaults to %(default)s'),
617 default=DEFAULT_ACTUALS_DIR)
commit-bot@chromium.org5865ec52014-03-10 18:09:25 +0000618 parser.add_argument('--actuals-repo',
619 help=('URL of SVN repo to download actual-results.json '
commit-bot@chromium.orgc0df2fb2014-03-28 14:28:04 +0000620 'files from. Defaults to %(default)s ; if set to '
621 'empty string, just compare to actual-results '
622 'already found in ACTUALS_DIR.'),
commit-bot@chromium.org5865ec52014-03-10 18:09:25 +0000623 default=DEFAULT_ACTUALS_REPO_URL)
624 parser.add_argument('--actuals-revision',
625 help=('revision of actual-results.json files to process. '
626 'Defaults to %(default)s . Beware of setting this '
627 'argument in conjunction with --editable; you '
628 'probably only want to edit results at HEAD.'),
629 default=DEFAULT_ACTUALS_REPO_REVISION)
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000630 parser.add_argument('--compare-configs', action='store_true',
631 help=('In addition to generating differences between '
632 'expectations and actuals, also generate '
633 'differences between these config pairs: '
634 + str(CONFIG_PAIRS_TO_COMPARE)))
epoger@google.com542b65f2013-10-15 20:10:33 +0000635 parser.add_argument('--editable', action='store_true',
epoger@google.comeb832592013-10-23 15:07:26 +0000636 help=('Allow HTTP clients to submit new baselines.'))
epoger@google.comf9d134d2013-09-27 15:02:44 +0000637 parser.add_argument('--export', action='store_true',
638 help=('Instead of only allowing access from HTTP clients '
639 'on localhost, allow HTTP clients on other hosts '
640 'to access this server. WARNING: doing so will '
641 'allow users on other hosts to modify your '
epoger@google.com542b65f2013-10-15 20:10:33 +0000642 'GM expectations, if combined with --editable.'))
epoger@google.comafaad3d2013-09-30 15:06:25 +0000643 parser.add_argument('--port', type=int,
644 help=('Which TCP port to listen on for HTTP requests; '
645 'defaults to %(default)s'),
646 default=DEFAULT_PORT)
epoger@google.com542b65f2013-10-15 20:10:33 +0000647 parser.add_argument('--reload', type=int,
648 help=('How often (a period in seconds) to update the '
epoger@google.comb063e132013-11-25 18:06:29 +0000649 'results. If specified, both expected and actual '
rmistry@google.comd6bab022013-12-02 13:50:38 +0000650 'results will be updated by running "gclient sync" '
651 'on your Skia checkout as a whole. '
epoger@google.com542b65f2013-10-15 20:10:33 +0000652 'By default, we do not reload at all, and you '
653 'must restart the server to pick up new data.'),
654 default=0)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000655 args = parser.parse_args()
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000656 if args.compare_configs:
657 config_pairs = CONFIG_PAIRS_TO_COMPARE
658 else:
659 config_pairs = None
660
epoger@google.comf9d134d2013-09-27 15:02:44 +0000661 global _SERVER
epoger@google.com542b65f2013-10-15 20:10:33 +0000662 _SERVER = Server(actuals_dir=args.actuals_dir,
commit-bot@chromium.org5865ec52014-03-10 18:09:25 +0000663 actuals_repo_revision=args.actuals_revision,
664 actuals_repo_url=args.actuals_repo,
epoger@google.com542b65f2013-10-15 20:10:33 +0000665 port=args.port, export=args.export, editable=args.editable,
commit-bot@chromium.org31d0b3d2014-03-31 15:17:52 +0000666 reload_seconds=args.reload, config_pairs=config_pairs)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000667 _SERVER.run()
668
rmistry@google.comd6bab022013-12-02 13:50:38 +0000669
epoger@google.comf9d134d2013-09-27 15:02:44 +0000670if __name__ == '__main__':
671 main()