Skip to content

Commit 44e77b2

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 those strings will span multiple lines. Non-verbose cmd2 history uses the same format as readline history entries.
1 parent e87d1ca commit 44e77b2

File tree

6 files changed

+240
-34
lines changed

6 files changed

+240
-34
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: 71 additions & 20 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)
@@ -2678,7 +2688,7 @@ def runcmds_plus_hooks(
26782688

26792689
return False
26802690

2681-
def _complete_statement(self, line: str) -> Statement:
2691+
def _complete_statement(self, line: str, *, orig_rl_history_length: Optional[int] = None) -> Statement:
26822692
"""Keep accepting lines of input until the command is complete.
26832693
26842694
There is some pretty hacky code here to handle some quirks of
@@ -2687,10 +2697,29 @@ def _complete_statement(self, line: str) -> Statement:
26872697
backwards compatibility with the standard library version of cmd.
26882698
26892699
:param line: the line being parsed
2700+
:param orig_rl_history_length: Optional length of the readline history before the current command was typed.
2701+
This is used to assist in combining multiline readline history entries and is only
2702+
populated by cmd2. Defaults to None.
26902703
:return: the completed Statement
26912704
:raises: Cmd2ShlexError if a shlex error occurs (e.g. No closing quotation)
26922705
:raises: EmptyStatement when the resulting Statement is blank
26932706
"""
2707+
2708+
def combine_rl_history(statement: Statement) -> None:
2709+
"""Combine all lines of a multiline command into a single readline history entry"""
2710+
if orig_rl_history_length is None or not statement.multiline_command:
2711+
return
2712+
2713+
# Remove all previous lines added to history for this command
2714+
while readline.get_current_history_length() > orig_rl_history_length:
2715+
readline.remove_history_item(readline.get_current_history_length() - 1)
2716+
2717+
formatted_command = single_line_format(statement)
2718+
2719+
# If formatted command is different than the previous history item, add it
2720+
if orig_rl_history_length == 0 or formatted_command != readline.get_history_item(orig_rl_history_length):
2721+
readline.add_history(formatted_command)
2722+
26942723
while True:
26952724
try:
26962725
statement = self.statement_parser.parse(line)
@@ -2702,7 +2731,7 @@ def _complete_statement(self, line: str) -> Statement:
27022731
# so we are done
27032732
break
27042733
except Cmd2ShlexError:
2705-
# we have unclosed quotation marks, lets parse only the command
2734+
# we have an unclosed quotation mark, let's parse only the command
27062735
# and see if it's a multiline
27072736
statement = self.statement_parser.parse_command_only(line)
27082737
if not statement.multiline_command:
@@ -2718,6 +2747,7 @@ def _complete_statement(self, line: str) -> Statement:
27182747
# Save the command line up to this point for tab completion
27192748
self._multiline_in_progress = line + '\n'
27202749

2750+
# Get next line of this command
27212751
nextline = self._read_command_line(self.continuation_prompt)
27222752
if nextline == 'eof':
27232753
# they entered either a blank line, or we hit an EOF
@@ -2726,7 +2756,14 @@ def _complete_statement(self, line: str) -> Statement:
27262756
# terminator
27272757
nextline = '\n'
27282758
self.poutput(nextline)
2729-
line = f'{self._multiline_in_progress}{nextline}'
2759+
2760+
line += f'\n{nextline}'
2761+
2762+
# Combine all history lines of this multiline command as we go.
2763+
if nextline:
2764+
statement = self.statement_parser.parse_command_only(line)
2765+
combine_rl_history(statement)
2766+
27302767
except KeyboardInterrupt:
27312768
self.poutput('^C')
27322769
statement = self.statement_parser.parse('')
@@ -2736,13 +2773,20 @@ def _complete_statement(self, line: str) -> Statement:
27362773

27372774
if not statement.command:
27382775
raise EmptyStatement
2776+
else:
2777+
# If necessary, update history with completed multiline command.
2778+
combine_rl_history(statement)
2779+
27392780
return statement
27402781

2741-
def _input_line_to_statement(self, line: str) -> Statement:
2782+
def _input_line_to_statement(self, line: str, *, orig_rl_history_length: Optional[int] = None) -> Statement:
27422783
"""
27432784
Parse the user's input line and convert it to a Statement, ensuring that all macros are also resolved
27442785
27452786
:param line: the line being parsed
2787+
:param orig_rl_history_length: Optional length of the readline history before the current command was typed.
2788+
This is used to assist in combining multiline readline history entries and is only
2789+
populated by cmd2. Defaults to None.
27462790
:return: parsed command line as a Statement
27472791
:raises: Cmd2ShlexError if a shlex error occurs (e.g. No closing quotation)
27482792
:raises: EmptyStatement when the resulting Statement is blank
@@ -2753,11 +2797,13 @@ def _input_line_to_statement(self, line: str) -> Statement:
27532797
# Continue until all macros are resolved
27542798
while True:
27552799
# Make sure all input has been read and convert it to a Statement
2756-
statement = self._complete_statement(line)
2800+
statement = self._complete_statement(line, orig_rl_history_length=orig_rl_history_length)
27572801

2758-
# Save the fully entered line if this is the first loop iteration
2802+
# If this is the first loop iteration, save the original line and stop
2803+
# combining multiline history entries in the remaining iterations.
27592804
if orig_line is None:
27602805
orig_line = statement.raw
2806+
orig_rl_history_length = None
27612807

27622808
# Check if this command matches a macro and wasn't already processed to avoid an infinite loop
27632809
if statement.command in self.macros.keys() and statement.command not in used_macros:
@@ -3111,7 +3157,7 @@ def configure_readline() -> None:
31113157
nonlocal saved_history
31123158
nonlocal parser
31133159

3114-
if readline_configured: # pragma: no cover
3160+
if readline_configured or rl_type == RlType.NONE: # pragma: no cover
31153161
return
31163162

31173163
# Configure tab completion
@@ -3163,7 +3209,7 @@ def complete_none(text: str, state: int) -> Optional[str]: # pragma: no cover
31633209
def restore_readline() -> None:
31643210
"""Restore readline tab completion and history"""
31653211
nonlocal readline_configured
3166-
if not readline_configured: # pragma: no cover
3212+
if not readline_configured or rl_type == RlType.NONE: # pragma: no cover
31673213
return
31683214

31693215
if self._completion_supported():
@@ -3310,6 +3356,13 @@ def _cmdloop(self) -> None:
33103356
self._startup_commands.clear()
33113357

33123358
while not stop:
3359+
# Used in building multiline readline history entries. Only applies
3360+
# when command line is read by input() in a terminal.
3361+
if rl_type != RlType.NONE and self.use_rawinput and sys.stdin.isatty():
3362+
orig_rl_history_length = readline.get_current_history_length()
3363+
else:
3364+
orig_rl_history_length = None
3365+
33133366
# Get commands from user
33143367
try:
33153368
line = self._read_command_line(self.prompt)
@@ -3318,7 +3371,7 @@ def _cmdloop(self) -> None:
33183371
line = ''
33193372

33203373
# Run the command along with all associated pre and post hooks
3321-
stop = self.onecmd_plus_hooks(line)
3374+
stop = self.onecmd_plus_hooks(line, orig_rl_history_length=orig_rl_history_length)
33223375
finally:
33233376
# Get sigint protection while we restore readline settings
33243377
with self.sigint_protection:
@@ -4871,15 +4924,13 @@ def _initialize_history(self, hist_file: str) -> None:
48714924

48724925
# Populate readline history
48734926
if rl_type != RlType.NONE:
4874-
last = None
48754927
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
4928+
formatted_command = single_line_format(item.statement)
4929+
4930+
# If formatted command is different than the previous history item, add it
4931+
cur_history_length = readline.get_current_history_length()
4932+
if cur_history_length == 0 or formatted_command != readline.get_history_item(cur_history_length):
4933+
readline.add_history(formatted_command)
48834934

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

cmd2/history.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,48 @@
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 line to display on a single line.
37+
38+
Spaces and newlines in quotes are preserved so those strings will span multiple lines.
39+
40+
:param statement: Statement being formatted.
41+
:return: formatted command line
42+
"""
43+
if not statement.raw:
44+
return ""
45+
46+
lines = statement.raw.splitlines()
47+
formatted_command = lines[0]
48+
49+
# Append any remaining lines to the command.
50+
for line in lines[1:]:
51+
try:
52+
shlex_split(formatted_command)
53+
except ValueError:
54+
# We are in quotes, so restore the newline.
55+
separator = "\n"
56+
else:
57+
# Don't add a space before line if one already exists or if it begins with the terminator.
58+
if (
59+
formatted_command.endswith(" ")
60+
or line.startswith(" ")
61+
or (statement.terminator and line.startswith(statement.terminator))
62+
):
63+
separator = ""
64+
else:
65+
separator = " "
66+
67+
formatted_command += separator + line
68+
69+
return formatted_command
70+
71+
3372
@dataclass(frozen=True)
3473
class HistoryItem:
3574
"""Class used to represent one command in the history list"""
@@ -85,15 +124,7 @@ def pr(self, idx: int, script: bool = False, expanded: bool = False, verbose: bo
85124
if expanded:
86125
ret_str = self.expanded
87126
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', ' ')
127+
ret_str = single_line_format(self.statement).rstrip()
97128

98129
# Display a numbered list if not writing to a script
99130
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 = (

0 commit comments

Comments
 (0)