Skip to content

Commit 3574ae1

Browse files
committed
Updated async_alert() to account for self.prompt not matching Readline's current prompt.
1 parent df1fe25 commit 3574ae1

File tree

4 files changed

+87
-51
lines changed

4 files changed

+87
-51
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
* Removed `--verbose` flag from set command since descriptions always show now.
2323
* All cmd2 built-in commands now populate `self.last_result`.
2424
* Argparse tab completer will complete remaining flag names if there are no more positionals to complete.
25+
* Updated `async_alert()` to account for `self.prompt` not matching Readline's current prompt.
2526
* Deletions (potentially breaking changes)
2627
* Deleted ``set_choices_provider()`` and ``set_completer()`` which were deprecated in 2.1.2
2728

cmd2/cmd2.py

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,9 @@
124124
)
125125
from .rl_utils import (
126126
RlType,
127+
rl_escape_prompt,
127128
rl_get_point,
128-
rl_make_safe_prompt,
129+
rl_get_prompt,
129130
rl_set_prompt,
130131
rl_type,
131132
rl_warning,
@@ -2982,11 +2983,11 @@ def restore_readline() -> None:
29822983
if sys.stdin.isatty():
29832984
try:
29842985
# Deal with the vagaries of readline and ANSI escape codes
2985-
safe_prompt = rl_make_safe_prompt(prompt)
2986+
escaped_prompt = rl_escape_prompt(prompt)
29862987

29872988
with self.sigint_protection:
29882989
configure_readline()
2989-
line = input(safe_prompt)
2990+
line = input(escaped_prompt)
29902991
finally:
29912992
with self.sigint_protection:
29922993
restore_readline()
@@ -5013,42 +5014,44 @@ def async_alert(self, alert_msg: str, new_prompt: Optional[str] = None) -> None:
50135014
Raises a `RuntimeError` if called while another thread holds `terminal_lock`.
50145015
50155016
IMPORTANT: This function will not print an alert unless it can acquire self.terminal_lock to ensure
5016-
a prompt is onscreen. Therefore it is best to acquire the lock before calling this function
5017+
a prompt is onscreen. Therefore it is best to acquire the lock before calling this function
50175018
to guarantee the alert prints and to avoid raising a RuntimeError.
50185019
50195020
:param alert_msg: the message to display to the user
5020-
:param new_prompt: if you also want to change the prompt that is displayed, then include it here
5021-
see async_update_prompt() docstring for guidance on updating a prompt
5021+
:param new_prompt: If you also want to change the prompt that is displayed, then include it here.
5022+
See async_update_prompt() docstring for guidance on updating a prompt.
50225023
"""
50235024
if not (vt100_support and self.use_rawinput):
50245025
return
50255026

50265027
# Sanity check that can't fail if self.terminal_lock was acquired before calling this function
50275028
if self.terminal_lock.acquire(blocking=False):
50285029

5029-
# Only update terminal if there are changes
5030+
# Windows terminals tend to flicker when we redraw the prompt and input lines.
5031+
# To reduce how often this occurs, only update terminal if there are changes.
50305032
update_terminal = False
50315033

50325034
if alert_msg:
50335035
alert_msg += '\n'
50345036
update_terminal = True
50355037

5036-
# Set the prompt if it's changed
5037-
if new_prompt is not None and new_prompt != self.prompt:
5038+
if new_prompt is not None:
50385039
self.prompt = new_prompt
50395040

5040-
# If we aren't at a continuation prompt, then it's OK to update it
5041-
if not self._at_continuation_prompt:
5042-
rl_set_prompt(self.prompt)
5043-
update_terminal = True
5041+
# Check if the prompt to display has changed from what's currently displayed
5042+
cur_onscreen_prompt = rl_get_prompt()
5043+
new_onscreen_prompt = self.continuation_prompt if self._at_continuation_prompt else self.prompt
5044+
5045+
if new_onscreen_prompt != cur_onscreen_prompt:
5046+
update_terminal = True
50445047

50455048
if update_terminal:
50465049
import shutil
50475050

5048-
current_prompt = self.continuation_prompt if self._at_continuation_prompt else self.prompt
5051+
# Generate the string which will replace the current prompt and input lines with the alert
50495052
terminal_str = ansi.async_alert_str(
50505053
terminal_columns=shutil.get_terminal_size().columns,
5051-
prompt=current_prompt,
5054+
prompt=cur_onscreen_prompt,
50525055
line=readline.get_line_buffer(),
50535056
cursor_offset=rl_get_point(),
50545057
alert_msg=alert_msg,
@@ -5060,7 +5063,10 @@ def async_alert(self, alert_msg: str, new_prompt: Optional[str] = None) -> None:
50605063
# noinspection PyUnresolvedReferences
50615064
readline.rl.mode.console.write(terminal_str)
50625065

5063-
# Redraw the prompt and input lines
5066+
# Update Readline's prompt before we redraw it
5067+
rl_set_prompt(new_onscreen_prompt)
5068+
5069+
# Redraw the prompt and input lines below the alert
50645070
rl_force_redisplay()
50655071

50665072
self.terminal_lock.release()
@@ -5079,7 +5085,7 @@ def async_update_prompt(self, new_prompt: str) -> None: # pragma: no cover
50795085
Raises a `RuntimeError` if called while another thread holds `terminal_lock`.
50805086
50815087
IMPORTANT: This function will not update the prompt unless it can acquire self.terminal_lock to ensure
5082-
a prompt is onscreen. Therefore it is best to acquire the lock before calling this function
5088+
a prompt is onscreen. Therefore it is best to acquire the lock before calling this function
50835089
to guarantee the prompt changes and to avoid raising a RuntimeError.
50845090
50855091
If user is at a continuation prompt while entering a multiline command, the onscreen prompt will

cmd2/rl_utils.py

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
# coding=utf-8
22
"""
3-
Imports the proper readline for the platform and provides utility functions for it
3+
Imports the proper Readline for the platform and provides utility functions for it
44
"""
55
import sys
66
from enum import (
77
Enum,
88
)
9+
from typing import (
10+
Union,
11+
cast,
12+
)
913

1014
# Prefer statically linked gnureadline if available (for macOS compatibility due to issues with libedit)
1115
try:
@@ -29,13 +33,13 @@ class RlType(Enum):
2933
NONE = 3
3034

3135

32-
# Check what implementation of readline we are using
36+
# Check what implementation of Readline we are using
3337
rl_type = RlType.NONE
3438

3539
# Tells if the terminal we are running in supports vt100 control characters
3640
vt100_support = False
3741

38-
# Explanation for why readline wasn't loaded
42+
# Explanation for why Readline wasn't loaded
3943
_rl_warn_reason = ''
4044

4145
# The order of this check matters since importing pyreadline/pyreadline3 will also show readline in the modules list
@@ -188,44 +192,64 @@ def rl_get_point() -> int: # pragma: no cover
188192
return 0
189193

190194

191-
# noinspection PyProtectedMember, PyUnresolvedReferences
195+
# noinspection PyUnresolvedReferences
196+
def rl_get_prompt() -> str: # pragma: no cover
197+
"""Gets Readline's current prompt"""
198+
if rl_type == RlType.GNU:
199+
encoded_prompt = ctypes.c_char_p.in_dll(readline_lib, "rl_prompt").value
200+
prompt = cast(bytes, encoded_prompt).decode(encoding='utf-8')
201+
202+
elif rl_type == RlType.PYREADLINE:
203+
prompt_data: Union[str, bytes] = readline.rl.prompt
204+
if isinstance(prompt_data, bytes):
205+
prompt = prompt_data.decode(encoding='utf-8')
206+
else:
207+
prompt = prompt_data
208+
209+
else:
210+
prompt = ''
211+
212+
return rl_unescape_prompt(prompt)
213+
214+
215+
# noinspection PyUnresolvedReferences
192216
def rl_set_prompt(prompt: str) -> None: # pragma: no cover
193217
"""
194-
Sets readline's prompt
218+
Sets Readline's prompt
195219
:param prompt: the new prompt value
196220
"""
197-
safe_prompt = rl_make_safe_prompt(prompt)
221+
escaped_prompt = rl_escape_prompt(prompt)
198222

199223
if rl_type == RlType.GNU:
200-
encoded_prompt = bytes(safe_prompt, encoding='utf-8')
224+
encoded_prompt = bytes(escaped_prompt, encoding='utf-8')
201225
readline_lib.rl_set_prompt(encoded_prompt)
202226

203227
elif rl_type == RlType.PYREADLINE:
204-
readline.rl._set_prompt(safe_prompt)
228+
readline.rl.prompt = escaped_prompt
205229

206230

207-
def rl_make_safe_prompt(prompt: str) -> str: # pragma: no cover
231+
def rl_escape_prompt(prompt: str) -> str:
208232
"""Overcome bug in GNU Readline in relation to calculation of prompt length in presence of ANSI escape codes
209233
210234
:param prompt: original prompt
211235
:return: prompt safe to pass to GNU Readline
212236
"""
213237
if rl_type == RlType.GNU:
214238
# start code to tell GNU Readline about beginning of invisible characters
215-
start = "\x01"
239+
escape_start = "\x01"
216240

217241
# end code to tell GNU Readline about end of invisible characters
218-
end = "\x02"
242+
escape_end = "\x02"
219243

220244
escaped = False
221245
result = ""
222246

223247
for c in prompt:
224248
if c == "\x1b" and not escaped:
225-
result += start + c
249+
result += escape_start + c
226250
escaped = True
227251
elif c.isalpha() and escaped:
228-
result += c + end
252+
result += c + escape_end
229253
escaped = False
230254
else:
231255
result += c
@@ -234,3 +258,13 @@ def rl_make_safe_prompt(prompt: str) -> str: # pragma: no cover
234258

235259
else:
236260
return prompt
261+
262+
263+
def rl_unescape_prompt(prompt: str) -> str:
264+
"""Remove escape characters from a Readline prompt"""
265+
if rl_type == RlType.GNU:
266+
escape_start = "\x01"
267+
escape_end = "\x02"
268+
prompt = prompt.replace(escape_start, "").replace(escape_end, "")
269+
270+
return prompt

tests/test_cmd2.py

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,37 +1020,32 @@ def test_default_to_shell(base_app, monkeypatch):
10201020
assert m.called
10211021

10221022

1023-
def test_ansi_prompt_not_esacped(base_app):
1023+
def test_escaping_prompt():
10241024
from cmd2.rl_utils import (
1025-
rl_make_safe_prompt,
1025+
rl_escape_prompt,
1026+
rl_unescape_prompt,
10261027
)
10271028

1029+
# This prompt has nothing which needs to be escaped
10281030
prompt = '(Cmd) '
1029-
assert rl_make_safe_prompt(prompt) == prompt
1031+
assert rl_escape_prompt(prompt) == prompt
10301032

1031-
1032-
def test_ansi_prompt_escaped():
1033-
from cmd2.rl_utils import (
1034-
rl_make_safe_prompt,
1035-
)
1036-
1037-
app = cmd2.Cmd()
1033+
# This prompt has color which needs to be escaped
10381034
color = 'cyan'
1039-
prompt = 'InColor'
1040-
color_prompt = ansi.style(prompt, fg=color)
1035+
prompt = ansi.style('InColor', fg=color)
10411036

1042-
readline_hack_start = "\x01"
1043-
readline_hack_end = "\x02"
1037+
escape_start = "\x01"
1038+
escape_end = "\x02"
10441039

1045-
readline_safe_prompt = rl_make_safe_prompt(color_prompt)
1046-
assert prompt != color_prompt
1040+
escaped_prompt = rl_escape_prompt(prompt)
10471041
if sys.platform.startswith('win'):
1048-
# PyReadline on Windows doesn't suffer from the GNU readline bug which requires the hack
1049-
assert readline_safe_prompt.startswith(ansi.fg_lookup(color))
1050-
assert readline_safe_prompt.endswith(ansi.FG_RESET)
1042+
# PyReadline on Windows doesn't need to escape invisible characters
1043+
assert escaped_prompt == prompt
10511044
else:
1052-
assert readline_safe_prompt.startswith(readline_hack_start + ansi.fg_lookup(color) + readline_hack_end)
1053-
assert readline_safe_prompt.endswith(readline_hack_start + ansi.FG_RESET + readline_hack_end)
1045+
assert escaped_prompt.startswith(escape_start + ansi.fg_lookup(color) + escape_end)
1046+
assert escaped_prompt.endswith(escape_start + ansi.FG_RESET + escape_end)
1047+
1048+
assert rl_unescape_prompt(escaped_prompt) == prompt
10541049

10551050

10561051
class HelpApp(cmd2.Cmd):

0 commit comments

Comments
 (0)