Skip to content

Do not asynchronously redraw continuation prompts #575

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Oct 12, 2018
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,6 @@ dmypy.sock

# cmd2 history file used in main.py
cmd2_history.txt

# Virtual environment
venv
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 0.9.6 (TBD)
* Enhancements
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change also requires a corresponding change to the installation instruction Sphinx docs. Additionally, the contributor’s guide needs to be updated to add this as a dependency.

* All platforms now depend on [wcwidth](https://pypi.python.org/pypi/wcwidth) to assist with asynchronous alerts.

## 0.9.5 (October 11, 2018)
* Bug Fixes
* Fixed bug where ``get_all_commands`` could return non-callable attributes
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ The tables below list all prerequisites along with the minimum required version
| [attrs](https://github.com/python-attrs/attrs) | `16.3` |
| [colorama](https://github.com/tartley/colorama) | `0.3.7` |
| [pyperclip](https://github.com/asweigart/pyperclip) | `1.5.27` |
| [wcwidth](https://pypi.python.org/pypi/wcwidth) | `0.1.7` |
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the doc updates



#### Additional prerequisites to run cmd2 unit tests
Expand Down
8 changes: 1 addition & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,7 @@ On all operating systems, the latest stable version of `cmd2` can be installed u
pip install -U cmd2
```

cmd2 works with Python 3.4+ on Windows, macOS, and Linux. It is pure Python code with
the only 3rd-party dependencies being on [attrs](https://github.com/python-attrs/attrs),
[colorama](https://github.com/tartley/colorama), and [pyperclip](https://github.com/asweigart/pyperclip).
Windows has an additional dependency on [pyreadline](https://pypi.python.org/pypi/pyreadline). Non-Windows platforms
have an additional dependency on [wcwidth](https://pypi.python.org/pypi/wcwidth). Finally, Python
3.4 has additional dependencies on [contextlib2](https://pypi.python.org/pypi/contextlib2) and the
[typing](https://pypi.org/project/typing/) backport.
cmd2 works with Python 3.4+ on Windows, macOS, and Linux. It is pure Python code with few 3rd-party dependencies.

For information on other installation options, see
[Installation Instructions](https://cmd2.readthedocs.io/en/latest/install.html) in the cmd2
Expand Down
146 changes: 75 additions & 71 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@
import argparse
import cmd
import collections
import colorama
from colorama import Fore
import glob
import inspect
import os
Expand All @@ -43,15 +41,20 @@
import threading
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Type, Union, IO

import colorama
from colorama import Fore
from wcwidth import wcswidth

from . import constants
from . import utils
from . import plugin
from . import utils
from .argparse_completer import AutoCompleter, ACArgumentParser, ACTION_ARG_CHOICES
from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer
from .parsing import StatementParser, Statement, Macro, MacroArg

# Set up readline
from .rl_utils import rl_type, RlType, rl_get_point, rl_set_prompt, vt100_support, rl_make_safe_prompt

if rl_type == RlType.NONE: # pragma: no cover
rl_warning = "Readline features including tab completion have been disabled since no \n" \
"supported version of readline was found. To resolve this, install \n" \
Expand All @@ -71,9 +74,6 @@

elif rl_type == RlType.GNU:

# We need wcswidth to calculate display width of tab completions
from wcwidth import wcswidth

# Get the readline lib so we can make changes to it
import ctypes
from .rl_utils import readline_lib
Expand Down Expand Up @@ -457,6 +457,9 @@ def __init__(self, completekey: str='tab', stdin=None, stdout=None, persistent_h
# Used to keep track of whether we are redirecting or piping output
self.redirecting = False

# Used to keep track of whether a continuation prompt is being displayed
self.at_continuation_prompt = False

# If this string is non-empty, then this warning message will print if a broken pipe error occurs while printing
self.broken_pipe_warning = ''

Expand Down Expand Up @@ -1845,6 +1848,7 @@ def _complete_statement(self, line: str) -> Statement:
# - a multiline command with unclosed quotation marks
if not self.quit_on_sigint:
try:
self.at_continuation_prompt = True
newline = self.pseudo_raw_input(self.continuation_prompt)
if newline == 'eof':
# they entered either a blank line, or we hit an EOF
Expand All @@ -1858,8 +1862,13 @@ def _complete_statement(self, line: str) -> Statement:
self.poutput('^C')
statement = self.statement_parser.parse('')
break
finally:
self.at_continuation_prompt = False
else:
self.at_continuation_prompt = True
newline = self.pseudo_raw_input(self.continuation_prompt)
self.at_continuation_prompt = False

if newline == 'eof':
# they entered either a blank line, or we hit an EOF
# for some other reason. Turn the literal 'eof'
Expand Down Expand Up @@ -2074,11 +2083,6 @@ def pseudo_raw_input(self, prompt: str) -> str:
- if input is a pipe (instead of a tty), look at self.echo
to decide whether to print the prompt and the input
"""

# Temporarily save over self.prompt to reflect what will be on screen
orig_prompt = self.prompt
self.prompt = prompt

if self.use_rawinput:
try:
if sys.stdin.isatty():
Expand Down Expand Up @@ -2122,9 +2126,6 @@ def pseudo_raw_input(self, prompt: str) -> str:
else:
line = 'eof'

# Restore prompt
self.prompt = orig_prompt

return line.strip()

def _cmdloop(self) -> bool:
Expand Down Expand Up @@ -3435,50 +3436,6 @@ class TestMyAppCase(Cmd2TestCase):
runner = unittest.TextTestRunner()
runner.run(testcase)

def _clear_input_lines_str(self) -> str: # pragma: no cover
"""
Returns a string that if printed will clear the prompt and input lines in the terminal,
leaving the cursor at the beginning of the first input line
:return: the string to print
"""
if not (vt100_support and self.use_rawinput):
return ''

import shutil
import colorama.ansi as ansi
from colorama import Cursor

visible_prompt = self.visible_prompt

# Get the size of the terminal
terminal_size = shutil.get_terminal_size()

# Figure out how many lines the prompt and user input take up
total_str_size = len(visible_prompt) + len(readline.get_line_buffer())
num_input_lines = int(total_str_size / terminal_size.columns) + 1

# Get the cursor's offset from the beginning of the first input line
cursor_input_offset = len(visible_prompt) + rl_get_point()

# Calculate what input line the cursor is on
cursor_input_line = int(cursor_input_offset / terminal_size.columns) + 1

# Create a string that will clear all input lines and print the alert
terminal_str = ''

# Move the cursor down to the last input line
if cursor_input_line != num_input_lines:
terminal_str += Cursor.DOWN(num_input_lines - cursor_input_line)

# Clear each input line from the bottom up so that the cursor ends up on the original first input line
terminal_str += (ansi.clear_line() + Cursor.UP(1)) * (num_input_lines - 1)
terminal_str += ansi.clear_line()

# Move the cursor to the beginning of the first input line
terminal_str += '\r'

return terminal_str

def async_alert(self, alert_msg: str, new_prompt: Optional[str] = None) -> None: # pragma: no cover
"""
Display an important message to the user while they are at the prompt in between commands.
Expand All @@ -3497,27 +3454,70 @@ def async_alert(self, alert_msg: str, new_prompt: Optional[str] = None) -> None:
if not (vt100_support and self.use_rawinput):
return

import shutil
import colorama.ansi as ansi
from colorama import Cursor

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

# Generate a string to clear the prompt and input lines and replace with the alert
terminal_str = self._clear_input_lines_str()
# Figure out what prompt is displaying
current_prompt = self.continuation_prompt if self.at_continuation_prompt else self.prompt

# Only update terminal if there are changes
update_terminal = False

if alert_msg:
terminal_str += alert_msg + '\n'
alert_msg += '\n'
update_terminal = True

# Set the new prompt now that _clear_input_lines_str is done using the old prompt
if new_prompt is not None:
# Set the prompt if its changed
if new_prompt is not None and new_prompt != self.prompt:
self.prompt = new_prompt
rl_set_prompt(self.prompt)

# Print terminal_str to erase the lines
if rl_type == RlType.GNU:
sys.stderr.write(terminal_str)
elif rl_type == RlType.PYREADLINE:
readline.rl.mode.console.write(terminal_str)
# If we aren't at a continuation prompt, then redraw the prompt now
if not self.at_continuation_prompt:
rl_set_prompt(self.prompt)
update_terminal = True

# Redraw the prompt and input lines
rl_force_redisplay()
if update_terminal:
# Remove ansi characters to get the visible width of the prompt
prompt_width = wcswidth(utils.strip_ansi(current_prompt))

# Get the size of the terminal
terminal_size = shutil.get_terminal_size()

# Figure out how many lines the prompt and user input take up
total_str_size = prompt_width + wcswidth(readline.get_line_buffer())
num_input_lines = int(total_str_size / terminal_size.columns) + 1

# Get the cursor's offset from the beginning of the first input line
cursor_input_offset = prompt_width + rl_get_point()

# Calculate what input line the cursor is on
cursor_input_line = int(cursor_input_offset / terminal_size.columns) + 1

# Create a string that when printed will clear all input lines and display the alert
terminal_str = ''

# Move the cursor down to the last input line
if cursor_input_line != num_input_lines:
terminal_str += Cursor.DOWN(num_input_lines - cursor_input_line)

# Clear each input line from the bottom up so that the cursor ends up on the original first input line
terminal_str += (ansi.clear_line() + Cursor.UP(1)) * (num_input_lines - 1)
terminal_str += ansi.clear_line()

# Move the cursor to the beginning of the first input line and print the alert
terminal_str += '\r' + alert_msg

if rl_type == RlType.GNU:
sys.stderr.write(terminal_str)
elif rl_type == RlType.PYREADLINE:
readline.rl.mode.console.write(terminal_str)

# Redraw the prompt and input lines
rl_force_redisplay()

self.terminal_lock.release()

Expand All @@ -3536,6 +3536,10 @@ def async_update_prompt(self, new_prompt: str) -> None: # pragma: no cover
a prompt is onscreen. Therefore it is best to acquire the lock before calling this function
to guarantee the prompt changes.

If a continuation prompt is currently being displayed while entering a multiline
command, the onscreen prompt will not change. However self.prompt will still be updated
and display immediately after the multiline line command completes.

:param new_prompt: what to change the prompt to
:raises RuntimeError if called while another thread holds terminal_lock
"""
Expand Down
4 changes: 1 addition & 3 deletions docs/install.rst
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,12 @@ the following Python packages are installed:
* attrs
* colorama
* pyperclip
* wcwidth

On Windows, there is an additional dependency:

* pyreadline

On macOS or Linux, there is an additional dependency:
* wcwidth


Upgrading cmd2
--------------
Expand Down
4 changes: 1 addition & 3 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,11 @@

SETUP_REQUIRES = ['setuptools_scm']

INSTALL_REQUIRES = ['pyperclip >= 1.5.27', 'colorama', 'attrs >= 16.3.0']
INSTALL_REQUIRES = ['pyperclip >= 1.5.27', 'colorama', 'attrs >= 16.3.0', 'wcwidth >= 0.1.7']

EXTRAS_REQUIRE = {
# Windows also requires pyreadline to ensure tab completion works
":sys_platform=='win32'": ['pyreadline'],
# POSIX OSes also require wcwidth for correctly estimating the displayed width of unicode chars
":sys_platform!='win32'": ['wcwidth'],
# Python 3.4 and earlier require contextlib2 for temporarily redirecting stderr and stdout
":python_version<'3.5'": ['contextlib2', 'typing'],
# Extra dependencies for running unit tests
Expand Down