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-Parser-84103.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "enhancement",
"category": "arguments",
"description": "When an operation name is not found, suggest operations with the same noun and an interchangeable verb, such as ``get-tables`` for ``list-tables``"
}
85 changes: 84 additions & 1 deletion awscli/argparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,19 @@
)


# Verbs that are easy to confuse because services pick different ones
# for the same kind of operation (e.g. glue get-tables vs. list-tables).
_INTERCHANGEABLE_VERBS = [
{'list', 'describe', 'get', 'search', 'query'},
{'update', 'modify', 'put'},
{'create', 'start'},
{'create', 'register'},
{'delete', 'deregister'},
{'stop', 'cancel'},
]
_MAX_VERB_SWAP_SUGGESTIONS = 3


class ArgParseException(Exception):
pass

Expand Down Expand Up @@ -116,7 +129,7 @@ def _check_value(self, action, value):
# converted value must be one of the choices (if specified)
if action.choices is not None and value not in action.choices:
msg = [f"Found invalid choice '{value}'\n"]
possible = get_close_matches(value, action.choices, cutoff=0.8)
possible = suggest_choices(value, action.choices)
if possible:
extra = ['Maybe you meant:\n']
for word in possible:
Expand Down Expand Up @@ -391,3 +404,73 @@ def __init__(self, *args, **kwargs):
def _build(self):
self.add_argument('--profile', type=str)
self.add_argument('--debug', action='store_true', default=False)


def suggest_choices(value, choices):
"""Return valid choices to suggest for an invalid ``value``.

Operations whose noun matches but whose verb is an interchangeable
one (``list-tables`` -> ``get-tables``) come first, followed by
spelling-level matches.
"""
choices = list(choices)
suggestions = _get_verb_swap_matches(value, choices)
for match in get_close_matches(value, choices, cutoff=0.8):
if match not in suggestions:
suggestions.append(match)
return suggestions


def _get_verb_swap_matches(value, choices):
verb, _, noun = value.partition('-')
alternatives = {
alt
for group in _INTERCHANGEABLE_VERBS
if verb in group
for alt in group
}
# Without a noun (e.g. a bare "search") there's nothing to tell the
# intended operation apart from every other one with a swapped verb.
if not alternatives or not noun:
return []
words = _noun_words(noun)
scored = []
for choice in choices:
choice_verb, _, choice_noun = choice.partition('-')
if choice_verb not in alternatives or not choice_noun:
continue
choice_words = _noun_words(choice_noun)
# The last word is the thing being operated on ("components" in
# "list-image-builder-components"), so it has to match. The
# choice may drop qualifiers that were typed, but not add its
# own, which would make it a different operation
# (get-column-statistics-for-table for list-tables).
if choice_words[-1] != words[-1] or not set(choice_words) <= set(
words
):
continue
score = (
# Same noun, including plurality: describe-channels typed,
# so list-channels outranks describe-channel.
choice_noun == noun,
choice_words == words,
len(choice_words),
choice_verb == verb,
)
scored.append((score, choice))
# Best score first; ties stay in alphabetical order.
scored.sort(key=lambda item: item[1])
scored.sort(key=lambda item: item[0], reverse=True)
return [choice for _, choice in scored[:_MAX_VERB_SWAP_SUGGESTIONS]]


def _noun_words(noun):
return [_singularize(word) for word in noun.split('-')]


def _singularize(word):
# Only needs to make "tables" and "table" compare equal; both sides
# of every comparison go through it, so odd stems don't matter.
if len(word) > 3 and word.endswith('s'):
return word[:-1]
return word
64 changes: 64 additions & 0 deletions tests/functional/test_argparser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"). You
# may not use this file except in compliance with the License. A copy of
# the License is located at
#
# http://aws.amazon.com/apache2.0/
#
# or in the "license" file accompanying this file. This file is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
# ANY KIND, either express or implied. See the License for the specific
# language governing permissions and limitations under the License.
from tests import CLIRunner

EXPECTED_FOOTER = (
'usage: aws [options] <command> <subcommand> [<subcommand> ...] '
'[parameters]\n'
'To see help text, you can run:\n'
'\n'
' aws help\n'
' aws <command> help\n'
' aws <command> <subcommand> help\n'
)


def test_invalid_operation_suggests_spelling_matches():
result = CLIRunner().run(['s3api', 'list-objecst'])
assert result.rc == 252
assert (
result.stderr
== (
'\n'
'aws: [ERROR]: An error occurred (ParamValidation): argument '
"operation: Found invalid choice 'list-objecst'\n"
'\n'
'Maybe you meant:\n'
'\n'
' * list-objects\n'
' * list-objects-v2\n'
'\n'
)
+ EXPECTED_FOOTER
)


