Skip to content

Commit db87f12

Browse files
federico-e-martinezanselor
authored andcommitted
Added support to show the most similar command
1 parent a55bb1e commit db87f12

File tree

6 files changed

+98
-1
lines changed

6 files changed

+98
-1
lines changed

cmd2/cmd2.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@
141141
Settable,
142142
get_defining_class,
143143
strip_doc_annotations,
144+
suggest_similar,
144145
)
145146

146147
# Set up readline
@@ -236,6 +237,7 @@ def __init__(
236237
command_sets: Optional[Iterable[CommandSet]] = None,
237238
auto_load_commands: bool = True,
238239
allow_clipboard: bool = True,
240+
suggest_similar_command: bool = False,
239241
) -> None:
240242
"""An easy but powerful framework for writing line-oriented command
241243
interpreters. Extends Python's cmd package.
@@ -530,6 +532,9 @@ def __init__(
530532
# Add functions decorated to be subcommands
531533
self._register_subcommands(self)
532534

535+
self.suggest_similar_command = suggest_similar_command
536+
self.default_suggestion_message = "Did you mean {}?"
537+
533538
def find_commandsets(self, commandset_type: Type[CommandSet], *, subclass_match: bool = False) -> List[CommandSet]:
534539
"""
535540
Find all CommandSets that match the provided CommandSet type.
@@ -2968,11 +2973,17 @@ def default(self, statement: Statement) -> Optional[bool]: # type: ignore[overr
29682973
return self.do_shell(statement.command_and_args)
29692974
else:
29702975
err_msg = self.default_error.format(statement.command)
2971-
2976+
if self.suggest_similar_command:
2977+
suggested_command = self._suggest_similar_command(statement.command)
2978+
if suggested_command:
2979+
err_msg = err_msg + ' ' + self.default_suggestion_message.format(suggested_command)
29722980
# Set apply_style to False so default_error's style is not overridden
29732981
self.perror(err_msg, apply_style=False)
29742982
return None
29752983

2984+
def _suggest_similar_command(self, command: str) -> Optional[str]:
2985+
return suggest_similar(command, self.get_visible_commands())
2986+
29762987
def read_input(
29772988
self,
29782989
prompt: str,

cmd2/utils.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import sys
1313
import threading
1414
import unicodedata
15+
from difflib import SequenceMatcher
1516
from enum import (
1617
Enum,
1718
)
@@ -1262,3 +1263,36 @@ def strip_doc_annotations(doc: str) -> str:
12621263
elif found_first:
12631264
break
12641265
return cmd_desc
1266+
1267+
1268+
def similarity_function(s1: str, s2: str) -> float:
1269+
# The ratio from s1,s2 may be different to s2,s1. We keep the max.
1270+
# See https://docs.python.org/3/library/difflib.html#difflib.SequenceMatcher.ratio
1271+
return max(SequenceMatcher(None, s1, s2).ratio(), SequenceMatcher(None, s2, s1).ratio())
1272+
1273+
1274+
MIN_SIMIL_TO_CONSIDER = 0.7
1275+
1276+
1277+
def suggest_similar(requested_command: str, options: Iterable[str],
1278+
similarity_function_to_use: Optional[Callable[[str, str], float]] = None) -> Optional[str]:
1279+
"""
1280+
Given a requested command and an iterable of possible options
1281+
returns the most similar (if any is similar)
1282+
1283+
:param requested_command: The command entered by the user
1284+
:param options: The list of avaiable commands to search for the most similar
1285+
:param similarity_function_to_use: An optional callable to use to compare commands
1286+
:returns The most similar command or None if no one is similar
1287+
1288+
"""
1289+
proposed_command = None
1290+
best_simil = MIN_SIMIL_TO_CONSIDER
1291+
requested_command_to_compare = requested_command.lower()
1292+
similarity_function_to_use = similarity_function_to_use or similarity_function
1293+
for each in options:
1294+
simil = similarity_function_to_use(each.lower(), requested_command_to_compare)
1295+
if best_simil < simil:
1296+
best_simil = simil
1297+
proposed_command = each
1298+
return proposed_command

docs/api/cmd.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,9 @@ cmd2.Cmd
7272
the operating system pasteboard. If ``False``, this capability will not
7373
be allowed. See :ref:`features/clipboard:Clipboard Integration` for more
7474
information.
75+
76+
.. attribute:: suggest_similar_command
77+
78+
If ``True``, ``cmd2`` will suggest the most similar command when the user
79+
types a command that does not exist.
80+
Default: ``False``.

docs/api/utils.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,5 @@ Miscellaneous
9797
.. autofunction:: cmd2.utils.alphabetical_sort
9898

9999
.. autofunction:: cmd2.utils.natural_sort
100+
101+
.. autofunction:: cmd2.utils.suggest_similar

tests/test_cmd2.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,16 @@ def test_base_error(base_app):
323323
assert "is not a recognized command" in err[0]
324324

325325

326+
def test_base_error_suggest_command(base_app):
327+
try:
328+
old_suggest_similar_command = base_app.suggest_similar_command
329+
base_app.suggest_similar_command = True
330+
out, err = run_cmd(base_app, 'historic')
331+
assert "history" in err[0]
332+
finally:
333+
base_app.suggest_similar_command = old_suggest_similar_command
334+
335+
326336
def test_run_script(base_app, request):
327337
test_dir = os.path.dirname(request.module.__file__)
328338
filename = os.path.join(test_dir, 'script.txt')

tests/test_utils.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -872,3 +872,37 @@ def test_find_editor_not_specified():
872872
with mock.patch.dict(os.environ, {'PATH': 'fake_dir'}, clear=True):
873873
editor = cu.find_editor()
874874
assert editor is None
875+
876+
877+
def test_similarity():
878+
suggested_command = cu.suggest_similar("comand", ["command", "UNRELATED", "NOT_SIMILAR"])
879+
assert suggested_command == "command"
880+
suggested_command = cu.suggest_similar("command", ["COMMAND", "acommands"])
881+
assert suggested_command == "COMMAND"
882+
883+
884+
def test_similarity_without_good_canididates():
885+
suggested_command = cu.suggest_similar("comand", ["UNRELATED", "NOT_SIMILAR"])
886+
assert suggested_command is None
887+
suggested_command = cu.suggest_similar("comand", [])
888+
assert suggested_command is None
889+
890+
891+
def test_similarity_overwrite_function():
892+
suggested_command = cu.suggest_similar("test", ["history", "test"])
893+
assert suggested_command == 'test'
894+
895+
def custom_similarity_function(s1, s2):
896+
return 1.0 if 'history' in (s1, s2) else 0.0
897+
898+
suggested_command = cu.suggest_similar("test", ["history", "test"],
899+
similarity_function_to_use=custom_similarity_function)
900+
assert suggested_command == 'history'
901+
902+
suggested_command = cu.suggest_similar("history", ["history", "test"],
903+
similarity_function_to_use=custom_similarity_function)
904+
assert suggested_command == 'history'
905+
906+
suggested_command = cu.suggest_similar("test", ["test"],
907+
similarity_function_to_use=custom_similarity_function)
908+
assert suggested_command is None

0 commit comments

Comments
 (0)