From becc564269ca98616f23cca307cad3731ab49036 Mon Sep 17 00:00:00 2001 From: 2Shirt <2xShirt@gmail.com> Date: Sun, 4 Jun 2023 17:43:02 -0700 Subject: [PATCH] Use new TUI layout in ddrescue-tui --- scripts/wk/clone/ddrescue.py | 305 +++++++++++++---------------------- 1 file changed, 115 insertions(+), 190 deletions(-) diff --git a/scripts/wk/clone/ddrescue.py b/scripts/wk/clone/ddrescue.py index 9e85cd90..afbc2fc0 100644 --- a/scripts/wk/clone/ddrescue.py +++ b/scripts/wk/clone/ddrescue.py @@ -32,8 +32,7 @@ from wk.hw.smart import ( smart_status_ok, update_smart_details, ) -from wk.ui import cli as ui -from wk.ui import ansi, tmux +from wk.ui import ansi, cli, tmux, tui # STATIC VARIABLES @@ -77,6 +76,7 @@ DDRESCUE_LOG_REGEX = re.compile( r'.*\(\s*(?P\d+\.?\d*)%\)$', re.IGNORECASE, ) +DDRESCUE_OUTPUT_HEIGHT = 14 INITIAL_SKIP_MIN = 64 * 1024 # This is ddrescue's minimum accepted value REGEX_REMAINING_TIME = re.compile( r'remaining time:' @@ -297,14 +297,14 @@ class BlockPair(): # Check destination size if cloning if not self.destination.is_file() and dest_size < self.size: - ui.print_error(f'Invalid destination: {self.destination}') + cli.print_error(f'Invalid destination: {self.destination}') raise std.GenericAbort() def set_initial_status(self) -> None: """Read map data and set initial statuses.""" self.load_map_data() percent = self.get_percent_recovered() - for name in self.status.keys(): + for name in self.status: if self.pass_complete(name): self.status[name] = percent else: @@ -337,17 +337,15 @@ class BlockPair(): class State(): """Object for tracking hardware diagnostic data.""" def __init__(self): - self.block_pairs = [] + self.block_pairs: list[BlockPair] = [] self.destination = None self.log_dir = None self.mode = None self.panes = {} self.source = None self.working_dir = None - - # Start a background process to maintain layout - self._init_tmux() - exe.start_thread(self._fix_tmux_layout_loop) + self.ui: tui.TUI = tui.TUI('Source') + self.ui.add_title_pane('Destination') def _add_block_pair(self, source, destination) -> None: """Add BlockPair object and run safety checks.""" @@ -365,71 +363,6 @@ class State(): description = self.source.path.name return pathlib.Path(f'{self.working_dir}/Clone_{description}.json') - def _fix_tmux_layout(self, forced=True) -> None: - """Fix tmux layout based on cfg.ddrescue.TMUX_LAYOUT.""" - layout = cfg.ddrescue.TMUX_LAYOUT - needs_fixed = tmux.layout_needs_fixed(self.panes, layout) - - # Main layout fix - try: - tmux.fix_layout(self.panes, layout, forced=forced) - except RuntimeError: - # Assuming self.panes changed while running - pass - - # Source/Destination - if forced or needs_fixed: - self.update_top_panes() - - # Return if Progress pane not present - if 'Progress' not in self.panes: - return - - # SMART/Journal - if forced or needs_fixed: - height = tmux.get_pane_size(self.panes['Progress'])[1] - 2 - p_ratios = [int((x/sum(PANE_RATIOS)) * height) for x in PANE_RATIOS] - if 'SMART' in self.panes: - tmux.resize_pane(self.panes['SMART'], height=p_ratios[0]) - tmux.resize_pane(height=p_ratios[1]) - if 'Journal' in self.panes: - tmux.resize_pane(self.panes['Journal'], height=p_ratios[2]) - - def _fix_tmux_layout_loop(self) -> None: - """Fix tmux layout on a loop. - - NOTE: This should be called as a thread. - """ - while True: - self._fix_tmux_layout(forced=False) - std.sleep(1) - - def _init_tmux(self) -> None: - """Initialize tmux layout.""" - tmux.kill_all_panes() - - # Source (placeholder) - self.panes['Source'] = tmux.split_window( - behind=True, - lines=2, - text=' ', - vertical=True, - ) - - # Started - self.panes['Started'] = tmux.split_window( - lines=cfg.ddrescue.TMUX_SIDE_WIDTH, - target_id=self.panes['Source'], - text=ansi.color_string( - ['Started', time.strftime("%Y-%m-%d %H:%M %Z")], - ['BLUE', None], - sep='\n', - ), - ) - - # Source / Dest - self.update_top_panes() - def _load_settings(self, discard_unused_settings=False) -> dict: """Load settings from previous run, returns dict.""" settings = {} @@ -442,7 +375,7 @@ class State(): settings = json.loads(_f.read()) except (OSError, json.JSONDecodeError) as err: LOG.error('Failed to load clone settings') - ui.print_error('Invalid clone settings detected.') + cli.print_error('Invalid clone settings detected.') raise std.GenericAbort() from err # Check settings @@ -454,10 +387,10 @@ class State(): bail = False for key in ('model', 'serial'): if settings['Source'][key] != getattr(self.source, key): - ui.print_error(f"Clone settings don't match source {key}") + cli.print_error(f"Clone settings don't match source {key}") bail = True if settings['Destination'][key] != getattr(self.destination, key): - ui.print_error(f"Clone settings don't match destination {key}") + cli.print_error(f"Clone settings don't match destination {key}") bail = True if bail: raise std.GenericAbort() @@ -488,7 +421,7 @@ class State(): with open(settings_file, 'w', encoding='utf-8') as _f: json.dump(settings, _f) except OSError as err: - ui.print_error('Failed to save clone settings') + cli.print_error('Failed to save clone settings') raise std.GenericAbort() from err def add_clone_block_pairs(self) -> None: @@ -522,7 +455,7 @@ class State(): # New run, use new settings file settings['Needs Format'] = True offset = 0 - user_choice = ui.choice( + user_choice = cli.choice( 'Format clone using GPT, MBR, or match Source type?', ['G', 'M', 'S'], ) @@ -533,7 +466,7 @@ class State(): else: # Match source type settings['Table Type'] = get_table_type(self.source.path) - if ui.ask('Create an empty Windows boot partition on the clone?'): + if cli.ask('Create an empty Windows boot partition on the clone?'): settings['Create Boot Partition'] = True offset = 2 if settings['Table Type'] == 'GPT' else 1 @@ -638,9 +571,9 @@ class State(): report.append(' ') # Prompt user - ui.clear_screen() - ui.print_report(report) - if not ui.ask(prompt_msg): + cli.clear_screen() + cli.print_report(report) + if not cli.ask(prompt_msg): raise std.GenericAbort() def generate_report(self) -> list[str]: @@ -704,7 +637,7 @@ class State(): def init_recovery(self, docopt_args) -> None: """Select source/dest and set env.""" - ui.clear_screen() + cli.clear_screen() source_parts = [] # Set log @@ -719,6 +652,8 @@ class State(): keep_history=True, timestamp=False, ) + self.progress_out = self.log_dir.joinpath('progress.out') + self.ui.set_progress_file(self.progress_out) # Set mode self.mode = set_mode(docopt_args) @@ -727,7 +662,7 @@ class State(): self.source = get_object(docopt_args['']) if not self.source: self.source = select_disk('Source') - self.update_top_panes() + self.ui.set_title('Source', self.source.name) # Select destination self.destination = get_object(docopt_args['']) @@ -736,7 +671,7 @@ class State(): self.destination = select_disk('Destination', self.source) elif self.mode == 'Image': self.destination = select_path('Destination') - self.update_top_panes() + self.ui.add_title_pane('Destination', self.destination.name) # Update details self.source.update_details(skip_children=False) @@ -749,10 +684,6 @@ class State(): ) # Update panes - self.panes['Progress'] = tmux.split_window( - lines=cfg.ddrescue.TMUX_SIDE_WIDTH, - watch_file=f'{self.log_dir}/progress.out', - ) self.update_progress_pane('Idle') # Set working dir @@ -795,8 +726,8 @@ class State(): try: exe.run_program(cmd) except subprocess.CalledProcessError: - ui.print_error('Failed to unmount source and/or destination') - ui.abort() + cli.print_error('Failed to unmount source and/or destination') + cli.abort() # Prep destination if self.mode == 'Clone': @@ -928,7 +859,7 @@ class State(): check=False, ) if proc.returncode != 0: - ui.print_error('Error(s) encoundtered while formatting destination') + cli.print_error('Error(s) encoundtered while formatting destination') raise std.GenericAbort() # Update settings @@ -969,13 +900,13 @@ class State(): # Check for critical errors if not smart_status_ok(self.destination): - ui.print_error( + cli.print_error( f'Critical error(s) detected for: {self.destination.path}', ) # Check for minor errors if not check_attributes(self.destination, only_blocking=False): - ui.print_warning( + cli.print_warning( f'Attribute error(s) detected for: {self.destination.path}', ) @@ -1026,7 +957,7 @@ class State(): destination_size *= 1.05 error_msg = 'Not enough free space on the destination' if required_size > destination_size: - ui.print_error(error_msg) + cli.print_error(error_msg) raise std.GenericAbort() def save_debug_reports(self) -> None: @@ -1110,14 +1041,14 @@ class State(): report.append(etoc) # Write to progress file - out_path = pathlib.Path(f'{self.log_dir}/progress.out') - with open(out_path, 'w', encoding='utf-8') as _f: - _f.write('\n'.join(report)) + self.progress_out.write_text('\n'.join(report), encoding='utf-8', errors='ignore') def update_top_panes(self) -> None: """(Re)create top source/destination panes.""" source_exists = True + source_str = '' dest_exists = True + dest_str = '' width = tmux.get_pane_size()[0] width = int(width / 2) - 1 @@ -1156,36 +1087,28 @@ class State(): else: dest_exists = self.destination.exists() - # Kill destination pane - if 'Destination' in self.panes: - tmux.kill_pane(self.panes.pop('Destination')) - # Source - source_str = ' ' if self.source: source_str = _format_string(self.source, width) - tmux.respawn_pane( - self.panes['Source'], - text=ansi.color_string( - ['Source', '' if source_exists else ' (Missing)', '\n', source_str], - ['BLUE', 'RED', None, None], - sep='', - ), - ) # Destination - dest_str = '' if self.destination: dest_str = _format_string(self.destination, width) - self.panes['Destination'] = tmux.split_window( - percent=50, - vertical=False, - target_id=self.panes['Source'], - text=ansi.color_string( - ['Destination', '' if dest_exists else ' (Missing)', '\n', dest_str], - ['BLUE', 'RED', None, None], - sep='', + + # Reset title panes + self.ui.reset_title_pane( + ansi.color_string( + ['Source', '' if source_exists else ' (Missing)'], + ['BLUE', 'RED'], ), + source_str, + ) + self.ui.add_title_pane( + ansi.color_string( + ['Destination', '' if dest_exists else ' (Missing)'], + ['BLUE', 'RED'], + ), + dest_str, ) @@ -1396,9 +1319,9 @@ def build_disk_report(dev) -> list[str]: return report -def build_main_menu() -> ui.Menu: +def build_main_menu() -> cli.Menu: """Build main menu, returns wk.ui.cli.Menu.""" - menu = ui.Menu(title=ansi.color_string('ddrescue TUI: Main Menu', 'GREEN')) + menu = cli.Menu(title=ansi.color_string('ddrescue TUI: Main Menu', 'GREEN')) menu.separator = ' ' # Add actions, options, etc @@ -1428,7 +1351,7 @@ def build_object_report(obj) -> list[str]: return report -def build_settings_menu(silent=True) -> ui.Menu: +def build_settings_menu(silent=True) -> cli.Menu: """Build settings menu, returns wk.ui.cli.Menu.""" title_text = [ ansi.color_string('ddrescue TUI: Expert Settings', 'GREEN'), @@ -1439,15 +1362,15 @@ def build_settings_menu(silent=True) -> ui.Menu: ), 'Please read the manual before making changes', ] - menu = ui.Menu(title='\n'.join(title_text)) + menu = cli.Menu(title='\n'.join(title_text)) menu.separator = ' ' preset = 'Default' if not silent: # Ask which preset to use - ui.print_standard( + cli.print_standard( f'Available ddrescue presets: {" / ".join(SETTING_PRESETS)}' ) - preset = ui.choice('Please select a preset:', SETTING_PRESETS) + preset = cli.choice('Please select a preset:', SETTING_PRESETS) # Fix selection for _p in SETTING_PRESETS: @@ -1496,7 +1419,7 @@ def build_sfdisk_partition_line(table_type, dev_path, size, details) -> str: # Safety Check if not dest_type: - ui.print_error(f'Failed to determine partition type for: {dev_path}') + cli.print_error(f'Failed to determine partition type for: {dev_path}') raise std.GenericAbort() # Add extra details @@ -1701,8 +1624,8 @@ def get_object(path) -> hw_disk.Disk | pathlib.Path: # Child/Parent check if obj.parent: - ui.print_warning(f'"{obj.path}" is a child device') - if ui.ask(f'Use parent device "{obj.parent}" instead?'): + cli.print_warning(f'"{obj.path}" is a child device') + if cli.ask(f'Use parent device "{obj.parent}" instead?'): obj = hw_disk.Disk(obj.parent) elif path.is_dir(): obj = path @@ -1713,7 +1636,7 @@ def get_object(path) -> hw_disk.Disk | pathlib.Path: # Abort if obj not set if not obj: - ui.print_error(f'Invalid source/dest path: {path}') + cli.print_error(f'Invalid source/dest path: {path}') raise std.GenericAbort() # Done @@ -1778,7 +1701,7 @@ def get_table_type(disk_path) -> str: # Check type if table_type not in ('GPT', 'MBR'): - ui.print_error(f'Unsupported partition table type: {table_type}') + cli.print_error(f'Unsupported partition table type: {table_type}') raise std.GenericAbort() # Done @@ -1787,7 +1710,7 @@ def get_table_type(disk_path) -> str: def get_working_dir(mode, destination, force_local=False) -> pathlib.Path: """Get working directory using mode and destination, returns path.""" - ticket_id = ui.get_ticket_id() + ticket_id = cli.get_ticket_id() working_dir = None # Use preferred path if possible @@ -1795,12 +1718,12 @@ def get_working_dir(mode, destination, force_local=False) -> pathlib.Path: try: path = pathlib.Path(destination).resolve() except TypeError as err: - ui.print_error(f'Invalid destination: {destination}') + cli.print_error(f'Invalid destination: {destination}') raise std.GenericAbort() from err if path.exists() and fstype_is_ok(path, map_dir=False): working_dir = path elif mode == 'Clone' and not force_local: - ui.print_info('Mounting backup shares...') + cli.print_info('Mounting backup shares...') net.mount_backup_shares(read_write=True) for server in cfg.net.BACKUP_SERVERS: path = pathlib.Path( @@ -1844,11 +1767,11 @@ def is_missing_source_or_destination(state) -> bool: if hasattr(item, 'path'): if not item.path.exists(): missing = True - ui.print_error(f'{name} disappeared') + cli.print_error(f'{name} disappeared') elif hasattr(item, 'exists'): if not item.exists(): missing = True - ui.print_error(f'{name} disappeared') + cli.print_error(f'{name} disappeared') else: LOG.error('Unknown %s type: %s', name, item) @@ -1880,7 +1803,7 @@ def source_or_destination_changed(state) -> bool: # Done if changed: - ui.print_error('Source and/or Destination changed') + cli.print_error('Source and/or Destination changed') return changed @@ -1895,7 +1818,6 @@ def main() -> None: raise RuntimeError('tmux session not found') # Init - atexit.register(tmux.kill_all_panes) main_menu = build_main_menu() settings_menu = build_settings_menu() state = State() @@ -1903,7 +1825,7 @@ def main() -> None: state.init_recovery(args) except (FileNotFoundError, std.GenericAbort): is_missing_source_or_destination(state) - ui.abort() + cli.abort() # Show menu while True: @@ -1921,18 +1843,18 @@ def main() -> None: # Detect drives if 'Detect drives' in selection[0]: - ui.clear_screen() - ui.print_warning(DETECT_DRIVES_NOTICE) - if ui.ask('Are you sure you proceed?'): - ui.print_standard('Forcing controllers to rescan for devices...') + cli.clear_screen() + cli.print_warning(DETECT_DRIVES_NOTICE) + if cli.ask('Are you sure you proceed?'): + cli.print_standard('Forcing controllers to rescan for devices...') cmd = 'echo "- - -" | sudo tee /sys/class/scsi_host/host*/scan' - exe.run_program(cmd, check=False, shell=True) + exe.run_program([cmd], check=False, shell=True) if source_or_destination_changed(state): - ui.abort() + cli.abort() # Start recovery if 'Start' in selection: - ui.clear_screen() + cli.clear_screen() run_recovery(state, main_menu, settings_menu, dry_run=args['--dry-run']) # Quit @@ -1942,8 +1864,8 @@ def main() -> None: break # Recovey < 100% - ui.print_warning('Recovery is less than 100%') - if ui.ask('Are you sure you want to quit?'): + cli.print_warning('Recovery is less than 100%') + if cli.ask('Are you sure you want to quit?'): break # Save results to log @@ -1963,7 +1885,7 @@ def mount_raw_image(path) -> pathlib.Path: # Check if not loopback_path: - ui.print_error(f'Failed to mount image: {path}') + cli.print_error(f'Failed to mount image: {path}') # Register unmount atexit atexit.register(unmount_loopback_device, loopback_path) @@ -2030,7 +1952,7 @@ def run_ddrescue(state, block_pair, pass_name, settings, dry_run=True) -> None: cmd = build_ddrescue_cmd(block_pair, pass_name, settings) poweroff_source_after_idle = True state.update_progress_pane('Active') - ui.clear_screen() + state.ui.clear_current_pane() warning_message = '' def _poweroff_source_drive(idle_minutes) -> None: @@ -2049,8 +1971,8 @@ def run_ddrescue(state, block_pair, pass_name, settings, dry_run=True) -> None: return if i % 600 == 0 and i > 0: if i == 600: - ui.print_standard(' ', flush=True) - ui.print_warning( + cli.print_standard(' ', flush=True) + cli.print_warning( f'Powering off source in {int((idle_minutes*60-i)/60)} minutes...', ) std.sleep(5) @@ -2060,10 +1982,10 @@ def run_ddrescue(state, block_pair, pass_name, settings, dry_run=True) -> None: cmd = ['sudo', 'hdparm', '-Y', source_dev] proc = exe.run_program(cmd, check=False) if proc.returncode: - ui.print_error(f'Failed to poweroff source {source_dev}') + cli.print_error(f'Failed to poweroff source {source_dev}') else: - ui.print_warning(f'Powered off source {source_dev}') - ui.print_standard( + cli.print_warning(f'Powered off source {source_dev}') + cli.print_standard( 'Press Enter to return to main menu...', end='', flush=True, ) @@ -2106,12 +2028,11 @@ def run_ddrescue(state, block_pair, pass_name, settings, dry_run=True) -> None: if warning_message: # Error detected on destination, stop recovery exe.stop_process(proc) - ui.print_error(warning_message) + cli.print_error(warning_message) break - if _i % 60 == 0: # Clear ddrescue pane - tmux.clear_pane() + state.ui.clear_current_pane() _i += 1 # Update progress @@ -2152,19 +2073,19 @@ def run_ddrescue(state, block_pair, pass_name, settings, dry_run=True) -> None: warning_message = 'Error(s) encountered, see message above' state.update_top_panes() if warning_message: - ui.print_standard(' ') - ui.print_standard(' ') - ui.print_error('DDRESCUE PROCESS HALTED') - ui.print_standard(' ') - ui.print_warning(warning_message) + cli.print_standard(' ') + cli.print_standard(' ') + cli.print_error('DDRESCUE PROCESS HALTED') + cli.print_standard(' ') + cli.print_warning(warning_message) # Needs attention? if str(proc.poll()) != '0': state.update_progress_pane('NEEDS ATTENTION') - ui.pause('Press Enter to return to main menu...') + cli.pause('Press Enter to return to main menu...') # Stop source poweroff countdown - ui.print_standard('Stopping device poweroff countdown...', flush=True) + cli.print_standard('Stopping device poweroff countdown...', flush=True) poweroff_source_after_idle = False poweroff_thread.join() @@ -2172,7 +2093,7 @@ def run_ddrescue(state, block_pair, pass_name, settings, dry_run=True) -> None: raise std.GenericAbort() -def run_recovery(state, main_menu, settings_menu, dry_run=True) -> None: +def run_recovery(state: State, main_menu, settings_menu, dry_run=True) -> None: """Run recovery passes.""" atexit.register(state.save_debug_reports) attempted_recovery = False @@ -2180,12 +2101,12 @@ def run_recovery(state, main_menu, settings_menu, dry_run=True) -> None: # Bail early if is_missing_source_or_destination(state): - ui.print_standard('') - ui.pause('Press Enter to return to main menu...') + cli.print_standard('') + cli.pause('Press Enter to return to main menu...') return if source_or_destination_changed(state): - ui.print_standard('') - ui.abort() + cli.print_standard('') + cli.abort() # Get settings for name, details in main_menu.toggles.items(): @@ -2196,14 +2117,14 @@ def run_recovery(state, main_menu, settings_menu, dry_run=True) -> None: state.retry_all_passes() # Start SMART/Journal - state.panes['SMART'] = tmux.split_window( - behind=True, lines=12, vertical=True, + state.ui.add_info_pane( + percent=50, + update_layout=False, watch_file=f'{state.log_dir}/smart.out', ) - if PLATFORM != 'Darwin': - state.panes['Journal'] = tmux.split_window( - lines=4, vertical=True, cmd='journalctl --dmesg --follow', - ) + if PLATFORM == 'Linux': + state.ui.add_worker_pane(lines=4, cmd='journalctl --dmesg --follow') + state.ui.set_current_pane_height(DDRESCUE_OUTPUT_HEIGHT) # Run pass(es) for pass_name in ('read-skip', 'read-full', 'trim', 'scrape'): @@ -2235,15 +2156,19 @@ def run_recovery(state, main_menu, settings_menu, dry_run=True) -> None: break # Stop SMART/Journal + state.ui.remove_all_info_panes() + state.ui.remove_all_worker_panes() + state.ui.clear_current_pane_height() + for pane in ('SMART', 'Journal'): if pane in state.panes: tmux.kill_pane(state.panes.pop(pane)) # Show warning if nothing was done if not attempted_recovery: - ui.print_warning('No actions performed') - ui.print_standard(' ') - ui.pause('Press Enter to return to main menu...') + cli.print_warning('No actions performed') + cli.print_standard(' ') + cli.pause('Press Enter to return to main menu...') # Done state.save_debug_reports() @@ -2253,9 +2178,9 @@ def run_recovery(state, main_menu, settings_menu, dry_run=True) -> None: def select_disk(prompt_msg, skip_disk=None) -> hw_disk.Disk: """Select disk from list, returns Disk().""" - ui.print_info('Scanning disks...') + cli.print_info('Scanning disks...') disks = hw_disk.get_disks() - menu = ui.Menu( + menu = cli.Menu( title=ansi.color_string(f'ddrescue TUI: {prompt_msg} Selection', 'GREEN'), ) menu.disabled_str = 'Already selected' @@ -2300,7 +2225,7 @@ def select_disk_parts(prompt_msg, disk) -> hw_disk.Disk: """Select disk parts from list, returns list of Disk().""" title = ansi.color_string('ddrescue TUI: Partition Selection', 'GREEN') title += f'\n\nDisk: {disk.path} {disk.description}' - menu = ui.Menu(title) + menu = cli.Menu(title) menu.separator = ' ' menu.add_action('All') menu.add_action('None') @@ -2363,13 +2288,13 @@ def select_disk_parts(prompt_msg, disk) -> hw_disk.Disk: if len(object_list) == len(disk.children): # NOTE: This is not true if the disk has no partitions msg = f'Preserve partition table and unused space in {prompt_msg.lower()}?' - if ui.ask(msg): + if cli.ask(msg): # Replace part list with whole disk obj object_list = [disk.path] # Convert object_list to hw_disk.Disk() objects - ui.print_standard(' ') - ui.print_info('Getting disk/partition details...') + cli.print_standard(' ') + cli.print_info('Getting disk/partition details...') object_list = [hw_disk.Disk(path) for path in object_list] # Done @@ -2379,7 +2304,7 @@ def select_disk_parts(prompt_msg, disk) -> hw_disk.Disk: def select_path(prompt_msg) -> pathlib.Path: """Select path, returns pathlib.Path.""" invalid = False - menu = ui.Menu( + menu = cli.Menu( title=ansi.color_string(f'ddrescue TUI: {prompt_msg} Path Selection', 'GREEN'), ) menu.separator = ' ' @@ -2393,7 +2318,7 @@ def select_path(prompt_msg) -> pathlib.Path: if 'Current directory' in selection: path = os.getcwd() elif 'Enter manually' in selection: - path = ui.input_text('Please enter path: ') + path = cli.input_text('Please enter path: ') elif 'Quit' in selection: raise std.GenericAbort() @@ -2403,7 +2328,7 @@ def select_path(prompt_msg) -> pathlib.Path: except TypeError: invalid = True if invalid or not path.is_dir(): - ui.print_error(f'Invalid path: {path}') + cli.print_error(f'Invalid path: {path}') raise std.GenericAbort() # Done @@ -2422,7 +2347,7 @@ def set_mode(docopt_args) -> str: # Ask user if necessary if not mode: - answer = ui.choice('Are we cloning or imaging?', ['C', 'I']) + answer = cli.choice('Are we cloning or imaging?', ['C', 'I']) if answer == 'C': mode = 'Clone' else: