From 8bfa009f6183d48eb1f9c77a7b00c7b1f7c10212 Mon Sep 17 00:00:00 2001 From: Alex Shovlin Date: Thu, 12 Nov 2026 15:40:07 -0500 Subject: [PATCH] Suggest related operations for similar nouns and verbs when an invalid subcommand is specified --- .../enhancement-Parser-84103.json | 5 + awscli/argparser.py | 85 ++++++++++++++- tests/functional/test_argparser.py | 64 +++++++++++ tests/unit/test_argparser.py | 101 ++++++++++++++++++ 4 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 .changes/next-release/enhancement-Parser-84103.json create mode 100644 tests/functional/test_argparser.py diff --git a/.changes/next-release/enhancement-Parser-84103.json b/.changes/next-release/enhancement-Parser-84103.json new file mode 100644 index 000000000000..ac4b3fe05a6f --- /dev/null +++ b/.changes/next-release/enhancement-Parser-84103.json @@ -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``" +} diff --git a/awscli/argparser.py b/awscli/argparser.py index 95fa4057ea1f..a2ad9d111d60 100644 --- a/awscli/argparser.py +++ b/awscli/argparser.py @@ -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 @@ -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: @@ -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 diff --git a/tests/functional/test_argparser.py b/tests/functional/test_argparser.py new file mode 100644 index 000000000000..5b7e515550d6 --- /dev/null +++ b/tests/functional/test_argparser.py @@ -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] [ ...] ' + '[parameters]\n' + 'To see help text, you can run:\n' + '\n' + ' aws help\n' + ' aws help\n' + ' aws 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 + ) diff --git a/tests/unit/test_argparser.py b/tests/unit/test_argparser.py index d73ffb35f437..e81671c8cb5d 100644 --- a/tests/unit/test_argparser.py +++ b/tests/unit/test_argparser.py @@ -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 @@ -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']), [])