Skip to content

Commit 659d4c4

Browse files
committed
Add command suggestions feature
1 parent 2a8f95f commit 659d4c4

2 files changed

Lines changed: 44 additions & 3 deletions

File tree

invoke/program.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import difflib
12
import getpass
23
import inspect
34
import json
@@ -86,6 +87,12 @@ def core_args(self) -> List["Argument"]:
8687
default=False,
8788
help="Echo executed commands before running.",
8889
),
90+
Argument(
91+
names=("suggestions", "s"),
92+
kind=bool,
93+
default=True,
94+
help="Show possible commands suggestions.",
95+
),
8996
Argument(
9097
names=("help", "h"),
9198
optional=True,
@@ -403,6 +410,11 @@ def run(self, argv: Optional[List[str]] = None, exit: bool = True) -> None:
403410
# problems.
404411
if isinstance(e, ParseError):
405412
print(e, file=sys.stderr)
413+
if self.args.suggestions.value:
414+
unrecognised_cmd = str(e).replace("No idea what '", "")
415+
unrecognised_cmd = unrecognised_cmd.replace("' is!", "")
416+
msg = self._possible_commands_msg(unrecognised_cmd)
417+
print(msg, file=sys.stderr)
406418
if isinstance(e, Exit) and e.message:
407419
print(e.message, file=sys.stderr)
408420
if isinstance(e, UnexpectedExit) and e.result.hide:
@@ -985,3 +997,23 @@ def print_columns(
985997
else:
986998
print(spec.rstrip())
987999
print("")
1000+
1001+
def _possible_commands_msg(self, unknown_cmd: str) -> str:
1002+
try:
1003+
all_tasks = self.scoped_collection.task_names
1004+
except AttributeError:
1005+
all_tasks = {}
1006+
1007+
possible_cmds = list(all_tasks.keys())
1008+
suggestions = difflib.get_close_matches(
1009+
unknown_cmd, possible_cmds, n=3, cutoff=0.7
1010+
)
1011+
output_message = f"'{unknown_cmd}' is not an invoke command. "
1012+
output_message += "See 'invoke --list'.\n"
1013+
if suggestions:
1014+
output_message += "\nThe most similar command(s):\n"
1015+
for cmd in suggestions:
1016+
output_message += f" {cmd}\n"
1017+
else:
1018+
output_message += "\nNo suggestions was found.\n"
1019+
return output_message

tests/program.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@
4141

4242

4343
pytestmark = pytest.mark.usefixtures("integration")
44+
no_idea_what_template = """
45+
No idea what '{0}' is!
46+
'{0}' is not an invoke command. See 'invoke --list'.
47+
48+
No suggestions was found.
49+
50+
""".lstrip()
4451

4552

4653
class Program_:
@@ -360,7 +367,7 @@ def seeks_and_loads_tasks_module_by_default(self):
360367
def does_not_seek_tasks_module_if_namespace_was_given(self):
361368
expect(
362369
"foo",
363-
err="No idea what 'foo' is!\n",
370+
err=no_idea_what_template.format("foo"),
364371
program=Program(namespace=Collection("blank")),
365372
)
366373

@@ -402,7 +409,7 @@ def ParseErrors_display_message_and_exit_1(self, mock_exit):
402409
# "no idea what foo is!") and exit 1. (Intent is to display that
403410
# info w/o a full traceback, basically.)
404411
stderr = sys.stderr.getvalue()
405-
assert stderr == "No idea what '{}' is!\n".format(nah)
412+
assert stderr == no_idea_what_template.format(nah)
406413
mock_exit.assert_called_with(1)
407414

408415
@trap
@@ -599,6 +606,7 @@ def core_help_option_prints_core_help(self):
599606
-r STRING, --search-root=STRING Change root directory used for finding
600607
task modules.
601608
-R, --dry Echo commands instead of running.
609+
-s, --[no-]suggestions Show possible commands suggestions.
602610
-T INT, --command-timeout=INT Specify a global command execution
603611
timeout, in seconds.
604612
-V, --version Show version and exit.
@@ -736,7 +744,8 @@ def exits_after_printing(self):
736744
expect("-c decorators -h punch --list", out=expected)
737745

738746
def complains_if_given_invalid_task_name(self):
739-
expect("-h this", err="No idea what 'this' is!\n")
747+
expected = no_idea_what_template.format("this")
748+
expect("-h this", err=expected)
740749

741750
class task_list:
742751
"--list"

0 commit comments

Comments
 (0)