diff options
Diffstat (limited to 'cmd2')
-rw-r--r-- | cmd2/ansi.py | 93 | ||||
-rw-r--r-- | cmd2/argparse_completer.py | 6 | ||||
-rw-r--r-- | cmd2/argparse_custom.py | 4 | ||||
-rw-r--r-- | cmd2/cmd2.py | 90 | ||||
-rw-r--r-- | cmd2/history.py | 4 | ||||
-rwxr-xr-x | cmd2/parsing.py | 9 | ||||
-rw-r--r-- | cmd2/rl_utils.py | 2 | ||||
-rw-r--r-- | cmd2/transcript.py | 8 | ||||
-rw-r--r-- | cmd2/utils.py | 165 |
9 files changed, 266 insertions, 115 deletions
diff --git a/cmd2/ansi.py b/cmd2/ansi.py index 86161ac2..78e0df81 100644 --- a/cmd2/ansi.py +++ b/cmd2/ansi.py @@ -1,5 +1,8 @@ # coding=utf-8 -"""Support for ANSI escape sequences which are used for things like applying style to text""" +""" +Support for ANSI escape sequences which are used for things like applying style to text, +setting the window title, and asynchronous alerts. + """ import functools import re from typing import Any, IO @@ -11,16 +14,16 @@ from wcwidth import wcswidth # On Windows, filter ANSI escape codes out of text sent to stdout/stderr, and replace them with equivalent Win32 calls colorama.init(strip=False) -# Values for allow_ansi setting -ANSI_NEVER = 'Never' -ANSI_TERMINAL = 'Terminal' -ANSI_ALWAYS = 'Always' +# Values for allow_style setting +STYLE_NEVER = 'Never' +STYLE_TERMINAL = 'Terminal' +STYLE_ALWAYS = 'Always' -# Controls when ANSI escape sequences are allowed in output -allow_ansi = ANSI_TERMINAL +# Controls when ANSI style style sequences are allowed in output +allow_style = STYLE_TERMINAL -# Regular expression to match ANSI escape sequences -ANSI_ESCAPE_RE = re.compile(r'\x1b[^m]*m') +# Regular expression to match ANSI style sequences (including 8-bit and 24-bit colors) +ANSI_STYLE_RE = re.compile(r'\x1b\[[^m]*m') # Foreground color presets FG_COLORS = { @@ -68,51 +71,51 @@ FG_RESET = FG_COLORS['reset'] BG_RESET = BG_COLORS['reset'] RESET_ALL = Style.RESET_ALL -BRIGHT = Style.BRIGHT -NORMAL = Style.NORMAL +# Text intensities +INTENSITY_BRIGHT = Style.BRIGHT +INTENSITY_DIM = Style.DIM +INTENSITY_NORMAL = Style.NORMAL -# ANSI escape sequences not provided by colorama +# ANSI style sequences not provided by colorama UNDERLINE_ENABLE = colorama.ansi.code_to_chars(4) UNDERLINE_DISABLE = colorama.ansi.code_to_chars(24) -def strip_ansi(text: str) -> str: +def strip_style(text: str) -> str: """ - Strip ANSI escape sequences from a string. + Strip ANSI style sequences from a string. - :param text: string which may contain ANSI escape sequences - :return: the same string with any ANSI escape sequences removed + :param text: string which may contain ANSI style sequences + :return: the same string with any ANSI style sequences removed """ - return ANSI_ESCAPE_RE.sub('', text) + return ANSI_STYLE_RE.sub('', text) -def ansi_safe_wcswidth(text: str) -> int: +def style_aware_wcswidth(text: str) -> int: """ - Wrap wcswidth to make it compatible with strings that contains ANSI escape sequences - + Wrap wcswidth to make it compatible with strings that contains ANSI style sequences :param text: the string being measured :return: the width of the string when printed to the terminal """ - # Strip ANSI escape sequences since they cause wcswidth to return -1 - return wcswidth(strip_ansi(text)) + # Strip ANSI style sequences since they cause wcswidth to return -1 + return wcswidth(strip_style(text)) -def ansi_aware_write(fileobj: IO, msg: str) -> None: +def style_aware_write(fileobj: IO, msg: str) -> None: """ - Write a string to a fileobject and strip its ANSI escape sequences if required by allow_ansi setting - + Write a string to a fileobject and strip its ANSI style sequences if required by allow_style setting :param fileobj: the file object being written to :param msg: the string being written """ - if allow_ansi.lower() == ANSI_NEVER.lower() or \ - (allow_ansi.lower() == ANSI_TERMINAL.lower() and not fileobj.isatty()): - msg = strip_ansi(msg) + if allow_style.lower() == STYLE_NEVER.lower() or \ + (allow_style.lower() == STYLE_TERMINAL.lower() and not fileobj.isatty()): + msg = strip_style(msg) fileobj.write(msg) def fg_lookup(fg_name: str) -> str: - """Look up ANSI escape codes based on foreground color name. - + """ + Look up ANSI escape codes based on foreground color name. :param fg_name: foreground color name to look up ANSI escape code(s) for :return: ANSI escape code(s) associated with this color :raises ValueError: if the color cannot be found @@ -125,8 +128,8 @@ def fg_lookup(fg_name: str) -> str: def bg_lookup(bg_name: str) -> str: - """Look up ANSI escape codes based on background color name. - + """ + Look up ANSI escape codes based on background color name. :param bg_name: background color name to look up ANSI escape code(s) for :return: ANSI escape code(s) associated with this color :raises ValueError: if the color cannot be found @@ -138,16 +141,18 @@ def bg_lookup(bg_name: str) -> str: return ansi_escape -def style(text: Any, *, fg: str = '', bg: str = '', bold: bool = False, underline: bool = False) -> str: - """Styles a string with ANSI colors and/or styles and returns the new string. - +def style(text: Any, *, fg: str = '', bg: str = '', bold: bool = False, + dim: bool = False, underline: bool = False) -> str: + """ + Apply ANSI colors and/or styles to a string and return it. The styling is self contained which means that at the end of the string reset code(s) are issued to undo whatever styling was done at the beginning. :param text: Any object compatible with str.format() :param fg: foreground color. Relies on `fg_lookup()` to retrieve ANSI escape based on name. Defaults to no color. :param bg: background color. Relies on `bg_lookup()` to retrieve ANSI escape based on name. Defaults to no color. - :param bold: apply the bold style if True. Defaults to False. + :param bold: apply the bold style if True. Can be combined with dim. Defaults to False. + :param dim: apply the dim style if True. Can be combined with bold. Defaults to False. :param underline: apply the underline style if True. Defaults to False. :return: the stylized string """ @@ -170,14 +175,18 @@ def style(text: Any, *, fg: str = '', bg: str = '', bold: bool = False, underlin removals.append(BG_RESET) if bold: - additions.append(Style.BRIGHT) - removals.append(Style.NORMAL) + additions.append(INTENSITY_BRIGHT) + removals.append(INTENSITY_NORMAL) + + if dim: + additions.append(INTENSITY_DIM) + removals.append(INTENSITY_NORMAL) if underline: additions.append(UNDERLINE_ENABLE) removals.append(UNDERLINE_DISABLE) - # Combine the ANSI escape sequences with the text + # Combine the ANSI style sequences with the text return "".join(additions) + text + "".join(removals) @@ -212,14 +221,14 @@ def async_alert_str(*, terminal_columns: int, prompt: str, line: str, cursor_off # That will be included in the input lines calculations since that is where the cursor is. num_prompt_terminal_lines = 0 for line in prompt_lines[:-1]: - line_width = ansi_safe_wcswidth(line) + line_width = style_aware_wcswidth(line) num_prompt_terminal_lines += int(line_width / terminal_columns) + 1 # Now calculate how many terminal lines are take up by the input last_prompt_line = prompt_lines[-1] - last_prompt_line_width = ansi_safe_wcswidth(last_prompt_line) + last_prompt_line_width = style_aware_wcswidth(last_prompt_line) - input_width = last_prompt_line_width + ansi_safe_wcswidth(line) + input_width = last_prompt_line_width + style_aware_wcswidth(line) num_input_terminal_lines = int(input_width / terminal_columns) + 1 diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index a2690dd0..23fd930e 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -444,11 +444,11 @@ class AutoCompleter(object): completions.sort(key=self._cmd2_app.default_sort_key) self._cmd2_app.matches_sorted = True - token_width = ansi.ansi_safe_wcswidth(action.dest) + token_width = ansi.style_aware_wcswidth(action.dest) completions_with_desc = [] for item in completions: - item_width = ansi.ansi_safe_wcswidth(item) + item_width = ansi.style_aware_wcswidth(item) if item_width > token_width: token_width = item_width @@ -585,7 +585,7 @@ class AutoCompleter(object): def _print_message(msg: str) -> None: """Print a message instead of tab completions and redraw the prompt and input line""" import sys - ansi.ansi_aware_write(sys.stdout, msg + '\n') + ansi.style_aware_write(sys.stdout, msg + '\n') rl_force_redisplay() def _print_arg_hint(self, arg_action: argparse.Action) -> None: diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 51c3375e..f735498d 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -802,11 +802,11 @@ class Cmd2ArgumentParser(argparse.ArgumentParser): return formatter.format_help() + '\n' def _print_message(self, message, file=None): - # Override _print_message to use ansi_aware_write() since we use ANSI escape characters to support color + # Override _print_message to use style_aware_write() since we use ANSI escape characters to support color if message: if file is None: file = sys.stderr - ansi.ansi_aware_write(file, message) + ansi.style_aware_write(file, message) # The default ArgumentParser class for a cmd2 app diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index b025043e..ec8d67b2 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -209,11 +209,11 @@ class Cmd(cmd.Cmd): # To make an attribute settable with the "do_set" command, add it to this ... self.settable = \ { - # allow_ansi is a special case in which it's an application-wide setting defined in ansi.py - 'allow_ansi': ('Allow ANSI escape sequences in output ' - '(valid values: {}, {}, {})'.format(ansi.ANSI_TERMINAL, - ansi.ANSI_ALWAYS, - ansi.ANSI_NEVER)), + # allow_style is a special case in which it's an application-wide setting defined in ansi.py + 'allow_style': ('Allow ANSI text style sequences in output ' + '(valid values: {}, {}, {})'.format(ansi.STYLE_TERMINAL, + ansi.STYLE_ALWAYS, + ansi.STYLE_NEVER)), 'continuation_prompt': 'On 2nd+ line of input', 'debug': 'Show full error stack on error', 'echo': 'Echo command issued into output', @@ -309,7 +309,7 @@ class Cmd(cmd.Cmd): if startup_script: startup_script = os.path.abspath(os.path.expanduser(startup_script)) if os.path.exists(startup_script): - self._startup_commands.append("run_script '{}'".format(startup_script)) + self._startup_commands.append("run_script {}".format(utils.quote_string(startup_script))) # Transcript files to run instead of interactive command loop self._transcript_files = None @@ -375,7 +375,7 @@ class Cmd(cmd.Cmd): else: # Here is the meaning of the various flags we are using with the less command: # -S causes lines longer than the screen width to be chopped (truncated) rather than wrapped - # -R causes ANSI "color" escape sequences to be output in raw form (i.e. colors are displayed) + # -R causes ANSI "style" escape sequences to be output in raw form (i.e. colors are displayed) # -X disables sending the termcap initialization and deinitialization strings to the terminal # -F causes less to automatically exit if the entire file can be displayed on the first screen self.pager = 'less -RXF' @@ -404,23 +404,23 @@ class Cmd(cmd.Cmd): # ----- Methods related to presenting output to the user ----- @property - def allow_ansi(self) -> str: - """Read-only property needed to support do_set when it reads allow_ansi""" - return ansi.allow_ansi + def allow_style(self) -> str: + """Read-only property needed to support do_set when it reads allow_style""" + return ansi.allow_style - @allow_ansi.setter - def allow_ansi(self, new_val: str) -> None: - """Setter property needed to support do_set when it updates allow_ansi""" + @allow_style.setter + def allow_style(self, new_val: str) -> None: + """Setter property needed to support do_set when it updates allow_style""" new_val = new_val.lower() - if new_val == ansi.ANSI_TERMINAL.lower(): - ansi.allow_ansi = ansi.ANSI_TERMINAL - elif new_val == ansi.ANSI_ALWAYS.lower(): - ansi.allow_ansi = ansi.ANSI_ALWAYS - elif new_val == ansi.ANSI_NEVER.lower(): - ansi.allow_ansi = ansi.ANSI_NEVER + if new_val == ansi.STYLE_TERMINAL.lower(): + ansi.allow_style = ansi.STYLE_TERMINAL + elif new_val == ansi.STYLE_ALWAYS.lower(): + ansi.allow_style = ansi.STYLE_ALWAYS + elif new_val == ansi.STYLE_NEVER.lower(): + ansi.allow_style = ansi.STYLE_NEVER else: - self.perror('Invalid value: {} (valid values: {}, {}, {})'.format(new_val, ansi.ANSI_TERMINAL, - ansi.ANSI_ALWAYS, ansi.ANSI_NEVER)) + self.perror('Invalid value: {} (valid values: {}, {}, {})'.format(new_val, ansi.STYLE_TERMINAL, + ansi.STYLE_ALWAYS, ansi.STYLE_NEVER)) @property def broken_pipe_warning(self) -> str: @@ -446,14 +446,14 @@ class Cmd(cmd.Cmd): @property def visible_prompt(self) -> str: - """Read-only property to get the visible prompt with any ANSI escape codes stripped. + """Read-only property to get the visible prompt with any ANSI style escape codes stripped. Used by transcript testing to make it easier and more reliable when users are doing things like coloring the prompt using ANSI color codes. :return: prompt stripped of any ANSI escape codes """ - return ansi.strip_ansi(self.prompt) + return ansi.strip_style(self.prompt) def poutput(self, msg: Any = '', *, end: str = '\n') -> None: """Print message to self.stdout and appends a newline by default @@ -466,7 +466,7 @@ class Cmd(cmd.Cmd): :param end: string appended after the end of the message, default a newline """ try: - ansi.ansi_aware_write(self.stdout, "{}{}".format(msg, end)) + ansi.style_aware_write(self.stdout, "{}{}".format(msg, end)) except BrokenPipeError: # This occurs if a command's output is being piped to another # process and that process closes before the command is @@ -489,7 +489,7 @@ class Cmd(cmd.Cmd): final_msg = ansi.style_error(msg) else: final_msg = "{}".format(msg) - ansi.ansi_aware_write(sys.stderr, final_msg + end) + ansi.style_aware_write(sys.stderr, final_msg + end) def pwarning(self, msg: Any = '', *, end: str = '\n', apply_style: bool = True) -> None: """Wraps perror, but applies ansi.style_warning by default @@ -578,8 +578,8 @@ class Cmd(cmd.Cmd): # Don't attempt to use a pager that can block if redirecting or running a script (either text or Python) # Also only attempt to use a pager if actually running in a real fully functional terminal if functional_terminal and not self._redirecting and not self.in_pyscript() and not self.in_script(): - if ansi.allow_ansi.lower() == ansi.ANSI_NEVER.lower(): - msg_str = ansi.strip_ansi(msg_str) + if ansi.allow_style.lower() == ansi.STYLE_NEVER.lower(): + msg_str = ansi.strip_style(msg_str) msg_str += end pager = self.pager @@ -1123,7 +1123,7 @@ class Cmd(cmd.Cmd): longest_match_length = 0 for cur_match in matches_to_display: - cur_length = ansi.ansi_safe_wcswidth(cur_match) + cur_length = ansi.style_aware_wcswidth(cur_match) if cur_length > longest_match_length: longest_match_length = cur_length else: @@ -1901,22 +1901,25 @@ class Cmd(cmd.Cmd): self.perror("Cannot redirect to paste buffer; missing 'pyperclip' and/or pyperclip dependencies") redir_error = True + # Redirecting to a file elif statement.output_to: - # going to a file - mode = 'w' - # statement.output can only contain - # REDIRECTION_APPEND or REDIRECTION_OUTPUT + # statement.output can only contain REDIRECTION_APPEND or REDIRECTION_OUTPUT if statement.output == constants.REDIRECTION_APPEND: mode = 'a' + else: + mode = 'w' + try: - new_stdout = open(utils.strip_quotes(statement.output_to), mode) + # Use line buffering + new_stdout = open(utils.strip_quotes(statement.output_to), mode=mode, buffering=1) saved_state.redirecting = True sys.stdout = self.stdout = new_stdout except OSError as ex: self.pexcept('Failed to redirect because - {}'.format(ex)) redir_error = True + + # Redirecting to a paste buffer else: - # going to a paste buffer new_stdout = tempfile.TemporaryFile(mode="w+") saved_state.redirecting = True sys.stdout = self.stdout = new_stdout @@ -2677,7 +2680,7 @@ class Cmd(cmd.Cmd): widest = 0 # measure the commands for command in cmds: - width = ansi.ansi_safe_wcswidth(command) + width = ansi.style_aware_wcswidth(command) if width > widest: widest = width # add a 4-space pad @@ -2742,6 +2745,7 @@ class Cmd(cmd.Cmd): self.stdout.write("\n") shortcuts_parser = DEFAULT_ARGUMENT_PARSER(description="List available shortcuts") + @with_argparser(shortcuts_parser) def do_shortcuts(self, _: argparse.Namespace) -> None: """List available shortcuts""" @@ -2751,6 +2755,7 @@ class Cmd(cmd.Cmd): self.poutput("Shortcuts for other commands:\n{}".format(result)) eof_parser = DEFAULT_ARGUMENT_PARSER(description="Called when <Ctrl>-D is pressed", epilog=INTERNAL_COMMAND_EPILOG) + @with_argparser(eof_parser) def do_eof(self, _: argparse.Namespace) -> bool: """Called when <Ctrl>-D is pressed""" @@ -2758,6 +2763,7 @@ class Cmd(cmd.Cmd): return True quit_parser = DEFAULT_ARGUMENT_PARSER(description="Exit this application") + @with_argparser(quit_parser) def do_quit(self, _: argparse.Namespace) -> bool: """Exit this application""" @@ -3239,6 +3245,7 @@ class Cmd(cmd.Cmd): # Only include the do_ipy() method if IPython is available on the system if ipython_available: # pragma: no cover ipython_parser = DEFAULT_ARGUMENT_PARSER(description="Enter an interactive IPython shell") + @with_argparser(ipython_parser) def do_ipy(self, _: argparse.Namespace) -> None: """Enter an interactive IPython shell""" @@ -3247,6 +3254,7 @@ class Cmd(cmd.Cmd): 'Run Python code from external files with: run filename.py\n') exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0]) + # noinspection PyUnusedLocal def load_ipy(cmd2_app: Cmd, py_bridge: PyBridge): """ Embed an IPython shell in an environment that is restricted to only the variables in this function @@ -3377,7 +3385,7 @@ class Cmd(cmd.Cmd): with os.fdopen(fd, 'w') as fobj: for command in history: if command.statement.multiline_command: - fobj.write('{}\n'.format(command.expanded.rstrip())) + fobj.write('{}\n'.format(command.expanded)) else: fobj.write('{}\n'.format(command.raw)) try: @@ -3391,7 +3399,7 @@ class Cmd(cmd.Cmd): with open(os.path.expanduser(args.output_file), 'w') as fobj: for item in history: if item.statement.multiline_command: - fobj.write('{}\n'.format(item.expanded.rstrip())) + fobj.write('{}\n'.format(item.expanded)) else: fobj.write('{}\n'.format(item.raw)) plural = 's' if len(history) > 1 else '' @@ -3739,7 +3747,7 @@ class Cmd(cmd.Cmd): verinfo = ".".join(map(str, sys.version_info[:3])) num_transcripts = len(transcripts_expanded) plural = '' if len(transcripts_expanded) == 1 else 's' - self.poutput(ansi.style(utils.center_text('cmd2 transcript test', pad='='), bold=True)) + self.poutput(ansi.style(utils.align_center(' cmd2 transcript test ', fill_char='='), bold=True)) self.poutput('platform {} -- Python {}, cmd2-{}, readline-{}'.format(sys.platform, verinfo, cmd2.__version__, rl_type)) self.poutput('cwd: {}'.format(os.getcwd())) @@ -3756,9 +3764,9 @@ class Cmd(cmd.Cmd): test_results = runner.run(testcase) execution_time = time.time() - start_time if test_results.wasSuccessful(): - ansi.ansi_aware_write(sys.stderr, stream.read()) - finish_msg = '{0} transcript{1} passed in {2:.3f} seconds'.format(num_transcripts, plural, execution_time) - finish_msg = ansi.style_success(utils.center_text(finish_msg, pad='=')) + ansi.style_aware_write(sys.stderr, stream.read()) + finish_msg = ' {0} transcript{1} passed in {2:.3f} seconds '.format(num_transcripts, plural, execution_time) + finish_msg = ansi.style_success(utils.align_center(finish_msg, fill_char='=')) self.poutput(finish_msg) else: # Strip off the initial traceback which isn't particularly useful for end users diff --git a/cmd2/history.py b/cmd2/history.py index 576ac37d..3b18fbeb 100644 --- a/cmd2/history.py +++ b/cmd2/history.py @@ -45,14 +45,14 @@ class HistoryItem(): """ if verbose: raw = self.raw.rstrip() - expanded = self.expanded.rstrip() + expanded = self.expanded ret_str = self._listformat.format(self.idx, raw) if raw != expanded: ret_str += '\n' + self._ex_listformat.format(self.idx, expanded) else: if expanded: - ret_str = self.expanded.rstrip() + ret_str = self.expanded else: ret_str = self.raw.rstrip() diff --git a/cmd2/parsing.py b/cmd2/parsing.py index 4e690b0b..cef0b088 100755 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -13,9 +13,10 @@ from . import utils def shlex_split(str_to_split: str) -> List[str]: - """A wrapper around shlex.split() that uses cmd2's preferred arguments. + """ + A wrapper around shlex.split() that uses cmd2's preferred arguments. + This allows other classes to easily call split() the same way StatementParser does. - This allows other classes to easily call split() the same way StatementParser does :param str_to_split: the string being split :return: A list of tokens """ @@ -26,8 +27,8 @@ def shlex_split(str_to_split: str) -> List[str]: class MacroArg: """ Information used to replace or unescape arguments in a macro value when the macro is resolved - Normal argument syntax : {5} - Escaped argument syntax: {{5}} + Normal argument syntax: {5} + Escaped argument syntax: {{5}} """ # The starting index of this argument in the macro value start_index = attr.ib(validator=attr.validators.instance_of(int)) diff --git a/cmd2/rl_utils.py b/cmd2/rl_utils.py index 9a23cbcd..4df733db 100644 --- a/cmd2/rl_utils.py +++ b/cmd2/rl_utils.py @@ -193,7 +193,7 @@ def rl_set_prompt(prompt: str) -> None: # pragma: no cover def rl_make_safe_prompt(prompt: str) -> str: # pragma: no cover - """Overcome bug in GNU Readline in relation to calculation of prompt length in presence of ANSI escape codes. + """Overcome bug in GNU Readline in relation to calculation of prompt length in presence of ANSI escape codes :param prompt: original prompt :return: prompt safe to pass to GNU Readline diff --git a/cmd2/transcript.py b/cmd2/transcript.py index 25a79310..940c97db 100644 --- a/cmd2/transcript.py +++ b/cmd2/transcript.py @@ -56,13 +56,13 @@ class Cmd2TestCase(unittest.TestCase): def _test_transcript(self, fname: str, transcript): line_num = 0 finished = False - line = ansi.strip_ansi(next(transcript)) + line = ansi.strip_style(next(transcript)) line_num += 1 while not finished: # Scroll forward to where actual commands begin while not line.startswith(self.cmdapp.visible_prompt): try: - line = ansi.strip_ansi(next(transcript)) + line = ansi.strip_style(next(transcript)) except StopIteration: finished = True break @@ -89,7 +89,7 @@ class Cmd2TestCase(unittest.TestCase): result = self.cmdapp.stdout.read() stop_msg = 'Command indicated application should quit, but more commands in transcript' # Read the expected result from transcript - if ansi.strip_ansi(line).startswith(self.cmdapp.visible_prompt): + if ansi.strip_style(line).startswith(self.cmdapp.visible_prompt): message = '\nFile {}, line {}\nCommand was:\n{}\nExpected: (nothing)\nGot:\n{}\n'.format( fname, line_num, command, result) self.assertTrue(not (result.strip()), message) @@ -97,7 +97,7 @@ class Cmd2TestCase(unittest.TestCase): self.assertFalse(stop, stop_msg) continue expected = [] - while not ansi.strip_ansi(line).startswith(self.cmdapp.visible_prompt): + while not ansi.strip_style(line).startswith(self.cmdapp.visible_prompt): expected.append(line) try: line = next(transcript) diff --git a/cmd2/utils.py b/cmd2/utils.py index a1a0d377..ffbe5a64 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -5,11 +5,11 @@ import collections import glob import os import re -import shutil import subprocess import sys import threading import unicodedata +from enum import Enum from typing import Any, Iterable, List, Optional, TextIO, Union from . import constants @@ -363,21 +363,6 @@ def get_exes_in_path(starts_with: str) -> List[str]: return list(exes_set) -def center_text(msg: str, *, pad: str = ' ') -> str: - """Centers text horizontally for display within the current terminal, optionally padding both sides. - - :param msg: message to display in the center - :param pad: if provided, the first character will be used to pad both sides of the message - :return: centered message, optionally padded on both sides with pad_char - """ - term_width = shutil.get_terminal_size().columns - surrounded_msg = ' {} '.format(msg) - if not pad: - pad = ' ' - fill_char = pad[:1] - return surrounded_msg.center(term_width, fill_char) - - class StdSim(object): """ Class to simulate behavior of sys.stdout or sys.stderr. @@ -644,3 +629,151 @@ def basic_complete(text: str, line: str, begidx: int, endidx: int, match_against :return: a list of possible tab completions """ return [cur_match for cur_match in match_against if cur_match.startswith(text)] + + +class TextAlignment(Enum): + LEFT = 1 + CENTER = 2 + RIGHT = 3 + + +def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ', + width: Optional[int] = None, tab_width: int = 4) -> 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. + + There are convenience wrappers around this function: align_left(), align_center(), and align_right() + + :param text: text to align (can contain multiple lines) + :param alignment: how to align the text + :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character) + :param width: display width of the aligned text. Defaults to width of the terminal. + :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will + be converted to a space. + :return: aligned text + :raises: TypeError if fill_char is more than one character + ValueError if text or fill_char contains an unprintable character + """ + import io + import shutil + + from . import ansi + + # Handle tabs + text = text.replace('\t', ' ' * tab_width) + if fill_char == '\t': + fill_char = ' ' + + if len(fill_char) != 1: + raise TypeError("Fill character must be exactly one character long") + + fill_char_width = ansi.style_aware_wcswidth(fill_char) + if fill_char_width == -1: + raise (ValueError("Fill character is an unprintable character")) + + if text: + lines = text.splitlines() + else: + lines = [''] + + if width is None: + width = shutil.get_terminal_size().columns + + text_buf = io.StringIO() + + for index, line in enumerate(lines): + if index > 0: + text_buf.write('\n') + + # Use style_aware_wcswidth to support characters with display widths + # greater than 1 as well as ANSI style sequences + line_width = ansi.style_aware_wcswidth(line) + if line_width == -1: + raise(ValueError("Text to align contains an unprintable character")) + + # Check if line is wider than the desired final width + if width <= line_width: + text_buf.write(line) + continue + + # Calculate how wide each side of filling needs to be + total_fill_width = width - line_width + + if alignment == TextAlignment.LEFT: + left_fill_width = 0 + right_fill_width = total_fill_width + elif alignment == TextAlignment.CENTER: + left_fill_width = total_fill_width // 2 + right_fill_width = total_fill_width - left_fill_width + else: + left_fill_width = total_fill_width + right_fill_width = 0 + + # Determine how many fill characters are needed to cover the width + left_fill = (left_fill_width // fill_char_width) * fill_char + right_fill = (right_fill_width // fill_char_width) * fill_char + + # In cases where the fill character display width didn't divide evenly into + # the gaps being filled, pad the remainder with spaces. + left_fill += ' ' * (left_fill_width - ansi.style_aware_wcswidth(left_fill)) + right_fill += ' ' * (right_fill_width - ansi.style_aware_wcswidth(right_fill)) + + text_buf.write(left_fill + line + right_fill) + + return text_buf.getvalue() + + +def align_left(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4) -> 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. + + :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) + :param width: display width of the aligned text. Defaults to width of the terminal. + :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will + be converted to a space. + :return: left-aligned text + :raises: TypeError if fill_char is more than one character + ValueError if text or fill_char contains an unprintable character + """ + return align_text(text, TextAlignment.LEFT, fill_char=fill_char, width=width, tab_width=tab_width) + + +def align_center(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4) -> 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. + + :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) + :param width: display width of the aligned text. Defaults to width of the terminal. + :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will + be converted to a space. + :return: centered text + :raises: TypeError if fill_char is more than one character + ValueError if text or fill_char contains an unprintable character + """ + return align_text(text, TextAlignment.CENTER, fill_char=fill_char, width=width, tab_width=tab_width) + + +def align_right(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4) -> 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. + + :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) + :param width: display width of the aligned text. Defaults to width of the terminal. + :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will + be converted to a space. + :return: right-aligned text + :raises: TypeError if fill_char is more than one character + ValueError if text or fill_char contains an unprintable character + """ + return align_text(text, TextAlignment.RIGHT, fill_char=fill_char, width=width, tab_width=tab_width) |