summaryrefslogtreecommitdiff
path: root/cmd2
diff options
context:
space:
mode:
authorkotfu <kotfu@kotfu.net>2020-02-14 20:51:21 -0700
committerkotfu <kotfu@kotfu.net>2020-02-14 20:51:21 -0700
commitc8ba8b94950edcad47f791cceec949f174ea7c71 (patch)
treedbae3fa7482c24d6cca9a52d597f498c61d18be0 /cmd2
parent9c6f1304816707e38c74926c93f62e48836b95c9 (diff)
parent013b9e0a2c75e17f8aa0e0f7cbe50d84d2f657d8 (diff)
downloadcmd2-git-c8ba8b94950edcad47f791cceec949f174ea7c71.tar.gz
Merge branch 'master' into api_docs
# Conflicts: # cmd2/ansi.py # docs/features/completion.rst
Diffstat (limited to 'cmd2')
-rw-r--r--cmd2/ansi.py15
-rw-r--r--cmd2/argparse_completer.py6
-rw-r--r--cmd2/argparse_custom.py14
-rw-r--r--cmd2/cmd2.py24
-rw-r--r--cmd2/constants.py3
-rw-r--r--cmd2/utils.py110
6 files changed, 115 insertions, 57 deletions
diff --git a/cmd2/ansi.py b/cmd2/ansi.py
index fbe51b9a..27c9e87a 100644
--- a/cmd2/ansi.py
+++ b/cmd2/ansi.py
@@ -282,22 +282,13 @@ def style(text: Any, *, fg: Union[str, fg] = '', bg: Union[str, bg] = '', bold:
# These can be altered to suit an application's needs and only need to be a
# function with the following structure: func(str) -> str
style_success = functools.partial(style, fg=fg.green)
-"""
-Partial function supplying arguments to :meth:`cmd2.ansi.style()` which colors
-text green to signify success.
-"""
+"""Partial function supplying arguments to :meth:`cmd2.ansi.style()` which colors text to signify success"""
style_warning = functools.partial(style, fg=fg.bright_yellow)
-"""
-Partial function supplying arguments to :meth:`cmd2.ansi.style()` which colors
-text yellow to signify a warning.
-"""
+"""Partial function supplying arguments to :meth:`cmd2.ansi.style()` which colors text to signify a warning"""
style_error = functools.partial(style, fg=fg.bright_red)
-"""
-Partial function supplying arguments to :meth:`cmd2.ansi.style()` which colors
-text red to signify an error.
-"""
+"""Partial function supplying arguments to :meth:`cmd2.ansi.style()` which colors text to signify an error"""
def async_alert_str(*, terminal_columns: int, prompt: str, line: str, cursor_offset: int, alert_msg: str) -> str:
diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py
index 6513fe13..185e01a2 100644
--- a/cmd2/argparse_completer.py
+++ b/cmd2/argparse_completer.py
@@ -444,7 +444,9 @@ class AutoCompleter:
completions.sort(key=self._cmd2_app.default_sort_key)
self._cmd2_app.matches_sorted = True
- token_width = ansi.style_aware_wcswidth(action.dest)
+ # If a metavar was defined, use that instead of the dest field
+ destination = action.metavar if action.metavar else action.dest
+ token_width = ansi.style_aware_wcswidth(destination)
completions_with_desc = []
for item in completions:
@@ -463,7 +465,7 @@ class AutoCompleter:
desc_header = getattr(action, ATTR_DESCRIPTIVE_COMPLETION_HEADER, None)
if desc_header is None:
desc_header = DEFAULT_DESCRIPTIVE_HEADER
- header = '\n{: <{token_width}}{}'.format(action.dest.upper(), desc_header, token_width=token_width + 2)
+ header = '\n{: <{token_width}}{}'.format(destination.upper(), desc_header, token_width=token_width + 2)
self._cmd2_app.completion_header = header
self._cmd2_app.display_matches = completions_with_desc
diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py
index a0e05ae9..a59270c3 100644
--- a/cmd2/argparse_custom.py
+++ b/cmd2/argparse_custom.py
@@ -62,8 +62,8 @@ Tab Completion:
return my_generated_list
completer_function
- Pass a tab-completion function that does custom completion. Since custom tab completion operations commonly
- need to modify cmd2's instance variables related to tab-completion, it will be rare to need a completer
+ Pass a tab completion function that does custom completion. Since custom tab completion operations commonly
+ need to modify cmd2's instance variables related to tab completion, it will be rare to need a completer
function. completer_method should be used in those cases.
Example:
@@ -90,7 +90,7 @@ Tab Completion:
path_filter=lambda path: os.path.isdir(path))
parser.add_argument('-o', '--options', choices_method=completer_method)
- Of the 5 tab-completion parameters, choices is the only one where argparse validates user input against items
+ Of the 5 tab completion parameters, choices is the only one where argparse validates user input against items
in the choices list. This is because the other 4 parameters are meant to tab complete data sets that are viewed
as dynamic. Therefore it is up to the developer to validate if the user has typed an acceptable value for these
arguments.
@@ -118,7 +118,7 @@ Tab Completion:
the developer to determine if the user entered the correct argument type (e.g. int) and validate their values.
CompletionError Class:
- Raised during tab-completion operations to report any sort of error you want printed by the AutoCompleter
+ Raised during tab completion operations to report any sort of error you want printed by the AutoCompleter
Example use cases
- Reading a database to retrieve a tab completion data set failed
@@ -231,7 +231,7 @@ def generate_range_error(range_min: int, range_max: Union[int, float]) -> str:
class CompletionError(Exception):
"""
- Raised during tab-completion operations to report any sort of error you want printed by the AutoCompleter
+ Raised during tab completion operations to report any sort of error you want printed by the AutoCompleter
Example use cases
- Reading a database to retrieve a tab completion data set failed
@@ -356,8 +356,8 @@ def _add_argument_wrapper(self, *args,
# Added args used by AutoCompleter
:param choices_function: function that provides choices for this argument
:param choices_method: cmd2-app method that provides choices for this argument
- :param completer_function: tab-completion function that provides choices for this argument
- :param completer_method: cmd2-app tab-completion method that provides choices for this argument
+ :param completer_function: tab completion function that provides choices for this argument
+ :param completer_method: cmd2-app tab completion method that provides choices for this argument
:param suppress_tab_hint: when AutoCompleter has no results to show during tab completion, it displays the current
argument's help text as a hint. Set this to True to suppress the hint. If this argument's
help text is set to argparse.SUPPRESS, then tab hints will not display regardless of the
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index 2c35a163..8f2cdca3 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -431,9 +431,8 @@ class Cmd(cmd.Cmd):
if new_val in [ansi.STYLE_TERMINAL, ansi.STYLE_ALWAYS, ansi.STYLE_NEVER]:
ansi.allow_style = new_val
else:
- raise ValueError('Invalid value: {} (valid values: {}, {}, {})'.format(new_val, ansi.STYLE_TERMINAL,
- ansi.STYLE_ALWAYS,
- ansi.STYLE_NEVER))
+ raise ValueError("must be {}, {}, or {} (case-insensitive)".format(ansi.STYLE_TERMINAL, ansi.STYLE_ALWAYS,
+ ansi.STYLE_NEVER))
def _completion_supported(self) -> bool:
"""Return whether tab completion is supported"""
@@ -2852,7 +2851,7 @@ class Cmd(cmd.Cmd):
"Call without arguments for a list of all settable parameters with their values.\n"
"Call with just param to view that parameter's value.")
set_parser_parent = DEFAULT_ARGUMENT_PARSER(description=set_description, add_help=False)
- set_parser_parent.add_argument('-l', '--long', action='store_true',
+ set_parser_parent.add_argument('-v', '--verbose', action='store_true',
help='include description of parameters when viewing')
set_parser_parent.add_argument('param', nargs=argparse.OPTIONAL, help='parameter to set or view',
choices_method=_get_settable_completion_items, descriptive_header='Description')
@@ -2886,8 +2885,8 @@ class Cmd(cmd.Cmd):
# Try to update the settable's value
try:
orig_value = getattr(self, args.param)
- new_value = settable.val_type(args.value)
- setattr(self, args.param, new_value)
+ setattr(self, args.param, settable.val_type(args.value))
+ new_value = getattr(self, args.param)
# noinspection PyBroadException
except Exception as e:
err_msg = "Error setting {}: {}".format(args.param, e)
@@ -2917,7 +2916,7 @@ class Cmd(cmd.Cmd):
# Display the results
for param in sorted(results, key=self.default_sort_key):
result_str = results[param]
- if args.long:
+ if args.verbose:
self.poutput('{} # {}'.format(utils.align_left(result_str, width=max_len),
self.settables[param].description))
else:
@@ -3814,9 +3813,6 @@ class Cmd(cmd.Cmd):
# Sanity check that can't fail if self.terminal_lock was acquired before calling this function
if self.terminal_lock.acquire(blocking=False):
- # 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
@@ -3835,6 +3831,8 @@ class Cmd(cmd.Cmd):
if update_terminal:
import shutil
+
+ current_prompt = self.continuation_prompt if self._at_continuation_prompt else self.prompt
terminal_str = ansi.async_alert_str(terminal_columns=shutil.get_terminal_size().columns,
prompt=current_prompt, line=readline.get_line_buffer(),
cursor_offset=rl_get_point(), alert_msg=alert_msg)
@@ -3867,9 +3865,9 @@ class Cmd(cmd.Cmd):
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.
+ If user is at a continuation prompt 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
"""
diff --git a/cmd2/constants.py b/cmd2/constants.py
index 9e8e7780..bc72817f 100644
--- a/cmd2/constants.py
+++ b/cmd2/constants.py
@@ -15,6 +15,9 @@ MULTILINE_TERMINATOR = ';'
LINE_FEED = '\n'
+# One character ellipsis
+HORIZONTAL_ELLIPSIS = '…'
+
DEFAULT_SHORTCUTS = {'?': 'help', '!': 'shell', '@': 'run_script', '@@': '_relative_run_script'}
# Used as the command name placeholder in disabled command messages.
diff --git a/cmd2/utils.py b/cmd2/utils.py
index cfe75f53..e324c2f1 100644
--- a/cmd2/utils.py
+++ b/cmd2/utils.py
@@ -11,7 +11,7 @@ import sys
import threading
import unicodedata
from enum import Enum
-from typing import Any, Callable, Iterable, List, Optional, TextIO, Union
+from typing import Any, Callable, Dict, Iterable, List, Optional, TextIO, Union
from . import constants
@@ -682,8 +682,8 @@ def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ',
width: Optional[int] = None, tab_width: int = 4, truncate: bool = False) -> str:
"""
Align text for display within a given width. Supports characters with display widths greater than 1.
- ANSI style sequences are safely ignored and do not count toward the display width. This means colored text is
- supported. If text has line breaks, then each line is aligned independently.
+ ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned
+ independently.
There are convenience wrappers around this function: align_left(), align_center(), and align_right()
@@ -696,7 +696,7 @@ def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ',
:param truncate: if True, then each line will be shortened to fit within the display width. The truncated
portions are replaced by a '…' character. Defaults to False.
:return: aligned text
- :raises: TypeError if fill_char is more than one character
+ :raises: TypeError if fill_char is more than one character (not including ANSI style sequences)
ValueError if text or fill_char contains an unprintable character
ValueError if width is less than 1
"""
@@ -716,7 +716,7 @@ def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ',
if fill_char == '\t':
fill_char = ' '
- if len(fill_char) != 1:
+ if len(ansi.strip_style(fill_char)) != 1:
raise TypeError("Fill character must be exactly one character long")
fill_char_width = ansi.style_aware_wcswidth(fill_char)
@@ -777,8 +777,8 @@ def align_left(text: str, *, fill_char: str = ' ', width: Optional[int] = None,
tab_width: int = 4, truncate: bool = False) -> str:
"""
Left align text for display within a given width. Supports characters with display widths greater than 1.
- ANSI style sequences are safely ignored and do not count toward the display width. This means colored text is
- supported. If text has line breaks, then each line is aligned independently.
+ ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned
+ independently.
:param text: text to left align (can contain multiple lines)
:param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
@@ -788,7 +788,7 @@ def align_left(text: str, *, fill_char: str = ' ', width: Optional[int] = None,
:param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is
replaced by a '…' character. Defaults to False.
:return: left-aligned text
- :raises: TypeError if fill_char is more than one character
+ :raises: TypeError if fill_char is more than one character (not including ANSI style sequences)
ValueError if text or fill_char contains an unprintable character
ValueError if width is less than 1
"""
@@ -800,8 +800,8 @@ def align_center(text: str, *, fill_char: str = ' ', width: Optional[int] = None
tab_width: int = 4, truncate: bool = False) -> str:
"""
Center text for display within a given width. Supports characters with display widths greater than 1.
- ANSI style sequences are safely ignored and do not count toward the display width. This means colored text is
- supported. If text has line breaks, then each line is aligned independently.
+ ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned
+ independently.
:param text: text to center (can contain multiple lines)
:param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
@@ -811,7 +811,7 @@ def align_center(text: str, *, fill_char: str = ' ', width: Optional[int] = None
:param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is
replaced by a '…' character. Defaults to False.
:return: centered text
- :raises: TypeError if fill_char is more than one character
+ :raises: TypeError if fill_char is more than one character (not including ANSI style sequences)
ValueError if text or fill_char contains an unprintable character
ValueError if width is less than 1
"""
@@ -823,8 +823,8 @@ def align_right(text: str, *, fill_char: str = ' ', width: Optional[int] = None,
tab_width: int = 4, truncate: bool = False) -> str:
"""
Right align text for display within a given width. Supports characters with display widths greater than 1.
- ANSI style sequences are safely ignored and do not count toward the display width. This means colored text is
- supported. If text has line breaks, then each line is aligned independently.
+ ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned
+ independently.
:param text: text to right align (can contain multiple lines)
:param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
@@ -834,7 +834,7 @@ def align_right(text: str, *, fill_char: str = ' ', width: Optional[int] = None,
:param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is
replaced by a '…' character. Defaults to False.
:return: right-aligned text
- :raises: TypeError if fill_char is more than one character
+ :raises: TypeError if fill_char is more than one character (not including ANSI style sequences)
ValueError if text or fill_char contains an unprintable character
ValueError if width is less than 1
"""
@@ -845,8 +845,15 @@ def align_right(text: str, *, fill_char: str = ' ', width: Optional[int] = None,
def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str:
"""
Truncate a single line to fit within a given display width. Any portion of the string that is truncated
- is replaced by a '…' character. Supports characters with display widths greater than 1. ANSI style sequences are
- safely ignored and do not count toward the display width. This means colored text is supported.
+ is replaced by a '…' character. Supports characters with display widths greater than 1. ANSI style sequences
+ do not count toward the display width.
+
+ If there are ANSI style sequences in the string after where truncation occurs, this function will append them
+ to the returned string.
+
+ This is done to prevent issues caused in cases like: truncate_string(fg.blue + hello + fg.reset, 3)
+ In this case, "hello" would be truncated before fg.reset resets the color from blue. Appending the remaining style
+ sequences makes sure the style is in the same state had the entire string been printed.
:param line: text to truncate
:param max_width: the maximum display width the resulting string is allowed to have
@@ -855,6 +862,7 @@ def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str:
:raises: ValueError if text contains an unprintable character like a new line
ValueError if max_width is less than 1
"""
+ import io
from . import ansi
# Handle tabs
@@ -866,12 +874,68 @@ def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str:
if max_width < 1:
raise ValueError("max_width must be at least 1")
- if ansi.style_aware_wcswidth(line) > max_width:
- # Remove characters until we fit. Leave room for the ellipsis.
- line = line[:max_width - 1]
- while ansi.style_aware_wcswidth(line) > max_width - 1:
- line = line[:-1]
+ if ansi.style_aware_wcswidth(line) <= max_width:
+ return line
+
+ # Find all style sequences in the line
+ styles = get_styles_in_text(line)
+
+ # Add characters one by one and preserve all style sequences
+ done = False
+ index = 0
+ total_width = 0
+ truncated_buf = io.StringIO()
+
+ while not done:
+ # Check if a style sequence is at this index. These don't count toward display width.
+ if index in styles:
+ truncated_buf.write(styles[index])
+ style_len = len(styles[index])
+ styles.pop(index)
+ index += style_len
+ continue
+
+ char = line[index]
+ char_width = ansi.style_aware_wcswidth(char)
+
+ # This char will make the text too wide, add the ellipsis instead
+ if char_width + total_width >= max_width:
+ char = constants.HORIZONTAL_ELLIPSIS
+ char_width = ansi.style_aware_wcswidth(char)
+ done = True
+
+ total_width += char_width
+ truncated_buf.write(char)
+ index += 1
+
+ # Append remaining style sequences from original string
+ truncated_buf.write(''.join(styles.values()))
+
+ return truncated_buf.getvalue()
+
+
+def get_styles_in_text(text: str) -> Dict[int, str]:
+ """
+ Return an OrderedDict containing all ANSI style sequences found in a string
+
+ The structure of the dictionary is:
+ key: index where sequences begins
+ value: ANSI style sequence found at index in text
+
+ Keys are in ascending order
+
+ :param text: text to search for style sequences
+ """
+ from . import ansi
+
+ start = 0
+ styles = collections.OrderedDict()
- line += "\N{HORIZONTAL ELLIPSIS}"
+ while True:
+ match = ansi.ANSI_STYLE_RE.search(text, start)
+ if match is None:
+ break
+ styles[match.start()] = match.group()
+ start += len(match.group())
- return line
+ return styles