I have been working on a Python Curses terminal user interface for RSync for a little while now. I recently visited code review and recieved some help with cleaning up the code to make it more pythonic. I worked for a bit to break up classes, simplify functions, etc. Now, I'm returning to see if my code is more complicated than it needs to be. Since I'm learning while programing, I'm trying to achieve loose coupling and high cohesion where it makes sense, but am also well.. learning while programming. During this adventure it seems some areas have become more simple and more reusable however, some areas of the code I feel like I have to inject multiple variables or instances to make something work.
The main function of the code sets up some instances to create left and right file manager panels, then allows for tabbing between the panels.
#!/usr/bin/env python3.6
import os
import curses
import curses.textpad
import curses.panel
import constants as CONST
from pathlib import Path
from options_menu import OptionsMenu
from ssh import SSH
from window_manager import WinManager
from file_explorer import FileExplorer
from reset_window import ResetWindow
from menu_bar import MenuBar
from status_bar import StatusBar
from key_press import KeyPress
from display import Display
def prepare_curses():
"""Setup terminal for curses operations."""
stdscr.erase()
stdscr.keypad(True)
curses.noecho()
curses.cbreak()
curses.curs_set(0)
curses.start_color()
curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLACK)
curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_WHITE)
curses.init_pair(3, curses.COLOR_CYAN, curses.COLOR_BLACK)
curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_CYAN)
stdscr.bkgdset(curses.color_pair(3))
def end_curses():
"""Gracefully reset terminal to normal settings."""
stdscr.keypad(0)
curses.echo()
curses.nocbreak()
curses.endwin()
os.system('reset')
class Main:
"""Main function handles high-level switching between windows and file menus."""
def __init__(self, menu_bar, win_manager, file_explorers, status_bar):
_menu_bar = menu_bar
_win_manager = win_manager
_file_explorers = file_explorers
_status_bar = status_bar
_current_panel = 0
ready_to_exit = False
while ready_to_exit is not True:
_win_manager.upd_panel(_current_panel, _file_explorers[_current_panel].full_path)
Display(_file_explorers[_current_panel])
key_press = KeyPress(
_file_explorers[_current_panel],
_file_explorers[_current_panel].data,
_file_explorers[_current_panel].position,
)
_file_explorers[_current_panel].scroll()
self.call_menu(key_press, _menu_bar, left_file_explorer)
_current_panel = self.switch_panels(key_press.tab_event, _current_panel, _status_bar)
ready_to_exit = self.exit_main_loop(key_press.key)
curses.doupdate()
def exit_main_loop(self, event):
if event == CONST.CONST_LET_Q_LWRCSE_KEY:
return True
def call_menu(self, key_press, _menu_bar, _left_file_explorer):
_call_menu_event = key_press.menu_event
_key_press = key_press.key
ready_to_return = False
if _call_menu_event is True:
while ready_to_return is not True:
_menu_bar(_key_press)
Display(_menu_bar)
_key_press = KeyPress(
_menu_bar,
_menu_bar.menu_item,
_menu_bar.position
)
self.call_ssh(_key_press.selected_menu_item)
_ssh_is_enabled = self.ssh_enabled()
ready_to_return = self.return_to_main_loop(_key_press.key, _ssh_is_enabled)
_menu_bar.close_menu()
ResetWindow(_left_file_explorer)
else:
return _call_menu_event
def update_all_views(self, _menu_bar, _file_explorers, _status_bar):
_left_file_explorer = _file_explorers[0]
_right_file_explorer = _file_explorers[1]
_menu_bar.draw_menu_bar()
ResetWindow(_left_file_explorer)
ResetWindow(_right_file_explorer)
_status_bar.refresh(1)
curses.doupdate()
def call_ssh(self, selected_menu_item):
if selected_menu_item == 'ssh':
ssh_object.start(win_manager, stdscr, None)
def ssh_enabled(self):
return ssh_object.is_enabled
def return_to_main_loop(self,event,ssh_is_enabled):
if event in (
CONST.CONST_LET_F_LWRCSE_KEY,
CONST.CONST_LET_O_LWRCSE_KEY,
CONST.CONST_LET_Q_LWRCSE_KEY
) or ssh_is_enabled is True:
return True
else:
return False
def switch_panels(self, tab_event, _current_panel, _status_bar):
if tab_event is True:
_current_panel = win_manager.set_active_panel(_current_panel)
_status_bar.refresh(_current_panel)
return _current_panel
else:
return _current_panel
if __name__ == '__main__':
#Setup curses and begin initializing various packages to draw the menubar,
#left and right windows, statusbar, a ssh object for storage, a global options object
#for storage and setup the file explorers.
stdscr = curses.initscr()
screen_height, screen_width = stdscr.getmaxyx()
prepare_curses()
menu_bar = MenuBar(stdscr)
win_manager = WinManager(stdscr)
left_win = win_manager.draw_left_win()
right_win = win_manager.draw_right_win()
ssh_object = SSH()
left_file_explorer = FileExplorer(win_manager, left_win)
right_file_explorer = FileExplorer(win_manager, right_win, ssh_object=ssh_object)
file_explorers = [left_file_explorer, right_file_explorer]
left_file_explorer.get_file_explorers(file_explorers)
right_file_explorer.get_file_explorers(file_explorers)
status_bar = StatusBar(stdscr)
global_options = OptionsMenu(stdscr)
main = Main(menu_bar, win_manager, file_explorers, status_bar)
ssh_object.ssh_close()
end_curses()
From here, the code relies heavily on the FileExplorer and KeyPress classes which I've also included below. The FileExplorer class is responsible for iterating over the files and folders in a given directory, parsing them and saving them in a dictionary. FileExplorer is also meant to do the same thing but for files over SSH, to ultimately allow the use of RSync from/to a remote system.
"""FileExplorer Class and ResetPath Class"""
import curses
import os
import stat
from ssh import SSH
from human_readable_size import human_readable_size
from window_manager import WinManager
from pop_ups import PopUpFileOpener
from pop_ups import PopUpCopyFile
from pathlib import Path
class ResetPath:
"""Used to reset the path to '/' when FileExplorer messses up"""
def __init__(self):
self.errors = 0
def error(self, obj):
self.obj = obj
self.errors += 1
if self.errors == 2:
self.obj.path = '/'
self.errors = 0
class FileExplorer:
"""Work horse of the application
Does the bulk of the work by taking a path and listing directorys/files within that
path. Also prints the list of directorys/files and is responsible for navigating
through those files. The ssh_explorer method is meant to closely mirror the explorer
method but is modified for paramiko where appropriate.
"""
#path = '/'
paths = [None, None]
prev_paths = ['/', '/']
path_errors = ResetPath()
def __init__(self, win_manager, window, path='/', ssh_object=None):
self.ssh_obj = ssh_object
self.win_manager = win_manager
self.window = window
self.screen_height, self.screen_width = self.win_manager.stdscr.getmaxyx()
height, width = self.window.getmaxyx()
start_y, start_x = self.window.getbegyx()
self.ssh_path_depth = 0
self.depth = 0
self.height = height
self.width = width - 2
self.start_y = start_y + 1
self.start_x = start_x + 1
self.position = 0
self.scroller = 0
self.event = None
self.ssh_path = None
self.ssh_path_hist = ['/']
self.path_info = list()
self.draw_pad()
self.explorer(path)
self.menu()
def explorer(self, path):
self.path = path
if isinstance(self.ssh_obj, SSH):
if self.ssh_obj.is_enabled:
self.use_ssh_explorer(path)
return
#cleaned_path = self.clean_path(path) #setting up for future use
_full_path = self.get_full_path(path) #create full path
path_obj = Path(_full_path)
file_dir_list = self.walk_tree(path_obj) #get files and folders from directory
data_list = self.file_dir_iter(file_dir_list) #parse files, identify if file is dir, get size
data_list = self.sort_file_list(data_list) #sort the list of files and folder
data_list = self.create_list_index(data_list) #add index
data = self.list_to_dict(data_list) #convert list to index
self.data = self.root_or_dot_dot(_full_path, data) #determine if parent dir exists
def ssh_explorer(self, ssh_path):
self.next_ssh_path = self.get_full_path(ssh_path)
ssh_files_folders_dir = self.ssh_obj.sftp.listdir(self.next_ssh_path)
ssh_files_folders_attr = self.ssh_obj.sftp.listdir_attr(self.next_ssh_path)
self.ssh_get_abs_path(self.next_ssh_path)
data_list = self.ssh_parse_files(ssh_files_folders_dir, ssh_files_folders_attr)
data_list = self.sort_file_list(data_list)
data_list = self.create_list_index(data_list)
self.data = self.list_to_dict(data_list)
self.data = self.root_or_dot_dot(self.next_ssh_path, self.data)
def ssh_parse_files(self, files_folders_dir, files_folders_attr):
data_list = list()
#item = 0
for file_name, attr in zip(files_folders_dir, files_folders_attr):
#is_dir = Path.is_dir()
is_dir = stat.S_ISDIR(attr.st_mode)
size = attr.st_size
size = human_readable_size(size, suffix="B")
if is_dir:
is_dir = '/'
else:
is_dir = ''
data_list.append([is_dir+file_name, size])
#item += 1
return data_list
def file_dir_iter(self, files_dirs):
"""Requires list object"""
data_list = list()
for item in files_dirs:
#_full_path = os.path.join(self.full_path, item)
_full_path = Path(self.full_path, item)
#is_dir = os.path.isdir(_full_path)
is_dir = Path.is_dir(_full_path)
size = os.path.getsize(_full_path)
#size = Path.stat().st_size
size = human_readable_size(size, suffix="B")
if is_dir:
#item = os.path.join(' ',item)
item = '/' + item
item = item.strip()
# item = os.path.normpath(item).strip()
else:
item
data_list.append([item, size])
return data_list
def create_list_index(self, list):
"""Requires list object"""
i = 1
for item in list:
item.insert(0,i)
i = i + 1
return list
def use_ssh_explorer(self, path):
if self.win_manager.active_panel == 1:
self.right_file_explorer.ssh_explorer(path)
def get_full_path(self, path):
#self.depth_test = len(path.parts)
if path == '/':
self.path_info.append('/')
self.depth = 0
elif path == ('..'):
if self.depth != 0:
self.path_info.pop()
self.depth -= 1
else:
self.path_info.append(path)
self.depth += 1
self.full_path = ''.join(map(str, self.path_info))
return self.full_path.replace('//','/')
def walk_tree(self, path):
file_dir_list = list()
for file_obj in path.iterdir():
file_dir_list.append(file_obj.name)
return file_dir_list
def sort_file_list(self, list):
"""Requires list object"""
data_list = sorted(list, key=lambda x:x[:1])
return data_list
def get_file_explorers(self, file_explorers):
'''imports file explorers'''
self.file_explorers = file_explorers
self.left_file_explorer = file_explorers[0]
self.right_file_explorer = file_explorers[1]
def clean_path(self, path):
return path.replace('//','/')
def list_to_dict(self, data_list):
item = 0
data_dict = dict()
data_dict = {item[0]: item[1:] for item in data_list}
return data_dict
def root_or_dot_dot(self, path, data):
if path == '/':
data[0] = ['/','']
else:
data[0] = ['..','']
return data
def join_path(self, path_1, path_2):
return os.path.join(path_1,path_2)
def draw_pad(self):
'''draws pad larger than the file window so that it is scrollable'''
self.pad = curses.newpad(self.height + 800, self.width) #size of pad
self.pad.scrollok(True)
self.pad.idlok(True)
self.pad.keypad(True)
self.pad.bkgd(curses.color_pair(1))
def menu(self):
'''creates selectable list of items, files, folders'''
self.pad.erase()
self.height, self.width = self.window.getmaxyx()
self.screen_height, self.screen_width = self.win_manager.stdscr.getmaxyx()
self.max_height = self.height -2
self.bottom = self.max_height #+ len(self.tup) #self.max_height
self.scroll_line = self.max_height - 3
self.pad.setscrreg(0,self.max_height) #self.bottom -2)
self.width = self.width - 2
self.pad_refresh = lambda: self.pad.noutrefresh(
self.scroller,
0,
self.start_y,
self.start_x,
self.bottom,
self.screen_width - 2
)
#par = '[ ]' # can be added to the msg below to create a selector, likely to be removed
for index, items in self.data.items():
padding = self.width - len(items[0]) - 5
if index == self.position:
mode = curses.A_REVERSE
else:
mode = curses.A_NORMAL
msg = f'{index:>3}{" "}{items[0]}{items[1]:>{padding}}'
self.pad.addstr(index, 0, str(msg), mode)
if mode == curses.A_REVERSE:
self.cursor = self.pad.getyx()[0]
self.pad_refresh()
def set_paths(self, path):
panel = WinManager.active_panel
if panel == 0:
oth_panel = 1
else:
oth_panel = 0
if path is None:
self.paths[panel] = self.prev_paths[panel].replace('//','/')
else:
self.prev_paths[panel] = self.paths[panel]
self.paths[oth_panel] = self.prev_paths[oth_panel]
def enter(self):
"""Changes the directory or opens file when enter key is called"""
_selected_path = self.get_file_name()
is_dir = _selected_path.startswith('/')
if self.position != 0:
_path_to_open = self.join_path(self.full_path,_selected_path)
if is_dir:
if WinManager.active_panel == 1:
if self.ssh_obj.is_enabled:
self.new_path = self.path
self.pad_refresh()
self.position = self.scroller = 0
self.paths[self.win_manager.active_panel] = _path_to_open
self.set_paths(_path_to_open)
self.explorer(_path_to_open)
else:
PopUpFileOpener(self.file_explorers, self.win_manager.stdscr, _path_to_open)
else:
path_parent = Path(self.full_path).parent
if self.ssh_obj:
if self.ssh_obj.is_enabled and self.win_manager.active_panel == 1:
self.par_dir = '..'
self.set_paths(self.full_path)
self.explorer(_selected_path)
self.win_manager.upd_panel(self.win_manager.active_panel, self.full_path)
def ssh_explorer_attr(self):
if glbl_opts.low_bandwidth:
size = 0
else:
self.ssh_files_folders_attr = self.ssh_obj.sftp.listdir_attr(
path=self.next_ssh_path)
size_list = []
for entry in self.ssh_files_folders_attr:
size = entry.st_size
return size_list
def ssh_get_abs_path(self, path):
self.ssh_abs_path = self.ssh_obj.sftp.normalize(path)
def go_to_top(self):
self.position = 0
self.scroller = 0
def go_to_bottom(self):
data_length = len(self.data)
self.position = data_length - 1
self.scroller = data_length - self.scroll_line - 1
def del_selected_items(self, sel_file):
PopUpDelete(sel_file)
def copy_selected_items(self):
file_name = self.get_file_name()
if self.position != 0:
if self.win_manager.active_panel == 0:
#self.from_file = self.path + '/' + file_name
self.from_file = self.left_file_explorer.full_path + '/' + file_name
self.to_path = self.right_file_explorer.full_path
elif self.win_manager.active_panel == 1:
self.from_file = self.right_file_explorer.full_path + '/' + file_name
self.to_path = self.left_file_explorer.full_path
PopUpCopyFile(
self.file_explorers,
self.win_manager.stdscr,
self.from_file,
self.to_path,
file_name
)
def start_rsync(self):
file_name = self.data[self.position][0]
left_panel_path = left_file_explorer.abs_path
right_panel_path = right_file_explorer.ssh_abs_path
if self.win_manager.active_panel == 0:
self.from_file = left_panel_path + '/' + file_name
self.to_path = right_panel_path
elif self.win_manager.active_panel == 1:
self.from_file = right_panel_path + '/' + file_name
self.to_path = left_panel_path
if self.position != 0:
rsync_obj = RSync(0).start(self.from_file, self.to_path, file_name)
def scroll(self):
if self.cursor > self.scroll_line:
self.data_length = len(self.data) - 1
if self.position != self.data_length:
self.scroller = self.position - self.scroll_line
if self.scroller == -1:
self.scroller = 0
if self.position == 0:
self.scroller = 0
def get_file_name(self):
return self.data[self.position][0]
def is_dir(self, path):
if path.startswith('/'):
self.full_path = os.path.join(path, '')
return True
return False
Here is the KeyPress class. The KeyPress class is well responsible for awaiting a keypress and then either going up or down, left or right in a menu, or kicking off some function such as copying a file, creating a directory or starting rsync, if ssh'd into a remote system.
"""KeyPress Class"""
import curses
import constants as CONST
from pop_ups import PopUpNewDir
from navigate import navigate
class KeyPress:
"""Call function based on keypress
This Class utilizes the curses module to evaluate a keypress (arrow keys, enter, escape, etc)
and complete an action."""
def __init__(self, obj, items=None, position=None):
self.selected_menu_item = None
self.obj = obj
self.items = items
self.tab_event = False
self.menu_event = False
self.position = position
self.get_input(obj)
self.key_or_arrow_event()
def __call__(self, *args, **kwds):
self.selected_menu_item = None
return self.key
def get_input(self, obj):
obj.window.keypad(True)
try:
self.key = obj.window.getch()
except KeyboardInterrupt:
return
def key_or_arrow_event(self):
if self.key in (curses.KEY_LEFT, curses.KEY_RIGHT, curses.KEY_UP, curses.KEY_DOWN):
self.arrow_event()
else:
self.key_event()
def arrow_event(self):
if self.key == curses.KEY_UP:
self.obj.position = navigate(-1, self.items, self.position)
elif self.key == curses.KEY_DOWN:
self.obj.position = navigate(1, self.items, self.position)
elif self.key == curses.KEY_RIGHT:
self.obj.position = navigate(1, self.items, self.position)
elif self.key == curses.KEY_LEFT:
self.obj.position = navigate(-1, self.items, self.position)
def key_event(self):
event = self.key
if event == CONST.CONST_ENTER_KEY:
enter_key_event = self.enter_key()
self.selected_menu_item = self.items[self.position]
if enter_key_event is not None: #use this or selected_menu_item... test and delete one or other
event = enter_key_event
return event
#elif event == 'ssh':
# return self.open_program(event)
elif event == CONST.CONST_ESCAPE_KEY:
return event
elif event == CONST.CONST_TAB_KEY:
self.tab_event = True
elif event in (CONST.CONST_LET_F_LWRCSE_KEY, CONST.CONST_LET_O_LWRCSE_KEY):
self.menu_event = True
elif event == CONST.CONST_LET_Q_LWRCSE_KEY:
return event
elif event == CONST.CONST_LET_X_LWRCSE_KEY:
self.close_ssh_key(event)
elif event == CONST.CONST_NUM_5_KEY:
self.copy_key()
elif event == CONST.CONST_LET_N_LWRCSE_KEY:
self.new_dir_key(obj, event)
elif event == CONST.CONST_LET_B_LWRCSE_KEY:
self.to_bottom_key(obj)
elif event == CONST.CONST_LET_T_LWRCSE_KEY:
self.to_top_key(obj)
elif event == CONST.CONST_NUM_9_KEY:
self.delete_key(obj, items, position)
else:
pass
def to_top_key(obj):
self.obj.go_to_top()
def to_bottom_key(obj):
self.obj.go_to_bottom()
def delete_key(obj, items, position):
item = items[position][0]
self.obj.del_selected_items(obj._exp.path + '/' + item)
def new_dir_key():
PopUpNewDir()
def enter_key(self):
item = self.obj.enter()
if item is not None:
return item
def close_ssh_key():
if self.ssh_obj.is_enabled:
self.ssh_obj.ssh.close()
self.ssh_obj.enabled()
win_manager.upd_panel()
#right_file_explorer.explorer(right_file_explorer.path)
#right_file_explorer.menu()
#ResetWindow(right_file_explorer)
else:
pass
def copy_key(self):
self.obj.copy_selected_items()
What I'm ultimately trying to achieve is making the code more transportable, more loosely coupled, but ultimately to help me learn so I can build good 'programming' habits and write better! I have the program up on github here with all of the classes https://github.com/1amdash/syncrpy-tui
You must log in to answer this question.
Explore related questions
See similar questions with these tags.