Skip to content

Commit 16343f0

Browse files
Feature/fuzzy suggestions (#64)
* Added feature to display possible incorrect word in command line * get close matches when command is correct but arguments are misspelled * Refactor and added tests * Do not print help message when incorrect words found * Added tests * Fix tests and format error message * Change in test * Refactor: remove unused import and doc changes * Fix: initialization and docs * Added changelog * Refactor * Fix: Argument flag with underscore while displaying suggestions
1 parent 583986f commit 16343f0

File tree

3 files changed

+111
-2
lines changed

3 files changed

+111
-2
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta
2020

2121
### Added
2222

23+
- Display, `Fuzzy suggestions`, valid keywords matching mistyped commands or arguments.
24+
2325
- `code42 alerts`:
2426
- Ability to search/poll for alerts with checkpointing using one of the following commands:
2527
- `print` to output to stdout.

src/code42cli/invoker.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
import sys
22

3+
import difflib
4+
35
from py42.exceptions import Py42HTTPError, Py42ForbiddenError
46

57
from code42cli.compat import str
68
from code42cli.errors import Code42CLIError
79
from code42cli.parser import ArgumentParserError, CommandParser
810
from code42cli.logger import get_main_cli_logger
911

12+
_DIFFLIB_CUT_OFF = 0.7
13+
1014

1115
class CommandInvoker(object):
16+
17+
_COMMAND_KEYWORDS = {}
18+
_COMMAND_ARG_KEYWORDS = {}
19+
1220
def __init__(self, top_command, cmd_parser=None):
1321
self._top_command = top_command
1422
self._cmd_parser = cmd_parser or CommandParser()
@@ -72,6 +80,7 @@ def _load_subcommands(self, path, node):
7280
for command in node.subcommands:
7381
new_key = u"{} {}".format(path, command.name).strip()
7482
self._commands[new_key] = command
83+
self._set_command_keywords(new_key)
7584

7685
def _try_run_command(self, command, path_parts, input_args):
7786
"""Runs a command called using `path_parts` by parsing
@@ -82,9 +91,65 @@ def _try_run_command(self, command, path_parts, input_args):
8291
parser = self._cmd_parser.prepare_cli_help(command)
8392
else:
8493
parser = self._cmd_parser.prepare_command(command, path_parts)
94+
self._set_argument_keywords(path_parts[0], command.get_arg_configs())
8595
parsed_args = self._cmd_parser.parse_args(input_args)
8696
parsed_args.func(parsed_args)
8797
except ArgumentParserError as err:
88-
get_main_cli_logger().log_error(err)
89-
parser.print_help(sys.stderr)
98+
logger = get_main_cli_logger()
99+
logger.print_and_log_error(u"{}".format(err))
100+
possible_correct_words = self._find_incorrect_word_match(err, path_parts)
101+
if possible_correct_words:
102+
logger.print_and_log_error(u"Did you mean one of the following?")
103+
for possible_correct_word in possible_correct_words:
104+
logger.print_info(u" {}".format(possible_correct_word))
105+
106+
else:
107+
parser.print_help(sys.stderr)
90108
sys.exit(2)
109+
110+
@staticmethod
111+
def _get_arg_flags(arguments):
112+
flag_names = []
113+
for arg in arguments.values():
114+
arg_flags = [name for name in arg.settings["options_list"] if name.startswith("-")]
115+
flag_names.extend(arg_flags)
116+
return flag_names
117+
118+
def _set_argument_keywords(self, command_key, arguments):
119+
self._COMMAND_ARG_KEYWORDS[command_key] = set()
120+
self._COMMAND_ARG_KEYWORDS[command_key].update(CommandInvoker._get_arg_flags(arguments))
121+
122+
def _set_command_keywords(self, new_key):
123+
"""Creates a dictionary, with top level command as key and set of all its subcommands
124+
as values.
125+
"""
126+
command_keys = new_key.split()
127+
if len(command_keys) == 1:
128+
self._COMMAND_KEYWORDS[command_keys[0]] = set()
129+
else:
130+
self._COMMAND_KEYWORDS[command_keys[0]].update(command_keys[1:])
131+
132+
def _find_incorrect_word_match(self, error, path_parts):
133+
possible_correct_words = []
134+
135+
try:
136+
# Here we assume the error string contains ":", for case where it doesn't we
137+
# assume the error is not due to misspelled word and we return error as is.
138+
error_detail, unmatched_words = str(error).split(u":")
139+
except ValueError:
140+
return possible_correct_words
141+
142+
if not unmatched_words or error_detail != u"unrecognized arguments":
143+
return possible_correct_words
144+
145+
# Arg-parser sets the first/leftmost incorrect command keyword in the error message.
146+
unmatched_word = unmatched_words.split()[0]
147+
148+
if not path_parts:
149+
available_values = self._COMMAND_KEYWORDS.keys()
150+
elif unmatched_word.strip().startswith('-'):
151+
available_values = self._COMMAND_ARG_KEYWORDS[path_parts[0]]
152+
else:
153+
available_values = self._COMMAND_KEYWORDS[path_parts[0]]
154+
155+
return difflib.get_close_matches(unmatched_word, available_values, cutoff=_DIFFLIB_CUT_OFF)

tests/test_invoker.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from code42cli.errors import Code42CLIError
1111
from code42cli.invoker import CommandInvoker
1212
from code42cli.parser import ArgumentParserError, CommandParser
13+
from code42cli.cmds import profile
14+
from code42cli.cmds.securitydata import main as secmain
1315

1416

1517
def dummy_method(one, two, three=None):
@@ -28,6 +30,19 @@ def load_sub_subcommands():
2830
return [Command("inner1", "the innerdesc1", handler=dummy_method)]
2931

3032

33+
def load_real_sub_commands():
34+
return [
35+
Command(
36+
u"profile", u"", subcommand_loader=profile.load_subcommands
37+
),
38+
Command(
39+
u"security-data",
40+
u"",
41+
subcommand_loader=secmain.load_subcommands,
42+
)
43+
]
44+
45+
3146
@pytest.fixture
3247
def mock_parser(mocker):
3348
return mocker.MagicMock(spec=CommandParser)
@@ -146,3 +161,30 @@ def test_run_when_cli_error_occurs_logs_request(self, mocker, mock_parser, caplo
146161
with caplog.at_level(logging.ERROR):
147162
invoker.run(["testsub1", "inner1", "one", "two", "--invalid", "test"])
148163
assert "a code42cli error" in caplog.text
164+
165+
def test_run_incorrect_command_suggests_proper_sub_commands(self, caplog):
166+
command = Command(u"", u"", subcommand_loader=load_real_sub_commands)
167+
cmd_invoker = CommandInvoker(command)
168+
with pytest.raises(SystemExit):
169+
cmd_invoker.run([u"profile", u"crate"])
170+
with caplog.at_level(logging.ERROR):
171+
assert u"Did you mean one of the following?" in caplog.text
172+
assert u"create" in caplog.text
173+
174+
def test_run_incorrect_command_suggests_proper_main_commands(self, caplog):
175+
command = Command(u"", u"", subcommand_loader=load_real_sub_commands)
176+
cmd_invoker = CommandInvoker(command)
177+
with pytest.raises(SystemExit):
178+
cmd_invoker.run([u"prfile", u"crate"])
179+
with caplog.at_level(logging.ERROR):
180+
assert u"Did you mean one of the following?" in caplog.text
181+
assert u"profile" in caplog.text
182+
183+
def test_run_incorrect_command_suggests_proper_argument_name(self, caplog):
184+
command = Command(u"", u"", subcommand_loader=load_real_sub_commands)
185+
cmd_invoker = CommandInvoker(command)
186+
with pytest.raises(SystemExit):
187+
cmd_invoker.run([u"security-data", u"write-to", u"abc", u"--filename"])
188+
with caplog.at_level(logging.ERROR):
189+
assert u"Did you mean one of the following?" in caplog.text
190+
assert u"--file-name" in caplog.text

0 commit comments

Comments
 (0)