Skip to content

Commit 05ec09b

Browse files
committed
feat: Add account filtering in aws configure sso
Signed-off-by: frauniki <[email protected]>
1 parent 9f13a1d commit 05ec09b

File tree

4 files changed

+586
-12
lines changed

4 files changed

+586
-12
lines changed

awscli/customizations/configure/sso.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,10 +439,12 @@ def _handle_single_account(self, accounts):
439439
def _handle_multiple_accounts(self, accounts):
440440
available_accounts_msg = (
441441
'There are {} AWS accounts available to you.\n'
442+
'Use arrow keys to navigate, type to filter, and press Enter to select.\n'
442443
)
443444
uni_print(available_accounts_msg.format(len(accounts)))
444445
selected_account = self._selector(
445-
accounts, display_format=display_account
446+
accounts, display_format=display_account, enable_filter=True,
447+
no_results_message='No matching accounts found'
446448
)
447449
sso_account_id = selected_account['accountId']
448450
return sso_account_id

awscli/customizations/wizard/ui/selectmenu.py

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
from prompt_toolkit.utils import get_cwidth
2424

2525

26-
def select_menu(items, display_format=None, max_height=10):
26+
def select_menu(
27+
items, display_format=None, max_height=10, enable_filter=False
28+
):
2729
"""Presents a list of options and allows the user to select one.
2830
2931
This presents a static list of options and prompts the user to select one.
@@ -42,6 +44,9 @@ def select_menu(items, display_format=None, max_height=10):
4244
:type max_height: int
4345
:param max_height: The max number of items to show in the list at a time.
4446
47+
:type enable_filter: bool
48+
:param enable_filter: Enable keyboard filtering of items.
49+
4550
:returns: The selected element from the items list.
4651
"""
4752
app_bindings = KeyBindings()
@@ -51,8 +56,19 @@ def exit_app(event):
5156
event.app.exit(exception=KeyboardInterrupt, style='class:aborting')
5257

