Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -672,7 +672,7 @@ 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),
Comment thread
tleonhardt marked this conversation as resolved.
"key_bindings": key_bindings,
"lexer": Cmd2Lexer(self),
"rprompt": self.get_rprompt,
Expand Down Expand Up @@ -2902,6 +2902,9 @@ def _complete_statement(self, line: str) -> Statement:
if not statement.command:
raise EmptyStatement

# Add the complete command to prompt-toolkit's history.
cast(Cmd2History, self.main_session.history).add_command(statement.raw)
Comment thread
tleonhardt marked this conversation as resolved.
Outdated
Comment thread
tleonhardt marked this conversation as resolved.
Outdated

return statement

def _input_line_to_statement(self, line: str) -> Statement:
Expand Down Expand Up @@ -5002,6 +5005,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:
Expand Down
62 changes: 34 additions & 28 deletions cmd2/pt_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,41 +152,47 @@ def get_completions(self, document: Document, _complete_event: object) -> Iterab


class Cmd2History(History):
"""History that bridges cmd2's history storage with prompt_toolkit."""
"""An in-memory prompt-toolkit History implementation designed for cmd2.

def __init__(self, cmd_app: 'Cmd') -> None:
"""Initialize prompt_toolkit based history wrapper class."""
This class gives cmd2 total control over what appears in the up-arrow
history, preventing multiline fragments from appearing in the navigation.
"""

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:
# Use add_command() to filter consecutive duplicates
# and save history strings from newest to oldest.
for string in history_strings:
self.add_command(string)

def store_string(self, string: str) -> None:
"""prompt_toolkit calls this when a line is accepted.
# Mark that self._loaded_strings is loaded.
self._loaded = True

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 add_command(self, string: str) -> None:
"""Manually add a finalized command string to the UI history stack.

This method is intentionally empty.
Ensures consecutive duplicates are not stored.
"""
# self._loaded_strings 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):
Comment thread
tleonhardt marked this conversation as resolved.
super().append_string(string)

def append_string(self, string: str) -> None:
"""No-op: Blocks prompt-toolkit from storing multiline fragments."""

def store_string(self, string: str) -> None:
"""No-op: Persistent history data is stored in cmd_app.history."""

def load_history_strings(self) -> Iterable[str]:
"""Yield strings from newest to oldest."""
yield from self._loaded_strings

def clear(self) -> None:
"""Clear the UI history navigation data."""
self._loaded_strings.clear()


class Cmd2Lexer(Lexer):
Expand Down
31 changes: 26 additions & 5 deletions tests/test_cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -1839,6 +1839,9 @@ def test_multiline_complete_statement_without_terminator(multiline_app, monkeypa
assert statement.command == command
assert statement.multiline_command

pt_history = multiline_app.main_session.history.get_strings()
assert pt_history[0] == statement.raw


def test_multiline_complete_statement_with_unclosed_quotes(multiline_app, monkeypatch) -> None:
read_command_mock = mock.MagicMock(name='_read_command_line', side_effect=['quotes', '" now closed;'])
Expand All @@ -1851,6 +1854,9 @@ def test_multiline_complete_statement_with_unclosed_quotes(multiline_app, monkey
assert statement.multiline_command
assert statement.terminator == ';'

pt_history = multiline_app.main_session.history.get_strings()
assert pt_history[0] == statement.raw


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
Expand All @@ -1864,28 +1870,36 @@ def test_multiline_input_line_to_statement(multiline_app, monkeypatch) -> None:
assert statement.command == 'orate'
assert statement.multiline_command

pt_history = multiline_app.main_session.history.get_strings()
assert pt_history[0] == statement.raw


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")

expected = "orate hi\nperson\n\n"
assert len(multiline_app.history) == 1
assert multiline_app.history.get(1).raw == "orate hi\nperson\n\n"
assert multiline_app.history.get(1).raw == expected

pt_history = multiline_app.main_session.history.get_strings()
assert len(pt_history) == 1
assert pt_history[0] == expected


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)

Expand All @@ -1899,6 +1913,10 @@ def test_multiline_history_with_quotes(multiline_app, monkeypatch) -> None:
assert history_lines[4] == 'quotes.'
assert history_lines[5] == ';'

pt_history = multiline_app.main_session.history.get_strings()
assert len(pt_history) == 1
assert pt_history[0] == history_item.raw


def test_multiline_complete_statement_eof(multiline_app, monkeypatch):
# Mock poutput to verify it's called
Expand All @@ -1920,6 +1938,9 @@ def test_multiline_complete_statement_eof(multiline_app, monkeypatch):
assert statement.args == args
assert statement.terminator == '\n'

pt_history = multiline_app.main_session.history.get_strings()
assert pt_history[0] == statement.raw

# Verify that poutput('\n') was called
poutput_mock.assert_called_once_with('\n')

Expand Down
137 changes: 75 additions & 62 deletions tests/test_pt_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = []
Expand Down Expand Up @@ -506,68 +503,84 @@ 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() does nothing."""
history = pt_utils.Cmd2History()
assert history._loaded
assert not history._loaded_strings

history.append_string("new command")
assert not history._loaded_strings

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 len(mock_cmd_app.history) == 0
assert not history._loaded_strings

def test_add_command(self):
"""Test that add_command() adds data."""
history = pt_utils.Cmd2History()
assert history._loaded
assert not history._loaded_strings

history.add_command("new command")
assert len(history._loaded_strings) == 1
assert history._loaded_strings[0] == "new command"

# Show that consecutive duplicates are filtered
history.add_command("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.add_command("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_clear(self):
history_strings = ["cmd1", "cmd2"]
history = pt_utils.Cmd2History(history_strings)
assert history._loaded
assert history.get_strings() == history_strings

history.clear()
assert not history.get_strings()
Loading