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