Skip to content

Commit 16ba98e

Browse files
Merge customizations for S3
1 parent 247c6da commit 16ba98e

14 files changed

Lines changed: 746 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"type": "enhancement",
3+
"category": "``s3`` copies",
4+
"description": "Adds ``all`` option to ``--copy-props`` for ``cp``, ``mv``, and ``sync`` commands. When set, S3 to S3 copy operations will copy object annotations, metadata, and tags."
5+
}

awscli/botocore/data/s3/2006-03-01/paginators-1.sdk-extras.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@
3232
"Prefix"
3333
]
3434
},
35+
"ListObjectAnnotations": {
36+
"non_aggregate_keys": [
37+
"AnnotationCount",
38+
"AnnotationPrefix",
39+
"Bucket",
40+
"Key",
41+
"ObjectVersionId",
42+
"RequestCharged"
43+
]
44+
},
3545
"ListParts": {
3646
"non_aggregate_keys": [
3747
"ChecksumAlgorithm",

awscli/customizations/s3/subcommands.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,7 @@
452452

453453
COPY_PROPS = {
454454
'name': 'copy-props',
455-
'choices': ['none', 'metadata-directive', 'default'],
455+
'choices': ['none', 'metadata-directive', 'default', 'all'],
456456
'default': 'default',
457457
'help_text': (
458458
'Determines which properties are copied from the source S3 object. '
@@ -468,6 +468,9 @@
468468
'<li>``default`` - The default value. Copies tags and properties '
469469
'covered under the ``metadata-directive`` value from the '
470470
'source S3 object.</li>'
471+
'<li>``all`` - Copies annotations in addition to the tags and '
472+
'properties covered under the ``default`` value from the source '
473+
'S3 object.</li>'
471474
'</ul>'
472475
'In order to copy the appropriate properties for multipart copies, '
473476
'some of the options may require additional API calls if a multipart '
@@ -476,8 +479,11 @@
476479
'<li>``metadata-directive`` may require additional ``HeadObject`` '
477480
'API calls.</li>'
478481
'<li>``default`` may require additional ``HeadObject``, '
479-
'``GetObjectTagging``, and ``PutObjectTagging`` API calls. Note this'
480-
' list of API calls may grow in the future in order to ensure '
482+
'``GetObjectTagging``, and ``PutObjectTagging`` API calls.</li>'
483+
'<li>``all`` may require the additional API calls covered under the '
484+
'``default`` value, as well as ``ListObjectAnnotations``, '
485+
'``GetObjectAnnotation``, and ``PutObjectAnnotation`` API calls. Note '
486+
'this list of API calls may grow in the future in order to ensure '
481487
'multipart copies preserve the exact properties a ``CopyObject`` '
482488
'API call would preserve.</li>'
483489
'</ul>'

awscli/customizations/s3/subscribers.py

Lines changed: 176 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -258,12 +258,14 @@ def _get_none_subscribers(self, fileinfo):
258258
return [
259259
ReplaceMetadataDirectiveSubscriber(),
260260
ReplaceTaggingDirectiveSubscriber(),
261+
ExcludeAnnotationDirectiveSubscriber(),
261262
]
262263

263264
def _get_metadata_directive_subscribers(self, fileinfo):
264265
return [
265266
self._create_metadata_directive_props_subscriber(fileinfo),
266267
ReplaceTaggingDirectiveSubscriber(),
268+
ExcludeAnnotationDirectiveSubscriber(),
267269
]
268270

269271
def _get_default_subscribers(self, fileinfo):
@@ -275,6 +277,19 @@ def _get_default_subscribers(self, fileinfo):
275277
self._cli_params,
276278
source_client=fileinfo.source_client,
277279
),
280+
ExcludeAnnotationDirectiveSubscriber(),
281+
]
282+
283+
def _get_all_subscribers(self, fileinfo):
284+
return [
285+
self._create_metadata_directive_props_subscriber(fileinfo),
286+
SetTagsSubscriber(
287+
self._client,
288+
self._transfer_config,
289+
self._cli_params,
290+
source_client=fileinfo.source_client,
291+
),
292+
self._create_annotations_subscriber(fileinfo),
278293
]
279294

280295
def _create_metadata_directive_props_subscriber(self, fileinfo):
@@ -289,20 +304,43 @@ def _create_metadata_directive_props_subscriber(self, fileinfo):
289304
)
290305
return SetMetadataDirectivePropsSubscriber(**subscriber_kwargs)
291306

307+
def _create_annotations_subscriber(self, fileinfo):
308+
kwargs = {
309+
'client': self._client,
310+
'transfer_config': self._transfer_config,
311+
'cli_params': self._cli_params,
312+
'source_client': fileinfo.source_client,
313+
}
314+
if not self._cli_params.get('dir_op'):
315+
kwargs['head_object_response'] = (
316+
fileinfo.associated_response_data
317+
)
318+
return SetAnnotationsSubscriber(**kwargs)
319+
292320

293-
class ReplaceDirectiveSubscriber(BaseSubscriber):
321+
class SetDirectiveSubscriber(BaseSubscriber):
294322
_DIRECTIVE_PARAM = ''
323+
_DIRECTIVE_VALUE = ''
295324

296325
def on_queued(self, future, **kwargs):
297-
future.meta.call_args.extra_args[self._DIRECTIVE_PARAM] = 'REPLACE'
326+
future.meta.call_args.extra_args[self._DIRECTIVE_PARAM] = (
327+
self._DIRECTIVE_VALUE
328+
)
298329

299330

300-
class ReplaceMetadataDirectiveSubscriber(ReplaceDirectiveSubscriber):
331+
class ReplaceMetadataDirectiveSubscriber(SetDirectiveSubscriber):
301332
_DIRECTIVE_PARAM = 'MetadataDirective'
333+
_DIRECTIVE_VALUE = 'REPLACE'
302334

303335

304-
class ReplaceTaggingDirectiveSubscriber(ReplaceDirectiveSubscriber):
336+
class ReplaceTaggingDirectiveSubscriber(SetDirectiveSubscriber):
305337
_DIRECTIVE_PARAM = 'TaggingDirective'
338+
_DIRECTIVE_VALUE = 'REPLACE'
339+
340+
341+
class ExcludeAnnotationDirectiveSubscriber(SetDirectiveSubscriber):
342+
_DIRECTIVE_PARAM = 'AnnotationDirective'
343+
_DIRECTIVE_VALUE = 'EXCLUDE'
306344

307345

308346
class SetMetadataDirectivePropsSubscriber(BaseSubscriber):
@@ -442,3 +480,137 @@ def _serialize_to_header_value(self, tags):
442480

443481
def _is_multipart_copy(self, future):
444482
return future.meta.size >= self._transfer_config.multipart_threshold
483+
484+
485+
class AnnotationCopyError(Exception):
486+
def __init__(self, bucket, key, succeeded, failed):
487+
succeeded_names = ', '.join(succeeded) or '(none)'
488+
failed_descriptions = '; '.join(
489+
f'{name}: {error}' for name, error in failed
490+
)
491+
super().__init__(
492+
f'Failed to copy all annotations to s3://{bucket}/{key}. '
493+
f'The object was copied successfully and was not deleted. '
494+
f'Annotations written: {succeeded_names}. '
495+
f'Annotations that failed: {failed_descriptions}.'
496+
)
497+
498+
499+
class SetAnnotationsSubscriber(OnDoneFilteredSubscriber):
500+
_ANNOTATIONS_CONTEXT_KEY = 'CopySourceAnnotations'
501+
502+
def __init__(
503+
self, client, transfer_config, cli_params, source_client,
504+
head_object_response=None,
505+
):
506+
self._client = client
507+
self._transfer_config = transfer_config
508+
self._cli_params = cli_params
509+
self._source_client = source_client
510+
self._head_object_response = head_object_response
511+
512+
def on_queued(self, future, **kwargs):
513+
# Annotations only need to be copied for multipart copies. Single-part
514+
# copies carry them over server-side.
515+
if not self._is_multipart_copy(future):
516+
return
517+
bucket, key = self._get_bucket_key_from_copy_source(future)
518+
source_version_id = self._get_source_version_id()
519+
annotation_names = self._list_annotation_names(
520+
bucket, key, source_version_id
521+
)
522+
if not annotation_names:
523+
return
524+
annotations = {}
525+
for name in annotation_names:
526+
annotations[name] = self._get_annotation(
527+
bucket, key, name, source_version_id
528+
)
529+
future.meta.user_context[self._ANNOTATIONS_CONTEXT_KEY] = annotations
530+
531+
def _on_success(self, future):
532+
annotations = future.meta.user_context.get(
533+
self._ANNOTATIONS_CONTEXT_KEY
534+
)
535+
if not annotations:
536+
return
537+
bucket = future.meta.call_args.bucket
538+
key = future.meta.call_args.key
539+
dest_etag, dest_version_id = self._get_dest_object_identity(future)
540+
succeeded = []
541+
failed = []
542+
for name, payload in annotations.items():
543+
try:
544+
self._put_annotation(
545+
bucket, key, name, payload, dest_etag, dest_version_id
546+
)
547+
succeeded.append(name)
548+
except Exception as e:
549+
failed.append((name, str(e)))
550+
if failed:
551+
future.set_exception(
552+
AnnotationCopyError(bucket, key, succeeded, failed)
553+
)
554+
555+
def _get_dest_object_identity(self, future):
556+
response = future.result()
557+
return response.get('ETag'), response.get('VersionId')
558+
559+
def _get_source_version_id(self):
560+
if self._head_object_response is not None:
561+
return self._head_object_response.get('VersionId')
562+
return None
563+
564+
def _list_annotation_names(self, bucket, key, version_id=None):
565+
extra_args = {}
566+
utils.RequestParamsMapper.map_list_object_annotations_params(
567+
extra_args, self._cli_params
568+
)
569+
if version_id is not None:
570+
extra_args['VersionId'] = version_id
571+
paginator = self._source_client.get_paginator(
572+
'list_object_annotations'
573+
)
574+
names = []
575+
for page in paginator.paginate(Bucket=bucket, Key=key, **extra_args):
576+
for annotation in page.get('Annotations', []):
577+
names.append(annotation['AnnotationName'])
578+
return names
579+
580+
def _get_annotation(self, bucket, key, name, version_id=None):
581+
extra_args = {}
582+
utils.RequestParamsMapper.map_get_object_annotation_params(
583+
extra_args, self._cli_params
584+
)
585+
if version_id is not None:
586+
extra_args['VersionId'] = version_id
587+
response = self._source_client.get_object_annotation(
588+
Bucket=bucket, Key=key, AnnotationName=name, **extra_args
589+
)
590+
return response['AnnotationPayload'].read()
591+
592+
def _put_annotation(
593+
self, bucket, key, name, payload, dest_etag, dest_version_id
594+
):
595+
extra_args = {}
596+
utils.RequestParamsMapper.map_put_object_annotation_params(
597+
extra_args, self._cli_params
598+
)
599+
if dest_etag is not None:
600+
extra_args['ObjectIfMatch'] = dest_etag
601+
if dest_version_id is not None:
602+
extra_args['VersionId'] = dest_version_id
603+
self._client.put_object_annotation(
604+
Bucket=bucket,
605+
Key=key,
606+
AnnotationName=name,
607+
AnnotationPayload=payload,
608+
**extra_args,
609+
)
610+
611+
def _get_bucket_key_from_copy_source(self, future):
612+
copy_source = future.meta.call_args.copy_source
613+
return copy_source['Bucket'], copy_source['Key']
614+
615+
def _is_multipart_copy(self, future):
616+
return future.meta.size >= self._transfer_config.multipart_threshold

awscli/customizations/s3/utils.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,21 @@ def map_put_object_tagging_params(cls, request_params, cli_params):
512512
"""Map CLI params to PutObjectTagging request params"""
513513
cls._set_request_payer_param(request_params, cli_params)
514514

515+
@classmethod
516+
def map_list_object_annotations_params(cls, request_params, cli_params):
517+
"""Map CLI params to ListObjectAnnotations request params"""
518+
cls._set_request_payer_param(request_params, cli_params)
519+
520+
@classmethod
521+
def map_get_object_annotation_params(cls, request_params, cli_params):
522+
"""Map CLI params to GetObjectAnnotation request params"""
523+
cls._set_request_payer_param(request_params, cli_params)
524+
525+
@classmethod
526+
def map_put_object_annotation_params(cls, request_params, cli_params):
527+
"""Map CLI params to PutObjectAnnotation request params"""
528+
cls._set_request_payer_param(request_params, cli_params)
529+
515530
@classmethod
516531
def map_copy_object_params(cls, request_params, cli_params):
517532
"""Map CLI params to CopyObject request params"""

awscli/s3transfer/copies.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ class CopySubmissionTask(SubmissionTask):
6969
'CopySourceSSECustomerKeyMD5',
7070
'MetadataDirective',
7171
'TaggingDirective',
72+
'AnnotationDirective',
7273
'IfNoneMatch',
7374
]
7475

