1212# language governing permissions and limitations under the License.
1313import copy
1414import math
15+ from urllib .parse import parse_qsl
1516
1617from botocore .exceptions import ClientError
1718
@@ -70,6 +71,8 @@ class CopySubmissionTask(SubmissionTask):
7071 'CopySourceSSECustomerKeyMD5' ,
7172 'MetadataDirective' ,
7273 'TaggingDirective' ,
74+ 'AnnotationDirective' ,
75+ 'Tagging' ,
7376 ]
7477
7578 # Metadata fields to preserve for multipart copies.
@@ -91,6 +94,20 @@ class CopySubmissionTask(SubmissionTask):
9194 'ExpectedBucketOwner' ,
9295 ]
9396
97+ GET_OBJECT_TAGGING_ARGS = ['RequestPayer' , 'ExpectedBucketOwner' ]
98+ PUT_OBJECT_TAGGING_ARGS = [
99+ 'RequestPayer' ,
100+ 'ExpectedBucketOwner' ,
101+ 'ChecksumAlgorithm' ,
102+ ]
103+ LIST_OBJECT_ANNOTATIONS_ARGS = ['RequestPayer' , 'ExpectedBucketOwner' ]
104+ GET_OBJECT_ANNOTATION_ARGS = ['RequestPayer' , 'ExpectedBucketOwner' ]
105+ PUT_OBJECT_ANNOTATION_ARGS = [
106+ 'RequestPayer' ,
107+ 'ExpectedBucketOwner' ,
108+ 'ChecksumAlgorithm' ,
109+ ]
110+
94111 def _submit (
95112 self , client , config , osutil , request_executor , transfer_future
96113 ):
@@ -113,6 +130,8 @@ def _submit(
113130 transfer request that tasks are being submitted for
114131 """
115132 preserved_metadata = {}
133+ source_version_id = None
134+ call_args = transfer_future .meta .call_args
116135 if (
117136 transfer_future .meta .size is None
118137 or transfer_future .meta .etag is None
@@ -122,7 +141,6 @@ def _submit(
122141 # the TransferManager. If the object is outside of the region
123142 # of the client, they may have to provide the file size themselves
124143 # with a completely new client.
125- call_args = transfer_future .meta .call_args
126144 head_object_request = (
127145 self ._get_head_object_request_from_copy_source (
128146 call_args .copy_source
@@ -148,6 +166,9 @@ def _submit(
148166 # during a multipart copy.
149167 transfer_future .meta .provide_object_etag (response .get ('ETag' ))
150168 preserved_metadata = self ._extract_preserved_metadata (response )
169+ # Pin the source version so all subsequent reads (tags, annotations)
170+ # are consistent with the object from the head call
171+ source_version_id = response .get ('VersionId' )
151172
152173 # If it is greater than threshold do a multipart copy, otherwise
153174 # do a regular copy object.
@@ -163,6 +184,7 @@ def _submit(
163184 request_executor ,
164185 transfer_future ,
165186 preserved_metadata ,
187+ source_version_id = source_version_id ,
166188 )
167189
168190 def _submit_copy_request (
@@ -199,9 +221,10 @@ def _submit_multipart_request(
199221 request_executor ,
200222 transfer_future ,
201223 preserved_metadata = None ,
224+ source_version_id = None ,
202225 ):
203226 call_args = transfer_future .meta .call_args
204- merged_extra_args = self ._merge_preserved_metadata (
227+ merged_extra_args = self ._apply_preserved_metadata (
205228 call_args .extra_args , preserved_metadata or {}
206229 )
207230
@@ -293,16 +316,19 @@ def _submit_multipart_request(
293316 complete_multipart_extra_args = self ._extra_complete_multipart_args (
294317 call_args .extra_args
295318 )
319+
296320 # Submit the request to complete the multipart upload.
297321 self ._transfer_coordinator .submit (
298322 request_executor ,
299- CompleteMultipartUploadTask (
323+ CopyCompleteMultipartUploadTask (
300324 transfer_coordinator = self ._transfer_coordinator ,
301325 main_kwargs = {
302326 'client' : client ,
303327 'bucket' : call_args .bucket ,
304328 'key' : call_args .key ,
305329 'extra_args' : complete_multipart_extra_args ,
330+ 'call_args' : call_args ,
331+ 'source_version_id' : source_version_id ,
306332 },
307333 pending_main_kwargs = {
308334 'upload_id' : create_multipart_future ,
@@ -319,15 +345,18 @@ def _extract_preserved_metadata(self, head_object_response):
319345 preserved [field ] = head_object_response [field ]
320346 return preserved
321347
322- def _merge_preserved_metadata (self , extra_args , preserved_metadata ):
323- if not preserved_metadata :
324- return extra_args
348+ def _apply_preserved_metadata (self , extra_args , preserved_metadata ):
349+ # MPU has no native MetadataDirective, handle metadata manually. REPLACE
350+ # means we copy whatever the user provided, anything else means we drop
351+ # what the user supplied
325352 if extra_args .get ('MetadataDirective' ) == 'REPLACE' :
326353 return extra_args
327- merged = dict (extra_args )
328- for field , value in preserved_metadata .items ():
329- merged [field ] = value
330- return merged
354+ result = {
355+ k : v for k , v in extra_args .items ()
356+ if k not in self .PRESERVED_METADATA_FIELDS
357+ }
358+ result .update (preserved_metadata )
359+ return result
331360
332361 def _get_head_object_request_from_copy_source (self , copy_source ):
333362 if isinstance (copy_source , dict ):
@@ -357,6 +386,148 @@ def _get_transfer_size(
357386 return part_size
358387
359388
389+ class CopyCompleteMultipartUploadTask (CompleteMultipartUploadTask ):
390+ """CompleteMultipartUpload variant that also applies tags and annotations.
391+
392+ After the destination object is finalized, copies/applies tags and
393+ annotations inline. Errors during apply propagate as task failures.
394+ """
395+
396+ def _main (
397+ self ,
398+ client ,
399+ bucket ,
400+ key ,
401+ upload_id ,
402+ parts ,
403+ extra_args ,
404+ call_args ,
405+ source_version_id ,
406+ ):
407+ response = client .complete_multipart_upload (
408+ Bucket = bucket ,
409+ Key = key ,
410+ UploadId = upload_id ,
411+ MultipartUpload = {'Parts' : parts },
412+ ** extra_args ,
413+ )
414+ dest_etag = response .get ('ETag' )
415+ dest_version_id = response .get ('VersionId' )
416+ self ._apply_tags (client , call_args , source_version_id , dest_version_id )
417+ self ._apply_annotations (
418+ client , call_args , source_version_id , dest_version_id , dest_etag
419+ )
420+
421+ def _apply_tags (self , client , call_args , source_version_id , dest_version_id ):
422+ extra_args = call_args .extra_args
423+ directive = extra_args .get ('TaggingDirective' )
424+ if directive not in ('COPY' , 'REPLACE' ):
425+ return
426+ if directive == 'COPY' :
427+ src_kwargs = {
428+ 'Bucket' : call_args .copy_source ['Bucket' ],
429+ 'Key' : call_args .copy_source ['Key' ],
430+ ** get_filtered_dict (
431+ extra_args , CopySubmissionTask .GET_OBJECT_TAGGING_ARGS
432+ ),
433+ }
434+ if source_version_id :
435+ src_kwargs ['VersionId' ] = source_version_id
436+ tag_set = call_args .source_client .get_object_tagging (
437+ ** src_kwargs
438+ ).get ('TagSet' , [])
439+ else : # REPLACE
440+ tag_set = [
441+ {'Key' : k , 'Value' : v }
442+ for k , v in parse_qsl (
443+ extra_args .get ('Tagging' , '' ),
444+ keep_blank_values = True ,
445+ )
446+ ]
447+ if not tag_set :
448+ return
449+ put_kwargs = {
450+ 'Bucket' : call_args .bucket ,
451+ 'Key' : call_args .key ,
452+ 'Tagging' : {'TagSet' : tag_set },
453+ ** get_filtered_dict (
454+ extra_args , CopySubmissionTask .PUT_OBJECT_TAGGING_ARGS
455+ ),
456+ }
457+ if dest_version_id :
458+ put_kwargs ['VersionId' ] = dest_version_id
459+ client .put_object_tagging (** put_kwargs )
460+
461+ def _apply_annotations (
462+ self ,
463+ client ,
464+ call_args ,
465+ source_version_id ,
466+ dest_version_id ,
467+ dest_etag ,
468+ ):
469+ # We copy annotations only if COPY is explicitly set by the user.
470+ extra_args = call_args .extra_args
471+ if extra_args .get ('AnnotationDirective' ) != 'COPY' :
472+ return
473+ src_base = {
474+ 'Bucket' : call_args .copy_source ['Bucket' ],
475+ 'Key' : call_args .copy_source ['Key' ],
476+ }
477+ if source_version_id :
478+ src_base ['VersionId' ] = source_version_id
479+ list_kwargs = {
480+ ** src_base ,
481+ ** get_filtered_dict (
482+ extra_args , CopySubmissionTask .LIST_OBJECT_ANNOTATIONS_ARGS
483+ ),
484+ }
485+ get_kwargs_base = {
486+ ** src_base ,
487+ ** get_filtered_dict (
488+ extra_args , CopySubmissionTask .GET_OBJECT_ANNOTATION_ARGS
489+ ),
490+ }
491+ put_passthrough = get_filtered_dict (
492+ extra_args , CopySubmissionTask .PUT_OBJECT_ANNOTATION_ARGS
493+ )
494+ list_response = call_args .source_client .list_object_annotations (
495+ ** list_kwargs
496+ )
497+ succeeded = []
498+ failed = {}
499+ for annotation in list_response .get ('Annotations' , []):
500+ name = annotation ['AnnotationName' ]
501+ payload_response = call_args .source_client .get_object_annotation (
502+ ** get_kwargs_base ,
503+ AnnotationName = name ,
504+ )
505+ put_kwargs = {
506+ 'Bucket' : call_args .bucket ,
507+ 'Key' : call_args .key ,
508+ 'AnnotationName' : name ,
509+ 'AnnotationPayload' : payload_response ['AnnotationPayload' ].read (),
510+ ** put_passthrough ,
511+ }
512+ if dest_version_id :
513+ put_kwargs ['VersionId' ] = dest_version_id
514+ if dest_etag :
515+ put_kwargs ['ObjectIfMatch' ] = dest_etag
516+ try :
517+ client .put_object_annotation (** put_kwargs )
518+ succeeded .append (name )
519+ except Exception as e :
520+ failed [name ] = e
521+ if failed :
522+ raise S3CopyFailedError (
523+ f'Failed to copy annotations to '
524+ f's3://{ call_args .bucket } /{ call_args .key } . '
525+ f'Succeeded: { succeeded } . '
526+ f'Failed: { list (failed .keys ())} . '
527+ f'Errors: { failed } '
528+ )
529+
530+
360531class CopyObjectTask (Task ):
361532 """Task to do a nonmultipart copy"""
362533
0 commit comments