Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(243)

Side by Side Diff: tools/skpdiff/skpdiff_server.py

Issue 20654006: download and rebaseline images using server (Closed) Base URL: https://skia.googlecode.com/svn/trunk
Patch Set: fix other missing filepath Created 7 years, 4 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch | Annotate | Revision Log
« no previous file with comments | « tools/skpdiff/diff_viewer.js ('k') | tools/skpdiff/viewer.html » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
1 #!/usr/bin/python 1 #!/usr/bin/python
2 # -*- coding: utf-8 -*- 2 # -*- coding: utf-8 -*-
3 3
4 from __future__ import print_function 4 from __future__ import print_function
5 import argparse 5 import argparse
6 import BaseHTTPServer 6 import BaseHTTPServer
7 import json 7 import json
8 import os 8 import os
9 import os.path 9 import os.path
10 import re 10 import re
11 import subprocess
11 import sys 12 import sys
12 import tempfile 13 import tempfile
13 import urllib2 14 import urllib2
14 15
15 # Grab the script path because that is where all the static assets are 16 # Grab the script path because that is where all the static assets are
16 SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) 17 SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
17 18
18 # Find the tools directory for python imports 19 # Find the tools directory for python imports
19 TOOLS_DIR = os.path.dirname(SCRIPT_DIR) 20 TOOLS_DIR = os.path.dirname(SCRIPT_DIR)
20 21
(...skipping 71 matching lines...) Expand 10 before | Expand all | Expand 10 after
92 93
93 94
94 def download_gm_image(image_name, image_path, hash_val): 95 def download_gm_image(image_name, image_path, hash_val):
95 """Download the gm result into the given path. 96 """Download the gm result into the given path.
96 97
97 @param image_name The GM file name, for example imageblur_gpu.png. 98 @param image_name The GM file name, for example imageblur_gpu.png.
98 @param image_path Path to place the image. 99 @param image_path Path to place the image.
99 @param hash_val The hash value of the image. 100 @param hash_val The hash value of the image.
100 """ 101 """
101 102
102 # Seperate the test name from a image name 103 # Separate the test name from a image name
103 image_match = IMAGE_FILENAME_RE.match(image_name) 104 image_match = IMAGE_FILENAME_RE.match(image_name)
104 test_name = image_match.group(1) 105 test_name = image_match.group(1)
105 106
106 # Calculate the URL of the requested image 107 # Calculate the URL of the requested image
107 image_url = gm_json.CreateGmActualUrl( 108 image_url = gm_json.CreateGmActualUrl(
108 test_name, gm_json.JSONKEY_HASHTYPE_BITMAP_64BITMD5, hash_val) 109 test_name, gm_json.JSONKEY_HASHTYPE_BITMAP_64BITMD5, hash_val)
109 110
110 # Download the image as requested 111 # Download the image as requested
111 download_file(image_url, image_path) 112 download_file(image_url, image_path)
112 113
113 114
114 def download_changed_images(expectations_dir, expected_name, updated_name,
115 expected_image_dir, actual_image_dir):
116
117 """Download the expected and actual GMs that changed into the given paths.
118 Determining what changed will be done by comparing each expected_name JSON
119 results file to its corresponding updated_name JSON results file if it
120 exists.
121
122 @param expectations_dir The directory to traverse for results files. This
123 should resmble expectations/gm in the Skia trunk.
124 @param expected_name The name of the expected result files. These are
125 in the format of expected-results.json.
126 @param updated_name The name of the updated expected result files.
127 Normally this matches --expectations-filename-output for the
128 rebaseline.py tool.
129 @param expected_image_dir The directory to place downloaded expected images
130 into.
131 @param actual_image_dir The directory to place downloaded actual images
132 into.
133 """
134
135 differ = jsondiff.GMDiffer()
136
137 # Look through expectations for hashes that changed
138 for root, dirs, files in os.walk(expectations_dir):
139 for expectation_file in files:
140 # There are many files in the expectations directory. We only care
141 # about expected results.
142 if expectation_file != expected_name:
143 continue
144
145 # Get the name of the results file, and be sure there is an updated
146 # result to compare against. If there is not, there is no point in
147 # diffing this device.
148 expected_file_path = os.path.join(root, expected_name)
149 updated_file_path = os.path.join(root, updated_name)
150 if not os.path.isfile(updated_file_path):
151 continue
152
153 # Find all expectations that did not match.
154 expected_diff = differ.GenerateDiffDict(expected_file_path,
155 updated_file_path)
156
157 # The name of the device corresponds to the name of the folder we
158 # are in.
159 device_name = os.path.basename(root)
160
161 # Create name prefixes to store the devices old and new GM results
162 expected_image_prefix = os.path.join(expected_image_dir,
163 device_name) + '-'
164
165 actual_image_prefix = os.path.join(actual_image_dir,
166 device_name) + '-'
167
168 # Download each image that had a differing result
169 for image_name, hashes in expected_diff.iteritems():
170 print('Downloading', image_name, 'for device', device_name)
171 download_gm_image(image_name,
172 expected_image_prefix + image_name,
173 hashes['old'])
174 download_gm_image(image_name,
175 actual_image_prefix + image_name,
176 hashes['new'])
177
178
179 def get_image_set_from_skpdiff(skpdiff_records): 115 def get_image_set_from_skpdiff(skpdiff_records):
180 """Get the set of all images references in the given records. 116 """Get the set of all images references in the given records.
181 117
182 @param skpdiff_records An array of records, which are dictionary objects. 118 @param skpdiff_records An array of records, which are dictionary objects.
183 """ 119 """
184 expected_set = frozenset([r['baselinePath'] for r in skpdiff_records]) 120 expected_set = frozenset([r['baselinePath'] for r in skpdiff_records])
185 actual_set = frozenset([r['testPath'] for r in skpdiff_records]) 121 actual_set = frozenset([r['testPath'] for r in skpdiff_records])
186 return expected_set | actual_set 122 return expected_set | actual_set
187 123
188 124
125 def set_expected_hash_in_json(expected_results_json, image_name, hash_value):
126 """Set the expected hash for the object extracted from
127 expected-results.json. Note that this only work with bitmap-64bitMD5 hash
128 types.
129
130 @param expected_results_json The Python dictionary with the results to
131 modify.
132 @param image_name The name of the image to set the hash of.
133 @param hash_value The hash to set for the image.
134 """
135 expected_results = expected_results_json[gm_json.JSONKEY_EXPECTEDRESULTS]
136
137 if image_name in expected_results:
138 expected_results[image_name][gm_json.JSONKEY_EXPECTEDRESULTS_ALLOWEDDIGE STS][0][1] = hash_value
139 else:
140 expected_results[image_name] = {
141 gm_json.JSONKEY_EXPECTEDRESULTS_ALLOWEDDIGESTS:
142 [
143 [
144 gm_json.JSONKEY_HASHTYPE_BITMAP_64BITMD5,
145 hash_value
146 ]
147 ]
148 }
149
150
151 def get_head_version(path):
152 """Get the version of the file at the given path stored inside the HEAD of
153 the git repository. It is returned as a string.
154
155 @param path The path of the file whose HEAD is returned. It is assumed the
156 path is inside a git repo rooted at SKIA_ROOT_DIR.
157 """
158
159 # git-show will not work with absolute paths. This ensures we give it a path
160 # relative to the skia root.
161 git_path = os.path.relpath(path, SKIA_ROOT_DIR)
162 git_show_proc = subprocess.Popen(['git', 'show', 'HEAD:' + git_path],
163 stdout=subprocess.PIPE)
164
165 # When invoked outside a shell, git will output the last committed version
166 # of the file directly to stdout.
167 git_version_content, _ = git_show_proc.communicate()
168 return git_version_content
169
170
171 class GMInstance:
172 """Information about a GM test result on a specific device:
173 - device_name = the name of the device that rendered it
174 - image_name = the GM test name and config
175 - expected_hash = the current expected hash value
176 - actual_hash = the actual hash value
177 """
178 def __init__(self,
179 device_name, image_name,
180 expected_hash, actual_hash):
181 self.device_name = device_name
182 self.image_name = image_name
183 self.expected_hash = expected_hash
184 self.actual_hash = actual_hash
185
186
187 class ExpectationsManager:
188 def __init__(self, expectations_dir, expected_name, updated_name,
189 skpdiff_path):
190 """
191 @param expectations_dir The directory to traverse for results files.
192 This should resemble expectations/gm in the Skia trunk.
193 @param expected_name The name of the expected result files. These
194 are in the format of expected-results.json.
195 @param updated_name The name of the updated expected result files.
196 Normally this matches --expectations-filename-output for the
197 rebaseline.py tool.
198 @param skpdiff_path The path used to execute the skpdiff command.
199 """
200 self._expectations_dir = expectations_dir
201 self._expected_name = expected_name
202 self._updated_name = updated_name
203 self._skpdiff_path = skpdiff_path
204 self._generate_gm_comparison()
205
206 def _generate_gm_comparison(self):
207 """Generate all the data needed to compare GMs:
208 - determine which GMs changed
209 - download the changed images
210 - compare them with skpdiff
211 """
212
213 # Get the expectations and compare them with actual hashes
214 self._get_expectations()
215
216
217 # Create a temporary file tree that makes sense for skpdiff to operate
218 # on.
219 image_output_dir = tempfile.mkdtemp('skpdiff')
220 expected_image_dir = os.path.join(image_output_dir, 'expected')
221 actual_image_dir = os.path.join(image_output_dir, 'actual')
222 os.mkdir(expected_image_dir)
223 os.mkdir(actual_image_dir)
224
225 # Download expected and actual images that differed into the temporary
226 # file tree.
227 self._download_expectation_images(expected_image_dir, actual_image_dir)
228
229 # Invoke skpdiff with our downloaded images and place its results in the
230 # temporary directory.
231 self.skpdiff_output_path = os.path.join(image_output_dir,
232 'skpdiff_output.json')
233 skpdiff_cmd = SKPDIFF_INVOKE_FORMAT.format(self._skpdiff_path,
234 self.skpdiff_output_path,
235 expected_image_dir,
236 actual_image_dir)
237 os.system(skpdiff_cmd)
238
239
240 def _get_expectations(self):
241 """Fills self._expectations with GMInstance objects for each test whose
242 expectation is different between the following two files:
243 - the local filesystem's updated results file
244 - git's head version of the expected results file
245 """
246 differ = jsondiff.GMDiffer()
247 self._expectations = []
248 for root, dirs, files in os.walk(self._expectations_dir):
249 for expectation_file in files:
250 # There are many files in the expectations directory. We only
251 # care about expected results.
252 if expectation_file != self._expected_name:
253 continue
254
255 # Get the name of the results file, and be sure there is an
256 # updated result to compare against. If there is not, there is
257 # no point in diffing this device.
258 expected_file_path = os.path.join(root, self._expected_name)
259 updated_file_path = os.path.join(root, self._updated_name)
260 if not os.path.isfile(updated_file_path):
261 continue
262
263 # Always get the expected results from git because we may have
264 # changed them in a previous instance of the server.
265 expected_contents = get_head_version(expected_file_path)
266 updated_contents = None
267 with open(updated_file_path, 'rb') as updated_file:
268 updated_contents = updated_file.read()
269
270 # Find all expectations that did not match.
271 expected_diff = differ.GenerateDiffDictFromStrings(
272 expected_contents,
273 updated_contents)
274
275 # The name of the device corresponds to the name of the folder
276 # we are in.
277 device_name = os.path.basename(root)
278
279 # Store old and new versions of the expectation for each GM
280 for image_name, hashes in expected_diff.iteritems():
281 self._expectations.append(
282 GMInstance(device_name, image_name,
283 hashes['old'], hashes['new']))
284
285
286 def _download_expectation_images(self, expected_image_dir, actual_image_dir) :
287 """Download the expected and actual images for the _expectations array.
288
289 @param expected_image_dir The directory to download expected images
290 into.
291 @param actual_image_dir The directory to download actual images into.
292 """
293 image_map = {}
294
295 # Look through expectations and download their images.
296 for expectation in self._expectations:
297 # Build appropriate paths to download the images into.
298 expected_image_path = os.path.join(expected_image_dir,
299 expectation.device_name + '-' +
300 expectation.image_name)
301
302 actual_image_path = os.path.join(actual_image_dir,
303 expectation.device_name + '-' +
304 expectation.image_name)
305
306 print('Downloading %s for device %s' % (
307 expectation.image_name, expectation.device_name))
308
309 # Download images
310 download_gm_image(expectation.image_name,
311 expected_image_path,
312 expectation.expected_hash)
313
314 download_gm_image(expectation.image_name,
315 actual_image_path,
316 expectation.actual_hash)
317
318 # Annotate the expectations with where the images were downloaded
319 # to.
320 expectation.expected_image_path = expected_image_path
321 expectation.actual_image_path = actual_image_path
322
323 # Map the image paths back to the expectations.
324 image_map[expected_image_path] = (False, expectation)
325 image_map[actual_image_path] = (True, expectation)
326
327 self.image_map = image_map
328
329 def _set_expected_hash(self, device_name, image_name, hash_value):
330 """Set the expected hash for the image of the given device. This always
331 writes directly to the expected results file of the given device
332
333 @param device_name The name of the device to write the hash to.
334 @param image_name The name of the image whose hash to set.
335 @param hash_value The value of the hash to set.
336 """
337
338 # Retrieve the expected results file as it is in the working tree
339 json_path = os.path.join(self._expectations_dir, device_name,
340 self._expected_name)
341 expectations = gm_json.LoadFromFile(json_path)
342
343 # Set the specified hash.
344 set_expected_hash_in_json(expectations, image_name, hash_value)
345
346 # Write it out to disk using gm_json to keep the formatting consistent.
347 gm_json.WriteToFile(expectations, json_path)
348
349 def use_hash_of(self, image_path):
350 """Determine the hash of the image at the path using the records, and
351 set it as the expected hash for its device and image config.
352
353 @param image_path The path of the image as it was stored in the output
354 of skpdiff_path
355 """
356
357 # Get the metadata about the image at the path.
358 is_actual, expectation = self.image_map[image_path]
359
360 expectation_hash = expectation.actual_hash if is_actual else\
361 expectation.expected_hash
362
363 # Write out that image's hash directly to the expected results file.
364 self._set_expected_hash(expectation.device_name, expectation.image_name,
365 expectation_hash)
366
367
189 class SkPDiffHandler(BaseHTTPServer.BaseHTTPRequestHandler): 368 class SkPDiffHandler(BaseHTTPServer.BaseHTTPRequestHandler):
190 def send_file(self, file_path): 369 def send_file(self, file_path):
191 # Grab the extension if there is one 370 # Grab the extension if there is one
192 extension = os.path.splitext(file_path)[1] 371 extension = os.path.splitext(file_path)[1]
193 if len(extension) >= 1: 372 if len(extension) >= 1:
194 extension = extension[1:] 373 extension = extension[1:]
195 374
196 # Determine the MIME type of the file from its extension 375 # Determine the MIME type of the file from its extension
197 mime_type = MIME_TYPE_MAP.get(extension, MIME_TYPE_MAP['']) 376 mime_type = MIME_TYPE_MAP.get(extension, MIME_TYPE_MAP[''])
198 377
199 # Open the file and send it over HTTP 378 # Open the file and send it over HTTP
200 if os.path.isfile(file_path): 379 if os.path.isfile(file_path):
201 with open(file_path, 'rb') as sending_file: 380 with open(file_path, 'rb') as sending_file:
202 self.send_response(200) 381 self.send_response(200)
203 self.send_header('Content-type', mime_type) 382 self.send_header('Content-type', mime_type)
204 self.end_headers() 383 self.end_headers()
205 self.wfile.write(sending_file.read()) 384 self.wfile.write(sending_file.read())
206 else: 385 else:
207 self.send_error(404) 386 self.send_error(404)
208 387
209 def serve_if_in_dir(self, dir_path, file_path): 388 def serve_if_in_dir(self, dir_path, file_path):
210 # Determine if the file exists relative to the given dir_path AND exists 389 # Determine if the file exists relative to the given dir_path AND exists
211 # under the dir_path. This is to prevent accidentally serving files 390 # under the dir_path. This is to prevent accidentally serving files
212 # outside the directory intended using symlinks, or '../'. 391 # outside the directory intended using symlinks, or '../'.
213 real_path = os.path.normpath(os.path.join(dir_path, file_path)) 392 real_path = os.path.normpath(os.path.join(dir_path, file_path))
214 print(repr(real_path))
215 if os.path.commonprefix([real_path, dir_path]) == dir_path: 393 if os.path.commonprefix([real_path, dir_path]) == dir_path:
216 if os.path.isfile(real_path): 394 if os.path.isfile(real_path):
217 self.send_file(real_path) 395 self.send_file(real_path)
218 return True 396 return True
219 return False 397 return False
220 398
221 def do_GET(self): 399 def do_GET(self):
222 # Simple rewrite rule of the root path to 'viewer.html' 400 # Simple rewrite rule of the root path to 'viewer.html'
223 if self.path == '' or self.path == '/': 401 if self.path == '' or self.path == '/':
224 self.path = '/viewer.html' 402 self.path = '/viewer.html'
(...skipping 16 matching lines...) Expand all
241 419
242 # WARNING: Serving any file the user wants is incredibly insecure. Its 420 # WARNING: Serving any file the user wants is incredibly insecure. Its
243 # redeeming quality is that we only serve gm files on a white list. 421 # redeeming quality is that we only serve gm files on a white list.
244 if self.path in self.server.image_set: 422 if self.path in self.server.image_set:
245 self.send_file(self.path) 423 self.send_file(self.path)
246 return 424 return
247 425
248 # If no file to send was found, just give the standard 404 426 # If no file to send was found, just give the standard 404
249 self.send_error(404) 427 self.send_error(404)
250 428
429 def do_POST(self):
430 if self.path == '/set_hash':
431 content_length = int(self.headers['Content-length'])
432 request_data = json.loads(self.rfile.read(content_length))
433 self.server.expectations_manager.use_hash_of(request_data['path'])
434 self.send_response(200)
435 self.send_header('Content-type', 'application/json')
436 self.end_headers()
437 self.wfile.write('{"success":true}')
438 return
251 439
252 def run_server(skpdiff_output_path, port=8080): 440 # If the we have no handler for this path, give em' the 404
441 self.send_error(404)
442
443
444 def run_server(expectations_manager, port=8080):
253 # Preload the skpdiff results file. This is so we can perform some 445 # Preload the skpdiff results file. This is so we can perform some
254 # processing on it. 446 # processing on it.
255 skpdiff_output_json = '' 447 skpdiff_output_json = ''
256 with open(skpdiff_output_path, 'rb') as records_file: 448 with open(expectations_manager.skpdiff_output_path, 'rb') as records_file:
257 skpdiff_output_json = records_file.read() 449 skpdiff_output_json = records_file.read()
258 450
259 # It's important to parse the results file so that we can make a set of 451 # It's important to parse the results file so that we can make a set of
260 # images that the web page might request. 452 # images that the web page might request.
261 skpdiff_records = json.loads(skpdiff_output_json)['records'] 453 skpdiff_records = json.loads(skpdiff_output_json)['records']
262 image_set = get_image_set_from_skpdiff(skpdiff_records) 454 image_set = get_image_set_from_skpdiff(skpdiff_records)
263 455
264 # Add JSONP padding to the JSON because the web page expects it. It expects 456 # Add JSONP padding to the JSON because the web page expects it. It expects
265 # it because it was designed to run with or without a web server. Without a 457 # it because it was designed to run with or without a web server. Without a
266 # web server, the only way to load JSON is with JSONP. 458 # web server, the only way to load JSON is with JSONP.
267 skpdiff_output_json = 'var SkPDiffRecords = ' + skpdiff_output_json 459 skpdiff_output_json = ('var SkPDiffRecords = ' +
460 json.dumps({'records': skpdiff_records}) + ';')
268 461
269 # Do not bind to interfaces other than localhost because the server will 462 # Do not bind to interfaces other than localhost because the server will
270 # attempt to serve files relative to the root directory as a last resort 463 # attempt to serve files relative to the root directory as a last resort
271 # before 404ing. This means all of your files can be accessed from this 464 # before 404ing. This means all of your files can be accessed from this
272 # server, so DO NOT let this server listen to anything but localhost. 465 # server, so DO NOT let this server listen to anything but localhost.
273 server_address = ('127.0.0.1', port) 466 server_address = ('127.0.0.1', port)
274 http_server = BaseHTTPServer.HTTPServer(server_address, SkPDiffHandler) 467 http_server = BaseHTTPServer.HTTPServer(server_address, SkPDiffHandler)
275 http_server.image_set = image_set 468 http_server.image_set = image_set
276 http_server.skpdiff_output_json = skpdiff_output_json 469 http_server.skpdiff_output_json = skpdiff_output_json
277 print('Navigate thine browser to: http://{}:{}'.format(*server_address)) 470 http_server.expectations_manager = expectations_manager
471 print('Navigate thine browser to: http://{}:{}/'.format(*server_address))
278 http_server.serve_forever() 472 http_server.serve_forever()
279 473
280 474
281 def main(): 475 def main():
282 parser = argparse.ArgumentParser() 476 parser = argparse.ArgumentParser()
283 parser.add_argument('--port', '-p', metavar='PORT', 477 parser.add_argument('--port', '-p', metavar='PORT',
284 type=int, 478 type=int,
285 default=8080, 479 default=8080,
286 help='port to bind the server to; ' + 480 help='port to bind the server to; ' +
287 'defaults to %(default)s', 481 'defaults to %(default)s',
(...skipping 25 matching lines...) Expand all
313 'defaults to out/Release/skpdiff or out/Default/skpdiff' 507 'defaults to out/Release/skpdiff or out/Default/skpdiff'
314 ) 508 )
315 509
316 args = vars(parser.parse_args()) # Convert args into a python dict 510 args = vars(parser.parse_args()) # Convert args into a python dict
317 511
318 # Make sure we have access to an skpdiff binary 512 # Make sure we have access to an skpdiff binary
319 skpdiff_path = get_skpdiff_path(args['skpdiff_path']) 513 skpdiff_path = get_skpdiff_path(args['skpdiff_path'])
320 if skpdiff_path is None: 514 if skpdiff_path is None:
321 sys.exit(1) 515 sys.exit(1)
322 516
323 # Create a temporary file tree that makes sense for skpdiff.to operate on
324 image_output_dir = tempfile.mkdtemp('skpdiff')
325 expected_image_dir = os.path.join(image_output_dir, 'expected')
326 actual_image_dir = os.path.join(image_output_dir, 'actual')
327 os.mkdir(expected_image_dir)
328 os.mkdir(actual_image_dir)
329
330 # Print out the paths of things for easier debugging 517 # Print out the paths of things for easier debugging
331 print('script dir :', SCRIPT_DIR) 518 print('script dir :', SCRIPT_DIR)
332 print('tools dir :', TOOLS_DIR) 519 print('tools dir :', TOOLS_DIR)
333 print('root dir :', SKIA_ROOT_DIR) 520 print('root dir :', SKIA_ROOT_DIR)
334 print('expectations dir :', args['expectations_dir']) 521 print('expectations dir :', args['expectations_dir'])
335 print('skpdiff path :', skpdiff_path) 522 print('skpdiff path :', skpdiff_path)
336 print('tmp dir :', image_output_dir)
337 print('expected image dir :', expected_image_dir)
338 print('actual image dir :', actual_image_dir)
339 523
340 # Download expected and actual images that differed into the temporary file 524 expectations_manager = ExpectationsManager(args['expectations_dir'],
341 # tree. 525 args['expected'],
342 download_changed_images(args['expectations_dir'], 526 args['updated'],
343 args['expected'], args['updated'], 527 skpdiff_path)
344 expected_image_dir, actual_image_dir)
345 528
346 # Invoke skpdiff with our downloaded images and place its results in the 529 run_server(expectations_manager, port=args['port'])
347 # temporary directory.
348 skpdiff_output_path = os.path.join(image_output_dir, 'skpdiff_output.json')
349 skpdiff_cmd = SKPDIFF_INVOKE_FORMAT.format(skpdiff_path,
350 skpdiff_output_path,
351 expected_image_dir,
352 actual_image_dir)
353 os.system(skpdiff_cmd)
354
355 run_server(skpdiff_output_path, port=args['port'])
356 530
357 if __name__ == '__main__': 531 if __name__ == '__main__':
358 main() 532 main()
OLDNEW
« no previous file with comments | « tools/skpdiff/diff_viewer.js ('k') | tools/skpdiff/viewer.html » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698