diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9ee804ac3..423c242ef 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -38,9 +38,9 @@ cmd2/exceptions.py @kmvanbrunt @anselor cmd2/history.py @tleonhardt cmd2/parsing.py @kmvanbrunt cmd2/plugin.py @anselor +cmd2/pt_utils.py @kmvanbrunt @tleonhardt cmd2/py_bridge.py @kmvanbrunt cmd2/rich_utils.py @kmvanbrunt -cmd2/rl_utils.py @kmvanbrunt cmd2/string_utils.py @kmvanbrunt cmd2/styles.py @tleonhardt @kmvanbrunt cmd2/terminal_utils.py @kmvanbrunt diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 688e24dd6..4a2b3b2f1 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -61,15 +61,13 @@ Nearly all project configuration, including for dependencies and quality tools i See the `dependencies` list under the `[project]` heading in [pyproject.toml](../pyproject.toml). -| Prerequisite | Minimum Version | Purpose | -| ---------------------------------------------------------- | --------------- | ------------------------------------------------------ | -| [python](https://www.python.org/downloads/) | `3.10` | Python programming language | -| [pyperclip](https://github.com/asweigart/pyperclip) | `1.8` | Cross-platform clipboard functions | -| [rich](https://github.com/Textualize/rich) | `14.1.0` | Add rich text and beautiful formatting in the terminal | -| [rich-argparse](https://github.com/hamdanal/rich-argparse) | `1.7.1` | A rich-enabled help formatter for argparse | - -> `macOS` and `Windows` each have an extra dependency to ensure they have a viable alternative to -> [readline](https://tiswww.case.edu/php/chet/readline/rltop.html) available. +| Prerequisite | Minimum Version | Purpose | +| ------------------------------------------------------------------------- | --------------- | ------------------------------------------------------ | +| [prompt-toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit) | `3.0.52` | Replacement for GNU `readline` that is cross-platform | +| [python](https://www.python.org/downloads/) | `3.10` | Python programming language | +| [pyperclip](https://github.com/asweigart/pyperclip) | `1.8` | Cross-platform clipboard functions | +| [rich](https://github.com/Textualize/rich) | `14.1.0` | Add rich text and beautiful formatting in the terminal | +| [rich-argparse](https://github.com/hamdanal/rich-argparse) | `1.7.1` | A rich-enabled help formatter for argparse | > Python 3.10 depends on [backports.strenum](https://github.com/clbarnes/backports.strenum) to use > the `enum.StrEnum` class introduced in Python 3.11. diff --git a/CHANGELOG.md b/CHANGELOG.md index b933fac36..e90d2fa16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +## 4.0.0 (TBD 2026) + +### Summary + +`cmd2` now has a dependency on +[prompt-toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit) which serves as a +pure-Python cross-platform replacement for +[GNU Readline](https://tiswww.case.edu/php/chet/readline/rltop.html). Previously, `cmd2` had used +different `readline` dependencies on each Operating System (OS) which was at times a very +frustrating developer and user experience due to small inconsistencies in these different readline +libraries. Now we have consistent cross-platform support for tab-completion, user terminal input, +and history. Additionally, this opens up some cool advanced features such as support for syntax +highlighting of user input while typing, auto-suggestions similar to those provided by the fish +shell, and the option for a persistent bottom bar that can display realtime status updates. + +### Details + +- Breaking Changes + - Removed all use of `readline` built-in module and underlying platform libraries + - Deleted `cmd2.rl_utils` module which dealt with importing the proper `readline` module for + each platform and provided utility functions related to `readline` + - Added a dependency on `prompt-toolkit` and a new `cmd2.pt_utils` module with supporting + utilities + ## 3.1.0 (December 25, 2025) - Potentially Breaking Changes diff --git a/Makefile b/Makefile index 07afeadc2..4712abb3c 100644 --- a/Makefile +++ b/Makefile @@ -77,7 +77,8 @@ publish: validate-tag build ## Publish a release to PyPI, uses token from ~/.pyp BUILD_DIRS = build dist *.egg-info DOC_DIRS = build MYPY_DIRS = .mypy_cache dmypy.json dmypy.sock -TEST_DIRS = .cache .coverage .pytest_cache htmlcov +TEST_DIRS = .cache .pytest_cache htmlcov +TEST_FILES = .coverage coverage.xml .PHONY: clean-build clean-build: ## Clean build artifacts @@ -108,6 +109,7 @@ clean-ruff: ## Clean ruff artifacts clean-test: ## Clean test artifacts @echo "🚀 Removing test artifacts" @uv run python -c "import shutil; import os; [shutil.rmtree(d, ignore_errors=True) for d in '$(TEST_DIRS)'.split() if os.path.isdir(d)]" + @uv run python -c "from pathlib import Path; [Path(f).unlink(missing_ok=True) for f in '$(TEST_FILES)'.split()]" .PHONY: clean clean: clean-build clean-docs clean-mypy clean-pycache clean-ruff clean-test ## Clean all artifacts diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 60a0ccf55..56b2b07a0 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -51,7 +51,6 @@ ) from types import ( FrameType, - ModuleType, ) from typing import ( IO, @@ -123,7 +122,6 @@ from .history import ( History, HistoryItem, - single_line_format, ) from .parsing import ( Macro, @@ -139,21 +137,36 @@ ) from .styles import Cmd2Style -# NOTE: When using gnureadline with Python 3.13, start_ipython needs to be imported before any readline-related stuff with contextlib.suppress(ImportError): from IPython import start_ipython -from .rl_utils import ( - RlType, - rl_escape_prompt, - rl_get_display_prompt, - rl_get_point, - rl_get_prompt, - rl_in_search_mode, - rl_set_prompt, - rl_type, - rl_warning, - vt100_support, +from prompt_toolkit.completion import Completer, DummyCompleter +from prompt_toolkit.formatted_text import ANSI +from prompt_toolkit.history import InMemoryHistory +from prompt_toolkit.input import DummyInput +from prompt_toolkit.output import DummyOutput +from prompt_toolkit.patch_stdout import patch_stdout +from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, set_title + +try: + if sys.platform == "win32": + from prompt_toolkit.output.win32 import NoConsoleScreenBufferError # type: ignore[attr-defined] + else: + # Trigger the except block for non-Windows platforms + raise ImportError # noqa: TRY301 +except ImportError: + + class NoConsoleScreenBufferError(Exception): # type: ignore[no-redef] + """Dummy exception to use when prompt_toolkit.output.win32.NoConsoleScreenBufferError is not available.""" + + def __init__(self, msg: str = '') -> None: + """Initialize NoConsoleScreenBufferError custom exception instance.""" + super().__init__(msg) + + +from .pt_utils import ( + Cmd2Completer, + Cmd2History, ) from .utils import ( Settable, @@ -163,49 +176,11 @@ suggest_similar, ) -# Set up readline -if rl_type == RlType.NONE: # pragma: no cover - Cmd2GeneralConsole(sys.stderr).print(rl_warning, style=Cmd2Style.WARNING) -else: - from .rl_utils import ( - readline, - rl_force_redisplay, - ) - - # Used by rlcompleter in Python console loaded by py command - orig_rl_delims = readline.get_completer_delims() - - if rl_type == RlType.PYREADLINE: - # Save the original pyreadline3 display completion function since we need to override it and restore it - orig_pyreadline_display = readline.rl.mode._display_completions - - elif rl_type == RlType.GNU: - # Get the readline lib so we can make changes to it - import ctypes - - from .rl_utils import ( - readline_lib, - ) - - rl_basic_quote_characters = ctypes.c_char_p.in_dll(readline_lib, "rl_basic_quote_characters") - orig_rl_basic_quotes = cast(bytes, ctypes.cast(rl_basic_quote_characters, ctypes.c_void_p).value) - - -class _SavedReadlineSettings: - """readline settings that are backed up when switching between readline environments.""" - - def __init__(self) -> None: - self.completer = None - self.delims = '' - self.basic_quotes: bytes | None = None - class _SavedCmd2Env: """cmd2 environment settings that are backed up when entering an interactive Python shell.""" def __init__(self) -> None: - self.readline_settings = _SavedReadlineSettings() - self.readline_module: ModuleType | None = None self.history: list[str] = [] @@ -296,6 +271,8 @@ class Cmd: Line-oriented command interpreters are often useful for test harnesses, internal tools, and rapid prototypes. """ + DEFAULT_COMPLETEKEY = 'tab' + DEFAULT_EDITOR = utils.find_editor() # Sorting keys for strings @@ -309,7 +286,7 @@ class Cmd: def __init__( self, - completekey: str = 'tab', + completekey: str = DEFAULT_COMPLETEKEY, stdin: TextIO | None = None, stdout: TextIO | None = None, *, @@ -333,7 +310,7 @@ def __init__( ) -> None: """Easy but powerful framework for writing line-oriented command interpreters, extends Python's cmd package. - :param completekey: readline name of a completion key, default to Tab + :param completekey: name of a completion key, default to Tab :param stdin: alternate input file object, if not specified, sys.stdin is used :param stdout: alternate output file object, if not specified, sys.stdout is used :param persistent_history_file: file path to load a persistent cmd2 command history from @@ -411,6 +388,9 @@ def __init__( # Key used for tab completion self.completekey = completekey + if self.completekey != self.DEFAULT_COMPLETEKEY: + # TODO(T or K): Configure prompt_toolkit `KeyBindings` with the custom key for completion # noqa: FIX002, TD003 + pass # Attributes which should NOT be dynamically settable via the set command at runtime self.default_to_shell = False # Attempt to run unrecognized commands as shell commands @@ -450,11 +430,35 @@ def __init__( # Commands to exclude from the help menu and tab completion self.hidden_commands = ['eof', '_relative_run_script'] - # Initialize history + # Initialize history from a persistent history file (if present) self.persistent_history_file = '' self._persistent_history_length = persistent_history_length self._initialize_history(persistent_history_file) + # Initialize prompt-toolkit PromptSession + self.history_adapter = Cmd2History(self) + self.completer = Cmd2Completer(self) + + try: + self.session: PromptSession[str] = PromptSession( + history=self.history_adapter, + completer=self.completer, + complete_style=CompleteStyle.READLINE_LIKE, + complete_in_thread=True, + ) + except (NoConsoleScreenBufferError, AttributeError, ValueError): + # Fallback to dummy input/output if PromptSession initialization fails. + # This can happen in some CI environments (like GitHub Actions on Windows) + # where isatty() is True but there is no real console. + self.session = PromptSession( + history=self.history_adapter, + completer=self.completer, + input=DummyInput(), + output=DummyOutput(), + complete_style=CompleteStyle.READLINE_LIKE, + complete_in_thread=True, + ) + # Commands to exclude from the history command self.exclude_from_history = ['eof', 'history'] @@ -622,14 +626,14 @@ def __init__( # An optional hint which prints above tab completion suggestions self.completion_hint: str = '' - # Normally cmd2 uses readline's formatter to columnize the list of completion suggestions. + # Normally cmd2 uses prompt-toolkit's formatter to columnize the list of completion suggestions. # If a custom format is preferred, write the formatted completions to this string. cmd2 will - # then print it instead of the readline format. ANSI style sequences and newlines are supported + # then print it instead of the prompt-toolkit format. ANSI style sequences and newlines are supported # when using this value. Even when using formatted_completions, the full matches must still be returned # from your completer function. ArgparseCompleter writes its tab completion tables to this string. self.formatted_completions: str = '' - # Used by complete() for readline tab completion + # Used by complete() for prompt-toolkit tab completion self.completion_matches: list[str] = [] # Use this list if you need to display tab completion suggestions that are different than the actual text @@ -1212,7 +1216,7 @@ def allow_style(self, new_val: ru.AllowStyle) -> None: def _completion_supported(self) -> bool: """Return whether tab completion is supported.""" - return self.use_rawinput and bool(self.completekey) and rl_type != RlType.NONE + return self.use_rawinput and bool(self.completekey) @property def visible_prompt(self) -> str: @@ -1608,7 +1612,7 @@ def ppaged( def _reset_completion_defaults(self) -> None: """Reset tab completion settings. - Needs to be called each time readline runs tab completion. + Needs to be called each time prompt-toolkit runs tab completion. """ self.allow_appended_space = True self.allow_closing_quote = True @@ -1619,10 +1623,13 @@ def _reset_completion_defaults(self) -> None: self.matches_delimited = False self.matches_sorted = False - if rl_type == RlType.GNU: - readline.set_completion_display_matches_hook(self._display_matches_gnu_readline) - elif rl_type == RlType.PYREADLINE: - readline.rl.mode._display_completions = self._display_matches_pyreadline + def _bottom_toolbar(self) -> Any: + """Get the bottom toolbar content.""" + if self.formatted_completions: + return ANSI(self.formatted_completions.rstrip()) + if self.completion_hint: + return ANSI(self.completion_hint.rstrip()) + return None def tokens_for_completion(self, line: str, begidx: int, endidx: int) -> tuple[list[str], list[str]]: """Get all tokens through the one being completed, used by tab completion functions. @@ -2009,12 +2016,12 @@ def complete_users() -> list[str]: matches[index] += os.path.sep self.display_matches[index] += os.path.sep - # Remove cwd if it was added to match the text readline expects + # Remove cwd if it was added to match the text prompt-toolkit expects if cwd_added: to_replace = cwd if cwd == os.path.sep else cwd + os.path.sep matches = [cur_path.replace(to_replace, '', 1) for cur_path in matches] - # Restore the tilde string if we expanded one to match the text readline expects + # Restore the tilde string if we expanded one to match the text prompt-toolkit expects if expanded_tilde_path: matches = [cur_path.replace(expanded_tilde_path, orig_tilde_path, 1) for cur_path in matches] @@ -2125,122 +2132,6 @@ def _redirect_complete(self, text: str, line: str, begidx: int, endidx: int, com # Call the command's completer function return compfunc(text, line, begidx, endidx) - @staticmethod - def _pad_matches_to_display(matches_to_display: list[str]) -> tuple[list[str], int]: # pragma: no cover - """Add padding to the matches being displayed as tab completion suggestions. - - The default padding of readline/pyreadine is small and not visually appealing - especially if matches have spaces. It appears very squished together. - - :param matches_to_display: the matches being padded - :return: the padded matches and length of padding that was added - """ - if rl_type == RlType.GNU: - # Add 2 to the padding of 2 that readline uses for a total of 4. - padding = 2 * ' ' - - elif rl_type == RlType.PYREADLINE: - # Add 3 to the padding of 1 that pyreadline3 uses for a total of 4. - padding = 3 * ' ' - - else: - return matches_to_display, 0 - - return [cur_match + padding for cur_match in matches_to_display], len(padding) - - def _display_matches_gnu_readline( - self, substitution: str, matches: list[str], longest_match_length: int - ) -> None: # pragma: no cover - """Print a match list using GNU readline's rl_display_match_list(). - - :param substitution: the substitution written to the command line - :param matches: the tab completion matches to display - :param longest_match_length: longest printed length of the matches - """ - if rl_type == RlType.GNU: - # Print hint if one exists and we are supposed to display it - hint_printed = False - if self.always_show_hint and self.completion_hint: - hint_printed = True - sys.stdout.write('\n' + self.completion_hint) - - # Check if we already have formatted results to print - if self.formatted_completions: - if not hint_printed: - sys.stdout.write('\n') - sys.stdout.write('\n' + self.formatted_completions + '\n') - - # Otherwise use readline's formatter - else: - # Check if we should show display_matches - if self.display_matches: - matches_to_display = self.display_matches - - # Recalculate longest_match_length for display_matches - longest_match_length = 0 - - for cur_match in matches_to_display: - cur_length = su.str_width(cur_match) - longest_match_length = max(longest_match_length, cur_length) - else: - matches_to_display = matches - - # Add padding for visual appeal - matches_to_display, padding_length = self._pad_matches_to_display(matches_to_display) - longest_match_length += padding_length - - # We will use readline's display function (rl_display_match_list()), so we - # need to encode our string as bytes to place in a C array. - encoded_substitution = bytes(substitution, encoding='utf-8') - encoded_matches = [bytes(cur_match, encoding='utf-8') for cur_match in matches_to_display] - - # rl_display_match_list() expects matches to be in argv format where - # substitution is the first element, followed by the matches, and then a NULL. - strings_array = cast(list[bytes | None], (ctypes.c_char_p * (1 + len(encoded_matches) + 1))()) - - # Copy in the encoded strings and add a NULL to the end - strings_array[0] = encoded_substitution - strings_array[1:-1] = encoded_matches - strings_array[-1] = None - - # rl_display_match_list(strings_array, number of completion matches, longest match length) - readline_lib.rl_display_match_list(strings_array, len(encoded_matches), longest_match_length) - - # Redraw prompt and input line - rl_force_redisplay() - - def _display_matches_pyreadline(self, matches: list[str]) -> None: # pragma: no cover - """Print a match list using pyreadline3's _display_completions(). - - :param matches: the tab completion matches to display - """ - if rl_type == RlType.PYREADLINE: - # Print hint if one exists and we are supposed to display it - hint_printed = False - if self.always_show_hint and self.completion_hint: - hint_printed = True - sys.stdout.write('\n' + self.completion_hint) - - # Check if we already have formatted results to print - if self.formatted_completions: - if not hint_printed: - sys.stdout.write('\n') - sys.stdout.write('\n' + self.formatted_completions + '\n') - - # Redraw the prompt and input lines - rl_force_redisplay() - - # Otherwise use pyreadline3's formatter - else: - # Check if we should show display_matches - matches_to_display = self.display_matches if self.display_matches else matches - - # Add padding for visual appeal - matches_to_display, _ = self._pad_matches_to_display(matches_to_display) - - # Display matches using actual display function. This also redraws the prompt and input lines. - orig_pyreadline_display(matches_to_display) - @staticmethod def _determine_ap_completer_type(parser: argparse.ArgumentParser) -> type[argparse_completer.ArgparseCompleter]: """Determine what type of ArgparseCompleter to use on a given parser. @@ -2368,11 +2259,11 @@ def _perform_completion( # Save the quote so we can add a matching closing quote later. completion_token_quote = raw_completion_token[0] - # readline still performs word breaks after a quote. Therefore, something like quoted search + # prompt-toolkit still performs word breaks after a quote. Therefore, something like quoted search # text with a space would have resulted in begidx pointing to the middle of the token we # we want to complete. Figure out where that token actually begins and save the beginning - # portion of it that was not part of the text readline gave us. We will remove it from the - # completions later since readline expects them to start with the original text. + # portion of it that was not part of the text prompt-toolkit gave us. We will remove it from the + # completions later since prompt-toolkit expects them to start with the original text. actual_begidx = line[:endidx].rfind(tokens[-1]) if actual_begidx != begidx: @@ -2395,7 +2286,7 @@ def _perform_completion( if not self.display_matches: # Since self.display_matches is empty, set it to self.completion_matches # before we alter them. That way the suggestions will reflect how we parsed - # the token being completed and not how readline did. + # the token being completed and not how prompt-toolkit did. import copy self.display_matches = copy.copy(self.completion_matches) @@ -2434,18 +2325,29 @@ def _perform_completion( if len(self.completion_matches) == 1 and self.allow_closing_quote and completion_token_quote: self.completion_matches[0] += completion_token_quote - def complete(self, text: str, state: int, custom_settings: utils.CustomCompletionSettings | None = None) -> str | None: + def complete( + self, + text: str, + state: int, + line: str | None = None, + begidx: int | None = None, + endidx: int | None = None, + custom_settings: utils.CustomCompletionSettings | None = None, + ) -> str | None: """Override of cmd's complete method which returns the next possible completion for 'text'. - This completer function is called by readline as complete(text, state), for state in 0, 1, 2, …, + This completer function is called by prompt-toolkit as complete(text, state), for state in 0, 1, 2, …, until it returns a non-string value. It should return the next possible completion starting with text. - Since readline suppresses any exception raised in completer functions, they can be difficult to debug. + Since prompt-toolkit suppresses any exception raised in completer functions, they can be difficult to debug. Therefore, this function wraps the actual tab completion logic and prints to stderr any exception that - occurs before returning control to readline. + occurs before returning control to prompt-toolkit. :param text: the current word that user is typing :param state: non-negative integer + :param line: optional current input line + :param begidx: optional beginning index of text + :param endidx: optional ending index of text :param custom_settings: used when not tab completing the main command line :return: the next possible completion for text or None """ @@ -2453,25 +2355,33 @@ def complete(self, text: str, state: int, custom_settings: utils.CustomCompletio if state == 0: self._reset_completion_defaults() + # If line is provided, use it and indices. Otherwise fallback to empty (for safety) + if line is None: + line = "" + if begidx is None: + begidx = 0 + if endidx is None: + endidx = 0 + # Check if we are completing a multiline command if self._at_continuation_prompt: # lstrip and prepend the previously typed portion of this multiline command lstripped_previous = self._multiline_in_progress.lstrip() - line = lstripped_previous + readline.get_line_buffer() + line = lstripped_previous + line # Increment the indexes to account for the prepended text - begidx = len(lstripped_previous) + readline.get_begidx() - endidx = len(lstripped_previous) + readline.get_endidx() + begidx = len(lstripped_previous) + begidx + endidx = len(lstripped_previous) + endidx else: # lstrip the original line - orig_line = readline.get_line_buffer() + orig_line = line line = orig_line.lstrip() num_stripped = len(orig_line) - len(line) # Calculate new indexes for the stripped line. If the cursor is at a position before the end of a # line of spaces, then the following math could result in negative indexes. Enforce a max of 0. - begidx = max(readline.get_begidx() - num_stripped, 0) - endidx = max(readline.get_endidx() - num_stripped, 0) + begidx = max(begidx - num_stripped, 0) + endidx = max(endidx - num_stripped, 0) # Shortcuts are not word break characters when tab completing. Therefore, shortcuts become part # of the text variable if there isn't a word break, like a space, after it. We need to remove it @@ -2524,20 +2434,22 @@ def complete(self, text: str, state: int, custom_settings: utils.CustomCompletio # Don't print error and redraw the prompt unless the error has length err_str = str(ex) if err_str: - self.print_to( - sys.stdout, - Text.assemble( - "\n", - (err_str, Cmd2Style.ERROR if ex.apply_style else ""), - ), - ) - rl_force_redisplay() + # If apply_style is True, then this is an error message that should be printed + # above the prompt so it remains in the scrollback. + if ex.apply_style: + self.print_to( + sys.stdout, + "\n" + err_str, + style=Cmd2Style.ERROR, + ) + # Otherwise, this is a hint that should be displayed below the prompt. + else: + self.completion_hint = err_str return None except Exception as ex: # noqa: BLE001 # Insert a newline so the exception doesn't print in the middle of the command line being tab completed self.perror() self.pexcept(ex) - rl_force_redisplay() return None def in_script(self) -> bool: @@ -2713,7 +2625,7 @@ def postloop(self) -> None: def parseline(self, line: str) -> tuple[str, str, str]: """Parse the line into a command name and a string containing the arguments. - :param line: line read by readline + :param line: line read by prompt-toolkit :return: tuple containing (command, args, line) """ statement = self.statement_parser.parse_command_only(line) @@ -2726,7 +2638,6 @@ def onecmd_plus_hooks( add_to_history: bool = True, raise_keyboard_interrupt: bool = False, py_bridge_call: bool = False, - orig_rl_history_length: int | None = None, ) -> bool: """Top-level function called by cmdloop() to handle parsing a line and running the command and all of its hooks. @@ -2738,9 +2649,6 @@ def onecmd_plus_hooks( :param py_bridge_call: This should only ever be set to True by PyBridge to signify the beginning of an app() call from Python. It is used to enable/disable the storage of the command's stdout. - :param orig_rl_history_length: Optional length of the readline history before the current command was typed. - This is used to assist in combining multiline readline history entries and is only - populated by cmd2. Defaults to None. :return: True if running of commands should stop """ import datetime @@ -2750,7 +2658,7 @@ def onecmd_plus_hooks( try: # Convert the line into a Statement - statement = self._input_line_to_statement(line, orig_rl_history_length=orig_rl_history_length) + statement = self._input_line_to_statement(line) # call the postparsing hooks postparsing_data = plugin.PostparsingData(False, statement) @@ -2905,7 +2813,7 @@ def runcmds_plus_hooks( return False - def _complete_statement(self, line: str, *, orig_rl_history_length: int | None = None) -> Statement: + def _complete_statement(self, line: str) -> Statement: """Keep accepting lines of input until the command is complete. There is some pretty hacky code here to handle some quirks of @@ -2914,29 +2822,10 @@ def _complete_statement(self, line: str, *, orig_rl_history_length: int | None = backwards compatibility with the standard library version of cmd. :param line: the line being parsed - :param orig_rl_history_length: Optional length of the readline history before the current command was typed. - This is used to assist in combining multiline readline history entries and is only - populated by cmd2. Defaults to None. :return: the completed Statement :raises Cmd2ShlexError: if a shlex error occurs (e.g. No closing quotation) :raises EmptyStatement: when the resulting Statement is blank """ - - def combine_rl_history(statement: Statement) -> None: - """Combine all lines of a multiline command into a single readline history entry.""" - if orig_rl_history_length is None or not statement.multiline_command: - return - - # Remove all previous lines added to history for this command - while readline.get_current_history_length() > orig_rl_history_length: - readline.remove_history_item(readline.get_current_history_length() - 1) - - formatted_command = single_line_format(statement) - - # If formatted command is different than the previous history item, add it - if orig_rl_history_length == 0 or formatted_command != readline.get_history_item(orig_rl_history_length): - readline.add_history(formatted_command) - while True: try: statement = self.statement_parser.parse(line) @@ -2976,11 +2865,6 @@ def combine_rl_history(statement: Statement) -> None: line += f'\n{nextline}' - # Combine all history lines of this multiline command as we go. - if nextline: - statement = self.statement_parser.parse_command_only(line) - combine_rl_history(statement) - except KeyboardInterrupt: self.poutput('^C') statement = self.statement_parser.parse('') @@ -2990,18 +2874,13 @@ def combine_rl_history(statement: Statement) -> None: if not statement.command: raise EmptyStatement - # If necessary, update history with completed multiline command. - combine_rl_history(statement) return statement - def _input_line_to_statement(self, line: str, *, orig_rl_history_length: int | None = None) -> Statement: + def _input_line_to_statement(self, line: str) -> Statement: """Parse the user's input line and convert it to a Statement, ensuring that all macros are also resolved. :param line: the line being parsed - :param orig_rl_history_length: Optional length of the readline history before the current command was typed. - This is used to assist in combining multiline readline history entries and is only - populated by cmd2. Defaults to None. :return: parsed command line as a Statement :raises Cmd2ShlexError: if a shlex error occurs (e.g. No closing quotation) :raises EmptyStatement: when the resulting Statement is blank @@ -3012,13 +2891,12 @@ def _input_line_to_statement(self, line: str, *, orig_rl_history_length: int | N # Continue until all macros are resolved while True: # Make sure all input has been read and convert it to a Statement - statement = self._complete_statement(line, orig_rl_history_length=orig_rl_history_length) + statement = self._complete_statement(line) # If this is the first loop iteration, save the original line and stop # combining multiline history entries in the remaining iterations. if orig_line is None: orig_line = statement.raw - orig_rl_history_length = None # Check if this command matches a macro and wasn't already processed to avoid an infinite loop if statement.command in self.macros and statement.command not in used_macros: @@ -3323,10 +3201,10 @@ def _suggest_similar_command(self, command: str) -> str | None: def read_input( self, - prompt: str, + prompt: str = '', *, history: list[str] | None = None, - completion_mode: utils.CompletionMode = utils.CompletionMode.NONE, + completion_mode: utils.CompletionMode = utils.CompletionMode.COMMANDS, preserve_quotes: bool = False, choices: Iterable[Any] | None = None, choices_provider: ChoicesProviderFunc | None = None, @@ -3335,150 +3213,102 @@ def read_input( ) -> str: """Read input from appropriate stdin value. - Also supports tab completion and up-arrow history while input is being entered. - - :param prompt: prompt to display to user - :param history: optional list of strings to use for up-arrow history. If completion_mode is - CompletionMode.COMMANDS and this is None, then cmd2's command list history will - be used. The passed in history will not be edited. It is the caller's responsibility - to add the returned input to history if desired. Defaults to None. - :param completion_mode: tells what type of tab completion to support. Tab completion only works when - self.use_rawinput is True and sys.stdin is a terminal. Defaults to - CompletionMode.NONE. - - The following optional settings apply when completion_mode is CompletionMode.CUSTOM: - - :param preserve_quotes: if True, then quoted tokens will keep their quotes when processed by - ArgparseCompleter. This is helpful in cases when you're tab completing - flag-like tokens (e.g. -o, --option) and you don't want them to be - treated as argparse flags when quoted. Set this to True if you plan - on passing the string to argparse with the tokens still quoted. - - A maximum of one of these should be provided: - - :param choices: iterable of accepted values for single argument - :param choices_provider: function that provides choices for single argument - :param completer: tab completion function that provides choices for single argument - :param parser: an argument parser which supports the tab completion of multiple arguments - - :return: the line read from stdin with all trailing new lines removed - :raises Exception: any exceptions raised by input() and stdin.readline() + :param prompt: prompt to display + :param history: optional list of history items to use for this input + :param completion_mode: type of tab completion to perform + :param preserve_quotes: if True, quotes are preserved in the completion items + :param choices: optional list of choices to tab complete + :param choices_provider: optional function that provides choices + :param completer: optional completer function + :param parser: optional argparse parser + :return: the line read from input """ - readline_configured = False - saved_completer: CompleterFunc | None = None - saved_history: list[str] | None = None - - def configure_readline() -> None: - """Configure readline tab completion and history.""" - nonlocal readline_configured - nonlocal saved_completer - nonlocal saved_history - nonlocal parser - - if readline_configured or rl_type == RlType.NONE: # pragma: no cover - return - - # Configure tab completion - if self._completion_supported(): - saved_completer = readline.get_completer() - - # Disable completion - if completion_mode == utils.CompletionMode.NONE: - - def complete_none(text: str, state: int) -> str | None: # pragma: no cover # noqa: ARG001 - return None - - complete_func = complete_none - - # Complete commands - elif completion_mode == utils.CompletionMode.COMMANDS: - complete_func = self.complete - - # Set custom completion settings - else: - if parser is None: - parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(add_help=False) - parser.add_argument( - 'arg', - suppress_tab_hint=True, - choices=choices, - choices_provider=choices_provider, - completer=completer, - ) - - custom_settings = utils.CustomCompletionSettings(parser, preserve_quotes=preserve_quotes) - complete_func = functools.partial(self.complete, custom_settings=custom_settings) + if self.use_rawinput and self.stdin.isatty(): + # Determine completer + completer_to_use: Completer + if completion_mode == utils.CompletionMode.NONE: + completer_to_use = DummyCompleter() + elif completion_mode == utils.CompletionMode.COMMANDS: + completer_to_use = self.completer + else: + # Custom completion + if parser is None: + parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(add_help=False) + parser.add_argument( + 'arg', + suppress_tab_hint=True, + choices=choices, + choices_provider=choices_provider, + completer=completer, + ) + custom_settings = utils.CustomCompletionSettings(parser, preserve_quotes=preserve_quotes) + completer_to_use = Cmd2Completer(self, custom_settings=custom_settings) - readline.set_completer(complete_func) + # Use dynamic prompt if the prompt matches self.prompt + def get_prompt() -> Any: + return ANSI(self.prompt) - # Overwrite history if not completing commands or new history was provided - if completion_mode != utils.CompletionMode.COMMANDS or history is not None: - saved_history = [] - for i in range(1, readline.get_current_history_length() + 1): - saved_history.append(readline.get_history_item(i)) + prompt_to_use: Any = ANSI(prompt) + if prompt == self.prompt: + prompt_to_use = get_prompt - readline.clear_history() + with patch_stdout(): if history is not None: + # If custom history is provided, we use the prompt() shortcut + # which can take a history object. + history_to_use = InMemoryHistory() for item in history: - readline.add_history(item) - - readline_configured = True + history_to_use.append_string(item) - def restore_readline() -> None: - """Restore readline tab completion and history.""" - nonlocal readline_configured - if not readline_configured or rl_type == RlType.NONE: # pragma: no cover - return - - if self._completion_supported(): - readline.set_completer(saved_completer) - - if saved_history is not None: - readline.clear_history() - for item in saved_history: - readline.add_history(item) - - readline_configured = False + temp_session1: PromptSession[str] = PromptSession( + history=history_to_use, + input=self.session.input, + output=self.session.output, + ) - # Check we are reading from sys.stdin - if self.use_rawinput: - if sys.stdin.isatty(): - try: - # Deal with the vagaries of readline and ANSI escape codes - escaped_prompt = rl_escape_prompt(prompt) + return temp_session1.prompt( + prompt_to_use, + completer=completer_to_use, + bottom_toolbar=self._bottom_toolbar, + ) - with self.sigint_protection: - configure_readline() - line = input(escaped_prompt) - finally: - with self.sigint_protection: - restore_readline() - else: - line = input() - if self.echo: - sys.stdout.write(f'{prompt}{line}\n') + return self.session.prompt( + prompt_to_use, + completer=completer_to_use, + bottom_toolbar=self._bottom_toolbar, + ) # Otherwise read from self.stdin elif self.stdin.isatty(): # on a tty, print the prompt first, then read the line - self.poutput(prompt, end='') - self.stdout.flush() - line = self.stdin.readline() + temp_session2: PromptSession[str] = PromptSession( + input=self.session.input, + output=self.session.output, + ) + line = temp_session2.prompt( + prompt, + bottom_toolbar=self._bottom_toolbar, + ) if len(line) == 0: - line = 'eof' + raise EOFError + return line.rstrip('\n') else: - # we are reading from a pipe, read the line to see if there is - # anything there, if so, then decide whether to print the - # prompt or not - line = self.stdin.readline() - if len(line): - # we read something, output the prompt and the something - if self.echo: - self.poutput(f'{prompt}{line}') - else: - line = 'eof' + # not a tty, just read the line + temp_session3: PromptSession[str] = PromptSession( + input=self.session.input, + output=self.session.output, + ) + line = temp_session3.prompt( + bottom_toolbar=self._bottom_toolbar, + ) + if len(line) == 0: + raise EOFError + line = line.rstrip('\n') - return line.rstrip('\r\n') + if self.echo: + self.poutput(f'{prompt}{line}') + + return line def _read_command_line(self, prompt: str) -> str: """Read command line from appropriate stdin. @@ -3499,62 +3329,6 @@ def _read_command_line(self, prompt: str) -> str: # Command line is gone. Do not allow asynchronous changes to the terminal. self.terminal_lock.acquire() - def _set_up_cmd2_readline(self) -> _SavedReadlineSettings: - """Set up readline with cmd2-specific settings, called at beginning of command loop. - - :return: Class containing saved readline settings - """ - readline_settings = _SavedReadlineSettings() - - if rl_type == RlType.GNU: - # To calculate line count when printing async_alerts, we rely on commands wider than - # the terminal to wrap across multiple lines. The default for horizontal-scroll-mode - # is "off" but a user may have overridden it in their readline initialization file. - readline.parse_and_bind("set horizontal-scroll-mode off") - - if self._completion_supported(): - # Set up readline for our tab completion needs - if rl_type == RlType.GNU: - # GNU readline automatically adds a closing quote if the text being completed has an opening quote. - # We don't want this behavior since cmd2 only adds a closing quote when self.allow_closing_quote is True. - # To fix this behavior, set readline's rl_basic_quote_characters to NULL. We don't need to worry about setting - # rl_completion_suppress_quote since we never declared rl_completer_quote_characters. - readline_settings.basic_quotes = cast(bytes, ctypes.cast(rl_basic_quote_characters, ctypes.c_void_p).value) - rl_basic_quote_characters.value = None - - readline_settings.completer = readline.get_completer() - readline.set_completer(self.complete) - - # Set the readline word delimiters for completion - completer_delims = " \t\n" - completer_delims += ''.join(constants.QUOTES) - completer_delims += ''.join(constants.REDIRECTION_CHARS) - completer_delims += ''.join(self.statement_parser.terminators) - - readline_settings.delims = readline.get_completer_delims() - readline.set_completer_delims(completer_delims) - - # Enable tab completion - readline.parse_and_bind(self.completekey + ": complete") - - return readline_settings - - def _restore_readline(self, readline_settings: _SavedReadlineSettings) -> None: - """Restore saved readline settings, called at end of command loop. - - :param readline_settings: the readline settings to restore - """ - if self._completion_supported(): - # Restore what we changed in readline - readline.set_completer(readline_settings.completer) - readline.set_completer_delims(readline_settings.delims) - - if rl_type == RlType.GNU: - readline.set_completion_display_matches_hook(None) - rl_basic_quote_characters.value = readline_settings.basic_quotes - elif rl_type == RlType.PYREADLINE: - readline.rl.mode._display_completions = orig_pyreadline_display - def _cmdloop(self) -> None: """Repeatedly issue a prompt, accept input, parse it, and dispatch to apporpriate commands. @@ -3563,25 +3337,12 @@ def _cmdloop(self) -> None: This serves the same role as cmd.cmdloop(). """ - saved_readline_settings = None - try: - # Get sigint protection while we set up readline for cmd2 - with self.sigint_protection: - saved_readline_settings = self._set_up_cmd2_readline() - # Run startup commands stop = self.runcmds_plus_hooks(self._startup_commands) self._startup_commands.clear() while not stop: - # Used in building multiline readline history entries. Only applies - # when command line is read by input() in a terminal. - if rl_type != RlType.NONE and self.use_rawinput and sys.stdin.isatty(): - orig_rl_history_length = readline.get_current_history_length() - else: - orig_rl_history_length = None - # Get commands from user try: line = self._read_command_line(self.prompt) @@ -3590,12 +3351,9 @@ def _cmdloop(self) -> None: line = '' # Run the command along with all associated pre and post hooks - stop = self.onecmd_plus_hooks(line, orig_rl_history_length=orig_rl_history_length) + stop = self.onecmd_plus_hooks(line) finally: - # Get sigint protection while we restore readline settings - with self.sigint_protection: - if saved_readline_settings is not None: - self._restore_readline(saved_readline_settings) + pass ############################################################# # Parsers and functions for alias command and subcommands @@ -4694,96 +4452,23 @@ def _reset_py_display() -> None: sys.displayhook = sys.__displayhook__ sys.excepthook = sys.__excepthook__ - def _set_up_py_shell_env(self, interp: InteractiveConsole) -> _SavedCmd2Env: + def _set_up_py_shell_env(self, _interp: InteractiveConsole) -> _SavedCmd2Env: """Set up interactive Python shell environment. :return: Class containing saved up cmd2 environment. """ cmd2_env = _SavedCmd2Env() - # Set up readline for Python shell - if rl_type != RlType.NONE: - # Save cmd2 history - for i in range(1, readline.get_current_history_length() + 1): - cmd2_env.history.append(readline.get_history_item(i)) - - readline.clear_history() - - # Restore py's history - for item in self._py_history: - readline.add_history(item) - - if self._completion_supported(): - # Set up tab completion for the Python console - # rlcompleter relies on the default settings of the Python readline module - if rl_type == RlType.GNU: - cmd2_env.readline_settings.basic_quotes = cast( - bytes, ctypes.cast(rl_basic_quote_characters, ctypes.c_void_p).value - ) - rl_basic_quote_characters.value = orig_rl_basic_quotes - - if 'gnureadline' in sys.modules: - # rlcompleter imports readline by name, so it won't use gnureadline - # Force rlcompleter to use gnureadline instead so it has our settings and history - if 'readline' in sys.modules: - cmd2_env.readline_module = sys.modules['readline'] - - sys.modules['readline'] = sys.modules['gnureadline'] - - cmd2_env.readline_settings.delims = readline.get_completer_delims() - readline.set_completer_delims(orig_rl_delims) - - # rlcompleter will not need cmd2's custom display function - # This will be restored by cmd2 the next time complete() is called - if rl_type == RlType.GNU: - readline.set_completion_display_matches_hook(None) - elif rl_type == RlType.PYREADLINE: - readline.rl.mode._display_completions = orig_pyreadline_display - - # Save off the current completer and set a new one in the Python console - # Make sure it tab completes from its locals() dictionary - cmd2_env.readline_settings.completer = readline.get_completer() - interp.runcode(compile("from rlcompleter import Completer", "", "exec")) - interp.runcode(compile("import readline", "", "exec")) - interp.runcode(compile("readline.set_completer(Completer(locals()).complete)", "", "exec")) - # Set up sys module for the Python console self._reset_py_display() return cmd2_env - def _restore_cmd2_env(self, cmd2_env: _SavedCmd2Env) -> None: + def _restore_cmd2_env(self, _cmd2_env: _SavedCmd2Env) -> None: """Restore cmd2 environment after exiting an interactive Python shell. :param cmd2_env: the environment settings to restore """ - # Set up readline for cmd2 - if rl_type != RlType.NONE: - # Save py's history - self._py_history.clear() - for i in range(1, readline.get_current_history_length() + 1): - self._py_history.append(readline.get_history_item(i)) - - readline.clear_history() - - # Restore cmd2's history - for item in cmd2_env.history: - readline.add_history(item) - - if self._completion_supported(): - # Restore cmd2's tab completion settings - readline.set_completer(cmd2_env.readline_settings.completer) - readline.set_completer_delims(cmd2_env.readline_settings.delims) - - if rl_type == RlType.GNU: - rl_basic_quote_characters.value = cmd2_env.readline_settings.basic_quotes - - if 'gnureadline' in sys.modules: - # Restore what the readline module pointed to - if cmd2_env.readline_module is None: - del sys.modules['readline'] - else: - sys.modules['readline'] = cmd2_env.readline_module def _run_python(self, *, pyscript: str | None = None) -> bool | None: """Run an interactive Python shell or execute a pyscript file. @@ -5123,7 +4808,7 @@ def do_history(self, args: argparse.Namespace) -> bool | None: if args.clear: self.last_result = True - # Clear command and readline history + # Clear command and prompt-toolkit history self.history.clear() if self.persistent_history_file: @@ -5136,8 +4821,6 @@ def do_history(self, args: argparse.Namespace) -> bool | None: self.last_result = False return None - if rl_type != RlType.NONE: - readline.clear_history() return None # If an argument was supplied, then retrieve partial contents of the history, otherwise retrieve it all @@ -5299,16 +4982,6 @@ def _initialize_history(self, hist_file: str) -> None: self.history.start_session() - # Populate readline history - if rl_type != RlType.NONE: - for item in self.history: - formatted_command = single_line_format(item.statement) - - # If formatted command is different than the previous history item, add it - cur_history_length = readline.get_current_history_length() - if cur_history_length == 0 or formatted_command != readline.get_history_item(cur_history_length): - readline.add_history(formatted_command) - def _persist_history(self) -> None: """Write history out to the persistent history file as compressed JSON.""" if not self.persistent_history_file: @@ -5635,7 +5308,7 @@ class TestMyAppCase(Cmd2TestCase): Rule("cmd2 transcript test", characters=self.ruler, style=Style.null()), style=Style(bold=True), ) - self.poutput(f'platform {sys.platform} -- Python {verinfo}, cmd2-{cmd2.__version__}, readline-{rl_type}') + self.poutput(f'platform {sys.platform} -- Python {verinfo}, cmd2-{cmd2.__version__}') self.poutput(f'cwd: {os.getcwd()}') self.poutput(f'cmd2 app: {sys.argv[0]}') self.poutput(f'collected {num_transcripts} transcript{plural}', style=Style(bold=True)) @@ -5665,7 +5338,7 @@ class TestMyAppCase(Cmd2TestCase): # Return a failure error code to support automated transcript-based testing self.exit_code = 1 - def async_alert(self, alert_msg: str, new_prompt: str | None = None) -> None: # pragma: no cover + def async_alert(self, alert_msg: str, new_prompt: str | None = None) -> None: """Display an important message to the user while they are at a command line prompt. To the user it appears as if an alert message is printed above the prompt and their @@ -5688,46 +5361,22 @@ def async_alert(self, alert_msg: str, new_prompt: str | None = None) -> None: # if threading.current_thread() is threading.main_thread(): raise RuntimeError("async_alert should not be called from the main thread") - if not (vt100_support and self.use_rawinput): - return - # Sanity check that can't fail if self.terminal_lock was acquired before calling this function if self.terminal_lock.acquire(blocking=False): - # Windows terminals tend to flicker when we redraw the prompt and input lines. - # To reduce how often this occurs, only update terminal if there are changes. - update_terminal = False - - if alert_msg: - alert_msg += '\n' - update_terminal = True - - if new_prompt is not None: - self.prompt = new_prompt - - # Check if the onscreen prompt needs to be refreshed to match self.prompt. - if self.need_prompt_refresh(): - update_terminal = True - rl_set_prompt(self.prompt) - - if update_terminal: - from .terminal_utils import async_alert_str - - # Print a string which replaces the onscreen prompt and input lines with the alert. - terminal_str = async_alert_str( - terminal_columns=ru.console_width(), - prompt=rl_get_display_prompt(), - line=readline.get_line_buffer(), - cursor_offset=rl_get_point(), - alert_msg=alert_msg, - ) + try: + if new_prompt is not None: + self.prompt = new_prompt - sys.stdout.write(terminal_str) - sys.stdout.flush() + if hasattr(self, 'session'): + # Invalidate to force prompt update if prompt changed + self.session.app.invalidate() - # Redraw the prompt and input lines below the alert - rl_force_redisplay() + if alert_msg: + sys.stdout.write(alert_msg + '\n') + sys.stdout.flush() - self.terminal_lock.release() + finally: + self.terminal_lock.release() else: raise RuntimeError("another thread holds terminal_lock") @@ -5756,8 +5405,8 @@ def async_refresh_prompt(self) -> None: # pragma: no cover One case where the onscreen prompt and self.prompt can get out of sync is when async_alert() is called while a user is in search mode (e.g. Ctrl-r). - To prevent overwriting readline's onscreen search prompt, self.prompt is updated - but readline's saved prompt isn't. + To prevent overwriting prompt-toolkit's onscreen search prompt, self.prompt is updated + but prompt-toolkit's saved prompt isn't. Therefore when a user aborts a search, the old prompt is still on screen until they press Enter or this method is called. Call need_prompt_refresh() in an async print @@ -5770,11 +5419,9 @@ def async_refresh_prompt(self) -> None: # pragma: no cover def need_prompt_refresh(self) -> bool: # pragma: no cover """Check whether the onscreen prompt needs to be asynchronously refreshed to match self.prompt.""" - if not (vt100_support and self.use_rawinput): - return False - - # Don't overwrite a readline search prompt or a continuation prompt. - return not rl_in_search_mode() and not self._at_continuation_prompt and self.prompt != rl_get_prompt() + # With prompt_toolkit, refresh is handled via invalidation. + # This method is kept for API compatibility. + return False @staticmethod def set_window_title(title: str) -> None: # pragma: no cover @@ -5782,17 +5429,7 @@ def set_window_title(title: str) -> None: # pragma: no cover :param title: the new window title """ - if not vt100_support: - return - - from .terminal_utils import set_title_str - - try: - sys.stdout.write(set_title_str(title)) - sys.stdout.flush() - except AttributeError: - # Debugging in Pycharm has issues with setting terminal title - pass + set_title(title) def enable_command(self, command: str) -> None: """Enable a command by restoring its functions. @@ -5929,7 +5566,7 @@ def cmdloop(self, intro: RenderableType = '') -> int: original_sigterm_handler = signal.getsignal(signal.SIGTERM) signal.signal(signal.SIGTERM, self.termination_signal_handler) - # Grab terminal lock before the command line prompt has been drawn by readline + # Grab terminal lock before the command line prompt has been drawn by prompt-toolkit self.terminal_lock.acquire() # Always run the preloop first diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py new file mode 100644 index 000000000..be707c951 --- /dev/null +++ b/cmd2/pt_utils.py @@ -0,0 +1,127 @@ +"""Utilities for integrating prompt_toolkit with cmd2.""" + +import re +from collections.abc import Iterable +from typing import ( + TYPE_CHECKING, +) + +from prompt_toolkit.completion import ( + Completer, + Completion, +) +from prompt_toolkit.document import Document +from prompt_toolkit.history import History + +from . import ( + constants, + utils, +) + +if TYPE_CHECKING: + from .cmd2 import Cmd + + +BASE_DELIMITERS = " \t\n" + "".join(constants.QUOTES) + "".join(constants.REDIRECTION_CHARS) + + +class Cmd2Completer(Completer): + """Completer that delegates to cmd2's completion logic.""" + + def __init__(self, cmd_app: 'Cmd', custom_settings: utils.CustomCompletionSettings | None = None) -> None: + """Initialize prompt_toolkit based completer class.""" + self.cmd_app = cmd_app + self.custom_settings = custom_settings + + # Define delimiters for completion to match cmd2/readline behavior + delimiters = BASE_DELIMITERS + if hasattr(self.cmd_app, 'statement_parser'): + delimiters += "".join(self.cmd_app.statement_parser.terminators) + + # Regex pattern for a word: one or more characters that are NOT delimiters + self.word_pattern = re.compile(f"[^{re.escape(delimiters)}]+") + + def get_completions(self, document: Document, _complete_event: object) -> Iterable[Completion]: + """Get completions for the current input.""" + text = document.get_word_before_cursor(pattern=self.word_pattern) + + # We need the full line and indexes for cmd2 + line = document.text + + # Calculate begidx and endidx + # get_word_before_cursor returns the word. + # We need to find where this word starts. + # document.cursor_position is the current cursor position. + + # text is the word before cursor. + # So begidx should be cursor_position - len(text) + # endidx should be cursor_position + + endidx = document.cursor_position + begidx = endidx - len(text) + + # Call cmd2's complete method. + # We pass state=0 to trigger the completion calculation. + self.cmd_app.complete(text, 0, line=line, begidx=begidx, endidx=endidx, custom_settings=self.custom_settings) + + # Now we iterate over self.cmd_app.completion_matches and self.cmd_app.display_matches + matches = self.cmd_app.completion_matches + display_matches = self.cmd_app.display_matches + + if not matches: + return + + # cmd2 separates completion matches (what is inserted) from display matches (what is shown). + # prompt_toolkit Completion object takes 'text' (what is inserted) and 'display' (what is shown). + + # Check if we have display matches and if they match the length of completion matches + use_display_matches = len(display_matches) == len(matches) + + for i, match in enumerate(matches): + display = display_matches[i] if use_display_matches else match + + # prompt_toolkit replaces the word before cursor by default if we use the default Completer? + # No, we yield Completion(text, start_position=...). + # Default start_position is 0 (append). + + start_position = -len(text) + + yield Completion(match, start_position=start_position, display=display) + + +class Cmd2History(History): + """History that bridges cmd2's history storage with prompt_toolkit.""" + + def __init__(self, cmd_app: 'Cmd') -> None: + """Initialize prompt_toolkit based history wrapper class.""" + super().__init__() + self.cmd_app = cmd_app + + def load_history_strings(self) -> Iterable[str]: + """Yield strings from cmd2's history to prompt_toolkit.""" + for item in self.cmd_app.history: + yield item.statement.raw + + def get_strings(self) -> list[str]: + """Get the strings from the history.""" + # We override this to always get the latest history from cmd2 + # instead of caching it like the base class does. + strings: list[str] = [] + last_item = None + for item in self.cmd_app.history: + if item.statement.raw != last_item: + strings.append(item.statement.raw) + last_item = item.statement.raw + return strings + + def store_string(self, string: str) -> None: + """prompt_toolkit calls this when a line is accepted. + + cmd2 handles history addition in its own loop (postcmd). + We don't want to double add. + However, PromptSession needs to know about it for the *current* session history navigation. + If we don't store it here, UP arrow might not work for the just entered command + unless cmd2 re-initializes the session or history object. + + This method is intentionally empty. + """ diff --git a/cmd2/rl_utils.py b/cmd2/rl_utils.py deleted file mode 100644 index c7f37a0d1..000000000 --- a/cmd2/rl_utils.py +++ /dev/null @@ -1,301 +0,0 @@ -"""Imports the proper Readline for the platform and provides utility functions for it.""" - -import contextlib -import sys -from enum import ( - Enum, -) - -######################################################################################################################### -# NOTE ON LIBEDIT: -# -# On Linux/Mac, the underlying readline API may be implemented by libedit instead of GNU readline. -# We don't support libedit because it doesn't implement all the readline features cmd2 needs. -# -# For example: -# cmd2 sets a custom display function using Python's readline.set_completion_display_matches_hook() to -# support many of its advanced tab completion features (e.g. tab completion tables, displaying path basenames, -# colored results, etc.). This function "sets or clears the rl_completion_display_matches_hook callback in the -# underlying library". libedit has never implemented rl_completion_display_matches_hook. It merely sets it to NULL -# and never references it. -# -# The workaround for Python environments using libedit is to install the gnureadline Python library. -######################################################################################################################### - -# Prefer statically linked gnureadline if installed due to compatibility issues with libedit -try: - import gnureadline as readline # type: ignore[import-not-found] -except ImportError: - # Note: If this actually fails, you should install gnureadline on Linux/Mac or pyreadline3 on Windows. - with contextlib.suppress(ImportError): - import readline - - -class RlType(Enum): - """Readline library types we support.""" - - GNU = 1 - PYREADLINE = 2 - NONE = 3 - - -# Check what implementation of Readline we are using -rl_type = RlType.NONE - -# Tells if the terminal we are running in supports vt100 control characters -vt100_support = False - -# Explanation for why Readline wasn't loaded -_rl_warn_reason = '' - -# The order of this check matters since importing pyreadline3 will also show readline in the modules list -if 'pyreadline3' in sys.modules: - rl_type = RlType.PYREADLINE - - import atexit - from ctypes import ( - byref, - ) - from ctypes.wintypes import ( - DWORD, - HANDLE, - ) - - # Check if we are running in a terminal - if sys.stdout is not None and sys.stdout.isatty(): # pragma: no cover - - def enable_win_vt100(handle: HANDLE) -> bool: - """Enable VT100 character sequences in a Windows console. - - This only works on Windows 10 and up - :param handle: the handle on which to enable vt100 - :return: True if vt100 characters are enabled for the handle. - """ - enable_virtual_terminal_processing = 0x0004 - - # Get the current mode for this handle in the console - cur_mode = DWORD(0) - readline.rl.console.GetConsoleMode(handle, byref(cur_mode)) - - ret_val = False - - # Check if ENABLE_VIRTUAL_TERMINAL_PROCESSING is already enabled - if (cur_mode.value & enable_virtual_terminal_processing) != 0: - ret_val = True - - elif readline.rl.console.SetConsoleMode(handle, cur_mode.value | enable_virtual_terminal_processing): - # Restore the original mode when we exit - atexit.register(readline.rl.console.SetConsoleMode, handle, cur_mode) - ret_val = True - - return ret_val - - # Enable VT100 sequences for stdout and stderr - STD_OUT_HANDLE = -11 - STD_ERROR_HANDLE = -12 - vt100_stdout_support = enable_win_vt100(readline.rl.console.GetStdHandle(STD_OUT_HANDLE)) - vt100_stderr_support = enable_win_vt100(readline.rl.console.GetStdHandle(STD_ERROR_HANDLE)) - vt100_support = vt100_stdout_support and vt100_stderr_support - - ############################################################################################################ - # pyreadline3 is incomplete in terms of the Python readline API. Add the missing functions we need. - ############################################################################################################ - # Add missing `readline.remove_history_item()` - if not hasattr(readline, 'remove_history_item'): - - def pyreadline_remove_history_item(pos: int) -> None: - """Remove the specified item number from the pyreadline3 history. - - An implementation of remove_history_item() for pyreadline3. - - :param pos: The 0-based position in history to remove. - """ - # Save of the current location of the history cursor - saved_cursor = readline.rl.mode._history.history_cursor - - # Delete the history item - del readline.rl.mode._history.history[pos] - - # Update the cursor if needed - if saved_cursor > pos: - readline.rl.mode._history.history_cursor -= 1 - - readline.remove_history_item = pyreadline_remove_history_item - -elif 'gnureadline' in sys.modules or 'readline' in sys.modules: - # We don't support libedit. See top of this file for why. - if readline.__doc__ is not None and 'libedit' not in readline.__doc__: - try: - # Load the readline lib so we can access members of it - import ctypes - - readline_lib = ctypes.CDLL(readline.__file__) - except (AttributeError, OSError): # pragma: no cover - _rl_warn_reason = ( - "this application is running in a non-standard Python environment in " - "which GNU readline is not loaded dynamically from a shared library file." - ) - else: - rl_type = RlType.GNU - vt100_support = sys.stdout.isatty() - -# Check if we loaded a supported version of readline -if rl_type == RlType.NONE: # pragma: no cover - if not _rl_warn_reason: - _rl_warn_reason = ( - "no supported version of readline was found. To resolve this, install " - "pyreadline3 on Windows or gnureadline on Linux/Mac." - ) - rl_warning = f"Readline features including tab completion have been disabled because {_rl_warn_reason}\n\n" -else: - rl_warning = '' - - -def rl_force_redisplay() -> None: # pragma: no cover - """Causes readline to display the prompt and input text wherever the cursor is and start reading input from this location. - - This is the proper way to restore the input line after printing to the screen. - """ - if not sys.stdout.isatty(): - return - - if rl_type == RlType.GNU: - readline_lib.rl_forced_update_display() - - # After manually updating the display, readline asks that rl_display_fixed be set to 1 for efficiency - display_fixed = ctypes.c_int.in_dll(readline_lib, "rl_display_fixed") - display_fixed.value = 1 - - elif rl_type == RlType.PYREADLINE: - # Call _print_prompt() first to set the new location of the prompt - readline.rl.mode._print_prompt() - readline.rl.mode._update_line() - - -def rl_get_point() -> int: # pragma: no cover - """Return the offset of the current cursor position in rl_line_buffer.""" - if rl_type == RlType.GNU: - return ctypes.c_int.in_dll(readline_lib, "rl_point").value - - if rl_type == RlType.PYREADLINE: - return int(readline.rl.mode.l_buffer.point) - - return 0 - - -def rl_get_prompt() -> str: # pragma: no cover - """Get Readline's prompt.""" - if rl_type == RlType.GNU: - encoded_prompt = ctypes.c_char_p.in_dll(readline_lib, "rl_prompt").value - prompt = '' if encoded_prompt is None else encoded_prompt.decode(encoding='utf-8') - - elif rl_type == RlType.PYREADLINE: - prompt_data: str | bytes = readline.rl.prompt - prompt = prompt_data.decode(encoding='utf-8') if isinstance(prompt_data, bytes) else prompt_data - - else: - prompt = '' - - return rl_unescape_prompt(prompt) - - -def rl_get_display_prompt() -> str: # pragma: no cover - """Get Readline's currently displayed prompt. - - In GNU Readline, the displayed prompt sometimes differs from the prompt. - This occurs in functions that use the prompt string as a message area, such as incremental search. - """ - if rl_type == RlType.GNU: - encoded_prompt = ctypes.c_char_p.in_dll(readline_lib, "rl_display_prompt").value - prompt = '' if encoded_prompt is None else encoded_prompt.decode(encoding='utf-8') - return rl_unescape_prompt(prompt) - return rl_get_prompt() - - -def rl_set_prompt(prompt: str) -> None: # pragma: no cover - """Set Readline's prompt. - - :param prompt: the new prompt value. - """ - escaped_prompt = rl_escape_prompt(prompt) - - if rl_type == RlType.GNU: - encoded_prompt = bytes(escaped_prompt, encoding='utf-8') - readline_lib.rl_set_prompt(encoded_prompt) - - elif rl_type == RlType.PYREADLINE: - readline.rl.prompt = escaped_prompt - - -def rl_escape_prompt(prompt: str) -> str: - """Overcome bug in GNU Readline in relation to calculation of prompt length in presence of ANSI escape codes. - - :param prompt: original prompt - :return: prompt safe to pass to GNU Readline - """ - if rl_type == RlType.GNU: - # start code to tell GNU Readline about beginning of invisible characters - escape_start = "\x01" - - # end code to tell GNU Readline about end of invisible characters - escape_end = "\x02" - - escaped = False - result = "" - - for c in prompt: - if c == "\x1b" and not escaped: - result += escape_start + c - escaped = True - elif c.isalpha() and escaped: - result += c + escape_end - escaped = False - else: - result += c - - return result - - return prompt - - -def rl_unescape_prompt(prompt: str) -> str: - """Remove escape characters from a Readline prompt.""" - if rl_type == RlType.GNU: - escape_start = "\x01" - escape_end = "\x02" - prompt = prompt.replace(escape_start, "").replace(escape_end, "") - - return prompt - - -def rl_in_search_mode() -> bool: # pragma: no cover - """Check if readline is doing either an incremental (e.g. Ctrl-r) or non-incremental (e.g. Esc-p) search.""" - if rl_type == RlType.GNU: - # GNU Readline defines constants that we can use to determine if in search mode. - # RL_STATE_ISEARCH 0x0000080 - # RL_STATE_NSEARCH 0x0000100 - in_search_mode = 0x0000180 - - readline_state = ctypes.c_int.in_dll(readline_lib, "rl_readline_state").value - return bool(in_search_mode & readline_state) - if rl_type == RlType.PYREADLINE: - from pyreadline3.modes.emacs import ( # type: ignore[import] - EmacsMode, - ) - - # These search modes only apply to Emacs mode, which is the default. - if not isinstance(readline.rl.mode, EmacsMode): - return False - - # While in search mode, the current keyevent function is set to one of the following. - search_funcs = ( - readline.rl.mode._process_incremental_search_keyevent, - readline.rl.mode._process_non_incremental_search_keyevent, - ) - return readline.rl.mode.process_keyevent_queue[-1] in search_funcs - return False - - -__all__ = [ - 'readline', -] diff --git a/cmd2/terminal_utils.py b/cmd2/terminal_utils.py index 1245803f0..4a5a2cddd 100644 --- a/cmd2/terminal_utils.py +++ b/cmd2/terminal_utils.py @@ -96,7 +96,7 @@ def async_alert_str(*, terminal_columns: int, prompt: str, line: str, cursor_off :param terminal_columns: terminal width (number of columns) :param prompt: current onscreen prompt - :param line: current contents of the Readline line buffer + :param line: current contents of the prompt-toolkit line buffer :param cursor_offset: the offset of the current cursor position within line :param alert_msg: the message to display to the user :return: the correct string so that the alert message appears to the user to be printed above the current line. diff --git a/docs/api/index.md b/docs/api/index.md index 36789dc49..47eaf259c 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -24,11 +24,10 @@ incremented according to the [Semantic Version Specification](https://semver.org - [cmd2.history](./history.md) - classes for storing the history of previously entered commands - [cmd2.parsing](./parsing.md) - classes for parsing and storing user input - [cmd2.plugin](./plugin.md) - data classes for hook methods +- [cmd2.pt_utils](./pt_utils.md) - utilities related to prompt-toolkit - [cmd2.py_bridge](./py_bridge.md) - classes for bridging calls from the embedded python environment to the host app - [cmd2.rich_utils](./rich_utils.md) - common utilities to support Rich in cmd2 applications -- [cmd2.rl_utils](./rl_utils.md) - imports the proper Readline for the platform and provides utility - functions for it - [cmd2.string_utils](./string_utils.md) - string utility functions - [cmd2.styles](./styles.md) - cmd2-specific Rich styles and a StrEnum of their corresponding names - [cmd2.terminal_utils](./terminal_utils.md) - support for terminal control escape sequences diff --git a/docs/api/pt_utils.md b/docs/api/pt_utils.md new file mode 100644 index 000000000..f5cd2358a --- /dev/null +++ b/docs/api/pt_utils.md @@ -0,0 +1,3 @@ +# cmd2.pt_utils + +::: cmd2.pt_utils diff --git a/docs/api/rl_utils.md b/docs/api/rl_utils.md deleted file mode 100644 index 52beb31ba..000000000 --- a/docs/api/rl_utils.md +++ /dev/null @@ -1,3 +0,0 @@ -# cmd2.rl_utils - -::: cmd2.rl_utils diff --git a/docs/examples/getting_started.md b/docs/examples/getting_started.md index 6be85b6e3..cd7bed73c 100644 --- a/docs/examples/getting_started.md +++ b/docs/examples/getting_started.md @@ -266,8 +266,10 @@ persist between invocations of your application, you'll need to do a little work Users can access command history using two methods: -- The [readline](https://docs.python.org/3/library/readline.html) library which provides a Python - interface to the [GNU readline library](https://en.wikipedia.org/wiki/GNU_Readline) +- The [prompt-toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit) library which + provides a pure Python replacement for the + [GNU readline library](https://en.wikipedia.org/wiki/GNU_Readline) which is fully cross-platform + compatible - The `history` command which is built-in to `cmd2` From the prompt in a `cmd2`-based application, you can press `Control-p` to move to the previously diff --git a/docs/features/completion.md b/docs/features/completion.md index 36e8a8f48..dc358aa1a 100644 --- a/docs/features/completion.md +++ b/docs/features/completion.md @@ -20,8 +20,8 @@ from `cmd2.Cmd`: complete_foo = cmd2.Cmd.path_complete ``` -This will effectively define the `complete_foo` readline completer method in your class and make it -utilize the same path completion logic as the built-in commands. +This will effectively define the `complete_foo` prompt-toolkit completer method in your class and +make it utilize the same path completion logic as the built-in commands. The built-in logic allows for a few more advanced path completion capabilities, such as cases where you only want to match directories. Suppose you have a custom command `bar` implemented by the diff --git a/docs/features/history.md b/docs/features/history.md index 09b962b39..e88876978 100644 --- a/docs/features/history.md +++ b/docs/features/history.md @@ -4,10 +4,10 @@ The `cmd` module from the Python standard library includes `readline` history. -[cmd2.Cmd][] offers the same `readline` capabilities, but also maintains its own data structures for -the history of all commands entered by the user. When the class is initialized, it creates an -instance of the [cmd2.history.History][] class (which is a subclass of `list`) as -`cmd2.Cmd.history`. +[cmd2.Cmd][] offers the same `readline` capabilitie via use of `prompt-toolkit`, but also maintains +its own data structures for the history of all commands entered by the user. When the class is +initialized, it creates an instance of the [cmd2.history.History][] class (which is a subclass of +`list`) as `cmd2.Cmd.history`. Each time a command is executed (this gets complex, see [Command Processing Loop](./hooks.md#command-processing-loop) for exactly when) the parsed @@ -20,9 +20,9 @@ this format instead of plain text to preserve the complete `cmd2.Statement` obje !!! note - `readline` saves everything you type, whether it is a valid command or not. `cmd2` only saves input to internal history if the command parses successfully and is a valid command. This design choice was intentional, because the contents of history can be saved to a file as a script, or can be re-run. Not saving invalid input reduces unintentional errors when doing so. + `prompt-toolkit` saves everything you type, whether it is a valid command or not. `cmd2` only saves input to internal history if the command parses successfully and is a valid command. This design choice was intentional, because the contents of history can be saved to a file as a script, or can be re-run. Not saving invalid input reduces unintentional errors when doing so. - However, this design choice causes an inconsistency between the `readline` history and the `cmd2` history when you enter an invalid command: it is saved to the `readline` history, but not to the `cmd2` history. + However, this design choice causes an inconsistency between the `prompt-toolkit` history and the `cmd2` history when you enter an invalid command: it is saved to the `prompt-toolkit` history, but not to the `cmd2` history. The `cmd2.Cmd.history` attribute, the `cmd2.history.History` class, and the [cmd2.history.HistoryItem][] class are all part of the public API for `cmd2.Cmd`. You could use @@ -34,13 +34,13 @@ built-in `history` command works). You can use the :arrow_up: up and :arrow_down: down arrow keys to move through the history of previously entered commands. -If the `readline` module is installed, you can press `Control-p` to move to the previously entered -command, and `Control-n` to move to the next command. You can also search through the command -history using `Control-r`. +You can press `Control-p` to move to the previously entered command, and `Control-n` to move to the +next command. You can also search through the command history using `Control-r`. You can refer to the [readline cheat sheet](http://readline.kablamo.org/emacs.html) or you can dig -into the [GNU Readline User Manual](http://man7.org/linux/man-pages/man3/readline.3.html) for all -the details, including instructions for customizing the key bindings. +into the +[Prompt Toolkit User Manual](https://python-prompt-toolkit.readthedocs.io/en/stable/pages/advanced_topics/key_bindings.html) +for all the details, including instructions for customizing the key bindings. `cmd2` makes a third type of history access available with the `history` command. Each time the user enters a command, `cmd2` saves the input. The `history` command lets you do interesting things with diff --git a/docs/features/prompt.md b/docs/features/prompt.md index 2ff3ae0d4..e08ddbac1 100644 --- a/docs/features/prompt.md +++ b/docs/features/prompt.md @@ -31,8 +31,8 @@ for an example of dynamically updating the prompt. `cmd2` provides these functions to provide asynchronous feedback to the user without interfering with the command line. This means the feedback is provided to the user when they are still entering text at the prompt. To use this functionality, the application must be running in a terminal that -supports [VT100](https://en.wikipedia.org/wiki/VT100) control characters and `readline`. Linux, Mac, -and Windows 10 and greater all support these. +supports [VT100](https://en.wikipedia.org/wiki/VT100) control characters. Linux, Mac, and Windows 10 +and greater all support these. - [cmd2.Cmd.async_alert][] - [cmd2.Cmd.async_update_prompt][] diff --git a/docs/migrating/why.md b/docs/migrating/why.md index c73e8ae61..d8ed06fb3 100644 --- a/docs/migrating/why.md +++ b/docs/migrating/why.md @@ -32,10 +32,10 @@ top-notch interactive command-line experience for their users. After switching from [cmd][cmd] to `cmd2`, your application will have the following new features and capabilities, without you having to do anything: -- More robust [History](../features/history.md). Both [cmd][cmd] and `cmd2` have readline history, - but `cmd2` also has a robust `history` command which allows you to edit prior commands in a text - editor of your choosing, re-run multiple commands at a time, save prior commands as a script to be - executed later, and much more. +- More robust [History](../features/history.md). Both [cmd][cmd] and `cmd2` have readline-style + history, but `cmd2` also has a robust `history` command which allows you to edit prior commands in + a text editor of your choosing, re-run multiple commands at a time, save prior commands as a + script to be executed later, and much more. - Users can redirect output to a file or pipe it to some other operating system command. You did remember to use `self.stdout` instead of `sys.stdout` in all of your print functions, right? If you did, then this will work out of the box. If you didn't, you'll have to go back and fix them. diff --git a/docs/overview/installation.md b/docs/overview/installation.md index d9c2cc9d0..5f8504658 100644 --- a/docs/overview/installation.md +++ b/docs/overview/installation.md @@ -88,36 +88,3 @@ If you wish to permanently uninstall `cmd2`, this can also easily be done with [pip](https://pypi.org/project/pip): $ pip uninstall cmd2 - -## readline Considerations - -`cmd2` heavily relies on Python's built-in -[readline](https://docs.python.org/3/library/readline.html) module for its tab completion -capabilities. Tab completion for `cmd2` applications is only tested against :simple-gnu: -[GNU Readline](https://tiswww.case.edu/php/chet/readline/rltop.html) or libraries fully compatible -with it. It does not work properly with the :simple-netbsd: NetBSD -[Editline](http://thrysoee.dk/editline/) library (`libedit`) which is similar, but not identical to -GNU Readline. `cmd2` will disable all tab-completion support if an incompatible version of -`readline` is found. - -When installed using `pip`, `uv`, or similar Python packaging tool on either `macOS` or `Windows`, -`cmd2` will automatically install a compatible version of readline. - -Most Linux operating systems come with a compatible version of readline. However, if you are using a -tool like `uv` to install Python on your system and configure a virtual environment, `uv` installed -versions of Python come with `libedit`. If you are using `cmd2` on Linux with a version of Python -installed via `uv`, you will likely need to manually add the `gnureadline` Python module to your -`uv` virtual environment. - -```sh -uv pip install gnureadline -``` - -macOS comes with the [libedit](http://thrysoee.dk/editline/) library which is similar, but not -identical, to GNU Readline. Tab completion for `cmd2` applications is only tested against GNU -Readline. In this case you just need to install the `gnureadline` Python package which is statically -linked against GNU Readline: - -```shell -$ pip install -U gnureadline -``` diff --git a/docs/overview/integrating.md b/docs/overview/integrating.md index b119deb86..66408d6c7 100644 --- a/docs/overview/integrating.md +++ b/docs/overview/integrating.md @@ -13,16 +13,3 @@ We recommend that you follow the advice given by the Python Packaging User Guide [install_requires](https://packaging.python.org/discussions/install-requires-vs-requirements/). By setting an upper bound on the allowed version, you can ensure that your project does not inadvertently get installed with an incompatible future version of `cmd2`. - -## OS Considerations - -If you would like to use [Tab Completion](../features/completion.md), then you need a compatible -version of [readline](https://tiswww.case.edu/php/chet/readline/rltop.html) installed on your -operating system (OS). `cmd2` forces a sane install of `readline` on both `Windows` and `macOS`, but -does not do so on `Linux`. If for some reason, you have a version of Python on a Linux OS who's -built-in `readline` module is based on the -[Editline Library (libedit)](https://www.thrysoee.dk/editline/) instead of `readline`, you will need -to manually add a dependency on `gnureadline`. Make sure to include the following dependency in your -`pyproject.toml` or `setup.py`: - - 'gnureadline' diff --git a/mkdocs.yml b/mkdocs.yml index bd5a9a911..137c42e07 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -204,9 +204,9 @@ nav: - api/history.md - api/parsing.md - api/plugin.md + - api/pt_utils.md - api/py_bridge.md - api/rich_utils.md - - api/rl_utils.md - api/string_utils.md - api/styles.md - api/terminal_utils.md diff --git a/pyproject.toml b/pyproject.toml index 65d313f9e..7e18800ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,9 +30,8 @@ classifiers = [ ] dependencies = [ "backports.strenum; python_version == '3.10'", - "gnureadline>=8; platform_system == 'Darwin'", + "prompt-toolkit>=3.0.52", "pyperclip>=1.8.2", - "pyreadline3>=3.4; platform_system == 'Windows'", "rich>=14.1.0", "rich-argparse>=1.7.1", ] diff --git a/tests/conftest.py b/tests/conftest.py index fa31b42b9..f9c83a3ec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,13 +11,11 @@ TypeVar, cast, ) -from unittest import mock import pytest import cmd2 from cmd2 import rich_utils as ru -from cmd2.rl_utils import readline from cmd2.utils import StdSim # For type hinting decorators @@ -122,8 +120,8 @@ def cmd_wrapper(*args: P.args, **kwargs: P.kwargs) -> T: def complete_tester(text: str, line: str, begidx: int, endidx: int, app: cmd2.Cmd) -> str | None: """This is a convenience function to test cmd2.complete() since - in a unit test environment there is no actual console readline - is monitoring. Therefore we use mock to provide readline data + in a unit test environment there is no actual console prompt-toolkit + is monitoring. Therefore we use mock to provide prompt-toolkit data to complete(). :param text: the string prefix we are attempting to match @@ -145,13 +143,15 @@ def get_begidx() -> int: def get_endidx() -> int: return endidx - # Run the readline tab completion function with readline mocks in place - with ( - mock.patch.object(readline, 'get_line_buffer', get_line), - mock.patch.object(readline, 'get_begidx', get_begidx), - mock.patch.object(readline, 'get_endidx', get_endidx), - ): - return app.complete(text, 0) + # Run the prompt-toolkit tab completion function with mocks in place + res = app.complete(text, 0, line, begidx, endidx) + + # If the completion resulted in a hint being set, then print it now + # so that it can be captured by tests using capsys. + if app.completion_hint: + print(app.completion_hint) + + return res def find_subcommand(action: argparse.ArgumentParser, subcmd_names: list[str]) -> argparse.ArgumentParser: diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index a46018904..5622c2393 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1,6 +1,5 @@ """Cmd2 unit/functional testing""" -import builtins import io import os import signal @@ -31,9 +30,6 @@ from cmd2 import rich_utils as ru from cmd2 import string_utils as su -# This ensures gnureadline is used in macOS tests -from cmd2.rl_utils import readline # type: ignore[atrr-defined] - from .conftest import ( SHORTCUTS_TXT, complete_tester, @@ -390,9 +386,10 @@ def test_run_script_with_binary_file(base_app, request) -> None: assert base_app.last_result is False -def test_run_script_with_python_file(base_app, request) -> None: - m = mock.MagicMock(name='input', return_value='2') - builtins.input = m +def test_run_script_with_python_file(base_app, request, monkeypatch) -> None: + # Mock out the read_input call so we don't actually wait for a user's response on stdin + read_input_mock = mock.MagicMock(name='read_input', return_value='2') + monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock) test_dir = os.path.dirname(request.module.__file__) filename = os.path.join(test_dir, 'pyscript', 'stop.py') @@ -1025,7 +1022,7 @@ def test_base_cmdloop_with_startup_commands() -> None: assert out == expected -def test_base_cmdloop_without_startup_commands() -> None: +def test_base_cmdloop_without_startup_commands(monkeypatch) -> None: # Need to patch sys.argv so cmd2 doesn't think it was called with arguments equal to the py.test args testargs = ["prog"] with mock.patch.object(sys, 'argv', testargs): @@ -1034,9 +1031,9 @@ def test_base_cmdloop_without_startup_commands() -> None: app.use_rawinput = True app.intro = 'Hello World, this is an intro ...' - # Mock out the input call so we don't actually wait for a user's response on stdin - m = mock.MagicMock(name='input', return_value='quit') - builtins.input = m + # Mock out the read_input call so we don't actually wait for a user's response on stdin + read_input_mock = mock.MagicMock(name='read_input', return_value='quit') + monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock) expected = app.intro + '\n' @@ -1046,7 +1043,7 @@ def test_base_cmdloop_without_startup_commands() -> None: assert out == expected -def test_cmdloop_without_rawinput() -> None: +def test_cmdloop_without_rawinput(monkeypatch) -> None: # Need to patch sys.argv so cmd2 doesn't think it was called with arguments equal to the py.test args testargs = ["prog"] with mock.patch.object(sys, 'argv', testargs): @@ -1056,14 +1053,13 @@ def test_cmdloop_without_rawinput() -> None: app.echo = False app.intro = 'Hello World, this is an intro ...' - # Mock out the input call so we don't actually wait for a user's response on stdin - m = mock.MagicMock(name='input', return_value='quit') - builtins.input = m + # Mock out the read_input call so we don't actually wait for a user's response on stdin + read_input_mock = mock.MagicMock(name='read_input', return_value='quit') + monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock) expected = app.intro + '\n' - with pytest.raises(OSError): # noqa: PT011 - app.cmdloop() + app.cmdloop() out = app.stdout.getvalue() assert out == expected @@ -1203,11 +1199,11 @@ def say_app(): return app -def test_ctrl_c_at_prompt(say_app) -> None: - # Mock out the input call so we don't actually wait for a user's response on stdin - m = mock.MagicMock(name='input') - m.side_effect = ['say hello', KeyboardInterrupt(), 'say goodbye', 'eof'] - builtins.input = m +def test_ctrl_c_at_prompt(say_app, monkeypatch) -> None: + # Mock out the read_input call so we don't actually wait for a user's response on stdin + read_input_mock = mock.MagicMock(name='read_input') + read_input_mock.side_effect = ['say hello', KeyboardInterrupt(), 'say goodbye', 'eof'] + monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock) say_app.cmdloop() @@ -1236,33 +1232,19 @@ def test_default_to_shell(base_app, monkeypatch) -> None: assert m.called -def test_escaping_prompt() -> None: - from cmd2.rl_utils import ( - rl_escape_prompt, - rl_unescape_prompt, - ) - - # This prompt has nothing which needs to be escaped - prompt = '(Cmd) ' - assert rl_escape_prompt(prompt) == prompt - - # This prompt has color which needs to be escaped - prompt = stylize('InColor', style=Color.CYAN) - - escape_start = "\x01" - escape_end = "\x02" +def test_visible_prompt() -> None: + app = cmd2.Cmd() - escaped_prompt = rl_escape_prompt(prompt) - if sys.platform.startswith('win'): - # PyReadline on Windows doesn't need to escape invisible characters - assert escaped_prompt == prompt - else: - cyan = "\x1b[36m" - reset_all = "\x1b[0m" - assert escaped_prompt.startswith(escape_start + cyan + escape_end) - assert escaped_prompt.endswith(escape_start + reset_all + escape_end) + # This prompt has nothing which needs to be stripped + app.prompt = '(Cmd) ' + assert app.visible_prompt == app.prompt + assert su.str_width(app.prompt) == len(app.prompt) - assert rl_unescape_prompt(escaped_prompt) == prompt + # This prompt has color which needs to be stripped + color_prompt = stylize('InColor', style=Color.CYAN) + '> ' + app.prompt = color_prompt + assert app.visible_prompt == 'InColor> ' + assert su.str_width(app.prompt) == len('InColor> ') class HelpApp(cmd2.Cmd): @@ -1771,11 +1753,11 @@ def test_multiline_complete_empty_statement_raises_exception(multiline_app) -> N multiline_app._complete_statement('') -def test_multiline_complete_statement_without_terminator(multiline_app) -> None: +def test_multiline_complete_statement_without_terminator(multiline_app, monkeypatch) -> None: # Mock out the input call so we don't actually wait for a user's response # on stdin when it looks for more input - m = mock.MagicMock(name='input', return_value='\n') - builtins.input = m + read_input_mock = mock.MagicMock(name='read_input', return_value='\n') + monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock) command = 'orate' args = 'hello world' @@ -1786,11 +1768,11 @@ def test_multiline_complete_statement_without_terminator(multiline_app) -> None: assert statement.multiline_command == command -def test_multiline_complete_statement_with_unclosed_quotes(multiline_app) -> None: +def test_multiline_complete_statement_with_unclosed_quotes(multiline_app, monkeypatch) -> None: # Mock out the input call so we don't actually wait for a user's response # on stdin when it looks for more input - m = mock.MagicMock(name='input', side_effect=['quotes', '" now closed;']) - builtins.input = m + read_input_mock = mock.MagicMock(name='read_input', side_effect=['quotes', '" now closed;']) + monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock) line = 'orate hi "partially open' statement = multiline_app._complete_statement(line) @@ -1800,114 +1782,55 @@ def test_multiline_complete_statement_with_unclosed_quotes(multiline_app) -> Non assert statement.terminator == ';' -def test_multiline_input_line_to_statement(multiline_app) -> None: +def test_multiline_input_line_to_statement(multiline_app, monkeypatch) -> None: # Verify _input_line_to_statement saves the fully entered input line for multiline commands # Mock out the input call so we don't actually wait for a user's response # on stdin when it looks for more input - m = mock.MagicMock(name='input', side_effect=['person', '\n']) - builtins.input = m + read_input_mock = mock.MagicMock(name='read_input', side_effect=['person', '\n']) + monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock) line = 'orate hi' statement = multiline_app._input_line_to_statement(line) - assert statement.raw == 'orate hi\nperson\n' + assert statement.raw == 'orate hi\nperson\n\n' assert statement == 'hi person' assert statement.command == 'orate' assert statement.multiline_command == 'orate' -def test_multiline_history_no_prior_history(multiline_app) -> None: - # Test no existing history prior to typing the command - m = mock.MagicMock(name='input', side_effect=['person', '\n']) - builtins.input = m - - # Set orig_rl_history_length to 0 before the first line is typed. - readline.clear_history() - orig_rl_history_length = readline.get_current_history_length() - - line = "orate hi" - readline.add_history(line) - multiline_app._complete_statement(line, orig_rl_history_length=orig_rl_history_length) - - assert readline.get_current_history_length() == orig_rl_history_length + 1 - assert readline.get_history_item(1) == "orate hi person" - - -def test_multiline_history_first_line_matches_prev_entry(multiline_app) -> None: - # Test when first line of multiline command matches previous history entry - m = mock.MagicMock(name='input', side_effect=['person', '\n']) - builtins.input = m - - # Since the first line of our command matches the previous entry, - # orig_rl_history_length is set before the first line is typed. - line = "orate hi" - readline.clear_history() - readline.add_history(line) - orig_rl_history_length = readline.get_current_history_length() - - multiline_app._complete_statement(line, orig_rl_history_length=orig_rl_history_length) - - assert readline.get_current_history_length() == orig_rl_history_length + 1 - assert readline.get_history_item(1) == line - assert readline.get_history_item(2) == "orate hi person" - - -def test_multiline_history_matches_prev_entry(multiline_app) -> None: - # Test combined multiline command that matches previous history entry - m = mock.MagicMock(name='input', side_effect=['person', '\n']) - builtins.input = m - - readline.clear_history() - readline.add_history("orate hi person") - orig_rl_history_length = readline.get_current_history_length() - - line = "orate hi" - readline.add_history(line) - multiline_app._complete_statement(line, orig_rl_history_length=orig_rl_history_length) - - # Since it matches the previous history item, nothing was added to readline history - assert readline.get_current_history_length() == orig_rl_history_length - assert readline.get_history_item(1) == "orate hi person" - - -def test_multiline_history_does_not_match_prev_entry(multiline_app) -> None: - # Test combined multiline command that does not match previous history entry - m = mock.MagicMock(name='input', side_effect=['person', '\n']) - builtins.input = m +def test_multiline_history_added(multiline_app, monkeypatch) -> None: + # Test that multiline commands are added to history as a single item + read_input_mock = mock.MagicMock(name='read_input', side_effect=['person', '\n']) + monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock) - readline.clear_history() - readline.add_history("no match") - orig_rl_history_length = readline.get_current_history_length() + multiline_app.history.clear() - line = "orate hi" - readline.add_history(line) - multiline_app._complete_statement(line, orig_rl_history_length=orig_rl_history_length) + # run_cmd calls onecmd_plus_hooks which triggers history addition + run_cmd(multiline_app, "orate hi") - # Since it doesn't match the previous history item, it was added to readline history - assert readline.get_current_history_length() == orig_rl_history_length + 1 - assert readline.get_history_item(1) == "no match" - assert readline.get_history_item(2) == "orate hi person" + assert len(multiline_app.history) == 1 + assert multiline_app.history.get(1).raw == "orate hi\nperson\n\n" -def test_multiline_history_with_quotes(multiline_app) -> None: - # Test combined multiline command with quotes - m = mock.MagicMock(name='input', side_effect=[' and spaces ', ' "', ' in', 'quotes.', ';']) - builtins.input = m +def test_multiline_history_with_quotes(multiline_app, monkeypatch) -> None: + # Test combined multiline command with quotes is added to history correctly + read_input_mock = mock.MagicMock(name='read_input', side_effect=[' and spaces ', ' "', ' in', 'quotes.', ';']) + monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock) - readline.clear_history() - orig_rl_history_length = readline.get_current_history_length() + multiline_app.history.clear() line = 'orate Look, "There are newlines' - readline.add_history(line) - multiline_app._complete_statement(line, orig_rl_history_length=orig_rl_history_length) - - # Since spaces and newlines in quotes are preserved, this history entry spans multiple lines. - assert readline.get_current_history_length() == orig_rl_history_length + 1 + run_cmd(multiline_app, line) - history_lines = readline.get_history_item(1).splitlines() + assert len(multiline_app.history) == 1 + history_item = multiline_app.history.get(1) + history_lines = history_item.raw.splitlines() assert history_lines[0] == 'orate Look, "There are newlines' assert history_lines[1] == ' and spaces ' - assert history_lines[2] == ' " in quotes.;' + assert history_lines[2] == ' "' + assert history_lines[3] == ' in' + assert history_lines[4] == 'quotes.' + assert history_lines[5] == ';' class CommandResultApp(cmd2.Cmd): @@ -1995,62 +1918,63 @@ def test_read_input_rawinput_true(capsys, monkeypatch) -> None: app = cmd2.Cmd() app.use_rawinput = True - # Mock out input() to return input_str - monkeypatch.setattr("builtins.input", lambda *args: input_str) - - # isatty is True - with mock.patch('sys.stdin.isatty', mock.MagicMock(name='isatty', return_value=True)): - line = app.read_input(prompt_str) - assert line == input_str - - # Run custom history code - readline.add_history('old_history') - custom_history = ['cmd1', 'cmd2'] - line = app.read_input(prompt_str, history=custom_history, completion_mode=cmd2.CompletionMode.NONE) - assert line == input_str - readline.clear_history() - - # Run all completion modes - line = app.read_input(prompt_str, completion_mode=cmd2.CompletionMode.NONE) - assert line == input_str - - line = app.read_input(prompt_str, completion_mode=cmd2.CompletionMode.COMMANDS) - assert line == input_str - - # custom choices - custom_choices = ['choice1', 'choice2'] - line = app.read_input(prompt_str, completion_mode=cmd2.CompletionMode.CUSTOM, choices=custom_choices) - assert line == input_str - - # custom choices_provider - line = app.read_input( - prompt_str, completion_mode=cmd2.CompletionMode.CUSTOM, choices_provider=cmd2.Cmd.get_all_commands - ) - assert line == input_str - - # custom completer - line = app.read_input(prompt_str, completion_mode=cmd2.CompletionMode.CUSTOM, completer=cmd2.Cmd.path_complete) - assert line == input_str - - # custom parser - line = app.read_input(prompt_str, completion_mode=cmd2.CompletionMode.CUSTOM, parser=cmd2.Cmd2ArgumentParser()) - assert line == input_str - - # isatty is False - with mock.patch('sys.stdin.isatty', mock.MagicMock(name='isatty', return_value=False)): - # echo True - app.echo = True - line = app.read_input(prompt_str) - out, _err = capsys.readouterr() - assert line == input_str - assert out == f"{prompt_str}{input_str}\n" - - # echo False - app.echo = False - line = app.read_input(prompt_str) - out, _err = capsys.readouterr() - assert line == input_str - assert not out + # Mock PromptSession.prompt (used when isatty=False) + # Also mock patch_stdout to prevent it from attempting to access the Windows console buffer in a Windows test environment + with ( + mock.patch('cmd2.cmd2.PromptSession.prompt', return_value=input_str), + mock.patch('cmd2.cmd2.patch_stdout'), + ): + # isatty is True + with mock.patch('sys.stdin.isatty', mock.MagicMock(name='isatty', return_value=True)): + line = app.read_input(prompt_str) + assert line == input_str + + # Run custom history code + custom_history = ['cmd1', 'cmd2'] + line = app.read_input(prompt_str, history=custom_history, completion_mode=cmd2.CompletionMode.NONE) + assert line == input_str + + # Run all completion modes + line = app.read_input(prompt_str, completion_mode=cmd2.CompletionMode.NONE) + assert line == input_str + + line = app.read_input(prompt_str, completion_mode=cmd2.CompletionMode.COMMANDS) + assert line == input_str + + # custom choices + custom_choices = ['choice1', 'choice2'] + line = app.read_input(prompt_str, completion_mode=cmd2.CompletionMode.CUSTOM, choices=custom_choices) + assert line == input_str + + # custom choices_provider + line = app.read_input( + prompt_str, completion_mode=cmd2.CompletionMode.CUSTOM, choices_provider=cmd2.Cmd.get_all_commands + ) + assert line == input_str + + # custom completer + line = app.read_input(prompt_str, completion_mode=cmd2.CompletionMode.CUSTOM, completer=cmd2.Cmd.path_complete) + assert line == input_str + + # custom parser + line = app.read_input(prompt_str, completion_mode=cmd2.CompletionMode.CUSTOM, parser=cmd2.Cmd2ArgumentParser()) + assert line == input_str + + # isatty is False + with mock.patch('sys.stdin.isatty', mock.MagicMock(name='isatty', return_value=False)): + # echo True + app.echo = True + line = app.read_input(prompt_str) + out, _err = capsys.readouterr() + assert line == input_str + assert out == f"{prompt_str}{input_str}\n" + + # echo False + app.echo = False + line = app.read_input(prompt_str) + out, _err = capsys.readouterr() + assert line == input_str + assert not out def test_read_input_rawinput_false(capsys, monkeypatch) -> None: @@ -2068,24 +1992,31 @@ def make_app(isatty: bool, empty_input: bool = False): new_app.use_rawinput = False return new_app + def mock_pt_prompt(message='', **kwargs): + # Emulate prompt printing for isatty=True case + if message: + print(message, end='') + return input_str + # isatty True app = make_app(isatty=True) - line = app.read_input(prompt_str) + with mock.patch('cmd2.cmd2.PromptSession.prompt', side_effect=mock_pt_prompt): + line = app.read_input(prompt_str) out, _err = capsys.readouterr() assert line == input_str assert out == prompt_str # isatty True, empty input app = make_app(isatty=True, empty_input=True) - line = app.read_input(prompt_str) + with mock.patch('cmd2.cmd2.PromptSession.prompt', return_value=''), pytest.raises(EOFError): + app.read_input(prompt_str) out, _err = capsys.readouterr() - assert line == 'eof' - assert out == prompt_str # isatty is False, echo is True app = make_app(isatty=False) app.echo = True - line = app.read_input(prompt_str) + with mock.patch('cmd2.cmd2.PromptSession.prompt', return_value=input_str): + line = app.read_input(prompt_str) out, _err = capsys.readouterr() assert line == input_str assert out == f"{prompt_str}{input_str}\n" @@ -2093,17 +2024,17 @@ def make_app(isatty: bool, empty_input: bool = False): # isatty is False, echo is False app = make_app(isatty=False) app.echo = False - line = app.read_input(prompt_str) + with mock.patch('cmd2.cmd2.PromptSession.prompt', return_value=input_str): + line = app.read_input(prompt_str) out, _err = capsys.readouterr() assert line == input_str assert not out # isatty is False, empty input app = make_app(isatty=False, empty_input=True) - line = app.read_input(prompt_str) + with mock.patch('cmd2.cmd2.PromptSession.prompt', return_value=''), pytest.raises(EOFError): + app.read_input(prompt_str) out, _err = capsys.readouterr() - assert line == 'eof' - assert not out def test_custom_stdout() -> None: @@ -2355,6 +2286,28 @@ def test_get_settable_completion_items(base_app) -> None: assert cur_settable.description[0:10] in cur_res.descriptive_data[1] +def test_completion_supported(base_app) -> None: + # use_rawinput is True and completekey is non-empty -> True + base_app.use_rawinput = True + base_app.completekey = 'tab' + assert base_app._completion_supported() is True + + # use_rawinput is False and completekey is non-empty -> False + base_app.use_rawinput = False + base_app.completekey = 'tab' + assert base_app._completion_supported() is False + + # use_rawinput is True and completekey is empty -> False + base_app.use_rawinput = True + base_app.completekey = '' + assert base_app._completion_supported() is False + + # use_rawinput is False and completekey is empty -> False + base_app.use_rawinput = False + base_app.completekey = '' + assert base_app._completion_supported() is False + + def test_alias_no_subcommand(base_app) -> None: _out, err = run_cmd(base_app, 'alias') assert "Usage: alias [-h]" in err[0] @@ -2923,13 +2876,13 @@ def exit_code_repl(): return app -def test_exit_code_default(exit_code_repl) -> None: +def test_exit_code_default(exit_code_repl, monkeypatch) -> None: app = exit_code_repl app.use_rawinput = True # Mock out the input call so we don't actually wait for a user's response on stdin - m = mock.MagicMock(name='input', return_value='exit') - builtins.input = m + read_input_mock = mock.MagicMock(name='read_input', return_value='exit') + monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock) expected = 'exiting with code: 0\n' @@ -2939,13 +2892,13 @@ def test_exit_code_default(exit_code_repl) -> None: assert out == expected -def test_exit_code_nonzero(exit_code_repl) -> None: +def test_exit_code_nonzero(exit_code_repl, monkeypatch) -> None: app = exit_code_repl app.use_rawinput = True # Mock out the input call so we don't actually wait for a user's response on stdin - m = mock.MagicMock(name='input', return_value='exit 23') - builtins.input = m + read_input_mock = mock.MagicMock(name='read_input', return_value='exit 23') + monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock) expected = 'exiting with code: 23\n' @@ -3313,3 +3266,251 @@ class SynonymApp(cmd2.cmd2.Cmd): assert synonym_parser is not None assert synonym_parser is help_parser + + +def test_custom_completekey(): + # Test setting a custom completekey + app = cmd2.Cmd(completekey='?') + assert app.completekey == '?' + + +def test_prompt_session_init_exception(monkeypatch): + from prompt_toolkit.shortcuts import PromptSession + + # Mock PromptSession to raise ValueError on first call, then succeed + valid_session_mock = mock.MagicMock(spec=PromptSession) + mock_session = mock.MagicMock(side_effect=[ValueError, valid_session_mock]) + monkeypatch.setattr("cmd2.cmd2.PromptSession", mock_session) + + cmd2.Cmd() + # Check that fallback to DummyInput/Output happened + from prompt_toolkit.input import DummyInput + from prompt_toolkit.output import DummyOutput + + assert mock_session.call_count == 2 + # Check args of second call + call_args = mock_session.call_args_list[1] + kwargs = call_args[1] + assert isinstance(kwargs['input'], DummyInput) + assert isinstance(kwargs['output'], DummyOutput) + + +def test_pager_on_windows(monkeypatch): + monkeypatch.setattr("sys.platform", "win32") + app = cmd2.Cmd() + assert app.pager == 'more' + assert app.pager_chop == 'more' + + +def test_path_complete_users_windows(monkeypatch, base_app): + monkeypatch.setattr("sys.platform", "win32") + + # Mock os.path.expanduser and isdir + monkeypatch.setattr("os.path.expanduser", lambda p: '/home/user' if p == '~user' else p) + monkeypatch.setattr("os.path.isdir", lambda p: p == '/home/user') + + matches = base_app.path_complete('~user', 'cmd ~user', 0, 9) + # Should contain ~user/ (or ~user\ depending on sep) + # Since we didn't mock os.path.sep, it will use system separator. + expected = '~user' + os.path.sep + assert expected in matches + + +def test_async_alert_success(base_app): + import threading + + success = [] + + def run_alert(): + base_app.async_alert("Alert Message", new_prompt="(New) ") + success.append(True) + + t = threading.Thread(target=run_alert) + t.start() + t.join() + + assert success + assert base_app.prompt == "(New) " + + +def test_async_alert_main_thread_error(base_app): + with pytest.raises(RuntimeError, match="main thread"): + base_app.async_alert("fail") + + +def test_async_alert_lock_held(base_app): + import threading + + # Acquire lock in main thread + base_app.terminal_lock.acquire() + + exceptions = [] + + def run_alert(): + try: + base_app.async_alert("fail") + except RuntimeError as e: + exceptions.append(e) + finally: + pass + + try: + t = threading.Thread(target=run_alert) + t.start() + t.join() + finally: + base_app.terminal_lock.release() + + assert len(exceptions) == 1 + assert "another thread holds terminal_lock" in str(exceptions[0]) + + +def test_bottom_toolbar(base_app): + from prompt_toolkit.formatted_text import ANSI + + # Test when both are empty + base_app.formatted_completions = '' + base_app.completion_hint = '' + assert base_app._bottom_toolbar() is None + + # Test when formatted_completions is set + text = 'completions' + base_app.formatted_completions = text + '\n' + base_app.completion_hint = '' + result = base_app._bottom_toolbar() + assert isinstance(result, ANSI) + + # Test when completion_hint is set + hint = 'hint' + base_app.formatted_completions = '' + base_app.completion_hint = hint + ' ' + result = base_app._bottom_toolbar() + assert isinstance(result, ANSI) + + # Test prioritization and rstrip + with mock.patch('cmd2.cmd2.ANSI', wraps=ANSI) as mock_ansi: + # formatted_completions takes precedence + base_app.formatted_completions = 'formatted\n' + base_app.completion_hint = 'hint' + base_app._bottom_toolbar() + mock_ansi.assert_called_with('formatted') + + # completion_hint used when formatted_completions is empty + base_app.formatted_completions = '' + base_app.completion_hint = 'hint ' + base_app._bottom_toolbar() + mock_ansi.assert_called_with('hint') + + +def test_multiline_complete_statement_keyboard_interrupt(multiline_app, monkeypatch): + # Mock read_input to raise KeyboardInterrupt + read_input_mock = mock.MagicMock(name='read_input', side_effect=KeyboardInterrupt) + monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock) + + # Mock poutput to verify ^C is printed + poutput_mock = mock.MagicMock(name='poutput') + monkeypatch.setattr(multiline_app, 'poutput', poutput_mock) + + with pytest.raises(exceptions.EmptyStatement): + multiline_app._complete_statement('orate incomplete') + + poutput_mock.assert_called_with('^C') + + +def test_complete_optional_args_defaults(base_app) -> None: + # Test that complete can be called with just text and state + complete_val = base_app.complete('test', 0) + assert complete_val is None + + +def test_prompt_session_init_no_console_error(monkeypatch): + from prompt_toolkit.shortcuts import PromptSession + + from cmd2.cmd2 import NoConsoleScreenBufferError + + # Mock PromptSession to raise NoConsoleScreenBufferError on first call, then succeed + valid_session_mock = mock.MagicMock(spec=PromptSession) + mock_session = mock.MagicMock(side_effect=[NoConsoleScreenBufferError, valid_session_mock]) + monkeypatch.setattr("cmd2.cmd2.PromptSession", mock_session) + + cmd2.Cmd() + + # Check that fallback to DummyInput/Output happened + from prompt_toolkit.input import DummyInput + from prompt_toolkit.output import DummyOutput + + assert mock_session.call_count == 2 + # Check args of second call + call_args = mock_session.call_args_list[1] + kwargs = call_args[1] + assert isinstance(kwargs['input'], DummyInput) + assert isinstance(kwargs['output'], DummyOutput) + + +def test_no_console_screen_buffer_error_dummy(): + from cmd2.cmd2 import NoConsoleScreenBufferError + + # Check that it behaves like a normal exception + err = NoConsoleScreenBufferError() + assert isinstance(err, Exception) + + +def test_read_input_dynamic_prompt(base_app, monkeypatch): + """Test that read_input uses a dynamic prompt when provided prompt matches app.prompt""" + input_str = 'some input' + base_app.use_rawinput = True + + # Mock PromptSession.prompt + # Also mock patch_stdout to prevent it from attempting to access the Windows console buffer in a Windows test environment + with ( + mock.patch('cmd2.cmd2.PromptSession.prompt', return_value=input_str) as mock_prompt, + mock.patch('cmd2.cmd2.patch_stdout'), + mock.patch('sys.stdin.isatty', mock.MagicMock(name='isatty', return_value=True)), + ): + # Call with exact app prompt + line = base_app.read_input(base_app.prompt) + assert line == input_str + + # Check that mock_prompt was called with a callable for the prompt + # args[0] should be the prompt_to_use + args, _ = mock_prompt.call_args + prompt_arg = args[0] + assert callable(prompt_arg) + + # Verify the callable returns the expected ANSI formatted prompt + from prompt_toolkit.formatted_text import ANSI + + result = prompt_arg() + assert isinstance(result, ANSI) + assert result.value == ANSI(base_app.prompt).value + + +def test_read_input_dynamic_prompt_with_history(base_app, monkeypatch): + """Test that read_input uses a dynamic prompt when provided prompt matches app.prompt and history is provided""" + input_str = 'some input' + base_app.use_rawinput = True + custom_history = ['cmd1', 'cmd2'] + + # Mock PromptSession.prompt + # Also mock patch_stdout to prevent it from attempting to access the Windows console buffer in a Windows test environment + with ( + mock.patch('cmd2.cmd2.PromptSession.prompt', return_value=input_str) as mock_prompt, + mock.patch('cmd2.cmd2.patch_stdout'), + mock.patch('sys.stdin.isatty', mock.MagicMock(name='isatty', return_value=True)), + ): + # Call with exact app prompt and history + line = base_app.read_input(base_app.prompt, history=custom_history) + assert line == input_str + + # Check that mock_prompt was called with a callable for the prompt + # args[0] should be the prompt_to_use + args, _ = mock_prompt.call_args + prompt_arg = args[0] + assert callable(prompt_arg) + + # Verify the callable returns the expected ANSI formatted prompt + from prompt_toolkit.formatted_text import ANSI + + result = prompt_arg() + assert isinstance(result, ANSI) + assert result.value == ANSI(base_app.prompt).value diff --git a/tests/test_completion.py b/tests/test_completion.py index bd31bd3fa..e2810fdb0 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -1,6 +1,6 @@ -"""Unit/functional testing for readline tab completion functions in the cmd2.py module. +"""Unit/functional testing for prompt-toolkit tab completion functions in the cmd2.py module. -These are primarily tests related to readline completer functions which handle tab completion of cmd2/cmd commands, +These are primarily tests related to prompt-toolkit completer functions which handle tab completion of cmd2/cmd commands, file system paths, and shell commands. """ diff --git a/tests/test_history.py b/tests/test_history.py index 1754f84f9..7d4485af9 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -28,19 +28,27 @@ def verify_hi_last_result(app: cmd2.Cmd, expected_length: int) -> None: # -# readline tests +# prompt-toolkit tests # -def test_readline_remove_history_item() -> None: - from cmd2.rl_utils import ( - readline, - ) +def test_pt_add_history_item() -> None: + from prompt_toolkit import PromptSession + from prompt_toolkit.history import InMemoryHistory + from prompt_toolkit.input import DummyInput + from prompt_toolkit.output import DummyOutput + + # Create a history object and add some initial items + history = InMemoryHistory() + history.append_string('command one') + history.append_string('command two') + assert 'command one' in history.get_strings() + assert len(history.get_strings()) == 2 + + # Start a session and use this history + session = PromptSession(history=history, input=DummyInput(), output=DummyOutput()) - readline.clear_history() - assert readline.get_current_history_length() == 0 - readline.add_history('this is a test') - assert readline.get_current_history_length() == 1 - readline.remove_history_item(0) - assert readline.get_current_history_length() == 0 + session.history.get_strings().append('new command') + assert 'new command' not in session.history.get_strings() + assert len(history.get_strings()) == 2 # @@ -949,7 +957,7 @@ def test_history_file_bad_json(mocker, capsys) -> None: assert 'Error processing persistent history data' in err -def test_history_populates_readline(hist_file) -> None: +def test_history_populates_pt(hist_file) -> None: # - create a cmd2 with persistent history app = cmd2.Cmd(persistent_history_file=hist_file) run_cmd(app, 'help') @@ -967,17 +975,14 @@ def test_history_populates_readline(hist_file) -> None: assert app.history.get(3).statement.raw == 'shortcuts' assert app.history.get(4).statement.raw == 'alias' - # readline only adds a single entry for multiple sequential identical commands - # so we check to make sure that cmd2 populated the readline history + # prompt-toolkit only adds a single entry for multiple sequential identical commands + # so we check to make sure that cmd2 populated the prompt-toolkit history # using the same rules - from cmd2.rl_utils import ( - readline, - ) - - assert readline.get_current_history_length() == 3 - assert readline.get_history_item(1) == 'help' - assert readline.get_history_item(2) == 'shortcuts' - assert readline.get_history_item(3) == 'alias' + pt_history = app.session.history.get_strings() + assert len(pt_history) == 3 + assert pt_history[0] == 'help' + assert pt_history[1] == 'shortcuts' + assert pt_history[2] == 'alias' # diff --git a/tests/test_pt_utils.py b/tests/test_pt_utils.py new file mode 100644 index 000000000..1f83cc022 --- /dev/null +++ b/tests/test_pt_utils.py @@ -0,0 +1,209 @@ +"""Unit tests for cmd2/pt_utils.py""" + +from typing import cast +from unittest.mock import Mock + +import pytest +from prompt_toolkit.document import Document + +from cmd2 import pt_utils, utils +from cmd2.history import HistoryItem +from cmd2.parsing import Statement + + +# Mock for cmd2.Cmd +class MockCmd: + def __init__(self): + self.complete = Mock() + self.completion_matches = [] + self.display_matches = [] + self.history = [] + + +@pytest.fixture +def mock_cmd_app(): + return MockCmd() + + +class TestCmd2Completer: + def test_get_completions_basic(self, mock_cmd_app): + """Test basic completion without display matches.""" + completer = pt_utils.Cmd2Completer(cast(any, mock_cmd_app)) + + # Setup document + text = "foo" + line = "command foo" + cursor_position = len(line) + document = Mock(spec=Document) + document.get_word_before_cursor.return_value = text + document.text = line + document.cursor_position = cursor_position + + # Setup matches + mock_cmd_app.completion_matches = ["foobar", "food"] + mock_cmd_app.display_matches = [] # Empty means use completion matches for display + + # Call get_completions + completions = list(completer.get_completions(document, None)) + + # Verify cmd_app.complete was called correctly + # begidx = cursor_position - len(text) = 11 - 3 = 8 + mock_cmd_app.complete.assert_called_once_with(text, 0, line=line, begidx=8, endidx=11, custom_settings=None) + + # Verify completions + assert len(completions) == 2 + assert completions[0].text == "foobar" + assert completions[0].start_position == -3 + # prompt_toolkit 3.0+ uses FormattedText for display + assert completions[0].display == [('', 'foobar')] + + assert completions[1].text == "food" + assert completions[1].start_position == -3 + assert completions[1].display == [('', 'food')] + + def test_get_completions_with_display_matches(self, mock_cmd_app): + """Test completion with display matches.""" + completer = pt_utils.Cmd2Completer(cast(any, mock_cmd_app)) + + # Setup document + text = "f" + line = "f" + document = Mock(spec=Document) + document.get_word_before_cursor.return_value = text + document.text = line + document.cursor_position = 1 + + # Setup matches + mock_cmd_app.completion_matches = ["foo", "bar"] + mock_cmd_app.display_matches = ["Foo Display", "Bar Display"] + + # Call get_completions + completions = list(completer.get_completions(document, None)) + + # Verify completions + assert len(completions) == 2 + assert completions[0].text == "foo" + assert completions[0].display == [('', 'Foo Display')] + + assert completions[1].text == "bar" + assert completions[1].display == [('', 'Bar Display')] + + def test_get_completions_mismatched_display_matches(self, mock_cmd_app): + """Test completion when display_matches length doesn't match completion_matches.""" + completer = pt_utils.Cmd2Completer(cast(any, mock_cmd_app)) + + document = Mock(spec=Document) + document.get_word_before_cursor.return_value = "" + document.text = "" + document.cursor_position = 0 + + mock_cmd_app.completion_matches = ["foo", "bar"] + mock_cmd_app.display_matches = ["Foo Display"] # Length mismatch + + completions = list(completer.get_completions(document, None)) + + # Should ignore display_matches and use completion_matches for display + assert len(completions) == 2 + assert completions[0].display == [('', 'foo')] + assert completions[1].display == [('', 'bar')] + + def test_get_completions_empty(self, mock_cmd_app): + """Test completion with no matches.""" + completer = pt_utils.Cmd2Completer(cast(any, mock_cmd_app)) + + document = Mock(spec=Document) + document.get_word_before_cursor.return_value = "" + document.text = "" + document.cursor_position = 0 + + mock_cmd_app.completion_matches = [] + + completions = list(completer.get_completions(document, None)) + + assert len(completions) == 0 + + def test_init_with_custom_settings(self, mock_cmd_app): + """Test initializing with custom settings.""" + mock_parser = Mock() + custom_settings = utils.CustomCompletionSettings(parser=mock_parser) + completer = pt_utils.Cmd2Completer(cast(any, mock_cmd_app), custom_settings=custom_settings) + + document = Mock(spec=Document) + document.get_word_before_cursor.return_value = "" + document.text = "" + document.cursor_position = 0 + + mock_cmd_app.completion_matches = [] + + list(completer.get_completions(document, None)) + + mock_cmd_app.complete.assert_called_once() + assert mock_cmd_app.complete.call_args[1]['custom_settings'] == custom_settings + + +class TestCmd2History: + def make_history_item(self, text): + statement = Mock(spec=Statement) + statement.raw = text + item = Mock(spec=HistoryItem) + item.statement = statement + return item + + def test_load_history_strings(self, mock_cmd_app): + """Test loading history strings yields all items in forward order.""" + history = pt_utils.Cmd2History(cast(any, mock_cmd_app)) + + # Setup history items + # History in cmd2 is oldest to newest + items = [ + self.make_history_item("cmd1"), + self.make_history_item("cmd2"), + self.make_history_item("cmd2"), # Duplicate + self.make_history_item("cmd3"), + ] + mock_cmd_app.history = items + + # Expected: cmd1, cmd2, cmd2, cmd3 (raw iteration) + result = list(history.load_history_strings()) + + assert result == ["cmd1", "cmd2", "cmd2", "cmd3"] + + def test_load_history_strings_empty(self, mock_cmd_app): + """Test loading history strings with empty history.""" + history = pt_utils.Cmd2History(cast(any, mock_cmd_app)) + + mock_cmd_app.history = [] + + result = list(history.load_history_strings()) + + assert result == [] + + def test_get_strings(self, mock_cmd_app): + """Test get_strings returns deduped strings and does not cache.""" + history = pt_utils.Cmd2History(cast(any, mock_cmd_app)) + + items = [ + self.make_history_item("cmd1"), + self.make_history_item("cmd2"), + self.make_history_item("cmd2"), # Duplicate + self.make_history_item("cmd3"), + ] + mock_cmd_app.history = items + + # Expect deduped: cmd1, cmd2, cmd3 + strings = history.get_strings() + assert strings == ["cmd1", "cmd2", "cmd3"] + + # Modify underlying history to prove it does NOT use cache + mock_cmd_app.history.append(self.make_history_item("cmd4")) + strings2 = history.get_strings() + assert strings2 == ["cmd1", "cmd2", "cmd3", "cmd4"] + + def test_store_string(self, mock_cmd_app): + """Test store_string does nothing.""" + history = pt_utils.Cmd2History(cast(any, mock_cmd_app)) + + # Just ensure it doesn't raise error or modify cmd2 history + history.store_string("new command") + + assert len(mock_cmd_app.history) == 0 diff --git a/tests/test_run_pyscript.py b/tests/test_run_pyscript.py index b41c9a060..d085a464d 100644 --- a/tests/test_run_pyscript.py +++ b/tests/test_run_pyscript.py @@ -1,6 +1,5 @@ """Unit/functional testing for run_pytest in cmd2""" -import builtins import os from unittest import ( mock, @@ -43,9 +42,10 @@ def test_run_pyscript_with_nonexist_file(base_app) -> None: assert base_app.last_result is False -def test_run_pyscript_with_non_python_file(base_app, request) -> None: - m = mock.MagicMock(name='input', return_value='2') - builtins.input = m +def test_run_pyscript_with_non_python_file(base_app, request, monkeypatch) -> None: + # Mock out the read_input call so we don't actually wait for a user's response on stdin + read_input_mock = mock.MagicMock(name='read_input', return_value='2') + monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock) test_dir = os.path.dirname(request.module.__file__) filename = os.path.join(test_dir, 'scripts', 'help.txt') @@ -55,13 +55,13 @@ def test_run_pyscript_with_non_python_file(base_app, request) -> None: @pytest.mark.parametrize('python_script', odd_file_names) -def test_run_pyscript_with_odd_file_names(base_app, python_script) -> None: +def test_run_pyscript_with_odd_file_names(base_app, python_script, monkeypatch) -> None: """Pass in file names with various patterns. Since these files don't exist, we will rely on the error text to make sure the file names were processed correctly. """ - # Mock input to get us passed the warning about not ending in .py - input_mock = mock.MagicMock(name='input', return_value='1') - builtins.input = input_mock + # Mock read_input to get us passed the warning about not ending in .py + read_input_mock = mock.MagicMock(name='read_input', return_value='1') + monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock) _out, err = run_cmd(base_app, f"run_pyscript {quote(python_script)}") err = ''.join(err)