Skip to content

Implemented No-overwrite for uploads using cp command #9569

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion awscli/customizations/s3/results.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@

from awscli.compat import ensure_text_type, queue
from awscli.customizations.s3.subscribers import OnDoneFilteredSubscriber
from awscli.customizations.s3.utils import WarningResult, human_readable_size
from awscli.customizations.s3.utils import (
WarningResult,
create_warning,
human_readable_size,
)
from awscli.customizations.utils import uni_print

LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -123,6 +127,12 @@ def _on_failure(self, future, e):
if isinstance(e, FatalError):
error_result_cls = ErrorResult
self._result_queue.put(error_result_cls(exception=e))
elif self._is_precondition_failed(e):
LOGGER.debug(
"Warning: Skipping file %s as it already exists on %s",
self._src,
self._dest,
)
else:
self._result_queue.put(
FailureResult(
Expand All @@ -133,6 +143,14 @@ def _on_failure(self, future, e):
)
)

def _is_precondition_failed(self, exception):
"""Check if this is a PreconditionFailed error"""
return (
hasattr(exception, 'response')
and exception.response.get('Error', {}).get('Code')
== 'PreconditionFailed'
)


class BaseResultHandler:
"""Base handler class to be called in the ResultProcessor"""
Expand Down
19 changes: 18 additions & 1 deletion awscli/customizations/s3/subcommands.py
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,15 @@
),
}

NO_OVERWRITE = {
'name': 'no-overwrite',
'action': 'store_true',
'help_text': (
"This flag prevents overwriting of files at the destination. With this flag, "
"only files not present at the destination will be transferred."
),
}

TRANSFER_ARGS = [
DRYRUN,
QUIET,
Expand Down Expand Up @@ -1057,7 +1066,14 @@ class CpCommand(S3TransferCommand):
}
]
+ TRANSFER_ARGS
+ [METADATA, COPY_PROPS, METADATA_DIRECTIVE, EXPECTED_SIZE, RECURSIVE]
+ [
METADATA,
COPY_PROPS,
METADATA_DIRECTIVE,
EXPECTED_SIZE,
RECURSIVE,
NO_OVERWRITE,
]
)


Expand All @@ -1081,6 +1097,7 @@ class MvCommand(S3TransferCommand):
METADATA_DIRECTIVE,
RECURSIVE,
VALIDATE_SAME_S3_PATHS,
NO_OVERWRITE,
]
)

Expand Down
7 changes: 7 additions & 0 deletions awscli/customizations/s3/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,7 @@ def map_put_object_params(cls, request_params, cli_params):
cls._set_sse_c_request_params(request_params, cli_params)
cls._set_request_payer_param(request_params, cli_params)
cls._set_checksum_algorithm_param(request_params, cli_params)
cls._set_no_overwrite_param(request_params, cli_params)

@classmethod
def map_get_object_params(cls, request_params, cli_params):
Expand Down Expand Up @@ -558,6 +559,12 @@ def map_delete_object_params(cls, request_params, cli_params):
def map_list_objects_v2_params(cls, request_params, cli_params):
cls._set_request_payer_param(request_params, cli_params)

@classmethod
def _set_no_overwrite_param(cls, request_params, cli_params):
"""Map No overwrite header with IfNoneMatch"""
if cli_params.get('no_overwrite'):
request_params['IfNoneMatch'] = "*"

@classmethod
def _set_request_payer_param(cls, request_params, cli_params):
if cli_params.get('request_payer'):
Expand Down
1 change: 1 addition & 0 deletions awscli/s3transfer/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ class TransferManager:
+ [
'ChecksumType',
'MpuObjectSize',
'IfNoneMatch',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we're modifying the vendored s3transfer, we should likely be porting the same changes to boto/s3transfer.

]
+ FULL_OBJECT_CHECKSUM_ARGS
)
Expand Down
6 changes: 5 additions & 1 deletion awscli/s3transfer/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,10 @@ class UploadSubmissionTask(SubmissionTask):

PUT_OBJECT_BLOCKLIST = ["ChecksumType", "MpuObjectSize"]

CREATE_MULTIPART_BLOCKLIST = FULL_OBJECT_CHECKSUM_ARGS + ["MpuObjectSize"]
CREATE_MULTIPART_BLOCKLIST = FULL_OBJECT_CHECKSUM_ARGS + [
"MpuObjectSize",
"IfNoneMatch",
]

UPLOAD_PART_ARGS = [
'ChecksumAlgorithm',
Expand All @@ -534,6 +537,7 @@ class UploadSubmissionTask(SubmissionTask):
'ExpectedBucketOwner',
'ChecksumType',
'MpuObjectSize',
"IfNoneMatch",
] + FULL_OBJECT_CHECKSUM_ARGS

def _get_upload_input_manager_cls(self, transfer_future):
Expand Down
101 changes: 101 additions & 0 deletions tests/functional/s3/test_cp_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,107 @@ def test_operations_used_in_recursive_download(self):
)
self.assertEqual(self.operations_called[0][0].name, 'ListObjectsV2')

