skia2/gm/rebaseline_server/server.py
epoger@google.com dcb4e65998 rebaseline_server: allow client to pull all results, or just failures
(SkipBuildbotRuns)

This will be handy for constrained networks or devices, where we don't want
to bother downloading info about all the successful tests.

R=jcgregorio@google.com

Review URL: https://codereview.chromium.org/26891003

git-svn-id: http://skia.googlecode.com/svn/trunk@11737 2bbb7eff-a529-9590-31e7-b0007b416f81
2013-10-11 18:45:33 +00:00

284 lines
10 KiB
Python
Executable File

#!/usr/bin/python
"""
Copyright 2013 Google Inc.
Use of this source code is governed by a BSD-style license that can be
found in the LICENSE file.
HTTP server for our HTML rebaseline viewer.
"""
# System-level imports
import argparse
import BaseHTTPServer
import json
import logging
import os
import posixpath
import re
import shutil
import sys
import urlparse
# Imports from within Skia
#
# We need to add the 'tools' directory, so that we can import svn.py within
# that directory.
# Make sure that the 'tools' dir is in the PYTHONPATH, but add it at the *end*
# so any dirs that are already in the PYTHONPATH will be preferred.
PARENT_DIRECTORY = os.path.dirname(os.path.realpath(__file__))
TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(PARENT_DIRECTORY))
TOOLS_DIRECTORY = os.path.join(TRUNK_DIRECTORY, 'tools')
if TOOLS_DIRECTORY not in sys.path:
sys.path.append(TOOLS_DIRECTORY)
import svn
# Imports from local dir
import results
ACTUALS_SVN_REPO = 'http://skia-autogen.googlecode.com/svn/gm-actual'
PATHSPLIT_RE = re.compile('/([^/]+)/(.+)')
TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.realpath(__file__))))
# A simple dictionary of file name extensions to MIME types. The empty string
# entry is used as the default when no extension was given or if the extension
# has no entry in this dictionary.
MIME_TYPE_MAP = {'': 'application/octet-stream',
'html': 'text/html',
'css': 'text/css',
'png': 'image/png',
'js': 'application/javascript',
'json': 'application/json'
}
DEFAULT_ACTUALS_DIR = '.gm-actuals'
DEFAULT_EXPECTATIONS_DIR = os.path.join(TRUNK_DIRECTORY, 'expectations', 'gm')
DEFAULT_PORT = 8888
_SERVER = None # This gets filled in by main()
class Server(object):
""" HTTP server for our HTML rebaseline viewer. """
def __init__(self,
actuals_dir=DEFAULT_ACTUALS_DIR,
expectations_dir=DEFAULT_EXPECTATIONS_DIR,
port=DEFAULT_PORT, export=False):
"""
Args:
actuals_dir: directory under which we will check out the latest actual
GM results
expectations_dir: directory under which to find GM expectations (they
must already be in that directory)
port: which TCP port to listen on for HTTP requests
export: whether to allow HTTP clients on other hosts to access this server
"""
self._actuals_dir = actuals_dir
self._expectations_dir = expectations_dir
self._port = port
self._export = export
def is_exported(self):
""" Returns true iff HTTP clients on other hosts are allowed to access
this server. """
return self._export
def fetch_results(self):
""" Create self.results, based on the expectations in
self._expectations_dir and the latest actuals from skia-autogen.
TODO(epoger): Add a new --browseonly mode setting. In that mode,
the gm-actuals and expectations will automatically be updated every few
minutes. See discussion in https://codereview.chromium.org/24274003/ .
"""
logging.info('Checking out latest actual GM results from %s into %s ...' % (
ACTUALS_SVN_REPO, self._actuals_dir))
actuals_repo = svn.Svn(self._actuals_dir)
if not os.path.isdir(self._actuals_dir):
os.makedirs(self._actuals_dir)
actuals_repo.Checkout(ACTUALS_SVN_REPO, '.')
else:
actuals_repo.Update('.')
logging.info(
'Parsing results from actuals in %s and expectations in %s ...' % (
self._actuals_dir, self._expectations_dir))
self.results = results.Results(
actuals_root=self._actuals_dir,
expected_root=self._expectations_dir)
def run(self):
self.fetch_results()
if self._export:
server_address = ('', self._port)
logging.warning('Running in "export" mode. Users on other machines will '
'be able to modify your GM expectations!')
else:
server_address = ('127.0.0.1', self._port)
http_server = BaseHTTPServer.HTTPServer(server_address, HTTPRequestHandler)
logging.info('Ready for requests on http://%s:%d' % (
http_server.server_name, http_server.server_port))
http_server.serve_forever()
class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
""" HTTP request handlers for various types of queries this server knows
how to handle (static HTML and Javascript, expected/actual results, etc.)
"""
def do_GET(self):
""" Handles all GET requests, forwarding them to the appropriate
do_GET_* dispatcher. """
if self.path == '' or self.path == '/' or self.path == '/index.html' :
self.redirect_to('/static/view.html?resultsToLoad=all')
return
if self.path == '/favicon.ico' :
self.redirect_to('/static/favicon.ico')
return
# All requests must be of this form:
# /dispatcher/remainder
# where 'dispatcher' indicates which do_GET_* dispatcher to run
# and 'remainder' is the remaining path sent to that dispatcher.
normpath = posixpath.normpath(self.path)
(dispatcher_name, remainder) = PATHSPLIT_RE.match(normpath).groups()
dispatchers = {
'results': self.do_GET_results,
'static': self.do_GET_static,
}
dispatcher = dispatchers[dispatcher_name]
dispatcher(remainder)
def do_GET_results(self, type):
""" Handle a GET request for GM results.
Args:
type: string indicating which set of results to return;
must be one of the results.RESULTS_* constants
"""
logging.debug('do_GET_results: sending results of type "%s"' % type)
try:
# TODO(epoger): Rather than using a global variable for the handler
# to refer to the Server object, make Server a subclass of
# HTTPServer, and then it could be available to the handler via
# the handler's .server instance variable.
response_dict = _SERVER.results.get_results_of_type(type)
response_dict['header'] = {
# Hash of testData, which the client must return with any edits--
# this ensures that the edits were made to a particular dataset.
'data-hash': str(hash(repr(response_dict['testData']))),
# Whether the server will accept edits back.
# TODO(epoger): Not yet implemented, so hardcoding to False;
# once we implement the 'browseonly' mode discussed in
# https://codereview.chromium.org/24274003/#msg6 , this value will vary.
'isEditable': False,
# Whether the service is accessible from other hosts.
'isExported': _SERVER.is_exported(),
}
self.send_json_dict(response_dict)
except:
self.send_error(404)
def do_GET_static(self, path):
""" Handle a GET request for a file under the 'static' directory.
Only allow serving of files within the 'static' directory that is a
filesystem sibling of this script.
Args:
path: path to file (under static directory) to retrieve
"""
# Strip arguments ('?resultsToLoad=all') from the path
path = urlparse.urlparse(path).path
logging.debug('do_GET_static: sending file "%s"' % path)
static_dir = os.path.realpath(os.path.join(PARENT_DIRECTORY, 'static'))
full_path = os.path.realpath(os.path.join(static_dir, path))
if full_path.startswith(static_dir):
self.send_file(full_path)
else:
logging.error(
'Attempted do_GET_static() of path [%s] outside of static dir [%s]'
% (full_path, static_dir))
self.send_error(404)
def redirect_to(self, url):
""" Redirect the HTTP client to a different url.
Args:
url: URL to redirect the HTTP client to
"""
self.send_response(301)
self.send_header('Location', url)
self.end_headers()
def send_file(self, path):
""" Send the contents of the file at this path, with a mimetype based
on the filename extension.
Args:
path: path of file whose contents to send to the HTTP client
"""
# Grab the extension if there is one
extension = os.path.splitext(path)[1]
if len(extension) >= 1:
extension = extension[1:]
# Determine the MIME type of the file from its extension
mime_type = MIME_TYPE_MAP.get(extension, MIME_TYPE_MAP[''])
# Open the file and send it over HTTP
if os.path.isfile(path):
with open(path, 'rb') as sending_file:
self.send_response(200)
self.send_header('Content-type', mime_type)
self.end_headers()
self.wfile.write(sending_file.read())
else:
self.send_error(404)
def send_json_dict(self, json_dict):
""" Send the contents of this dictionary in JSON format, with a JSON
mimetype.
Args:
json_dict: dictionary to send
"""
self.send_response(200)
self.send_header('Content-type', 'application/json')
self.end_headers()
json.dump(json_dict, self.wfile)
def main():
logging.basicConfig(level=logging.INFO)
parser = argparse.ArgumentParser()
parser.add_argument('--actuals-dir',
help=('Directory into which we will check out the latest '
'actual GM results. If this directory does not '
'exist, it will be created. Defaults to %(default)s'),
default=DEFAULT_ACTUALS_DIR)
parser.add_argument('--expectations-dir',
help=('Directory under which to find GM expectations; '
'defaults to %(default)s'),
default=DEFAULT_EXPECTATIONS_DIR)
parser.add_argument('--export', action='store_true',
help=('Instead of only allowing access from HTTP clients '
'on localhost, allow HTTP clients on other hosts '
'to access this server. WARNING: doing so will '
'allow users on other hosts to modify your '
'GM expectations!'))
parser.add_argument('--port', type=int,
help=('Which TCP port to listen on for HTTP requests; '
'defaults to %(default)s'),
default=DEFAULT_PORT)
args = parser.parse_args()
global _SERVER
_SERVER = Server(expectations_dir=args.expectations_dir,
port=args.port, export=args.export)
_SERVER.run()
if __name__ == '__main__':
main()