Skip to content
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

Suggest most simillar command #1272

Merged
merged 5 commits into from
Aug 4, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
13 changes: 12 additions & 1 deletion cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@
Settable,
get_defining_class,
strip_doc_annotations,
suggest_similar,
)

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

self.suggest_similar_command = suggest_similar_command
self.default_suggestion_message = "Did you mean {}?"

def find_commandsets(self, commandset_type: Type[CommandSet], *, subclass_match: bool = False) -> List[CommandSet]:
"""
Find all CommandSets that match the provided CommandSet type.
Expand Down Expand Up @@ -2968,11 +2973,17 @@ def default(self, statement: Statement) -> Optional[bool]: # type: ignore[overr
return self.do_shell(statement.command_and_args)
else:
err_msg = self.default_error.format(statement.command)

if self.suggest_similar_command:
suggested_command = self._suggest_similar_command(statement.command)
if suggested_command:
err_msg = err_msg + ' ' + self.default_suggestion_message.format(suggested_command)
# Set apply_style to False so default_error's style is not overridden
self.perror(err_msg, apply_style=False)
return None

def _suggest_similar_command(self, command: str) -> Optional[str]:
return suggest_similar(command, self.get_visible_commands())

def read_input(
self,
prompt: str,
Expand Down
34 changes: 34 additions & 0 deletions cmd2/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import sys
import threading
import unicodedata
from difflib import SequenceMatcher
from enum import (
Enum,
)
Expand Down Expand Up @@ -1262,3 +1263,36 @@ def strip_doc_annotations(doc: str) -> str:
elif found_first:
break
return cmd_desc


def similarity_function(s1: str, s2: str) -> float:
# The ratio from s1,s2 may be different to s2,s1. We keep the max.
# See https://docs.python.org/3/library/difflib.html#difflib.SequenceMatcher.ratio
return max(SequenceMatcher(None, s1, s2).ratio(), SequenceMatcher(None, s2, s1).ratio())


MIN_SIMIL_TO_CONSIDER = 0.7


def suggest_similar(requested_command: str, options: Iterable[str],
similarity_function_to_use: Optional[Callable[[str, str], float]] = None) -> Optional[str]:
"""
Given a requested command and an iterable of possible options
returns the most similar (if any is similar)

:param requested_command: The command entered by the user
:param options: The list of avaiable commands to search for the most similar
:param similarity_function_to_use: An optional callable to use to compare commands
:returns The most similar command or None if no one is similar

"""
proposed_command = None
best_simil = MIN_SIMIL_TO_CONSIDER
requested_command_to_compare = requested_command.lower()
similarity_function_to_use = similarity_function_to_use or similarity_function
for each in options:
simil = similarity_function_to_use(each.lower(), requested_command_to_compare)
if best_simil < simil:
best_simil = simil
proposed_command = each
return proposed_command
6 changes: 6 additions & 0 deletions docs/api/cmd.rst
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,9 @@ cmd2.Cmd
the operating system pasteboard. If ``False``, this capability will not
be allowed. See :ref:`features/clipboard:Clipboard Integration` for more
information.

.. attribute:: suggest_similar_command

If ``True``, ``cmd2`` will suggest the most similar command when the user
types a command that does not exist.
Default: ``False``.
2 changes: 2 additions & 0 deletions docs/api/utils.rst
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,5 @@ Miscellaneous
.. autofunction:: cmd2.utils.alphabetical_sort

.. autofunction:: cmd2.utils.natural_sort

.. autofunction:: cmd2.utils.suggest_similar
10 changes: 10 additions & 0 deletions tests/test_cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,16 @@ def test_base_error(base_app):
assert "is not a recognized command" in err[0]


def test_base_error_suggest_command(base_app):
try:
old_suggest_similar_command = base_app.suggest_similar_command
base_app.suggest_similar_command = True
out, err = run_cmd(base_app, 'historic')
assert "history" in err[0]
finally:
base_app.suggest_similar_command = old_suggest_similar_command


def test_run_script(base_app, request):
test_dir = os.path.dirname(request.module.__file__)
filename = os.path.join(test_dir, 'script.txt')
Expand Down
34 changes: 34 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -872,3 +872,37 @@ def test_find_editor_not_specified():
with mock.patch.dict(os.environ, {'PATH': 'fake_dir'}, clear=True):
editor = cu.find_editor()
assert editor is None


def test_similarity():
suggested_command = cu.suggest_similar("comand", ["command", "UNRELATED", "NOT_SIMILAR"])
assert suggested_command == "command"
suggested_command = cu.suggest_similar("command", ["COMMAND", "acommands"])
assert suggested_command == "COMMAND"


def test_similarity_without_good_canididates():
suggested_command = cu.suggest_similar("comand", ["UNRELATED", "NOT_SIMILAR"])
assert suggested_command is None
suggested_command = cu.suggest_similar("comand", [])
assert suggested_command is None


def test_similarity_overwrite_function():
suggested_command = cu.suggest_similar("test", ["history", "test"])
assert suggested_command == 'test'

def custom_similarity_function(s1, s2):
return 1.0 if 'history' in (s1, s2) else 0.0

suggested_command = cu.suggest_similar("test", ["history", "test"],
similarity_function_to_use=custom_similarity_function)
assert suggested_command == 'history'

suggested_command = cu.suggest_similar("history", ["history", "test"],
similarity_function_to_use=custom_similarity_function)
assert suggested_command == 'history'

suggested_command = cu.suggest_similar("test", ["test"],
similarity_function_to_use=custom_similarity_function)
assert suggested_command is None
Loading