OLD | NEW |
| (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 downloads. | |
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 import storage_uri | |
43 from boto.s3.resumable_download_handler import get_cur_file_size | |
44 from boto.s3.resumable_download_handler import ResumableDownloadHandler | |
45 from boto.exception import ResumableTransferDisposition | |
46 from boto.exception import ResumableDownloadException | |
47 from boto.exception import StorageResponseError | |
48 from boto.tests.cb_test_harnass import CallbackTestHarnass | |
49 | |
50 | |
51 class ResumableDownloadTests(unittest.TestCase): | |
52 """ | |
53 Resumable download test suite. | |
54 """ | |
55 | |
56 def get_suite_description(self): | |
57 return 'Resumable download test suite' | |
58 | |
59 @staticmethod | |
60 def resilient_close(key): | |
61 try: | |
62 key.close() | |
63 except StorageResponseError, e: | |
64 pass | |
65 | |
66 @classmethod | |
67 def setUp(cls): | |
68 """ | |
69 Creates file-like object for detination of each download test. | |
70 | |
71 This method's namingCase is required by the unittest framework. | |
72 """ | |
73 cls.dst_fp = open(cls.dst_file_name, 'w') | |
74 | |
75 @classmethod | |
76 def tearDown(cls): | |
77 """ | |
78 Deletes any objects or files created by last test run, and closes | |
79 any keys in case they were read incompletely (which would leave | |
80 partial buffers of data for subsequent tests to trip over). | |
81 | |
82 This method's namingCase is required by the unittest framework. | |
83 """ | |
84 # Recursively delete dst dir and then re-create it, so in effect we | |
85 # remove all dirs and files under that directory. | |
86 shutil.rmtree(cls.tmp_dir) | |
87 os.mkdir(cls.tmp_dir) | |
88 | |
89 # Close test objects. | |
90 cls.resilient_close(cls.empty_src_key) | |
91 cls.resilient_close(cls.small_src_key) | |
92 cls.resilient_close(cls.larger_src_key) | |
93 | |
94 @classmethod | |
95 def build_test_input_object(cls, obj_name, size, debug): | |
96 buf = [] | |
97 for i in range(size): | |
98 buf.append(str(random.randint(0, 9))) | |
99 string_data = ''.join(buf) | |
100 uri = cls.src_bucket_uri.clone_replace_name(obj_name) | |
101 key = uri.new_key(validate=False) | |
102 key.set_contents_from_file(StringIO.StringIO(string_data)) | |
103 # Set debug on key's connection after creating data, so only the test | |
104 # runs will show HTTP output (if called passed debug>0). | |
105 key.bucket.connection.debug = debug | |
106 return (string_data, key) | |
107 | |
108 @classmethod | |
109 def set_up_class(cls, debug): | |
110 """ | |
111 Initializes test suite. | |
112 """ | |
113 | |
114 # Create the test bucket. | |
115 hostname = socket.gethostname().split('.')[0] | |
116 uri_base_str = 'gs://res_download_test_%s_%s_%s' % ( | |
117 hostname, os.getpid(), int(time.time())) | |
118 cls.src_bucket_uri = storage_uri('%s_dst' % uri_base_str) | |
119 cls.src_bucket_uri.create_bucket() | |
120 | |
121 # Create test source objects. | |
122 cls.empty_src_key_size = 0 | |
123 (cls.empty_src_key_as_string, cls.empty_src_key) = ( | |
124 cls.build_test_input_object('empty', cls.empty_src_key_size, | |
125 debug=debug)) | |
126 cls.small_src_key_size = 2 * 1024 # 2 KB. | |
127 (cls.small_src_key_as_string, cls.small_src_key) = ( | |
128 cls.build_test_input_object('small', cls.small_src_key_size, | |
129 debug=debug)) | |
130 cls.larger_src_key_size = 500 * 1024 # 500 KB. | |
131 (cls.larger_src_key_as_string, cls.larger_src_key) = ( | |
132 cls.build_test_input_object('larger', cls.larger_src_key_size, | |
133 debug=debug)) | |
134 | |
135 # Use a designated tmpdir prefix to make it easy to find the end of | |
136 # the tmp path. | |
137 cls.tmpdir_prefix = 'tmp_resumable_download_test' | |
138 | |
139 # Create temp dir and name for download file. | |
140 cls.tmp_dir = tempfile.mkdtemp(prefix=cls.tmpdir_prefix) | |
141 cls.dst_file_name = '%s%sdst_file' % (cls.tmp_dir, os.sep) | |
142 | |
143 cls.tracker_file_name = '%s%stracker' % (cls.tmp_dir, os.sep) | |
144 | |
145 cls.created_test_data = True | |
146 | |
147 @classmethod | |
148 def tear_down_class(cls): | |
149 """ | |
150 Deletes test objects and bucket and tmp dir created by set_up_class. | |
151 """ | |
152 if not hasattr(cls, 'created_test_data'): | |
153 return | |
154 # Call cls.tearDown() in case the tests got interrupted, to ensure | |
155 # dst objects get deleted. | |
156 cls.tearDown() | |
157 | |
158 # Delete test objects. | |
159 cls.empty_src_key.delete() | |
160 cls.small_src_key.delete() | |
161 cls.larger_src_key.delete() | |
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.src_bucket_uri.delete_bucket() | |
169 break | |
170 except StorageResponseError: | |
171 print 'Test bucket (%s) not yet deleted, still trying' % ( | |
172 cls.src_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_download(self): | |
178 """ | |
179 Tests that non-resumable downloads work | |
180 """ | |
181 self.small_src_key.get_contents_to_file(self.dst_fp) | |
182 self.assertEqual(self.small_src_key_size, | |
183 get_cur_file_size(self.dst_fp)) | |
184 self.assertEqual(self.small_src_key_as_string, | |
185 self.small_src_key.get_contents_as_string()) | |
186 | |
187 def test_download_without_persistent_tracker(self): | |
188 """ | |
189 Tests a single resumable download, with no tracker persistence | |
190 """ | |
191 res_download_handler = ResumableDownloadHandler() | |
192 self.small_src_key.get_contents_to_file( | |
193 self.dst_fp, res_download_handler=res_download_handler) | |
194 self.assertEqual(self.small_src_key_size, | |
195 get_cur_file_size(self.dst_fp)) | |
196 self.assertEqual(self.small_src_key_as_string, | |
197 self.small_src_key.get_contents_as_string()) | |
198 | |
199 def test_failed_download_with_persistent_tracker(self): | |
200 """ | |
201 Tests that failed resumable download leaves a correct tracker file | |
202 """ | |
203 harnass = CallbackTestHarnass() | |
204 res_download_handler = ResumableDownloadHandler( | |
205 tracker_file_name=self.tracker_file_name, num_retries=0) | |
206 try: | |
207 self.small_src_key.get_contents_to_file( | |
208 self.dst_fp, cb=harnass.call, | |
209 res_download_handler=res_download_handler) | |
210 self.fail('Did not get expected ResumableDownloadException') | |
211 except ResumableDownloadException, e: | |
212 # We'll get a ResumableDownloadException at this point because | |
213 # of CallbackTestHarnass (above). Check that the tracker file was | |
214 # created correctly. | |
215 self.assertEqual(e.disposition, ResumableTransferDisposition.ABORT) | |
216 self.assertTrue(os.path.exists(self.tracker_file_name)) | |
217 f = open(self.tracker_file_name) | |
218 etag_line = f.readline() | |
219 m = re.search(ResumableDownloadHandler.ETAG_REGEX, etag_line) | |
220 f.close() | |
221 self.assertTrue(m) | |
222 | |
223 def test_retryable_exception_recovery(self): | |
224 """ | |
225 Tests handling of a retryable exception | |
226 """ | |
227 # Test one of the RETRYABLE_EXCEPTIONS. | |
228 exception = ResumableDownloadHandler.RETRYABLE_EXCEPTIONS[0] | |
229 harnass = CallbackTestHarnass(exception=exception) | |
230 res_download_handler = ResumableDownloadHandler(num_retries=1) | |
231 self.small_src_key.get_contents_to_file( | |
232 self.dst_fp, cb=harnass.call, | |
233 res_download_handler=res_download_handler) | |
234 # Ensure downloaded object has correct content. | |
235 self.assertEqual(self.small_src_key_size, | |
236 get_cur_file_size(self.dst_fp)) | |
237 self.assertEqual(self.small_src_key_as_string, | |
238 self.small_src_key.get_contents_as_string()) | |
239 | |
240 def test_non_retryable_exception_handling(self): | |
241 """ | |
242 Tests resumable download that fails with a non-retryable exception | |
243 """ | |
244 harnass = CallbackTestHarnass( | |
245 exception=OSError(errno.EACCES, 'Permission denied')) | |
246 res_download_handler = ResumableDownloadHandler(num_retries=1) | |
247 try: | |
248 self.small_src_key.get_contents_to_file( | |
249 self.dst_fp, cb=harnass.call, | |
250 res_download_handler=res_download_handler) | |
251 self.fail('Did not get expected OSError') | |
252 except OSError, e: | |
253 # Ensure the error was re-raised. | |
254 self.assertEqual(e.errno, 13) | |
255 | |
256 def test_failed_and_restarted_download_with_persistent_tracker(self): | |
257 """ | |
258 Tests resumable download that fails once and then completes, | |
259 with tracker file | |
260 """ | |
261 harnass = CallbackTestHarnass() | |
262 res_download_handler = ResumableDownloadHandler( | |
263 tracker_file_name=self.tracker_file_name, num_retries=1) | |
264 self.small_src_key.get_contents_to_file( | |
265 self.dst_fp, cb=harnass.call, | |
266 res_download_handler=res_download_handler) | |
267 # Ensure downloaded object has correct content. | |
268 self.assertEqual(self.small_src_key_size, | |
269 get_cur_file_size(self.dst_fp)) | |
270 self.assertEqual(self.small_src_key_as_string, | |
271 self.small_src_key.get_contents_as_string()) | |
272 # Ensure tracker file deleted. | |
273 self.assertFalse(os.path.exists(self.tracker_file_name)) | |
274 | |
275 def test_multiple_in_process_failures_then_succeed(self): | |
276 """ | |
277 Tests resumable download that fails twice in one process, then completes | |
278 """ | |
279 res_download_handler = ResumableDownloadHandler(num_retries=3) | |
280 self.small_src_key.get_contents_to_file( | |
281 self.dst_fp, res_download_handler=res_download_handler) | |
282 # Ensure downloaded object has correct content. | |
283 self.assertEqual(self.small_src_key_size, | |
284 get_cur_file_size(self.dst_fp)) | |
285 self.assertEqual(self.small_src_key_as_string, | |
286 self.small_src_key.get_contents_as_string()) | |
287 | |
288 def test_multiple_in_process_failures_then_succeed_with_tracker_file(self): | |
289 """ | |
290 Tests resumable download that fails completely in one process, | |
291 then when restarted completes, using a tracker file | |
292 """ | |
293 # Set up test harnass that causes more failures than a single | |
294 # ResumableDownloadHandler instance will handle, writing enough data | |
295 # before the first failure that some of it survives that process run. | |
296 harnass = CallbackTestHarnass( | |
297 fail_after_n_bytes=self.larger_src_key_size/2, num_times_to_fail=2) | |
298 res_download_handler = ResumableDownloadHandler( | |
299 tracker_file_name=self.tracker_file_name, num_retries=0) | |
300 try: | |
301 self.larger_src_key.get_contents_to_file( | |
302 self.dst_fp, cb=harnass.call, | |
303 res_download_handler=res_download_handler) | |
304 self.fail('Did not get expected ResumableDownloadException') | |
305 except ResumableDownloadException, e: | |
306 self.assertEqual(e.disposition, ResumableTransferDisposition.ABORT) | |
307 # Ensure a tracker file survived. | |
308 self.assertTrue(os.path.exists(self.tracker_file_name)) | |
309 # Try it one more time; this time should succeed. | |
310 self.larger_src_key.get_contents_to_file( | |
311 self.dst_fp, cb=harnass.call, | |
312 res_download_handler=res_download_handler) | |
313 self.assertEqual(self.larger_src_key_size, | |
314 get_cur_file_size(self.dst_fp)) | |
315 self.assertEqual(self.larger_src_key_as_string, | |
316 self.larger_src_key.get_contents_as_string()) | |
317 self.assertFalse(os.path.exists(self.tracker_file_name)) | |
318 # Ensure some of the file was downloaded both before and after failure. | |
319 self.assertTrue( | |
320 len(harnass.transferred_seq_before_first_failure) > 1 and | |
321 len(harnass.transferred_seq_after_first_failure) > 1) | |
322 | |
323 def test_download_with_inital_partial_download_before_failure(self): | |
324 """ | |
325 Tests resumable download that successfully downloads some content | |
326 before it fails, then restarts and completes | |
327 """ | |
328 # Set up harnass to fail download after several hundred KB so download | |
329 # server will have saved something before we retry. | |
330 harnass = CallbackTestHarnass( | |
331 fail_after_n_bytes=self.larger_src_key_size/2) | |
332 res_download_handler = ResumableDownloadHandler(num_retries=1) | |
333 self.larger_src_key.get_contents_to_file( | |
334 self.dst_fp, cb=harnass.call, | |
335 res_download_handler=res_download_handler) | |
336 # Ensure downloaded object has correct content. | |
337 self.assertEqual(self.larger_src_key_size, | |
338 get_cur_file_size(self.dst_fp)) | |
339 self.assertEqual(self.larger_src_key_as_string, | |
340 self.larger_src_key.get_contents_as_string()) | |
341 # Ensure some of the file was downloaded both before and after failure. | |
342 self.assertTrue( | |
343 len(harnass.transferred_seq_before_first_failure) > 1 and | |
344 len(harnass.transferred_seq_after_first_failure) > 1) | |
345 | |
346 def test_zero_length_object_download(self): | |
347 """ | |
348 Tests downloading a zero-length object (exercises boundary conditions). | |
349 """ | |
350 res_download_handler = ResumableDownloadHandler() | |
351 self.empty_src_key.get_contents_to_file( | |
352 self.dst_fp, res_download_handler=res_download_handler) | |
353 self.assertEqual(0, get_cur_file_size(self.dst_fp)) | |
354 | |
355 def test_download_with_object_size_change_between_starts(self): | |
356 """ | |
357 Tests resumable download on an object that changes sizes between inital | |
358 download start and restart | |
359 """ | |
360 harnass = CallbackTestHarnass( | |
361 fail_after_n_bytes=self.larger_src_key_size/2, num_times_to_fail=2) | |
362 # Set up first process' ResumableDownloadHandler not to do any | |
363 # retries (initial download request will establish expected size to | |
364 # download server). | |
365 res_download_handler = ResumableDownloadHandler( | |
366 tracker_file_name=self.tracker_file_name, num_retries=0) | |
367 try: | |
368 self.larger_src_key.get_contents_to_file( | |
369 self.dst_fp, cb=harnass.call, | |
370 res_download_handler=res_download_handler) | |
371 self.fail('Did not get expected ResumableDownloadException') | |
372 except ResumableDownloadException, e: | |
373 self.assertEqual(e.disposition, ResumableTransferDisposition.ABORT) | |
374 # Ensure a tracker file survived. | |
375 self.assertTrue(os.path.exists(self.tracker_file_name)) | |
376 # Try it again, this time with different src key (simulating an | |
377 # object that changes sizes between downloads). | |
378 try: | |
379 self.small_src_key.get_contents_to_file( | |
380 self.dst_fp, res_download_handler=res_download_handler) | |
381 self.fail('Did not get expected ResumableDownloadException') | |
382 except ResumableDownloadException, e: | |
383 self.assertEqual(e.disposition, ResumableTransferDisposition.ABORT) | |
384 self.assertNotEqual( | |
385 e.message.find('md5 signature doesn\'t match etag'), -1) | |
386 | |
387 def test_download_with_file_content_change_during_download(self): | |
388 """ | |
389 Tests resumable download on an object where the file content changes | |
390 without changing length while download in progress | |
391 """ | |
392 harnass = CallbackTestHarnass( | |
393 fail_after_n_bytes=self.larger_src_key_size/2, num_times_to_fail=2) | |
394 # Set up first process' ResumableDownloadHandler not to do any | |
395 # retries (initial download request will establish expected size to | |
396 # download server). | |
397 res_download_handler = ResumableDownloadHandler( | |
398 tracker_file_name=self.tracker_file_name, num_retries=0) | |
399 dst_filename = self.dst_fp.name | |
400 try: | |
401 self.larger_src_key.get_contents_to_file( | |
402 self.dst_fp, cb=harnass.call, | |
403 res_download_handler=res_download_handler) | |
404 self.fail('Did not get expected ResumableDownloadException') | |
405 except ResumableDownloadException, e: | |
406 self.assertEqual(e.disposition, ResumableTransferDisposition.ABORT) | |
407 # Ensure a tracker file survived. | |
408 self.assertTrue(os.path.exists(self.tracker_file_name)) | |
409 # Before trying again change the first byte of the file fragment | |
410 # that was already downloaded. | |
411 orig_size = get_cur_file_size(self.dst_fp) | |
412 self.dst_fp.seek(0, os.SEEK_SET) | |
413 self.dst_fp.write('a') | |
414 # Ensure the file size didn't change. | |
415 self.assertEqual(orig_size, get_cur_file_size(self.dst_fp)) | |
416 try: | |
417 self.larger_src_key.get_contents_to_file( | |
418 self.dst_fp, cb=harnass.call, | |
419 res_download_handler=res_download_handler) | |
420 self.fail('Did not get expected ResumableDownloadException') | |
421 except ResumableDownloadException, e: | |
422 self.assertEqual(e.disposition, ResumableTransferDisposition.ABORT) | |
423 self.assertNotEqual( | |
424 e.message.find('md5 signature doesn\'t match etag'), -1) | |
425 # Ensure the bad data wasn't left around. | |
426 self.assertFalse(os.path.exists(dst_filename)) | |
427 | |
428 def test_download_with_invalid_tracker_etag(self): | |
429 """ | |
430 Tests resumable download with a tracker file containing an invalid etag | |
431 """ | |
432 invalid_etag_tracker_file_name = ( | |
433 '%s%sinvalid_etag_tracker' % (self.tmp_dir, os.sep)) | |
434 f = open(invalid_etag_tracker_file_name, 'w') | |
435 f.write('3.14159\n') | |
436 f.close() | |
437 res_download_handler = ResumableDownloadHandler( | |
438 tracker_file_name=invalid_etag_tracker_file_name) | |
439 # An error should be printed about the invalid tracker, but then it | |
440 # should run the update successfully. | |
441 self.small_src_key.get_contents_to_file( | |
442 self.dst_fp, res_download_handler=res_download_handler) | |
443 self.assertEqual(self.small_src_key_size, | |
444 get_cur_file_size(self.dst_fp)) | |
445 self.assertEqual(self.small_src_key_as_string, | |
446 self.small_src_key.get_contents_as_string()) | |
447 | |
448 def test_download_with_inconsistent_etag_in_tracker(self): | |
449 """ | |
450 Tests resumable download with an inconsistent etag in tracker file | |
451 """ | |
452 inconsistent_etag_tracker_file_name = ( | |
453 '%s%sinconsistent_etag_tracker' % (self.tmp_dir, os.sep)) | |
454 f = open(inconsistent_etag_tracker_file_name, 'w') | |
455 good_etag = self.small_src_key.etag.strip('"\'') | |
456 new_val_as_list = [] | |
457 for c in reversed(good_etag): | |
458 new_val_as_list.append(c) | |
459 f.write('%s\n' % ''.join(new_val_as_list)) | |
460 f.close() | |
461 res_download_handler = ResumableDownloadHandler( | |
462 tracker_file_name=inconsistent_etag_tracker_file_name) | |
463 # An error should be printed about the expired tracker, but then it | |
464 # should run the update successfully. | |
465 self.small_src_key.get_contents_to_file( | |
466 self.dst_fp, res_download_handler=res_download_handler) | |
467 self.assertEqual(self.small_src_key_size, | |
468 get_cur_file_size(self.dst_fp)) | |
469 self.assertEqual(self.small_src_key_as_string, | |
470 self.small_src_key.get_contents_as_string()) | |
471 | |
472 def test_download_with_unwritable_tracker_file(self): | |
473 """ | |
474 Tests resumable download with an unwritable tracker file | |
475 """ | |
476 # Make dir where tracker_file lives temporarily unwritable. | |
477 save_mod = os.stat(self.tmp_dir).st_mode | |
478 try: | |
479 os.chmod(self.tmp_dir, 0) | |
480 res_download_handler = ResumableDownloadHandler( | |
481 tracker_file_name=self.tracker_file_name) | |
482 except ResumableDownloadException, e: | |
483 self.assertEqual(e.disposition, ResumableTransferDisposition.ABORT) | |
484 self.assertNotEqual( | |
485 e.message.find('Couldn\'t write URI tracker file'), -1) | |
486 finally: | |
487 # Restore original protection of dir where tracker_file lives. | |
488 os.chmod(self.tmp_dir, save_mod) | |
489 | |
490 if __name__ == '__main__': | |
491 if sys.version_info[:3] < (2, 5, 1): | |
492 sys.exit('These tests must be run on at least Python 2.5.1\n') | |
493 | |
494 # Use -d to see more HTTP protocol detail during tests. Note that | |
495 # unlike the upload test case, you won't see much for the downloads | |
496 # because there's no HTTP server state protocol for in the download case | |
497 # (and the actual Range GET HTTP protocol detail is suppressed by the | |
498 # normal boto.s3.Key.get_file() processing). | |
499 debug = 0 | |
500 opts, args = getopt.getopt(sys.argv[1:], 'd', ['debug']) | |
501 for o, a in opts: | |
502 if o in ('-d', '--debug'): | |
503 debug = 2 | |
504 | |
505 test_loader = unittest.TestLoader() | |
506 test_loader.testMethodPrefix = 'test_' | |
507 suite = test_loader.loadTestsFromTestCase(ResumableDownloadTests) | |
508 # Seems like there should be a cleaner way to find the test_class. | |
509 test_class = suite.__getattribute__('_tests')[0] | |
510 # We call set_up_class() and tear_down_class() ourselves because we | |
511 # don't assume the user has Python 2.7 (which supports classmethods | |
512 # that do it, with camelCase versions of these names). | |
513 try: | |
514 print 'Setting up %s...' % test_class.get_suite_description() | |
515 test_class.set_up_class(debug) | |
516 print 'Running %s...' % test_class.get_suite_description() | |
517 unittest.TextTestRunner(verbosity=2).run(suite) | |
518 finally: | |
519 print 'Cleaning up after %s...' % test_class.get_suite_description() | |
520 test_class.tear_down_class() | |
521 print '' | |
OLD | NEW |