From 83e4e21beda0d2c003d395e4b009f62e94f34466 Mon Sep 17 00:00:00 2001 From: Steve Yoo Date: Tue, 16 Dec 2025 10:46:17 -0500 Subject: [PATCH 1/6] Add case-sensitivity handling to multi-object download operations --- .../next-release/enhancement-s3-54404.json | 5 + awscli/customizations/s3/filegenerator.py | 4 + awscli/customizations/s3/fileinfo.py | 4 + awscli/customizations/s3/fileinfobuilder.py | 6 + awscli/customizations/s3/s3handler.py | 8 + awscli/customizations/s3/subcommands.py | 138 +++++++++++++- awscli/customizations/s3/subscribers.py | 14 ++ awscli/customizations/s3/syncstrategy/base.py | 9 + .../s3/syncstrategy/caseconflict.py | 92 ++++++++++ awscli/topics/s3-case-insensitivity.rst | 105 +++++++++++ awscli/topics/topic-tags.json | 16 ++ tests/functional/s3/test_cp_command.py | 39 ++++ tests/functional/s3/test_mv_command.py | 110 +++++++++++ tests/functional/s3/test_sync_command.py | 102 +++++++++++ .../s3/syncstrategy/test_base.py | 28 +++ .../s3/syncstrategy/test_caseconflict.py | 172 ++++++++++++++++++ .../customizations/s3/test_fileinfobuilder.py | 2 + .../customizations/s3/test_subscribers.py | 15 ++ 18 files changed, 863 insertions(+), 6 deletions(-) create mode 100644 .changes/next-release/enhancement-s3-54404.json create mode 100644 awscli/customizations/s3/syncstrategy/caseconflict.py create mode 100644 awscli/topics/s3-case-insensitivity.rst create mode 100644 tests/unit/customizations/s3/syncstrategy/test_caseconflict.py diff --git a/.changes/next-release/enhancement-s3-54404.json b/.changes/next-release/enhancement-s3-54404.json new file mode 100644 index 000000000000..9e6d9030e48c --- /dev/null +++ b/.changes/next-release/enhancement-s3-54404.json @@ -0,0 +1,5 @@ +{ + "type": "enhancement", + "category": "``s3``", + "description": "Adds new parameter ``--case-conflict`` that configures how case conflicts on case-insensitive filesystems" +} diff --git a/awscli/customizations/s3/filegenerator.py b/awscli/customizations/s3/filegenerator.py index d99cdcb31bf3..5e635193c471 100644 --- a/awscli/customizations/s3/filegenerator.py +++ b/awscli/customizations/s3/filegenerator.py @@ -109,6 +109,8 @@ def __init__( operation_name=None, response_data=None, etag=None, + case_conflict_submitted=None, + case_conflict_key=None, ): self.src = src self.dest = dest @@ -120,6 +122,8 @@ def __init__( self.operation_name = operation_name self.response_data = response_data self.etag = etag + self.case_conflict_submitted = case_conflict_submitted + self.case_conflict_key = case_conflict_key class FileGenerator: diff --git a/awscli/customizations/s3/fileinfo.py b/awscli/customizations/s3/fileinfo.py index 0a3dd561b501..6e1a603d3095 100644 --- a/awscli/customizations/s3/fileinfo.py +++ b/awscli/customizations/s3/fileinfo.py @@ -55,6 +55,8 @@ def __init__( is_stream=False, associated_response_data=None, etag=None, + case_conflict_submitted=None, + case_conflict_key=None, ): self.src = src self.src_type = src_type @@ -73,6 +75,8 @@ def __init__( self.is_stream = is_stream self.associated_response_data = associated_response_data self.etag = etag + self.case_conflict_submitted = case_conflict_submitted + self.case_conflict_key = case_conflict_key def is_glacier_compatible(self): """Determines if a file info object is glacier compatible diff --git a/awscli/customizations/s3/fileinfobuilder.py b/awscli/customizations/s3/fileinfobuilder.py index 181aaf039327..c2c10bb8ed44 100644 --- a/awscli/customizations/s3/fileinfobuilder.py +++ b/awscli/customizations/s3/fileinfobuilder.py @@ -48,6 +48,12 @@ def _inject_info(self, file_base): file_info_attr['is_stream'] = self._is_stream file_info_attr['associated_response_data'] = file_base.response_data file_info_attr['etag'] = file_base.etag + file_info_attr['case_conflict_submitted'] = getattr( + file_base, 'case_conflict_submitted', None + ) + file_info_attr['case_conflict_key'] = getattr( + file_base, 'case_conflict_key', None + ) # This is a bit quirky. The below conditional hinges on the --delete # flag being set, which only occurs during a sync command. The source diff --git a/awscli/customizations/s3/s3handler.py b/awscli/customizations/s3/s3handler.py index b4a6c7ba5527..f4cfd8f0f414 100644 --- a/awscli/customizations/s3/s3handler.py +++ b/awscli/customizations/s3/s3handler.py @@ -32,6 +32,7 @@ SuccessResult, ) from awscli.customizations.s3.subscribers import ( + CaseConflictCleanupSubscriber, CopyPropsSubscriberFactory, DeleteCopySourceObjectSubscriber, DeleteSourceFileSubscriber, @@ -425,6 +426,13 @@ def _add_additional_subscribers(self, subscribers, fileinfo): subscribers.append( DeleteSourceObjectSubscriber(fileinfo.source_client) ) + if fileinfo.case_conflict_submitted is not None: + subscribers.append( + CaseConflictCleanupSubscriber( + fileinfo.case_conflict_submitted, + fileinfo.case_conflict_key, + ) + ) def _submit_transfer_request(self, fileinfo, extra_args, subscribers): bucket, key = find_bucket_key(fileinfo.src) diff --git a/awscli/customizations/s3/subcommands.py b/awscli/customizations/s3/subcommands.py index 97404566279e..3a259e0fe9aa 100644 --- a/awscli/customizations/s3/subcommands.py +++ b/awscli/customizations/s3/subcommands.py @@ -36,10 +36,12 @@ from awscli.customizations.s3.filters import create_filter from awscli.customizations.s3.s3handler import S3TransferHandlerFactory from awscli.customizations.s3.syncstrategy.base import ( + AlwaysSync, MissingFileSync, NeverSync, SizeAndLastModifiedSync, ) +from awscli.customizations.s3.syncstrategy.caseconflict import CaseConflictSync from awscli.customizations.s3.utils import ( AppendFilter, RequestParamsMapper, @@ -543,9 +545,7 @@ 'name': 'progress-multiline', 'dest': 'progress_multiline', 'action': 'store_true', - 'help_text': ( - 'Show progress on multiple lines.' - ), + 'help_text': ('Show progress on multiple lines.'), } @@ -664,6 +664,35 @@ ), } + +CASE_CONFLICT = { + 'name': 'case-conflict', + 'choices': [ + 'ignore', + 'skip', + 'warn', + 'error', + ], + 'default': 'warn', + 'help_text': ( + "Configures behavior when attempting to download multiple objects " + "whose keys differ only by case, which can cause undefined behavior " + "on case-insensitive filesystems. " + "This parameter only applies for commands that perform multiple S3 " + "to local downloads. " + f"See Handling case " + "conflicts for details. Valid values are: " + "" + ), +} + + TRANSFER_ARGS = [ DRYRUN, QUIET, @@ -1081,7 +1110,14 @@ class CpCommand(S3TransferCommand): } ] + TRANSFER_ARGS - + [METADATA, COPY_PROPS, METADATA_DIRECTIVE, EXPECTED_SIZE, RECURSIVE] + + [ + METADATA, + COPY_PROPS, + METADATA_DIRECTIVE, + EXPECTED_SIZE, + RECURSIVE, + CASE_CONFLICT, + ] ) @@ -1105,6 +1141,7 @@ class MvCommand(S3TransferCommand): METADATA_DIRECTIVE, RECURSIVE, VALIDATE_SAME_S3_PATHS, + CASE_CONFLICT, ] ) @@ -1150,7 +1187,7 @@ class SyncCommand(S3TransferCommand): } ] + TRANSFER_ARGS - + [METADATA, COPY_PROPS, METADATA_DIRECTIVE] + + [METADATA, COPY_PROPS, METADATA_DIRECTIVE, CASE_CONFLICT] ) @@ -1307,7 +1344,16 @@ def choose_sync_strategies(self): sync_strategies['file_at_src_and_dest_sync_strategy'] = ( SizeAndLastModifiedSync() ) - sync_strategies['file_not_at_dest_sync_strategy'] = MissingFileSync() + if self._should_handle_case_conflicts(): + sync_strategies['file_not_at_dest_sync_strategy'] = ( + CaseConflictSync( + on_case_conflict=self.parameters['case_conflict'] + ) + ) + else: + sync_strategies['file_not_at_dest_sync_strategy'] = ( + MissingFileSync() + ) sync_strategies['file_not_at_src_sync_strategy'] = NeverSync() # Determine what strategies to override if any. @@ -1440,6 +1486,12 @@ def run(self): 'file_info_builder': [file_info_builder], 's3_handler': [s3_transfer_handler], } + if self._should_handle_case_conflicts(): + self._handle_case_conflicts( + command_dict, + rev_files, + rev_generator, + ) elif self.cmd == 'rm': command_dict = { 'setup': [files], @@ -1456,6 +1508,12 @@ def run(self): 'file_info_builder': [file_info_builder], 's3_handler': [s3_transfer_handler], } + if self._should_handle_case_conflicts(): + self._handle_case_conflicts( + command_dict, + rev_files, + rev_generator, + ) files = command_dict['setup'] while self.instructions: @@ -1517,6 +1575,74 @@ def _map_sse_c_params(self, request_parameters, paths_type): }, ) + def _should_handle_case_conflicts(self): + return ( + self.cmd in {'sync', 'cp', 'mv'} + and self.parameters.get('paths_type') == 's3local' + and self.parameters['case_conflict'] != 'ignore' + and self.parameters.get('dir_op') + ) + + def _handle_case_conflicts(self, command_dict, rev_files, rev_generator): + # Objects are not returned in lexicographical order when + # operated on S3 Express directory buckets. This is required + # for sync operations to behave correctly, which is what + # recursive copies and moves fall back to so potential case + # conflicts can be detected and handled. + if not is_s3express_bucket( + split_s3_bucket_key(self.parameters['src'])[0] + ): + self._modify_instructions_for_case_conflicts( + command_dict, rev_files, rev_generator + ) + return + # `skip` and `error` are not valid choices in this case because + # it's not possible to detect case conflicts. + if self.parameters['case_conflict'] not in {'ignore', 'warn'}: + raise ParamValidationError( + f"`{self.parameters['case_conflict']}` is not a valid value " + "for `--case-conflict` when operating on S3 Express " + "directory buckets. Valid values: `warn`, `ignore`." + ) + msg = ( + "warning: Recursive copies/moves from an S3 Express " + "directory bucket to a case-insensitive local filesystem " + "may result in undefined behavior if there are " + "S3 object key names that differ only by case. To disable " + "this warning, set the `--case-conflict` parameter to `ignore`. " + f"For more information, see {CaseConflictSync.DOC_URI}." + ) + uni_print(msg, sys.stderr) + + def _modify_instructions_for_case_conflicts( + self, command_dict, rev_files, rev_generator + ): + # Command will perform recursive S3 to local downloads. + # Checking for potential case conflicts requires knowledge + # of local files. Instead of writing a separate validation + # mechanism for recursive downloads, we modify the instructions + # to mimic a sync command. + sync_strategies = { + # Local filename exists with exact case match. Always sync + # because it's a copy operation. + 'file_at_src_and_dest_sync_strategy': AlwaysSync(), + # Local filename either doesn't exist or differs only by case. + # Let `CaseConflictSync` determine which it is and handle it + # according to configured `--case-conflict` parameter. + 'file_not_at_dest_sync_strategy': CaseConflictSync( + on_case_conflict=self.parameters['case_conflict'] + ), + # Copy is one-way so never sync if not at source. + 'file_not_at_src_sync_strategy': NeverSync(), + } + command_dict['setup'].append(rev_files) + command_dict['file_generator'].append(rev_generator) + command_dict['filters'].append(create_filter(self.parameters)) + command_dict['comparator'] = [Comparator(**sync_strategies)] + self.instructions.insert( + self.instructions.index('file_info_builder'), 'comparator' + ) + # TODO: This class is fairly quirky in the sense that it is both a builder # and a data object. In the future we should make the following refactorings diff --git a/awscli/customizations/s3/subscribers.py b/awscli/customizations/s3/subscribers.py index 68dc05b65a97..47ca4ab809aa 100644 --- a/awscli/customizations/s3/subscribers.py +++ b/awscli/customizations/s3/subscribers.py @@ -99,6 +99,20 @@ def on_queued(self, future, **kwargs): ) +class CaseConflictCleanupSubscriber(BaseSubscriber): + """ + A subscriber which removes object compare key from case conflict set + when download finishes. + """ + + def __init__(self, submitted, case_conflict_key): + self._submitted = submitted + self._key = case_conflict_key + + def on_done(self, future, **kwargs): + self._submitted.discard(self._key) + + class DeleteSourceSubscriber(OnDoneFilteredSubscriber): """A subscriber which deletes the source of the transfer.""" diff --git a/awscli/customizations/s3/syncstrategy/base.py b/awscli/customizations/s3/syncstrategy/base.py index f15a76bd4040..eed1297a0a64 100644 --- a/awscli/customizations/s3/syncstrategy/base.py +++ b/awscli/customizations/s3/syncstrategy/base.py @@ -264,3 +264,12 @@ def determine_should_sync(self, src_file, dest_file): src_file.dest, ) return True + + +class AlwaysSync(BaseSync): + def __init__(self, sync_type='file_at_src_and_dest'): + super(AlwaysSync, self).__init__(sync_type) + + def determine_should_sync(self, src_file, dest_file): + LOG.debug(f"syncing: {src_file.src} -> {src_file.dest}") + return True diff --git a/awscli/customizations/s3/syncstrategy/caseconflict.py b/awscli/customizations/s3/syncstrategy/caseconflict.py new file mode 100644 index 000000000000..582a841c0b86 --- /dev/null +++ b/awscli/customizations/s3/syncstrategy/caseconflict.py @@ -0,0 +1,92 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +import logging +import os +import sys + +from awscli.customizations.s3.syncstrategy.base import BaseSync +from awscli.customizations.utils import uni_print + +LOG = logging.getLogger(__name__) + + +class CaseConflictException(Exception): + pass + + +class CaseConflictSync(BaseSync): + DOC_URI = ( + "https://docs.aws.amazon.com/cli/latest/topic/" + "s3-case-insensitivity.html" + ) + + def __init__( + self, + sync_type='file_not_at_dest', + on_case_conflict='warn', + submitted=None, + ): + super().__init__(sync_type) + self._on_case_conflict = on_case_conflict + if submitted is None: + submitted = set() + self._submitted = submitted + + @property + def submitted(self): + return self._submitted + + def determine_should_sync(self, src_file, dest_file): + # `src_file.compare_key` and `dest_file.compare_key` are not equal. + # This could mean that they're completely different or differ + # only by case. eg, `/tmp/a` and `/tmp/b` versus `/tmp/a` and `/tmp/A`. + # If the source file's destination already exists, that means it + # differs only by case and the conflict needs to be handled. + should_sync = True + # Normalize compare key for case sensitivity. + lower_compare_key = src_file.compare_key.lower() + if lower_compare_key in self._submitted or os.path.exists( + src_file.dest + ): + handler = getattr(self, f"_handle_{self._on_case_conflict}") + should_sync = handler(src_file) + if should_sync: + LOG.debug(f"syncing: {src_file.src} -> {src_file.dest}") + self._submitted.add(lower_compare_key) + # Set properties so that a subscriber can be created + # that removes the key from the set after download finishes. + src_file.case_conflict_submitted = self._submitted + src_file.case_conflict_key = lower_compare_key + return should_sync + + @staticmethod + def _handle_skip(src_file): + msg = ( + f"warning: Skipping {src_file.src} -> {src_file.dest} " + "because a file whose name differs only by case either exists " + "or is being downloaded.\n" + ) + uni_print(msg, sys.stderr) + return False + + @staticmethod + def _handle_warn(src_file): + msg = ( + f"warning: Downloading {src_file.src} -> {src_file.dest} " + "despite a file whose name differs only by case either existing " + "or being downloaded. This behavior is not defined on " + "case-insensitive filesystems and may result in overwriting " + "existing files or race conditions between concurrent downloads. " + f"For more information, see {CaseConflictSync.DOC_URI}.\n" + ) + uni_print(msg, sys.stderr) + return True + + @staticmethod + def _handle_error(src_file): + msg = ( + f"Failed to download {src_file.src} -> {src_file.dest} " + "because a file whose name differs only by case either exists " + "or is being downloaded." + ) + raise CaseConflictException(msg) diff --git a/awscli/topics/s3-case-insensitivity.rst b/awscli/topics/s3-case-insensitivity.rst new file mode 100644 index 000000000000..d36905404206 --- /dev/null +++ b/awscli/topics/s3-case-insensitivity.rst @@ -0,0 +1,105 @@ +:title: AWS CLI S3 Case-Insensitivity +:description: Using 'aws s3' commands on case-insensitive filesystems +:category: S3 +:related command: s3 cp, s3 sync, s3 mv + + +This page explains how to detect and handle potential case conflicts when +downloading multiple objects from S3 to a local case-insensitive filesystem +using a single AWS CLI command. + +Case conflicts +============== +S3 object keys are case-sensitive meaning that a bucket can have a set of +key names that differ only by case, for example, ``a.txt`` and ``A.txt``. + +The AWS CLI offers high-level S3 commands that manage transfers of +multiple S3 objects using a single command: + +* ``aws s3 sync`` +* ``aws s3 cp --recursive`` +* ``aws s3 mv --recursive`` + +Case conflicts can occur on case-insensitive filesystems when an S3 bucket +has multiple objects whose keys differ only by case and a single AWS CLI +command is called to download multiple S3 objects **OR** a local file +already exists whose name differs only by case. + +For example, consider an S3 bucket with the following stored objects: + +* ``a.txt`` +* ``A.txt`` + +When the following AWS CLI command is called, the AWS CLI will submit +requests to download ``a.txt`` and ``A.txt``. Since only +one can exist on a case-insensitive filesystem, the last download to finish +will be the file that's locally available. + +.. code-block:: + + aws s3 sync s3://examplebucket ./mylocaldir + +Detecting and handling case conflicts +===================================== +To detect and handle case conflicts, you can specify the ``--case-conflict`` +parameter. The following values are valid options: + +* ``error`` - When a case conflict is detected, the command will immediately + fail and abort in-progress downloads. +* ``warn`` - (Default) When a case conflict is detected, the AWS CLI will + display a warning. +* ``skip`` - When a case conflict is detected, the command will skip + downloading the object and continue and display a warning. +* ``ignore`` - Case conflicts will not be detected or handled. + + +Continuing the prior example, the following describes what happens when +appending the ``--case-conflict`` parameter with possible values: + +``--case-conflict error`` + +1. Submit a download request for ``A.txt``. +2. Detect that ``a.txt`` conflicts with an object that's been submitted for download. +3. Throw an error. If ``A.txt`` finished downloading, it will be locally available. Otherwise, the download request for ``A.txt`` will be aborted. + +``--case-conflict warn`` + +1. Submit a download request for ``A.txt``. +2. Detect that ``a.txt`` conflicts with an object that's been submitted for download. +3. Display a warning. +4. Submit a download request for ``a.txt``, downloading ``A.txt`` and ``a.txt`` in parallel. + +``--case-conflict skip`` + +1. Submit a download request for ``A.txt``. +2. Detect that ``a.txt`` conflicts with an object that's been submitted for download. +3. Skip downloading ``a.txt`` and continue. + +``--case-conflict ignore`` + +1. Submit a download request for ``A.txt``. +2. Submit a download request for ``a.txt``, downloading ``A.txt`` and ``a.txt`` in parallel. + +If your local filesystem is case-sensitive, there's no need to detect and +handle case conflicts. We recommend setting ``--case-conflict ignore`` +in this case. + +S3 Express directory buckets +============================ +Detecting case conflicts is **NOT** supported when the source is an S3 Express +directory bucket. When operating on directory buckets, valid values for the +``--case-conflict`` parameter are: + +* ``warn`` +* ``ignore`` + +The following values are invalid when operating on directory buckets: + +* ``error`` +* ``skip`` + +For example, calling the following command will fail: + +.. code-block:: + + aws s3 cp s3://mydirbucket--usw2-az1--x-s3 ./mylocaldir --recursive --case-conflict error diff --git a/awscli/topics/topic-tags.json b/awscli/topics/topic-tags.json index 590449f4d2ca..2daafca7915e 100644 --- a/awscli/topics/topic-tags.json +++ b/awscli/topics/topic-tags.json @@ -70,6 +70,22 @@ "AWS CLI S3 FAQ" ] }, + "s3-case-insensitivity": { + "category": [ + "S3" + ], + "description": [ + "Using 'aws s3' commands on case-insensitive filesystems" + ], + "related command": [ + "s3 cp", + "s3 sync", + "s3 mv" + ], + "title": [ + "AWS CLI S3 Case-Insensitivity" + ] + }, "ddb-expressions": { "category": ["ddb"], "description": [ diff --git a/tests/functional/s3/test_cp_command.py b/tests/functional/s3/test_cp_command.py index dd7fc8ef4b74..c9e5a14a4425 100644 --- a/tests/functional/s3/test_cp_command.py +++ b/tests/functional/s3/test_cp_command.py @@ -23,6 +23,7 @@ BaseS3CLIRunnerTest, BaseS3TransferCommandTest, ) +from tests.functional.s3.test_sync_command import TestSyncCaseConflict MB = 1024**2 @@ -2698,3 +2699,41 @@ def test_respects_ca_bundle_parameter_no_verify( self.assertEqual(len(crt_requests), 1) tls_context_options = mock_client_tls_context_options.call_args[0][0] self.assertFalse(tls_context_options.verify_peer) + + +class TestCpRecursiveCaseConflict(TestSyncCaseConflict): + prefix = 's3 cp --recursive ' + + +class TestS3ExpressCpRecursive(BaseCPCommandTest): + prefix = 's3 cp --recursive ' + + def test_s3_express_error_raises_exception(self): + cmd = ( + f"{self.prefix} s3://bucket--usw2-az1--x-s3 {self.files.rootdir} " + "--case-conflict error" + ) + _, stderr, _ = self.run_cmd(cmd, expected_rc=252) + assert "`error` is not a valid value" in stderr + + def test_s3_express_skip_raises_exception(self): + cmd = ( + f"{self.prefix} s3://bucket--usw2-az1--x-s3 {self.files.rootdir} " + "--case-conflict skip" + ) + _, stderr, _ = self.run_cmd(cmd, expected_rc=252) + assert "`skip` is not a valid value" in stderr + + def test_s3_express_warn_emits_warning(self): + cmd = ( + f"{self.prefix} s3://bucket--usw2-az1--x-s3 {self.files.rootdir} " + "--case-conflict warn" + ) + self.parsed_responses = [ + self.list_objects_response(['a.txt', 'A.txt']), + self.get_object_response(), + self.get_object_response(), + ] + + _, stderr, _ = self.run_cmd(cmd, expected_rc=0) + assert "warning: Recursive copies/moves" in stderr diff --git a/tests/functional/s3/test_mv_command.py b/tests/functional/s3/test_mv_command.py index 17ce7a5aa1f8..6925380dc73e 100644 --- a/tests/functional/s3/test_mv_command.py +++ b/tests/functional/s3/test_mv_command.py @@ -22,6 +22,7 @@ BaseCRTTransferClientTest, BaseS3TransferCommandTest, ) +from tests.functional.s3.test_sync_command import TestSyncCaseConflict class TestMvCommand(BaseS3TransferCommandTest): @@ -702,3 +703,112 @@ def test_raises_warning_if_validation_not_set_source(self): "s3://bucket/key" ) self.assert_raises_warning(cmdline) + + +class TestMvRecursiveCaseConflict(TestSyncCaseConflict): + prefix = 's3 mv --recursive ' + + def test_warn_with_existing_file(self): + self.files.create_file(self.lower_key, 'mycontent') + cmd = ( + f"{self.prefix} s3://bucket {self.files.rootdir} " + "--case-conflict warn" + ) + self.parsed_responses = [ + self.list_objects_response([self.upper_key]), + self.get_object_response(), + self.delete_object_response(), + ] + _, stderr, _ = self.run_cmd(cmd, expected_rc=0) + assert f"warning: Downloading bucket/{self.upper_key}" in stderr + + def test_warn_with_case_conflicts_in_s3(self): + cmd = ( + f"{self.prefix} s3://bucket {self.files.rootdir} " + "--case-conflict warn" + ) + self.parsed_responses = [ + self.list_objects_response([self.upper_key, self.lower_key]), + self.get_object_response(), + self.delete_object_response(), + self.get_object_response(), + self.delete_object_response(), + ] + _, stderr, _ = self.run_cmd(cmd, expected_rc=0) + assert f"warning: Downloading bucket/{self.lower_key}" in stderr + + def test_skip_with_case_conflicts_in_s3(self): + cmd = ( + f"{self.prefix} s3://bucket {self.files.rootdir} " + "--case-conflict skip" + ) + self.parsed_responses = [ + self.list_objects_response([self.upper_key, self.lower_key]), + self.get_object_response(), + self.delete_object_response(), + ] + _, stderr, _ = self.run_cmd(cmd, expected_rc=0) + assert f"warning: Skipping bucket/{self.lower_key}" in stderr + + def test_ignore_with_existing_file(self): + self.files.create_file(self.lower_key, 'mycontent') + cmd = ( + f"{self.prefix} s3://bucket {self.files.rootdir} " + "--case-conflict ignore" + ) + self.parsed_responses = [ + self.list_objects_response([self.upper_key]), + self.get_object_response(), + self.delete_object_response(), + ] + self.run_cmd(cmd, expected_rc=0) + + def test_ignore_with_case_conflicts_in_s3(self): + cmd = ( + f"{self.prefix} s3://bucket {self.files.rootdir} " + "--case-conflict ignore" + ) + self.parsed_responses = [ + self.list_objects_response([self.upper_key, self.lower_key]), + self.get_object_response(), + self.delete_object_response(), + self.get_object_response(), + self.delete_object_response(), + ] + self.run_cmd(cmd, expected_rc=0) + + +class TestS3ExpressMvRecursive(BaseS3TransferCommandTest): + prefix = 's3 mv --recursive ' + + def test_s3_express_error_raises_exception(self): + cmd = ( + f"{self.prefix} s3://bucket--usw2-az1--x-s3 {self.files.rootdir} " + "--case-conflict error" + ) + _, stderr, _ = self.run_cmd(cmd, expected_rc=252) + assert "`error` is not a valid value" in stderr + + def test_s3_express_skip_raises_exception(self): + cmd = ( + f"{self.prefix} s3://bucket--usw2-az1--x-s3 {self.files.rootdir} " + "--case-conflict skip" + ) + _, stderr, _ = self.run_cmd(cmd, expected_rc=252) + assert "`skip` is not a valid value" in stderr + + def test_s3_express_warn_emits_warning(self): + cmd = ( + f"{self.prefix} s3://bucket--usw2-az1--x-s3 {self.files.rootdir} " + "--case-conflict warn" + ) + self.parsed_responses = [ + self.list_objects_response(['a.txt', 'A.txt']), + self.get_object_response(), + self.delete_object_response(), + self.get_object_response(), + self.delete_object_response(), + ] + + _, stderr, _ = self.run_cmd(cmd, expected_rc=0) + assert "warning: Recursive copies/moves" in stderr diff --git a/tests/functional/s3/test_sync_command.py b/tests/functional/s3/test_sync_command.py index 49f9b16df588..284324c41e19 100644 --- a/tests/functional/s3/test_sync_command.py +++ b/tests/functional/s3/test_sync_command.py @@ -736,3 +736,105 @@ def test_compatible_with_sync_with_local_directory_like_directory_bucket( # Just asserting that command validated and made an API call self.assertEqual(len(self.operations_called), 1) self.assertEqual(self.operations_called[0][0].name, 'ListObjectsV2') + + +class TestSyncCaseConflict(BaseS3TransferCommandTest): + prefix = 's3 sync ' + + def setUp(self): + super().setUp() + self.lower_key = 'a.txt' + self.upper_key = 'A.txt' + + def test_error_with_existing_file(self): + self.files.create_file(self.lower_key, 'mycontent') + cmd = ( + f"{self.prefix} s3://bucket {self.files.rootdir} " + "--case-conflict error" + ) + self.parsed_responses = [self.list_objects_response([self.upper_key])] + _, stderr, _ = self.run_cmd(cmd, expected_rc=1) + assert f"Failed to download bucket/{self.upper_key}" in stderr + + def test_error_with_case_conflicts_in_s3(self): + cmd = ( + f"{self.prefix} s3://bucket {self.files.rootdir} " + "--case-conflict error" + ) + self.parsed_responses = [ + self.list_objects_response([self.upper_key, self.lower_key]) + ] + _, stderr, _ = self.run_cmd(cmd, expected_rc=1) + assert f"Failed to download bucket/{self.lower_key}" in stderr + + def test_warn_with_existing_file(self): + self.files.create_file(self.lower_key, 'mycontent') + cmd = ( + f"{self.prefix} s3://bucket {self.files.rootdir} " + "--case-conflict warn" + ) + self.parsed_responses = [ + self.list_objects_response([self.upper_key]), + self.get_object_response(), + ] + _, stderr, _ = self.run_cmd(cmd, expected_rc=0) + assert f"warning: Downloading bucket/{self.upper_key}" in stderr + + def test_warn_with_case_conflicts_in_s3(self): + cmd = ( + f"{self.prefix} s3://bucket {self.files.rootdir} " + "--case-conflict warn" + ) + self.parsed_responses = [ + self.list_objects_response([self.upper_key, self.lower_key]), + self.get_object_response(), + self.get_object_response(), + ] + _, stderr, _ = self.run_cmd(cmd, expected_rc=0) + assert f"warning: Downloading bucket/{self.lower_key}" in stderr + + def test_skip_with_existing_file(self): + self.files.create_file(self.lower_key, 'mycontent') + cmd = ( + f"{self.prefix} s3://bucket {self.files.rootdir} " + "--case-conflict skip" + ) + self.parsed_responses = [self.list_objects_response([self.upper_key])] + _, stderr, _ = self.run_cmd(cmd, expected_rc=0) + assert f"warning: Skipping bucket/{self.upper_key}" in stderr + + def test_skip_with_case_conflicts_in_s3(self): + cmd = ( + f"{self.prefix} s3://bucket {self.files.rootdir} " + "--case-conflict skip" + ) + self.parsed_responses = [ + self.list_objects_response([self.upper_key, self.lower_key]), + self.get_object_response(), + ] + _, stderr, _ = self.run_cmd(cmd, expected_rc=0) + assert f"warning: Skipping bucket/{self.lower_key}" in stderr + + def test_ignore_with_existing_file(self): + self.files.create_file(self.lower_key, 'mycontent') + cmd = ( + f"{self.prefix} s3://bucket {self.files.rootdir} " + "--case-conflict ignore" + ) + self.parsed_responses = [ + self.list_objects_response([self.upper_key]), + self.get_object_response(), + ] + self.run_cmd(cmd, expected_rc=0) + + def test_ignore_with_case_conflicts_in_s3(self): + cmd = ( + f"{self.prefix} s3://bucket {self.files.rootdir} " + "--case-conflict ignore" + ) + self.parsed_responses = [ + self.list_objects_response([self.upper_key, self.lower_key]), + self.get_object_response(), + self.get_object_response(), + ] + self.run_cmd(cmd, expected_rc=0) diff --git a/tests/unit/customizations/s3/syncstrategy/test_base.py b/tests/unit/customizations/s3/syncstrategy/test_base.py index 05c874a02682..7deb9a46ff06 100644 --- a/tests/unit/customizations/s3/syncstrategy/test_base.py +++ b/tests/unit/customizations/s3/syncstrategy/test_base.py @@ -15,6 +15,7 @@ from awscli.customizations.exceptions import ParamValidationError from awscli.customizations.s3.filegenerator import FileStat from awscli.customizations.s3.syncstrategy.base import ( + AlwaysSync, BaseSync, MissingFileSync, NeverSync, @@ -383,5 +384,32 @@ def test_determine_should_sync(self): self.assertTrue(should_sync) +class TestAlwaysSync: + def test_determine_should_sync(self): + always_sync = AlwaysSync() + time = datetime.datetime.now() + src_file = FileStat( + src='', + dest='', + compare_key='test.py', + size=10, + last_update=time, + src_type='s3', + dest_type='local', + operation_name='download', + ) + dest_file = FileStat( + src='', + dest='', + compare_key='test.py', + size=10, + last_update=time, + src_type='local', + dest_type='s3', + operation_name='', + ) + assert always_sync.determine_should_sync(src_file, dest_file) is True + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/customizations/s3/syncstrategy/test_caseconflict.py b/tests/unit/customizations/s3/syncstrategy/test_caseconflict.py new file mode 100644 index 000000000000..abdaac83f035 --- /dev/null +++ b/tests/unit/customizations/s3/syncstrategy/test_caseconflict.py @@ -0,0 +1,172 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +import pytest + +from awscli.customizations.s3.filegenerator import FileStat +from awscli.customizations.s3.syncstrategy.caseconflict import ( + CaseConflictException, + CaseConflictSync, +) + + +@pytest.fixture +def lower_compare_key(): + return 'a.txt' + + +@pytest.fixture +def lower_temp_filepath(lower_compare_key, tmp_path): + p = tmp_path / lower_compare_key + return p.resolve() + + +@pytest.fixture +def lower_file_stat(lower_compare_key, lower_temp_filepath): + return FileStat( + src=f'bucket/{lower_compare_key}', + dest=lower_temp_filepath, + compare_key=lower_compare_key, + ) + + +@pytest.fixture +def upper_compare_key(): + return 'A.txt' + + +@pytest.fixture +def upper_temp_filepath(upper_compare_key, tmp_path): + p = tmp_path / upper_compare_key + p.write_text('foobar') + return p.resolve() + + +@pytest.fixture +def upper_file_stat(upper_compare_key, upper_temp_filepath): + return FileStat( + src=f'bucket/{upper_compare_key}', + dest=upper_temp_filepath, + compare_key=upper_compare_key, + ) + + +@pytest.fixture +def upper_key(upper_file_stat): + return upper_file_stat.compare_key.lower() + + +@pytest.fixture +def no_conflict_file_stat(tmp_path): + return FileStat( + src='bucket/foo.txt', + # Note that this file was never written to. + dest=f'{tmp_path}/foo.txt', + compare_key='foo.txt', + ) + + +class TestCaseConflictSync: + def test_error_with_no_conflict_syncs( + self, no_conflict_file_stat, lower_file_stat, capsys + ): + case_conflict = CaseConflictSync(on_case_conflict='error') + should_sync = case_conflict.determine_should_sync( + no_conflict_file_stat, lower_file_stat + ) + captured = capsys.readouterr() + assert should_sync is True + assert not captured.err + + def test_error_with_existing_file(self, lower_file_stat, upper_file_stat): + case_conflict_sync = CaseConflictSync(on_case_conflict='error') + with pytest.raises(CaseConflictException) as exc: + case_conflict_sync.determine_should_sync( + lower_file_stat, + upper_file_stat, + ) + assert 'Failed to download bucket/a.txt' in str(exc.value) + + def test_error_with_case_conflicts_in_s3( + self, lower_file_stat, upper_file_stat, upper_key + ): + case_conflict_sync = CaseConflictSync( + on_case_conflict='error', submitted={upper_key} + ) + with pytest.raises(CaseConflictException) as exc: + case_conflict_sync.determine_should_sync( + lower_file_stat, + upper_file_stat, + ) + assert 'Failed to download bucket/a.txt' in str(exc.value) + + def test_warn_with_no_conflict_syncs( + self, no_conflict_file_stat, lower_file_stat, capsys + ): + case_conflict = CaseConflictSync(on_case_conflict='warn') + should_sync = case_conflict.determine_should_sync( + no_conflict_file_stat, lower_file_stat + ) + captured = capsys.readouterr() + assert should_sync is True + assert not captured.err + + def test_warn_with_existing_file( + self, lower_file_stat, upper_file_stat, capsys + ): + case_conflict_sync = CaseConflictSync(on_case_conflict='warn') + should_sync = case_conflict_sync.determine_should_sync( + lower_file_stat, upper_file_stat + ) + captured = capsys.readouterr() + assert should_sync is True + assert 'warning: ' in captured.err + + def test_warn_with_case_conflicts_in_s3( + self, lower_file_stat, upper_file_stat, upper_key, capsys + ): + case_conflict_sync = CaseConflictSync( + on_case_conflict='warn', submitted={upper_key} + ) + should_sync = case_conflict_sync.determine_should_sync( + lower_file_stat, + upper_file_stat, + ) + captured = capsys.readouterr() + assert should_sync is True + assert 'warning: ' in captured.err + + def test_skip_with_no_conflict_syncs( + self, no_conflict_file_stat, lower_file_stat, capsys + ): + case_conflict = CaseConflictSync(on_case_conflict='skip') + should_sync = case_conflict.determine_should_sync( + no_conflict_file_stat, lower_file_stat + ) + captured = capsys.readouterr() + assert should_sync is True + assert not captured.err + + def test_skip_with_existing_file( + self, lower_file_stat, upper_file_stat, capsys + ): + case_conflict_sync = CaseConflictSync(on_case_conflict='skip') + should_sync = case_conflict_sync.determine_should_sync( + lower_file_stat, upper_file_stat + ) + captured = capsys.readouterr() + assert should_sync is False + assert 'warning: Skipping' in captured.err + + def test_skip_with_case_conflicts_in_s3( + self, lower_file_stat, upper_file_stat, upper_key, capsys + ): + case_conflict_sync = CaseConflictSync( + on_case_conflict='skip', submitted={upper_key} + ) + should_sync = case_conflict_sync.determine_should_sync( + lower_file_stat, + upper_file_stat, + ) + captured = capsys.readouterr() + assert should_sync is False + assert 'warning: Skipping' in captured.err diff --git a/tests/unit/customizations/s3/test_fileinfobuilder.py b/tests/unit/customizations/s3/test_fileinfobuilder.py index e37f11e485ec..ffc8fdeaf9fe 100644 --- a/tests/unit/customizations/s3/test_fileinfobuilder.py +++ b/tests/unit/customizations/s3/test_fileinfobuilder.py @@ -36,6 +36,8 @@ def test_info_setter(self): operation_name='operation_name', response_data='associated_response_data', etag='etag', + case_conflict_submitted='case_conflict_submitted', + case_conflict_key='case_conflict_key', ) ] file_infos = info_setter.call(files) diff --git a/tests/unit/customizations/s3/test_subscribers.py b/tests/unit/customizations/s3/test_subscribers.py index 906b4970e7d3..324b85fe8a05 100644 --- a/tests/unit/customizations/s3/test_subscribers.py +++ b/tests/unit/customizations/s3/test_subscribers.py @@ -28,6 +28,7 @@ from awscli.customizations.s3.fileinfo import FileInfo from awscli.customizations.s3.results import WarningResult from awscli.customizations.s3.subscribers import ( + CaseConflictCleanupSubscriber, CopyPropsSubscriberFactory, CreateDirectoryError, DeleteCopySourceObjectSubscriber, @@ -751,3 +752,17 @@ def test_add_extra_params_to_delete_object_call(self): self.client.delete_object.assert_called_once_with( Bucket=self.bucket, Key=self.key, RequestPayer='requester' ) + + +class TestCaseConflictCleanupSubscriber: + def test_on_done_removes_key_from_set(self): + submitted = {123, 456} + subscriber = CaseConflictCleanupSubscriber(submitted, 123) + subscriber.on_done(future=None) + assert submitted == {456} + + def test_on_done_handles_missing_key(self): + submitted = {456} + subscriber = CaseConflictCleanupSubscriber(submitted, 123) + subscriber.on_done(future=None) + assert submitted == {456} From d44ada09cec8590b5289ddb49e378105e1a6decd Mon Sep 17 00:00:00 2001 From: Steve Yoo Date: Tue, 16 Dec 2025 15:02:02 -0500 Subject: [PATCH 2/6] Add test decorator for case-insensitive filesystem --- awscli/testutils.py | 20 +++++++ tests/functional/s3/test_mv_command.py | 56 +++++-------------- tests/functional/s3/test_sync_command.py | 5 +- .../s3/syncstrategy/test_caseconflict.py | 4 ++ 4 files changed, 42 insertions(+), 43 deletions(-) diff --git a/awscli/testutils.py b/awscli/testutils.py index efc6449ad51b..b3606a1da72c 100644 --- a/awscli/testutils.py +++ b/awscli/testutils.py @@ -33,6 +33,8 @@ import tempfile import time import unittest +from functools import lru_cache +from pathlib import Path from pprint import pformat from subprocess import PIPE, Popen from unittest import mock @@ -89,6 +91,24 @@ def decorator(func): return decorator +@lru_cache(maxsize=1) +def filesystem_is_case_insensitive(): + with tempfile.TemporaryDirectory() as tmpdir: + f = Path(tmpdir) / 'a.txt' + f.touch() + return (Path(tmpdir) / 'A.txt').exists() + + +def skip_if_case_sensitive(): + def decorator(func): + return unittest.skipIf( + not filesystem_is_case_insensitive(), + "This test requires a case-insensitive filesystem.", + )(func) + + return decorator + + def create_clidriver(): driver = awscli.clidriver.create_clidriver() session = driver.session diff --git a/tests/functional/s3/test_mv_command.py b/tests/functional/s3/test_mv_command.py index 6925380dc73e..a7a6f3db356b 100644 --- a/tests/functional/s3/test_mv_command.py +++ b/tests/functional/s3/test_mv_command.py @@ -16,7 +16,7 @@ from awscrt.s3 import S3RequestType from awscli.compat import BytesIO -from awscli.testutils import mock +from awscli.testutils import mock, skip_if_case_sensitive from tests import requires_crt from tests.functional.s3 import ( BaseCRTTransferClientTest, @@ -708,6 +708,7 @@ def test_raises_warning_if_validation_not_set_source(self): class TestMvRecursiveCaseConflict(TestSyncCaseConflict): prefix = 's3 mv --recursive ' + @skip_if_case_sensitive() def test_warn_with_existing_file(self): self.files.create_file(self.lower_key, 'mycontent') cmd = ( @@ -723,19 +724,17 @@ def test_warn_with_existing_file(self): assert f"warning: Downloading bucket/{self.upper_key}" in stderr def test_warn_with_case_conflicts_in_s3(self): - cmd = ( - f"{self.prefix} s3://bucket {self.files.rootdir} " - "--case-conflict warn" - ) - self.parsed_responses = [ - self.list_objects_response([self.upper_key, self.lower_key]), - self.get_object_response(), - self.delete_object_response(), - self.get_object_response(), - self.delete_object_response(), - ] - _, stderr, _ = self.run_cmd(cmd, expected_rc=0) - assert f"warning: Downloading bucket/{self.lower_key}" in stderr + # This test case becomes very flaky because mv + # performs a get and delete operation twice. + # Delete is called after the get finishes, but + # the order of responses become non-deterministic + # when downloading multiple objects. The order + # could be [get, get, delete, delete] or + # [get, delete, get, delete]. Rather than making + # complex changes to patch this behavior, we're + # delegating the assertions to the sync and cp + # test suites. + pass def test_skip_with_case_conflicts_in_s3(self): cmd = ( @@ -764,18 +763,7 @@ def test_ignore_with_existing_file(self): self.run_cmd(cmd, expected_rc=0) def test_ignore_with_case_conflicts_in_s3(self): - cmd = ( - f"{self.prefix} s3://bucket {self.files.rootdir} " - "--case-conflict ignore" - ) - self.parsed_responses = [ - self.list_objects_response([self.upper_key, self.lower_key]), - self.get_object_response(), - self.delete_object_response(), - self.get_object_response(), - self.delete_object_response(), - ] - self.run_cmd(cmd, expected_rc=0) + pass class TestS3ExpressMvRecursive(BaseS3TransferCommandTest): @@ -796,19 +784,3 @@ def test_s3_express_skip_raises_exception(self): ) _, stderr, _ = self.run_cmd(cmd, expected_rc=252) assert "`skip` is not a valid value" in stderr - - def test_s3_express_warn_emits_warning(self): - cmd = ( - f"{self.prefix} s3://bucket--usw2-az1--x-s3 {self.files.rootdir} " - "--case-conflict warn" - ) - self.parsed_responses = [ - self.list_objects_response(['a.txt', 'A.txt']), - self.get_object_response(), - self.delete_object_response(), - self.get_object_response(), - self.delete_object_response(), - ] - - _, stderr, _ = self.run_cmd(cmd, expected_rc=0) - assert "warning: Recursive copies/moves" in stderr diff --git a/tests/functional/s3/test_sync_command.py b/tests/functional/s3/test_sync_command.py index 284324c41e19..0ad54f6a18a9 100644 --- a/tests/functional/s3/test_sync_command.py +++ b/tests/functional/s3/test_sync_command.py @@ -16,7 +16,7 @@ from awscli.compat import BytesIO from awscli.customizations.s3.utils import relative_path -from awscli.testutils import cd, mock +from awscli.testutils import cd, mock, skip_if_case_sensitive from tests.functional.s3 import ( BaseCRTTransferClientTest, BaseS3CLIRunnerTest, @@ -746,6 +746,7 @@ def setUp(self): self.lower_key = 'a.txt' self.upper_key = 'A.txt' + @skip_if_case_sensitive() def test_error_with_existing_file(self): self.files.create_file(self.lower_key, 'mycontent') cmd = ( @@ -767,6 +768,7 @@ def test_error_with_case_conflicts_in_s3(self): _, stderr, _ = self.run_cmd(cmd, expected_rc=1) assert f"Failed to download bucket/{self.lower_key}" in stderr + @skip_if_case_sensitive() def test_warn_with_existing_file(self): self.files.create_file(self.lower_key, 'mycontent') cmd = ( @@ -793,6 +795,7 @@ def test_warn_with_case_conflicts_in_s3(self): _, stderr, _ = self.run_cmd(cmd, expected_rc=0) assert f"warning: Downloading bucket/{self.lower_key}" in stderr + @skip_if_case_sensitive() def test_skip_with_existing_file(self): self.files.create_file(self.lower_key, 'mycontent') cmd = ( diff --git a/tests/unit/customizations/s3/syncstrategy/test_caseconflict.py b/tests/unit/customizations/s3/syncstrategy/test_caseconflict.py index abdaac83f035..536e40f2a9be 100644 --- a/tests/unit/customizations/s3/syncstrategy/test_caseconflict.py +++ b/tests/unit/customizations/s3/syncstrategy/test_caseconflict.py @@ -7,6 +7,7 @@ CaseConflictException, CaseConflictSync, ) +from awscli.testutils import skip_if_case_sensitive @pytest.fixture @@ -77,6 +78,7 @@ def test_error_with_no_conflict_syncs( assert should_sync is True assert not captured.err + @skip_if_case_sensitive() def test_error_with_existing_file(self, lower_file_stat, upper_file_stat): case_conflict_sync = CaseConflictSync(on_case_conflict='error') with pytest.raises(CaseConflictException) as exc: @@ -110,6 +112,7 @@ def test_warn_with_no_conflict_syncs( assert should_sync is True assert not captured.err + @skip_if_case_sensitive() def test_warn_with_existing_file( self, lower_file_stat, upper_file_stat, capsys ): @@ -146,6 +149,7 @@ def test_skip_with_no_conflict_syncs( assert should_sync is True assert not captured.err + @skip_if_case_sensitive() def test_skip_with_existing_file( self, lower_file_stat, upper_file_stat, capsys ): From aa407b219c760737499fbdafa011aa9d615bc0ed Mon Sep 17 00:00:00 2001 From: Steve Yoo Date: Tue, 16 Dec 2025 15:13:03 -0500 Subject: [PATCH 3/6] Change filesystem case resolution --- awscli/testutils.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/awscli/testutils.py b/awscli/testutils.py index b3606a1da72c..870af064cd63 100644 --- a/awscli/testutils.py +++ b/awscli/testutils.py @@ -54,6 +54,12 @@ INTEG_LOG = logging.getLogger('awscli.tests.integration') AWS_CMD = None +with tempfile.TemporaryDirectory() as tmpdir: + tmpf = Path(tmpdir) / 'a.txt' + with open(tmpf, 'w') as f: + pass + CASE_INSENSITIVE = (Path(tmpdir) / 'A.txt').exists() + def skip_if_windows(reason): """Decorator to skip tests that should not be run on windows. @@ -91,18 +97,10 @@ def decorator(func): return decorator -@lru_cache(maxsize=1) -def filesystem_is_case_insensitive(): - with tempfile.TemporaryDirectory() as tmpdir: - f = Path(tmpdir) / 'a.txt' - f.touch() - return (Path(tmpdir) / 'A.txt').exists() - - def skip_if_case_sensitive(): def decorator(func): return unittest.skipIf( - not filesystem_is_case_insensitive(), + not CASE_INSENSITIVE, "This test requires a case-insensitive filesystem.", )(func) From f04025ecdb7c6cd2a9cfcd350a0f3a69f94fd8d9 Mon Sep 17 00:00:00 2001 From: Steve Yoo Date: Wed, 17 Dec 2025 13:48:39 -0500 Subject: [PATCH 4/6] fix tests --- awscli/testutils.py | 6 ++---- tests/functional/s3/test_cp_command.py | 8 +++++++- tests/functional/s3/test_sync_command.py | 4 +++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/awscli/testutils.py b/awscli/testutils.py index 870af064cd63..bdc63e261651 100644 --- a/awscli/testutils.py +++ b/awscli/testutils.py @@ -33,7 +33,6 @@ import tempfile import time import unittest -from functools import lru_cache from pathlib import Path from pprint import pformat from subprocess import PIPE, Popen @@ -55,10 +54,9 @@ AWS_CMD = None with tempfile.TemporaryDirectory() as tmpdir: - tmpf = Path(tmpdir) / 'a.txt' - with open(tmpf, 'w') as f: + with open(Path(tmpdir) / 'aws-cli-tmp-file', 'w') as f: pass - CASE_INSENSITIVE = (Path(tmpdir) / 'A.txt').exists() + CASE_INSENSITIVE = (Path(tmpdir) / 'AWS-CLI-TMP-FILE').exists() def skip_if_windows(reason): diff --git a/tests/functional/s3/test_cp_command.py b/tests/functional/s3/test_cp_command.py index c9e5a14a4425..3a1bab8cf6f0 100644 --- a/tests/functional/s3/test_cp_command.py +++ b/tests/functional/s3/test_cp_command.py @@ -17,7 +17,12 @@ from awscli.compat import BytesIO, OrderedDict from awscli.customizations.s3.utils import relative_path -from awscli.testutils import BaseAWSCommandParamsTest, capture_input, mock +from awscli.testutils import ( + BaseAWSCommandParamsTest, + capture_input, + mock, + skip_if_windows, +) from tests.functional.s3 import ( BaseCRTTransferClientTest, BaseS3CLIRunnerTest, @@ -2724,6 +2729,7 @@ def test_s3_express_skip_raises_exception(self): _, stderr, _ = self.run_cmd(cmd, expected_rc=252) assert "`skip` is not a valid value" in stderr + @skip_if_windows def test_s3_express_warn_emits_warning(self): cmd = ( f"{self.prefix} s3://bucket--usw2-az1--x-s3 {self.files.rootdir} " diff --git a/tests/functional/s3/test_sync_command.py b/tests/functional/s3/test_sync_command.py index 0ad54f6a18a9..e47121f53911 100644 --- a/tests/functional/s3/test_sync_command.py +++ b/tests/functional/s3/test_sync_command.py @@ -16,7 +16,7 @@ from awscli.compat import BytesIO from awscli.customizations.s3.utils import relative_path -from awscli.testutils import cd, mock, skip_if_case_sensitive +from awscli.testutils import cd, mock, skip_if_case_sensitive, skip_if_windows from tests.functional.s3 import ( BaseCRTTransferClientTest, BaseS3CLIRunnerTest, @@ -782,6 +782,7 @@ def test_warn_with_existing_file(self): _, stderr, _ = self.run_cmd(cmd, expected_rc=0) assert f"warning: Downloading bucket/{self.upper_key}" in stderr + @skip_if_windows() def test_warn_with_case_conflicts_in_s3(self): cmd = ( f"{self.prefix} s3://bucket {self.files.rootdir} " @@ -830,6 +831,7 @@ def test_ignore_with_existing_file(self): ] self.run_cmd(cmd, expected_rc=0) + @skip_if_windows() def test_ignore_with_case_conflicts_in_s3(self): cmd = ( f"{self.prefix} s3://bucket {self.files.rootdir} " From 329aa2c3909626116ffa75d2c625b1a9d861f0c6 Mon Sep 17 00:00:00 2001 From: Steve Yoo Date: Wed, 17 Dec 2025 13:53:18 -0500 Subject: [PATCH 5/6] skip for windows --- tests/functional/s3/test_cp_command.py | 2 +- tests/functional/s3/test_sync_command.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/functional/s3/test_cp_command.py b/tests/functional/s3/test_cp_command.py index 3a1bab8cf6f0..382d79bcee5d 100644 --- a/tests/functional/s3/test_cp_command.py +++ b/tests/functional/s3/test_cp_command.py @@ -2729,7 +2729,7 @@ def test_s3_express_skip_raises_exception(self): _, stderr, _ = self.run_cmd(cmd, expected_rc=252) assert "`skip` is not a valid value" in stderr - @skip_if_windows + @skip_if_windows("Can't rename to same file") def test_s3_express_warn_emits_warning(self): cmd = ( f"{self.prefix} s3://bucket--usw2-az1--x-s3 {self.files.rootdir} " diff --git a/tests/functional/s3/test_sync_command.py b/tests/functional/s3/test_sync_command.py index e47121f53911..1cbced8e4ed9 100644 --- a/tests/functional/s3/test_sync_command.py +++ b/tests/functional/s3/test_sync_command.py @@ -782,7 +782,7 @@ def test_warn_with_existing_file(self): _, stderr, _ = self.run_cmd(cmd, expected_rc=0) assert f"warning: Downloading bucket/{self.upper_key}" in stderr - @skip_if_windows() + @skip_if_windows("Can't rename to same file") def test_warn_with_case_conflicts_in_s3(self): cmd = ( f"{self.prefix} s3://bucket {self.files.rootdir} " @@ -831,7 +831,7 @@ def test_ignore_with_existing_file(self): ] self.run_cmd(cmd, expected_rc=0) - @skip_if_windows() + @skip_if_windows("Can't rename to same file") def test_ignore_with_case_conflicts_in_s3(self): cmd = ( f"{self.prefix} s3://bucket {self.files.rootdir} " From 52f8bae3f0e5d4936f6e5d34f11224fb49061adc Mon Sep 17 00:00:00 2001 From: Steve Yoo Date: Mon, 22 Dec 2025 15:25:18 -0500 Subject: [PATCH 6/6] fix changelog --- .changes/next-release/enhancement-s3-54404.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changes/next-release/enhancement-s3-54404.json b/.changes/next-release/enhancement-s3-54404.json index 9e6d9030e48c..4bf1f1917dd4 100644 --- a/.changes/next-release/enhancement-s3-54404.json +++ b/.changes/next-release/enhancement-s3-54404.json @@ -1,5 +1,5 @@ { "type": "enhancement", "category": "``s3``", - "description": "Adds new parameter ``--case-conflict`` that configures how case conflicts on case-insensitive filesystems" + "description": "Adds new parameter ``--case-conflict`` that configures how case conflicts are handled on case-insensitive filesystems" }