def test_no_overwrite_flag_when_object_not_exists_on_target(self):
full_path = self.files.create_file('foo.txt', 'mycontent')
cmdline = f'{self.prefix} {full_path} s3://bucket --no-overwrite'
self.parsed_responses = [
{'ETag': '"c8afdb36c52cf4727836669019e69222"'}
]
self.run_cmd(cmdline, expected_rc=0)
# Verify putObject was called
self.assertEqual(len(self.operations_called), 1)
self.assertEqual(self.operations_called[0][0].name, 'PutObject')
# Verify the IfNoneMatch condition was set in the request
self.assertEqual(self.operations_called[0][1]['IfNoneMatch'], '*')

def test_no_overwrite_flag_when_object_exists_on_target(self):
full_path = self.files.create_file('foo.txt', 'mycontent')
cmdline = f'{self.prefix} {full_path} s3://bucket --no-overwrite'
# Set up the response to simulate a PreconditionFailed error
self.http_response.status_code = 412
self.parsed_responses = [
{
'Error': {
'Code': 'PreconditionFailed',
'Message': 'At least one of the pre-conditions you specified did not hold',
'Condition': 'If-None-Match',
}
}
]
self.run_cmd(cmdline, expected_rc=0)
# Verify PutObject was attempted with IfNoneMatch
self.assertEqual(len(self.operations_called), 1)
self.assertEqual(self.operations_called[0][0].name, 'PutObject')
self.assertEqual(self.operations_called[0][1]['IfNoneMatch'], '*')

def test_no_overwrite_flag_multipart_upload_when_object_not_exists_on_target(
self,
):
# Create a large file that will trigger multipart upload
full_path = self.files.create_file('foo.txt', 'a' * 10 * (1024**2))
cmdline = f'{self.prefix} {full_path} s3://bucket --no-overwrite'

# Set up responses for multipart upload
self.parsed_responses = [
{'UploadId': 'foo'}, # CreateMultipartUpload response
{'ETag': '"foo-1"'}, # UploadPart response
{'ETag': '"foo-2"'}, # UploadPart response
{}, # CompleteMultipartUpload response
]
self.run_cmd(cmdline, expected_rc=0)
# Verify all multipart operations were called
self.assertEqual(len(self.operations_called), 4)
self.assertEqual(
self.operations_called[0][0].name, 'CreateMultipartUpload'
)
self.assertEqual(self.operations_called[1][0].name, 'UploadPart')
self.assertEqual(self.operations_called[2][0].name, 'UploadPart')
self.assertEqual(
self.operations_called[3][0].name, 'CompleteMultipartUpload'
)
# Verify the IfNoneMatch condition was set in the CompleteMultipartUpload request
self.assertEqual(self.operations_called[3][1]['IfNoneMatch'], '*')

def test_no_overwrite_flag_multipart_upload_when_object_exists_on_target(
self,
):
# Create a large file that will trigger multipart upload
full_path = self.files.create_file('foo.txt', 'a' * 10 * (1024**2))
cmdline = f'{self.prefix} {full_path} s3://bucket --no-overwrite'
# Set up responses for multipart upload
self.parsed_responses = [
{'UploadId': 'foo'}, # CreateMultipartUpload response
{'ETag': '"foo-1"'}, # UploadPart response
{'ETag': '"foo-2"'}, # UploadPart response
{
'Error': {
'Code': 'PreconditionFailed',
'Message': 'At least one of the pre-conditions you specified did not hold',
'Condition': 'If-None-Match',
}
}, # PreconditionFailed error for CompleteMultipart Upload
{}, # AbortMultipartUpload response
]
# Checking for success as file is skipped
self.run_cmd(cmdline, expected_rc=0)
# Set up the response to simulate a PreconditionFailed error
self.http_response.status_code = 412
# Verify all multipart operations were called
self.assertEqual(len(self.operations_called), 5)
self.assertEqual(
self.operations_called[0][0].name, 'CreateMultipartUpload'
)
self.assertEqual(self.operations_called[1][0].name, 'UploadPart')
self.assertEqual(self.operations_called[2][0].name, 'UploadPart')
self.assertEqual(
self.operations_called[3][0].name, 'CompleteMultipartUpload'
)
self.assertEqual(
self.operations_called[4][0].name, 'AbortMultipartUpload'
)
# Verify the IfNoneMatch condition was set in the CompleteMultipartUpload request
self.assertEqual(self.operations_called[3][1]['IfNoneMatch'], '*')

def test_dryrun_download(self):
self.parsed_responses = [self.head_object_response()]
target = self.files.full_path('file.txt')
Expand Down
106 changes: 106 additions & 0 deletions tests/functional/s3/test_mv_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,112 @@ def test_download_with_checksum_mode_crc32(self):
self.operations_called[1][1]['ChecksumMode'], 'ENABLED'
)

