Skip to content

Commit

Permalink
Merge pull request #35 from kazhala/feat/fuzzy-exact
Browse files Browse the repository at this point in the history
Added support for exact match in fuzzy prompt #34
  • Loading branch information
kazhala committed Jan 28, 2022
2 parents 832f980 + 35c879e commit cff1116
Show file tree
Hide file tree
Showing 3 changed files with 169 additions and 7 deletions.
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)

0 comments on commit cff1116

Please sign in to comment.