Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changes/next-release/enhancement-help-27828.json
Original file line number Diff line number Diff line change
@@ -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."
}
44 changes: 44 additions & 0 deletions awscli/argparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,50 @@ def choices(self, val):
pass



class _HelpFlagResolver(argparse.ArgumentParser):
"""Minimal parser that detects --help (and abbreviations like --hel, --he).

@kdaily kdaily Sep 17, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Allowing abbreviations means that any existing top level parameter that turns into a CLI flag and shares a prefix with help is now superseded by the help system. For example, aws shield associate-health-check has a parameter --health-check-arn Previously, --he arn:123:abc would pass the value arn:123:abc to the HealthCheckArn property:

$ aws shield associate-health-check --he arn:123:abc --protection-id 123456789123456789123456789123456789

However, with this change, this now opens the help. While this example may be mitigated by determining that a value follows the flag, there's no limitation that a top level parameter with a shared prefix to help has to.


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

Expand Down
41 changes: 40 additions & 1 deletion awscli/clidriver.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
MainArgParser,
ServiceArgParser,
SubCommandArgParser,
detect_help_flag,
)
from awscli.argprocess import unpack_argument
from awscli.arguments import (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Similarly to my other comment --help doesn't reach help here the way the positional does aws ec2 --region us-east-1 help renders ec2 help and aws ec2 --region us-east-1 --help gives argument operation: Found invalid choice 'us-east-1', because --region is skipped but not its value

else:
parser = self.create_parser(command_table)
parser.parse_known_args(args)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

aws --region us-west-2 ec2 describe-instances --help prints nothing and exits with 0.
It looks like --region is skipped but not us-west-2 in the loop above which is skipping any token starting with a dash but not its value

return
if current_cmd is None:
return self.create_help_command()([], None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Does this work for aliases? aws my-ec2 --help prints nothing and exits 0 for me

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks like BaseAliasCommand inherits the base create_help_command() from commands.py, which returns None

help_cmd = current_cmd.create_help_command()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

aws help --help

aws: [ERROR]: 'ProviderHelpCommand' object has no attribute 'create_help_command'

Every other double-help collapses to one rendering (aws ec2 help --help, aws ec2 --help help, aws ec2 describe-instances help --help) all render the same page as the plain help form, whcih makes me expect was help --help will print aws help, which is what it does today

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
Expand Down
4 changes: 4 additions & 0 deletions awscli/data/cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@
"enhanced"
],
"help": "<p>The formatting style for error output. By default, errors are displayed in enhanced format.</p>"
},
"help": {
"action": "store_true",
"help": "<p>Display help for the command/subcommand.</p>"
}
}
}
4 changes: 4 additions & 0 deletions awscli/examples/global_options.rst
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,7 @@
* enhanced


``--help`` (boolean)

Display help for the command/subcommand.

1 change: 1 addition & 0 deletions awscli/examples/global_synopsis.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@
[--cli-auto-prompt]
[--no-cli-auto-prompt]
[--cli-error-format <value>]
[--help]
104 changes: 104 additions & 0 deletions tests/functional/docs/test_help_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion tests/unit/test_clidriver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
]


Expand Down Expand Up @@ -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)
Expand Down
Loading