diff --git a/.changes/unreleased/added-20260101-125718.yaml b/.changes/unreleased/added-20260101-125718.yaml new file mode 100644 index 00000000..6301c64d --- /dev/null +++ b/.changes/unreleased/added-20260101-125718.yaml @@ -0,0 +1,3 @@ +kind: added +body: Support fab command to start interactive (repl) mode +time: 2026-01-01T12:57:18.777151041Z diff --git a/src/fabric_cli/commands/config/fab_config_set.py b/src/fabric_cli/commands/config/fab_config_set.py index becad7d3..e1c1d9b2 100644 --- a/src/fabric_cli/commands/config/fab_config_set.py +++ b/src/fabric_cli/commands/config/fab_config_set.py @@ -98,11 +98,21 @@ def _handle_fab_config_mode(previous_mode: str, current_mode: str) -> None: Context().cleanup_context_files(cleanup_all_stale=True, cleanup_current=True) if current_mode == fab_constant.FAB_MODE_INTERACTIVE: - utils_ui.print("Switching to interactive mode...") + # Show deprecation warning + utils_ui.print_warning( + "Mode configuration is deprecated. Running 'fab' now automatically enters interactive mode." + ) + utils_ui.print("Starting interactive mode...") from fabric_cli.core.fab_interactive import start_interactive_mode start_interactive_mode() - elif (current_mode == fab_constant.FAB_MODE_COMMANDLINE - and previous_mode == fab_constant.FAB_MODE_INTERACTIVE): - utils_ui.print("Exiting interactive mode. Goodbye!") - os._exit(0) \ No newline at end of file + elif current_mode == fab_constant.FAB_MODE_COMMANDLINE: + # Show deprecation warning with better messaging + utils_ui.print_warning( + "Mode configuration is deprecated. Running 'fab' now automatically enters interactive mode." + ) + utils_ui.print("Configuration saved for backward compatibility.") + + if previous_mode == fab_constant.FAB_MODE_INTERACTIVE: + utils_ui.print("Exiting interactive mode. Goodbye!") + os._exit(0) \ No newline at end of file diff --git a/src/fabric_cli/core/fab_constant.py b/src/fabric_cli/core/fab_constant.py index 151a294c..d979b241 100644 --- a/src/fabric_cli/core/fab_constant.py +++ b/src/fabric_cli/core/fab_constant.py @@ -313,7 +313,7 @@ # Interactive command constants INTERACTIVE_QUIT_COMMANDS = ["quit", "q", "exit"] -INTERACTIVE_HELP_COMMANDS = ["help", "h", "fab", "-h", "--help"] +INTERACTIVE_HELP_COMMANDS = ["help", "h", "-h", "--help"] INTERACTIVE_VERSION_COMMANDS = ["version", "v", "-v", "--version"] # Platform metadata diff --git a/src/fabric_cli/core/fab_interactive.py b/src/fabric_cli/core/fab_interactive.py index d5fa3a32..155f52be 100644 --- a/src/fabric_cli/core/fab_interactive.py +++ b/src/fabric_cli/core/fab_interactive.py @@ -22,9 +22,12 @@ class InteractiveCLI: def __init__(self, parser=None, subparsers=None): """Initialize the interactive CLI.""" if parser is None or subparsers is None: - from fabric_cli.core.fab_parser_setup import get_global_parser_and_subparsers + from fabric_cli.core.fab_parser_setup import ( + get_global_parser_and_subparsers, + ) + parser, subparsers = get_global_parser_and_subparsers() - + self.parser = parser self.parser.set_mode(fab_constant.FAB_MODE_INTERACTIVE) self.subparsers = subparsers @@ -47,22 +50,27 @@ def handle_command(self, command): """Process the user command.""" fab_logger.print_log_file_path() - command_parts = shlex.split(command.strip()) # Split the command into parts + command_parts = shlex.split(command.strip()) - # Handle special commands first if command in fab_constant.INTERACTIVE_QUIT_COMMANDS: utils_ui.print(fab_constant.INTERACTIVE_EXIT_MESSAGE) - return True # Exit + return True elif command in fab_constant.INTERACTIVE_HELP_COMMANDS: utils_ui.display_help( fab_commands.COMMANDS, "Usage: [flags]" ) - return False # Do not exit + return False elif command in fab_constant.INTERACTIVE_VERSION_COMMANDS: utils_ui.print_version() - return False # Do not exit + return False + elif command.strip() == "fab": + utils_ui.print( + "In interactive mode, commands don't require the fab prefix. Use --help to view the list of supported commands." + ) + return False + elif not command.strip(): + return False - # Interactive mode self.parser.set_mode(fab_constant.FAB_MODE_INTERACTIVE) # Now check for subcommands @@ -91,7 +99,7 @@ def handle_command(self, command): # Catch SystemExit raised by ArgumentParser and prevent exiting return else: - self.parser.error(f"invalid choice: '{command.strip()}'") + self.parser.error(f"invalid choice: '{command.strip()}'. Type 'help' for available commands.") return False @@ -102,31 +110,45 @@ def start_interactive(self): return self._is_running = True - + try: utils_ui.print("\nWelcome to the Fabric CLI ⚡") utils_ui.print("Type 'help' for help. \n") while True: - context = Context().context - pwd_context = f"/{context.path.strip('/')}" - - prompt_text = HTML( - f"fab:{html.escape(pwd_context)}$ " - ) - - user_input = self.session.prompt( - prompt_text, - style=self.custom_style, - cursor=CursorShape.BLINKING_BEAM, - enable_history_search=True, - ) - should_exit = self.handle_command(user_input) - if should_exit: # Check if the command was to exit + try: + context = Context().context + pwd_context = f"/{context.path.strip('/')}" + + prompt_text = HTML( + f"fab:{html.escape(pwd_context)}$ " + ) + + user_input = self.session.prompt( + prompt_text, + style=self.custom_style, + cursor=CursorShape.BLINKING_BEAM, + enable_history_search=True, + ) + should_exit = self.handle_command(user_input) + if should_exit: # Check if the command was to exit + break + + except KeyboardInterrupt: + # Handle Ctrl+C gracefully during command input + utils_ui.print("\nUse 'quit' or 'exit' to leave interactive mode.") + continue + except Exception as e: + # Handle unexpected errors during prompt processing + utils_ui.print(f"Error in interactive session: {str(e)}") break except (EOFError, KeyboardInterrupt): utils_ui.print(f"\n{fab_constant.INTERACTIVE_EXIT_MESSAGE}") + except Exception as e: + # Handle critical errors that would terminate the session + utils_ui.print(f"\nCritical error in interactive mode: {str(e)}") + utils_ui.print(fab_constant.INTERACTIVE_EXIT_MESSAGE) finally: self._is_running = False diff --git a/src/fabric_cli/main.py b/src/fabric_cli/main.py index 6d576695..2b4be323 100644 --- a/src/fabric_cli/main.py +++ b/src/fabric_cli/main.py @@ -9,11 +9,11 @@ from fabric_cli.core import fab_constant, fab_logger, fab_state_config from fabric_cli.core.fab_commands import Command from fabric_cli.core.fab_exceptions import FabricCLIError +from fabric_cli.core.fab_interactive import start_interactive_mode +from fabric_cli.core.fab_parser_setup import get_global_parser_and_subparsers from fabric_cli.parsers import fab_auth_parser as auth_parser from fabric_cli.utils import fab_ui from fabric_cli.utils.fab_commands import COMMANDS -from fabric_cli.core.fab_interactive import start_interactive_mode -from fabric_cli.core.fab_parser_setup import get_global_parser_and_subparsers def main(): @@ -25,6 +25,7 @@ def main(): try: fab_state_config.init_defaults() + if args.command == "auth" and args.auth_command == None: auth_parser.show_help(args) return @@ -35,8 +36,8 @@ def main(): fab_state_config.get_config(fab_constant.FAB_MODE) == fab_constant.FAB_MODE_INTERACTIVE ): - # Use shared interactive mode startup start_interactive_mode() + return if args.command == "auth" and args.auth_command == "logout": login.logout(args) @@ -47,6 +48,7 @@ def main(): return last_exit_code = fab_constant.EXIT_CODE_SUCCESS + if args.command: if args.command not in ["auth"]: fab_logger.print_log_file_path() @@ -56,21 +58,21 @@ def main(): commands_execs = 0 for index, command in enumerate(args.command): command_parts = command.strip().split() - subparser = subparsers.choices[command_parts[0]] - subparser_args = subparser.parse_args(command_parts[1:]) - subparser_args.command = command_parts[0] - last_exit_code = _execute_command( - subparser_args, subparsers, parser - ) - commands_execs += 1 - if index != len(args.command) - 1: - fab_ui.print_grey("------------------------------") + if command_parts: # Ensure we have valid command parts + subparser = subparsers.choices[command_parts[0]] + subparser_args = subparser.parse_args(command_parts[1:]) + subparser_args.command = command_parts[0] + last_exit_code = _execute_command( + subparser_args, subparsers, parser + ) + commands_execs += 1 + if index != len(args.command) - 1: + fab_ui.print_grey("------------------------------") if commands_execs > 1: fab_ui.print("\n") fab_ui.print_output_format( args, message=f"{len(args.command)} commands executed." ) - else: last_exit_code = _execute_command(args, subparsers, parser) @@ -82,24 +84,39 @@ def main(): elif args.version: fab_ui.print_version() else: - # Display help if "fab" - fab_ui.display_help(COMMANDS) + # AUTO-REPL: When no command is provided, automatically enter interactive mode + start_interactive_mode() except KeyboardInterrupt: - fab_ui.print_output_error( - FabricCLIError( - "Operation cancelled", - fab_constant.ERROR_OPERATION_CANCELLED, - ), - output_format_type=args.output_format, - ) - sys.exit(fab_constant.EXIT_CODE_CANCELLED_OR_MISUSE_BUILTINS) + _handle_keyboard_interrupt(args) except Exception as err: - fab_ui.print_output_error( - FabricCLIError(err.args[0], fab_constant.ERROR_UNEXPECTED_ERROR), - output_format_type=args.output_format, + _handle_unexpected_error(err, args) + + +def _handle_keyboard_interrupt(args): + """Handle KeyboardInterrupt with proper error formatting.""" + fab_ui.print_output_error( + FabricCLIError( + "Operation cancelled", + fab_constant.ERROR_OPERATION_CANCELLED, + ), + output_format_type=args.output_format, + ) + sys.exit(fab_constant.EXIT_CODE_CANCELLED_OR_MISUSE_BUILTINS) + + +def _handle_unexpected_error(err, args): + """Handle unexpected errors with proper error formatting.""" + try: + error_message = str(err.args[0]) if err.args else str(err) + except: + error_message = "An unexpected error occurred" + + fab_ui.print_output_error( + FabricCLIError(error_message, fab_constant.ERROR_UNEXPECTED_ERROR), + output_format_type=args.output_format, ) - sys.exit(fab_constant.EXIT_CODE_ERROR) + sys.exit(fab_constant.EXIT_CODE_ERROR) def _execute_command(args, subparsers, parser): @@ -120,3 +137,4 @@ def _execute_command(args, subparsers, parser): if __name__ == "__main__": main() + diff --git a/tests/test_commands/test_config.py b/tests/test_commands/test_config.py index 3cedf1b0..da9bb014 100644 --- a/tests/test_commands/test_config.py +++ b/tests/test_commands/test_config.py @@ -177,7 +177,8 @@ def test_config_set_mode_interactive_success( self, mock_questionary_print, mock_fab_set_state_config, cli_executor: CLIExecutor ): """Test successful transition to interactive mode""" - with patch("fabric_cli.core.fab_interactive.start_interactive_mode") as mock_start_interactive: + with patch("fabric_cli.core.fab_interactive.start_interactive_mode") as mock_start_interactive, \ + patch("fabric_cli.utils.fab_ui.print_warning") as mock_print_warning: mock_fab_set_state_config(constant.FAB_MODE, constant.FAB_MODE_COMMANDLINE) @@ -185,15 +186,19 @@ def test_config_set_mode_interactive_success( cli_executor.exec_command(f"config set mode {constant.FAB_MODE_INTERACTIVE}") # Assert + mock_print_warning.assert_called_once_with( + "Mode configuration is deprecated. Running 'fab' now automatically enters interactive mode." + ) mock_questionary_print.assert_called() mock_start_interactive.assert_called_once_with() - assert mock_questionary_print.call_args[0][0] == 'Switching to interactive mode...' + assert mock_questionary_print.call_args[0][0] == 'Starting interactive mode...' def test_config_set_mode_interactive_from_interactive_success( self, mock_questionary_print, mock_fab_set_state_config, cli_executor: CLIExecutor ): """Test setting interactive mode while already in interactive mode""" - with patch("fabric_cli.core.fab_interactive.start_interactive_mode") as mock_start_interactive: + with patch("fabric_cli.core.fab_interactive.start_interactive_mode") as mock_start_interactive, \ + patch("fabric_cli.utils.fab_ui.print_warning") as mock_print_warning: mock_fab_set_state_config(constant.FAB_MODE, constant.FAB_MODE_INTERACTIVE) @@ -201,67 +206,40 @@ def test_config_set_mode_interactive_from_interactive_success( cli_executor.exec_command(f"config set mode {constant.FAB_MODE_INTERACTIVE}") # Assert + mock_print_warning.assert_called_once_with( + "Mode configuration is deprecated. Running 'fab' now automatically enters interactive mode." + ) mock_questionary_print.assert_called() - mock_start_interactive.assert_called_once_with() - assert mock_questionary_print.call_args[0][0] == 'Switching to interactive mode...' + mock_start_interactive.assert_called_once() + assert mock_questionary_print.call_args[0][0] == 'Starting interactive mode...' def test_config_set_mode_command_line_from_interactive_success( self, mock_fab_set_state_config, mock_questionary_print, cli_executor: CLIExecutor ): """Test transition from interactive to command_line mode""" - with patch("os._exit") as mock_exit: - + with patch("fabric_cli.utils.fab_ui.print_warning") as mock_print_warning, \ + patch("os._exit") as mock_exit: + mock_fab_set_state_config(constant.FAB_MODE, constant.FAB_MODE_INTERACTIVE) + # Execute command cli_executor.exec_command(f"config set mode {constant.FAB_MODE_COMMANDLINE}") + expected_calls = [ + ("Updating 'mode' value...",), + ("Configuration saved for backward compatibility.",), + ("Exiting interactive mode. Goodbye!",) + ] + # Assert mock_questionary_print.assert_called() - assert mock_questionary_print.call_args[0][0] == "Exiting interactive mode. Goodbye!" + actual_calls = [call.args for call in mock_questionary_print.mock_calls] + assert actual_calls == expected_calls mock_exit.assert_called_once_with(0) - def test_start_interactive_mode_success(self): - """Test mode switching creates singleton and launches interactive CLI""" - with patch("fabric_cli.core.fab_interactive.InteractiveCLI") as mock_interactive_cli: - - mock_cli_instance = mock_interactive_cli.return_value - - from fabric_cli.core.fab_interactive import start_interactive_mode - start_interactive_mode() - - mock_interactive_cli.assert_called_once() - mock_cli_instance.start_interactive.assert_called_once() - - def test_start_interactive_mode_already_running(self): - """Test that calling start_interactive_mode when already running prints message""" - with patch("fabric_cli.core.fab_interactive.InteractiveCLI") as mock_interactive_cli, \ - patch("fabric_cli.core.fab_interactive.utils_ui") as mock_utils_ui: - - from fabric_cli.core import fab_interactive - - mock_cli_instance = mock_interactive_cli.return_value - mock_cli_instance._is_running = True - - fab_interactive.start_interactive_mode() - - # Should call InteractiveCLI() and then start_interactive should print message - mock_interactive_cli.assert_called_once() - mock_cli_instance.start_interactive.assert_called_once() - - def test_interactive_cli_singleton_pattern(self): - """Test that InteractiveCLI follows singleton pattern""" - from fabric_cli.core.fab_interactive import InteractiveCLI - - with patch("fabric_cli.core.fab_parser_setup.get_global_parser_and_subparsers") as mock_get_parsers: - mock_parser = type('MockParser', (), {'set_mode': lambda self, mode: None})() - mock_subparsers = object() - mock_get_parsers.return_value = (mock_parser, mock_subparsers) - - # Create two instances - instance1 = InteractiveCLI() - instance2 = InteractiveCLI() - - # Should be the same instance - assert instance1 is instance2 + # Verify proper exit sequence + mock_print_warning.assert_called_once_with( + "Mode configuration is deprecated. Running 'fab' now automatically enters interactive mode." + ) # endregion diff --git a/tests/test_core/test_fab_interactive.py b/tests/test_core/test_fab_interactive.py index b6a72631..cc3a7629 100644 --- a/tests/test_core/test_fab_interactive.py +++ b/tests/test_core/test_fab_interactive.py @@ -97,6 +97,18 @@ def test_handle_command_valid_subcommand_success( mock_subparsers.choices["ls"].parse_args.assert_called_once_with([]) mock_print_log_file_path.assert_called_once() + def test_handle_command_fab_in_interactive_mode_success( + self, interactive_cli, mock_print_ui, mock_print_log_file_path + ): + """Test that typing 'fab' in interactive mode shows appropriate message.""" + result = interactive_cli.handle_command("fab") + + # Verify it stays in interactive mode and shows message + assert result is False + mock_print_ui.assert_called_with("In interactive mode, commands don't require the fab prefix. Use --help to view the list of supported commands.") + mock_print_log_file_path.assert_called_once() + + def test_handle_command_with_arguments_success( self, interactive_cli, @@ -121,7 +133,7 @@ def test_handle_command_invalid_subcommand_failure( interactive_cli.handle_command(command) interactive_cli.parser.error.assert_called_once_with( - "invalid choice: 'invalid_command'" + "invalid choice: 'invalid_command'. Type 'help' for available commands." ) def test_handle_command_empty_input_success( @@ -194,23 +206,47 @@ def test_start_interactive_keyboard_interrupt_success( self, interactive_cli, mock_print_ui ): """Test KeyboardInterrupt handling in start_interactive.""" - # Configure session mock to raise KeyboardInterrupt - interactive_cli.session.prompt.side_effect = KeyboardInterrupt() + # Configure session mock to raise KeyboardInterrupt once, then quit + interactive_cli.session.prompt.side_effect = [KeyboardInterrupt(), "quit"] + + # Mock handle_command to return True for quit + def mock_handle_command(cmd): + return cmd == "quit" - interactive_cli.start_interactive() + with patch.object( + interactive_cli, "handle_command", side_effect=mock_handle_command + ): + interactive_cli.start_interactive() - # Verify goodbye message for interrupt - mock_print_ui.assert_called_with(f"\n{fab_constant.INTERACTIVE_EXIT_MESSAGE}") + # Verify that Ctrl+C message is shown and then exit message + calls = mock_print_ui.call_args_list + ctrl_c_message_found = any( + "Use 'quit' or 'exit' to leave interactive mode." in str(call) + for call in calls + ) + assert ctrl_c_message_found, "Should show Ctrl+C handling message" def test_start_interactive_eof_error_failure(self, interactive_cli, mock_print_ui): """Test EOFError handling in start_interactive.""" - # Configure session mock to raise EOFError - interactive_cli.session.prompt.side_effect = EOFError() + # Configure session mock to raise EOFError once, then quit + interactive_cli.session.prompt.side_effect = [EOFError(), "quit"] - interactive_cli.start_interactive() + # Mock handle_command to return True for quit + def mock_handle_command(cmd): + return cmd == "quit" + + with patch.object( + interactive_cli, "handle_command", side_effect=mock_handle_command + ): + interactive_cli.start_interactive() - # Verify goodbye message for EOF - mock_print_ui.assert_called_with(f"\n{fab_constant.INTERACTIVE_EXIT_MESSAGE}") + # Verify that EOF error message is shown + calls = mock_print_ui.call_args_list + error_message_found = any( + "Error in interactive session:" in str(call) or "Session will continue" in str(call) + for call in calls + ) + assert error_message_found, "Should show EOF error handling message" def test_start_interactive_context_display_success( self, interactive_cli, mock_html_escape, mock_context @@ -284,6 +320,77 @@ def test_custom_style_configuration_success(self, interactive_cli): assert any("detail" in rule for rule in style_rules) assert any("input" in rule for rule in style_rules) + # Test fab + enter automatic mode switching + + def test_fab_command_in_interactive_mode_shows_message_success( + self, interactive_cli, mock_print_ui, mock_print_log_file_path + ): + """Test that running 'fab' while already in interactive mode shows informative message.""" + result = interactive_cli.handle_command("fab") + + # Verify it stays in interactive mode and shows message + assert result is False + mock_print_ui.assert_called_with("In interactive mode, commands don't require the fab prefix. Use --help to view the list of supported commands.") + mock_print_log_file_path.assert_called_once() + + def test_interactive_cli_singleton_pattern_success(self): + """Test that InteractiveCLI follows singleton pattern""" + from fabric_cli.core.fab_interactive import InteractiveCLI + + with patch("fabric_cli.core.fab_parser_setup.get_global_parser_and_subparsers") as mock_get_parsers: + mock_parser = type('MockParser', (), {'set_mode': lambda self, mode: None})() + mock_subparsers = object() + mock_get_parsers.return_value = (mock_parser, mock_subparsers) + + # Create two instances + instance1 = InteractiveCLI() + instance2 = InteractiveCLI() + + # Should be the same instance + assert instance1 is instance2 + + def test_start_interactive_mode_success(self): + """Test mode switching creates singleton and launches interactive CLI""" + with patch("fabric_cli.core.fab_interactive.InteractiveCLI") as mock_interactive_cli: + from unittest.mock import Mock + + mock_cli_instance = Mock() + mock_cli_instance._is_running = False + mock_interactive_cli.return_value = mock_cli_instance + + from fabric_cli.core.fab_interactive import start_interactive_mode + start_interactive_mode() + + mock_interactive_cli.assert_called_once() + mock_cli_instance.start_interactive.assert_called_once() + + def test_start_interactive_mode_already_running(self): + """Test that calling start_interactive_mode when already running prints message""" + with patch("fabric_cli.core.fab_interactive.InteractiveCLI") as mock_interactive_cli, \ + patch("fabric_cli.core.fab_interactive.utils_ui.print") as mock_print: + from unittest.mock import Mock + from fabric_cli.core import fab_interactive + + mock_cli_instance = Mock() + mock_cli_instance._is_running = True + mock_interactive_cli.return_value = mock_cli_instance + + # Mock the start_interactive method to simulate the actual behavior + def mock_start_interactive(): + if mock_cli_instance._is_running: + mock_print("Interactive mode is already running.") + return + + mock_cli_instance.start_interactive = mock_start_interactive + + fab_interactive.start_interactive_mode() + + # Should call InteractiveCLI() and then start_interactive should print message + mock_interactive_cli.assert_called_once() + mock_print.assert_called_once_with("Interactive mode is already running.") + + + # endregion @pytest.fixture def mock_parser(): diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 00000000..d2b605de --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import sys +from unittest.mock import patch + +import pytest + + +class TestMainModule: + """Test suite for main module functionality.""" + + def test_main_no_args_starts_interactive_mode_success(self): + """Test that running 'fab' without arguments automatically enters interactive mode.""" + with patch("fabric_cli.main.start_interactive_mode") as mock_start_interactive: + from fabric_cli.main import main + + with patch.object(sys, 'argv', ['fab']): + with patch("fabric_cli.core.fab_parser_setup.get_global_parser_and_subparsers") as mock_get_parsers: + mock_parser = type('MockParser', (), { + 'parse_args': lambda: type('Args', (), { + 'command': None, + 'version': False + })(), + 'set_mode': lambda mode: None + })() + mock_subparsers = object() + mock_get_parsers.return_value = (mock_parser, mock_subparsers) + + with patch("fabric_cli.core.fab_state_config.init_defaults"): + main() + + mock_start_interactive.assert_called_once() + + def test_start_interactive_mode_directly_success(self): + """Test that start_interactive_mode can be called directly without infinite loops.""" + # Mock the InteractiveCLI to prevent actual interactive session + with patch("fabric_cli.core.fab_interactive.InteractiveCLI") as mock_interactive_cli: + from unittest.mock import Mock + + mock_cli_instance = Mock() + mock_cli_instance._is_running = False + mock_cli_instance.start_interactive = Mock() # Mock the start_interactive method + mock_interactive_cli.return_value = mock_cli_instance + + from fabric_cli.core.fab_interactive import start_interactive_mode + start_interactive_mode() + + mock_interactive_cli.assert_called_once() + mock_cli_instance.start_interactive.assert_called_once() \ No newline at end of file