server.py revision cb55f11a8f8ad66cdfc7643298a8772837cc35df
1#!/usr/bin/python
2
3'''
4Copyright 2013 Google Inc.
5
6Use of this source code is governed by a BSD-style license that can be
7found in the LICENSE file.
8'''
9
10'''
11HTTP server for our HTML rebaseline viewer.
12'''
13
14# System-level imports
15import argparse
16import BaseHTTPServer
17import json
18import os
19import posixpath
20import re
21import shutil
22import sys
23
24# Imports from within Skia
25#
26# We need to add the 'tools' directory, so that we can import svn.py within
27# that directory.
28# Make sure that the 'tools' dir is in the PYTHONPATH, but add it at the *end*
29# so any dirs that are already in the PYTHONPATH will be preferred.
30PARENT_DIRECTORY = os.path.dirname(os.path.realpath(__file__))
31TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(PARENT_DIRECTORY))
32TOOLS_DIRECTORY = os.path.join(TRUNK_DIRECTORY, 'tools')
33if TOOLS_DIRECTORY not in sys.path:
34  sys.path.append(TOOLS_DIRECTORY)
35import svn
36
37# Imports from local dir
38import results
39
40ACTUALS_SVN_REPO = 'http://skia-autogen.googlecode.com/svn/gm-actual'
41PATHSPLIT_RE = re.compile('/([^/]+)/(.+)')
42TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(os.path.dirname(
43    os.path.realpath(__file__))))
44
45# A simple dictionary of file name extensions to MIME types. The empty string
46# entry is used as the default when no extension was given or if the extension
47# has no entry in this dictionary.
48MIME_TYPE_MAP = {'': 'application/octet-stream',
49                 'html': 'text/html',
50                 'css': 'text/css',
51                 'png': 'image/png',
52                 'js': 'application/javascript',
53                 'json': 'application/json'
54                 }
55
56DEFAULT_ACTUALS_DIR = '.gm-actuals'
57DEFAULT_EXPECTATIONS_DIR = os.path.join(TRUNK_DIRECTORY, 'expectations', 'gm')
58DEFAULT_PORT = 8888
59
60_SERVER = None   # This gets filled in by main()
61
62class Server(object):
63  """ HTTP server for our HTML rebaseline viewer.
64
65  params:
66    actuals_dir: directory under which we will check out the latest actual
67                 GM results
68    expectations_dir: directory under which to find GM expectations (they
69                      must already be in that directory)
70    port: which TCP port to listen on for HTTP requests
71    export: whether to allow HTTP clients on other hosts to access this server
72  """
73  def __init__(self,
74               actuals_dir=DEFAULT_ACTUALS_DIR,
75               expectations_dir=DEFAULT_EXPECTATIONS_DIR,
76               port=DEFAULT_PORT, export=False):
77    self._actuals_dir = actuals_dir
78    self._expectations_dir = expectations_dir
79    self._port = port
80    self._export = export
81
82  def fetch_results(self):
83    """ Create self.results, based on the expectations in
84    self._expectations_dir and the latest actuals from skia-autogen.
85
86    TODO(epoger): Add a new --browseonly mode setting.  In that mode,
87    the gm-actuals and expectations will automatically be updated every few
88    minutes.  See discussion in https://codereview.chromium.org/24274003/ .
89    """
90    print 'Checking out latest actual GM results from %s into %s ...' % (
91        ACTUALS_SVN_REPO, self._actuals_dir)
92    actuals_repo = svn.Svn(self._actuals_dir)
93    if not os.path.isdir(self._actuals_dir):
94      os.makedirs(self._actuals_dir)
95      actuals_repo.Checkout(ACTUALS_SVN_REPO, '.')
96    else:
97      actuals_repo.Update('.')
98    print 'Parsing results from actuals in %s and expectations in %s ...' % (
99        self._actuals_dir, self._expectations_dir)
100    self.results = results.Results(
101      actuals_root=self._actuals_dir,
102      expected_root=self._expectations_dir)
103
104  def run(self):
105    self.fetch_results()
106    if self._export:
107      server_address = ('', self._port)
108      print ('WARNING: Running in "export" mode. Users on other machines will '
109             'be able to modify your GM expectations!')
110    else:
111      server_address = ('127.0.0.1', self._port)
112    http_server = BaseHTTPServer.HTTPServer(server_address, HTTPRequestHandler)
113    print 'Ready for requests on http://%s:%d' % (
114        http_server.server_name, http_server.server_port)
115    http_server.serve_forever()
116
117
118class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
119  """ HTTP request handlers for various types of queries this server knows
120      how to handle (static HTML and Javascript, expected/actual results, etc.)
121  """
122  def do_GET(self):
123    """ Handles all GET requests, forwarding them to the appropriate
124        do_GET_* dispatcher. """
125    if self.path == '' or self.path == '/' or self.path == '/index.html' :
126      self.redirect_to('/static/view.html')
127      return
128    if self.path == '/favicon.ico' :
129      self.redirect_to('/static/favicon.ico')
130      return
131
132    # All requests must be of this form:
133    #   /dispatcher/remainder
134    # where "dispatcher" indicates which do_GET_* dispatcher to run
135    # and "remainder" is the remaining path sent to that dispatcher.
136    normpath = posixpath.normpath(self.path)
137    (dispatcher_name, remainder) = PATHSPLIT_RE.match(normpath).groups()
138    dispatchers = {
139      'results': self.do_GET_results,
140      'static': self.do_GET_static,
141    }
142    dispatcher = dispatchers[dispatcher_name]
143    dispatcher(remainder)
144
145  def do_GET_results(self, result_type):
146    """ Handle a GET request for GM results.
147    For now, we ignore the remaining path info, because we only know how to
148    return all results.
149
150    TODO(epoger): Unless we start making use of result_type, remove that
151    parameter."""
152    print 'do_GET_results: sending results of type "%s"' % result_type
153    response_dict = _SERVER.results.GetAll()
154    if response_dict:
155      self.send_json_dict(response_dict)
156    else:
157      self.send_error(404)
158
159  def do_GET_static(self, path):
160    """ Handle a GET request for a file under the 'static' directory.
161    Only allow serving of files within the 'static' directory that is a
162    filesystem sibling of this script. """
163    print 'do_GET_static: sending file "%s"' % path
164    static_dir = os.path.realpath(os.path.join(PARENT_DIRECTORY, 'static'))
165    full_path = os.path.realpath(os.path.join(static_dir, path))
166    if full_path.startswith(static_dir):
167      self.send_file(full_path)
168    else:
169      print ('Attempted do_GET_static() of path [%s] outside of static dir [%s]'
170             % (full_path, static_dir))
171      self.send_error(404)
172
173  def redirect_to(self, url):
174    """ Redirect the HTTP client to a different url. """
175    self.send_response(301)
176    self.send_header('Location', url)
177    self.end_headers()
178
179  def send_file(self, path):
180    """ Send the contents of the file at this path, with a mimetype based
181        on the filename extension. """
182    # Grab the extension if there is one
183    extension = os.path.splitext(path)[1]
184    if len(extension) >= 1:
185      extension = extension[1:]
186
187    # Determine the MIME type of the file from its extension
188    mime_type = MIME_TYPE_MAP.get(extension, MIME_TYPE_MAP[''])
189
190    # Open the file and send it over HTTP
191    if os.path.isfile(path):
192      with open(path, 'rb') as sending_file:
193        self.send_response(200)
194        self.send_header('Content-type', mime_type)
195        self.end_headers()
196        self.wfile.write(sending_file.read())
197    else:
198      self.send_error(404)
199
200  def send_json_dict(self, json_dict):
201    """ Send the contents of this dictionary in JSON format, with a JSON
202        mimetype. """
203    self.send_response(200)
204    self.send_header('Content-type', 'application/json')
205    self.end_headers()
206    json.dump(json_dict, self.wfile)
207
208
209def main():
210  parser = argparse.ArgumentParser()
211  parser.add_argument('--actuals-dir',
212                    help=('Directory into which we will check out the latest '
213                          'actual GM results. If this directory does not '
214                          'exist, it will be created. Defaults to %(default)s'),
215                    default=DEFAULT_ACTUALS_DIR)
216  parser.add_argument('--expectations-dir',
217                    help=('Directory under which to find GM expectations; '
218                          'defaults to %(default)s'),
219                    default=DEFAULT_EXPECTATIONS_DIR)
220  parser.add_argument('--export', action='store_true',
221                      help=('Instead of only allowing access from HTTP clients '
222                            'on localhost, allow HTTP clients on other hosts '
223                            'to access this server.  WARNING: doing so will '
224                            'allow users on other hosts to modify your '
225                            'GM expectations!'))
226  parser.add_argument('--port', type=int,
227                      help=('Which TCP port to listen on for HTTP requests; '
228                            'defaults to %(default)s'),
229                      default=DEFAULT_PORT)
230  args = parser.parse_args()
231  global _SERVER
232  _SERVER = Server(expectations_dir=args.expectations_dir,
233                   port=args.port, export=args.export)
234  _SERVER.run()
235
236if __name__ == '__main__':
237  main()
238