diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index dd618fce..e8787065 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -69,7 +69,10 @@ ) import rich.box -from prompt_toolkit import print_formatted_text +from prompt_toolkit import ( + filters, + print_formatted_text, +) from prompt_toolkit.application import get_app from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.completion import Completer, DummyCompleter @@ -136,6 +139,8 @@ CompletionError, EmbeddedConsoleExit, EmptyStatement, + IncompleteStatement, + MacroError, PassThroughException, RedirectionError, SkipPostcommandHooks, @@ -200,6 +205,7 @@ def __init__(self, msg: str = '') -> None: if TYPE_CHECKING: # pragma: no cover StaticArgParseBuilder = staticmethod[[], argparse.ArgumentParser] ClassArgParseBuilder = classmethod['Cmd' | CommandSet, [], argparse.ArgumentParser] + from prompt_toolkit.buffer import Buffer else: StaticArgParseBuilder = staticmethod ClassArgParseBuilder = classmethod @@ -510,12 +516,6 @@ def __init__( # Used to keep track of whether we are redirecting or piping output self._redirecting = False - # Used to keep track of whether a continuation prompt is being displayed - self._at_continuation_prompt = False - - # The multiline command currently being typed which is used to complete multiline commands. - self._multiline_in_progress = '' - # Characters used to draw a horizontal rule. Should not be blank. self.ruler = "─" @@ -643,6 +643,39 @@ def __init__( # the current command being executed self.current_command: Statement | None = None + def _should_continue_multiline(self) -> bool: + """Return whether prompt-toolkit should continue prompting the user for a multiline command.""" + buffer: Buffer = get_app().current_buffer + line: str = buffer.text + + used_macros = [] + + # Continue until all macros are resolved + while True: + try: + statement = self._check_statement_complete(line) + except IncompleteStatement: + # The statement (or the resolved macro) is incomplete. + # Keep prompting the user. + return True + + except (Cmd2ShlexError, EmptyStatement): + # These are "finished" states (even if they are errors). + # Submit so the main loop can handle the exception. + return False + + # 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: + used_macros.append(statement.command) + try: + line = self._resolve_macro(statement) + except MacroError: + # Resolve failed. Submit to let the main loop handle the error. + return False + else: + # No macro found or already processed. The statement is complete. + return False + def _create_main_session(self, auto_suggest: bool, completekey: str) -> PromptSession[str]: """Create and return the main PromptSession for the application. @@ -671,9 +704,11 @@ def _(event: Any) -> None: # pragma: no cover "complete_in_thread": True, "complete_while_typing": False, "completer": Cmd2Completer(self), - "history": Cmd2History(self), + "history": Cmd2History(item.raw for item in self.history), "key_bindings": key_bindings, "lexer": Cmd2Lexer(self), + "multiline": filters.Condition(self._should_continue_multiline), + "prompt_continuation": self.continuation_prompt, "rprompt": self.get_rprompt, } @@ -2369,25 +2404,15 @@ def complete( :return: a Completions object """ try: - # 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 + line - - # Increment the indexes to account for the prepended text - begidx = len(lstripped_previous) + begidx - endidx = len(lstripped_previous) + endidx - else: - # lstrip the original line - orig_line = line - line = orig_line.lstrip() - num_stripped = len(orig_line) - len(line) + # lstrip the original line + 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(begidx - num_stripped, 0) - endidx = max(endidx - num_stripped, 0) + # 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(begidx - num_stripped, 0) + endidx = max(endidx - num_stripped, 0) # Shortcuts are not word break characters when 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 @@ -2843,6 +2868,36 @@ def runcmds_plus_hooks( return False + def _check_statement_complete(self, line: str) -> Statement: + """Check if the given line is a complete statement. + + :param line: the current input string to check + :return: the completed Statement + :raises Cmd2ShlexError: if a shlex error occurs on a non-multiline command + :raises IncompleteStatement: if more input is needed for multiline + :raises EmptyStatement: if the command is blank + """ + try: + statement = self.statement_parser.parse(line) + + # Check if we have a finished multiline command or a standard command + if (statement.multiline_command and statement.terminator) or not statement.multiline_command: + if not statement.command: + raise EmptyStatement + return statement + + except Cmd2ShlexError: + # Check if the error is occurring within a multiline command + partial_statement = self.statement_parser.parse_command_only(line) + if not partial_statement.multiline_command: + # It's a standard command with a quoting error, raise it + raise + + # If we reached here, the statement is incomplete: + # - Multiline command missing a terminator + # - Multiline command with an unclosed quotation mark + raise IncompleteStatement + def _complete_statement(self, line: str) -> Statement: """Keep accepting lines of input until the command is complete. @@ -2853,52 +2908,22 @@ def _complete_statement(self, line: str) -> Statement: """ while True: try: - statement = self.statement_parser.parse(line) - if statement.multiline_command and statement.terminator: - # we have a completed multiline command, we are done - break - if not statement.multiline_command: - # it's not a multiline command, but we parsed it ok - # so we are done - break - except Cmd2ShlexError: - # we have an unclosed quotation mark, let's parse only the command - # and see if it's a multiline - partial_statement = self.statement_parser.parse_command_only(line) - if not partial_statement.multiline_command: - # not a multiline command, so raise the exception - raise - - # if we get here we must have: - # - a multiline command with no terminator - # - a multiline command with unclosed quotation marks - try: - self._at_continuation_prompt = True - - # Save the command line up to this point for completion - self._multiline_in_progress = line + '\n' - - # Get next line of this command + return self._check_statement_complete(line) + except IncompleteStatement: # noqa: PERF203 + # If incomplete, we need to fetch the next line try: - nextline = self._read_command_line(self.continuation_prompt) - except EOFError: - # Add a blank line, which serves as a command terminator. - nextline = '\n' - self.poutput(nextline) - - line += f'\n{nextline}' - - except KeyboardInterrupt: - self.poutput('^C') - statement = self.statement_parser.parse('') - break - finally: - self._at_continuation_prompt = False + try: + nextline = self._read_command_line(self.continuation_prompt) + except EOFError: + # Add a blank line, which serves as a command terminator. + nextline = '\n' + self.poutput(nextline) - if not statement.command: - raise EmptyStatement + line += f'\n{nextline}' - return statement + except KeyboardInterrupt: + self.poutput('^C') + raise EmptyStatement from None 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. @@ -2913,7 +2938,7 @@ def _input_line_to_statement(self, line: str) -> Statement: # Continue until all macros are resolved while True: - # Make sure all input has been read and convert it to a Statement + # Get a complete statement (handling multiline input) statement = self._complete_statement(line) # If this is the first loop iteration, save the original line @@ -2923,16 +2948,16 @@ def _input_line_to_statement(self, line: str) -> Statement: # 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: used_macros.append(statement.command) - resolve_result = self._resolve_macro(statement) - if resolve_result is None: - raise EmptyStatement - line = resolve_result + try: + line = self._resolve_macro(statement) + except MacroError as ex: + self.perror(ex) + raise EmptyStatement from None else: + # No macro found or already processed. The statement is complete. break - # If a macro was expanded, the 'statement' now contains the expanded text. - # We need to swap the 'raw' attribute back to the string the user typed - # so history shows the original line. + # Restore original 'raw' text if a macro was expanded if orig_line != statement.raw: statement_dict = statement.to_dict() statement_dict["raw"] = orig_line @@ -2940,11 +2965,13 @@ def _input_line_to_statement(self, line: str) -> Statement: return statement - def _resolve_macro(self, statement: Statement) -> str | None: + def _resolve_macro(self, statement: Statement) -> str: """Resolve a macro and return the resulting string. :param statement: the parsed statement from the command line - :return: the resolved macro or None on error + :return: the resolved macro string + :raises KeyError: if its not a macro + :raises MacroError: if the macro cannot be resolved (e.g. not enough args) """ if statement.command not in self.macros: raise KeyError(f"{statement.command} is not a macro") @@ -2954,8 +2981,7 @@ def _resolve_macro(self, statement: Statement) -> str | None: # Make sure enough arguments were passed in if len(statement.arg_list) < macro.minimum_arg_count: plural = '' if macro.minimum_arg_count == 1 else 's' - self.perror(f"The macro '{statement.command}' expects at least {macro.minimum_arg_count} argument{plural}") - return None + raise MacroError(f"The macro '{statement.command}' expects at least {macro.minimum_arg_count} argument{plural}") # Resolve the arguments in reverse and read their values from statement.argv since those # are unquoted. Macro args should have been quoted when the macro was created. @@ -3399,25 +3425,18 @@ def _process_alerts(self) -> None: # Clear the alerts self._alert_queue.clear() - if alert_text: - if not self._at_continuation_prompt and latest_prompt is not None: - # Update prompt now so patch_stdout can redraw it immediately. - self.prompt = latest_prompt + if latest_prompt is not None: + # Update prompt so patch_stdout() or get_app().invalidate() can redraw it. + self.prompt = latest_prompt + if alert_text: # Print the alert messages above the prompt. with patch_stdout(): print_formatted_text(pt_filter_style(alert_text)) - if self._at_continuation_prompt and latest_prompt is not None: - # Update state only. The onscreen prompt won't change until the next prompt starts. - self.prompt = latest_prompt - elif latest_prompt is not None: - self.prompt = latest_prompt - - # Refresh UI immediately unless at a continuation prompt. - if not self._at_continuation_prompt: - get_app().invalidate() + # Refresh UI immediately to show the new prompt + get_app().invalidate() def _read_command_line(self, prompt: str) -> str: """Read the next command line from the input stream. @@ -4993,6 +5012,7 @@ def do_history(self, args: argparse.Namespace) -> bool | None: # Clear command and prompt-toolkit history self.history.clear() + cast(Cmd2History, self.main_session.history).clear() if self.persistent_history_file: try: diff --git a/cmd2/exceptions.py b/cmd2/exceptions.py index 5b25aefb..a113a02d 100644 --- a/cmd2/exceptions.py +++ b/cmd2/exceptions.py @@ -77,5 +77,13 @@ class EmptyStatement(Exception): # noqa: N818 """Custom exception class for handling behavior when the user just presses .""" +class IncompleteStatement(Exception): # noqa: N818 + """Raised when more input is required to complete a multiline statement.""" + + +class MacroError(Exception): + """Raised when a macro fails to resolve (e.g., insufficient arguments).""" + + class RedirectionError(Exception): """Custom exception class for when redirecting or piping output fails.""" diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index cd825ef2..c99d7c97 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -152,41 +152,40 @@ def get_completions(self, document: Document, _complete_event: object) -> Iterab class Cmd2History(History): - """History that bridges cmd2's history storage with prompt_toolkit.""" + """A non-persistent, in-memory history buffer for prompt-toolkit. - def __init__(self, cmd_app: 'Cmd') -> None: - """Initialize prompt_toolkit based history wrapper class.""" + This class serves as the backing store for UI history navigation (e.g., arrowing + through previous commands). It explicitly avoids handling persistence, + deferring all permanent storage logic to the cmd2 application. + """ + + def __init__(self, history_strings: Iterable[str] | None = None) -> None: + """Initialize the instance.""" 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 + if history_strings: + for string in history_strings: + self.append_string(string) + + # Mark that self._loaded_strings is populated. + self._loaded = True + + def append_string(self, string: str) -> None: + """Override to filter our consecutive duplicates.""" + # History is sorted newest to oldest, so we compare to the first element. + if string and (not self._loaded_strings or self._loaded_strings[0] != string): + super().append_string(string) def store_string(self, string: str) -> None: - """prompt_toolkit calls this when a line is accepted. + """No-op: Persistent history data is stored in cmd_app.history.""" - 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. + def load_history_strings(self) -> Iterable[str]: + """Yield strings from newest to oldest.""" + yield from self._loaded_strings - This method is intentionally empty. - """ + def clear(self) -> None: + """Clear the UI history navigation data.""" + self._loaded_strings.clear() class Cmd2Lexer(Lexer): diff --git a/examples/async_printing.py b/examples/async_printing.py index cd9ffa27..d92f52bf 100755 --- a/examples/async_printing.py +++ b/examples/async_printing.py @@ -32,7 +32,7 @@ class AlerterApp(cmd2.Cmd): def __init__(self) -> None: """Initializer.""" - super().__init__() + super().__init__(multiline_commands=["help"]) self.prompt = "(APR)> " diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index dde7b1dd..c5904f95 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1208,23 +1208,19 @@ def test_ctrl_d_at_prompt(say_app, monkeypatch) -> None: reason="Don't have a real Windows console with how we are currently running tests in GitHub Actions", ) @pytest.mark.parametrize( - ('msg', 'prompt', 'is_stale', 'at_continuation_prompt'), + ('msg', 'prompt', 'is_stale'), [ - ("msg_text", None, False, False), - ("msg_text", "new_prompt> ", False, False), - ("msg_text", "new_prompt> ", False, True), - ("msg_text", "new_prompt> ", True, False), - ("msg_text", "new_prompt> ", True, True), - (None, "new_prompt> ", False, False), - (None, "new_prompt> ", False, True), - (None, "new_prompt> ", True, False), - (None, "new_prompt> ", True, True), + ("msg_text", None, False), + ("msg_text", "new_prompt> ", False), + ("msg_text", "new_prompt> ", True), + (None, "new_prompt> ", False), + (None, "new_prompt> ", True), # Blank prompt is acceptable - ("msg_text", "", False, False), - (None, "", False, False), + ("msg_text", "", False), + (None, "", False), ], ) -def test_async_alert(base_app, msg, prompt, is_stale, at_continuation_prompt) -> None: +def test_async_alert(base_app, msg, prompt, is_stale) -> None: import time with ( @@ -1246,8 +1242,6 @@ def test_async_alert(base_app, msg, prompt, is_stale, at_continuation_prompt) -> # In the future alert.timestamp = time.monotonic() + 99999999 - base_app._at_continuation_prompt = at_continuation_prompt - with create_pipe_input() as pipe_input: base_app.main_session = PromptSession( input=pipe_input, @@ -1266,7 +1260,7 @@ def test_async_alert(base_app, msg, prompt, is_stale, at_continuation_prompt) -> # If there's only a prompt update, we expect invalidate() only if not continuation/stale elif prompt is not None: - if is_stale or at_continuation_prompt: + if is_stale: mock_app.invalidate.assert_not_called() else: mock_app.invalidate.assert_called_once() @@ -1847,11 +1841,11 @@ def test_multiline_input_line_to_statement(multiline_app, monkeypatch) -> None: def test_multiline_history_added(multiline_app, monkeypatch) -> None: # Test that multiline commands are added to history as a single item + run_cmd(multiline_app, "history --clear") + read_command_mock = mock.MagicMock(name='_read_command_line', side_effect=['person', '\n']) monkeypatch.setattr("cmd2.Cmd._read_command_line", read_command_mock) - multiline_app.history.clear() - # run_cmd calls onecmd_plus_hooks which triggers history addition run_cmd(multiline_app, "orate hi") @@ -1861,11 +1855,11 @@ def test_multiline_history_added(multiline_app, monkeypatch) -> None: def test_multiline_history_with_quotes(multiline_app, monkeypatch) -> None: # Test combined multiline command with quotes is added to history correctly + run_cmd(multiline_app, "history --clear") + read_command_mock = mock.MagicMock(name='_read_command_line', side_effect=[' and spaces ', ' "', ' in', 'quotes.', ';']) monkeypatch.setattr("cmd2.Cmd._read_command_line", read_command_mock) - multiline_app.history.clear() - line = 'orate Look, "There are newlines' run_cmd(multiline_app, line) @@ -2786,6 +2780,45 @@ def test_nonexistent_macro(base_app) -> None: assert exception is not None +@pytest.mark.parametrize( + # The line of text and whether to continue prompting to finish a multiline command. + ('line', 'should_continue'), + [ + ("", False), + (" ", False), + ("help", False), + ("help alias", False), + ("orate", True), + ("orate;", False), + ("orate\n", False), + ("orate\narg", True), + ("orate\narg;", False), + ("orate\narg\n", False), + ("single_mac", False), # macro resolution error returns False (no arg passed) + ("single_mac arg", False), + ("multi_mac", False), # macro resolution error returns False (no arg passed) + ("multi_mac arg", True), + ("multi_mac arg;", False), + ("multi_mac arg\n", False), + ("multi_mac\narg", True), + ("multi_mac\narg;", False), + ("multi_mac\narg\n", False), + ], +) +def test_should_continue_multiline(multiline_app: MultilineApp, line: str, should_continue: bool) -> None: + mock_buffer = mock.MagicMock() + mock_buffer.text = line + + mock_app = mock.MagicMock() + mock_app.current_buffer = mock_buffer + + run_cmd(multiline_app, "macro create single_mac help {1}") + run_cmd(multiline_app, "macro create multi_mac orate {1}") + + with mock.patch('cmd2.cmd2.get_app', return_value=mock_app): + assert multiline_app._should_continue_multiline() is should_continue + + @with_ansi_style(ru.AllowStyle.ALWAYS) def test_perror_style(base_app, capsys) -> None: msg = 'testing...' diff --git a/tests/test_completion.py b/tests/test_completion.py index f2d882bb..2d257883 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -830,12 +830,8 @@ def test_complete_multiline_on_single_line(cmd2_app) -> None: def test_complete_multiline_on_multiple_lines(cmd2_app) -> None: - # Set the same variables _complete_statement() sets when a user is entering data at a continuation prompt - cmd2_app._at_continuation_prompt = True - cmd2_app._multiline_in_progress = "test_multiline\n" - text = 'Ba' - line = f'{text}' + line = f'test_multiline\n{text}' endidx = len(line) begidx = endidx - len(text) diff --git a/tests/test_pt_utils.py b/tests/test_pt_utils.py index 859855e6..15d37672 100644 --- a/tests/test_pt_utils.py +++ b/tests/test_pt_utils.py @@ -20,8 +20,6 @@ ) from cmd2 import rich_utils as ru from cmd2 import string_utils as su -from cmd2.history import HistoryItem -from cmd2.parsing import Statement from cmd2.pt_utils import pt_filter_style from .conftest import with_ansi_style @@ -34,7 +32,6 @@ def __init__(self) -> None: self.complete = Mock(return_value=cmd2.Completions()) self.always_show_hint = False - self.history = [] self.statement_parser = Mock() self.statement_parser.terminators = [';'] self.statement_parser.shortcuts = [] @@ -506,68 +503,75 @@ def test_get_completions_custom_delimiters(self, mock_cmd_app: MockCmd) -> None: 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)) - - # Set up 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 + def test_load_history_strings(self): + """Test loading history strings yields all items newest to oldest.""" - # Expected: cmd1, cmd2, cmd2, cmd3 (raw iteration) - result = list(history.load_history_strings()) + history_strings = ["cmd1", "cmd2", "cmd2", "cmd3", "cmd2"] + history = pt_utils.Cmd2History(history_strings) + assert history._loaded - assert result == ["cmd1", "cmd2", "cmd2", "cmd3"] + # Consecutive duplicates are removed + expected = ["cmd2", "cmd3", "cmd2", "cmd1"] + assert list(history.load_history_strings()) == expected - def test_load_history_strings_empty(self, mock_cmd_app): + def test_load_history_strings_empty(self): """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()) + history = pt_utils.Cmd2History() + assert history._loaded + assert list(history.load_history_strings()) == [] + + history = pt_utils.Cmd2History([]) + assert history._loaded + assert list(history.load_history_strings()) == [] + + history = pt_utils.Cmd2History(None) + assert history._loaded + assert list(history.load_history_strings()) == [] + + def test_get_strings(self): + history_strings = ["cmd1", "cmd2", "cmd2", "cmd3", "cmd2"] + history = pt_utils.Cmd2History(history_strings) + assert history._loaded + + # Consecutive duplicates are removed + expected = ["cmd1", "cmd2", "cmd3", "cmd2"] + assert history.get_strings() == expected + + def test_append_string(self): + """Test that append_string() adds data.""" + history = pt_utils.Cmd2History() + assert history._loaded + assert not history._loaded_strings + + history.append_string("new command") + assert len(history._loaded_strings) == 1 + assert history._loaded_strings[0] == "new command" + + # Show that consecutive duplicates are filtered + history.append_string("new command") + assert len(history._loaded_strings) == 1 + assert history._loaded_strings[0] == "new command" + + # Show that new items are placed at the front + history.append_string("even newer command") + assert len(history._loaded_strings) == 2 + assert history._loaded_strings[0] == "even newer command" + assert history._loaded_strings[1] == "new command" + + def test_store_string(self): + """Test that store_string() does nothing.""" + history = pt_utils.Cmd2History() + assert history._loaded + assert not history._loaded_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 not history._loaded_strings + + def test_clear(self): + history_strings = ["cmd1", "cmd2"] + history = pt_utils.Cmd2History(history_strings) + assert history._loaded + assert history.get_strings() == history_strings - assert len(mock_cmd_app.history) == 0 + history.clear() + assert not history.get_strings()