blob: 81e0c14454e5a0a4b20116bed3ea1c8a134c09d1 [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
epoger@google.comf9d134d2013-09-27 15:02:44 +000022import sys
epoger@google.com542b65f2013-10-15 20:10:33 +000023import thread
24import time
epoger@google.comdcb4e652013-10-11 18:45:33 +000025import urlparse
epoger@google.comf9d134d2013-09-27 15:02:44 +000026
27# Imports from within Skia
28#
29# We need to add the 'tools' directory, so that we can import svn.py within
30# that directory.
31# Make sure that the 'tools' dir is in the PYTHONPATH, but add it at the *end*
32# so any dirs that are already in the PYTHONPATH will be preferred.
epoger@google.comcb55f112013-10-02 19:27:35 +000033PARENT_DIRECTORY = os.path.dirname(os.path.realpath(__file__))
34TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(PARENT_DIRECTORY))
epoger@google.comf9d134d2013-09-27 15:02:44 +000035TOOLS_DIRECTORY = os.path.join(TRUNK_DIRECTORY, 'tools')
36if TOOLS_DIRECTORY not in sys.path:
37 sys.path.append(TOOLS_DIRECTORY)
38import svn
39
40# Imports from local dir
41import results
42
43ACTUALS_SVN_REPO = 'http://skia-autogen.googlecode.com/svn/gm-actual'
epoger@google.com542b65f2013-10-15 20:10:33 +000044EXPECTATIONS_SVN_REPO = 'http://skia.googlecode.com/svn/trunk/expectations/gm'
epoger@google.comf9d134d2013-09-27 15:02:44 +000045PATHSPLIT_RE = re.compile('/([^/]+)/(.+)')
46TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(os.path.dirname(
47 os.path.realpath(__file__))))
epoger@google.com9dddf6f2013-11-08 16:25:25 +000048GENERATED_IMAGES_ROOT = os.path.join(PARENT_DIRECTORY, 'static',
49 'generated-images')
epoger@google.comf9d134d2013-09-27 15:02:44 +000050
51# A simple dictionary of file name extensions to MIME types. The empty string
52# entry is used as the default when no extension was given or if the extension
53# has no entry in this dictionary.
54MIME_TYPE_MAP = {'': 'application/octet-stream',
55 'html': 'text/html',
56 'css': 'text/css',
57 'png': 'image/png',
58 'js': 'application/javascript',
59 'json': 'application/json'
60 }
61
62DEFAULT_ACTUALS_DIR = '.gm-actuals'
63DEFAULT_EXPECTATIONS_DIR = os.path.join(TRUNK_DIRECTORY, 'expectations', 'gm')
64DEFAULT_PORT = 8888
65
epoger@google.comeb832592013-10-23 15:07:26 +000066_HTTP_HEADER_CONTENT_LENGTH = 'Content-Length'
67_HTTP_HEADER_CONTENT_TYPE = 'Content-Type'
68
epoger@google.comf9d134d2013-09-27 15:02:44 +000069_SERVER = None # This gets filled in by main()
70
epoger@google.comb08c7072013-10-30 14:09:04 +000071def get_routable_ip_address():
72 """Returns routable IP address of this host (the IP address of its network
73 interface that would be used for most traffic, not its localhost
74 interface). See http://stackoverflow.com/a/166589 """
75 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
76 sock.connect(('8.8.8.8', 80))
77 host = sock.getsockname()[0]
78 sock.close()
79 return host
80
81
epoger@google.comf9d134d2013-09-27 15:02:44 +000082class Server(object):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +000083 """ HTTP server for our HTML rebaseline viewer. """
epoger@google.comf9d134d2013-09-27 15:02:44 +000084
epoger@google.comf9d134d2013-09-27 15:02:44 +000085 def __init__(self,
86 actuals_dir=DEFAULT_ACTUALS_DIR,
87 expectations_dir=DEFAULT_EXPECTATIONS_DIR,
epoger@google.com542b65f2013-10-15 20:10:33 +000088 port=DEFAULT_PORT, export=False, editable=True,
89 reload_seconds=0):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +000090 """
91 Args:
92 actuals_dir: directory under which we will check out the latest actual
93 GM results
94 expectations_dir: directory under which to find GM expectations (they
95 must already be in that directory)
96 port: which TCP port to listen on for HTTP requests
97 export: whether to allow HTTP clients on other hosts to access this server
epoger@google.com542b65f2013-10-15 20:10:33 +000098 editable: whether HTTP clients are allowed to submit new baselines
99 reload_seconds: polling interval with which to check for new results;
100 if 0, don't check for new results at all
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000101 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000102 self._actuals_dir = actuals_dir
103 self._expectations_dir = expectations_dir
104 self._port = port
105 self._export = export
epoger@google.com542b65f2013-10-15 20:10:33 +0000106 self._editable = editable
107 self._reload_seconds = reload_seconds
epoger@google.comf9d134d2013-09-27 15:02:44 +0000108
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000109 def is_exported(self):
110 """ Returns true iff HTTP clients on other hosts are allowed to access
111 this server. """
112 return self._export
113
epoger@google.com542b65f2013-10-15 20:10:33 +0000114 def is_editable(self):
115 """ Returns true iff HTTP clients are allowed to submit new baselines. """
116 return self._editable
epoger@google.comf9d134d2013-09-27 15:02:44 +0000117
epoger@google.com542b65f2013-10-15 20:10:33 +0000118 def reload_seconds(self):
119 """ Returns the result reload period in seconds, or 0 if we don't reload
120 results. """
121 return self._reload_seconds
122
epoger@google.comeb832592013-10-23 15:07:26 +0000123 def update_results(self):
epoger@google.com542b65f2013-10-15 20:10:33 +0000124 """ Create or update self.results, based on the expectations in
125 self._expectations_dir and the latest actuals from skia-autogen.
epoger@google.comf9d134d2013-09-27 15:02:44 +0000126 """
epoger@google.com61952902013-11-08 17:23:54 +0000127 with self._svn_update_lock:
128 # self._svn_update_lock prevents us from updating the actual GM results
epoger@google.comeb832592013-10-23 15:07:26 +0000129 # in multiple threads simultaneously
130 logging.info('Updating actual GM results in %s from SVN repo %s ...' % (
131 self._actuals_dir, ACTUALS_SVN_REPO))
132 actuals_repo = svn.Svn(self._actuals_dir)
133 if not os.path.isdir(self._actuals_dir):
134 os.makedirs(self._actuals_dir)
135 actuals_repo.Checkout(ACTUALS_SVN_REPO, '.')
epoger@google.com542b65f2013-10-15 20:10:33 +0000136 else:
epoger@google.comeb832592013-10-23 15:07:26 +0000137 actuals_repo.Update('.')
epoger@google.comeb832592013-10-23 15:07:26 +0000138 # We only update the expectations dir if the server was run with a
139 # nonzero --reload argument; otherwise, we expect the user to maintain
140 # her own expectations as she sees fit.
141 #
epoger@google.com61952902013-11-08 17:23:54 +0000142 # self._svn_update_lock prevents us from updating the expected GM results
epoger@google.comeb832592013-10-23 15:07:26 +0000143 # in multiple threads simultaneously
144 #
145 # TODO(epoger): Use git instead of svn to check out expectations, since
146 # the Skia repo is moving to git.
147 if self._reload_seconds:
148 logging.info(
149 'Updating expected GM results in %s from SVN repo %s ...' % (
150 self._expectations_dir, EXPECTATIONS_SVN_REPO))
151 expectations_repo = svn.Svn(self._expectations_dir)
152 if not os.path.isdir(self._expectations_dir):
153 os.makedirs(self._expectations_dir)
154 expectations_repo.Checkout(EXPECTATIONS_SVN_REPO, '.')
155 else:
156 expectations_repo.Update('.')
epoger@google.com61952902013-11-08 17:23:54 +0000157 # end of "with self._svn_update_lock:"
epoger@google.comeb832592013-10-23 15:07:26 +0000158
epoger@google.com61952902013-11-08 17:23:54 +0000159 logging.info(
epoger@google.com9dddf6f2013-11-08 16:25:25 +0000160 ('Parsing results from actuals in %s and expectations in %s, '
161 + 'and generating pixel diffs (may take a while) ...') % (
epoger@google.comeb832592013-10-23 15:07:26 +0000162 self._actuals_dir, self._expectations_dir))
epoger@google.com61952902013-11-08 17:23:54 +0000163 new_results = results.Results(
epoger@google.comeb832592013-10-23 15:07:26 +0000164 actuals_root=self._actuals_dir,
epoger@google.com9dddf6f2013-11-08 16:25:25 +0000165 expected_root=self._expectations_dir,
166 generated_images_root=GENERATED_IMAGES_ROOT)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000167
epoger@google.com61952902013-11-08 17:23:54 +0000168 # Make sure we don't update self.results while a client is in the middle
169 # of reading from it.
170 with self.results_lock:
171 self.results = new_results
172
epoger@google.com542b65f2013-10-15 20:10:33 +0000173 def _result_reloader(self):
174 """ If --reload argument was specified, reload results at the appropriate
175 interval.
176 """
177 while self._reload_seconds:
178 time.sleep(self._reload_seconds)
epoger@google.comeb832592013-10-23 15:07:26 +0000179 self.update_results()
epoger@google.com542b65f2013-10-15 20:10:33 +0000180
epoger@google.comf9d134d2013-09-27 15:02:44 +0000181 def run(self):
epoger@google.com542b65f2013-10-15 20:10:33 +0000182 self.results_lock = thread.allocate_lock()
epoger@google.com61952902013-11-08 17:23:54 +0000183 self._svn_update_lock = thread.allocate_lock()
epoger@google.comeb832592013-10-23 15:07:26 +0000184 self.update_results()
epoger@google.com542b65f2013-10-15 20:10:33 +0000185 thread.start_new_thread(self._result_reloader, ())
186
epoger@google.comf9d134d2013-09-27 15:02:44 +0000187 if self._export:
188 server_address = ('', self._port)
epoger@google.comb08c7072013-10-30 14:09:04 +0000189 host = get_routable_ip_address()
epoger@google.com542b65f2013-10-15 20:10:33 +0000190 if self._editable:
191 logging.warning('Running with combination of "export" and "editable" '
192 'flags. Users on other machines will '
193 'be able to modify your GM expectations!')
epoger@google.comf9d134d2013-09-27 15:02:44 +0000194 else:
epoger@google.comb08c7072013-10-30 14:09:04 +0000195 host = '127.0.0.1'
196 server_address = (host, self._port)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000197 http_server = BaseHTTPServer.HTTPServer(server_address, HTTPRequestHandler)
epoger@google.com61952902013-11-08 17:23:54 +0000198 logging.info('Ready for requests on http://%s:%d' % (
199 host, http_server.server_port))
epoger@google.comf9d134d2013-09-27 15:02:44 +0000200 http_server.serve_forever()
201
202
203class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
204 """ HTTP request handlers for various types of queries this server knows
205 how to handle (static HTML and Javascript, expected/actual results, etc.)
206 """
207 def do_GET(self):
208 """ Handles all GET requests, forwarding them to the appropriate
209 do_GET_* dispatcher. """
210 if self.path == '' or self.path == '/' or self.path == '/index.html' :
epoger@google.com045c3d32013-11-01 16:46:41 +0000211 self.redirect_to('/static/index.html')
epoger@google.comf9d134d2013-09-27 15:02:44 +0000212 return
213 if self.path == '/favicon.ico' :
214 self.redirect_to('/static/favicon.ico')
215 return
216
217 # All requests must be of this form:
218 # /dispatcher/remainder
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000219 # where 'dispatcher' indicates which do_GET_* dispatcher to run
220 # and 'remainder' is the remaining path sent to that dispatcher.
epoger@google.comf9d134d2013-09-27 15:02:44 +0000221 normpath = posixpath.normpath(self.path)
222 (dispatcher_name, remainder) = PATHSPLIT_RE.match(normpath).groups()
223 dispatchers = {
224 'results': self.do_GET_results,
225 'static': self.do_GET_static,
226 }
227 dispatcher = dispatchers[dispatcher_name]
228 dispatcher(remainder)
229
epoger@google.comdcb4e652013-10-11 18:45:33 +0000230 def do_GET_results(self, type):
epoger@google.comf9d134d2013-09-27 15:02:44 +0000231 """ Handle a GET request for GM results.
epoger@google.comf9d134d2013-09-27 15:02:44 +0000232
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000233 Args:
epoger@google.comdcb4e652013-10-11 18:45:33 +0000234 type: string indicating which set of results to return;
235 must be one of the results.RESULTS_* constants
236 """
237 logging.debug('do_GET_results: sending results of type "%s"' % type)
238 try:
239 # TODO(epoger): Rather than using a global variable for the handler
240 # to refer to the Server object, make Server a subclass of
241 # HTTPServer, and then it could be available to the handler via
242 # the handler's .server instance variable.
epoger@google.com542b65f2013-10-15 20:10:33 +0000243
244 with _SERVER.results_lock:
245 response_dict = _SERVER.results.get_results_of_type(type)
246 time_updated = _SERVER.results.get_timestamp()
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000247 response_dict['header'] = {
epoger@google.com542b65f2013-10-15 20:10:33 +0000248 # Timestamps:
249 # 1. when this data was last updated
250 # 2. when the caller should check back for new data (if ever)
251 #
252 # We only return these timestamps if the --reload argument was passed;
253 # otherwise, we have no idea when the expectations were last updated
254 # (we allow the user to maintain her own expectations as she sees fit).
255 'timeUpdated': time_updated if _SERVER.reload_seconds() else None,
256 'timeNextUpdateAvailable': (
257 (time_updated+_SERVER.reload_seconds()) if _SERVER.reload_seconds()
258 else None),
259
epoger@google.comeb832592013-10-23 15:07:26 +0000260 # The type we passed to get_results_of_type()
261 'type': type,
262
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000263 # Hash of testData, which the client must return with any edits--
264 # this ensures that the edits were made to a particular dataset.
epoger@google.com542b65f2013-10-15 20:10:33 +0000265 'dataHash': str(hash(repr(response_dict['testData']))),
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000266
267 # Whether the server will accept edits back.
epoger@google.com542b65f2013-10-15 20:10:33 +0000268 'isEditable': _SERVER.is_editable(),
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000269
270 # Whether the service is accessible from other hosts.
271 'isExported': _SERVER.is_exported(),
272 }
epoger@google.comf9d134d2013-09-27 15:02:44 +0000273 self.send_json_dict(response_dict)
epoger@google.comdcb4e652013-10-11 18:45:33 +0000274 except:
epoger@google.comf9d134d2013-09-27 15:02:44 +0000275 self.send_error(404)
epoger@google.com542b65f2013-10-15 20:10:33 +0000276 raise
epoger@google.comf9d134d2013-09-27 15:02:44 +0000277
278 def do_GET_static(self, path):
epoger@google.comcb55f112013-10-02 19:27:35 +0000279 """ Handle a GET request for a file under the 'static' directory.
280 Only allow serving of files within the 'static' directory that is a
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000281 filesystem sibling of this script.
282
283 Args:
284 path: path to file (under static directory) to retrieve
285 """
epoger@google.comdcb4e652013-10-11 18:45:33 +0000286 # Strip arguments ('?resultsToLoad=all') from the path
287 path = urlparse.urlparse(path).path
288
289 logging.debug('do_GET_static: sending file "%s"' % path)
epoger@google.comcb55f112013-10-02 19:27:35 +0000290 static_dir = os.path.realpath(os.path.join(PARENT_DIRECTORY, 'static'))
291 full_path = os.path.realpath(os.path.join(static_dir, path))
292 if full_path.startswith(static_dir):
293 self.send_file(full_path)
294 else:
epoger@google.comdcb4e652013-10-11 18:45:33 +0000295 logging.error(
296 'Attempted do_GET_static() of path [%s] outside of static dir [%s]'
297 % (full_path, static_dir))
epoger@google.comcb55f112013-10-02 19:27:35 +0000298 self.send_error(404)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000299
epoger@google.comeb832592013-10-23 15:07:26 +0000300 def do_POST(self):
301 """ Handles all POST requests, forwarding them to the appropriate
302 do_POST_* dispatcher. """
303 # All requests must be of this form:
304 # /dispatcher
305 # where 'dispatcher' indicates which do_POST_* dispatcher to run.
306 normpath = posixpath.normpath(self.path)
307 dispatchers = {
308 '/edits': self.do_POST_edits,
309 }
310 try:
311 dispatcher = dispatchers[normpath]
312 dispatcher()
313 self.send_response(200)
314 except:
315 self.send_error(404)
316 raise
317
318 def do_POST_edits(self):
319 """ Handle a POST request with modifications to GM expectations, in this
320 format:
321
322 {
323 'oldResultsType': 'all', # type of results that the client loaded
324 # and then made modifications to
325 'oldResultsHash': 39850913, # hash of results when the client loaded them
326 # (ensures that the client and server apply
327 # modifications to the same base)
328 'modifications': [
329 {
330 'builder': 'Test-Android-Nexus10-MaliT604-Arm7-Debug',
331 'test': 'strokerect',
332 'config': 'gpu',
333 'expectedHashType': 'bitmap-64bitMD5',
334 'expectedHashDigest': '1707359671708613629',
335 },
336 ...
337 ],
338 }
339
340 Raises an Exception if there were any problems.
341 """
342 if not _SERVER.is_editable():
343 raise Exception('this server is not running in --editable mode')
344
345 content_type = self.headers[_HTTP_HEADER_CONTENT_TYPE]
346 if content_type != 'application/json;charset=UTF-8':
347 raise Exception('unsupported %s [%s]' % (
348 _HTTP_HEADER_CONTENT_TYPE, content_type))
349
350 content_length = int(self.headers[_HTTP_HEADER_CONTENT_LENGTH])
351 json_data = self.rfile.read(content_length)
352 data = json.loads(json_data)
353 logging.debug('do_POST_edits: received new GM expectations data [%s]' %
354 data)
355
356 with _SERVER.results_lock:
357 oldResultsType = data['oldResultsType']
358 oldResults = _SERVER.results.get_results_of_type(oldResultsType)
359 oldResultsHash = str(hash(repr(oldResults['testData'])))
360 if oldResultsHash != data['oldResultsHash']:
361 raise Exception('results of type "%s" changed while the client was '
362 'making modifications. The client should reload the '
363 'results and submit the modifications again.' %
364 oldResultsType)
365 _SERVER.results.edit_expectations(data['modifications'])
366
367 # Now that the edits have been committed, update results to reflect them.
368 _SERVER.update_results()
369
epoger@google.comf9d134d2013-09-27 15:02:44 +0000370 def redirect_to(self, url):
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000371 """ Redirect the HTTP client to a different url.
372
373 Args:
374 url: URL to redirect the HTTP client to
375 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000376 self.send_response(301)
377 self.send_header('Location', url)
378 self.end_headers()
379
380 def send_file(self, path):
381 """ Send the contents of the file at this path, with a mimetype based
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000382 on the filename extension.
383
384 Args:
385 path: path of file whose contents to send to the HTTP client
386 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000387 # Grab the extension if there is one
388 extension = os.path.splitext(path)[1]
389 if len(extension) >= 1:
390 extension = extension[1:]
391
392 # Determine the MIME type of the file from its extension
393 mime_type = MIME_TYPE_MAP.get(extension, MIME_TYPE_MAP[''])
394
395 # Open the file and send it over HTTP
396 if os.path.isfile(path):
397 with open(path, 'rb') as sending_file:
398 self.send_response(200)
399 self.send_header('Content-type', mime_type)
400 self.end_headers()
401 self.wfile.write(sending_file.read())
402 else:
403 self.send_error(404)
404
405 def send_json_dict(self, json_dict):
406 """ Send the contents of this dictionary in JSON format, with a JSON
epoger@google.com9fb6c8a2013-10-09 18:05:58 +0000407 mimetype.
408
409 Args:
410 json_dict: dictionary to send
411 """
epoger@google.comf9d134d2013-09-27 15:02:44 +0000412 self.send_response(200)
413 self.send_header('Content-type', 'application/json')
414 self.end_headers()
415 json.dump(json_dict, self.wfile)
416
417
418def main():
epoger@google.comdcb4e652013-10-11 18:45:33 +0000419 logging.basicConfig(level=logging.INFO)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000420 parser = argparse.ArgumentParser()
421 parser.add_argument('--actuals-dir',
422 help=('Directory into which we will check out the latest '
423 'actual GM results. If this directory does not '
424 'exist, it will be created. Defaults to %(default)s'),
425 default=DEFAULT_ACTUALS_DIR)
epoger@google.com542b65f2013-10-15 20:10:33 +0000426 parser.add_argument('--editable', action='store_true',
epoger@google.comeb832592013-10-23 15:07:26 +0000427 help=('Allow HTTP clients to submit new baselines.'))
epoger@google.comf9d134d2013-09-27 15:02:44 +0000428 parser.add_argument('--expectations-dir',
429 help=('Directory under which to find GM expectations; '
430 'defaults to %(default)s'),
431 default=DEFAULT_EXPECTATIONS_DIR)
432 parser.add_argument('--export', action='store_true',
433 help=('Instead of only allowing access from HTTP clients '
434 'on localhost, allow HTTP clients on other hosts '
435 'to access this server. WARNING: doing so will '
436 'allow users on other hosts to modify your '
epoger@google.com542b65f2013-10-15 20:10:33 +0000437 'GM expectations, if combined with --editable.'))
epoger@google.comafaad3d2013-09-30 15:06:25 +0000438 parser.add_argument('--port', type=int,
439 help=('Which TCP port to listen on for HTTP requests; '
440 'defaults to %(default)s'),
441 default=DEFAULT_PORT)
epoger@google.com542b65f2013-10-15 20:10:33 +0000442 parser.add_argument('--reload', type=int,
443 help=('How often (a period in seconds) to update the '
444 'results. If specified, both EXPECTATIONS_DIR and '
445 'ACTUAL_DIR will be updated. '
446 'By default, we do not reload at all, and you '
447 'must restart the server to pick up new data.'),
448 default=0)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000449 args = parser.parse_args()
450 global _SERVER
epoger@google.com542b65f2013-10-15 20:10:33 +0000451 _SERVER = Server(actuals_dir=args.actuals_dir,
452 expectations_dir=args.expectations_dir,
453 port=args.port, export=args.export, editable=args.editable,
454 reload_seconds=args.reload)
epoger@google.comf9d134d2013-09-27 15:02:44 +0000455 _SERVER.run()
456
457if __name__ == '__main__':
458 main()