def test_mv_no_overwrite_flag_when_object_not_exists_on_target(self):
full_path = self.files.create_file('foo.txt', 'contents')
cmdline = f'{self.prefix} {full_path} s3://bucket --no-overwrite'
self.run_cmd(cmdline, expected_rc=0)
# Verify putObject was called
self.assertEqual(len(self.operations_called), 1)
self.assertEqual(self.operations_called[0][0].name, 'PutObject')
# Verify the IfNoneMatch condition was set in the request
self.assertEqual(self.operations_called[0][1]['IfNoneMatch'], '*')
# Verify source file was deleted (move operation)
self.assertFalse(os.path.exists(full_path))

def test_mv_no_overwrite_flag_when_object_exists_on_target(self):
full_path = self.files.create_file('foo.txt', 'mycontent')
cmdline = (
f'{self.prefix} {full_path} s3://bucket/foo.txt --no-overwrite'
)
# Set up the response to simulate a PreconditionFailed error
self.http_response.status_code = 412
self.parsed_responses = [
{
'Error': {
'Code': 'PreconditionFailed',
'Message': 'At least one of the pre-conditions you specified did not hold',
'Condition': 'If-None-Match',
}
}
]
self.run_cmd(cmdline, expected_rc=0)
# Verify PutObject was attempted with IfNoneMatch
self.assertEqual(len(self.operations_called), 1)
self.assertEqual(self.operations_called[0][0].name, 'PutObject')
self.assertEqual(self.operations_called[0][1]['IfNoneMatch'], '*')
# Verify source file was not deleted
self.assertTrue(os.path.exists(full_path))

def test_mv_no_overwrite_flag_multipart_upload_when_object_not_exists_on_target(
self,
):
# Create a large file that will trigger multipart upload
full_path = self.files.create_file('foo.txt', 'a' * 10 * (1024**2))
cmdline = f'{self.prefix} {full_path} s3://bucket --no-overwrite'
# Set up responses for multipart upload
self.parsed_responses = [
{'UploadId': 'foo'}, # CreateMultipartUpload response
{'ETag': '"foo-1"'}, # UploadPart response
{'ETag': '"foo-2"'}, # UploadPart response
{}, # CompleteMultipartUpload response
]
self.run_cmd(cmdline, expected_rc=0)
# Verify all multipart operations were called
self.assertEqual(len(self.operations_called), 4)
self.assertEqual(
self.operations_called[0][0].name, 'CreateMultipartUpload'
)
self.assertEqual(self.operations_called[1][0].name, 'UploadPart')
self.assertEqual(self.operations_called[2][0].name, 'UploadPart')
self.assertEqual(
self.operations_called[3][0].name, 'CompleteMultipartUpload'
)
# Verify the IfNoneMatch condition was set in the CompleteMultipartUpload request
self.assertEqual(self.operations_called[3][1]['IfNoneMatch'], '*')
# Verify source file was deleted (successful move operation)
self.assertFalse(os.path.exists(full_path))

def test_mv_no_overwrite_flag_multipart_upload_when_object_exists_on_target(
self,
):
# Create a large file that will trigger multipart upload
full_path = self.files.create_file('foo.txt', 'a' * 10 * (1024**2))
cmdline = f'{self.prefix} {full_path} s3://bucket --no-overwrite'
# Set up responses for multipart upload
self.parsed_responses = [
{'UploadId': 'foo'}, # CreateMultipartUpload response
{'ETag': '"foo-1"'}, # UploadPart response
{'ETag': '"foo-2"'}, # UploadPart response
{
'Error': {
'Code': 'PreconditionFailed',
'Message': 'At least one of the pre-conditions you specified did not hold',
'Condition': 'If-None-Match',
}
}, # CompleteMultipartUpload response
{}, # Abort Multipart
]
self.run_cmd(cmdline, expected_rc=0)
# Set up the response to simulate a PreconditionFailed error
self.http_response.status_code = 412
# Verify all multipart operations were called
self.assertEqual(len(self.operations_called), 5)
self.assertEqual(
self.operations_called[0][0].name, 'CreateMultipartUpload'
)
self.assertEqual(self.operations_called[1][0].name, 'UploadPart')
self.assertEqual(self.operations_called[2][0].name, 'UploadPart')
self.assertEqual(
self.operations_called[3][0].name, 'CompleteMultipartUpload'
)
self.assertEqual(
self.operations_called[4][0].name, 'AbortMultipartUpload'
)
# Verify the IfNoneMatch condition was set in the CompleteMultipartUpload request
self.assertEqual(self.operations_called[3][1]['IfNoneMatch'], '*')
# Verify source file was not deleted (failed move operation due to PreconditionFailed)
self.assertTrue(os.path.exists(full_path))


class TestMvWithCRTClient(BaseCRTTransferClientTest):
def test_upload_move_using_crt_client(self):
Expand Down
Loading
Loading