Fix index out of range for third menu

This commit is contained in:
Digital Studium 2024-05-06 13:26:08 +03:00
parent 25dd5420d1
commit 03bc2bc96d
2 changed files with 80 additions and 33 deletions

71
kls
View File

@ -1,6 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import subprocess, curses, time import subprocess, curses, time
# constants
KEY_BINDINGS = { # can be extended KEY_BINDINGS = { # can be extended
"1": 'kubectl -n {namespace} get {api_resource} {resource} -o yaml | batcat -l yaml --paging always --style numbers', "1": 'kubectl -n {namespace} get {api_resource} {resource} -o yaml | batcat -l yaml --paging always --style numbers',
"\n": 'kubectl -n {namespace} get {api_resource} {resource} -o yaml | batcat -l yaml --paging always --style numbers', # Enter key "\n": 'kubectl -n {namespace} get {api_resource} {resource} -o yaml | batcat -l yaml --paging always --style numbers', # Enter key
@ -14,11 +15,8 @@ KEY_BINDINGS = { # can be extended
# which api resources are on the top of menu? # which api resources are on the top of menu?
TOP_API_RESOURCES = ["pods", "services", "configmaps", "secrets", "persistentvolumeclaims", "ingresses", "nodes", TOP_API_RESOURCES = ["pods", "services", "configmaps", "secrets", "persistentvolumeclaims", "ingresses", "nodes",
"deployments", "statefulsets", "daemonsets", "storageclasses", "all"] "deployments", "statefulsets", "daemonsets", "storageclasses", "all"]
HELP_TEXT = "letters: filter mode, Esc: exit filter mode or exit kls, 1/Enter: get yaml, 2: describe, 3: edit, 4: logs, 5: exec, 6: debug, arrows/TAB/PgUp/PgDn: navigation" HELP_TEXT = "letters: filter mode, Esc: exit filter mode or exit kls, 1/Enter: get yaml, 2: describe, 3: edit, 4: logs, 5: exec, 6: debug, arrows/TAB/PgUp/PgDn: navigation"
MOUSE_ENABLED = True MOUSE_ENABLED = True
SCREEN = curses.initscr() # screen initialization, needed for ROWS_HEIGHT working SCREEN = curses.initscr() # screen initialization, needed for ROWS_HEIGHT working
HEADER_HEIGHT = 4 # in rows HEADER_HEIGHT = 4 # in rows
FOOTER_HEIGHT = 3 FOOTER_HEIGHT = 3
@ -26,6 +24,7 @@ ROWS_HEIGHT = curses.LINES - HEADER_HEIGHT - FOOTER_HEIGHT - 3 # maximum numbe
WIDTH = curses.COLS WIDTH = curses.COLS
# classes
class CircularList: class CircularList:
def __init__(self, elements): def __init__(self, elements):
self.elements = elements self.elements = elements
@ -56,6 +55,7 @@ class Menu:
self.win = curses.newwin(curses.LINES - FOOTER_HEIGHT, width, 0, begin_x) self.win = curses.newwin(curses.LINES - FOOTER_HEIGHT, width, 0, begin_x)
# helper functions
def draw_row(window: curses.window, text: str, y: int, x: int, selected: bool = False): def draw_row(window: curses.window, text: str, y: int, x: int, selected: bool = False):
window.addstr(y, x, text, curses.A_REVERSE | curses.A_BOLD if selected else curses.A_NORMAL) window.addstr(y, x, text, curses.A_REVERSE | curses.A_BOLD if selected else curses.A_NORMAL)
window.clrtoeol() window.clrtoeol()
@ -87,7 +87,9 @@ def refresh_third_menu():
draw_menu(menu) draw_menu(menu)
def run_command(key: str, api_resource: str, resource: str): def handle_key_bindings(key: str, api_resource: str, resource: str):
if not menus[2].selected_row():
return
if key in ("4", "5", "6"): if key in ("4", "5", "6"):
if api_resource not in ["pods", "all"] or (api_resource == "all" and not resource.startswith("pod/")): if api_resource not in ["pods", "all"] or (api_resource == "all" and not resource.startswith("pod/")):
return return
@ -99,12 +101,15 @@ def run_command(key: str, api_resource: str, resource: str):
subprocess.call(command, shell=True) subprocess.call(command, shell=True)
curses.reset_prog_mode() # restore the previous terminal state curses.reset_prog_mode() # restore the previous terminal state
SCREEN.refresh() SCREEN.refresh()
curses.mousemask(curses.REPORT_MOUSE_POSITION) # mouse tracking enable_mouse_support()
print('\033[?1003h') # enable mouse tracking with the XTERM API. That's the magic
def handle_filter_state(key: str, menu: Menu): def handle_filter_state(key: str, menu: Menu):
if key == "\x1b": if key in ["KEY_BACKSPACE", "\x08"] and not menu.filter:
return
elif key == "\x1b" and not menu.filter:
globals().update(selected_menu=None) # exit
elif key == "\x1b":
menu.filter = "" # Escape key exits filter mode menu.filter = "" # Escape key exits filter mode
elif key in ["KEY_BACKSPACE", "\x08"]: elif key in ["KEY_BACKSPACE", "\x08"]:
menu.filter = menu.filter[:-1] # Backspace key deletes a character (\x08 is also Backspace) menu.filter = menu.filter[:-1] # Backspace key deletes a character (\x08 is also Backspace)
@ -118,6 +123,8 @@ def handle_filter_state(key: str, menu: Menu):
def handle_mouse(menu: Menu): def handle_mouse(menu: Menu):
if not MOUSE_ENABLED:
return
try: try:
mouse_info = curses.getmouse() mouse_info = curses.getmouse()
except curses.error: # this fixes scrolling error except curses.error: # this fixes scrolling error
@ -153,21 +160,29 @@ def handle_mouse(menu: Menu):
def handle_vertical_navigation(key: str, menu: Menu): def handle_vertical_navigation(key: str, menu: Menu):
if len(menu.visible_rows()) <= 1: if len(menu.visible_rows()) <= 1:
return return
shifts = {"KEY_DOWN": 1, "KEY_UP": -1, "KEY_NPAGE": 1, "KEY_PPAGE": -1, 'KEY_HOME': 0, 'KEY_END': -1} keys_numbers = {"KEY_DOWN": 1, "KEY_UP": -1, "KEY_NPAGE": 1, "KEY_PPAGE": -1, 'KEY_HOME': 0, 'KEY_END': -1}
if key in ["KEY_DOWN", "KEY_UP"]: if key in ["KEY_DOWN", "KEY_UP"]:
if menu.filtered_rows.size > menu.rows_height: if menu.filtered_rows.size > menu.rows_height:
menu.filtered_rows.shift(shifts[key]) menu.filtered_rows.shift(keys_numbers[key])
else: else:
menu.visible_row_index = (menu.visible_row_index + shifts[key]) % menu.filtered_rows.size # index of the selected visible row menu.visible_row_index = (menu.visible_row_index + keys_numbers[key]) % menu.filtered_rows.size # index of the selected visible row
elif key in ["KEY_NPAGE", "KEY_PPAGE"]: elif key in ["KEY_NPAGE", "KEY_PPAGE"]:
menu.filtered_rows.shift(shifts[key] * len(menu.visible_rows())) menu.filtered_rows.shift(keys_numbers[key] * len(menu.visible_rows()))
elif key in ['KEY_HOME','KEY_END']: elif key in ['KEY_HOME','KEY_END']:
menu.visible_row_index = shifts[key] menu.visible_row_index = keys_numbers[key]
draw_rows(menu) draw_rows(menu)
if menu != menus[2]: if menu != menus[2]:
menus[2].visible_row_index = 0 menus[2].visible_row_index = 0
def handle_horizontal_navigation(key, menu):
increment = {"KEY_RIGHT": 1, "\t": 1, "KEY_LEFT": -1, "KEY_BTAB": -1}[key]
next_menu = menus[(menus.index(menu) + increment) % 3]
draw_row(menu.win, menu.title, 1, 2, selected=False) # remove selection from the current menu title
draw_row(next_menu.win, next_menu.title, 1, 2, selected=True) # and select the new menu title
globals().update(selected_menu=next_menu)
def catch_input(menu: Menu): def catch_input(menu: Menu):
while True: # refresh third menu until key pressed while True: # refresh third menu until key pressed
try: try:
@ -177,20 +192,14 @@ def catch_input(menu: Menu):
refresh_third_menu() refresh_third_menu()
time.sleep(0.1) time.sleep(0.1)
if key in ["\t", "KEY_RIGHT", "KEY_BTAB", "KEY_LEFT"]: if key in ["\t", "KEY_RIGHT", "KEY_BTAB", "KEY_LEFT"]:
increment = {"KEY_RIGHT": 1, "\t": 1, "KEY_LEFT": -1, "KEY_BTAB": -1}[key] handle_horizontal_navigation(key, menu)
next_menu = menus[(menus.index(menu) + increment) % 3]
draw_row(menu.win, menu.title, 1, 2, selected=False) # remove selection from the current menu title
draw_row(next_menu.win, next_menu.title, 1, 2, selected=True) # and select the new menu title
globals().update(selected_menu=next_menu)
elif key in ["KEY_UP", "KEY_DOWN", "KEY_NPAGE", "KEY_PPAGE", "KEY_HOME", "KEY_END"]: elif key in ["KEY_UP", "KEY_DOWN", "KEY_NPAGE", "KEY_PPAGE", "KEY_HOME", "KEY_END"]:
handle_vertical_navigation(key, menu) handle_vertical_navigation(key, menu)
elif key == "KEY_MOUSE" and MOUSE_ENABLED: elif key == "KEY_MOUSE":
handle_mouse(menu) handle_mouse(menu)
elif key in KEY_BINDINGS.keys() and menus[2].selected_row(): elif key in KEY_BINDINGS.keys():
run_command(key, api_resource(), resource()) handle_key_bindings(key, api_resource(), resource())
elif key == "\x1b" and not menu.filter: elif key in ["\x1b", "KEY_BACKSPACE", "\x08"] or key.isalpha() or key == "-": # \x1b - escape, \x08 - backspace
globals().update(selected_menu=None) # exit
elif (key in ["\x1b", "KEY_BACKSPACE", "\x08"] and menu.filter) or key.isalpha() or key == "-":
handle_filter_state(key, menu) handle_filter_state(key, menu)
@ -198,7 +207,12 @@ def kubectl(command: str) -> list:
return subprocess.check_output(f"kubectl {command}", shell=True).decode().strip().split("\n") return subprocess.check_output(f"kubectl {command}", shell=True).decode().strip().split("\n")
def main(screen): def enable_mouse_support():
curses.mousemask(curses.REPORT_MOUSE_POSITION) # mouse tracking
print('\033[?1003h') # enable mouse tracking with the XTERM API. That's the magic
def init_menus():
global menus, selected_menu, namespace, api_resource, resource global menus, selected_menu, namespace, api_resource, resource
api_resources_kubectl = [x.split()[0] for x in kubectl("api-resources --no-headers --verbs=get")] api_resources_kubectl = [x.split()[0] for x in kubectl("api-resources --no-headers --verbs=get")]
api_resources = list( api_resources = list(
@ -210,7 +224,7 @@ def main(screen):
selected_menu = menus[0] selected_menu = menus[0]
namespace = menus[0].selected_row # method alias namespace = menus[0].selected_row # method alias
api_resource = menus[1].selected_row api_resource = menus[1].selected_row
resource = lambda: menus[2].selected_row().split()[0] resource = lambda: menus[2].selected_row().split()[0] if menus[2].selected_row() else None
SCREEN.refresh() # I don't know why this is needed but it doesn't work without it SCREEN.refresh() # I don't know why this is needed but it doesn't work without it
SCREEN.nodelay(True) # don't block while waiting for input SCREEN.nodelay(True) # don't block while waiting for input
SCREEN.keypad(True) # needed for arrow keys SCREEN.keypad(True) # needed for arrow keys
@ -218,8 +232,11 @@ def main(screen):
curses.curs_set(0) # make the cursor invisible curses.curs_set(0) # make the cursor invisible
curses.use_default_colors() # don't change the terminal color curses.use_default_colors() # don't change the terminal color
curses.noecho() # don't output characters at the top curses.noecho() # don't output characters at the top
curses.mousemask(curses.REPORT_MOUSE_POSITION) # mouse tracking enable_mouse_support()
print('\033[?1003h') # enable mouse tracking with the XTERM API. That's the magic
def main(screen):
init_menus()
for menu in menus: # draw the main windows for menu in menus: # draw the main windows
draw_menu(menu) draw_menu(menu)
draw_row(curses.newwin(3, curses.COLS, curses.LINES - FOOTER_HEIGHT, 0), HELP_TEXT, 1, 2) # and the help window draw_row(curses.newwin(3, curses.COLS, curses.LINES - FOOTER_HEIGHT, 0), HELP_TEXT, 1, 2) # and the help window

42
test.py
View File

@ -4,12 +4,12 @@ import os
os.system("ln -s kls kls.py") os.system("ln -s kls kls.py")
from kls import * # Import the functions and classes from kls script import kls
class TestCircularList(unittest.TestCase): class TestCircularList(unittest.TestCase):
def setUp(self): def setUp(self):
self.circular_list = CircularList(['a', 'b', 'c']) self.circular_list = kls.CircularList(['a', 'b', 'c'])
def test_forward(self): def test_forward(self):
self.circular_list.shift(1) self.circular_list.shift(1)
@ -24,14 +24,16 @@ class TestScriptFunctions(unittest.TestCase):
@patch('kls.subprocess.check_output') @patch('kls.subprocess.check_output')
def test_kubectl(self, mock_check_output): def test_kubectl(self, mock_check_output):
mock_check_output.return_value = b'pod1\npod2\npod3' mock_check_output.return_value = b'pod1\npod2\npod3'
result = kubectl('get pods') result = kls.kubectl('get pods')
self.assertEqual(result, ['pod1', 'pod2', 'pod3']) self.assertEqual(result, ['pod1', 'pod2', 'pod3'])
class TestMenu(unittest.TestCase): class TestMenu(unittest.TestCase):
def setUp(self): def setUp(self):
self.rows = ['a', 'b', 'c'] self.rows = ['a', 'b', 'c']
self.menu = Menu('Test', self.rows, 0, 10, 2) self.second_menu = kls.Menu("Test Menu 2", ["option1", "option2", "option3"], 0, 10, 2)
self.third_menu = kls.Menu("Test Menu 3", ["option1", "option2", "option3"], 0, 10, 2)
self.menu = kls.Menu('Test', self.rows, 0, 10, 2)
os.system("ln -s kls kls.py") os.system("ln -s kls kls.py")
def test_menu(self): def test_menu(self):
@ -43,15 +45,43 @@ class TestMenu(unittest.TestCase):
def test_filter_rows_with_filter(self): def test_filter_rows_with_filter(self):
# Apply a filter and test # Apply a filter and test
self.menu.filter = 'a' self.menu.filter = 'a'
self.menu.filtered_rows = CircularList([x for x in self.menu.rows if self.menu.filter in x]) self.menu.filtered_rows = kls.CircularList([x for x in self.menu.rows if self.menu.filter in x])
self.assertEqual(self.menu.filtered_rows.elements, ['a']) self.assertEqual(self.menu.filtered_rows.elements, ['a'])
def test_filter_rows_with_nonexistent_filter(self): def test_filter_rows_with_nonexistent_filter(self):
# Apply a filter that matches no rows # Apply a filter that matches no rows
self.menu.filter = 'nonexistent' self.menu.filter = 'nonexistent'
self.menu.filtered_rows = CircularList([x for x in self.menu.rows if self.menu.filter in x]) self.menu.filtered_rows = kls.CircularList([x for x in self.menu.rows if self.menu.filter in x])
self.assertEqual(self.menu.filtered_rows.elements, []) self.assertEqual(self.menu.filtered_rows.elements, [])
def test_vertical_navigation(self):
kls.selected_menu = self.menu
# global menus
kls.menus = [self.menu, self.second_menu, self.third_menu] # Add the menu to the list of menus
# Test moving down one row
kls.handle_vertical_navigation("KEY_DOWN", self.menu)
self.assertEqual(self.menu.visible_row_index, 0)
# Test moving up one row
kls.handle_vertical_navigation("KEY_UP", self.menu)
self.assertEqual(self.menu.visible_row_index, 0)
# Test moving to the next page
kls.handle_vertical_navigation("KEY_NPAGE", self.menu)
self.assertEqual(self.menu.visible_row_index, 0)
# Test moving to the previous page
kls.handle_vertical_navigation("KEY_PPAGE", self.menu)
self.assertEqual(self.menu.visible_row_index, 0)
# Test moving to the first row
kls.handle_vertical_navigation("KEY_HOME", self.menu)
self.assertEqual(self.menu.visible_row_index, 0)
# Test moving to the last row
kls.handle_vertical_navigation("KEY_END", self.menu)
self.assertEqual(self.menu.visible_row_index, -1)
def tearDown(self): def tearDown(self):
# Remove the symlink after each test # Remove the symlink after each test
os.unlink('kls.py') os.unlink('kls.py')