Skip to content

Commit 457d2f5

Browse files
committed
Combine individual lines of a multiline command into a single line for readline history.
Spaces and newlines in quotes are preserved so such history entries will display across multiple lines. Non-verbose cmd2 history uses the same format as readline history entries.
1 parent e87d1ca commit 457d2f5

File tree

6 files changed

+210
-29
lines changed

6 files changed

+210
-29
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* `cmd2` 2.5 supports Python 3.8+ (removed support for Python 3.6 and 3.7)
44
* Bug Fixes
55
* Fixed issue where persistent history file was not saved upon SIGHUP and SIGTERM signals.
6+
* Multiline commands are no longer fragmented in up-arrow history.
67
* Enhancements
78
* Removed dependency on `attrs` and replaced with [dataclasses](https://docs.python.org/3/library/dataclasses.html)
89
* add `allow_clipboard` initialization parameter and attribute to disable ability to
@@ -12,7 +13,6 @@
1213
* Deletions (potentially breaking changes)
1314
* Removed `apply_style` from `Cmd.pwarning()`.
1415

15-
1616
## 2.4.3 (January 27, 2023)
1717
* Bug Fixes
1818
* Fixed ValueError caused when passing `Cmd.columnize()` strings wider than `display_width`.

cmd2/cmd2.py

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@
118118
from .history import (
119119
History,
120120
HistoryItem,
121+
single_line_format,
121122
)
122123
from .parsing import (
123124
Macro,
@@ -2227,7 +2228,7 @@ def complete( # type: ignore[override]
22272228
# Check if we are completing a multiline command
22282229
if self._at_continuation_prompt:
22292230
# lstrip and prepend the previously typed portion of this multiline command
2230-
lstripped_previous = self._multiline_in_progress.lstrip().replace(constants.LINE_FEED, ' ')
2231+
lstripped_previous = self._multiline_in_progress.lstrip()
22312232
line = lstripped_previous + readline.get_line_buffer()
22322233

22332234
# Increment the indexes to account for the prepended text
@@ -2503,7 +2504,13 @@ def parseline(self, line: str) -> Tuple[str, str, str]:
25032504
return statement.command, statement.args, statement.command_and_args
25042505

25052506
def onecmd_plus_hooks(
2506-
self, line: str, *, add_to_history: bool = True, raise_keyboard_interrupt: bool = False, py_bridge_call: bool = False
2507+
self,
2508+
line: str,
2509+
*,
2510+
add_to_history: bool = True,
2511+
raise_keyboard_interrupt: bool = False,
2512+
py_bridge_call: bool = False,
2513+
orig_rl_history_length: Optional[int] = None,
25072514
) -> bool:
25082515
"""Top-level function called by cmdloop() to handle parsing a line and running the command and all of its hooks.
25092516
@@ -2515,6 +2522,9 @@ def onecmd_plus_hooks(
25152522
:param py_bridge_call: This should only ever be set to True by PyBridge to signify the beginning
25162523
of an app() call from Python. It is used to enable/disable the storage of the
25172524
command's stdout.
2525+
:param orig_rl_history_length: Optional length of the readline history before the current command was typed.
2526+
This is used to assist in combining multiline readline history entries and is only
2527+
populated by cmd2. Defaults to None.
25182528
:return: True if running of commands should stop
25192529
"""
25202530
import datetime
@@ -2524,7 +2534,7 @@ def onecmd_plus_hooks(
25242534

25252535
try:
25262536
# Convert the line into a Statement
2527-
statement = self._input_line_to_statement(line)
2537+
statement = self._input_line_to_statement(line, orig_rl_history_length=orig_rl_history_length)
25282538

25292539
# call the postparsing hooks
25302540
postparsing_data = plugin.PostparsingData(False, statement)
@@ -2738,11 +2748,14 @@ def _complete_statement(self, line: str) -> Statement:
27382748
raise EmptyStatement
27392749
return statement
27402750

2741-
def _input_line_to_statement(self, line: str) -> Statement:
2751+
def _input_line_to_statement(self, line: str, *, orig_rl_history_length: Optional[int] = None) -> Statement:
27422752
"""
27432753
Parse the user's input line and convert it to a Statement, ensuring that all macros are also resolved
27442754
27452755
:param line: the line being parsed
2756+
:param orig_rl_history_length: Optional length of the readline history before the current command was typed.
2757+
This is used to assist in combining multiline readline history entries and is only
2758+
populated by cmd2. Defaults to None.
27462759
:return: parsed command line as a Statement
27472760
:raises: Cmd2ShlexError if a shlex error occurs (e.g. No closing quotation)
27482761
:raises: EmptyStatement when the resulting Statement is blank
@@ -2755,10 +2768,23 @@ def _input_line_to_statement(self, line: str) -> Statement:
27552768
# Make sure all input has been read and convert it to a Statement
27562769
statement = self._complete_statement(line)
27572770

2758-
# Save the fully entered line if this is the first loop iteration
2771+
# If this is the first loop iteration, save the original line and if necessary,
2772+
# combine multiline history entries into single readline history item.
27592773
if orig_line is None:
27602774
orig_line = statement.raw
27612775

2776+
if orig_rl_history_length is not None and statement.multiline_command:
2777+
# Remove all lines added to history for this command
2778+
while readline.get_current_history_length() > orig_rl_history_length:
2779+
readline.remove_history_item(readline.get_current_history_length() - 1)
2780+
2781+
# Combine the lines of this command
2782+
combined_command = single_line_format(statement)
2783+
2784+
# If combined_command is different than the previous history item, then add it
2785+
if orig_rl_history_length == 0 or combined_command != readline.get_history_item(orig_rl_history_length):
2786+
readline.add_history(combined_command)
2787+
27622788
# Check if this command matches a macro and wasn't already processed to avoid an infinite loop
27632789
if statement.command in self.macros.keys() and statement.command not in used_macros:
27642790
used_macros.append(statement.command)
@@ -3111,7 +3137,7 @@ def configure_readline() -> None:
31113137
nonlocal saved_history
31123138
nonlocal parser
31133139

3114-
if readline_configured: # pragma: no cover
3140+
if readline_configured or rl_type == RlType.NONE: # pragma: no cover
31153141
return
31163142

31173143
# Configure tab completion
@@ -3163,7 +3189,7 @@ def complete_none(text: str, state: int) -> Optional[str]: # pragma: no cover
31633189
def restore_readline() -> None:
31643190
"""Restore readline tab completion and history"""
31653191
nonlocal readline_configured
3166-
if not readline_configured: # pragma: no cover
3192+
if not readline_configured or rl_type == RlType.NONE: # pragma: no cover
31673193
return
31683194

31693195
if self._completion_supported():
@@ -3310,6 +3336,13 @@ def _cmdloop(self) -> None:
33103336
self._startup_commands.clear()
33113337

33123338
while not stop:
3339+
# Used in building multiline readline history entries. Only applies
3340+
# when command line is read using input() in a terminal.
3341+
if rl_type != RlType.NONE and self.use_rawinput and sys.stdin.isatty():
3342+
orig_rl_history_length = readline.get_current_history_length()
3343+
else:
3344+
orig_rl_history_length = None
3345+
33133346
# Get commands from user
33143347
try:
33153348
line = self._read_command_line(self.prompt)
@@ -3318,7 +3351,7 @@ def _cmdloop(self) -> None:
33183351
line = ''
33193352

33203353
# Run the command along with all associated pre and post hooks
3321-
stop = self.onecmd_plus_hooks(line)
3354+
stop = self.onecmd_plus_hooks(line, orig_rl_history_length=orig_rl_history_length)
33223355
finally:
33233356
# Get sigint protection while we restore readline settings
33243357
with self.sigint_protection:
@@ -4873,13 +4906,13 @@ def _initialize_history(self, hist_file: str) -> None:
48734906
if rl_type != RlType.NONE:
48744907
last = None
48754908
for item in self.history:
4876-
# Break the command into its individual lines
4877-
for line in item.raw.splitlines():
4878-
# readline only adds a single entry for multiple sequential identical lines
4879-
# so we emulate that behavior here
4880-
if line != last:
4881-
readline.add_history(line)
4882-
last = line
4909+
formatted_command = single_line_format(item.statement)
4910+
4911+
# readline only adds a single entry for multiple sequential identical lines
4912+
# so we emulate that behavior here
4913+
if formatted_command != last:
4914+
readline.add_history(formatted_command)
4915+
last = formatted_command
48834916

48844917
def _persist_history(self) -> None:
48854918
"""Write history out to the persistent history file as compressed JSON"""

cmd2/history.py

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,51 @@
2727
)
2828
from .parsing import (
2929
Statement,
30+
shlex_split,
3031
)
3132

3233

34+
def single_line_format(statement: Statement) -> str:
35+
"""
36+
Format a command to display on a single line.
37+
38+
All spaces and newlines in quotes are preserved.
39+
40+
:param statement: Statement of multiline command being converted.
41+
:return: converted multiline command as string
42+
"""
43+
lines = statement.raw.rstrip().splitlines()
44+
ret_str = lines[0]
45+
46+
# If first line doesn't have an unclosed quote, then remove trailing white space.
47+
try:
48+
shlex_split(ret_str)
49+
except ValueError:
50+
pass
51+
else:
52+
ret_str = ret_str.rstrip()
53+
54+
# Append any remaining lines to the command.
55+
for line in lines[1:]:
56+
try:
57+
shlex_split(ret_str)
58+
except ValueError:
59+
# We are in quotes, so restore the newline.
60+
separator = "\n"
61+
else:
62+
line = line.rstrip()
63+
64+
# Don't add a space if the line already begins with one or the terminator.
65+
if line.startswith(" ") or line.startswith(statement.terminator):
66+
separator = ""
67+
else:
68+
separator = " "
69+
70+
ret_str += separator + line
71+
72+
return ret_str
73+
74+
3375
@dataclass(frozen=True)
3476
class HistoryItem:
3577
"""Class used to represent one command in the history list"""
@@ -85,15 +127,7 @@ def pr(self, idx: int, script: bool = False, expanded: bool = False, verbose: bo
85127
if expanded:
86128
ret_str = self.expanded
87129
else:
88-
ret_str = self.raw.rstrip()
89-
90-
# In non-verbose mode, display raw multiline commands on 1 line
91-
if self.statement.multiline_command:
92-
# This is an approximation and not meant to be a perfect piecing together of lines.
93-
# All newlines will be converted to spaces, including the ones in quoted strings that
94-
# are considered literals. Also, if the final line starts with a terminator, then the
95-
# terminator will have an extra space before it in the 1 line version.
96-
ret_str = ret_str.replace('\n', ' ')
130+
ret_str = single_line_format(self.statement)
97131

98132
# Display a numbered list if not writing to a script
99133
if not script:

cmd2/rl_utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838

3939

4040
class RlType(Enum):
41-
"""Readline library types we recognize"""
41+
"""Readline library types we support"""
4242

4343
GNU = 1
4444
PYREADLINE = 2
@@ -151,7 +151,7 @@ def pyreadline_remove_history_item(pos: int) -> None:
151151
rl_type = RlType.GNU
152152
vt100_support = sys.stdout.isatty()
153153

154-
# Check if readline was loaded
154+
# Check if we loaded a supported version of readline
155155
if rl_type == RlType.NONE: # pragma: no cover
156156
if not _rl_warn_reason:
157157
_rl_warn_reason = (

tests/test_cmd2.py

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import builtins
77
import io
88
import os
9+
import readline
910
import signal
1011
import sys
1112
import tempfile
@@ -1637,6 +1638,100 @@ def test_multiline_input_line_to_statement(multiline_app):
16371638
assert statement.multiline_command == 'orate'
16381639

16391640

1641+
def test_multiline_history_no_prior_history(multiline_app):
1642+
# Test no existing history prior to typing the command
1643+
m = mock.MagicMock(name='input', side_effect=['person', '\n'])
1644+
builtins.input = m
1645+
1646+
# Set orig_rl_history_length to 0 before the first line is typed.
1647+
readline.clear_history()
1648+
orig_rl_history_length = readline.get_current_history_length()
1649+
1650+
line = "orate hi"
1651+
readline.add_history(line)
1652+
multiline_app._input_line_to_statement(line, orig_rl_history_length=orig_rl_history_length)
1653+
1654+
assert readline.get_current_history_length() == orig_rl_history_length + 1
1655+
assert readline.get_history_item(1) == "orate hi person"
1656+
1657+
1658+
def test_multiline_history_first_line_matches_prev_entry(multiline_app):
1659+
# Test when first line of multiline command matches previous history entry
1660+
m = mock.MagicMock(name='input', side_effect=['person', '\n'])
1661+
builtins.input = m
1662+
1663+
# Since the first line of our command matches the previous entry,
1664+
# orig_rl_history_length is set before the first line is typed.
1665+
line = "orate hi"
1666+
readline.clear_history()
1667+
readline.add_history(line)
1668+
orig_rl_history_length = readline.get_current_history_length()
1669+
1670+
multiline_app._input_line_to_statement(line, orig_rl_history_length=orig_rl_history_length)
1671+
1672+
assert readline.get_current_history_length() == orig_rl_history_length + 1
1673+
assert readline.get_history_item(1) == line
1674+
assert readline.get_history_item(2) == "orate hi person"
1675+
1676+
1677+
def test_multiline_history_matches_prev_entry(multiline_app):
1678+
# Test combined multiline command that matches previous history entry
1679+
m = mock.MagicMock(name='input', side_effect=['person', '\n'])
1680+
builtins.input = m
1681+
1682+
readline.clear_history()
1683+
readline.add_history("orate hi person")
1684+
orig_rl_history_length = readline.get_current_history_length()
1685+
1686+
line = "orate hi"
1687+
readline.add_history(line)
1688+
multiline_app._input_line_to_statement(line, orig_rl_history_length=orig_rl_history_length)
1689+
1690+
# Since it matches the previous history item, nothing was added to readline history
1691+
assert readline.get_current_history_length() == orig_rl_history_length
1692+
assert readline.get_history_item(1) == "orate hi person"
1693+
1694+
1695+
def test_multiline_history_does_not_match_prev_entry(multiline_app):
1696+
# Test combined multiline command that does not match previous history entry
1697+
m = mock.MagicMock(name='input', side_effect=['person', '\n'])
1698+
builtins.input = m
1699+
1700+
readline.clear_history()
1701+
readline.add_history("no match")
1702+
orig_rl_history_length = readline.get_current_history_length()
1703+
1704+
line = "orate hi"
1705+
readline.add_history(line)
1706+
multiline_app._input_line_to_statement(line, orig_rl_history_length=orig_rl_history_length)
1707+
1708+
# Since it doesn't match the previous history item, it was added to readline history
1709+
assert readline.get_current_history_length() == orig_rl_history_length + 1
1710+
assert readline.get_history_item(1) == "no match"
1711+
assert readline.get_history_item(2) == "orate hi person"
1712+
1713+
1714+
def test_multiline_history_with_quotes(multiline_app):
1715+
# Test combined multiline command with quotes
1716+
m = mock.MagicMock(name='input', side_effect=[' and spaces ', ' "', ' in', 'quotes.', ';'])
1717+
builtins.input = m
1718+
1719+
readline.clear_history()
1720+
orig_rl_history_length = readline.get_current_history_length()
1721+
1722+
line = 'orate Look, "There are newlines'
1723+
readline.add_history(line)
1724+
multiline_app._input_line_to_statement(line, orig_rl_history_length=orig_rl_history_length)
1725+
1726+
# Since spaces and newlines in quotes are preserved, this history entry spans multiple lines.
1727+
assert readline.get_current_history_length() == orig_rl_history_length + 1
1728+
1729+
history_lines = readline.get_history_item(1).splitlines()
1730+
assert history_lines[0] == 'orate Look, "There are newlines'
1731+
assert history_lines[1] == ' and spaces '
1732+
assert history_lines[2] == ' " in quotes.;'
1733+
1734+
16401735
class CommandResultApp(cmd2.Cmd):
16411736
def __init__(self, *args, **kwargs):
16421737
super().__init__(*args, **kwargs)
@@ -1731,8 +1826,6 @@ def test_read_input_rawinput_true(capsys, monkeypatch):
17311826
assert line == input_str
17321827

17331828
# Run custom history code
1734-
import readline
1735-
17361829
readline.add_history('old_history')
17371830
custom_history = ['cmd1', 'cmd2']
17381831
line = app.read_input(prompt_str, history=custom_history, completion_mode=cmd2.CompletionMode.NONE)

0 commit comments

Comments
 (0)