Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-s3transfer for openSUSE:Factory checked in at 2025-11-24 14:14:26 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-s3transfer (Old) and /work/SRC/openSUSE:Factory/.python-s3transfer.new.14147 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-s3transfer" Mon Nov 24 14:14:26 2025 rev:41 rq:1319689 version:0.15.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-s3transfer/python-s3transfer.changes 2025-09-18 21:08:01.788778227 +0200 +++ /work/SRC/openSUSE:Factory/.python-s3transfer.new.14147/python-s3transfer.changes 2025-11-24 14:16:58.956993191 +0100 @@ -1,0 +2,6 @@ +Mon Nov 24 09:05:47 UTC 2025 - John Paul Adrian Glaubitz <[email protected]> + +- Update to version 0.15.0 + * feature:``CopyPartTask``: Validate ETag of stored object during multipart copies + +------------------------------------------------------------------- Old: ---- s3transfer-0.14.0.tar.gz New: ---- s3transfer-0.15.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-s3transfer.spec ++++++ --- /var/tmp/diff_new_pack.VzwGw4/_old 2025-11-24 14:16:59.557018400 +0100 +++ /var/tmp/diff_new_pack.VzwGw4/_new 2025-11-24 14:16:59.557018400 +0100 @@ -18,7 +18,7 @@ %{?sle15_python_module_pythons} Name: python-s3transfer -Version: 0.14.0 +Version: 0.15.0 Release: 0 Summary: Python S3 transfer manager License: Apache-2.0 ++++++ s3transfer-0.14.0.tar.gz -> s3transfer-0.15.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/s3transfer-0.14.0/PKG-INFO new/s3transfer-0.15.0/PKG-INFO --- old/s3transfer-0.14.0/PKG-INFO 2025-09-09 20:09:31.783774000 +0200 +++ new/s3transfer-0.15.0/PKG-INFO 2025-11-20 20:13:52.397613000 +0100 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: s3transfer -Version: 0.14.0 +Version: 0.15.0 Summary: An Amazon S3 Transfer Manager Home-page: https://github.com/boto/s3transfer Author: Amazon Web Services diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/s3transfer-0.14.0/s3transfer/__init__.py new/s3transfer-0.15.0/s3transfer/__init__.py --- old/s3transfer-0.14.0/s3transfer/__init__.py 2025-09-09 20:09:31.000000000 +0200 +++ new/s3transfer-0.15.0/s3transfer/__init__.py 2025-11-20 20:13:52.000000000 +0100 @@ -146,7 +146,7 @@ from s3transfer.exceptions import RetriesExceededError, S3UploadFailedError __author__ = 'Amazon Web Services' -__version__ = '0.14.0' +__version__ = '0.15.0' logger = logging.getLogger(__name__) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/s3transfer-0.14.0/s3transfer/copies.py new/s3transfer-0.15.0/s3transfer/copies.py --- old/s3transfer-0.14.0/s3transfer/copies.py 2025-09-09 20:04:48.000000000 +0200 +++ new/s3transfer-0.15.0/s3transfer/copies.py 2025-11-20 20:13:52.000000000 +0100 @@ -13,6 +13,9 @@ import copy import math +from botocore.exceptions import ClientError + +from s3transfer.exceptions import S3CopyFailedError from s3transfer.tasks import ( CompleteMultipartUploadTask, CreateMultipartUploadTask, @@ -98,8 +101,10 @@ :param transfer_future: The transfer future associated with the transfer request that tasks are being submitted for """ - # Determine the size if it was not provided - if transfer_future.meta.size is None: + if ( + transfer_future.meta.size is None + or transfer_future.meta.etag is None + ): # If a size was not provided figure out the size for the # user. Note that we will only use the client provided to # the TransferManager. If the object is outside of the region @@ -127,6 +132,9 @@ transfer_future.meta.provide_transfer_size( response['ContentLength'] ) + # Provide an etag to ensure a stored object is not modified + # during a multipart copy. + transfer_future.meta.provide_object_etag(response.get('ETag')) # If it is greater than threshold do a multipart copy, otherwise # do a regular copy object. @@ -218,6 +226,10 @@ num_parts, transfer_future.meta.size, ) + if transfer_future.meta.etag is not None: + extra_part_args['CopySourceIfMatch'] = ( + transfer_future.meta.etag + ) # Get the size of the part copy as well for the progress # callbacks. size = self._get_transfer_size( @@ -367,14 +379,27 @@ the multipart upload. If a checksum is in the response, it will also be included. """ - response = client.upload_part_copy( - CopySource=copy_source, - Bucket=bucket, - Key=key, - UploadId=upload_id, - PartNumber=part_number, - **extra_args, - ) + try: + response = client.upload_part_copy( + CopySource=copy_source, + Bucket=bucket, + Key=key, + UploadId=upload_id, + PartNumber=part_number, + **extra_args, + ) + except ClientError as e: + error_code = e.response.get('Error', {}).get('Code') + src_key = copy_source['Key'] + src_bucket = copy_source['Bucket'] + if error_code == "PreconditionFailed": + raise S3CopyFailedError( + f'Contents of stored object "{src_key}" ' + f'in bucket "{src_bucket}" did not match ' + 'expected ETag.' + ) + else: + raise for callback in callbacks: callback(bytes_transferred=size) etag = response['CopyPartResult']['ETag'] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/s3transfer-0.14.0/s3transfer/exceptions.py new/s3transfer-0.15.0/s3transfer/exceptions.py --- old/s3transfer-0.14.0/s3transfer/exceptions.py 2025-09-09 20:09:31.000000000 +0200 +++ new/s3transfer-0.15.0/s3transfer/exceptions.py 2025-11-20 20:13:52.000000000 +0100 @@ -27,6 +27,10 @@ pass +class S3CopyFailedError(Exception): + pass + + class InvalidSubscriberMethodError(Exception): pass diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/s3transfer-0.14.0/s3transfer.egg-info/PKG-INFO new/s3transfer-0.15.0/s3transfer.egg-info/PKG-INFO --- old/s3transfer-0.14.0/s3transfer.egg-info/PKG-INFO 2025-09-09 20:09:31.000000000 +0200 +++ new/s3transfer-0.15.0/s3transfer.egg-info/PKG-INFO 2025-11-20 20:13:52.000000000 +0100 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: s3transfer -Version: 0.14.0 +Version: 0.15.0 Summary: An Amazon S3 Transfer Manager Home-page: https://github.com/boto/s3transfer Author: Amazon Web Services diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/s3transfer-0.14.0/tests/functional/test_copy.py new/s3transfer-0.15.0/tests/functional/test_copy.py --- old/s3transfer-0.14.0/tests/functional/test_copy.py 2025-09-09 20:04:48.000000000 +0200 +++ new/s3transfer-0.15.0/tests/functional/test_copy.py 2025-11-20 20:13:52.000000000 +0100 @@ -10,12 +10,15 @@ # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF # ANY KIND, either express or implied. See the License for the specific # language governing permissions and limitations under the License. +import copy + from botocore.exceptions import ClientError from botocore.stub import Stubber +from s3transfer.exceptions import S3CopyFailedError from s3transfer.manager import TransferConfig, TransferManager from s3transfer.utils import MIN_UPLOAD_CHUNKSIZE -from tests import BaseGeneralInterfaceTest, FileSizeProvider +from tests import BaseGeneralInterfaceTest, ETagProvider, FileSizeProvider class BaseCopyTest(BaseGeneralInterfaceTest): @@ -31,6 +34,7 @@ # Initialize some default arguments self.bucket = 'mybucket' self.key = 'mykey' + self.etag = 'myetag' self.copy_source = {'Bucket': 'mysourcebucket', 'Key': 'mysourcekey'} self.extra_args = {} self.subscribers = [] @@ -122,7 +126,10 @@ self.add_successful_copy_responses() call_kwargs = self.create_call_kwargs() - call_kwargs['subscribers'] = [FileSizeProvider(len(self.content))] + call_kwargs['subscribers'] = [ + FileSizeProvider(len(self.content)), + ETagProvider(self.etag), + ] future = self.manager.copy(**call_kwargs) future.result() @@ -330,7 +337,10 @@ return [ { 'method': 'head_object', - 'service_response': {'ContentLength': len(self.content)}, + 'service_response': { + 'ContentLength': len(self.content), + 'ETag': self.etag, + }, }, { 'method': 'create_multipart_upload', @@ -392,6 +402,7 @@ 'UploadId': self.multipart_id, 'PartNumber': i + 1, 'CopySourceRange': range_val, + 'CopySourceIfMatch': self.etag, } if extra_expected_params: if 'ChecksumAlgorithm' in extra_expected_params: @@ -470,6 +481,7 @@ 'UploadId': self.multipart_id, 'PartNumber': i + 1, 'CopySourceRange': range_val, + 'CopySourceIfMatch': self.etag, } ) @@ -700,3 +712,41 @@ ) future.result() self.stubber.assert_no_pending_responses() + + def test_copy_fails_if_etag_validation_fails(self): + expected_params = { + 'Bucket': 'mybucket', + 'Key': 'mykey', + 'CopySource': {'Bucket': 'mysourcebucket', 'Key': 'mysourcekey'}, + 'CopySourceIfMatch': self.etag, + 'UploadId': self.multipart_id, + } + self.add_get_head_response_with_default_expected_params() + self.add_create_multipart_response_with_default_expected_params() + expected_ranges = ['bytes=0-5242879', 'bytes=5242880-10485759'] + for i, stubbed_response in enumerate( + self.create_stubbed_responses()[2:4] + ): + stubbed_response['expected_params'] = copy.deepcopy( + expected_params + ) + stubbed_response['expected_params']['CopySourceRange'] = ( + expected_ranges[i] + ) + stubbed_response['expected_params']['PartNumber'] = i + 1 + self.stubber.add_response(**stubbed_response) + # Simulate ETag validation failure by adding a + # client error for the last UploadCopyPart request. + self.stubber.add_client_error( + method='upload_part_copy', + service_error_code='PreconditionFailed', + service_message=( + 'At least one of the pre-conditions you specified did not hold' + ), + http_status_code=412, + ) + + future = self.manager.copy(**self.create_call_kwargs()) + with self.assertRaises(S3CopyFailedError) as e: + future.result() + self.assertIn('did not match expected ETag', str(e.exception))
