Skip to content

Add command suggestions feature #1022

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
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
62 changes: 62 additions & 0 deletions invoke/program.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import difflib
import getpass
import inspect
import json
Expand Down Expand Up @@ -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,
Copy link
Author

@krepysh krepysh Mar 7, 2025

Choose a reason for hiding this comment

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

Is it possible to default here to True?

If someone wanted to disable new feature, it is possible with --no-suggestions flag I guess.

help="Show possible commands suggestions.",
),
Argument(
names=("help", "h"),
optional=True,
Expand Down Expand Up @@ -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 "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)
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:
Expand Down Expand Up @@ -985,3 +997,53 @@ def print_columns(
else:
print(spec.rstrip())
print("")

def _possible_commands_msg(self, unknown_cmd: str) -> str:
all_tasks = {}
if hasattr(self, "scoped_collection"):
all_tasks = self.scoped_collection.task_names
possible_cmds = list(all_tasks.keys())
suggestions = _get_best_match(
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


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]]
31 changes: 28 additions & 3 deletions tests/program.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_:
Expand Down Expand Up @@ -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")),
)

Expand All @@ -369,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")

Expand Down Expand Up @@ -402,7 +425,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
Expand Down Expand Up @@ -599,6 +622,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.
Expand Down Expand Up @@ -736,7 +760,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"
Expand Down