| OLD | NEW |
| 1 #!/usr/bin/env python | 1 #!/usr/bin/env python |
| 2 # | 2 # |
| 3 # Copyright 2010 Google Inc. | 3 # Copyright 2010 Google Inc. |
| 4 # | 4 # |
| 5 # Permission is hereby granted, free of charge, to any person obtaining a | 5 # Permission is hereby granted, free of charge, to any person obtaining a |
| 6 # copy of this software and associated documentation files (the | 6 # copy of this software and associated documentation files (the |
| 7 # "Software"), to deal in the Software without restriction, including | 7 # "Software"), to deal in the Software without restriction, including |
| 8 # without limitation the rights to use, copy, modify, merge, publish, dis- | 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 | 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- | 10 # persons to whom the Software is furnished to do so, subject to the fol- |
| (...skipping 27 matching lines...) Expand all Loading... |
| 38 import time | 38 import time |
| 39 import unittest | 39 import unittest |
| 40 | 40 |
| 41 import boto | 41 import boto |
| 42 from boto import storage_uri | 42 from boto import storage_uri |
| 43 from boto.s3.resumable_download_handler import get_cur_file_size | 43 from boto.s3.resumable_download_handler import get_cur_file_size |
| 44 from boto.s3.resumable_download_handler import ResumableDownloadHandler | 44 from boto.s3.resumable_download_handler import ResumableDownloadHandler |
| 45 from boto.exception import ResumableTransferDisposition | 45 from boto.exception import ResumableTransferDisposition |
| 46 from boto.exception import ResumableDownloadException | 46 from boto.exception import ResumableDownloadException |
| 47 from boto.exception import StorageResponseError | 47 from boto.exception import StorageResponseError |
| 48 from tests.s3.cb_test_harnass import CallbackTestHarnass | 48 from cb_test_harnass import CallbackTestHarnass |
| 49 | 49 |
| 50 # We don't use the OAuth2 authentication plugin directly; importing it here | 50 # We don't use the OAuth2 authentication plugin directly; importing it here |
| 51 # ensures that it's loaded and available by default. | 51 # ensures that it's loaded and available by default. |
| 52 try: | 52 try: |
| 53 from oauth2_plugin import oauth2_plugin | 53 from oauth2_plugin import oauth2_plugin |
| 54 except ImportError: | 54 except ImportError: |
| 55 # Do nothing - if user doesn't have OAuth2 configured it doesn't matter; | 55 # Do nothing - if user doesn't have OAuth2 configured it doesn't matter; |
| 56 # and if they do, the tests will fail (as they should in that case). | 56 # and if they do, the tests will fail (as they should in that case). |
| 57 pass | 57 pass |
| 58 | 58 |
| 59 | 59 |
| 60 class ResumableDownloadTests(unittest.TestCase): | 60 class ResumableDownloadTests(unittest.TestCase): |
| 61 """ | 61 """ |
| 62 Resumable download test suite. | 62 Resumable download test suite. |
| 63 """ | 63 """ |
| 64 | 64 |
| 65 def get_suite_description(self): | 65 def get_suite_description(self): |
| 66 return 'Resumable download test suite' | 66 return 'Resumable download test suite' |
| 67 | 67 |
| 68 @staticmethod | 68 @staticmethod |
| 69 def resilient_close(key): | 69 def resilient_close(key): |
| 70 try: | 70 try: |
| 71 key.close() | 71 key.close() |
| 72 except StorageResponseError, e: | 72 except StorageResponseError, e: |
| 73 pass | 73 pass |
| 74 | 74 |
| 75 @classmethod | 75 def build_test_input_object(self, obj_name, size): |
| 76 def setUp(cls): | |
| 77 """ | |
| 78 Creates file-like object for detination of each download test. | |
| 79 | |
| 80 This method's namingCase is required by the unittest framework. | |
| 81 """ | |
| 82 cls.dst_fp = open(cls.dst_file_name, 'w') | |
| 83 | |
| 84 @classmethod | |
| 85 def tearDown(cls): | |
| 86 """ | |
| 87 Deletes any objects or files created by last test run, and closes | |
| 88 any keys in case they were read incompletely (which would leave | |
| 89 partial buffers of data for subsequent tests to trip over). | |
| 90 | |
| 91 This method's namingCase is required by the unittest framework. | |
| 92 """ | |
| 93 # Recursively delete dst dir and then re-create it, so in effect we | |
| 94 # remove all dirs and files under that directory. | |
| 95 shutil.rmtree(cls.tmp_dir) | |
| 96 os.mkdir(cls.tmp_dir) | |
| 97 | |
| 98 # Close test objects. | |
| 99 cls.resilient_close(cls.empty_src_key) | |
| 100 cls.resilient_close(cls.small_src_key) | |
| 101 cls.resilient_close(cls.larger_src_key) | |
| 102 | |
| 103 @classmethod | |
| 104 def build_test_input_object(cls, obj_name, size, debug): | |
| 105 buf = [] | 76 buf = [] |
| 106 for i in range(size): | 77 for i in range(size): |
| 107 buf.append(str(random.randint(0, 9))) | 78 buf.append(str(random.randint(0, 9))) |
| 108 string_data = ''.join(buf) | 79 string_data = ''.join(buf) |
| 109 uri = cls.src_bucket_uri.clone_replace_name(obj_name) | 80 uri = self.src_bucket_uri.clone_replace_name(obj_name) |
| 110 key = uri.new_key(validate=False) | 81 key = uri.new_key(validate=False) |
| 111 key.set_contents_from_file(StringIO.StringIO(string_data)) | 82 key.set_contents_from_file(StringIO.StringIO(string_data)) |
| 112 # Set debug on key's connection after creating data, so only the test | |
| 113 # runs will show HTTP output (if called passed debug>0). | |
| 114 key.bucket.connection.debug = debug | |
| 115 return (string_data, key) | 83 return (string_data, key) |
| 116 | 84 |
| 117 @classmethod | 85 def setUp(self): |
| 118 def set_up_class(cls, debug): | |
| 119 """ | 86 """ |
| 120 Initializes test suite. | 87 Initializes for each test. |
| 121 """ | 88 """ |
| 122 | |
| 123 # Create the test bucket. | 89 # Create the test bucket. |
| 124 hostname = socket.gethostname().split('.')[0] | 90 hostname = socket.gethostname().split('.')[0] |
| 125 uri_base_str = 'gs://res_download_test_%s_%s_%s' % ( | 91 uri_base_str = 'gs://res-download-test-%s-%s-%s' % ( |
| 126 hostname, os.getpid(), int(time.time())) | 92 hostname, os.getpid(), int(time.time())) |
| 127 cls.src_bucket_uri = storage_uri('%s_dst' % uri_base_str) | 93 self.src_bucket_uri = storage_uri('%s-dst' % uri_base_str) |
| 128 cls.src_bucket_uri.create_bucket() | 94 self.src_bucket_uri.create_bucket() |
| 129 | 95 |
| 130 # Create test source objects. | 96 # Create test source objects. |
| 131 cls.empty_src_key_size = 0 | 97 self.empty_src_key_size = 0 |
| 132 (cls.empty_src_key_as_string, cls.empty_src_key) = ( | 98 (self.empty_src_key_as_string, self.empty_src_key) = ( |
| 133 cls.build_test_input_object('empty', cls.empty_src_key_size, | 99 self.build_test_input_object('empty', self.empty_src_key_size)) |
| 134 debug=debug)) | 100 self.small_src_key_size = 2 * 1024 # 2 KB. |
| 135 cls.small_src_key_size = 2 * 1024 # 2 KB. | 101 (self.small_src_key_as_string, self.small_src_key) = ( |
| 136 (cls.small_src_key_as_string, cls.small_src_key) = ( | 102 self.build_test_input_object('small', self.small_src_key_size)) |
| 137 cls.build_test_input_object('small', cls.small_src_key_size, | 103 self.larger_src_key_size = 500 * 1024 # 500 KB. |
| 138 debug=debug)) | 104 (self.larger_src_key_as_string, self.larger_src_key) = ( |
| 139 cls.larger_src_key_size = 500 * 1024 # 500 KB. | 105 self.build_test_input_object('larger', self.larger_src_key_size)) |
| 140 (cls.larger_src_key_as_string, cls.larger_src_key) = ( | |
| 141 cls.build_test_input_object('larger', cls.larger_src_key_size, | |
| 142 debug=debug)) | |
| 143 | 106 |
| 144 # Use a designated tmpdir prefix to make it easy to find the end of | 107 # Use a designated tmpdir prefix to make it easy to find the end of |
| 145 # the tmp path. | 108 # the tmp path. |
| 146 cls.tmpdir_prefix = 'tmp_resumable_download_test' | 109 self.tmpdir_prefix = 'tmp_resumable_download_test' |
| 147 | 110 |
| 148 # Create temp dir and name for download file. | 111 # Create temp dir and name for download file. |
| 149 cls.tmp_dir = tempfile.mkdtemp(prefix=cls.tmpdir_prefix) | 112 self.tmp_dir = tempfile.mkdtemp(prefix=self.tmpdir_prefix) |
| 150 cls.dst_file_name = '%s%sdst_file' % (cls.tmp_dir, os.sep) | 113 self.dst_file_name = '%s%sdst_file' % (self.tmp_dir, os.sep) |
| 151 | 114 |
| 152 cls.tracker_file_name = '%s%stracker' % (cls.tmp_dir, os.sep) | 115 self.tracker_file_name = '%s%stracker' % (self.tmp_dir, os.sep) |
| 153 | 116 |
| 154 cls.created_test_data = True | 117 # Create file-like object for detination of each download test. |
| 118 self.dst_fp = open(self.dst_file_name, 'w') |
| 119 self.created_test_data = True |
| 155 | 120 |
| 156 @classmethod | 121 def tearDown(self): |
| 157 def tear_down_class(cls): | |
| 158 """ | 122 """ |
| 159 Deletes test objects and bucket and tmp dir created by set_up_class. | 123 Deletes test objects and bucket and tmp dir created by set_up_class, |
| 124 and closes any keys in case they were read incompletely (which would |
| 125 leave partial buffers of data for subsequent tests to trip over). |
| 160 """ | 126 """ |
| 161 if not hasattr(cls, 'created_test_data'): | 127 if not hasattr(self, 'created_test_data'): |
| 162 return | 128 return |
| 163 # Call cls.tearDown() in case the tests got interrupted, to ensure | 129 # Recursively delete dst dir and then re-create it, so in effect we |
| 164 # dst objects get deleted. | 130 # remove all dirs and files under that directory. |
| 165 cls.tearDown() | 131 shutil.rmtree(self.tmp_dir) |
| 132 os.mkdir(self.tmp_dir) |
| 133 |
| 134 # Close test objects. |
| 135 self.resilient_close(self.empty_src_key) |
| 136 self.resilient_close(self.small_src_key) |
| 137 self.resilient_close(self.larger_src_key) |
| 166 | 138 |
| 167 # Delete test objects. | 139 # Delete test objects. |
| 168 cls.empty_src_key.delete() | 140 self.empty_src_key.delete() |
| 169 cls.small_src_key.delete() | 141 self.small_src_key.delete() |
| 170 cls.larger_src_key.delete() | 142 self.larger_src_key.delete() |
| 171 | 143 |
| 172 # Retry (for up to 2 minutes) the bucket gets deleted (it may not | 144 # Retry (for up to 2 minutes) the bucket gets deleted (it may not |
| 173 # the first time round, due to eventual consistency of bucket delete | 145 # the first time round, due to eventual consistency of bucket delete |
| 174 # operations). | 146 # operations). |
| 175 for i in range(60): | 147 for i in range(60): |
| 176 try: | 148 try: |
| 177 cls.src_bucket_uri.delete_bucket() | 149 self.src_bucket_uri.delete_bucket() |
| 178 break | 150 break |
| 179 except StorageResponseError: | 151 except StorageResponseError: |
| 180 print 'Test bucket (%s) not yet deleted, still trying' % ( | 152 print 'Test bucket (%s) not yet deleted, still trying' % ( |
| 181 cls.src_bucket_uri.uri) | 153 self.src_bucket_uri.uri) |
| 182 time.sleep(2) | 154 time.sleep(2) |
| 183 shutil.rmtree(cls.tmp_dir) | 155 shutil.rmtree(self.tmp_dir) |
| 184 cls.tmp_dir = tempfile.mkdtemp(prefix=cls.tmpdir_prefix) | 156 self.tmp_dir = tempfile.mkdtemp(prefix=self.tmpdir_prefix) |
| 185 | 157 |
| 186 def test_non_resumable_download(self): | 158 def test_non_resumable_download(self): |
| 187 """ | 159 """ |
| 188 Tests that non-resumable downloads work | 160 Tests that non-resumable downloads work |
| 189 """ | 161 """ |
| 190 self.small_src_key.get_contents_to_file(self.dst_fp) | 162 self.small_src_key.get_contents_to_file(self.dst_fp) |
| 191 self.assertEqual(self.small_src_key_size, | 163 self.assertEqual(self.small_src_key_size, |
| 192 get_cur_file_size(self.dst_fp)) | 164 get_cur_file_size(self.dst_fp)) |
| 193 self.assertEqual(self.small_src_key_as_string, | 165 self.assertEqual(self.small_src_key_as_string, |
| 194 self.small_src_key.get_contents_as_string()) | 166 self.small_src_key.get_contents_as_string()) |
| (...skipping 177 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 372 | 344 |
| 373 def test_zero_length_object_download(self): | 345 def test_zero_length_object_download(self): |
| 374 """ | 346 """ |
| 375 Tests downloading a zero-length object (exercises boundary conditions). | 347 Tests downloading a zero-length object (exercises boundary conditions). |
| 376 """ | 348 """ |
| 377 res_download_handler = ResumableDownloadHandler() | 349 res_download_handler = ResumableDownloadHandler() |
| 378 self.empty_src_key.get_contents_to_file( | 350 self.empty_src_key.get_contents_to_file( |
| 379 self.dst_fp, res_download_handler=res_download_handler) | 351 self.dst_fp, res_download_handler=res_download_handler) |
| 380 self.assertEqual(0, get_cur_file_size(self.dst_fp)) | 352 self.assertEqual(0, get_cur_file_size(self.dst_fp)) |
| 381 | 353 |
| 382 def test_download_with_object_size_change_between_starts(self): | |
| 383 """ | |
| 384 Tests resumable download on an object that changes sizes between inital | |
| 385 download start and restart | |
| 386 """ | |
| 387 harnass = CallbackTestHarnass( | |
| 388 fail_after_n_bytes=self.larger_src_key_size/2, num_times_to_fail=2) | |
| 389 # Set up first process' ResumableDownloadHandler not to do any | |
| 390 # retries (initial download request will establish expected size to | |
| 391 # download server). | |
| 392 res_download_handler = ResumableDownloadHandler( | |
| 393 tracker_file_name=self.tracker_file_name, num_retries=0) | |
| 394 try: | |
| 395 self.larger_src_key.get_contents_to_file( | |
| 396 self.dst_fp, cb=harnass.call, | |
| 397 res_download_handler=res_download_handler) | |
| 398 self.fail('Did not get expected ResumableDownloadException') | |
| 399 except ResumableDownloadException, e: | |
| 400 # First abort (from harnass-forced failure) should be | |
| 401 # ABORT_CUR_PROCESS. | |
| 402 self.assertEqual(e.disposition, ResumableTransferDisposition.ABORT_C
UR_PROCESS) | |
| 403 # Ensure a tracker file survived. | |
| 404 self.assertTrue(os.path.exists(self.tracker_file_name)) | |
| 405 # Try it again, this time with different src key (simulating an | |
| 406 # object that changes sizes between downloads). | |
| 407 try: | |
| 408 self.small_src_key.get_contents_to_file( | |
| 409 self.dst_fp, res_download_handler=res_download_handler) | |
| 410 self.fail('Did not get expected ResumableDownloadException') | |
| 411 except ResumableDownloadException, e: | |
| 412 # This abort should be a hard abort (object size changing during | |
| 413 # transfer). | |
| 414 self.assertEqual(e.disposition, ResumableTransferDisposition.ABORT) | |
| 415 self.assertNotEqual( | |
| 416 e.message.find('md5 signature doesn\'t match etag'), -1) | |
| 417 | |
| 418 def test_download_with_file_content_change_during_download(self): | |
| 419 """ | |
| 420 Tests resumable download on an object where the file content changes | |
| 421 without changing length while download in progress | |
| 422 """ | |
| 423 harnass = CallbackTestHarnass( | |
| 424 fail_after_n_bytes=self.larger_src_key_size/2, num_times_to_fail=2) | |
| 425 # Set up first process' ResumableDownloadHandler not to do any | |
| 426 # retries (initial download request will establish expected size to | |
| 427 # download server). | |
| 428 res_download_handler = ResumableDownloadHandler( | |
| 429 tracker_file_name=self.tracker_file_name, num_retries=0) | |
| 430 dst_filename = self.dst_fp.name | |
| 431 try: | |
| 432 self.larger_src_key.get_contents_to_file( | |
| 433 self.dst_fp, cb=harnass.call, | |
| 434 res_download_handler=res_download_handler) | |
| 435 self.fail('Did not get expected ResumableDownloadException') | |
| 436 except ResumableDownloadException, e: | |
| 437 # First abort (from harnass-forced failure) should be | |
| 438 # ABORT_CUR_PROCESS. | |
| 439 self.assertEqual(e.disposition, | |
| 440 ResumableTransferDisposition.ABORT_CUR_PROCESS) | |
| 441 # Ensure a tracker file survived. | |
| 442 self.assertTrue(os.path.exists(self.tracker_file_name)) | |
| 443 # Before trying again change the first byte of the file fragment | |
| 444 # that was already downloaded. | |
| 445 orig_size = get_cur_file_size(self.dst_fp) | |
| 446 self.dst_fp.seek(0, os.SEEK_SET) | |
| 447 self.dst_fp.write('a') | |
| 448 # Ensure the file size didn't change. | |
| 449 self.assertEqual(orig_size, get_cur_file_size(self.dst_fp)) | |
| 450 try: | |
| 451 self.larger_src_key.get_contents_to_file( | |
| 452 self.dst_fp, cb=harnass.call, | |
| 453 res_download_handler=res_download_handler) | |
| 454 self.fail('Did not get expected ResumableDownloadException') | |
| 455 except ResumableDownloadException, e: | |
| 456 # This abort should be a hard abort (file content changing during | |
| 457 # transfer). | |
| 458 self.assertEqual(e.disposition, ResumableTransferDisposition.ABORT) | |
| 459 self.assertNotEqual( | |
| 460 e.message.find('md5 signature doesn\'t match etag'), -1) | |
| 461 # Ensure the bad data wasn't left around. | |
| 462 self.assertFalse(os.path.exists(dst_filename)) | |
| 463 | |
| 464 def test_download_with_invalid_tracker_etag(self): | 354 def test_download_with_invalid_tracker_etag(self): |
| 465 """ | 355 """ |
| 466 Tests resumable download with a tracker file containing an invalid etag | 356 Tests resumable download with a tracker file containing an invalid etag |
| 467 """ | 357 """ |
| 468 invalid_etag_tracker_file_name = ( | 358 invalid_etag_tracker_file_name = ( |
| 469 '%s%sinvalid_etag_tracker' % (self.tmp_dir, os.sep)) | 359 '%s%sinvalid_etag_tracker' % (self.tmp_dir, os.sep)) |
| 470 f = open(invalid_etag_tracker_file_name, 'w') | 360 f = open(invalid_etag_tracker_file_name, 'w') |
| 471 f.write('3.14159\n') | 361 f.write('3.14159\n') |
| 472 f.close() | 362 f.close() |
| 473 res_download_handler = ResumableDownloadHandler( | 363 res_download_handler = ResumableDownloadHandler( |
| (...skipping 41 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 515 os.chmod(self.tmp_dir, 0) | 405 os.chmod(self.tmp_dir, 0) |
| 516 res_download_handler = ResumableDownloadHandler( | 406 res_download_handler = ResumableDownloadHandler( |
| 517 tracker_file_name=self.tracker_file_name) | 407 tracker_file_name=self.tracker_file_name) |
| 518 except ResumableDownloadException, e: | 408 except ResumableDownloadException, e: |
| 519 self.assertEqual(e.disposition, ResumableTransferDisposition.ABORT) | 409 self.assertEqual(e.disposition, ResumableTransferDisposition.ABORT) |
| 520 self.assertNotEqual( | 410 self.assertNotEqual( |
| 521 e.message.find('Couldn\'t write URI tracker file'), -1) | 411 e.message.find('Couldn\'t write URI tracker file'), -1) |
| 522 finally: | 412 finally: |
| 523 # Restore original protection of dir where tracker_file lives. | 413 # Restore original protection of dir where tracker_file lives. |
| 524 os.chmod(self.tmp_dir, save_mod) | 414 os.chmod(self.tmp_dir, save_mod) |
| 525 | |
| 526 if __name__ == '__main__': | |
| 527 if sys.version_info[:3] < (2, 5, 1): | |
| 528 sys.exit('These tests must be run on at least Python 2.5.1\n') | |
| 529 | |
| 530 # Use -d to see more HTTP protocol detail during tests. Note that | |
| 531 # unlike the upload test case, you won't see much for the downloads | |
| 532 # because there's no HTTP server state protocol for in the download case | |
| 533 # (and the actual Range GET HTTP protocol detail is suppressed by the | |
| 534 # normal boto.s3.Key.get_file() processing). | |
| 535 debug = 0 | |
| 536 opts, args = getopt.getopt(sys.argv[1:], 'd', ['debug']) | |
| 537 for o, a in opts: | |
| 538 if o in ('-d', '--debug'): | |
| 539 debug = 2 | |
| 540 | |
| 541 test_loader = unittest.TestLoader() | |
| 542 test_loader.testMethodPrefix = 'test_' | |
| 543 suite = test_loader.loadTestsFromTestCase(ResumableDownloadTests) | |
| 544 # Seems like there should be a cleaner way to find the test_class. | |
| 545 test_class = suite.__getattribute__('_tests')[0] | |
| 546 # We call set_up_class() and tear_down_class() ourselves because we | |
| 547 # don't assume the user has Python 2.7 (which supports classmethods | |
| 548 # that do it, with camelCase versions of these names). | |
| 549 try: | |
| 550 print 'Setting up %s...' % test_class.get_suite_description() | |
| 551 test_class.set_up_class(debug) | |
| 552 print 'Running %s...' % test_class.get_suite_description() | |
| 553 unittest.TextTestRunner(verbosity=2).run(suite) | |
| 554 finally: | |
| 555 print 'Cleaning up after %s...' % test_class.get_suite_description() | |
| 556 test_class.tear_down_class() | |
| 557 print '' | |
| OLD | NEW |