From 659d4c472584d84c4cca794dee6d4c202c5aa388 Mon Sep 17 00:00:00 2001 From: Anton Topchii Date: Sun, 9 Feb 2025 09:53:59 +0100 Subject: [PATCH 1/4] Add command suggestions feature --- invoke/program.py | 32 ++++++++++++++++++++++++++++++++ tests/program.py | 15 ++++++++++++--- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/invoke/program.py b/invoke/program.py index c7e5cd004..c58da2f2c 100644 --- a/invoke/program.py +++ b/invoke/program.py @@ -1,3 +1,4 @@ +import difflib import getpass import inspect import json @@ -86,6 +87,12 @@ def core_args(self) -> List["Argument"]: default=False, help="Echo executed commands before running.", ), + Argument( + names=("suggestions", "s"), + kind=bool, + default=True, + help="Show possible commands suggestions.", + ), Argument( names=("help", "h"), optional=True, @@ -403,6 +410,11 @@ def run(self, argv: Optional[List[str]] = None, exit: bool = True) -> None: # problems. if isinstance(e, ParseError): print(e, file=sys.stderr) + if self.args.suggestions.value: + unrecognised_cmd = str(e).replace("No idea what '", "") + unrecognised_cmd = unrecognised_cmd.replace("' is!", "") + msg = self._possible_commands_msg(unrecognised_cmd) + print(msg, file=sys.stderr) if isinstance(e, Exit) and e.message: print(e.message, file=sys.stderr) if isinstance(e, UnexpectedExit) and e.result.hide: @@ -985,3 +997,23 @@ def print_columns( else: print(spec.rstrip()) print("") + + def _possible_commands_msg(self, unknown_cmd: str) -> str: + try: + all_tasks = self.scoped_collection.task_names + except AttributeError: + all_tasks = {} + + possible_cmds = list(all_tasks.keys()) + suggestions = difflib.get_close_matches( + unknown_cmd, possible_cmds, n=3, cutoff=0.7 + ) + output_message = f"'{unknown_cmd}' is not an invoke command. " + output_message += "See 'invoke --list'.\n" + if suggestions: + output_message += "\nThe most similar command(s):\n" + for cmd in suggestions: + output_message += f" {cmd}\n" + else: + output_message += "\nNo suggestions was found.\n" + return output_message diff --git a/tests/program.py b/tests/program.py index 2d249b1fb..6c08ee709 100644 --- a/tests/program.py +++ b/tests/program.py @@ -41,6 +41,13 @@ pytestmark = pytest.mark.usefixtures("integration") +no_idea_what_template = """ +No idea what '{0}' is! +'{0}' is not an invoke command. See 'invoke --list'. + +No suggestions was found. + +""".lstrip() class Program_: @@ -360,7 +367,7 @@ def seeks_and_loads_tasks_module_by_default(self): def does_not_seek_tasks_module_if_namespace_was_given(self): expect( "foo", - err="No idea what 'foo' is!\n", + err=no_idea_what_template.format("foo"), program=Program(namespace=Collection("blank")), ) @@ -402,7 +409,7 @@ def ParseErrors_display_message_and_exit_1(self, mock_exit): # "no idea what foo is!") and exit 1. (Intent is to display that # info w/o a full traceback, basically.) stderr = sys.stderr.getvalue() - assert stderr == "No idea what '{}' is!\n".format(nah) + assert stderr == no_idea_what_template.format(nah) mock_exit.assert_called_with(1) @trap @@ -599,6 +606,7 @@ def core_help_option_prints_core_help(self): -r STRING, --search-root=STRING Change root directory used for finding task modules. -R, --dry Echo commands instead of running. + -s, --[no-]suggestions Show possible commands suggestions. -T INT, --command-timeout=INT Specify a global command execution timeout, in seconds. -V, --version Show version and exit. @@ -736,7 +744,8 @@ def exits_after_printing(self): expect("-c decorators -h punch --list", out=expected) def complains_if_given_invalid_task_name(self): - expect("-h this", err="No idea what 'this' is!\n") + expected = no_idea_what_template.format("this") + expect("-h this", err=expected) class task_list: "--list" From 9455d8a87884ea746d239e8cad69c58d28f37e7a Mon Sep 17 00:00:00 2001 From: Anton Topchii Date: Fri, 7 Mar 2025 18:35:34 +0100 Subject: [PATCH 2/4] Skip extra processing when handling ParseError raised on early stage This change prevent program from crashing, when ParseError is raised due to processing core args --- invoke/program.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invoke/program.py b/invoke/program.py index c58da2f2c..be5413ef0 100644 --- a/invoke/program.py +++ b/invoke/program.py @@ -410,7 +410,7 @@ def run(self, argv: Optional[List[str]] = None, exit: bool = True) -> None: # problems. if isinstance(e, ParseError): print(e, file=sys.stderr) - if self.args.suggestions.value: + if "No idea what " in str(e) and self.args.suggestions.value: unrecognised_cmd = str(e).replace("No idea what '", "") unrecognised_cmd = unrecognised_cmd.replace("' is!", "") msg = self._possible_commands_msg(unrecognised_cmd) From 275d004fc5b9df96de946e9401262734377d00be Mon Sep 17 00:00:00 2001 From: Anton Topchii Date: Fri, 7 Mar 2025 19:29:47 +0100 Subject: [PATCH 3/4] Add test case for command suggestion --- tests/program.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/program.py b/tests/program.py index 6c08ee709..640120eb0 100644 --- a/tests/program.py +++ b/tests/program.py @@ -376,6 +376,22 @@ def explicit_namespace_works_correctly(self): ns = Collection.from_module(load("integration")) expect("print-foo", out="foo\n", program=Program(namespace=ns)) + def correctly_suggest_most_simillart_command(self): + ns = Collection.from_module(load("integration")) + expected = """ +No idea what '{0}' is! +'{0}' is not an invoke command. See 'invoke --list'. + +The most similar command(s): + print-foo + +""".lstrip() + expect( + "print-fo", + err=expected.format("print-fo"), + program=Program(namespace=ns), + ) + def allows_explicit_task_module_specification(self): expect("-c integration print-foo", out="foo\n") From 1ca19049aad8bac073ffe981c01880bec385f434 Mon Sep 17 00:00:00 2001 From: Anton Topchii Date: Fri, 7 Mar 2025 20:42:51 +0100 Subject: [PATCH 4/4] Introduce more advanced method of commands comparison --- invoke/program.py | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/invoke/program.py b/invoke/program.py index be5413ef0..104aae799 100644 --- a/invoke/program.py +++ b/invoke/program.py @@ -999,13 +999,11 @@ def print_columns( print("") def _possible_commands_msg(self, unknown_cmd: str) -> str: - try: + all_tasks = {} + if hasattr(self, "scoped_collection"): all_tasks = self.scoped_collection.task_names - except AttributeError: - all_tasks = {} - possible_cmds = list(all_tasks.keys()) - suggestions = difflib.get_close_matches( + suggestions = _get_best_match( unknown_cmd, possible_cmds, n=3, cutoff=0.7 ) output_message = f"'{unknown_cmd}' is not an invoke command. " @@ -1017,3 +1015,35 @@ def _possible_commands_msg(self, unknown_cmd: str) -> str: else: output_message += "\nNo suggestions was found.\n" return output_message + + +def _get_best_match( + word: str, possibilities: List[str], n: int = 3, cutoff: float = 0.7 +) -> List[str]: + """Return a list of the top `n` best-matching commands for a given word. + + This function accounts for dot-separated commands by normalizing them— + splitting them into parts, sorting them alphabetically, and rejoining them. + This allows for matching commands that contain the same elements but in + different orders. + + For example, 'task1.task2' and 'task2.task1' will have a similarity score + of 0.98. + """ + normalized_unknown_cmd = ".".join(sorted(word.split("."))) + matches = [] + for cmd in possibilities: + normalized_cmd = ".".join(sorted(cmd.split("."))) + similarity_normalized = difflib.SequenceMatcher( + None, normalized_unknown_cmd, normalized_cmd + ).ratio() + similarity_raw = difflib.SequenceMatcher(None, word, cmd).ratio() + # The idea here is to decrease the similarity score if we have + # reordered the given word + similarity = max(similarity_normalized * 0.98, similarity_raw) + if similarity >= cutoff: + matches.append((similarity, cmd)) + + matches.sort(reverse=True) + + return [match[1] for match in matches[:n]]