def test_invalid_operation_suggests_verb_swaps():
result = CLIRunner().run(['glue', 'list-tables'])
assert result.rc == 252
assert (
result.stderr
== (
'\n'
'aws: [ERROR]: An error occurred (ParamValidation): argument '
"operation: Found invalid choice 'list-tables'\n"
'\n'
'Maybe you meant:\n'
'\n'
' * get-tables\n'
' * search-tables\n'
' * get-table\n'
'\n'
)
+ EXPECTED_FOOTER
)
101 changes: 101 additions & 0 deletions tests/unit/test_argparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
ServiceArgParser,
format_unknown_options_error,
get_option_suggestion_candidates,
suggest_choices,
)
from awscli.arguments import CustomArgument
from awscli.testutils import mock, unittest
Expand Down Expand Up @@ -236,3 +237,103 @@ def test_ignores_value_after_equals(self):
def test_does_not_match_values(self):
msg = format_unknown_options_error(['region'], self.candidates)
self.assertNotIn('Maybe you meant', msg)


class TestSuggestChoices(unittest.TestCase):
def test_swaps_verb_for_same_noun(self):
choices = ['get-tables', 'get-table', 'create-table']
self.assertEqual(
suggest_choices('list-tables', choices),
['get-tables', 'get-table'],
)

def test_swaps_create_and_start(self):
choices = ['start-job', 'stop-job', 'list-jobs']
self.assertEqual(suggest_choices('create-job', choices), ['start-job'])

def test_prefers_matching_plurality(self):
choices = ['describe-channel', 'list-channels', 'delete-channel']
self.assertEqual(
suggest_choices('describe-channels', choices)[:2],
['list-channels', 'describe-channel'],
)

def test_matches_when_choice_drops_qualifiers(self):
choices = ['list-components', 'list-images', 'get-component']
self.assertEqual(
suggest_choices('list-image-builder-components', choices),
['list-components', 'get-component'],
)

def test_does_not_match_choice_with_extra_words(self):
choices = ['get-column-statistics-for-table', 'get-tables']
self.assertEqual(
suggest_choices('list-tables', choices), ['get-tables']
)

def test_bare_verb_gets_no_verb_swaps(self):
# No noun to match, so even a single swapped-verb operation
# isn't suggested.
choices = ['query-vectors', 'put-vectors', 'list-vectors']
self.assertEqual(suggest_choices('search', choices), [])

def test_verb_swaps_come_before_close_matches(self):
choices = ['describe-namespace-x', 'get-namespace']
self.assertEqual(
suggest_choices('describe-namespace', choices),
['get-namespace', 'describe-namespace-x'],
)

def test_still_suggests_typos(self):
choices = ['list-objects', 'list-buckets']
self.assertEqual(
suggest_choices('list-objecst', choices), ['list-objects']
)

def test_no_verb_swap_for_other_verbs(self):
choices = ['remove-tags', 'list-tags']
self.assertEqual(suggest_choices('delete-tags', choices), [])

def test_search_swaps_with_read_verbs(self):
choices = ['search-profiles', 'merge-profiles']
self.assertEqual(
suggest_choices('get-profile', choices)[0], 'search-profiles'
)

def test_swaps_update_with_modify_and_put(self):
self.assertEqual(
suggest_choices(
'update-instance-attribute', ['modify-instance-attribute']
),
['modify-instance-attribute'],
)
self.assertEqual(
suggest_choices('update-bucket-policy', ['put-bucket-policy']),
['put-bucket-policy'],
)

def test_swaps_register_and_deregister(self):
choices = ['register-task-definition', 'deregister-task-definition']
self.assertEqual(
suggest_choices('create-task-definition', choices)[0],
'register-task-definition',
)
self.assertEqual(
suggest_choices('delete-task-definition', choices)[0],
'deregister-task-definition',
)

def test_swaps_stop_and_cancel(self):
self.assertEqual(
suggest_choices('stop-job', ['cancel-job']), ['cancel-job']
)

def test_verb_groups_are_not_transitive(self):
# create swaps with both start and register, but those two
# aren't interchangeable with each other.
self.assertEqual(
suggest_choices('start-widget', ['register-widget']), []
)

def test_no_suggestions_for_unrelated_value(self):
self.assertEqual(suggest_choices('ec2', ['s3', 'iam']), [])
Loading