diff --git a/InquirerPy/prompts/fuzzy.py b/InquirerPy/prompts/fuzzy.py index 2e2226e..4dbc9a1 100644 --- a/InquirerPy/prompts/fuzzy.py +++ b/InquirerPy/prompts/fuzzy.py @@ -4,6 +4,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple, Union, cast from pfzy import fuzzy_match +from pfzy.score import fzy_scorer, substr_scorer from pfzy.types import HAYSTACKS from prompt_toolkit.application.application import Application from prompt_toolkit.buffer import Buffer @@ -63,12 +64,14 @@ def __init__( session_result: Optional[InquirerPySessionResult], multiselect: bool, marker_pl: str, + match_exact: bool, ) -> None: self._pointer = pointer self._marker = marker self._marker_pl = marker_pl self._current_text = current_text self._max_lines = max_lines if max_lines > 0 else 1 + self._scorer = fzy_scorer if not match_exact else substr_scorer super().__init__( choices=choices, default=None, @@ -219,6 +222,7 @@ async def _filter_choices(self, wait_time: float) -> List[Dict[str, Any]]: self._current_text(), cast(HAYSTACKS, self.choices), key="name", + scorer=self._scorer, ) return choices @@ -287,7 +291,9 @@ class FuzzyPrompt(BaseListPrompt): Setting to True will also change the result from a single value to a list of values. prompt: Input prompt symbol. Custom symbol to display infront of the input buffer to indicate for input. border: Create border around the choice window. - info: Display choice information similar to fzf --info=inline next tot he prompt. + info: Display choice information similar to fzf --info=inline next to the prompt. + match_exact: Use exact sub-string match instead of using fzy fuzzy match algorithm. + exact_symbol: Custom symbol to display in the info section when `info=True`. marker: Marker Symbol. Custom symbol to indicate if a choice is selected. This will take effects when `multiselect` is True. marker_pl: Marker place holder when the choice is not selected. @@ -329,6 +335,8 @@ def __init__( marker_pl: str = " ", border: bool = False, info: bool = True, + match_exact: bool = False, + exact_symbol: str = " E", height: Union[str, int] = None, max_height: Union[str, int] = None, validate: InquirerPyValidate = None, @@ -347,11 +355,13 @@ def __init__( self._info = info self._task = None self._rendered = False + self._exact_symbol = exact_symbol keybindings = { "up": [{"key": "up"}, {"key": "c-p"}], "down": [{"key": "down"}, {"key": "c-n"}], "toggle": [], + "toggle-exact": [], **keybindings, } super().__init__( @@ -376,6 +386,7 @@ def __init__( mandatory_message=mandatory_message, session_result=session_result, ) + self.kb_func_lookup = {"toggle-exact": [{"func": self._toggle_exact}]} self._default = ( default if not isinstance(default, Callable) @@ -395,6 +406,7 @@ def __init__( session_result=session_result, multiselect=multiselect, marker_pl=marker_pl, + match_exact=match_exact, ) self._buffer = Buffer(on_text_changed=self._on_text_changed) @@ -470,6 +482,23 @@ def __init__( after_render=self._after_render, ) + def _toggle_exact(self, _, value: bool = None) -> None: + """Toggle matching algorithm. + + Switch between fzy fuzzy match or sub-string exact match. + + Args: + value: Specify the value to toggle. + """ + if value is not None: + self.content_control._scorer = fzy_scorer if not value else substr_scorer + else: + self.content_control._scorer = ( + fzy_scorer + if self.content_control._scorer == substr_scorer + else substr_scorer + ) + def _on_rendered(self, _) -> None: """Render callable choices and set the buffer default text. @@ -503,17 +532,15 @@ def _generate_after_input(self) -> List[Tuple[str, str]]: display_message.append( ( "class:fuzzy_info", - "%s/%s" - % ( - self.content_control.choice_count, - len(self.content_control.choices), - ), + f"{self.content_control.choice_count}/{len(self.content_control.choices)}", ) ) if self._multiselect: display_message.append( - ("class:fuzzy_info", " (%s)" % len(self.selected_choices)) + ("class:fuzzy_info", f" ({len(self.selected_choices)})") ) + if self.content_control._scorer == substr_scorer: + display_message.append(("class:fuzzy_info", self._exact_symbol)) return display_message def _generate_before_input(self) -> List[Tuple[str, str]]: diff --git a/docs/pages/prompts/fuzzy.md b/docs/pages/prompts/fuzzy.md index 2dfb242..2959bd9 100644 --- a/docs/pages/prompts/fuzzy.md +++ b/docs/pages/prompts/fuzzy.md @@ -88,6 +88,48 @@ choices = [ ] ``` +## Exact Sub-String Match + +This prompt uses the [fzy](https://github.com/jhawthorn/fzy) fuzzy match algorithm by default. You can enable exact sub-string match +by using the parameter `match_exact`. + +
+ Classic Syntax (PyInquirer) + +```{code-block} python +from InquirerPy import prompt + +questions = [ + { + "type": "fuzzy", + "message": "Select actions:", + "choices": ["hello", "weather", "what", "whoa", "hey", "yo"], + "match_exact": True, + "exact_symbol": " E", # indicator of exact match + }, +] + +result = prompt(questions=questions) +``` + +
+ +
+ Alternate Syntax + +```{code-block} python +from InquirerPy import inquirer + +result = inquirer.fuzzy( + message="Select actions:", + choices=["hello", "weather", "what", "whoa", "hey", "yo"], + match_exact=True, + exact_symbol=" E", # indicator of exact match +).execute() +``` + +
+ ## Reference ```{eval-rst} diff --git a/tests/prompts/test_fuzzy.py b/tests/prompts/test_fuzzy.py index f4526b7..8a19e17 100644 --- a/tests/prompts/test_fuzzy.py +++ b/tests/prompts/test_fuzzy.py @@ -3,6 +3,7 @@ from typing import Callable, NamedTuple from unittest.mock import ANY, MagicMock, call, patch +from pfzy.score import fzy_scorer, substr_scorer from prompt_toolkit.application.application import Application from prompt_toolkit.buffer import Buffer from prompt_toolkit.layout.layout import Layout @@ -27,6 +28,7 @@ class TestFuzzy(unittest.TestCase): session_result=None, multiselect=False, marker_pl=" ", + match_exact=False, ) @patch("InquirerPy.utils.shutil.get_terminal_size") @@ -179,6 +181,7 @@ def test_prompt_filter1(self): session_result=None, multiselect=False, marker_pl=" ", + match_exact=False, ) self.assertEqual( content_control._filtered_choices, @@ -292,6 +295,7 @@ def test_prompt_filter2(self): session_result=None, multiselect=False, marker_pl=" ", + match_exact=False, ) content_control.choices[0]["indices"] = [1, 2, 3] asyncio.run(content_control._filter_choices(0.0)) @@ -381,6 +385,7 @@ def test_constructor(self, mocked_term, mocked_height, mocked_control): session_result=None, multiselect=False, marker_pl=" ", + match_exact=False, ) prompt = FuzzyPrompt( @@ -411,6 +416,7 @@ def test_constructor(self, mocked_term, mocked_height, mocked_control): session_result=None, multiselect=False, marker_pl=" ", + match_exact=False, ) def test_prompt_after_input(self): @@ -428,6 +434,15 @@ def test_prompt_after_input(self): ) self.assertEqual(prompt._generate_after_input(), []) + def test_prompt_after_input2(self): + prompt = FuzzyPrompt( + message="", choices=["1", "2", "3"], match_exact=True, exact_symbol=" *" + ) + self.assertEqual( + prompt._generate_after_input(), + [("", " "), ("class:fuzzy_info", "3/3"), ("class:fuzzy_info", " *")], + ) + def test_prompt_before_input(self): prompt = FuzzyPrompt( message="Select one of them", @@ -872,6 +887,7 @@ def test_control_line_render(self) -> None: session_result=None, multiselect=True, marker_pl=" ", + match_exact=False, ) # range 0-4 self.assertEqual(control._last_line, 4) @@ -895,3 +911,80 @@ def test_control_line_render(self) -> None: control._get_formatted_choices() self.assertEqual(control._last_line, 6) self.assertEqual(control._first_line, 2) + + def test_control_exact_match(self) -> None: + content_control = InquirerPyFuzzyControl( + choices=["meat", "what", "whaaah", "weather", "haha"], + pointer=INQUIRERPY_POINTER_SEQUENCE, + marker=INQUIRERPY_POINTER_SEQUENCE, + current_text=lambda: "aa", + max_lines=80, + session_result=None, + multiselect=False, + marker_pl=" ", + match_exact=True, + ) + self.assertEqual( + content_control._filtered_choices, + [ + { + "enabled": False, + "index": 0, + "indices": [], + "name": "meat", + "value": "meat", + }, + { + "enabled": False, + "index": 1, + "indices": [], + "name": "what", + "value": "what", + }, + { + "enabled": False, + "index": 2, + "indices": [], + "name": "whaaah", + "value": "whaaah", + }, + { + "enabled": False, + "index": 3, + "indices": [], + "name": "weather", + "value": "weather", + }, + { + "enabled": False, + "index": 4, + "indices": [], + "name": "haha", + "value": "haha", + }, + ], + ) + result = asyncio.run(content_control._filter_choices(0.0)) + self.assertEqual( + result, + [ + { + "enabled": False, + "index": 2, + "indices": [2, 3], + "name": "whaaah", + "value": "whaaah", + } + ], + ) + + def test_toggle_exact(self): + self.assertEqual(self.prompt.content_control._scorer, fzy_scorer) + self.prompt._toggle_exact(None) + self.assertEqual(self.prompt.content_control._scorer, substr_scorer) + self.prompt._toggle_exact(None) + self.assertEqual(self.prompt.content_control._scorer, fzy_scorer) + self.prompt._toggle_exact(None, True) + self.assertEqual(self.prompt.content_control._scorer, substr_scorer) + self.prompt._toggle_exact(None, False) + self.assertEqual(self.prompt.content_control._scorer, fzy_scorer)