OLD | NEW |
| (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 | |
OLD | NEW |