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

Added support for exact match in fuzzy prompt #34 #35

Merged
merged 6 commits into from Jan 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
41 changes: 34 additions & 7 deletions InquirerPy/prompts/fuzzy.py
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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__(
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]]:
Expand Down
42 changes: 42 additions & 0 deletions docs/pages/prompts/fuzzy.md
Expand Up @@ -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`.

<details>
<summary>Classic Syntax (PyInquirer)</summary>

```{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)
```

</details>

<details open>
<summary>Alternate Syntax</summary>

```{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()
```

</details>

## Reference

```{eval-rst}
Expand Down
93 changes: 93 additions & 0 deletions tests/prompts/test_fuzzy.py
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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):
Expand All @@ -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",
Expand Down Expand Up @@ -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)
Expand All @@ -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)