Skip to content

Commit 29e5def

Browse files
committed
Rewrote Cmd2History to only accept completed commands.
1 parent cd154e1 commit 29e5def

File tree

3 files changed

+113
-91
lines changed

3 files changed

+113
-91
lines changed

cmd2/cmd2.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -672,7 +672,7 @@ def _(event: Any) -> None: # pragma: no cover
672672
"complete_in_thread": True,
673673
"complete_while_typing": False,
674674
"completer": Cmd2Completer(self),
675-
"history": Cmd2History(self),
675+
"history": Cmd2History(item.raw for item in self.history),
676676
"key_bindings": key_bindings,
677677
"lexer": Cmd2Lexer(self),
678678
"rprompt": self.get_rprompt,
@@ -2902,6 +2902,9 @@ def _complete_statement(self, line: str) -> Statement:
29022902
if not statement.command:
29032903
raise EmptyStatement
29042904

2905+
# Add the complete command to prompt-toolkit's history.
2906+
cast(Cmd2History, self.main_session.history).add_command(statement.raw)
2907+
29052908
return statement
29062909

29072910
def _input_line_to_statement(self, line: str) -> Statement:
@@ -5002,6 +5005,7 @@ def do_history(self, args: argparse.Namespace) -> bool | None:
50025005

50035006
# Clear command and prompt-toolkit history
50045007
self.history.clear()
5008+
cast(Cmd2History, self.main_session.history).clear()
50055009

50065010
if self.persistent_history_file:
50075011
try:

cmd2/pt_utils.py

Lines changed: 33 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -152,41 +152,46 @@ def get_completions(self, document: Document, _complete_event: object) -> Iterab
152152

153153

154154
class Cmd2History(History):
155-
"""History that bridges cmd2's history storage with prompt_toolkit."""
155+
"""An in-memory prompt-toolkit History implementation designed for cmd2.
156156
157-
def __init__(self, cmd_app: 'Cmd') -> None:
158-
"""Initialize prompt_toolkit based history wrapper class."""
157+
This class gives cmd2 total control over what appears in the up-arrow
158+
history, preventing multiline fragments from appearing in the navigation.
159+
"""
160+
161+
def __init__(self, history_strings: Iterable[str] | None = None) -> None:
162+
"""Initialize the instance."""
159163
super().__init__()
160-
self.cmd_app = cmd_app
161164

162-
def load_history_strings(self) -> Iterable[str]:
163-
"""Yield strings from cmd2's history to prompt_toolkit."""
164-
for item in self.cmd_app.history:
165-
yield item.statement.raw
166-
167-
def get_strings(self) -> list[str]:
168-
"""Get the strings from the history."""
169-
# We override this to always get the latest history from cmd2
170-
# instead of caching it like the base class does.
171-
strings: list[str] = []
172-
last_item = None
173-
for item in self.cmd_app.history:
174-
if item.statement.raw != last_item:
175-
strings.append(item.statement.raw)
176-
last_item = item.statement.raw
177-
return strings
165+
if history_strings:
166+
# Use add_command() to filter consecutive duplicates
167+
# and save history strings from newest to oldest.
168+
for string in history_strings:
169+
self.add_command(string)
178170

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

182-
cmd2 handles history addition in its own loop (postcmd).
183-
We don't want to double add.
184-
However, PromptSession needs to know about it for the *current* session history navigation.
185-
If we don't store it here, UP arrow might not work for the just entered command
186-
unless cmd2 re-initializes the session or history object.
174+
def add_command(self, string: str) -> None:
175+
"""Manually add a finalized command string to the UI history stack.
187176
188-
This method is intentionally empty.
177+
Ensures consecutive duplicates are not stored.
189178
"""
179+
if string and (not self._loaded_strings or self._loaded_strings[0] != string):
180+
super().append_string(string)
181+
182+
def append_string(self, string: str) -> None:
183+
"""No-op: Blocks prompt-toolkit from storing multiline fragments."""
184+
185+
def store_string(self, string: str) -> None:
186+
"""No-op: Persistent history data is stored in cmd_app.history."""
187+
188+
def load_history_strings(self) -> Iterable[str]:
189+
"""Yield strings from newest to oldest."""
190+
yield from self._loaded_strings
191+
192+
def clear(self) -> None:
193+
"""Clear the UI history navigation data."""
194+
self._loaded_strings.clear()
190195

191196

192197
class Cmd2Lexer(Lexer):

tests/test_pt_utils.py

Lines changed: 75 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,6 @@
2020
)
2121
from cmd2 import rich_utils as ru
2222
from cmd2 import string_utils as su
23-
from cmd2.history import HistoryItem
24-
from cmd2.parsing import Statement
2523
from cmd2.pt_utils import pt_filter_style
2624

2725
from .conftest import with_ansi_style
@@ -34,7 +32,6 @@ def __init__(self) -> None:
3432
self.complete = Mock(return_value=cmd2.Completions())
3533

3634
self.always_show_hint = False
37-
self.history = []
3835
self.statement_parser = Mock()
3936
self.statement_parser.terminators = [';']
4037
self.statement_parser.shortcuts = []
@@ -506,68 +503,84 @@ def test_get_completions_custom_delimiters(self, mock_cmd_app: MockCmd) -> None:
506503

507504

