From c0f9c7b39b6ed07421be8a1fb334571a6b6c8de3 Mon Sep 17 00:00:00 2001 From: Laurie O Date: Wed, 14 Jan 2026 15:01:39 +1000 Subject: [PATCH 1/3] Add short aliases to some S3 command options Aliases as specified with name. Single-letter aliases are prefixed with a single hyphen, otherwise with two hyphens. ValueError is raised when positional argument defines aliases. Aliases are: * '-r' for '--recursive' ('s3 cp', 's3 ls', 's3 mv', 's3 rm') * '-H' for '--human-readable' ('s3 ls') * '-s' for '--summarize' ('s3 ls') * '-n' for '--dryrun' ('s3 cp', 's3 mv', 's3 rm') --- awscli/arguments.py | 21 +++++++++++++++++++-- awscli/customizations/s3/subcommands.py | 7 ++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/awscli/arguments.py b/awscli/arguments.py index 686253ad0f6a..0c6bfff2e615 100644 --- a/awscli/arguments.py +++ b/awscli/arguments.py @@ -219,6 +219,7 @@ def __init__( argument_model=None, synopsis='', const=None, + aliases=None, ): self._name = name self._help = help_text @@ -235,6 +236,9 @@ def __init__( choices = [] self._choices = choices self._synopsis = synopsis + if positional_arg and aliases: + raise ValueError("A positional argument cannot have aliases") + self._aliases = aliases # These are public attributes that are ok to access from external # objects. @@ -271,13 +275,22 @@ def cli_name(self): else: return '--' + self._name + @property + def cli_flags(self): + if self._aliases is None: + return (self.cli_name,) + return ( + *(("-" if len(a) == 1 else "--") + a for a in self._aliases), + self.cli_name, + ) + def add_to_parser(self, parser): """ See the ``BaseCLIArgument.add_to_parser`` docs for more information. """ - cli_name = self.cli_name + cli_flags = self.cli_flags kwargs = {} if self._dest is not None: kwargs['dest'] = self._dest @@ -293,7 +306,7 @@ def add_to_parser(self, parser): kwargs['nargs'] = self._nargs if self._const is not None: kwargs['const'] = self._const - parser.add_argument(cli_name, **kwargs) + parser.add_argument(*cli_flags, **kwargs) @property def required(self): @@ -349,6 +362,10 @@ def positional_arg(self): def nargs(self): return self._nargs + @property + def aliases(self): + return self._aliases + class CLIArgument(BaseCLIArgument): """Represents a CLI argument that maps to a service parameter.""" diff --git a/awscli/customizations/s3/subcommands.py b/awscli/customizations/s3/subcommands.py index f77d7196e488..6cb2b8f145f9 100644 --- a/awscli/customizations/s3/subcommands.py +++ b/awscli/customizations/s3/subcommands.py @@ -43,22 +43,23 @@ RECURSIVE = {'name': 'recursive', 'action': 'store_true', 'dest': 'dir_op', + 'aliases': ['r'], 'help_text': ( "Command is performed on all files or objects " "under the specified directory or prefix.")} -HUMAN_READABLE = {'name': 'human-readable', 'action': 'store_true', +HUMAN_READABLE = {'name': 'human-readable', 'action': 'store_true', 'aliases': ['H'], 'help_text': "Displays file sizes in human readable format."} -SUMMARIZE = {'name': 'summarize', 'action': 'store_true', +SUMMARIZE = {'name': 'summarize', 'action': 'store_true', 'aliases': ['s'], 'help_text': ( "Displays summary information " "(number of objects, total size).")} -DRYRUN = {'name': 'dryrun', 'action': 'store_true', +DRYRUN = {'name': 'dryrun', 'action': 'store_true', 'aliases': ['n'], 'help_text': ( "Displays the operations that would be performed using the " "specified command without actually running them.")} From 06495191e3392991756a4901f2b7767680ee3147 Mon Sep 17 00:00:00 2001 From: Laurie O Date: Fri, 16 Jan 2026 12:10:41 +1000 Subject: [PATCH 2/3] Support short options during completion --- awscli/completer.py | 24 +++++++++++++++++------- tests/unit/test_completer.py | 6 +++++- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/awscli/completer.py b/awscli/completer.py index cf08f18fc33a..fa031f8fe6ac 100755 --- a/awscli/completer.py +++ b/awscli/completer.py @@ -106,7 +106,9 @@ def _get_command(self, command_help, command_args): return command_name, cmd_obj.create_help_command() return None, None - def _get_documented_completions(self, table, startswith=None): + def _get_documented_completions( + self, table, startswith=None, include_aliases=False + ): names = [] for key, command in table.items(): if getattr(command, '_UNDOCUMENTED', False): @@ -117,25 +119,33 @@ def _get_documented_completions(self, table, startswith=None): if getattr(command, 'positional_arg', False): continue names.append(key) + if include_aliases and getattr(command, 'aliases', None): + names.extend(command.aliases) return names def _find_possible_options(self, current_arg, opts, subcmd_help=None): all_options = copy.copy(self.main_options) if subcmd_help is not None: all_options += self._get_documented_completions( - subcmd_help.arg_table + subcmd_help.arg_table, include_aliases=True ) + # Prefix with hyphens to match arg (assume single-letter options are + # short) + all_options_prefixed = { + ('-' if len(o) == 1 else '--') + o for o in all_options + } + for option in opts: # Look through list of options on cmdline. If there are # options that have already been specified and they are # not the current word, remove them from list of possibles. if option != current_arg: - stripped_opt = option.lstrip('-') - if stripped_opt in all_options: - all_options.remove(stripped_opt) - cw = current_arg.lstrip('-') - possibilities = ['--' + n for n in all_options if n.startswith(cw)] + if option in all_options_prefixed: + all_options_prefixed.remove(option) + possibilities = [ + n for n in all_options_prefixed if n.startswith(current_arg) + ] if len(possibilities) == 1 and possibilities[0] == current_arg: return self._complete_option(possibilities[0]) return possibilities diff --git a/tests/unit/test_completer.py b/tests/unit/test_completer.py index d4dff96fad17..9824758f8261 100644 --- a/tests/unit/test_completer.py +++ b/tests/unit/test_completer.py @@ -292,7 +292,7 @@ class TestCompleteCustomCommands(BaseCompleterTest): def setUp(self): super(TestCompleteCustomCommands, self).setUp() custom_arguments = [ - {'name': 'recursive'}, + {'name': 'recursive', 'aliases': ['r']}, {'name': 'sse'} ] custom_commands = [ @@ -338,6 +338,10 @@ def test_complete_custom_command_arguments(self): self.assert_completion(self.completer, 'aws s3 cp --', [ '--bar', '--recursive', '--sse']) + def test_complete_custom_command_short_arguments(self): + self.assert_completion(self.completer, 'aws s3 cp -', [ + '-r', '--bar', '--recursive', '--sse']) + def test_complete_custom_command_arguments_with_arg_already_used(self): self.assert_completion(self.completer, 'aws s3 cp --recursive --', [ '--bar', '--sse']) From 249919a2f0be4a9e8c9dda33ff2da7faacb0097f Mon Sep 17 00:00:00 2001 From: Laurie O Date: Fri, 16 Jan 2026 12:56:30 +1000 Subject: [PATCH 3/3] Add unit-tests --- tests/functional/s3/test_cp_command.py | 10 +++- tests/functional/s3/test_ls_command.py | 24 +++++++-- tests/functional/s3/test_mv_command.py | 51 +++++++++++++++++++ tests/functional/s3/test_rm_command.py | 8 ++- tests/functional/s3/test_sync_command.py | 8 ++- .../customizations/s3/test_plugin.py | 8 ++- 6 files changed, 101 insertions(+), 8 deletions(-) diff --git a/tests/functional/s3/test_cp_command.py b/tests/functional/s3/test_cp_command.py index 507d133a32e2..9cd805eee75a 100644 --- a/tests/functional/s3/test_cp_command.py +++ b/tests/functional/s3/test_cp_command.py @@ -194,11 +194,17 @@ def test_operations_used_in_download_file(self): self.assertEqual(self.operations_called[1][0].name, 'GetObject') def test_operations_used_in_recursive_download(self): + self._test_operations_used_in_recursive_download(arg='--recursive') + + def test_operations_used_in_recursive_download_short_option(self): + self._test_operations_used_in_recursive_download(arg='-r') + + def _test_operations_used_in_recursive_download(self, arg): self.parsed_responses = [ {'ETag': '"foo-1"', 'Contents': [], 'CommonPrefixes': []}, ] - cmdline = '%s s3://bucket/key.txt %s --recursive' % ( - self.prefix, self.files.rootdir) + cmdline = '%s s3://bucket/key.txt %s %s' % ( + self.prefix, self.files.rootdir, arg) self.run_cmd(cmdline, expected_rc=0) # We called ListObjectsV2 but had no objects to download, so # we only have a single ListObjectsV2 operation being called. diff --git a/tests/functional/s3/test_ls_command.py b/tests/functional/s3/test_ls_command.py index 4df626f8b600..146936189417 100644 --- a/tests/functional/s3/test_ls_command.py +++ b/tests/functional/s3/test_ls_command.py @@ -18,11 +18,17 @@ class TestLSCommand(BaseS3TransferCommandTest): def test_operations_used_in_recursive_list(self): + self._test_operations_used_in_recursive_list(arg='--recursive') + + def test_operations_used_in_recursive_list_short_option(self): + self._test_operations_used_in_recursive_list(arg='-r') + + def _test_operations_used_in_recursive_list(self, arg): time_utc = "2014-01-09T20:45:49.000Z" self.parsed_responses = [{"CommonPrefixes": [], "Contents": [ {"Key": "foo/bar.txt", "Size": 100, "LastModified": time_utc}]}] - stdout, _, _ = self.run_cmd('s3 ls s3://bucket/ --recursive', expected_rc=0) + stdout, _, _ = self.run_cmd(f's3 ls s3://bucket/ {arg}', expected_rc=0) call_args = self.operations_called[0][1] # We should not be calling the args with any delimiter because we # want a recursive listing. @@ -123,6 +129,12 @@ def test_fail_rc_no_objects_nor_prefixes(self): self.run_cmd('s3 ls s3://bucket/foo', expected_rc=1) def test_human_readable_file_size(self): + self._test_human_readable_file_size(arg='--human-readable') + + def test_human_readable_file_size_short_option(self): + self._test_human_readable_file_size(arg='-H') + + def _test_human_readable_file_size(self, arg): time_utc = "2014-01-09T20:45:49.000Z" self.parsed_responses = [{"CommonPrefixes": [], "Contents": [ {"Key": "onebyte.txt", "Size": 1, "LastModified": time_utc}, @@ -131,7 +143,7 @@ def test_human_readable_file_size(self): {"Key": "onegigabyte.txt", "Size": 1024 ** 3, "LastModified": time_utc}, {"Key": "oneterabyte.txt", "Size": 1024 ** 4, "LastModified": time_utc}, {"Key": "onepetabyte.txt", "Size": 1024 ** 5, "LastModified": time_utc} ]}] - stdout, _, _ = self.run_cmd('s3 ls s3://bucket/ --human-readable', + stdout, _, _ = self.run_cmd(f's3 ls s3://bucket/ {arg}', expected_rc=0) call_args = self.operations_called[0][1] # Time is stored in UTC timezone, but the actual time displayed @@ -146,6 +158,12 @@ def test_human_readable_file_size(self): self.assertIn('%s 1.0 PiB onepetabyte.txt\n' % time_fmt, stdout) def test_summarize(self): + self._test_summarize(arg='--summarize') + + def test_summarize_short_option(self): + self._test_summarize(arg='-s') + + def _test_summarize(self, arg): time_utc = "2014-01-09T20:45:49.000Z" self.parsed_responses = [{"CommonPrefixes": [], "Contents": [ {"Key": "onebyte.txt", "Size": 1, "LastModified": time_utc}, @@ -154,7 +172,7 @@ def test_summarize(self): {"Key": "onegigabyte.txt", "Size": 1024 ** 3, "LastModified": time_utc}, {"Key": "oneterabyte.txt", "Size": 1024 ** 4, "LastModified": time_utc}, {"Key": "onepetabyte.txt", "Size": 1024 ** 5, "LastModified": time_utc} ]}] - stdout, _, _ = self.run_cmd('s3 ls s3://bucket/ --summarize', expected_rc=0) + stdout, _, _ = self.run_cmd(f's3 ls s3://bucket/ {arg}', expected_rc=0) call_args = self.operations_called[0][1] # Time is stored in UTC timezone, but the actual time displayed # is specific to your tzinfo, so shift the timezone to your local's. diff --git a/tests/functional/s3/test_mv_command.py b/tests/functional/s3/test_mv_command.py index dcddbad7ca5b..2d821857b116 100644 --- a/tests/functional/s3/test_mv_command.py +++ b/tests/functional/s3/test_mv_command.py @@ -69,6 +69,57 @@ def test_metadata_directive_copy(self): self.assertEqual(self.operations_called[1][1]['MetadataDirective'], 'REPLACE') + def test_recursive(self): + self._test_recursive(arg='--recursive') + + def test_recursive_short_option(self): + self._test_recursive(arg='-r') + + def _test_recursive(self, arg): + self.parsed_responses = [ + self.list_objects_response( + ['foo/a/1.txt', 'foo/a/2.txt', 'foo/b/3.txt'], + ), + self.copy_object_response(), + self.copy_object_response(), + self.copy_object_response(), + self.delete_object_response(), + self.delete_object_response(), + self.delete_object_response(), + ] + cmdline = ( + f'{self.prefix} s3://bucket/foo/ s3://bucket/bar/ {arg}' + ) + self.run_cmd(cmdline, expected_rc=0) + + self.assertEqual(len(self.operations_called), 7, + self.operations_called) + + self.assertEqual(self.operations_called[0][0].name, 'ListObjectsV2') + self.assertEqual(self.operations_called[0][1]['Prefix'], 'foo/') + self.assertEqual(self.operations_called[1][0].name, 'CopyObject') + self.assertEqual(self.operations_called[2][0].name, 'DeleteObject') + self.assertEqual(self.operations_called[3][0].name, 'CopyObject') + self.assertEqual(self.operations_called[4][0].name, 'DeleteObject') + self.assertEqual(self.operations_called[5][0].name, 'CopyObject') + self.assertEqual(self.operations_called[6][0].name, 'DeleteObject') + + self.assertEqual( + { + ( + self.operations_called[i][1]['CopySource']['Key'], + self.operations_called[i][1]['Key'], + self.operations_called[i + 1][1]['Key'], # delete + ) + for i in [1, 3, 5] + }, + { + ('foo/a/1.txt', 'bar/a/1.txt', 'foo/a/1.txt'), + ('foo/a/2.txt', 'bar/a/2.txt', 'foo/a/2.txt'), + ('foo/b/3.txt', 'bar/b/3.txt', 'foo/b/3.txt'), + }, + ) # set-equal doesn't care about order + def test_no_metadata_directive_for_non_copy(self): full_path = self.files.create_file('foo.txt', 'mycontent') cmdline = '%s %s s3://bucket --metadata-directive REPLACE' % \ diff --git a/tests/functional/s3/test_rm_command.py b/tests/functional/s3/test_rm_command.py index 21588d367c66..d918ad1773a8 100644 --- a/tests/functional/s3/test_rm_command.py +++ b/tests/functional/s3/test_rm_command.py @@ -39,7 +39,13 @@ def test_delete_with_request_payer(self): ) def test_recursive_delete_with_requests(self): - cmdline = '%s s3://mybucket/ --recursive --request-payer' % self.prefix + self._test_recursive_delete_with_requests(arg='--recursive') + + def test_recursive_delete_with_requests_short_option(self): + self._test_recursive_delete_with_requests(arg='-r') + + def _test_recursive_delete_with_requests(self, arg): + cmdline = '%s s3://mybucket/ %s --request-payer' % (self.prefix, arg) self.parsed_responses = [ self.list_objects_response(['mykey']), self.empty_response(), diff --git a/tests/functional/s3/test_sync_command.py b/tests/functional/s3/test_sync_command.py index f67cd07f13a4..fe619580962d 100644 --- a/tests/functional/s3/test_sync_command.py +++ b/tests/functional/s3/test_sync_command.py @@ -43,7 +43,13 @@ def test_website_redirect_ignore_paramfile(self): ) def test_no_recursive_option(self): - cmdline = '. s3://mybucket --recursive' + self._test_no_recursive_option(arg='--recursive') + + def test_no_recursive_short_option(self): + self._test_no_recursive_option(arg='-r') + + def _test_no_recursive_option(self, arg): + cmdline = '. s3://mybucket %s' % arg # Return code will be 2 for invalid parameter ``--recursive`` self.run_cmd(cmdline, expected_rc=2) diff --git a/tests/integration/customizations/s3/test_plugin.py b/tests/integration/customizations/s3/test_plugin.py index 9ac845651c21..599c00252f40 100644 --- a/tests/integration/customizations/s3/test_plugin.py +++ b/tests/integration/customizations/s3/test_plugin.py @@ -1431,11 +1431,17 @@ class TestDryrun(BaseS3IntegrationTest): This ensures that dryrun works. """ def test_dryrun(self): + self._test_dryrun(arg='--dryrun') + + def test_dryrun_short_option(self): + self._test_dryrun(arg='-n') + + def _test_dryrun(self, arg): bucket_name = _SHARED_BUCKET foo_txt = self.files.create_file('foo.txt', 'foo contents') # Copy file into bucket. - p = aws('s3 cp %s s3://%s/ --dryrun' % (foo_txt, bucket_name)) + p = aws('s3 cp %s s3://%s/ %s' % (foo_txt, bucket_name, arg)) self.assertEqual(p.rc, 0) self.assert_no_errors(p) self.assertTrue(self.key_not_exists(bucket_name, 'foo.txt'))