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

Side by Side Diff: build/android/pylib/results/flakiness_dashboard/json_results_generator.py

Issue 2101243005: Add a snapshot of flutter/engine/src/build to our sdk (Closed) Base URL: git@github.com:dart-lang/sdk.git@master
Patch Set: add README.dart Created 4 years, 5 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
OLDNEW
(Empty)
1 # Copyright 2014 The Chromium Authors. All rights reserved.
2 # Use of this source code is governed by a BSD-style license that can be
3 # found in the LICENSE file.
4
5 #
6 # Most of this file was ported over from Blink's
7 # Tools/Scripts/webkitpy/layout_tests/layout_package/json_results_generator.py
8 # Tools/Scripts/webkitpy/common/net/file_uploader.py
9 #
10
11 import json
12 import logging
13 import mimetypes
14 import os
15 import time
16 import urllib2
17
18 _log = logging.getLogger(__name__)
19
20 _JSON_PREFIX = 'ADD_RESULTS('
21 _JSON_SUFFIX = ');'
22
23
24 def HasJSONWrapper(string):
25 return string.startswith(_JSON_PREFIX) and string.endswith(_JSON_SUFFIX)
26
27
28 def StripJSONWrapper(json_content):
29 # FIXME: Kill this code once the server returns json instead of jsonp.
30 if HasJSONWrapper(json_content):
31 return json_content[len(_JSON_PREFIX):len(json_content) - len(_JSON_SUFFIX)]
32 return json_content
33
34
35 def WriteJSON(json_object, file_path, callback=None):
36 # Specify separators in order to get compact encoding.
37 json_string = json.dumps(json_object, separators=(',', ':'))
38 if callback:
39 json_string = callback + '(' + json_string + ');'
40 with open(file_path, 'w') as fp:
41 fp.write(json_string)
42
43
44 def ConvertTrieToFlatPaths(trie, prefix=None):
45 """Flattens the trie of paths, prepending a prefix to each."""
46 result = {}
47 for name, data in trie.iteritems():
48 if prefix:
49 name = prefix + '/' + name
50
51 if len(data) and not 'results' in data:
52 result.update(ConvertTrieToFlatPaths(data, name))
53 else:
54 result[name] = data
55
56 return result
57
58
59 def AddPathToTrie(path, value, trie):
60 """Inserts a single path and value into a directory trie structure."""
61 if not '/' in path:
62 trie[path] = value
63 return
64
65 directory, _slash, rest = path.partition('/')
66 if not directory in trie:
67 trie[directory] = {}
68 AddPathToTrie(rest, value, trie[directory])
69
70
71 def TestTimingsTrie(individual_test_timings):
72 """Breaks a test name into dicts by directory
73
74 foo/bar/baz.html: 1ms
75 foo/bar/baz1.html: 3ms
76
77 becomes
78 foo: {
79 bar: {
80 baz.html: 1,
81 baz1.html: 3
82 }
83 }
84 """
85 trie = {}
86 for test_result in individual_test_timings:
87 test = test_result.test_name
88
89 AddPathToTrie(test, int(1000 * test_result.test_run_time), trie)
90
91 return trie
92
93
94 class TestResult(object):
95 """A simple class that represents a single test result."""
96
97 # Test modifier constants.
98 (NONE, FAILS, FLAKY, DISABLED) = range(4)
99
100 def __init__(self, test, failed=False, elapsed_time=0):
101 self.test_name = test
102 self.failed = failed
103 self.test_run_time = elapsed_time
104
105 test_name = test
106 try:
107 test_name = test.split('.')[1]
108 except IndexError:
109 _log.warn('Invalid test name: %s.', test)
110
111 if test_name.startswith('FAILS_'):
112 self.modifier = self.FAILS
113 elif test_name.startswith('FLAKY_'):
114 self.modifier = self.FLAKY
115 elif test_name.startswith('DISABLED_'):
116 self.modifier = self.DISABLED
117 else:
118 self.modifier = self.NONE
119
120 def Fixable(self):
121 return self.failed or self.modifier == self.DISABLED
122
123
124 class JSONResultsGeneratorBase(object):
125 """A JSON results generator for generic tests."""
126
127 MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG = 750
128 # Min time (seconds) that will be added to the JSON.
129 MIN_TIME = 1
130
131 # Note that in non-chromium tests those chars are used to indicate
132 # test modifiers (FAILS, FLAKY, etc) but not actual test results.
133 PASS_RESULT = 'P'
134 SKIP_RESULT = 'X'
135 FAIL_RESULT = 'F'
136 FLAKY_RESULT = 'L'
137 NO_DATA_RESULT = 'N'
138
139 MODIFIER_TO_CHAR = {TestResult.NONE: PASS_RESULT,
140 TestResult.DISABLED: SKIP_RESULT,
141 TestResult.FAILS: FAIL_RESULT,
142 TestResult.FLAKY: FLAKY_RESULT}
143
144 VERSION = 4
145 VERSION_KEY = 'version'
146 RESULTS = 'results'
147 TIMES = 'times'
148 BUILD_NUMBERS = 'buildNumbers'
149 TIME = 'secondsSinceEpoch'
150 TESTS = 'tests'
151
152 FIXABLE_COUNT = 'fixableCount'
153 FIXABLE = 'fixableCounts'
154 ALL_FIXABLE_COUNT = 'allFixableCount'
155
156 RESULTS_FILENAME = 'results.json'
157 TIMES_MS_FILENAME = 'times_ms.json'
158 INCREMENTAL_RESULTS_FILENAME = 'incremental_results.json'
159
160 # line too long pylint: disable=line-too-long
161 URL_FOR_TEST_LIST_JSON = (
162 'http://%s/testfile?builder=%s&name=%s&testlistjson=1&testtype=%s&master=% s')
163 # pylint: enable=line-too-long
164
165 def __init__(self, builder_name, build_name, build_number,
166 results_file_base_path, builder_base_url,
167 test_results_map, svn_repositories=None,
168 test_results_server=None,
169 test_type='',
170 master_name=''):
171 """Modifies the results.json file. Grabs it off the archive directory
172 if it is not found locally.
173
174 Args
175 builder_name: the builder name (e.g. Webkit).
176 build_name: the build name (e.g. webkit-rel).
177 build_number: the build number.
178 results_file_base_path: Absolute path to the directory containing the
179 results json file.
180 builder_base_url: the URL where we have the archived test results.
181 If this is None no archived results will be retrieved.
182 test_results_map: A dictionary that maps test_name to TestResult.
183 svn_repositories: A (json_field_name, svn_path) pair for SVN
184 repositories that tests rely on. The SVN revision will be
185 included in the JSON with the given json_field_name.
186 test_results_server: server that hosts test results json.
187 test_type: test type string (e.g. 'layout-tests').
188 master_name: the name of the buildbot master.
189 """
190 self._builder_name = builder_name
191 self._build_name = build_name
192 self._build_number = build_number
193 self._builder_base_url = builder_base_url
194 self._results_directory = results_file_base_path
195
196 self._test_results_map = test_results_map
197 self._test_results = test_results_map.values()
198
199 self._svn_repositories = svn_repositories
200 if not self._svn_repositories:
201 self._svn_repositories = {}
202
203 self._test_results_server = test_results_server
204 self._test_type = test_type
205 self._master_name = master_name
206
207 self._archived_results = None
208
209 def GenerateJSONOutput(self):
210 json_object = self.GetJSON()
211 if json_object:
212 file_path = (
213 os.path.join(
214 self._results_directory,
215 self.INCREMENTAL_RESULTS_FILENAME))
216 WriteJSON(json_object, file_path)
217
218 def GenerateTimesMSFile(self):
219 times = TestTimingsTrie(self._test_results_map.values())
220 file_path = os.path.join(self._results_directory, self.TIMES_MS_FILENAME)
221 WriteJSON(times, file_path)
222
223 def GetJSON(self):
224 """Gets the results for the results.json file."""
225 results_json = {}
226
227 if not results_json:
228 results_json, error = self._GetArchivedJSONResults()
229 if error:
230 # If there was an error don't write a results.json
231 # file at all as it would lose all the information on the
232 # bot.
233 _log.error('Archive directory is inaccessible. Not '
234 'modifying or clobbering the results.json '
235 'file: ' + str(error))
236 return None
237
238 builder_name = self._builder_name
239 if results_json and builder_name not in results_json:
240 _log.debug('Builder name (%s) is not in the results.json file.'
241 % builder_name)
242
243 self._ConvertJSONToCurrentVersion(results_json)
244
245 if builder_name not in results_json:
246 results_json[builder_name] = (
247 self._CreateResultsForBuilderJSON())
248
249 results_for_builder = results_json[builder_name]
250
251 if builder_name:
252 self._InsertGenericMetaData(results_for_builder)
253
254 self._InsertFailureSummaries(results_for_builder)
255
256 # Update the all failing tests with result type and time.
257 tests = results_for_builder[self.TESTS]
258 all_failing_tests = self._GetFailedTestNames()
259 all_failing_tests.update(ConvertTrieToFlatPaths(tests))
260
261 for test in all_failing_tests:
262 self._InsertTestTimeAndResult(test, tests)
263
264 return results_json
265
266 def SetArchivedResults(self, archived_results):
267 self._archived_results = archived_results
268
269 def UploadJSONFiles(self, json_files):
270 """Uploads the given json_files to the test_results_server (if the
271 test_results_server is given)."""
272 if not self._test_results_server:
273 return
274
275 if not self._master_name:
276 _log.error(
277 '--test-results-server was set, but --master-name was not. Not '
278 'uploading JSON files.')
279 return
280
281 _log.info('Uploading JSON files for builder: %s', self._builder_name)
282 attrs = [('builder', self._builder_name),
283 ('testtype', self._test_type),
284 ('master', self._master_name)]
285
286 files = [(json_file, os.path.join(self._results_directory, json_file))
287 for json_file in json_files]
288
289 url = 'http://%s/testfile/upload' % self._test_results_server
290 # Set uploading timeout in case appengine server is having problems.
291 # 120 seconds are more than enough to upload test results.
292 uploader = _FileUploader(url, 120)
293 try:
294 response = uploader.UploadAsMultipartFormData(files, attrs)
295 if response:
296 if response.code == 200:
297 _log.info('JSON uploaded.')
298 else:
299 _log.debug(
300 "JSON upload failed, %d: '%s'" %
301 (response.code, response.read()))
302 else:
303 _log.error('JSON upload failed; no response returned')
304 except Exception, err:
305 _log.error('Upload failed: %s' % err)
306 return
307
308 def _GetTestTiming(self, test_name):
309 """Returns test timing data (elapsed time) in second
310 for the given test_name."""
311 if test_name in self._test_results_map:
312 # Floor for now to get time in seconds.
313 return int(self._test_results_map[test_name].test_run_time)
314 return 0
315
316 def _GetFailedTestNames(self):
317 """Returns a set of failed test names."""
318 return set([r.test_name for r in self._test_results if r.failed])
319
320 def _GetModifierChar(self, test_name):
321 """Returns a single char (e.g. SKIP_RESULT, FAIL_RESULT,
322 PASS_RESULT, NO_DATA_RESULT, etc) that indicates the test modifier
323 for the given test_name.
324 """
325 if test_name not in self._test_results_map:
326 return self.__class__.NO_DATA_RESULT
327
328 test_result = self._test_results_map[test_name]
329 if test_result.modifier in self.MODIFIER_TO_CHAR.keys():
330 return self.MODIFIER_TO_CHAR[test_result.modifier]
331
332 return self.__class__.PASS_RESULT
333
334 def _get_result_char(self, test_name):
335 """Returns a single char (e.g. SKIP_RESULT, FAIL_RESULT,
336 PASS_RESULT, NO_DATA_RESULT, etc) that indicates the test result
337 for the given test_name.
338 """
339 if test_name not in self._test_results_map:
340 return self.__class__.NO_DATA_RESULT
341
342 test_result = self._test_results_map[test_name]
343 if test_result.modifier == TestResult.DISABLED:
344 return self.__class__.SKIP_RESULT
345
346 if test_result.failed:
347 return self.__class__.FAIL_RESULT
348
349 return self.__class__.PASS_RESULT
350
351 def _GetSVNRevision(self, in_directory):
352 """Returns the svn revision for the given directory.
353
354 Args:
355 in_directory: The directory where svn is to be run.
356 """
357 # This is overridden in flakiness_dashboard_results_uploader.py.
358 raise NotImplementedError()
359
360 def _GetArchivedJSONResults(self):
361 """Download JSON file that only contains test
362 name list from test-results server. This is for generating incremental
363 JSON so the file generated has info for tests that failed before but
364 pass or are skipped from current run.
365
366 Returns (archived_results, error) tuple where error is None if results
367 were successfully read.
368 """
369 results_json = {}
370 old_results = None
371 error = None
372
373 if not self._test_results_server:
374 return {}, None
375
376 results_file_url = (self.URL_FOR_TEST_LIST_JSON %
377 (urllib2.quote(self._test_results_server),
378 urllib2.quote(self._builder_name),
379 self.RESULTS_FILENAME,
380 urllib2.quote(self._test_type),
381 urllib2.quote(self._master_name)))
382
383 try:
384 # FIXME: We should talk to the network via a Host object.
385 results_file = urllib2.urlopen(results_file_url)
386 old_results = results_file.read()
387 except urllib2.HTTPError, http_error:
388 # A non-4xx status code means the bot is hosed for some reason
389 # and we can't grab the results.json file off of it.
390 if http_error.code < 400 and http_error.code >= 500:
391 error = http_error
392 except urllib2.URLError, url_error:
393 error = url_error
394
395 if old_results:
396 # Strip the prefix and suffix so we can get the actual JSON object.
397 old_results = StripJSONWrapper(old_results)
398
399 try:
400 results_json = json.loads(old_results)
401 except Exception:
402 _log.debug('results.json was not valid JSON. Clobbering.')
403 # The JSON file is not valid JSON. Just clobber the results.
404 results_json = {}
405 else:
406 _log.debug('Old JSON results do not exist. Starting fresh.')
407 results_json = {}
408
409 return results_json, error
410
411 def _InsertFailureSummaries(self, results_for_builder):
412 """Inserts aggregate pass/failure statistics into the JSON.
413 This method reads self._test_results and generates
414 FIXABLE, FIXABLE_COUNT and ALL_FIXABLE_COUNT entries.
415
416 Args:
417 results_for_builder: Dictionary containing the test results for a
418 single builder.
419 """
420 # Insert the number of tests that failed or skipped.
421 fixable_count = len([r for r in self._test_results if r.Fixable()])
422 self._InsertItemIntoRawList(results_for_builder,
423 fixable_count, self.FIXABLE_COUNT)
424
425 # Create a test modifiers (FAILS, FLAKY etc) summary dictionary.
426 entry = {}
427 for test_name in self._test_results_map.iterkeys():
428 result_char = self._GetModifierChar(test_name)
429 entry[result_char] = entry.get(result_char, 0) + 1
430
431 # Insert the pass/skip/failure summary dictionary.
432 self._InsertItemIntoRawList(results_for_builder, entry,
433 self.FIXABLE)
434
435 # Insert the number of all the tests that are supposed to pass.
436 all_test_count = len(self._test_results)
437 self._InsertItemIntoRawList(results_for_builder,
438 all_test_count, self.ALL_FIXABLE_COUNT)
439
440 def _InsertItemIntoRawList(self, results_for_builder, item, key):
441 """Inserts the item into the list with the given key in the results for
442 this builder. Creates the list if no such list exists.
443
444 Args:
445 results_for_builder: Dictionary containing the test results for a
446 single builder.
447 item: Number or string to insert into the list.
448 key: Key in results_for_builder for the list to insert into.
449 """
450 if key in results_for_builder:
451 raw_list = results_for_builder[key]
452 else:
453 raw_list = []
454
455 raw_list.insert(0, item)
456 raw_list = raw_list[:self.MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG]
457 results_for_builder[key] = raw_list
458
459 def _InsertItemRunLengthEncoded(self, item, encoded_results):
460 """Inserts the item into the run-length encoded results.
461
462 Args:
463 item: String or number to insert.
464 encoded_results: run-length encoded results. An array of arrays, e.g.
465 [[3,'A'],[1,'Q']] encodes AAAQ.
466 """
467 if len(encoded_results) and item == encoded_results[0][1]:
468 num_results = encoded_results[0][0]
469 if num_results <= self.MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG:
470 encoded_results[0][0] = num_results + 1
471 else:
472 # Use a list instead of a class for the run-length encoding since
473 # we want the serialized form to be concise.
474 encoded_results.insert(0, [1, item])
475
476 def _InsertGenericMetaData(self, results_for_builder):
477 """ Inserts generic metadata (such as version number, current time etc)
478 into the JSON.
479
480 Args:
481 results_for_builder: Dictionary containing the test results for
482 a single builder.
483 """
484 self._InsertItemIntoRawList(results_for_builder,
485 self._build_number, self.BUILD_NUMBERS)
486
487 # Include SVN revisions for the given repositories.
488 for (name, path) in self._svn_repositories:
489 # Note: for JSON file's backward-compatibility we use 'chrome' rather
490 # than 'chromium' here.
491 lowercase_name = name.lower()
492 if lowercase_name == 'chromium':
493 lowercase_name = 'chrome'
494 self._InsertItemIntoRawList(results_for_builder,
495 self._GetSVNRevision(path),
496 lowercase_name + 'Revision')
497
498 self._InsertItemIntoRawList(results_for_builder,
499 int(time.time()),
500 self.TIME)
501
502 def _InsertTestTimeAndResult(self, test_name, tests):
503 """ Insert a test item with its results to the given tests dictionary.
504
505 Args:
506 tests: Dictionary containing test result entries.
507 """
508
509 result = self._get_result_char(test_name)
510 test_time = self._GetTestTiming(test_name)
511
512 this_test = tests
513 for segment in test_name.split('/'):
514 if segment not in this_test:
515 this_test[segment] = {}
516 this_test = this_test[segment]
517
518 if not len(this_test):
519 self._PopulateResultsAndTimesJSON(this_test)
520
521 if self.RESULTS in this_test:
522 self._InsertItemRunLengthEncoded(result, this_test[self.RESULTS])
523 else:
524 this_test[self.RESULTS] = [[1, result]]
525
526 if self.TIMES in this_test:
527 self._InsertItemRunLengthEncoded(test_time, this_test[self.TIMES])
528 else:
529 this_test[self.TIMES] = [[1, test_time]]
530
531 def _ConvertJSONToCurrentVersion(self, results_json):
532 """If the JSON does not match the current version, converts it to the
533 current version and adds in the new version number.
534 """
535 if self.VERSION_KEY in results_json:
536 archive_version = results_json[self.VERSION_KEY]
537 if archive_version == self.VERSION:
538 return
539 else:
540 archive_version = 3
541
542 # version 3->4
543 if archive_version == 3:
544 for results in results_json.values():
545 self._ConvertTestsToTrie(results)
546
547 results_json[self.VERSION_KEY] = self.VERSION
548
549 def _ConvertTestsToTrie(self, results):
550 if not self.TESTS in results:
551 return
552
553 test_results = results[self.TESTS]
554 test_results_trie = {}
555 for test in test_results.iterkeys():
556 single_test_result = test_results[test]
557 AddPathToTrie(test, single_test_result, test_results_trie)
558
559 results[self.TESTS] = test_results_trie
560
561 def _PopulateResultsAndTimesJSON(self, results_and_times):
562 results_and_times[self.RESULTS] = []
563 results_and_times[self.TIMES] = []
564 return results_and_times
565
566 def _CreateResultsForBuilderJSON(self):
567 results_for_builder = {}
568 results_for_builder[self.TESTS] = {}
569 return results_for_builder
570
571 def _RemoveItemsOverMaxNumberOfBuilds(self, encoded_list):
572 """Removes items from the run-length encoded list after the final
573 item that exceeds the max number of builds to track.
574
575 Args:
576 encoded_results: run-length encoded results. An array of arrays, e.g.
577 [[3,'A'],[1,'Q']] encodes AAAQ.
578 """
579 num_builds = 0
580 index = 0
581 for result in encoded_list:
582 num_builds = num_builds + result[0]
583 index = index + 1
584 if num_builds > self.MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG:
585 return encoded_list[:index]
586 return encoded_list
587
588 def _NormalizeResultsJSON(self, test, test_name, tests):
589 """ Prune tests where all runs pass or tests that no longer exist and
590 truncate all results to maxNumberOfBuilds.
591
592 Args:
593 test: ResultsAndTimes object for this test.
594 test_name: Name of the test.
595 tests: The JSON object with all the test results for this builder.
596 """
597 test[self.RESULTS] = self._RemoveItemsOverMaxNumberOfBuilds(
598 test[self.RESULTS])
599 test[self.TIMES] = self._RemoveItemsOverMaxNumberOfBuilds(
600 test[self.TIMES])
601
602 is_all_pass = self._IsResultsAllOfType(test[self.RESULTS],
603 self.PASS_RESULT)
604 is_all_no_data = self._IsResultsAllOfType(test[self.RESULTS],
605 self.NO_DATA_RESULT)
606 max_time = max([test_time[1] for test_time in test[self.TIMES]])
607
608 # Remove all passes/no-data from the results to reduce noise and
609 # filesize. If a test passes every run, but takes > MIN_TIME to run,
610 # don't throw away the data.
611 if is_all_no_data or (is_all_pass and max_time <= self.MIN_TIME):
612 del tests[test_name]
613
614 # method could be a function pylint: disable=R0201
615 def _IsResultsAllOfType(self, results, result_type):
616 """Returns whether all the results are of the given type
617 (e.g. all passes)."""
618 return len(results) == 1 and results[0][1] == result_type
619
620
621 class _FileUploader(object):
622
623 def __init__(self, url, timeout_seconds):
624 self._url = url
625 self._timeout_seconds = timeout_seconds
626
627 def UploadAsMultipartFormData(self, files, attrs):
628 file_objs = []
629 for filename, path in files:
630 with file(path, 'rb') as fp:
631 file_objs.append(('file', filename, fp.read()))
632
633 # FIXME: We should use the same variable names for the formal and actual
634 # parameters.
635 content_type, data = _EncodeMultipartFormData(attrs, file_objs)
636 return self._UploadData(content_type, data)
637
638 def _UploadData(self, content_type, data):
639 start = time.time()
640 end = start + self._timeout_seconds
641 while time.time() < end:
642 try:
643 request = urllib2.Request(self._url, data,
644 {'Content-Type': content_type})
645 return urllib2.urlopen(request)
646 except urllib2.HTTPError as e:
647 _log.warn("Received HTTP status %s loading \"%s\". "
648 'Retrying in 10 seconds...' % (e.code, e.filename))
649 time.sleep(10)
650
651
652 def _GetMIMEType(filename):
653 return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
654
655
656 # FIXME: Rather than taking tuples, this function should take more
657 # structured data.
658 def _EncodeMultipartFormData(fields, files):
659 """Encode form fields for multipart/form-data.
660
661 Args:
662 fields: A sequence of (name, value) elements for regular form fields.
663 files: A sequence of (name, filename, value) elements for data to be
664 uploaded as files.
665 Returns:
666 (content_type, body) ready for httplib.HTTP instance.
667
668 Source:
669 http://code.google.com/p/rietveld/source/browse/trunk/upload.py
670 """
671 BOUNDARY = '-M-A-G-I-C---B-O-U-N-D-A-R-Y-'
672 CRLF = '\r\n'
673 lines = []
674
675 for key, value in fields:
676 lines.append('--' + BOUNDARY)
677 lines.append('Content-Disposition: form-data; name="%s"' % key)
678 lines.append('')
679 if isinstance(value, unicode):
680 value = value.encode('utf-8')
681 lines.append(value)
682
683 for key, filename, value in files:
684 lines.append('--' + BOUNDARY)
685 lines.append('Content-Disposition: form-data; name="%s"; '
686 'filename="%s"' % (key, filename))
687 lines.append('Content-Type: %s' % _GetMIMEType(filename))
688 lines.append('')
689 if isinstance(value, unicode):
690 value = value.encode('utf-8')
691 lines.append(value)
692
693 lines.append('--' + BOUNDARY + '--')
694 lines.append('')
695 body = CRLF.join(lines)
696 content_type = 'multipart/form-data; boundary=%s' % BOUNDARY
697 return content_type, body
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698