Skip to content

Commit c719f89

Browse files
committed
Implemented a FuzzySelect interaction
1 parent 11d06de commit c719f89

File tree

2 files changed

+121
-7
lines changed

2 files changed

+121
-7
lines changed

awsshell/interaction.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@
22
import sys
33
import jmespath
44

5-
from six import with_metaclass
65
from abc import ABCMeta, abstractmethod
6+
from six import with_metaclass, string_types
77

88
from prompt_toolkit import prompt
9+
from prompt_toolkit.completion import Completer, Completion
10+
from prompt_toolkit.contrib.validators.base import SentenceValidator
911
from prompt_toolkit.contrib.completers import PathCompleter
12+
1013
from awsshell.utils import FSLayer, FileReadError
1114
from awsshell.selectmenu import select_prompt
15+
from awsshell.fuzzy import fuzzy_search
1216

1317

1418
class InteractionException(Exception):
@@ -108,13 +112,69 @@ def execute(self, data):
108112
return data
109113

110114

115+
class FuzzyCompleter(Completer):
116+
"""Filters the completion list by doing a fuzzy search with the input."""
117+
118+
def __init__(self, corpus, meta_dict={}):
119+
self.corpus = list(corpus)
120+
self.meta_dict = meta_dict
121+
assert all(isinstance(w, string_types) for w in self.corpus)
122+
123+
def get_completions(self, document, complete_event):
124+
text = document.text_before_cursor
125+
if len(text) == 0:
126+
matches = self.corpus
127+
else:
128+
matches = fuzzy_search(text, self.corpus)
129+
for match in matches:
130+
display_meta = self.meta_dict.get(match)
131+
yield Completion(match, -len(text), display_meta=display_meta)
132+
133+
134+
class FuzzySelect(Interaction):
135+
"""Typing will apply a case senstive fuzzy filter to the options.
136+
137+
Show completions based on the given list of options, allowing the user to
138+
type to begin filtering the options with a fuzzy search. The prompt will
139+
also validate that the input is from the list and will reject all other
140+
inputs.
141+
"""
142+
143+
def __init__(self, model, prompt_msg, prompter=prompt):
144+
super(FuzzySelect, self).__init__(model, prompt_msg)
145+
self._prompter = prompter
146+
self._validator_opts = {
147+
'move_cursor_to_end': True,
148+
'error_message': 'Invalid Selection: Must choose from the list'
149+
}
150+
151+
def execute(self, data):
152+
if not isinstance(data, list) or len(data) < 1:
153+
raise InteractionException('FuzzySelect expects a non-empty list')
154+
if self._model.get('Path') is not None:
155+
# This will not handle duplicate strings as options
156+
display_data = jmespath.search(self._model['Path'], data)
157+
option_dict = dict(zip(display_data, data))
158+
completer = FuzzyCompleter(display_data)
159+
validator = SentenceValidator(display_data, **self._validator_opts)
160+
selection = self._prompter(self.prompt, completer=completer,
161+
validator=validator)
162+
return option_dict[selection]
163+
else:
164+
completer = FuzzyCompleter(data)
165+
validator = SentenceValidator(data, **self._validator_opts)
166+
return self._prompter(self.prompt, completer=completer,
167+
validator=validator)
168+
169+
111170
class InteractionLoader(object):
112171
"""An interaction loader. Create interactions based on their name.
113172
114173
The class will maintain a dict of ScreenType to Interaction object so
115174
Interaction objects can be instantiated from their corresponding str.
116175
"""
117176
_INTERACTIONS = {
177+
'FuzzySelect': FuzzySelect,
118178
'SimpleSelect': SimpleSelect,
119179
'SimplePrompt': SimplePrompt,
120180
'FilePrompt': FilePrompt

tests/unit/test_interaction.py

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
import pytest
33

44
from awsshell.utils import InMemoryFSLayer
5+
from prompt_toolkit.buffer import Document
6+
from prompt_toolkit.contrib.validators.base import Validator, ValidationError
57
from awsshell.interaction import InteractionLoader, InteractionException
68
from awsshell.interaction import SimpleSelect, SimplePrompt, FilePrompt
9+
from awsshell.interaction import FuzzyCompleter, FuzzySelect
710

811

912
@pytest.fixture
@@ -98,14 +101,15 @@ def test_simple_select_with_path():
98101
assert xformed == options[1]
99102

100103

101-
def test_simple_select_bad_data(simple_selector):
102-
# Test that simple select throws exceptions when given bad data
104+
@pytest.mark.parametrize('selector', [simple_selector(), FuzzySelect({}, u'')])
105+
def test_simple_select_bad_data(selector):
106+
# Test that a selector select throws exceptions when given bad data
103107
with pytest.raises(InteractionException) as ie:
104-
simple_selector.execute({})
105-
assert 'SimpleSelect expects a non-empty list' in str(ie.value)
108+
selector.execute({})
109+
assert 'expects a non-empty list' in str(ie.value)
106110
with pytest.raises(InteractionException) as ie:
107-
simple_selector.execute([])
108-
assert 'SimpleSelect expects a non-empty list' in str(ie.value)
111+
selector.execute([])
112+
assert 'expects a non-empty list' in str(ie.value)
109113

110114

111115
def test_simple_prompt():
@@ -126,3 +130,53 @@ def test_simple_prompt_bad_data(simple_prompt):
126130
with pytest.raises(InteractionException) as ie:
127131
simple_prompt.execute([])
128132
assert 'SimplePrompt expects a dict as data' in str(ie.value)
133+
134+
135+
def test_fuzzy_completer():
136+
def to_list(candidates):
137+
return [c.text for c in candidates]
138+
139+
corpus = ['A word', 'Awo', 'A b c']
140+
document = Document(text=u'')
141+
completer = FuzzyCompleter(corpus)
142+
candidates = completer.get_completions(document, None)
143+
assert to_list(candidates) == corpus
144+
document = Document(text=u'Awo')
145+
candidates = completer.get_completions(document, None)
146+
assert to_list(candidates) == ['Awo', 'A word']
147+
148+
149+
def test_fuzzy_select():
150+
# Verify that SimpleSelect calls prompt and it returns a selection
151+
prompt = mock.Mock()
152+
selector = FuzzySelect({}, 'one or two?', prompt)
153+
options = ['one', 'two']
154+
prompt.return_value = options[1]
155+
xformed = selector.execute(options)
156+
assert prompt.call_count == 1
157+
assert xformed == options[1]
158+
args, kwargs = prompt.call_args
159+
validator = kwargs['validator']
160+
assert isinstance(kwargs['validator'], Validator)
161+
with pytest.raises(ValidationError):
162+
document = Document(text=u'three')
163+
validator.validate(document)
164+
165+
166+
def test_fuzzy_select_with_path():
167+
# Verify that FuzzySelect calls prompt and it returns the corresponding
168+
# item derived from the path.
169+
prompt = mock.Mock()
170+
model = {'Path': '[].a'}
171+
fuzzy_selector = FuzzySelect(model, 'Promptingu', prompt)
172+
options = [{'a': '1', 'b': 'one'}, {'a': '2', 'b': 'two'}]
173+
prompt.return_value = '2'
174+
xformed = fuzzy_selector.execute(options)
175+
assert prompt.call_count == 1
176+
assert xformed == options[1]
177+
args, kwargs = prompt.call_args
178+
validator = kwargs['validator']
179+
assert isinstance(kwargs['validator'], Validator)
180+
with pytest.raises(ValidationError):
181+
document = Document(text=u'3')
182+
validator.validate(document)

0 commit comments

Comments
 (0)