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

Side by Side Diff: boto/tests/test_resumable_uploads.py

Issue 8386013: Merging in latest boto. (Closed) Base URL: svn://svn.chromium.org/boto
Patch Set: Redoing vendor drop by deleting and then merging. Created 9 years, 1 month 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 | « boto/tests/test_resumable_downloads.py ('k') | boto/tests/test_s3connection.py » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
(Empty)
1 #!/usr/bin/env python
2 #
3 # Copyright 2010 Google Inc.
4 #
5 # Permission is hereby granted, free of charge, to any person obtaining a
6 # copy of this software and associated documentation files (the
7 # "Software"), to deal in the Software without restriction, including
8 # without limitation the rights to use, copy, modify, merge, publish, dis-
9 # tribute, sublicense, and/or sell copies of the Software, and to permit
10 # persons to whom the Software is furnished to do so, subject to the fol-
11 # lowing conditions:
12 #
13 # The above copyright notice and this permission notice shall be included
14 # in all copies or substantial portions of the Software.
15 #
16 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
17 # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL-
18 # ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
19 # SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20 # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
22 # IN THE SOFTWARE.
23
24 """
25 Tests of resumable uploads.
26 """
27
28 import errno
29 import getopt
30 import os
31 import random
32 import re
33 import shutil
34 import socket
35 import StringIO
36 import sys
37 import tempfile
38 import time
39 import unittest
40
41 import boto
42 from boto.exception import GSResponseError
43 from boto.gs.resumable_upload_handler import ResumableUploadHandler
44 from boto.exception import ResumableTransferDisposition
45 from boto.exception import ResumableUploadException
46 from boto.exception import StorageResponseError
47 from boto.tests.cb_test_harnass import CallbackTestHarnass
48
49
50 class ResumableUploadTests(unittest.TestCase):
51 """
52 Resumable upload test suite.
53 """
54
55 def get_suite_description(self):
56 return 'Resumable upload test suite'
57
58 @classmethod
59 def setUp(cls):
60 """
61 Creates dst_key needed by all tests.
62
63 This method's namingCase is required by the unittest framework.
64 """
65 cls.dst_key = cls.dst_key_uri.new_key(validate=False)
66
67 @classmethod
68 def tearDown(cls):
69 """
70 Deletes any objects or files created by last test run.
71
72 This method's namingCase is required by the unittest framework.
73 """
74 try:
75 cls.dst_key_uri.delete_key()
76 except GSResponseError:
77 # Ignore possible not-found error.
78 pass
79 # Recursively delete dst dir and then re-create it, so in effect we
80 # remove all dirs and files under that directory.
81 shutil.rmtree(cls.tmp_dir)
82 os.mkdir(cls.tmp_dir)
83
84 @staticmethod
85 def build_test_input_file(size):
86 buf = []
87 # I manually construct the random data here instead of calling
88 # os.urandom() because I want to constrain the range of data (in
89 # this case to 0'..'9') so the test
90 # code can easily overwrite part of the StringIO file with
91 # known-to-be-different values.
92 for i in range(size):
93 buf.append(str(random.randint(0, 9)))
94 file_as_string = ''.join(buf)
95 return (file_as_string, StringIO.StringIO(file_as_string))
96
97 @classmethod
98 def set_up_class(cls, debug):
99 """
100 Initializes test suite.
101 """
102
103 # Use a designated tmpdir prefix to make it easy to find the end of
104 # the tmp path.
105 cls.tmpdir_prefix = 'tmp_resumable_upload_test'
106
107 # Create test source file data.
108 cls.empty_src_file_size = 0
109 (cls.empty_src_file_as_string, cls.empty_src_file) = (
110 cls.build_test_input_file(cls.empty_src_file_size))
111 cls.small_src_file_size = 2 * 1024 # 2 KB.
112 (cls.small_src_file_as_string, cls.small_src_file) = (
113 cls.build_test_input_file(cls.small_src_file_size))
114 cls.larger_src_file_size = 500 * 1024 # 500 KB.
115 (cls.larger_src_file_as_string, cls.larger_src_file) = (
116 cls.build_test_input_file(cls.larger_src_file_size))
117 cls.largest_src_file_size = 1024 * 1024 # 1 MB.
118 (cls.largest_src_file_as_string, cls.largest_src_file) = (
119 cls.build_test_input_file(cls.largest_src_file_size))
120
121 # Create temp dir.
122 cls.tmp_dir = tempfile.mkdtemp(prefix=cls.tmpdir_prefix)
123
124 # Create the test bucket.
125 hostname = socket.gethostname().split('.')[0]
126 cls.uri_base_str = 'gs://res_upload_test_%s_%s_%s' % (
127 hostname, os.getpid(), int(time.time()))
128 cls.dst_bucket_uri = boto.storage_uri('%s_dst' %
129 cls.uri_base_str, debug=debug)
130 cls.dst_bucket_uri.create_bucket()
131 cls.dst_key_uri = cls.dst_bucket_uri.clone_replace_name('obj')
132
133 cls.tracker_file_name = '%s%suri_tracker' % (cls.tmp_dir, os.sep)
134
135 cls.syntactically_invalid_tracker_file_name = (
136 '%s%ssynt_invalid_uri_tracker' % (cls.tmp_dir, os.sep))
137 f = open(cls.syntactically_invalid_tracker_file_name, 'w')
138 f.write('ftp://example.com')
139 f.close()
140
141 cls.invalid_upload_id = (
142 'http://pub.commondatastorage.googleapis.com/?upload_id='
143 'AyzB2Uo74W4EYxyi5dp_-r68jz8rtbvshsv4TX7srJVkJ57CxTY5Dw2')
144 cls.invalid_upload_id_tracker_file_name = (
145 '%s%sinvalid_upload_id_tracker' % (cls.tmp_dir, os.sep))
146 f = open(cls.invalid_upload_id_tracker_file_name, 'w')
147 f.write(cls.invalid_upload_id)
148 f.close()
149
150 cls.created_test_data = True
151
152 @classmethod
153 def tear_down_class(cls):
154 """
155 Deletes bucket and tmp dir created by set_up_class.
156 """
157 if not hasattr(cls, 'created_test_data'):
158 return
159 # Call cls.tearDown() in case the tests got interrupted, to ensure
160 # dst objects get deleted.
161 cls.tearDown()
162
163 # Retry (for up to 2 minutes) the bucket gets deleted (it may not
164 # the first time round, due to eventual consistency of bucket delete
165 # operations).
166 for i in range(60):
167 try:
168 cls.dst_bucket_uri.delete_bucket()
169 break
170 except StorageResponseError:
171 print 'Test bucket (%s) not yet deleted, still trying' % (
172 cls.dst_bucket_uri.uri)
173 time.sleep(2)
174 shutil.rmtree(cls.tmp_dir)
175 cls.tmp_dir = tempfile.mkdtemp(prefix=cls.tmpdir_prefix)
176
177 def test_non_resumable_upload(self):
178 """
179 Tests that non-resumable uploads work
180 """
181 self.dst_key.set_contents_from_file(self.small_src_file)
182 self.assertEqual(self.small_src_file_size, self.dst_key.size)
183 self.assertEqual(self.small_src_file_as_string,
184 self.dst_key.get_contents_as_string())
185
186 def test_upload_without_persistent_tracker(self):
187 """
188 Tests a single resumable upload, with no tracker URI persistence
189 """
190 res_upload_handler = ResumableUploadHandler()
191 self.dst_key.set_contents_from_file(
192 self.small_src_file, res_upload_handler=res_upload_handler)
193 self.assertEqual(self.small_src_file_size, self.dst_key.size)
194 self.assertEqual(self.small_src_file_as_string,
195 self.dst_key.get_contents_as_string())
196
197 def test_failed_upload_with_persistent_tracker(self):
198 """
199 Tests that failed resumable upload leaves a correct tracker URI file
200 """
201 harnass = CallbackTestHarnass()
202 res_upload_handler = ResumableUploadHandler(
203 tracker_file_name=self.tracker_file_name, num_retries=0)
204 try:
205 self.dst_key.set_contents_from_file(
206 self.small_src_file, cb=harnass.call,
207 res_upload_handler=res_upload_handler)
208 self.fail('Did not get expected ResumableUploadException')
209 except ResumableUploadException, e:
210 # We'll get a ResumableUploadException at this point because
211 # of CallbackTestHarnass (above). Check that the tracker file was
212 # created correctly.
213 self.assertEqual(e.disposition, ResumableTransferDisposition.ABORT)
214 self.assertTrue(os.path.exists(self.tracker_file_name))
215 f = open(self.tracker_file_name)
216 uri_from_file = f.readline().strip()
217 f.close()
218 self.assertEqual(uri_from_file,
219 res_upload_handler.get_tracker_uri())
220
221 def test_retryable_exception_recovery(self):
222 """
223 Tests handling of a retryable exception
224 """
225 # Test one of the RETRYABLE_EXCEPTIONS.
226 exception = ResumableUploadHandler.RETRYABLE_EXCEPTIONS[0]
227 harnass = CallbackTestHarnass(exception=exception)
228 res_upload_handler = ResumableUploadHandler(num_retries=1)
229 self.dst_key.set_contents_from_file(
230 self.small_src_file, cb=harnass.call,
231 res_upload_handler=res_upload_handler)
232 # Ensure uploaded object has correct content.
233 self.assertEqual(self.small_src_file_size, self.dst_key.size)
234 self.assertEqual(self.small_src_file_as_string,
235 self.dst_key.get_contents_as_string())
236
237 def test_non_retryable_exception_handling(self):
238 """
239 Tests a resumable upload that fails with a non-retryable exception
240 """
241 harnass = CallbackTestHarnass(
242 exception=OSError(errno.EACCES, 'Permission denied'))
243 res_upload_handler = ResumableUploadHandler(num_retries=1)
244 try:
245 self.dst_key.set_contents_from_file(
246 self.small_src_file, cb=harnass.call,
247 res_upload_handler=res_upload_handler)
248 self.fail('Did not get expected OSError')
249 except OSError, e:
250 # Ensure the error was re-raised.
251 self.assertEqual(e.errno, 13)
252
253 def test_failed_and_restarted_upload_with_persistent_tracker(self):
254 """
255 Tests resumable upload that fails once and then completes, with tracker
256 file
257 """
258 harnass = CallbackTestHarnass()
259 res_upload_handler = ResumableUploadHandler(
260 tracker_file_name=self.tracker_file_name, num_retries=1)
261 self.dst_key.set_contents_from_file(
262 self.small_src_file, cb=harnass.call,
263 res_upload_handler=res_upload_handler)
264 # Ensure uploaded object has correct content.
265 self.assertEqual(self.small_src_file_size, self.dst_key.size)
266 self.assertEqual(self.small_src_file_as_string,
267 self.dst_key.get_contents_as_string())
268 # Ensure tracker file deleted.
269 self.assertFalse(os.path.exists(self.tracker_file_name))
270
271 def test_multiple_in_process_failures_then_succeed(self):
272 """
273 Tests resumable upload that fails twice in one process, then completes
274 """
275 res_upload_handler = ResumableUploadHandler(num_retries=3)
276 self.dst_key.set_contents_from_file(
277 self.small_src_file, res_upload_handler=res_upload_handler)
278 # Ensure uploaded object has correct content.
279 self.assertEqual(self.small_src_file_size, self.dst_key.size)
280 self.assertEqual(self.small_src_file_as_string,
281 self.dst_key.get_contents_as_string())
282
283 def test_multiple_in_process_failures_then_succeed_with_tracker_file(self):
284 """
285 Tests resumable upload that fails completely in one process,
286 then when restarted completes, using a tracker file
287 """
288 # Set up test harnass that causes more failures than a single
289 # ResumableUploadHandler instance will handle, writing enough data
290 # before the first failure that some of it survives that process run.
291 harnass = CallbackTestHarnass(
292 fail_after_n_bytes=self.larger_src_file_size/2, num_times_to_fail=2)
293 res_upload_handler = ResumableUploadHandler(
294 tracker_file_name=self.tracker_file_name, num_retries=1)
295 try:
296 self.dst_key.set_contents_from_file(
297 self.larger_src_file, cb=harnass.call,
298 res_upload_handler=res_upload_handler)
299 self.fail('Did not get expected ResumableUploadException')
300 except ResumableUploadException, e:
301 self.assertEqual(e.disposition, ResumableTransferDisposition.ABORT)
302 # Ensure a tracker file survived.
303 self.assertTrue(os.path.exists(self.tracker_file_name))
304 # Try it one more time; this time should succeed.
305 self.dst_key.set_contents_from_file(
306 self.larger_src_file, cb=harnass.call,
307 res_upload_handler=res_upload_handler)
308 self.assertEqual(self.larger_src_file_size, self.dst_key.size)
309 self.assertEqual(self.larger_src_file_as_string,
310 self.dst_key.get_contents_as_string())
311 self.assertFalse(os.path.exists(self.tracker_file_name))
312 # Ensure some of the file was uploaded both before and after failure.
313 self.assertTrue(len(harnass.transferred_seq_before_first_failure) > 1
314 and
315 len(harnass.transferred_seq_after_first_failure) > 1)
316
317 def test_upload_with_inital_partial_upload_before_failure(self):
318 """
319 Tests resumable upload that successfully uploads some content
320 before it fails, then restarts and completes
321 """
322 # Set up harnass to fail upload after several hundred KB so upload
323 # server will have saved something before we retry.
324 harnass = CallbackTestHarnass(
325 fail_after_n_bytes=self.larger_src_file_size/2)
326 res_upload_handler = ResumableUploadHandler(num_retries=1)
327 self.dst_key.set_contents_from_file(
328 self.larger_src_file, cb=harnass.call,
329 res_upload_handler=res_upload_handler)
330 # Ensure uploaded object has correct content.
331 self.assertEqual(self.larger_src_file_size, self.dst_key.size)
332 self.assertEqual(self.larger_src_file_as_string,
333 self.dst_key.get_contents_as_string())
334 # Ensure some of the file was uploaded both before and after failure.
335 self.assertTrue(len(harnass.transferred_seq_before_first_failure) > 1
336 and
337 len(harnass.transferred_seq_after_first_failure) > 1)
338
339 def test_empty_file_upload(self):
340 """
341 Tests uploading an empty file (exercises boundary conditions).
342 """
343 res_upload_handler = ResumableUploadHandler()
344 self.dst_key.set_contents_from_file(
345 self.empty_src_file, res_upload_handler=res_upload_handler)
346 self.assertEqual(0, self.dst_key.size)
347
348 def test_upload_retains_metadata(self):
349 """
350 Tests that resumable upload correctly sets passed metadata
351 """
352 res_upload_handler = ResumableUploadHandler()
353 headers = {'Content-Type' : 'text/plain', 'Content-Encoding' : 'gzip',
354 'x-goog-meta-abc' : 'my meta', 'x-goog-acl' : 'public-read'}
355 self.dst_key.set_contents_from_file(
356 self.small_src_file, headers=headers,
357 res_upload_handler=res_upload_handler)
358 self.assertEqual(self.small_src_file_size, self.dst_key.size)
359 self.assertEqual(self.small_src_file_as_string,
360 self.dst_key.get_contents_as_string())
361 self.dst_key.open_read()
362 self.assertEqual('text/plain', self.dst_key.content_type)
363 self.assertEqual('gzip', self.dst_key.content_encoding)
364 self.assertTrue('abc' in self.dst_key.metadata)
365 self.assertEqual('my meta', str(self.dst_key.metadata['abc']))
366 acl = self.dst_key.get_acl()
367 for entry in acl.entries.entry_list:
368 if str(entry.scope) == '<AllUsers>':
369 self.assertEqual('READ', str(acl.entries.entry_list[1].permissio n))
370 return
371 self.fail('No <AllUsers> scope found')
372
373 def test_upload_with_file_size_change_between_starts(self):
374 """
375 Tests resumable upload on a file that changes sizes between inital
376 upload start and restart
377 """
378 harnass = CallbackTestHarnass(
379 fail_after_n_bytes=self.larger_src_file_size/2)
380 # Set up first process' ResumableUploadHandler not to do any
381 # retries (initial upload request will establish expected size to
382 # upload server).
383 res_upload_handler = ResumableUploadHandler(
384 tracker_file_name=self.tracker_file_name, num_retries=0)
385 try:
386 self.dst_key.set_contents_from_file(
387 self.larger_src_file, cb=harnass.call,
388 res_upload_handler=res_upload_handler)
389 self.fail('Did not get expected ResumableUploadException')
390 except ResumableUploadException, e:
391 self.assertEqual(e.disposition, ResumableTransferDisposition.ABORT)
392 # Ensure a tracker file survived.
393 self.assertTrue(os.path.exists(self.tracker_file_name))
394 # Try it again, this time with different size source file.
395 # Wait 1 second between retry attempts, to give upload server a
396 # chance to save state so it can respond to changed file size with
397 # 500 response in the next attempt.
398 time.sleep(1)
399 try:
400 self.dst_key.set_contents_from_file(
401 self.largest_src_file, res_upload_handler=res_upload_handler)
402 self.fail('Did not get expected ResumableUploadException')
403 except ResumableUploadException, e:
404 self.assertEqual(e.disposition, ResumableTransferDisposition.ABORT)
405 self.assertNotEqual(
406 e.message.find('attempt to upload a different size file'), -1)
407
408 def test_upload_with_file_size_change_during_upload(self):
409 """
410 Tests resumable upload on a file that changes sizes while upload
411 in progress
412 """
413 # Create a file we can change during the upload.
414 test_file_size = 500 * 1024 # 500 KB.
415 test_file = self.build_test_input_file(test_file_size)[1]
416 harnass = CallbackTestHarnass(fp_to_change=test_file,
417 fp_change_pos=test_file_size)
418 res_upload_handler = ResumableUploadHandler(num_retries=1)
419 try:
420 self.dst_key.set_contents_from_file(
421 test_file, cb=harnass.call,
422 res_upload_handler=res_upload_handler)
423 self.fail('Did not get expected ResumableUploadException')
424 except ResumableUploadException, e:
425 self.assertEqual(e.disposition, ResumableTransferDisposition.ABORT)
426 self.assertNotEqual(
427 e.message.find('File changed during upload'), -1)
428
429 def test_upload_with_file_content_change_during_upload(self):
430 """
431 Tests resumable upload on a file that changes one byte of content
432 (so, size stays the same) while upload in progress
433 """
434 test_file_size = 500 * 1024 # 500 KB.
435 test_file = self.build_test_input_file(test_file_size)[1]
436 harnass = CallbackTestHarnass(fail_after_n_bytes=test_file_size/2,
437 fp_to_change=test_file,
438 # Writing at file_size-5 won't change file
439 # size because CallbackTestHarnass only
440 # writes 3 bytes.
441 fp_change_pos=test_file_size-5)
442 res_upload_handler = ResumableUploadHandler(num_retries=1)
443 try:
444 self.dst_key.set_contents_from_file(
445 test_file, cb=harnass.call,
446 res_upload_handler=res_upload_handler)
447 self.fail('Did not get expected ResumableUploadException')
448 except ResumableUploadException, e:
449 self.assertEqual(e.disposition, ResumableTransferDisposition.ABORT)
450 # Ensure the file size didn't change.
451 test_file.seek(0, os.SEEK_END)
452 self.assertEqual(test_file_size, test_file.tell())
453 self.assertNotEqual(
454 e.message.find('md5 signature doesn\'t match etag'), -1)
455 # Ensure the bad data wasn't left around.
456 all_keys = self.dst_key_uri.get_all_keys()
457 self.assertEqual(0, len(all_keys))
458
459 def test_upload_with_content_length_header_set(self):
460 """
461 Tests resumable upload on a file when the user supplies a
462 Content-Length header. This is used by gsutil, for example,
463 to set the content length when gzipping a file.
464 """
465 res_upload_handler = ResumableUploadHandler()
466 try:
467 self.dst_key.set_contents_from_file(
468 self.small_src_file, res_upload_handler=res_upload_handler,
469 headers={'Content-Length' : self.small_src_file_size})
470 self.fail('Did not get expected ResumableUploadException')
471 except ResumableUploadException, e:
472 self.assertEqual(e.disposition, ResumableTransferDisposition.ABORT)
473 self.assertNotEqual(
474 e.message.find('Attempt to specify Content-Length header'), -1)
475
476 def test_upload_with_syntactically_invalid_tracker_uri(self):
477 """
478 Tests resumable upload with a syntactically invalid tracker URI
479 """
480 res_upload_handler = ResumableUploadHandler(
481 tracker_file_name=self.syntactically_invalid_tracker_file_name)
482 # An error should be printed about the invalid URI, but then it
483 # should run the update successfully.
484 self.dst_key.set_contents_from_file(
485 self.small_src_file, res_upload_handler=res_upload_handler)
486 self.assertEqual(self.small_src_file_size, self.dst_key.size)
487 self.assertEqual(self.small_src_file_as_string,
488 self.dst_key.get_contents_as_string())
489
490 def test_upload_with_invalid_upload_id_in_tracker_file(self):
491 """
492 Tests resumable upload with invalid upload ID
493 """
494 res_upload_handler = ResumableUploadHandler(
495 tracker_file_name=self.invalid_upload_id_tracker_file_name)
496 # An error should occur, but then the tracker URI should be
497 # regenerated and the the update should succeed.
498 self.dst_key.set_contents_from_file(
499 self.small_src_file, res_upload_handler=res_upload_handler)
500 self.assertEqual(self.small_src_file_size, self.dst_key.size)
501 self.assertEqual(self.small_src_file_as_string,
502 self.dst_key.get_contents_as_string())
503 self.assertNotEqual(self.invalid_upload_id,
504 res_upload_handler.get_tracker_uri())
505
506 def test_upload_with_unwritable_tracker_file(self):
507 """
508 Tests resumable upload with an unwritable tracker file
509 """
510 # Make dir where tracker_file lives temporarily unwritable.
511 save_mod = os.stat(self.tmp_dir).st_mode
512 try:
513 os.chmod(self.tmp_dir, 0)
514 res_upload_handler = ResumableUploadHandler(
515 tracker_file_name=self.tracker_file_name)
516 except ResumableUploadException, e:
517 self.assertEqual(e.disposition, ResumableTransferDisposition.ABORT)
518 self.assertNotEqual(
519 e.message.find('Couldn\'t write URI tracker file'), -1)
520 finally:
521 # Restore original protection of dir where tracker_file lives.
522 os.chmod(self.tmp_dir, save_mod)
523
524 if __name__ == '__main__':
525 if sys.version_info[:3] < (2, 5, 1):
526 sys.exit('These tests must be run on at least Python 2.5.1\n')
527
528 # Use -d to see more HTTP protocol detail during tests.
529 debug = 0
530 opts, args = getopt.getopt(sys.argv[1:], 'd', ['debug'])
531 for o, a in opts:
532 if o in ('-d', '--debug'):
533 debug = 2
534
535 test_loader = unittest.TestLoader()
536 test_loader.testMethodPrefix = 'test_'
537 suite = test_loader.loadTestsFromTestCase(ResumableUploadTests)
538 # Seems like there should be a cleaner way to find the test_class.
539 test_class = suite.__getattribute__('_tests')[0]
540 # We call set_up_class() and tear_down_class() ourselves because we
541 # don't assume the user has Python 2.7 (which supports classmethods
542 # that do it, with camelCase versions of these names).
543 try:
544 print 'Setting up %s...' % test_class.get_suite_description()
545 test_class.set_up_class(debug)
546 print 'Running %s...' % test_class.get_suite_description()
547 unittest.TextTestRunner(verbosity=2).run(suite)
548 finally:
549 print 'Cleaning up after %s...' % test_class.get_suite_description()
550 test_class.tear_down_class()
551 print ''
OLDNEW
« no previous file with comments | « boto/tests/test_resumable_downloads.py ('k') | boto/tests/test_s3connection.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698