diff --git a/.changes/next-release/enhancement-help-27828.json b/.changes/next-release/enhancement-help-27828.json new file mode 100644 index 000000000000..dc445887f73d --- /dev/null +++ b/.changes/next-release/enhancement-help-27828.json @@ -0,0 +1,5 @@ +{ + "type": "enhancement", + "category": "help", + "description": "Add a ``--help`` parameter that renders the same help as the ``help`` parameter on every command." +} diff --git a/awscli/argparser.py b/awscli/argparser.py index 8ddb5f228550..a95e2ebaf497 100644 --- a/awscli/argparser.py +++ b/awscli/argparser.py @@ -60,6 +60,50 @@ def choices(self, val): pass + +class _HelpFlagResolver(argparse.ArgumentParser): + """Minimal parser that detects --help (and abbreviations like --hel, --he). + + This is used as a first pass before the real parse so that argparse + handles abbreviation matching consistently with all other flags. + """ + + def __init__(self): + super().__init__(add_help=False) + self.add_argument( + '--help', action='store_true', default=False, dest='help_flag' + ) + + def error(self, message): + raise ArgParseException(message) + + +_HELP_RESOLVER = _HelpFlagResolver() + + +def detect_help_flag(args): + """Detect and strip --help (and abbreviations) from args. + + Returns (remaining_args, help_detected). When --help is present the + flag is removed from the arg list but is *not* replaced with the + positional ``help`` token. Callers are responsible for routing to + the appropriate help rendering. + + --help is intercepted here rather than handled by argparse because + it must take priority over all other argument validation. If --help + were a normal parser argument, a preceding value-taking flag (e.g. + ``--query --help``) could consume it as that flag's value, or the + parser could reject it due to missing required positional args. + By stripping it early, we guarantee that --help always renders help + regardless of what else is in the arg list. + """ + try: + parsed, remaining = _HELP_RESOLVER.parse_known_args(args) + except ArgParseException: + return args, False + return remaining, parsed.help_flag + + class CLIArgParser(argparse.ArgumentParser): Formatter = argparse.RawTextHelpFormatter diff --git a/awscli/clidriver.py b/awscli/clidriver.py index bc860a4a8bf2..487acd5a9b93 100644 --- a/awscli/clidriver.py +++ b/awscli/clidriver.py @@ -41,6 +41,7 @@ MainArgParser, ServiceArgParser, SubCommandArgParser, + detect_help_flag, ) from awscli.argprocess import unpack_argument from awscli.arguments import ( @@ -94,7 +95,7 @@ HISTORY_RECORDER = get_global_history_recorder() METADATA_FILENAME = 'metadata.json' INSTALL_FILENAME = 'install.json' -_NO_AUTO_PROMPT_ARGS = ['help', '--version'] +_NO_AUTO_PROMPT_ARGS = ['help', '--help', '--version'] _CLI_AUTO_PROMPT_OPTION = '--cli-auto-prompt' _NO_CLI_AUTO_PROMPT_OPTION = '--no-cli-auto-prompt' # Don't remove this line. The idna encoding @@ -589,8 +590,11 @@ def main(self, args=None): command_table = self._get_command_table() parser = self.create_parser(command_table) self._add_aliases(command_table, parser) + args, help_flag = detect_help_flag(args) parsed_args = None try: + if help_flag: + return self._route_help(args, command_table) # Because _handle_top_level_args emits events, it's possible # that exceptions can be raised, which should have the same # general exception handling logic as calling into the @@ -615,6 +619,41 @@ def main(self, args=None): parsed_globals=parsed_args, ) + def _route_help(self, args, command_table): + # Follow the user's command path (e.g. "s3api delete-object") to + # find the deepest recognized command, then render its help + # directly. This avoids injecting a bare 'help' token that + # could be consumed as a flag value. + current_cmd = None + for arg in args: + if arg.startswith('-'): + continue + if arg in command_table: + current_cmd = command_table[arg] + command_table = getattr( + current_cmd, 'subcommand_table', {} + ) + if not command_table: + # No further subcommands (e.g. we reached an + # operation). Stop scanning so remaining bare + # words (positional param values) aren't + # misinterpreted as commands. + break + else: + # Bare word that isn't a known command — let the + # real parser produce the "invalid choice" error. + if current_cmd is not None: + current_cmd([arg, 'help'], None) + else: + parser = self.create_parser(command_table) + parser.parse_known_args(args) + return + if current_cmd is None: + return self.create_help_command()([], None) + help_cmd = current_cmd.create_help_command() + if help_cmd is not None: + return help_cmd([], None) + def _emit_session_event(self, parsed_args): # This event is guaranteed to run after the session has been # initialized and a profile has been set. This was previously diff --git a/awscli/data/cli.json b/awscli/data/cli.json index 312d46b9e192..d42a14bfb77e 100644 --- a/awscli/data/cli.json +++ b/awscli/data/cli.json @@ -97,6 +97,10 @@ "enhanced" ], "help": "

The formatting style for error output. By default, errors are displayed in enhanced format.

" + }, + "help": { + "action": "store_true", + "help": "

Display help for the command/subcommand.

" } } } diff --git a/awscli/examples/global_options.rst b/awscli/examples/global_options.rst index 20354555fe3c..aae2f6b18456 100644 --- a/awscli/examples/global_options.rst +++ b/awscli/examples/global_options.rst @@ -116,3 +116,7 @@ * enhanced +``--help`` (boolean) + + Display help for the command/subcommand. + diff --git a/awscli/examples/global_synopsis.rst b/awscli/examples/global_synopsis.rst index 3e603348debb..f310cb57eaf2 100644 --- a/awscli/examples/global_synopsis.rst +++ b/awscli/examples/global_synopsis.rst @@ -17,3 +17,4 @@ [--cli-auto-prompt] [--no-cli-auto-prompt] [--cli-error-format ] +[--help] diff --git a/tests/functional/docs/test_help_output.py b/tests/functional/docs/test_help_output.py index c627064257fc..f36116c1bcb5 100644 --- a/tests/functional/docs/test_help_output.py +++ b/tests/functional/docs/test_help_output.py @@ -267,6 +267,110 @@ def test_deprecated_operations_not_documented(self): self.assert_not_contains('put-bucket-notification\n') +class TestHelpFlagOutput(BaseAWSHelpOutputTest): + """Test that --help flag produces the same output as the help subcommand.""" + + def test_top_level_help_flag(self): + self.driver.main(['--help']) + self.assert_contains('***\naws\n***') + self.assert_contains( + 'The AWS Command Line Interface is a unified tool ' + 'to manage your AWS services.' + ) + + def test_service_help_flag(self): + self.driver.main(['ec2', '--help']) + self.assert_contains('***\nec2\n***') + self.assert_contains('===========\nDescription\n===========') + self.assert_contains('* describe-instances') + + def test_operation_help_flag(self): + self.driver.main(['ec2', 'run-instances', '--help']) + self.assert_contains('*************\nrun-instances\n*************') + self.assert_contains('Launches the specified number of instances') + + def test_custom_service_help_flag(self): + self.driver.main(['s3', '--help']) + self.assert_contains('high-level S3 commands') + self.assert_contains('* cp') + + def test_custom_operation_help_flag(self): + self.driver.main(['s3', 'ls', '--help']) + self.assert_contains('List S3 objects') + + def test_help_flag_takes_priority_over_missing_param_value(self): + # --query is a global arg unknown to the operation parser, so --help + # takes priority and renders operation help. + self.driver.main( + [ + "s3api", + "delete-object", + "--bucket", + "b", + "--key", + "k", + "--query", + "--help", + ] + ) + self.assert_contains('delete-object') + + def test_help_flag_abbreviation(self): + self.driver.main(['--hel']) + self.assert_contains('***\naws\n***') + + def test_help_flag_with_operation_level_choices_param(self): + # --acl is an operation-level param with constrained choices. + # --help should render help, not error about invalid choice. + self.driver.main(['s3', 'cp', '--acl', '--help']) + self.assert_contains('cp') + + def test_help_flag_with_operation_level_value_param(self): + # --expected-size takes a free-form value. + # --help should render help, not consume 'help' as the value. + self.driver.main(['s3', 'cp', '--expected-size', '--help']) + self.assert_contains('cp') + + def test_help_flag_with_positional_args(self): + # Positional args should be ignored when --help is present. + self.driver.main( + ['s3', 'cp', 'localfile', 's3://bucket/key', '--help'] + ) + self.assert_contains('cp') + + def test_help_flag_before_modeled_operation(self): + # --help before the operation should still show operation help. + self.driver.main(['s3api', '--help', 'put-object']) + self.assert_contains('put-object') + + def test_help_flag_before_modeled_service(self): + # --help before the service should still show the deepest + # recognized command's help. + self.driver.main(['--help', 's3api', 'put-object']) + self.assert_contains('put-object') + + def test_help_flag_with_invalid_top_level_command(self): + stderr = StringIO() + with mock.patch('sys.stderr', stderr): + rc = self.driver.main(['fake-service', '--help']) + self.assertEqual(rc, 252) + self.assertIn('Found invalid choice', stderr.getvalue()) + + def test_help_flag_with_invalid_service_operation(self): + stderr = StringIO() + with mock.patch('sys.stderr', stderr): + rc = self.driver.main(['s3api', 'fake-command', '--help']) + self.assertEqual(rc, 252) + self.assertIn('Found invalid choice', stderr.getvalue()) + + def test_help_flag_with_invalid_custom_command_operation(self): + stderr = StringIO() + with mock.patch('sys.stderr', stderr): + rc = self.driver.main(['s3', 'fake-command', '--help']) + self.assertEqual(rc, 252) + self.assertIn('Found invalid choice', stderr.getvalue()) + + class TestRemoveDeprecatedCommands(BaseAWSHelpOutputTest): def assert_command_does_not_exist(self, service, command): # Basically try to get the help output for the removed diff --git a/tests/unit/test_clidriver.py b/tests/unit/test_clidriver.py index 7635c302e7aa..5aacf1e55839 100644 --- a/tests/unit/test_clidriver.py +++ b/tests/unit/test_clidriver.py @@ -217,6 +217,7 @@ def _generate_auto_prompt_resolve_cases(): Case(['--no-cli-auto-prompt'], 'on-partial', 'off'), Case(['--version'], 'on', 'off'), Case(['help'], 'on', 'off'), + Case(['--help'], 'on', 'off'), ] @@ -834,7 +835,7 @@ def test_help_blurb_in_operation_error_message(self): self.assertIn(HELP_BLURB, self.stderr.getvalue()) def test_help_blurb_in_unknown_argument_error_message(self): - args = ['s3api', 'list-objects', '--help'] + args = ['s3api', 'list-objects', '--unknown-flag-xyz'] driver = create_clidriver(args) rc = driver.main(args) self.assertEqual(rc, 252)