508505
class TestCmd2History:
509-
def make_history_item(self, text):
510-
statement = Mock(spec=Statement)
511-
statement.raw = text
512-
item = Mock(spec=HistoryItem)
513-
item.statement = statement
514-
return item
515-
516-
def test_load_history_strings(self, mock_cmd_app):
517-
"""Test loading history strings yields all items in forward order."""
518-
history = pt_utils.Cmd2History(cast(Any, mock_cmd_app))
519-
520-
# Set up history items
521-
# History in cmd2 is oldest to newest
522-
items = [
523-
self.make_history_item("cmd1"),
524-
self.make_history_item("cmd2"),
525-
self.make_history_item("cmd2"), # Duplicate
526-
self.make_history_item("cmd3"),
527-
]
528-
mock_cmd_app.history = items
506+
def test_load_history_strings(self):
507+
"""Test loading history strings yields all items newest to oldest."""
529508

530-
# Expected: cmd1, cmd2, cmd2, cmd3 (raw iteration)
531-
result = list(history.load_history_strings())
509+
history_strings = ["cmd1", "cmd2", "cmd2", "cmd3", "cmd2"]
510+
history = pt_utils.Cmd2History(history_strings)
511+
assert history._loaded
532512

533-
assert result == ["cmd1", "cmd2", "cmd2", "cmd3"]
513+
# Consecutive duplicates are removed
514+
expected = ["cmd2", "cmd3", "cmd2", "cmd1"]
515+
assert list(history.load_history_strings()) == expected
534516

535-
def test_load_history_strings_empty(self, mock_cmd_app):
517+
def test_load_history_strings_empty(self):
536518
"""Test loading history strings with empty history."""
537-
history = pt_utils.Cmd2History(cast(Any, mock_cmd_app))
538-
539-
mock_cmd_app.history = []
540-
541-
result = list(history.load_history_strings())
519+
history = pt_utils.Cmd2History()
520+
assert history._loaded
521+
assert list(history.load_history_strings()) == []
522+
523+
history = pt_utils.Cmd2History([])
524+
assert history._loaded
525+
assert list(history.load_history_strings()) == []
526+
527+
history = pt_utils.Cmd2History(None)
528+
assert history._loaded
529+
assert list(history.load_history_strings()) == []
530+
531+
def test_get_strings(self):
532+
history_strings = ["cmd1", "cmd2", "cmd2", "cmd3", "cmd2"]
533+
history = pt_utils.Cmd2History(history_strings)
534+
assert history._loaded
535+
536+
# Consecutive duplicates are removed
537+
expected = ["cmd1", "cmd2", "cmd3", "cmd2"]
538+
assert history.get_strings() == expected
539+
540+
def test_append_string(self):
541+
"""Test that append_string() does nothing."""
542+
history = pt_utils.Cmd2History()
543+
assert history._loaded
544+
assert not history._loaded_strings
545+
546+
history.append_string("new command")
547+
assert not history._loaded_strings
548+
549+
def test_store_string(self):
550+
"""Test that store_string() does nothing."""
551+
history = pt_utils.Cmd2History()
552+
assert history._loaded
553+
assert not history._loaded_strings
542554

543-
assert result == []
544-
545-
def test_get_strings(self, mock_cmd_app):
546-
"""Test get_strings returns deduped strings and does not cache."""
547-
history = pt_utils.Cmd2History(cast(Any, mock_cmd_app))
548-
549-
items = [
550-
self.make_history_item("cmd1"),
551-
self.make_history_item("cmd2"),
552-
self.make_history_item("cmd2"), # Duplicate
553-
self.make_history_item("cmd3"),
554-
]
555-
mock_cmd_app.history = items
556-
557-
# Expect deduped: cmd1, cmd2, cmd3
558-
strings = history.get_strings()
559-
assert strings == ["cmd1", "cmd2", "cmd3"]
560-
561-
# Modify underlying history to prove it does NOT use cache
562-
mock_cmd_app.history.append(self.make_history_item("cmd4"))
563-
strings2 = history.get_strings()
564-
assert strings2 == ["cmd1", "cmd2", "cmd3", "cmd4"]
565-
566-
def test_store_string(self, mock_cmd_app):
567-
"""Test store_string does nothing."""
568-
history = pt_utils.Cmd2History(cast(Any, mock_cmd_app))
569-
570-
# Just ensure it doesn't raise error or modify cmd2 history
571555
history.store_string("new command")
572-
573-
assert len(mock_cmd_app.history) == 0
556+
assert not history._loaded_strings
557+
558+
def test_add_command(self):
559+
"""Test that add_command() adds data."""
560+
history = pt_utils.Cmd2History()
561+
assert history._loaded
562+
assert not history._loaded_strings
563+
564+
history.add_command("new command")
565+
assert len(history._loaded_strings) == 1
566+
assert history._loaded_strings[0] == "new command"
567+
568+
# Show that consecutive duplicates are filtered
569+
history.add_command("new command")
570+
assert len(history._loaded_strings) == 1
571+
assert history._loaded_strings[0] == "new command"
572+
573+
# Show that new items are placed at the front
574+
history.add_command("even newer command")
575+
assert len(history._loaded_strings) == 2
576+
assert history._loaded_strings[0] == "even newer command"
577+
assert history._loaded_strings[1] == "new command"
578+
579+
def test_clear(self):
580+
history_strings = ["cmd1", "cmd2"]
581+
history = pt_utils.Cmd2History(history_strings)
582+
assert history._loaded
583+
assert history.get_strings() == history_strings
584+
585+
history.clear()
586+
assert not history.get_strings()

0 commit comments

Comments
 (0)