@@ -81,7 +82,6 @@ class CopySubmissionTask(SubmissionTask):
8182
'IfNoneMatch',
8283
]
8384

84-
8585
def _submit(
8686
self, client, config, osutil, request_executor, transfer_future
8787
):

awscli/s3transfer/tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ def _main(self, client, bucket, key, upload_id, parts, extra_args):
376376
:param extra_args: A dictionary of any extra arguments that may be
377377
used in completing the multipart transfer.
378378
"""
379-
client.complete_multipart_upload(
379+
return client.complete_multipart_upload(
380380
Bucket=bucket,
381381
Key=key,
382382
UploadId=upload_id,

tests/functional/botocore/test_paginator_config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@
111111
's3.ListObjectsV2.KeyCount',
112112
's3.ListObjectsV2.Name',
113113
's3.ListObjectsV2.EncodingType',
114+
's3.ListObjectAnnotations.ContinuationToken',
115+
's3.ListObjectAnnotations.MaxAnnotationResults',
114116
's3.ListParts.PartNumberMarker',
115117
's3.ListParts.AbortDate',
116118
's3.ListParts.MaxParts',

tests/functional/s3/__init__.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,15 @@ def get_object_tagging_response(self, tags):
116116
def put_object_tagging_response(self):
117117
return 'PutObjectTagging', self.empty_response()
118118

119+
def list_object_annotations_response(self, names):
120+
return {'Annotations': [{'AnnotationName': name} for name in names]}
121+
122+
def get_object_annotation_response(self, payload):
123+
return {'AnnotationPayload': payload}
124+
125+
def put_object_annotation_response(self):
126+
return self.empty_response()
127+
119128
def empty_response(self):
120129
return {}
121130

@@ -162,6 +171,7 @@ def copy_object_request(
162171
'Key': key,
163172
'CopySource': {'Bucket': source_bucket, 'Key': source_key},
164173
}
174+
override_kwargs.setdefault('AnnotationDirective', 'EXCLUDE')
165175
params.update(override_kwargs)
166176
return 'CopyObject', params
167177

@@ -229,6 +239,34 @@ def put_object_tagging_request(self, bucket, key, tags):
229239
},
230240
}
231241

242+
def list_object_annotations_request(self, bucket, key, **override_kwargs):
243+
params = {'Bucket': bucket, 'Key': key}
244+
params.update(override_kwargs)
245+
return 'ListObjectAnnotations', params
246+
247+
def get_object_annotation_request(
248+
self, bucket, key, annotation_name, **override_kwargs
249+
):
250+
params = {
251+
'Bucket': bucket,
252+
'Key': key,
253+
'AnnotationName': annotation_name,
254+
}
255+
params.update(override_kwargs)
256+
return 'GetObjectAnnotation', params
257+
258+
def put_object_annotation_request(
259+
self, bucket, key, annotation_name, payload, **override_kwargs
260+
):
261+
params = {
262+
'Bucket': bucket,
263+
'Key': key,
264+
'AnnotationName': annotation_name,
265+
'AnnotationPayload': payload,
266+
}
267+
params.update(override_kwargs)
268+
return 'PutObjectAnnotation', params
269+
232270
def no_such_key_error_response(self):
233271
return {
234272
'Error': {

0 commit comments

Comments
 (0)