5358
min_height = min(max_height, len(items))
59+
if enable_filter:
60+
# Add 1 to height for filter line
61+
min_height = min(max_height + 1, len(items) + 1)
62+
menu_control = FilterableSelectionMenuControl(
63+
items, display_format=display_format
64+
)
65+
else:
66+
menu_control = SelectionMenuControl(
67+
items, display_format=display_format
68+
)
69+
5470
menu_window = Window(
55-
SelectionMenuControl(items, display_format=display_format),
71+
menu_control,
5672
always_hide_cursor=False,
5773
height=Dimension(min=min_height, max=min_height),
5874
scroll_offsets=ScrollOffsets(),
@@ -122,6 +138,8 @@ def is_focusable(self):
122138

123139
def preferred_width(self, max_width):
124140
items = self._get_items()
141+
if not items:
142+
return self.MIN_WIDTH
125143
if self._display_format:
126144
items = (self._display_format(i) for i in items)
127145
max_item_width = max(get_cwidth(i) for i in items)
@@ -188,6 +206,157 @@ def app_result(event):
188206
return kb
189207

190208

209+
class FilterableSelectionMenuControl(SelectionMenuControl):
210+
"""Menu that supports keyboard filtering of items"""
211+
212+
def __init__(self, items, display_format=None, cursor='>', no_results_message=None):
213+
super().__init__(items, display_format=display_format, cursor=cursor)
214+
self._filter_text = ''
215+
self._filtered_items = items if items else []
216+
self._all_items = items if items else []
217+
self._filter_enabled = True
218+
self._no_results_message = no_results_message or 'No matching items found'
219+
220+
def _get_items(self):
221+
if callable(self._all_items):
222+
self._all_items = self._all_items()
223+
return self._filtered_items
224+
225+
def preferred_width(self, max_width):
226+
# Ensure minimum width for search display
227+
min_search_width = max(20, len("Search: " + self._filter_text) + 5)
228+
229+
# Get width from filtered items
230+
items = self._filtered_items
231+
if not items:
232+
# Width for no results message
233+
no_results_width = get_cwidth(self._no_results_message) + 4
234+
return max(no_results_width, min_search_width)
235+
236+
if self._display_format:
237+
items_display = [self._display_format(i) for i in items]
238+
else:
239+
items_display = [str(i) for i in items]
240+
241+
if items_display:
242+
max_item_width = max(get_cwidth(i) for i in items_display)
243+
max_item_width += self._format_overhead
244+
else:
245+
max_item_width = self.MIN_WIDTH
246+
247+
max_item_width = max(max_item_width, min_search_width)
248+
249+
if max_item_width < self.MIN_WIDTH:
250+
max_item_width = self.MIN_WIDTH
251+
return min(max_width, max_item_width)
252+
253+
def _update_filtered_items(self):
254+
"""Update the filtered items based on the current filter text"""
255+
if not self._filter_text:
256+
self._filtered_items = self._all_items
257+
else:
258+
filter_lower = self._filter_text.lower()
259+
self._filtered_items = [
260+
item
261+
for item in self._all_items
262+
if filter_lower
263+
in (
264+
self._display_format(item)
265+
if self._display_format
266+
else str(item)
267+
).lower()
268+
]
269+
270+
# Reset selection if it's out of bounds
271+
if self._selection >= len(self._filtered_items):
272+
self._selection = 0
273+
274+
def preferred_height(self, width, max_height, wrap_lines, get_line_prefix):
275+
# Add 1 extra line for the filter display
276+
return min(max_height, len(self._get_items()) + 1)
277+
278+
def create_content(self, width, height):
279+
def get_line(i):
280+
# First line shows the filter
281+
if i == 0:
282+
filter_display = (
283+
f"Search: {self._filter_text}_"
284+
if self._filter_enabled
285+
else f"Search: {self._filter_text}"
286+
)
287+
return [('class:filter', filter_display)]
288+
289+
# Show "No results" message if filtered items is empty
290+
if not self._filtered_items:
291+
if i == 1:
292+
return [
293+
('class:no-results', f' {self._no_results_message}')
294+
]
295+
return [('', '')]
296+
297+
# Adjust for the filter line
298+
item_index = i - 1
299+
if item_index >= len(self._filtered_items):
300+
return [('', '')]
301+
302+
item = self._filtered_items[item_index]
303+
is_selected = item_index == self._selection
304+
return self._menu_item_fragment(item, is_selected, width)
305+
306+
# Ensure at least 2 lines (search + no results or items)
307+
line_count = max(2, len(self._filtered_items) + 1)
308+
cursor_y = self._selection + 1 if self._filtered_items else 0
309+
310+
return UIContent(
311+
get_line=get_line,
312+
cursor_position=Point(x=0, y=cursor_y),
313+
line_count=line_count,
314+
)
315+
316+
def get_key_bindings(self):
317+
kb = KeyBindings()
318+
319+
@kb.add('up')
320+
def move_up(event):
321+
if len(self._filtered_items) > 0:
322+
self._move_cursor(-1)
323+
324+
@kb.add('down')
325+
def move_down(event):
326+
if len(self._filtered_items) > 0:
327+
self._move_cursor(1)
328+
329+
@kb.add('enter')
330+
def app_result(event):
331+
if len(self._filtered_items) > 0:
332+
result = self._filtered_items[self._selection]
333+
event.app.exit(result=result)
334+
335+
@kb.add('backspace')
336+
def delete_char(event):
337+
if self._filter_text:
338+
self._filter_text = self._filter_text[:-1]
339+
self._update_filtered_items()
340+
341+
@kb.add('c-u')
342+
def clear_filter(event):
343+
self._filter_text = ''
344+
self._update_filtered_items()
345+
346+
# Add support for typing any character
347+
from string import printable
348+
349+
for char in printable:
350+
if char not in ('\n', '\r', '\t'):
351+
352+
@kb.add(char)
353+
def add_char(event, c=char):
354+
self._filter_text += c
355+
self._update_filtered_items()
356+
357+
return kb
358+
359+
191360
class CollapsableSelectionMenuControl(SelectionMenuControl):
192361
"""Menu that collapses to text with selection when loses focus"""
193362

tests/unit/customizations/configure/test_sso.py

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -747,6 +747,8 @@ class PTKStubber:
747747
_ALLOWED_SELECT_MENU_KWARGS = {
748748
"display_format",
749749
"max_height",
750+
"enable_filter",
751+
"no_results_message",
750752
}
751753

752754
def __init__(self, user_inputs=None):
@@ -763,9 +765,9 @@ def prompt(self, message, **kwargs):
763765
f'Received prompt with no stubbed answer: "{message}"'
764766
)
765767
prompt = self._expected_inputs.pop(0)
766-
assert isinstance(
767-
prompt, Prompt
768-
), f'Did not receive user input of type Prompt for: "{message}"'
768+
assert isinstance(prompt, Prompt), (
769+
f'Did not receive user input of type Prompt for: "{message}"'
770+
)
769771
if prompt.expected_message is not None:
770772
assert message == prompt.expected_message, (
771773
f"Prompt does not match expected "
@@ -792,9 +794,9 @@ def select_menu(self, items, **kwargs):
792794
f'Received select_menu with no stubbed answer: "{items}"'
793795
)
794796
select_menu = self._expected_inputs.pop(0)
795-
assert isinstance(
796-
select_menu, SelectMenu
797-
), f'Did not receive user input of type SelectMenu for: "{items}"'
797+
assert isinstance(select_menu, SelectMenu), (
798+
f'Did not receive user input of type SelectMenu for: "{items}"'
799+
)
798800
if select_menu.expected_choices is not None:
799801
assert items == select_menu.expected_choices, (
800802
f"Choices does not match expected select_menu choices "
@@ -807,9 +809,9 @@ def _initialize_expected_inputs_if_needed(self):
807809
self._expected_inputs = self.user_inputs.get_expected_inputs()
808810

809811
def _validate_kwargs(self, provided_kwargs, allowed_kwargs):
810-
assert set(provided_kwargs).issubset(
811-
allowed_kwargs
812-
), "Arguments provided does not matched allowed keyword arguments"
812+
assert set(provided_kwargs).issubset(allowed_kwargs), (
813+
"Arguments provided does not matched allowed keyword arguments"
814+
)
813815

814816

815817
def write_aws_config(aws_config, lines):
@@ -1484,6 +1486,66 @@ def test_configure_sso_suggests_values_from_sessions(
14841486
sso_cmd = sso_cmd_factory(session=session)
14851487
assert sso_cmd(args, parsed_globals) == 0
14861488

1489+
def test_multiple_accounts_uses_filterable_menu(
1490+
self,
1491+
sso_cmd,
1492+
ptk_stubber,
1493+
aws_config,
1494+
stub_sso_list_roles,
1495+
stub_sso_list_accounts,
1496+
mock_do_sso_login,
1497+
botocore_session,
1498+
args,
1499+
parsed_globals,
1500+
configure_sso_legacy_inputs,
1501+
capsys,
1502+
):
1503+
"""Test that multiple accounts selection shows filter instructions"""
1504+
inputs = configure_sso_legacy_inputs
1505+
selected_account_id = inputs.account_id_select.answer["accountId"]
1506+
ptk_stubber.user_inputs = inputs
1507+
1508+
stub_sso_list_accounts(inputs.account_id_select.expected_choices)
1509+
stub_sso_list_roles(
1510+
inputs.role_name_select.expected_choices,
1511+
expected_account_id=selected_account_id,
1512+
)
1513+
1514+
sso_cmd(args, parsed_globals)
1515+
1516+
# Verify the filter instructions are shown for multiple accounts
1517+
stdout = capsys.readouterr().out
1518+
assert "Use arrow keys to navigate, type to filter" in stdout
1519+
assert "There are 2 AWS accounts available to you" in stdout
1520+
1521+
def test_single_account_does_not_use_filterable_menu(
1522+
self,
1523+
sso_cmd,
1524+
ptk_stubber,
1525+
aws_config,
1526+
stub_simple_single_item_sso_responses,
1527+
mock_do_sso_login,
1528+
botocore_session,
1529+
args,
1530+
parsed_globals,
1531+
configure_sso_legacy_inputs,
1532+
account_id,
1533+
role_name,
1534+
capsys,
1535+
):
1536+
"""Test that single account does not show filter instructions"""
1537+
inputs = configure_sso_legacy_inputs
1538+
inputs.skip_account_and_role_selection()
1539+
ptk_stubber.user_inputs = inputs
1540+
stub_simple_single_item_sso_responses(account_id, role_name)
1541+
1542+
sso_cmd(args, parsed_globals)
1543+
1544+
# Verify the filter instructions are NOT shown for single account
1545+
stdout = capsys.readouterr().out
1546+
assert "Use arrow keys to navigate, type to filter" not in stdout
1547+
assert "The only AWS account available to you is" in stdout
1548+
14871549
def test_single_account_single_role_device_code_fallback(
14881550
self,
14891551
sso_cmd,

0 commit comments

Comments
 (0)