From f0b05805af162310d05dcda29bf073ccb4510415 Mon Sep 17 00:00:00 2001 From: Federico Martinez Date: Sat, 29 Jul 2023 13:19:20 -0300 Subject: [PATCH 1/5] Added support to show the most similar command --- cmd2/cmd2.py | 13 ++++++++++++- cmd2/utils.py | 34 ++++++++++++++++++++++++++++++++++ docs/api/cmd.rst | 6 ++++++ docs/api/utils.rst | 2 ++ tests/test_cmd2.py | 10 ++++++++++ tests/test_utils.py | 34 ++++++++++++++++++++++++++++++++++ 6 files changed, 98 insertions(+), 1 deletion(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index d60e7668..7cb1b9cc 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -141,6 +141,7 @@ Settable, get_defining_class, strip_doc_annotations, + suggest_similar, ) # Set up readline @@ -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. @@ -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. @@ -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, diff --git a/cmd2/utils.py b/cmd2/utils.py index f5b91b99..89575517 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -12,6 +12,7 @@ import sys import threading import unicodedata +from difflib import SequenceMatcher from enum import ( Enum, ) @@ -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 diff --git a/docs/api/cmd.rst b/docs/api/cmd.rst index f9bd07c6..4b8cdca6 100644 --- a/docs/api/cmd.rst +++ b/docs/api/cmd.rst @@ -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``. diff --git a/docs/api/utils.rst b/docs/api/utils.rst index 0fc11b50..754db1a1 100644 --- a/docs/api/utils.rst +++ b/docs/api/utils.rst @@ -97,3 +97,5 @@ Miscellaneous .. autofunction:: cmd2.utils.alphabetical_sort .. autofunction:: cmd2.utils.natural_sort + +.. autofunction:: cmd2.utils.suggest_similar diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index e5d7db64..054eef6e 100755 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -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') diff --git a/tests/test_utils.py b/tests/test_utils.py index 806cfc7d..2f127660 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -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 From 49cdd858266e9bb7e99b755ff03eb1b0278f362a Mon Sep 17 00:00:00 2001 From: Federico Martinez Date: Wed, 2 Aug 2023 11:34:34 -0300 Subject: [PATCH 2/5] changes based on formatter and doc jobs --- cmd2/utils.py | 5 +++-- docs/api/utils.rst | 1 + tests/test_utils.py | 12 +++++------- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/cmd2/utils.py b/cmd2/utils.py index 89575517..3b6e2208 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -1274,8 +1274,9 @@ def similarity_function(s1: str, s2: str) -> float: 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]: +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) diff --git a/docs/api/utils.rst b/docs/api/utils.rst index 754db1a1..82644cbb 100644 --- a/docs/api/utils.rst +++ b/docs/api/utils.rst @@ -99,3 +99,4 @@ Miscellaneous .. autofunction:: cmd2.utils.natural_sort .. autofunction:: cmd2.utils.suggest_similar + diff --git a/tests/test_utils.py b/tests/test_utils.py index 2f127660..e8198d4b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -889,20 +889,18 @@ def test_similarity_without_good_canididates(): def test_similarity_overwrite_function(): - suggested_command = cu.suggest_similar("test", ["history", "test"]) + options = ["history", "test"] + suggested_command = cu.suggest_similar("test", options) 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) + suggested_command = cu.suggest_similar("test", options, 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) + suggested_command = cu.suggest_similar("history", options, 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) + suggested_command = cu.suggest_similar("test", ["test"], similarity_function_to_use=custom_similarity_function) assert suggested_command is None From a58a992c4ee089c6c8a6f63a680abaf15405496e Mon Sep 17 00:00:00 2001 From: Federico Martinez Date: Wed, 2 Aug 2023 11:42:22 -0300 Subject: [PATCH 3/5] more changes to comply with formatting --- cmd2/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cmd2/utils.py b/cmd2/utils.py index 3b6e2208..fb06cc31 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -1275,7 +1275,7 @@ def similarity_function(s1: str, s2: str) -> float: def suggest_similar( - requested_command: str, options: Iterable[str], similarity_function_to_use: Optional[Callable[[str, str], float]] = None + 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 @@ -1285,7 +1285,6 @@ def suggest_similar( :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 From fc11ad2a862ac959335ed0a3e01110e745b2b0df Mon Sep 17 00:00:00 2001 From: Federico Martinez Date: Wed, 2 Aug 2023 12:08:04 -0300 Subject: [PATCH 4/5] changes to make sphinx work and ordered the imports --- cmd2/utils.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/cmd2/utils.py b/cmd2/utils.py index fb06cc31..b29649a1 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -12,7 +12,9 @@ import sys import threading import unicodedata -from difflib import SequenceMatcher +from difflib import ( + SequenceMatcher, +) from enum import ( Enum, ) @@ -1278,14 +1280,14 @@ 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) + 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 options: The list of available 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 + :return: 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() From 9968437acbd5d5ba96e4ab7449b01b783f0e8a72 Mon Sep 17 00:00:00 2001 From: Federico Martinez Date: Thu, 3 Aug 2023 10:10:58 -0300 Subject: [PATCH 5/5] added noqa: E721 to prevent error in linter job --- cmd2/cmd2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 7cb1b9cc..f1e6c847 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -547,7 +547,7 @@ def find_commandsets(self, commandset_type: Type[CommandSet], *, subclass_match: return [ cmdset for cmdset in self._installed_command_sets - if type(cmdset) == commandset_type or (subclass_match and isinstance(cmdset, commandset_type)) + if type(cmdset) == commandset_type or (subclass_match and isinstance(cmdset, commandset_type)) # noqa: E721 ] def find_commandset_for_command(self, command_name: str) -> Optional[CommandSet]: @@ -5551,7 +5551,7 @@ def _resolve_func_self( func_self = None candidate_sets: List[CommandSet] = [] for installed_cmd_set in self._installed_command_sets: - if type(installed_cmd_set) == func_class: + if type(installed_cmd_set) == func_class: # noqa: E721 # Case 2: CommandSet is an exact type match for the function's CommandSet func_self = installed_cmd_set break