diff options
-rw-r--r-- | CHANGELOG.md | 21 | ||||
-rwxr-xr-x | README.md | 14 | ||||
-rw-r--r-- | cmd2/ansi.py | 182 | ||||
-rw-r--r-- | cmd2/argparse_completer.py | 14 | ||||
-rw-r--r-- | cmd2/cmd2.py | 362 | ||||
-rw-r--r-- | cmd2/constants.py | 10 | ||||
-rw-r--r-- | cmd2/history.py | 12 | ||||
-rw-r--r-- | cmd2/parsing.py | 10 | ||||
-rw-r--r-- | cmd2/transcript.py | 10 | ||||
-rw-r--r-- | cmd2/utils.py | 23 | ||||
-rw-r--r-- | docs/argument_processing.rst | 2 | ||||
-rw-r--r-- | docs/settingchanges.rst | 4 | ||||
-rw-r--r-- | docs/unfreefeatures.rst | 11 | ||||
-rwxr-xr-x | examples/async_printing.py | 26 | ||||
-rwxr-xr-x | examples/colors.py | 94 | ||||
-rwxr-xr-x | examples/paged_output.py | 10 | ||||
-rwxr-xr-x | examples/pirate.py | 21 | ||||
-rwxr-xr-x | examples/plumbum_colors.py | 86 | ||||
-rwxr-xr-x | examples/python_scripting.py | 11 | ||||
-rwxr-xr-x | examples/table_display.py | 3 | ||||
-rw-r--r-- | tests/conftest.py | 4 | ||||
-rw-r--r-- | tests/scripts/postcmds.txt | 2 | ||||
-rw-r--r-- | tests/scripts/precmds.txt | 2 | ||||
-rw-r--r-- | tests/test_ansi.py | 89 | ||||
-rw-r--r-- | tests/test_cmd2.py | 145 | ||||
-rw-r--r-- | tests/test_history.py | 27 | ||||
-rw-r--r-- | tests/test_utils.py | 15 | ||||
-rw-r--r-- | tests/transcripts/regex_set.txt | 2 |
28 files changed, 703 insertions, 509 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 8abe6a6a..cc12b1da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,33 @@ * Enhancements * Added support for and testing with Python 3.8, starting with 3.8 beta * Improved information displayed during transcript testing + * Added `ansi` module with functions and constants to support ANSI escape sequences which are used for things + like applying style to text + * Added support for applying styles (color, bold, underline) to text via `style()` function in `ansi` module + * Added default styles to ansi.py for printing `success`, `warning`. and `error` text. These are the styles used + by cmd2 and can be overridden to match the color scheme of your application. + * Added `ansi_aware_write()` function to `ansi` module. This function takes into account the value of `allow_ansi` + to determine if ANSI escape sequences should be stripped when not writing to a tty. See documentation for more + information on the `allow_ansi` setting. * Breaking Changes * Python 3.4 reached its [end of life](https://www.python.org/dev/peps/pep-0429/) on March 18, 2019 and is no longer supported by `cmd2` * If you need to use Python 3.4, you should pin your requirements to use `cmd2` 0.9.13 * Made lots of changes to minimize the public API of the `cmd2.Cmd` class * Attributes and methods we do not intend to be public now all begin with an underscore * We make no API stability guarantees about these internal functions + * Split `perror` into 2 functions: + * `perror` - print a message to sys.stderr + * `pexcept` - print Exception message to sys.stderr. If debug is true, print exception traceback if one exists + * Signature of `poutput` and `perror` significantly changed + * Removed color parameters `color`, `err_color`, and `war_color` from `poutput` and `perror` + * See the docstrings of these methods or the [cmd2 docs](https://cmd2.readthedocs.io/en/latest/unfreefeatures.html#poutput-pfeedback-perror-ppaged) for more info on applying styles to output messages + * `end` argument is now keyword-only and cannot be specified positionally + * `traceback_war` no longer exists as an argument since it isn't needed now that `perror` and `pexcept` exist + * Moved `cmd2.Cmd.colors` to ansi.py and renamed it to `allow_ansi`. This is now an application-wide setting. + * Renamed the following constants and moved them to ansi.py + * `COLORS_ALWAYS` --> `ANSI_ALWAYS` + * `COLORS_NEVER` --> `ANSI_NEVER` + * `COLORS_TERMINAL` --> `ANSI_TERMINAL` * **Renamed Commands Notice** * The following commands have been renamed. The old names will be supported until the next release. * `load` --> `run_script` @@ -43,6 +43,7 @@ Main Features - Built-in regression testing framework for your applications (transcript-based testing) - Transcripts for use with built-in regression can be automatically generated from `history -t` or `run_script -t` - Alerts that seamlessly print while user enters text at prompt +- Colored and stylized output using `ansi.style()` Python 2.7 support is EOL ------------------------- @@ -89,7 +90,7 @@ Instructions for implementing each feature follow. class MyApp(cmd2.Cmd): def do_foo(self, args): """This docstring is the built-in help for the foo command.""" - print('foo bar baz') + print(cmd2.ansi.style('foo bar baz', fg='red')) ``` - By default the docstring for your **do_foo** method is the help for the **foo** command - NOTE: This doesn't apply if you use one of the `argparse` decorators mentioned below @@ -314,11 +315,10 @@ example/transcript_regex.txt: ```text # Run this transcript with "python example.py -t transcript_regex.txt" -# The regex for colors is because no color on Windows. # The regex for editor will match whatever program you use. # regexes on prompts just make the trailing space obvious (Cmd) set -colors: /(True|False)/ +allow_ansi: Terminal continuation_prompt: >/ / debug: False echo: False @@ -331,9 +331,7 @@ quiet: False timing: False ``` -Note how a regular expression `/(True|False)/` is used for output of the **show color** command since -colored text is currently not available for cmd2 on Windows. Regular expressions can be used anywhere within a -transcript file simply by enclosing them within forward slashes, `/`. +Regular expressions can be used anywhere within a transcript file simply by enclosing them within forward slashes, `/`. Found a bug? @@ -357,12 +355,16 @@ Here are a few examples of open-source projects which use `cmd2`: * [Ceph](https://ceph.com/) is a distributed object, block, and file storage platform * [JSShell](https://github.com/Den1al/JSShell) * An interactive multi-user web JavaScript shell +* [psiTurk](https://psiturk.org) + * An open platform for science on Amazon Mechanical Turk * [Jok3r](http://www.jok3r-framework.com) * Network & Web Pentest Automation Framework * [Poseidon](https://github.com/CyberReboot/poseidon) * Leverages software-defined networks (SDNs) to acquire and then feed network traffic to a number of machine learning techniques * [Unipacker](https://github.com/unipacker/unipacker) * Automatic and platform-independent unpacker for Windows binaries based on emulation +* [FLASHMINGO](https://github.com/fireeye/flashmingo) + * Automatic analysis of SWF files based on some heuristics. Extensible via plugins. * [tomcatmanager](https://github.com/tomcatmanager/tomcatmanager) * A command line tool and python library for managing a tomcat server * [mptcpanalyzer](https://github.com/teto/mptcpanalyzer) diff --git a/cmd2/ansi.py b/cmd2/ansi.py new file mode 100644 index 00000000..035991bb --- /dev/null +++ b/cmd2/ansi.py @@ -0,0 +1,182 @@ +# coding=utf-8 +"""Support for ANSI escape sequences which are used for things like applying style to text""" +import functools +import re +from typing import Any, IO + +import colorama +from colorama import Fore, Back, Style +from wcwidth import wcswidth + +# Values for allow_ansi setting +ANSI_NEVER = 'Never' +ANSI_TERMINAL = 'Terminal' +ANSI_ALWAYS = 'Always' + +# Controls when ANSI escape sequences are allowed in output +allow_ansi = ANSI_TERMINAL + +# Regular expression to match ANSI escape sequences +ANSI_ESCAPE_RE = re.compile(r'\x1b[^m]*m') + +# Foreground color presets +FG_COLORS = { + 'black': Fore.BLACK, + 'red': Fore.RED, + 'green': Fore.GREEN, + 'yellow': Fore.YELLOW, + 'blue': Fore.BLUE, + 'magenta': Fore.MAGENTA, + 'cyan': Fore.CYAN, + 'white': Fore.WHITE, + 'bright_black': Fore.LIGHTBLACK_EX, + 'bright_red': Fore.LIGHTRED_EX, + 'bright_green': Fore.LIGHTGREEN_EX, + 'bright_yellow': Fore.LIGHTYELLOW_EX, + 'bright_blue': Fore.LIGHTBLUE_EX, + 'bright_magenta': Fore.LIGHTMAGENTA_EX, + 'bright_cyan': Fore.LIGHTCYAN_EX, + 'bright_white': Fore.LIGHTWHITE_EX, + 'reset': Fore.RESET, +} + +# Background color presets +BG_COLORS = { + 'black': Back.BLACK, + 'red': Back.RED, + 'green': Back.GREEN, + 'yellow': Back.YELLOW, + 'blue': Back.BLUE, + 'magenta': Back.MAGENTA, + 'cyan': Back.CYAN, + 'white': Back.WHITE, + 'bright_black': Back.LIGHTBLACK_EX, + 'bright_red': Back.LIGHTRED_EX, + 'bright_green': Back.LIGHTGREEN_EX, + 'bright_yellow': Back.LIGHTYELLOW_EX, + 'bright_blue': Back.LIGHTBLUE_EX, + 'bright_magenta': Back.LIGHTMAGENTA_EX, + 'bright_cyan': Back.LIGHTCYAN_EX, + 'bright_white': Back.LIGHTWHITE_EX, + 'reset': Back.RESET, +} + +FG_RESET = FG_COLORS['reset'] +BG_RESET = BG_COLORS['reset'] +RESET_ALL = Style.RESET_ALL + +# ANSI escape 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: + """ + Strip ANSI escape sequences from a string. + + :param text: string which may contain ANSI escape sequences + :return: the same string with any ANSI escape sequences removed + """ + return ANSI_ESCAPE_RE.sub('', text) + + +def ansi_safe_wcswidth(text: str) -> int: + """ + Wrap wcswidth to make it compatible with strings that contains ANSI escape sequences + + :param text: the string being measured + """ + # Strip ANSI escape sequences since they cause wcswidth to return -1 + return wcswidth(strip_ansi(text)) + + +def ansi_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 + + :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) + fileobj.write(msg) + + +def fg_lookup(fg_name: str) -> str: + """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 + """ + try: + ansi_escape = FG_COLORS[fg_name.lower()] + except KeyError: + raise ValueError('Foreground color {!r} does not exist.'.format(fg_name)) + return ansi_escape + + +def bg_lookup(bg_name: str) -> str: + """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 + """ + try: + ansi_escape = BG_COLORS[bg_name.lower()] + except KeyError: + raise ValueError('Background color {!r} does not exist.'.format(bg_name)) + 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. + + 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 underline: apply the underline style if True. Defaults to False. + :return: the stylized string + """ + # List of strings that add style + additions = [] + + # List of strings that remove style + removals = [] + + # Convert the text object into a string if it isn't already one + text = "{}".format(text) + + # Process the style settings + if fg: + additions.append(fg_lookup(fg)) + removals.append(FG_RESET) + + if bg: + additions.append(bg_lookup(bg)) + removals.append(BG_RESET) + + if bold: + additions.append(Style.BRIGHT) + removals.append(Style.NORMAL) + + if underline: + additions.append(UNDERLINE_ENABLE) + removals.append(UNDERLINE_DISABLE) + + # Combine the ANSI escape sequences with the text + return "".join(additions) + text + "".join(removals) + + +# Default styles for printing strings of various types. +# 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='green', bold=True) +style_warning = functools.partial(style, fg='bright_yellow') +style_error = functools.partial(style, fg='bright_red') diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 6b3f5298..539132dd 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -66,10 +66,8 @@ import sys from argparse import ZERO_OR_MORE, ONE_OR_MORE, ArgumentError, _, _get_action_name, SUPPRESS from typing import List, Dict, Tuple, Callable, Union -from colorama import Fore - +from .ansi import ansi_aware_write, ansi_safe_wcswidth, style_error from .rl_utils import rl_force_redisplay -from .utils import ansi_safe_wcswidth # attribute that can optionally added to an argparse argument (called an Action) to # define the completion choices for the argument. You may provide a Collection or a Function. @@ -996,7 +994,8 @@ class ACArgumentParser(argparse.ArgumentParser): linum += 1 self.print_usage(sys.stderr) - self.exit(2, Fore.LIGHTRED_EX + '{}\n\n'.format(formatted_message) + Fore.RESET) + formatted_message = style_error(formatted_message) + self.exit(2, '{}\n\n'.format(formatted_message)) def format_help(self) -> str: """Copy of format_help() from argparse.ArgumentParser with tweaks to separately display required parameters""" @@ -1048,6 +1047,13 @@ class ACArgumentParser(argparse.ArgumentParser): # determine help from format above 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 + if message: + if file is None: + file = _sys.stderr + ansi_aware_write(file, message) + def _get_nargs_pattern(self, action) -> str: # Override _get_nargs_pattern behavior to use the nargs ranges provided by AutoCompleter if isinstance(action, _RangeAction) and \ diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index e5c2ac44..eb2d4c15 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -27,7 +27,7 @@ Git repository on GitHub at https://github.com/python-cmd2/cmd2 # import this module, many of these imports are lazy-loaded # i.e. we only import the module when we use it # For example, we don't import the 'traceback' module -# until the perror() function is called and the debug +# until the pexcept() function is called and the debug # setting is True import argparse import cmd @@ -40,11 +40,11 @@ import sys import threading from collections import namedtuple from contextlib import redirect_stdout -from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Type, Union, IO +from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Type, Union import colorama -from colorama import Fore +from . import ansi from . import constants from . import plugin from . import utils @@ -60,7 +60,7 @@ 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" \ "pyreadline on Windows or gnureadline on Mac.\n\n" - sys.stderr.write(Fore.LIGHTYELLOW_EX + rl_warning + Fore.RESET) + sys.stderr.write(ansi.style_warning(rl_warning)) else: from .rl_utils import rl_force_redisplay, readline @@ -337,20 +337,20 @@ class Cmd(cmd.Cmd): terminators: Optional[List[str]] = None, shortcuts: Optional[Dict[str, str]] = None) -> None: """An easy but powerful framework for writing line-oriented command interpreters, extends Python's cmd package. - :param completekey: (optional) readline name of a completion key, default to Tab - :param stdin: (optional) alternate input file object, if not specified, sys.stdin is used - :param stdout: (optional) alternate output file object, if not specified, sys.stdout is used - :param persistent_history_file: (optional) file path to load a persistent cmd2 command history from - :param persistent_history_length: (optional) max number of history items to write to the persistent history file - :param startup_script: (optional) file path to a script to execute at startup - :param use_ipython: (optional) should the "ipy" command be included for an embedded IPython shell - :param allow_cli_args: (optional) if True, then cmd2 will process command line arguments as either - commands to be run or, if -t is specified, transcript files to run. - This should be set to False if your application parses its own arguments. - :param transcript_files: (optional) allows running transcript tests when allow_cli_args is False - :param allow_redirection: (optional) should output redirection and pipes be allowed - :param multiline_commands: (optional) list of commands allowed to accept multi-line input - :param shortcuts: (optional) dictionary containing shortcuts for commands + :param completekey: readline name of a completion key, default to Tab + :param stdin: alternate input file object, if not specified, sys.stdin is used + :param stdout: alternate output file object, if not specified, sys.stdout is used + :param persistent_history_file: file path to load a persistent cmd2 command history from + :param persistent_history_length: max number of history items to write to the persistent history file + :param startup_script: file path to a script to execute at startup + :param use_ipython: should the "ipy" command be included for an embedded IPython shell + :param allow_cli_args: if True, then cmd2 will process command line arguments as either + commands to be run or, if -t is specified, transcript files to run. + This should be set to False if your application parses its own arguments. + :param transcript_files: allow running transcript tests when allow_cli_args is False + :param allow_redirection: should output redirection and pipes be allowed + :param multiline_commands: list of commands allowed to accept multi-line input + :param shortcuts: dictionary containing shortcuts for commands """ # If use_ipython is False, make sure the do_ipy() method doesn't exit if not use_ipython: @@ -374,7 +374,6 @@ class Cmd(cmd.Cmd): self.quit_on_sigint = False # Quit the loop on interrupt instead of just resetting prompt # Attributes which ARE dynamically settable at runtime - self.colors = constants.COLORS_TERMINAL self.continuation_prompt = '> ' self.debug = False self.echo = False @@ -385,16 +384,23 @@ class Cmd(cmd.Cmd): self.timing = False # Prints elapsed time for each command # To make an attribute settable with the "do_set" command, add it to this ... - self.settable = {'colors': 'Allow colorized output (valid values: Terminal, Always, Never)', - 'continuation_prompt': 'On 2nd+ line of input', - 'debug': 'Show full error stack on error', - 'echo': 'Echo command issued into output', - 'editor': 'Program used by ``edit``', - 'feedback_to_output': 'Include nonessentials in `|`, `>` results', - 'locals_in_py': 'Allow access to your application in py via self', - 'prompt': 'The prompt issued to solicit input', - 'quiet': "Don't print nonessential feedback", - 'timing': 'Report execution times'} + 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)), + 'continuation_prompt': 'On 2nd+ line of input', + 'debug': 'Show full error stack on error', + 'echo': 'Echo command issued into output', + 'editor': 'Program used by ``edit``', + 'feedback_to_output': 'Include nonessentials in `|`, `>` results', + 'locals_in_py': 'Allow access to your application in py via self', + 'prompt': 'The prompt issued to solicit input', + 'quiet': "Don't print nonessential feedback", + 'timing': 'Report execution times' + } # Commands to exclude from the help menu and tab completion self.hidden_commands = ['eof', '_relative_load', '_relative_run_script'] @@ -551,6 +557,25 @@ 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 + + @allow_ansi.setter + def allow_ansi(self, new_val: str) -> None: + """Setter property needed to support do_set when it updates allow_ansi""" + 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 + else: + self.perror('Invalid value: {} (valid values: {}, {}, {})'.format(new_val, ansi.ANSI_TERMINAL, + ansi.ANSI_ALWAYS, ansi.ANSI_NEVER)) + + @property def visible_prompt(self) -> str: """Read-only property to get the visible prompt with any ANSI escape codes stripped. @@ -559,7 +584,7 @@ class Cmd(cmd.Cmd): :return: prompt stripped of any ANSI escape codes """ - return utils.strip_ansi(self.prompt) + return ansi.strip_ansi(self.prompt) @property def aliases(self) -> Dict[str, str]: @@ -576,69 +601,67 @@ class Cmd(cmd.Cmd): """Setter for the allow_redirection property that determines whether or not redirection of stdout is allowed.""" self._statement_parser.allow_redirection = value - def _decolorized_write(self, fileobj: IO, msg: str) -> None: - """Write a string to a fileobject, stripping ANSI escape sequences if necessary - - Honor the current colors setting, which requires us to check whether the - fileobject is a tty. - """ - if self.colors.lower() == constants.COLORS_NEVER.lower() or \ - (self.colors.lower() == constants.COLORS_TERMINAL.lower() and not fileobj.isatty()): - msg = utils.strip_ansi(msg) - fileobj.write(msg) - - def poutput(self, msg: Any, end: str = '\n', color: str = '') -> None: - """Smarter self.stdout.write(); color aware and adds newline of not present. + def poutput(self, msg: Any, *, end: str = '\n') -> None: + """Print message to self.stdout and appends a newline by default Also handles BrokenPipeError exceptions for when a commands's output has been piped to another process and that process terminates before the cmd2 command is finished executing. - :param msg: message to print to current stdout (anything convertible to a str with '{}'.format() is OK) - :param end: (optional) string appended after the end of the message if not already present, default a newline - :param color: (optional) color escape to output this message with + :param msg: message to print (anything convertible to a str with '{}'.format() is OK) + :param end: string appended after the end of the message, default a newline """ - if msg is not None and msg != '': - try: - msg_str = '{}'.format(msg) - if not msg_str.endswith(end): - msg_str += end - if color: - msg_str = color + msg_str + Fore.RESET - self._decolorized_write(self.stdout, msg_str) - except BrokenPipeError: - # This occurs if a command's output is being piped to another - # process and that process closes before the command is - # finished. If you would like your application to print a - # warning message, then set the broken_pipe_warning attribute - # to the message you want printed. - if self.broken_pipe_warning: - sys.stderr.write(self.broken_pipe_warning) + try: + ansi.ansi_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 + # finished. If you would like your application to print a + # warning message, then set the broken_pipe_warning attribute + # to the message you want printed. + if self.broken_pipe_warning: + sys.stderr.write(self.broken_pipe_warning) + + def perror(self, msg: Any, *, end: str = '\n', apply_style: bool = True) -> None: + """Print message to sys.stderr + + :param msg: message to print (anything convertible to a str with '{}'.format() is OK) + :param end: string appended after the end of the message, default a newline + :param apply_style: If True, then ansi.style_error will be applied to the message text. Set to False in cases + where the message text already has the desired style. Defaults to True. + """ + if apply_style: + final_msg = ansi.style_error(msg) + else: + final_msg = "{}".format(msg) + ansi.ansi_aware_write(sys.stderr, final_msg + end) - def perror(self, err: Union[str, Exception], traceback_war: bool = True, err_color: str = Fore.LIGHTRED_EX, - war_color: str = Fore.LIGHTYELLOW_EX) -> None: - """ Print error message to sys.stderr and if debug is true, print an exception Traceback if one exists. + def pexcept(self, msg: Any, *, end: str = '\n', apply_style: bool = True) -> None: + """Print Exception message to sys.stderr. If debug is true, print exception traceback if one exists. - :param err: an Exception or error message to print out - :param traceback_war: (optional) if True, print a message to let user know they can enable debug - :param err_color: (optional) color escape to output error with - :param war_color: (optional) color escape to output warning with + :param msg: message or Exception to print + :param end: string appended after the end of the message, default a newline + :param apply_style: If True, then ErrorStyle will be applied to the message text. Set to False in cases + where the message text already has the desired style. Defaults to True. """ if self.debug and sys.exc_info() != (None, None, None): import traceback traceback.print_exc() - if isinstance(err, Exception): - err_msg = "EXCEPTION of type '{}' occurred with message: '{}'\n".format(type(err).__name__, err) + if isinstance(msg, Exception): + final_msg = "EXCEPTION of type '{}' occurred with message: '{}'".format(type(msg).__name__, msg) else: - err_msg = "{}\n".format(err) - err_msg = err_color + err_msg + Fore.RESET - self._decolorized_write(sys.stderr, err_msg) + final_msg = "{}".format(msg) + + if apply_style: + final_msg = ansi.style_error(final_msg) + + if not self.debug: + warning = "\nTo enable full traceback, run the following command: 'set debug true'" + final_msg += ansi.style_warning(warning) - if traceback_war and not self.debug: - war = "To enable full traceback, run the following command: 'set debug true'\n" - war = war_color + war + Fore.RESET - self._decolorized_write(sys.stderr, war) + # Set apply_style to False since style has already been applied + self.perror(final_msg, end=end, apply_style=False) def pfeedback(self, msg: str) -> None: """For printing nonessential feedback. Can be silenced with `quiet`. @@ -647,7 +670,7 @@ class Cmd(cmd.Cmd): if self.feedback_to_output: self.poutput(msg) else: - self._decolorized_write(sys.stderr, "{}\n".format(msg)) + ansi.ansi_aware_write(sys.stderr, "{}\n".format(msg)) def ppaged(self, msg: str, end: str = '\n', chop: bool = False) -> None: """Print output using a pager if it would go off screen and stdout isn't currently being redirected. @@ -683,8 +706,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_py and not self._script_dir: - if self.colors.lower() == constants.COLORS_NEVER.lower(): - msg_str = utils.strip_ansi(msg_str) + if ansi.allow_ansi.lower() == ansi.ANSI_NEVER.lower(): + msg_str = ansi.strip_ansi(msg_str) pager = self.pager if chop: @@ -696,7 +719,7 @@ class Cmd(cmd.Cmd): pipe_proc = subprocess.Popen(pager, shell=True, stdin=subprocess.PIPE) pipe_proc.communicate(msg_str.encode('utf-8', 'replace')) else: - self._decolorized_write(self.stdout, msg_str) + ansi.ansi_aware_write(self.stdout, msg_str) except BrokenPipeError: # This occurs if a command's output is being piped to another process and that process closes before the # command is finished. If you would like your application to print a warning message, then set the @@ -1269,7 +1292,7 @@ class Cmd(cmd.Cmd): longest_match_length = 0 for cur_match in matches_to_display: - cur_length = utils.ansi_safe_wcswidth(cur_match) + cur_length = ansi.ansi_safe_wcswidth(cur_match) if cur_length > longest_match_length: longest_match_length = cur_length else: @@ -1562,7 +1585,7 @@ class Cmd(cmd.Cmd): try: return self._complete_worker(text, state) except Exception as e: - self.perror(e) + self.pexcept(e) return None def _autocomplete_default(self, text: str, line: str, begidx: int, endidx: int, @@ -1676,8 +1699,8 @@ class Cmd(cmd.Cmd): except EmptyStatement: return self._run_cmdfinalization_hooks(stop, None) except ValueError as ex: - # If shlex.split failed on syntax, let user know whats going on - self.perror("Invalid syntax: {}".format(ex), traceback_war=False) + # If shlex.split failed on syntax, let user know what's going on + self.pexcept("Invalid syntax: {}".format(ex)) return stop # now that we have a statement, run it with all the hooks @@ -1762,7 +1785,7 @@ class Cmd(cmd.Cmd): # don't do anything, but do allow command finalization hooks to run pass except Exception as ex: - self.perror(ex) + self.pexcept(ex) finally: return self._run_cmdfinalization_hooks(stop, statement) @@ -1785,7 +1808,7 @@ class Cmd(cmd.Cmd): # modifications to the statement return data.stop except Exception as ex: - self.perror(ex) + self.pexcept(ex) def runcmds_plus_hooks(self, cmds: List[Union[HistoryItem, str]]) -> bool: """ @@ -1925,9 +1948,12 @@ class Cmd(cmd.Cmd): # Make sure enough arguments were passed in if len(statement.arg_list) < macro.minimum_arg_count: - self.perror("The macro '{}' expects at least {} argument(s)".format(statement.command, - macro.minimum_arg_count), - traceback_war=False) + self.perror( + "The macro '{}' expects at least {} argument(s)".format( + statement.command, + macro.minimum_arg_count + ) + ) return None # Resolve the arguments in reverse and read their values from statement.argv since those @@ -2020,8 +2046,7 @@ class Cmd(cmd.Cmd): elif statement.output: import tempfile if (not statement.output_to) and (not self._can_clip): - self.perror("Cannot redirect to paste buffer; install 'pyperclip' and re-run to enable", - traceback_war=False) + self.perror("Cannot redirect to paste buffer; install 'pyperclip' and re-run to enable") redir_error = True elif statement.output_to: @@ -2036,7 +2061,7 @@ class Cmd(cmd.Cmd): saved_state.redirecting = True sys.stdout = self.stdout = new_stdout except OSError as ex: - self.perror('Failed to redirect because - {}'.format(ex), traceback_war=False) + self.pexcept('Failed to redirect because - {}'.format(ex)) redir_error = True else: # going to a paste buffer @@ -2139,7 +2164,7 @@ class Cmd(cmd.Cmd): return self.do_shell(statement.command_and_args) else: err_msg = self.default_error.format(statement.command) - self._decolorized_write(sys.stderr, "{}\n".format(err_msg)) + ansi.ansi_aware_write(sys.stderr, "{}\n".format(err_msg)) def _pseudo_raw_input(self, prompt: str) -> str: """Began life as a copy of cmd's cmdloop; like raw_input but @@ -2268,11 +2293,11 @@ class Cmd(cmd.Cmd): # Validate the alias name valid, errmsg = self._statement_parser.is_valid_command(args.name) if not valid: - self.perror("Invalid alias name: {}".format(errmsg), traceback_war=False) + self.perror("Invalid alias name: {}".format(errmsg)) return if args.name in self.macros: - self.perror("Alias cannot have the same name as a macro", traceback_war=False) + self.perror("Alias cannot have the same name as a macro") return # Unquote redirection and terminator tokens @@ -2303,7 +2328,7 @@ class Cmd(cmd.Cmd): del self.aliases[cur_name] self.poutput("Alias '{}' deleted".format(cur_name)) else: - self.perror("Alias '{}' does not exist".format(cur_name), traceback_war=False) + self.perror("Alias '{}' does not exist".format(cur_name)) def _alias_list(self, args: argparse.Namespace) -> None: """List some or all aliases""" @@ -2312,7 +2337,7 @@ class Cmd(cmd.Cmd): if cur_name in self.aliases: self.poutput("alias create {} {}".format(cur_name, self.aliases[cur_name])) else: - self.perror("Alias '{}' not found".format(cur_name), traceback_war=False) + self.perror("Alias '{}' not found".format(cur_name)) else: sorted_aliases = utils.alphabetical_sort(self.aliases) for cur_alias in sorted_aliases: @@ -2399,15 +2424,15 @@ class Cmd(cmd.Cmd): # Validate the macro name valid, errmsg = self._statement_parser.is_valid_command(args.name) if not valid: - self.perror("Invalid macro name: {}".format(errmsg), traceback_war=False) + self.perror("Invalid macro name: {}".format(errmsg)) return if args.name in self.get_all_commands(): - self.perror("Macro cannot have the same name as a command", traceback_war=False) + self.perror("Macro cannot have the same name as a command") return if args.name in self.aliases: - self.perror("Macro cannot have the same name as an alias", traceback_war=False) + self.perror("Macro cannot have the same name as an alias") return # Unquote redirection and terminator tokens @@ -2434,7 +2459,7 @@ class Cmd(cmd.Cmd): cur_num_str = (re.findall(MacroArg.digit_pattern, cur_match.group())[0]) cur_num = int(cur_num_str) if cur_num < 1: - self.perror("Argument numbers must be greater than 0", traceback_war=False) + self.perror("Argument numbers must be greater than 0") return arg_nums.add(cur_num) @@ -2448,8 +2473,9 @@ class Cmd(cmd.Cmd): # Make sure the argument numbers are continuous if len(arg_nums) != max_arg_num: - self.perror("Not all numbers between 1 and {} are present " - "in the argument placeholders".format(max_arg_num), traceback_war=False) + self.perror( + "Not all numbers between 1 and {} are present " + "in the argument placeholders".format(max_arg_num)) return # Find all escaped arguments @@ -2484,7 +2510,7 @@ class Cmd(cmd.Cmd): del self.macros[cur_name] self.poutput("Macro '{}' deleted".format(cur_name)) else: - self.perror("Macro '{}' does not exist".format(cur_name), traceback_war=False) + self.perror("Macro '{}' does not exist".format(cur_name)) def _macro_list(self, args: argparse.Namespace) -> None: """List some or all macros""" @@ -2493,7 +2519,7 @@ class Cmd(cmd.Cmd): if cur_name in self.macros: self.poutput("macro create {} {}".format(cur_name, self.macros[cur_name].value)) else: - self.perror("Macro '{}' not found".format(cur_name), traceback_war=False) + self.perror("Macro '{}' not found".format(cur_name)) else: sorted_macros = utils.alphabetical_sort(self.macros) for cur_macro in sorted_macros: @@ -2671,7 +2697,7 @@ class Cmd(cmd.Cmd): # If there is no help information then print an error elif help_func is None and (func is None or not func.__doc__): err_msg = self.help_error.format(args.command) - self._decolorized_write(sys.stderr, "{}\n".format(err_msg)) + ansi.ansi_aware_write(sys.stderr, "{}\n".format(err_msg)) # Otherwise delegate to cmd base class do_help() else: @@ -2713,12 +2739,12 @@ class Cmd(cmd.Cmd): if len(cmds_cats) == 0: # No categories found, fall back to standard behavior - self.poutput("{}\n".format(str(self.doc_leader))) + self.poutput("{}".format(str(self.doc_leader))) self._print_topics(self.doc_header, cmds_doc, verbose) else: # Categories found, Organize all commands by category - self.poutput('{}\n'.format(str(self.doc_leader))) - self.poutput('{}\n\n'.format(str(self.doc_header))) + self.poutput('{}'.format(str(self.doc_leader))) + self.poutput('{}'.format(str(self.doc_header)), end="\n\n") for category in sorted(cmds_cats.keys()): self._print_topics(category, cmds_cats[category], verbose) self._print_topics('Other', cmds_doc, verbose) @@ -2738,7 +2764,7 @@ class Cmd(cmd.Cmd): widest = 0 # measure the commands for command in cmds: - width = utils.ansi_safe_wcswidth(command) + width = ansi.ansi_safe_wcswidth(command) if width > widest: widest = width # add a 4-space pad @@ -2806,7 +2832,7 @@ class Cmd(cmd.Cmd): def do_shortcuts(self, _: argparse.Namespace) -> None: """List available shortcuts""" result = "\n".join('%s: %s' % (sc[0], sc[1]) for sc in sorted(self._statement_parser.shortcuts)) - self.poutput("Shortcuts for other commands:\n{}\n".format(result)) + self.poutput("Shortcuts for other commands:\n{}".format(result)) @with_argparser(ACArgumentParser(epilog=INTERNAL_COMMAND_EPILOG)) def do_eof(self, _: argparse.Namespace) -> bool: @@ -2845,7 +2871,7 @@ class Cmd(cmd.Cmd): except IndexError: fulloptions.append((opt[0], opt[0])) for (idx, (_, text)) in enumerate(fulloptions): - self.poutput(' %2d. %s\n' % (idx + 1, text)) + self.poutput(' %2d. %s' % (idx + 1, text)) while True: safe_prompt = rl_make_safe_prompt(prompt) response = input(safe_prompt) @@ -2862,8 +2888,8 @@ class Cmd(cmd.Cmd): result = fulloptions[choice - 1][0] break except (ValueError, IndexError): - self.poutput("{!r} isn't a valid choice. Pick a number between 1 and {}:\n".format(response, - len(fulloptions))) + self.poutput("{!r} isn't a valid choice. Pick a number between 1 and {}:".format( + response, len(fulloptions))) return result def _cmdenvironment(self) -> str: @@ -2902,8 +2928,7 @@ class Cmd(cmd.Cmd): if args.all: self.poutput('\nRead only settings:{}'.format(self._cmdenvironment())) else: - self.perror("Parameter '{}' not supported (type 'set' for list of parameters).".format(param), - traceback_war=False) + self.perror("Parameter '{}' not supported (type 'set' for list of parameters).".format(param)) set_description = ("Set a settable parameter or show current settings of parameters\n" "\n" @@ -2940,17 +2965,20 @@ class Cmd(cmd.Cmd): return self._show(args, param) # Update the settable's value - current_value = getattr(self, param) - value = utils.cast(current_value, value) - setattr(self, param, value) + orig_value = getattr(self, param) + setattr(self, param, utils.cast(orig_value, value)) - self.poutput('{} - was: {}\nnow: {}\n'.format(param, current_value, value)) + # In cases where a Python property is used to validate and update a settable's value, its value will not + # change if the passed in one is invalid. Therefore we should read its actual value back and not assume. + new_value = getattr(self, param) + + self.poutput('{} - was: {}\nnow: {}'.format(param, orig_value, new_value)) # See if we need to call a change hook for this settable - if current_value != value: + if orig_value != new_value: onchange_hook = getattr(self, '_onchange_{}'.format(param), None) if onchange_hook is not None: - onchange_hook(old=current_value, new=value) + onchange_hook(old=orig_value, new=new_value) shell_parser = ACArgumentParser() setattr(shell_parser.add_argument('command', help='the command to run'), @@ -3028,7 +3056,7 @@ class Cmd(cmd.Cmd): from .pyscript_bridge import PyscriptBridge if self._in_py: err = "Recursively entering interactive Python consoles is not allowed." - self.perror(err, traceback_war=False) + self.perror(err) return False bridge = PyscriptBridge(self) @@ -3050,8 +3078,7 @@ class Cmd(cmd.Cmd): with open(expanded_filename) as f: interp.runcode(f.read()) except OSError as ex: - error_msg = "Error opening script file '{}': {}".format(expanded_filename, ex) - self.perror(error_msg, traceback_war=False) + self.pexcept("Error opening script file '{}': {}".format(expanded_filename, ex)) def py_quit(): """Function callable from the interactive Python console to exit that environment""" @@ -3235,9 +3262,9 @@ class Cmd(cmd.Cmd): # Restore command line arguments to original state sys.argv = orig_args if args.__statement__.command == "pyscript": - self.perror("pyscript has been renamed and will be removed in the next release, " - "please use run_pyscript instead\n", - traceback_war=False, err_color=Fore.LIGHTYELLOW_EX) + warning = ("pyscript has been renamed and will be removed in the next release, " + "please use run_pyscript instead\n") + self.perror(ansi.style_warning(warning)) return py_return @@ -3367,9 +3394,8 @@ class Cmd(cmd.Cmd): if args.run: if cowardly_refuse_to_run: - self.perror("Cowardly refusing to run all previously entered commands.", traceback_war=False) - self.perror("If this is what you want to do, specify '1:' as the range of history.", - traceback_war=False) + self.perror("Cowardly refusing to run all previously entered commands.") + self.perror("If this is what you want to do, specify '1:' as the range of history.") else: return self.runcmds_plus_hooks(history) elif args.edit: @@ -3395,9 +3421,10 @@ class Cmd(cmd.Cmd): else: fobj.write('{}\n'.format(item.raw)) plural = 's' if len(history) > 1 else '' + except OSError as e: + self.pexcept('Error saving {!r} - {}'.format(args.output_file, e)) + else: self.pfeedback('{} command{} saved to {}'.format(len(history), plural, args.output_file)) - except Exception as e: - self.perror('Saving {!r} - {}'.format(args.output_file, e), traceback_war=False) elif args.transcript: self._generate_transcript(history, args.transcript) else: @@ -3442,7 +3469,7 @@ class Cmd(cmd.Cmd): pass except OSError as ex: msg = "can not read persistent history file '{}': {}" - self.perror(msg.format(hist_file, ex), traceback_war=False) + self.pexcept(msg.format(hist_file, ex)) return self.history = history @@ -3478,7 +3505,7 @@ class Cmd(cmd.Cmd): pickle.dump(self.history, fobj) except OSError as ex: msg = "can not write persistent history file '{}': {}" - self.perror(msg.format(self.persistent_history_file, ex), traceback_war=False) + self.pexcept(msg.format(self.persistent_history_file, ex)) def _generate_transcript(self, history: List[Union[HistoryItem, str]], transcript_file: str) -> None: """ @@ -3488,8 +3515,7 @@ class Cmd(cmd.Cmd): transcript_path = os.path.abspath(os.path.expanduser(transcript_file)) transcript_dir = os.path.dirname(transcript_path) if not os.path.isdir(transcript_dir) or not os.access(transcript_dir, os.W_OK): - self.perror("{!r} is not a directory or you don't have write access".format(transcript_dir), - traceback_war=False) + self.perror("{!r} is not a directory or you don't have write access".format(transcript_dir)) return commands_run = 0 @@ -3547,14 +3573,14 @@ class Cmd(cmd.Cmd): # Check if all commands ran if commands_run < len(history): warning = "Command {} triggered a stop and ended transcript generation early".format(commands_run) - self.perror(warning, err_color=Fore.LIGHTYELLOW_EX, traceback_war=False) + self.perror(ansi.style_warning(warning)) # finally, we can write the transcript out to the file try: with open(transcript_file, 'w') as fout: fout.write(transcript) except OSError as ex: - self.perror('Failed to save transcript: {}'.format(ex), traceback_war=False) + self.pexcept('Failed to save transcript: {}'.format(ex)) else: # and let the user know what we did if commands_run > 1: @@ -3622,22 +3648,22 @@ class Cmd(cmd.Cmd): try: # Make sure the path exists and we can access it if not os.path.exists(expanded_path): - self.perror("'{}' does not exist or cannot be accessed".format(expanded_path), traceback_war=False) + self.perror("'{}' does not exist or cannot be accessed".format(expanded_path)) return # Make sure expanded_path points to a file if not os.path.isfile(expanded_path): - self.perror("'{}' is not a file".format(expanded_path), traceback_war=False) + self.perror("'{}' is not a file".format(expanded_path)) return # Make sure the file is not empty if os.path.getsize(expanded_path) == 0: - self.perror("'{}' is empty".format(expanded_path), traceback_war=False) + self.perror("'{}' is empty".format(expanded_path)) return # Make sure the file is ASCII or UTF-8 encoded text if not utils.is_text_file(expanded_path): - self.perror("'{}' is not an ASCII or UTF-8 encoded text file".format(expanded_path), traceback_war=False) + self.perror("'{}' is not an ASCII or UTF-8 encoded text file".format(expanded_path)) return try: @@ -3645,7 +3671,7 @@ class Cmd(cmd.Cmd): with open(expanded_path, encoding='utf-8') as target: script_commands = target.read().splitlines() except OSError as ex: # pragma: no cover - self.perror("Problem accessing script from '{}': {}".format(expanded_path, ex)) + self.pexcept("Problem accessing script from '{}': {}".format(expanded_path, ex)) return orig_script_dir_count = len(self._script_dir) @@ -3665,9 +3691,9 @@ class Cmd(cmd.Cmd): self._script_dir.pop() finally: if args.__statement__.command == "load": - self.perror("load has been renamed and will be removed in the next release, " - "please use run_script instead\n", - traceback_war=False, err_color=Fore.LIGHTYELLOW_EX) + warning = ("load has been renamed and will be removed in the next release, " + "please use run_script instead\n") + self.perror(ansi.style_warning(warning)) # load has been deprecated do_load = do_run_script @@ -3692,9 +3718,9 @@ class Cmd(cmd.Cmd): :return: True if running of commands should stop """ if args.__statement__.command == "_relative_load": - self.perror("_relative_load has been renamed and will be removed in the next release, " - "please use _relative_run_script instead\n", - traceback_war=False, err_color=Fore.LIGHTYELLOW_EX) + warning = ("_relative_load has been renamed and will be removed in the next release, " + "please use _relative_run_script instead\n") + self.perror(ansi.style_warning(warning)) file_path = args.file_path # NOTE: Relative path is an absolute path, it is just relative to the current script directory @@ -3715,7 +3741,6 @@ class Cmd(cmd.Cmd): import time import unittest import cmd2 - from colorama import Style from .transcript import Cmd2TestCase class TestMyAppCase(Cmd2TestCase): @@ -3724,19 +3749,19 @@ class Cmd(cmd.Cmd): # Validate that there is at least one transcript file transcripts_expanded = utils.files_from_glob_patterns(transcript_paths, access=os.R_OK) if not transcripts_expanded: - self.perror('No test files found - nothing to test', traceback_war=False) + self.perror('No test files found - nothing to test') self.exit_code = -1 return verinfo = ".".join(map(str, sys.version_info[:3])) num_transcripts = len(transcripts_expanded) plural = '' if len(transcripts_expanded) == 1 else 's' - self.poutput(Style.BRIGHT + utils.center_text('cmd2 transcript test', pad='=') + Style.RESET_ALL) + self.poutput(ansi.style(utils.center_text('cmd2 transcript test', pad='='), bold=True)) self.poutput('platform {} -- Python {}, cmd2-{}, readline-{}'.format(sys.platform, verinfo, cmd2.__version__, rl_type)) self.poutput('cwd: {}'.format(os.getcwd())) self.poutput('cmd2 app: {}'.format(sys.argv[0])) - self.poutput(Style.BRIGHT + 'collected {} transcript{}\n'.format(num_transcripts, plural) + Style.RESET_ALL) + self.poutput(ansi.style('collected {} transcript{}'.format(num_transcripts, plural), bold=True)) self.__class__.testfiles = transcripts_expanded sys.argv = [sys.argv[0]] # the --test argument upsets unittest.main() @@ -3747,9 +3772,10 @@ class Cmd(cmd.Cmd): test_results = runner.run(testcase) execution_time = time.time() - start_time if test_results.wasSuccessful(): - self._decolorized_write(sys.stderr, stream.read()) + ansi.ansi_aware_write(sys.stderr, stream.read()) finish_msg = '{0} transcript{1} passed in {2:.3f} seconds'.format(num_transcripts, plural, execution_time) - self.poutput(Style.BRIGHT + utils.center_text(finish_msg, pad='=') + Style.RESET_ALL, color=Fore.GREEN) + finish_msg = ansi.style_success(utils.center_text(finish_msg, pad='=')) + self.poutput(finish_msg) else: # Strip off the initial traceback which isn't particularly useful for end users error_str = stream.read() @@ -3758,7 +3784,7 @@ class Cmd(cmd.Cmd): start = end_of_trace + file_offset # But print the transcript file name and line number followed by what was expected and what was observed - self.perror(error_str[start:], traceback_war=False) + self.perror(error_str[start:]) # Return a failure error code to support automated transcript-based testing self.exit_code = -1 @@ -3782,7 +3808,6 @@ class Cmd(cmd.Cmd): 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 @@ -3818,14 +3843,14 @@ class Cmd(cmd.Cmd): # 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 = utils.ansi_safe_wcswidth(line) + line_width = ansi.ansi_safe_wcswidth(line) num_prompt_terminal_lines += int(line_width / terminal_size.columns) + 1 # Now calculate how many terminal lines are take up by the input last_prompt_line = prompt_lines[-1] - last_prompt_line_width = utils.ansi_safe_wcswidth(last_prompt_line) + last_prompt_line_width = ansi.ansi_safe_wcswidth(last_prompt_line) - input_width = last_prompt_line_width + utils.ansi_safe_wcswidth(readline.get_line_buffer()) + input_width = last_prompt_line_width + ansi.ansi_safe_wcswidth(readline.get_line_buffer()) num_input_terminal_lines = int(input_width / terminal_size.columns) + 1 @@ -3844,10 +3869,10 @@ class Cmd(cmd.Cmd): # Clear each line from the bottom up so that the cursor ends up on the first prompt line total_lines = num_prompt_terminal_lines + num_input_terminal_lines - terminal_str += (ansi.clear_line() + Cursor.UP(1)) * (total_lines - 1) + terminal_str += (colorama.ansi.clear_line() + Cursor.UP(1)) * (total_lines - 1) # Clear the first prompt line - terminal_str += ansi.clear_line() + terminal_str += colorama.ansi.clear_line() # Move the cursor to the beginning of the first prompt line and print the alert terminal_str += '\r' + alert_msg @@ -3904,8 +3929,7 @@ 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): try: - import colorama.ansi as ansi - sys.stderr.write(ansi.set_title(title)) + sys.stderr.write(colorama.ansi.set_title(title)) except AttributeError: # Debugging in Pycharm has issues with setting terminal title pass @@ -4007,7 +4031,7 @@ class Cmd(cmd.Cmd): :param message_to_print: the message reporting that the command is disabled :param kwargs: not used """ - self._decolorized_write(sys.stderr, "{}\n".format(message_to_print)) + ansi.ansi_aware_write(sys.stderr, "{}\n".format(message_to_print)) def cmdloop(self, intro: Optional[str] = None) -> int: """This is an outer wrapper around _cmdloop() which deals with extra features provided by cmd2. @@ -4048,7 +4072,7 @@ class Cmd(cmd.Cmd): # Print the intro, if there is one, right after the preloop if self.intro is not None: - self.poutput(str(self.intro) + "\n") + self.poutput(self.intro) # And then call _cmdloop() to enter the main loop self._cmdloop() diff --git a/cmd2/constants.py b/cmd2/constants.py index 06d6c6c4..9fd58b01 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -2,8 +2,6 @@ # coding=utf-8 """Constants and definitions""" -import re - # Used for command parsing, output redirection, tab completion and word # breaks. Do not change. QUOTES = ['"', "'"] @@ -15,14 +13,6 @@ REDIRECTION_TOKENS = [REDIRECTION_PIPE, REDIRECTION_OUTPUT, REDIRECTION_APPEND] COMMENT_CHAR = '#' MULTILINE_TERMINATOR = ';' -# Regular expression to match ANSI escape codes -ANSI_ESCAPE_RE = re.compile(r'\x1b[^m]*m') - LINE_FEED = '\n' -# Values for colors setting -COLORS_NEVER = 'Never' -COLORS_TERMINAL = 'Terminal' -COLORS_ALWAYS = 'Always' - DEFAULT_SHORTCUTS = {'?': 'help', '!': 'shell', '@': 'run_script', '@@': '_relative_run_script'} diff --git a/cmd2/history.py b/cmd2/history.py index a61ab0d8..dbc9a3a4 100644 --- a/cmd2/history.py +++ b/cmd2/history.py @@ -16,8 +16,8 @@ from .parsing import Statement @attr.s(frozen=True) class HistoryItem(): """Class used to represent one command in the History list""" - _listformat = ' {:>4} {}\n' - _ex_listformat = ' {:>4}x {}\n' + _listformat = ' {:>4} {}' + _ex_listformat = ' {:>4}x {}' statement = attr.ib(default=None, validator=attr.validators.instance_of(Statement)) idx = attr.ib(default=None, validator=attr.validators.instance_of(int)) @@ -46,7 +46,7 @@ class HistoryItem(): if verbose: ret_str = self._listformat.format(self.idx, self.raw.rstrip()) if self.raw != self.expanded.rstrip(): - ret_str += self._ex_listformat.format(self.idx, self.expanded.rstrip()) + ret_str += '\n' + self._ex_listformat.format(self.idx, self.expanded.rstrip()) else: if expanded: ret_str = self.expanded.rstrip() @@ -153,7 +153,7 @@ class History(list): """Return an index or slice of the History list, :param span: string containing an index or a slice - :param include_persisted: (optional) if True, then retrieve full results including from persisted history + :param include_persisted: if True, then retrieve full results including from persisted history :return: a list of HistoryItems This method can accommodate input in any of these forms: @@ -227,7 +227,7 @@ class History(list): """Find history items which contain a given string :param search: the string to search for - :param include_persisted: (optional) if True, then search full history including persisted history + :param include_persisted: if True, then search full history including persisted history :return: a list of history items, or an empty list if the string was not found """ def isin(history_item): @@ -244,7 +244,7 @@ class History(list): """Find history items which match a given regular expression :param regex: the regular expression to search for. - :param include_persisted: (optional) if True, then search full history including persisted history + :param include_persisted: if True, then search full history including persisted history :return: a list of history items, or an empty list if the string was not found """ regex = regex.strip() diff --git a/cmd2/parsing.py b/cmd2/parsing.py index f705128c..86087db1 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -257,11 +257,11 @@ class StatementParser: * multiline commands * shortcuts - :param allow_redirection: (optional) should redirection and pipes be allowed? - :param terminators: (optional) iterable containing strings which should terminate multiline commands - :param multiline_commands: (optional) iterable containing the names of commands that accept multiline input - :param aliases: (optional) dictionary contaiing aliases - :param shortcuts (optional) an iterable of tuples with each tuple containing the shortcut and the expansion + :param allow_redirection: should redirection and pipes be allowed? + :param terminators: iterable containing strings which should terminate multiline commands + :param multiline_commands: iterable containing the names of commands that accept multiline input + :param aliases: dictionary containing aliases + :param shortcuts: an iterable of tuples with each tuple containing the shortcut and the expansion """ self.allow_redirection = allow_redirection if terminators is None: diff --git a/cmd2/transcript.py b/cmd2/transcript.py index 316592ce..25a79310 100644 --- a/cmd2/transcript.py +++ b/cmd2/transcript.py @@ -13,7 +13,7 @@ import re import unittest from typing import Tuple -from . import utils +from . import ansi, utils class Cmd2TestCase(unittest.TestCase): @@ -56,13 +56,13 @@ class Cmd2TestCase(unittest.TestCase): def _test_transcript(self, fname: str, transcript): line_num = 0 finished = False - line = utils.strip_ansi(next(transcript)) + line = ansi.strip_ansi(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 = utils.strip_ansi(next(transcript)) + line = ansi.strip_ansi(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 utils.strip_ansi(line).startswith(self.cmdapp.visible_prompt): + if ansi.strip_ansi(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 utils.strip_ansi(line).startswith(self.cmdapp.visible_prompt): + while not ansi.strip_ansi(line).startswith(self.cmdapp.visible_prompt): expected.append(line) try: line = next(transcript) diff --git a/cmd2/utils.py b/cmd2/utils.py index 3e28641d..812fa227 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -12,30 +12,9 @@ import threading import unicodedata from typing import Any, Iterable, List, Optional, TextIO, Union -from wcwidth import wcswidth - from . import constants -def strip_ansi(text: str) -> str: - """Strip ANSI escape codes from a string. - - :param text: string which may contain ANSI escape codes - :return: the same string with any ANSI escape codes removed - """ - return constants.ANSI_ESCAPE_RE.sub('', text) - - -def ansi_safe_wcswidth(text: str) -> int: - """ - Wraps wcswidth to make it compatible with colored strings - - :param text: the string being measured - """ - # Strip ANSI escape codes since they cause wcswidth to return -1 - return wcswidth(strip_ansi(text)) - - def is_quoted(arg: str) -> bool: """ Checks if a string is quoted @@ -382,7 +361,7 @@ 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: (optional) if provided, the first character will be used to pad both sides of the message + :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 diff --git a/docs/argument_processing.rst b/docs/argument_processing.rst index 599e4cf0..a1fc107b 100644 --- a/docs/argument_processing.rst +++ b/docs/argument_processing.rst @@ -265,7 +265,7 @@ Here's what it looks like:: """List contents of current directory.""" # No arguments for this command if unknown: - self.perror("dir does not take any positional arguments:", traceback_war=False) + self.perror("dir does not take any positional arguments:") self.do_help('dir') self.last_result = CommandResult('', 'Bad arguments') return diff --git a/docs/settingchanges.rst b/docs/settingchanges.rst index 0e4feac1..81f025a7 100644 --- a/docs/settingchanges.rst +++ b/docs/settingchanges.rst @@ -174,7 +174,7 @@ comments, is viewable from within a running application with:: (Cmd) set --long - colors: Terminal # Allow colorized output + allow_ansi: Terminal # Allow ANSI escape sequences in output (valid values: Terminal, Always, Never) continuation_prompt: > # On 2nd+ line of input debug: False # Show full error stack on error echo: False # Echo command issued into output @@ -187,5 +187,5 @@ with:: Any of these user-settable parameters can be set while running your app with the ``set`` command like so:: - set colors Never + set allow_ansi Never diff --git a/docs/unfreefeatures.rst b/docs/unfreefeatures.rst index ada3a2f6..713e44e6 100644 --- a/docs/unfreefeatures.rst +++ b/docs/unfreefeatures.rst @@ -141,7 +141,7 @@ instead. These methods have these advantages: Colored Output ============== -The output methods in the previous section all honor the ``colors`` setting, +The output methods in the previous section all honor the ``allow_ansi`` setting, which has three possible values: Never @@ -152,14 +152,17 @@ Terminal (the default value) poutput(), pfeedback(), and ppaged() do not strip any ANSI escape sequences when the output is a terminal, but if the output is a pipe or a file the escape sequences are stripped. If you want colorized - output you must add ANSI escape sequences, preferably using some python - color library like `plumbum.colors`, `colorama`, `blessings`, or - `termcolor`. + output you must add ANSI escape sequences using either cmd2's internal ansi + module or another color library such as `plumbum.colors`, `colorama`, or `colored`. Always poutput(), pfeedback(), and ppaged() never strip ANSI escape sequences, regardless of the output destination +Colored and otherwise styled output can be generated using the `ansi.style()` function: + +.. automethod:: cmd2.ansi.style + .. _quiet: diff --git a/examples/async_printing.py b/examples/async_printing.py index 3089070f..a9b20408 100755 --- a/examples/async_printing.py +++ b/examples/async_printing.py @@ -4,15 +4,13 @@ A simple example demonstrating an application that asynchronously prints alerts, updates the prompt and changes the window title """ - import random import threading import time from typing import List -from colorama import Fore - import cmd2 +from cmd2 import ansi ALERTS = ["Watch as this application prints alerts and updates the prompt", "This will only happen when the prompt is present", @@ -141,26 +139,12 @@ class AlerterApp(cmd2.Cmd): return alert_str def _generate_colored_prompt(self) -> str: - """ - Randomly generates a colored prompt + """Randomly generates a colored prompt :return: the new prompt """ - rand_num = random.randint(1, 20) - - status_color = Fore.RESET - - if rand_num == 1: - status_color = Fore.LIGHTRED_EX - elif rand_num == 2: - status_color = Fore.LIGHTYELLOW_EX - elif rand_num == 3: - status_color = Fore.CYAN - elif rand_num == 4: - status_color = Fore.LIGHTGREEN_EX - elif rand_num == 5: - status_color = Fore.LIGHTBLUE_EX - - return status_color + self.visible_prompt + Fore.RESET + fg_color = random.choice(list(ansi.FG_COLORS.keys())) + bg_color = random.choice(list(ansi.BG_COLORS.keys())) + return ansi.style(self.visible_prompt.rstrip(), fg=fg_color, bg=bg_color) + ' ' def _alerter_thread_func(self) -> None: """ Prints alerts and updates the prompt any time the prompt is showing """ diff --git a/examples/colors.py b/examples/colors.py index f8a9dfdb..8c54dfa4 100755 --- a/examples/colors.py +++ b/examples/colors.py @@ -6,7 +6,7 @@ A sample application for cmd2. Demonstrating colorized output. Experiment with the command line options on the `speak` command to see how different output colors ca -The colors setting has three possible values: +The allow_ansi setting has three possible values: Never poutput(), pfeedback(), and ppaged() strip all ANSI escape sequences @@ -16,68 +16,40 @@ Terminal (the default value) poutput(), pfeedback(), and ppaged() do not strip any ANSI escape sequences when the output is a terminal, but if the output is a pipe or a file the escape sequences are stripped. If you want colorized - output you must add ANSI escape sequences, preferably using some python - color library like `plumbum.colors`, `colorama`, `blessings`, or - `termcolor`. + output you must add ANSI escape sequences using either cmd2's internal ansi + module or another color library such as `plumbum.colors` or `colorama`. Always poutput(), pfeedback(), and ppaged() never strip ANSI escape sequences, regardless of the output destination """ - -import random import argparse import cmd2 -from colorama import Fore, Back - -FG_COLORS = { - 'black': Fore.BLACK, - 'red': Fore.RED, - 'green': Fore.GREEN, - 'yellow': Fore.YELLOW, - 'blue': Fore.BLUE, - 'magenta': Fore.MAGENTA, - 'cyan': Fore.CYAN, - 'white': Fore.WHITE, -} -BG_COLORS = { - 'black': Back.BLACK, - 'red': Back.RED, - 'green': Back.GREEN, - 'yellow': Back.YELLOW, - 'blue': Back.BLUE, - 'magenta': Back.MAGENTA, - 'cyan': Back.CYAN, - 'white': Back.WHITE, -} +from cmd2 import ansi class CmdLineApp(cmd2.Cmd): """Example cmd2 application demonstrating colorized output.""" - - # Setting this true makes it run a shell command if a cmd2/cmd command doesn't exist - # default_to_shell = True - MUMBLES = ['like', '...', 'um', 'er', 'hmmm', 'ahh'] - MUMBLE_FIRST = ['so', 'like', 'well'] - MUMBLE_LAST = ['right?'] - def __init__(self): - shortcuts = dict(cmd2.DEFAULT_SHORTCUTS) - shortcuts.update({'&': 'speak'}) # Set use_ipython to True to enable the "ipy" command which embeds and interactive IPython shell - super().__init__(use_ipython=True, multiline_commands=['orate'], shortcuts=shortcuts) + super().__init__(use_ipython=True) self.maxrepeats = 3 # Make maxrepeats settable at runtime self.settable['maxrepeats'] = 'max repetitions for speak command' + # Should ANSI color output be allowed + self.allow_ansi = ansi.ANSI_TERMINAL + speak_parser = argparse.ArgumentParser() speak_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay') speak_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE') speak_parser.add_argument('-r', '--repeat', type=int, help='output [n] times') - speak_parser.add_argument('-f', '--fg', choices=FG_COLORS, help='foreground color to apply to output') - speak_parser.add_argument('-b', '--bg', choices=BG_COLORS, help='background color to apply to output') + speak_parser.add_argument('-f', '--fg', choices=ansi.FG_COLORS, help='foreground color to apply to output') + speak_parser.add_argument('-b', '--bg', choices=ansi.BG_COLORS, help='background color to apply to output') + speak_parser.add_argument('-l', '--bold', action='store_true', help='bold the output') + speak_parser.add_argument('-u', '--underline', action='store_true', help='underline the output') speak_parser.add_argument('words', nargs='+', help='words to say') @cmd2.with_argparser(speak_parser) @@ -92,49 +64,11 @@ class CmdLineApp(cmd2.Cmd): words.append(word) repetitions = args.repeat or 1 - - color_on = '' - if args.fg: - color_on += FG_COLORS[args.fg] - if args.bg: - color_on += BG_COLORS[args.bg] - color_off = Fore.RESET + Back.RESET + output_str = ansi.style(' '.join(words), fg=args.fg, bg=args.bg, bold=args.bold, underline=args.underline) for i in range(min(repetitions, self.maxrepeats)): # .poutput handles newlines, and accommodates output redirection too - self.poutput(color_on + ' '.join(words) + color_off) - - do_say = do_speak # now "say" is a synonym for "speak" - do_orate = do_speak # another synonym, but this one takes multi-line input - - mumble_parser = argparse.ArgumentParser() - mumble_parser.add_argument('-r', '--repeat', type=int, help='how many times to repeat') - mumble_parser.add_argument('-f', '--fg', help='foreground color to apply to output') - mumble_parser.add_argument('-b', '--bg', help='background color to apply to output') - mumble_parser.add_argument('words', nargs='+', help='words to say') - - @cmd2.with_argparser(mumble_parser) - def do_mumble(self, args): - """Mumbles what you tell me to.""" - color_on = '' - if args.fg and args.fg in FG_COLORS: - color_on += FG_COLORS[args.fg] - if args.bg and args.bg in BG_COLORS: - color_on += BG_COLORS[args.bg] - color_off = Fore.RESET + Back.RESET - - repetitions = args.repeat or 1 - for i in range(min(repetitions, self.maxrepeats)): - output = [] - if random.random() < .33: - output.append(random.choice(self.MUMBLE_FIRST)) - for word in args.words: - if random.random() < .40: - output.append(random.choice(self.MUMBLES)) - output.append(word) - if random.random() < .25: - output.append(random.choice(self.MUMBLE_LAST)) - self.poutput(color_on + ' '.join(output) + color_off) + self.poutput(output_str) if __name__ == '__main__': diff --git a/examples/paged_output.py b/examples/paged_output.py index b3824012..cba5c7c5 100755 --- a/examples/paged_output.py +++ b/examples/paged_output.py @@ -14,15 +14,15 @@ class PagedOutput(cmd2.Cmd): def __init__(self): super().__init__() - def page_file(self, file_path: str, chop: bool=False): + def page_file(self, file_path: str, chop: bool = False): """Helper method to prevent having too much duplicated code.""" filename = os.path.expanduser(file_path) try: with open(filename, 'r') as f: text = f.read() self.ppaged(text, chop=chop) - except FileNotFoundError: - self.perror('ERROR: file {!r} not found'.format(filename), traceback_war=False) + except OSError as ex: + self.pexcept('Error reading {!r}: {}'.format(filename, ex)) @cmd2.with_argument_list def do_page_wrap(self, args: List[str]): @@ -31,7 +31,7 @@ class PagedOutput(cmd2.Cmd): Usage: page_wrap <file_path> """ if not args: - self.perror('page_wrap requires a path to a file as an argument', traceback_war=False) + self.perror('page_wrap requires a path to a file as an argument') return self.page_file(args[0], chop=False) @@ -46,7 +46,7 @@ class PagedOutput(cmd2.Cmd): Usage: page_chop <file_path> """ if not args: - self.perror('page_truncate requires a path to a file as an argument', traceback_war=False) + self.perror('page_truncate requires a path to a file as an argument') return self.page_file(args[0], chop=True) diff --git a/examples/pirate.py b/examples/pirate.py index 699ee80c..eda3994e 100755 --- a/examples/pirate.py +++ b/examples/pirate.py @@ -8,22 +8,10 @@ It demonstrates many features of cmd2. """ import argparse -from colorama import Fore - import cmd2 +import cmd2.ansi from cmd2.constants import MULTILINE_TERMINATOR -COLORS = { - 'black': Fore.BLACK, - 'red': Fore.RED, - 'green': Fore.GREEN, - 'yellow': Fore.YELLOW, - 'blue': Fore.BLUE, - 'magenta': Fore.MAGENTA, - 'cyan': Fore.CYAN, - 'white': Fore.WHITE, -} - class Pirate(cmd2.Cmd): """A piratical example cmd2 application involving looting and drinking.""" @@ -34,7 +22,7 @@ class Pirate(cmd2.Cmd): super().__init__(multiline_commands=['sing'], terminators=[MULTILINE_TERMINATOR, '...'], shortcuts=shortcuts) self.default_to_shell = True - self.songcolor = Fore.BLUE + self.songcolor = 'blue' # Make songcolor settable at runtime self.settable['songcolor'] = 'Color to ``sing`` in (black/red/green/yellow/blue/magenta/cyan/white)' @@ -82,8 +70,7 @@ class Pirate(cmd2.Cmd): def do_sing(self, arg): """Sing a colorful song.""" - color_escape = COLORS.get(self.songcolor, Fore.RESET) - self.poutput(arg, color=color_escape) + self.poutput(cmd2.ansi.style(arg, fg=self.songcolor)) yo_parser = argparse.ArgumentParser() yo_parser.add_argument('--ho', type=int, default=2, help="How often to chant 'ho'") @@ -101,7 +88,7 @@ class Pirate(cmd2.Cmd): if __name__ == '__main__': import sys - # Create an instance of the Pirate derived class and enter the REPL with cmdlooop(). + # Create an instance of the Pirate derived class and enter the REPL with cmdloop(). pirate = Pirate() sys_exit_code = pirate.cmdloop() print('Exiting with code: {!r}'.format(sys_exit_code)) diff --git a/examples/plumbum_colors.py b/examples/plumbum_colors.py index 2c57c22b..a969c4de 100755 --- a/examples/plumbum_colors.py +++ b/examples/plumbum_colors.py @@ -6,7 +6,7 @@ A sample application for cmd2. Demonstrating colorized output using the plumbum Experiment with the command line options on the `speak` command to see how different output colors ca -The colors setting has three possible values: +The allow_ansi setting has three possible values: Never poutput(), pfeedback(), and ppaged() strip all ANSI escape sequences @@ -16,9 +16,8 @@ Terminal (the default value) poutput(), pfeedback(), and ppaged() do not strip any ANSI escape sequences when the output is a terminal, but if the output is a pipe or a file the escape sequences are stripped. If you want colorized - output you must add ANSI escape sequences, preferably using some python - color library like `plumbum.colors`, `colorama`, `blessings`, or - `termcolor`. + output you must add ANSI escape sequences using either cmd2's internal ansi + module or another color library such as `plumbum.colors` or `colorama`. Always poutput(), pfeedback(), and ppaged() never strip ANSI escape sequences, @@ -26,13 +25,11 @@ Always WARNING: This example requires the plumbum package, which isn't normally required by cmd2. """ - -import random import argparse import cmd2 -from colorama import Fore, Back -from plumbum.colors import fg, bg, reset +from cmd2 import ansi +from plumbum.colors import fg, bg FG_COLORS = { 'black': fg.Black, @@ -43,7 +40,9 @@ FG_COLORS = { 'magenta': fg.Purple, 'cyan': fg.SkyBlue1, 'white': fg.White, + 'purple': fg.Purple, } + BG_COLORS = { 'black': bg.BLACK, 'red': bg.DarkRedA, @@ -56,31 +55,39 @@ BG_COLORS = { } -class CmdLineApp(cmd2.Cmd): - """Example cmd2 application demonstrating colorized output.""" +def get_fg(fg): + return str(FG_COLORS[fg]) + + +def get_bg(bg): + return str(BG_COLORS[bg]) + + +ansi.fg_lookup = get_fg +ansi.bg_lookup = get_bg - # Setting this true makes it run a shell command if a cmd2/cmd command doesn't exist - # default_to_shell = True - MUMBLES = ['like', '...', 'um', 'er', 'hmmm', 'ahh'] - MUMBLE_FIRST = ['so', 'like', 'well'] - MUMBLE_LAST = ['right?'] +class CmdLineApp(cmd2.Cmd): + """Example cmd2 application demonstrating colorized output.""" def __init__(self): - shortcuts = dict(cmd2.DEFAULT_SHORTCUTS) - shortcuts.update({'&': 'speak'}) # Set use_ipython to True to enable the "ipy" command which embeds and interactive IPython shell - super().__init__(use_ipython=True, multiline_commands=['orate'], shortcuts=shortcuts) + super().__init__(use_ipython=True) self.maxrepeats = 3 # Make maxrepeats settable at runtime self.settable['maxrepeats'] = 'max repetitions for speak command' + # Should ANSI color output be allowed + self.allow_ansi = ansi.ANSI_TERMINAL + speak_parser = argparse.ArgumentParser() speak_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay') speak_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE') speak_parser.add_argument('-r', '--repeat', type=int, help='output [n] times') speak_parser.add_argument('-f', '--fg', choices=FG_COLORS, help='foreground color to apply to output') speak_parser.add_argument('-b', '--bg', choices=BG_COLORS, help='background color to apply to output') + speak_parser.add_argument('-l', '--bold', action='store_true', help='bold the output') + speak_parser.add_argument('-u', '--underline', action='store_true', help='underline the output') speak_parser.add_argument('words', nargs='+', help='words to say') @cmd2.with_argparser(speak_parser) @@ -95,52 +102,15 @@ class CmdLineApp(cmd2.Cmd): words.append(word) repetitions = args.repeat or 1 - - color_on = '' - if args.fg: - color_on += FG_COLORS[args.fg] - if args.bg: - color_on += BG_COLORS[args.bg] - color_off = reset + output_str = ansi.style(' '.join(words), fg=args.fg, bg=args.bg, bold=args.bold, underline=args.underline) for i in range(min(repetitions, self.maxrepeats)): # .poutput handles newlines, and accommodates output redirection too - self.poutput(color_on + ' '.join(words) + color_off) - - do_say = do_speak # now "say" is a synonym for "speak" - do_orate = do_speak # another synonym, but this one takes multi-line input - - mumble_parser = argparse.ArgumentParser() - mumble_parser.add_argument('-r', '--repeat', type=int, help='how many times to repeat') - mumble_parser.add_argument('-f', '--fg', help='foreground color to apply to output') - mumble_parser.add_argument('-b', '--bg', help='background color to apply to output') - mumble_parser.add_argument('words', nargs='+', help='words to say') - - @cmd2.with_argparser(mumble_parser) - def do_mumble(self, args): - """Mumbles what you tell me to.""" - color_on = '' - if args.fg and args.fg in FG_COLORS: - color_on += FG_COLORS[args.fg] - if args.bg and args.bg in BG_COLORS: - color_on += BG_COLORS[args.bg] - color_off = Fore.RESET + Back.RESET - - repetitions = args.repeat or 1 - for i in range(min(repetitions, self.maxrepeats)): - output = [] - if random.random() < .33: - output.append(random.choice(self.MUMBLE_FIRST)) - for word in args.words: - if random.random() < .40: - output.append(random.choice(self.MUMBLES)) - output.append(word) - if random.random() < .25: - output.append(random.choice(self.MUMBLE_LAST)) - self.poutput(color_on + ' '.join(output) + color_off) + self.poutput(output_str) if __name__ == '__main__': import sys + c = CmdLineApp() sys.exit(c.cmdloop()) diff --git a/examples/python_scripting.py b/examples/python_scripting.py index c45648bc..3d0a54a9 100755 --- a/examples/python_scripting.py +++ b/examples/python_scripting.py @@ -17,9 +17,8 @@ This application and the "scripts/conditional.py" script serve as an example for import argparse import os -from colorama import Fore - import cmd2 +from cmd2 import ansi class CmdLineApp(cmd2.Cmd): @@ -35,7 +34,7 @@ class CmdLineApp(cmd2.Cmd): def _set_prompt(self): """Set prompt so it displays the current working directory.""" self.cwd = os.getcwd() - self.prompt = Fore.CYAN + '{!r} $ '.format(self.cwd) + Fore.RESET + self.prompt = ansi.style('{!r} $ '.format(self.cwd), fg='cyan') def postcmd(self, stop: bool, line: str) -> bool: """Hook method executed just after a command dispatch is finished. @@ -56,7 +55,7 @@ class CmdLineApp(cmd2.Cmd): """ # Expect 1 argument, the directory to change to if not arglist or len(arglist) != 1: - self.perror("cd requires exactly 1 argument:", traceback_war=False) + self.perror("cd requires exactly 1 argument:") self.do_help('cd') self.last_result = cmd2.CommandResult('', 'Bad arguments') return @@ -83,7 +82,7 @@ class CmdLineApp(cmd2.Cmd): data = path if err: - self.perror(err, traceback_war=False) + self.perror(err) self.last_result = cmd2.CommandResult(out, err, data) # Enable tab completion for cd command @@ -98,7 +97,7 @@ class CmdLineApp(cmd2.Cmd): """List contents of current directory.""" # No arguments for this command if unknown: - self.perror("dir does not take any positional arguments:", traceback_war=False) + self.perror("dir does not take any positional arguments:") self.do_help('dir') self.last_result = cmd2.CommandResult('', 'Bad arguments') return diff --git a/examples/table_display.py b/examples/table_display.py index dcde7a81..cedd2ca0 100755 --- a/examples/table_display.py +++ b/examples/table_display.py @@ -9,7 +9,8 @@ You can use the arrow keys (left, right, up, and down) to scroll around the tabl You can quit out of the pager by typing "q". You can also search for text within the pager using "/". WARNING: This example requires the tableformatter module: https://github.com/python-tableformatter/tableformatter -- pip install tableformatter +and either the colored or colorama module +- pip install tableformatter colorama """ from typing import Tuple diff --git a/tests/conftest.py b/tests/conftest.py index b049dfff..8040c21d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -88,7 +88,7 @@ SHORTCUTS_TXT = """Shortcuts for other commands: """ # Output from the show command with default settings -SHOW_TXT = """colors: Terminal +SHOW_TXT = """allow_ansi: Terminal continuation_prompt: > debug: False echo: False @@ -101,7 +101,7 @@ timing: False """ SHOW_LONG = """ -colors: Terminal # Allow colorized output (valid values: Terminal, Always, Never) +allow_ansi: Terminal # Allow ANSI escape sequences in output (valid values: Terminal, Always, Never) continuation_prompt: > # On 2nd+ line of input debug: False # Show full error stack on error echo: False # Echo command issued into output diff --git a/tests/scripts/postcmds.txt b/tests/scripts/postcmds.txt index dea8f265..74f1e226 100644 --- a/tests/scripts/postcmds.txt +++ b/tests/scripts/postcmds.txt @@ -1 +1 @@ -set colors Never +set allow_ansi Never diff --git a/tests/scripts/precmds.txt b/tests/scripts/precmds.txt index 0ae7eae8..0167aa22 100644 --- a/tests/scripts/precmds.txt +++ b/tests/scripts/precmds.txt @@ -1 +1 @@ -set colors Always +set allow_ansi Always diff --git a/tests/test_ansi.py b/tests/test_ansi.py new file mode 100644 index 00000000..bf628ef0 --- /dev/null +++ b/tests/test_ansi.py @@ -0,0 +1,89 @@ +# coding=utf-8 +# flake8: noqa E302 +""" +Unit testing for cmd2/ansi.py module +""" +import pytest +from colorama import Fore, Back, Style + +import cmd2.ansi as ansi + +HELLO_WORLD = 'Hello, world!' + + +def test_strip_ansi(): + base_str = HELLO_WORLD + ansi_str = Fore.GREEN + base_str + Fore.RESET + assert base_str != ansi_str + assert base_str == ansi.strip_ansi(ansi_str) + + +def test_ansi_safe_wcswidth(): + base_str = HELLO_WORLD + ansi_str = Fore.GREEN + base_str + Fore.RESET + assert ansi.ansi_safe_wcswidth(ansi_str) != len(ansi_str) + + +def test_style_none(): + base_str = HELLO_WORLD + ansi_str = base_str + assert ansi.style(base_str) == ansi_str + + +def test_style_fg(): + base_str = HELLO_WORLD + ansi_str = Fore.BLUE + base_str + Fore.RESET + assert ansi.style(base_str, fg='blue') == ansi_str + + +def test_style_bg(): + base_str = HELLO_WORLD + ansi_str = Back.GREEN + base_str + Back.RESET + assert ansi.style(base_str, bg='green') == ansi_str + + +def test_style_bold(): + base_str = HELLO_WORLD + ansi_str = Style.BRIGHT + base_str + Style.NORMAL + assert ansi.style(base_str, bold=True) == ansi_str + + +def test_style_underline(): + base_str = HELLO_WORLD + ansi_str = ansi.UNDERLINE_ENABLE + base_str + ansi.UNDERLINE_DISABLE + assert ansi.style(base_str, underline=True) == ansi_str + + +def test_style_multi(): + base_str = HELLO_WORLD + ansi_str = Fore.BLUE + Back.GREEN + Style.BRIGHT + ansi.UNDERLINE_ENABLE + \ + base_str + Fore.RESET + Back.RESET + Style.NORMAL + ansi.UNDERLINE_DISABLE + assert ansi.style(base_str, fg='blue', bg='green', bold=True, underline=True) == ansi_str + + +def test_style_color_not_exist(): + base_str = HELLO_WORLD + + with pytest.raises(ValueError): + ansi.style(base_str, fg='fake', bg='green') + + with pytest.raises(ValueError): + ansi.style(base_str, fg='blue', bg='fake') + + +def test_fg_lookup_exist(): + assert ansi.fg_lookup('green') == Fore.GREEN + + +def test_fg_lookup_nonexist(): + with pytest.raises(ValueError): + ansi.fg_lookup('foo') + + +def test_bg_lookup_exist(): + assert ansi.bg_lookup('green') == Back.GREEN + + +def test_bg_lookup_nonexist(): + with pytest.raises(ValueError): + ansi.bg_lookup('bar') diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 9a5b2b47..d3e51130 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -11,7 +11,6 @@ import os import sys import tempfile -from colorama import Fore, Back, Style import pytest # Python 3.5 had some regressions in the unitest.mock module, so use 3rd party mock if available @@ -21,7 +20,7 @@ except ImportError: from unittest import mock import cmd2 -from cmd2 import clipboard, constants, utils +from cmd2 import ansi, clipboard, constants, utils from .conftest import run_cmd, normalize, verify_help_text, HELP_HISTORY, SHORTCUTS_TXT, SHOW_TXT, SHOW_LONG def CreateOutsimApp(): @@ -182,6 +181,30 @@ now: True out, err = run_cmd(base_app, 'set quiet') assert out == ['quiet: True'] +@pytest.mark.parametrize('new_val, is_valid, expected', [ + (ansi.ANSI_NEVER, False, ansi.ANSI_NEVER), + ('neVeR', False, ansi.ANSI_NEVER), + (ansi.ANSI_TERMINAL, False, ansi.ANSI_TERMINAL), + ('TeRMInal', False, ansi.ANSI_TERMINAL), + (ansi.ANSI_ALWAYS, False, ansi.ANSI_ALWAYS), + ('AlWaYs', False, ansi.ANSI_ALWAYS), + ('invalid', True, ansi.ANSI_TERMINAL), +]) +def test_set_allow_ansi(base_app, new_val, is_valid, expected): + # Initialize allow_ansi for this test + ansi.allow_ansi = ansi.ANSI_TERMINAL + + # Use the set command to alter it + out, err = run_cmd(base_app, 'set allow_ansi {}'.format(new_val)) + + # Verify the results + assert bool(err) == is_valid + assert ansi.allow_ansi == expected + + # Reload ansi module to reset allow_ansi to its default since it's an + # application-wide setting that can affect other unit tests. + import importlib + importlib.reload(ansi) class OnChangeHookApp(cmd2.Cmd): def __init__(self, *args, **kwargs): @@ -354,11 +377,11 @@ def test_run_script_nested_run_scripts(base_app, request): expected = """ %s _relative_run_script precmds.txt -set colors Always +set allow_ansi Always help shortcuts _relative_run_script postcmds.txt -set colors Never""" % initial_run +set allow_ansi Never""" % initial_run out, err = run_cmd(base_app, 'history -s') assert out == normalize(expected) @@ -373,11 +396,11 @@ def test_runcmds_plus_hooks(base_app, request): 'run_script ' + postfilepath]) expected = """ run_script %s -set colors Always +set allow_ansi Always help shortcuts run_script %s -set colors Never""" % (prefilepath, postfilepath) +set allow_ansi Never""" % (prefilepath, postfilepath) out, err = run_cmd(base_app, 'history -s') assert out == normalize(expected) @@ -846,9 +869,9 @@ def test_ansi_prompt_not_esacped(base_app): def test_ansi_prompt_escaped(): from cmd2.rl_utils import rl_make_safe_prompt app = cmd2.Cmd() - color = Fore.CYAN + color = 'cyan' prompt = 'InColor' - color_prompt = color + prompt + Fore.RESET + color_prompt = ansi.style(prompt, fg=color) readline_hack_start = "\x01" readline_hack_end = "\x02" @@ -857,11 +880,11 @@ def test_ansi_prompt_escaped(): assert prompt != color_prompt if sys.platform.startswith('win'): # PyReadline on Windows doesn't suffer from the GNU readline bug which requires the hack - assert readline_safe_prompt.startswith(color) - assert readline_safe_prompt.endswith(Fore.RESET) + assert readline_safe_prompt.startswith(ansi.fg_lookup(color)) + assert readline_safe_prompt.endswith(ansi.FG_RESET) else: - assert readline_safe_prompt.startswith(readline_hack_start + color + readline_hack_end) - assert readline_safe_prompt.endswith(readline_hack_start + Fore.RESET + readline_hack_end) + assert readline_safe_prompt.startswith(readline_hack_start + ansi.fg_lookup(color) + readline_hack_end) + assert readline_safe_prompt.endswith(readline_hack_start + ansi.FG_RESET + readline_hack_end) class HelpApp(cmd2.Cmd): @@ -1356,7 +1379,7 @@ def test_pseudo_raw_input_piped_rawinput_true_echo_true(capsys): app, out = piped_rawinput_true(capsys, True, command) out = out.splitlines() assert out[0] == '{}{}'.format(app.prompt, command) - assert out[1].startswith('colors:') + assert out[1].startswith('allow_ansi:') # using the decorator puts the original input function back when this unit test returns @mock.patch('builtins.input', mock.MagicMock(name='input', side_effect=['set', EOFError])) @@ -1364,7 +1387,7 @@ def test_pseudo_raw_input_piped_rawinput_true_echo_false(capsys): command = 'set' app, out = piped_rawinput_true(capsys, False, command) firstline = out.splitlines()[0] - assert firstline.startswith('colors:') + assert firstline.startswith('allow_ansi:') assert not '{}{}'.format(app.prompt, command) in out # the next helper function and two tests check for piped @@ -1383,13 +1406,13 @@ def test_pseudo_raw_input_piped_rawinput_false_echo_true(capsys): app, out = piped_rawinput_false(capsys, True, command) out = out.splitlines() assert out[0] == '{}{}'.format(app.prompt, command) - assert out[1].startswith('colors:') + assert out[1].startswith('allow_ansi:') def test_pseudo_raw_input_piped_rawinput_false_echo_false(capsys): command = 'set' app, out = piped_rawinput_false(capsys, False, command) firstline = out.splitlines()[0] - assert firstline.startswith('colors:') + assert firstline.startswith('allow_ansi:') assert not '{}{}'.format(app.prompt, command) in out @@ -1447,32 +1470,35 @@ def test_poutput_empty_string(outsim_app): msg = '' outsim_app.poutput(msg) out = outsim_app.stdout.getvalue() - expected = msg + expected = '\n' assert out == expected def test_poutput_none(outsim_app): msg = None outsim_app.poutput(msg) out = outsim_app.stdout.getvalue() - expected = '' + expected = 'None\n' assert out == expected -def test_poutput_color_always(outsim_app): +def test_poutput_ansi_always(outsim_app): msg = 'Hello World' - color = Fore.CYAN - outsim_app.colors = 'Always' - outsim_app.poutput(msg, color=color) + ansi.allow_ansi = ansi.ANSI_ALWAYS + colored_msg = ansi.style(msg, fg='cyan') + outsim_app.poutput(colored_msg) out = outsim_app.stdout.getvalue() - expected = color + msg + '\n' + Fore.RESET + expected = colored_msg + '\n' + assert colored_msg != msg assert out == expected -def test_poutput_color_never(outsim_app): + +def test_poutput_ansi_never(outsim_app): msg = 'Hello World' - color = Fore.CYAN - outsim_app.colors = 'Never' - outsim_app.poutput(msg, color=color) + ansi.allow_ansi = ansi.ANSI_NEVER + colored_msg = ansi.style(msg, fg='cyan') + outsim_app.poutput(colored_msg) out = outsim_app.stdout.getvalue() expected = msg + '\n' + assert colored_msg != msg assert out == expected @@ -1766,23 +1792,24 @@ def test_ppaged(outsim_app): out = outsim_app.stdout.getvalue() assert out == msg + end -def test_ppaged_strips_color_when_redirecting(outsim_app): +def test_ppaged_strips_ansi_when_redirecting(outsim_app): msg = 'testing...' end = '\n' - outsim_app.colors = cmd2.constants.COLORS_TERMINAL + ansi.allow_ansi = ansi.ANSI_TERMINAL outsim_app._redirecting = True - outsim_app.ppaged(Fore.RED + msg) + outsim_app.ppaged(ansi.style(msg, fg='red')) out = outsim_app.stdout.getvalue() assert out == msg + end -def test_ppaged_strips_color_when_redirecting_if_always(outsim_app): +def test_ppaged_strips_ansi_when_redirecting_if_always(outsim_app): msg = 'testing...' end = '\n' - outsim_app.colors = cmd2.constants.COLORS_ALWAYS + ansi.allow_ansi = ansi.ANSI_ALWAYS outsim_app._redirecting = True - outsim_app.ppaged(Fore.RED + msg) + colored_msg = ansi.style(msg, fg='red') + outsim_app.ppaged(colored_msg) out = outsim_app.stdout.getvalue() - assert out == Fore.RED + msg + end + assert out == colored_msg + end # we override cmd.parseline() so we always get consistent # command parsing by parent methods we don't override @@ -1897,28 +1924,22 @@ def test_exit_code_nonzero(exit_code_repl): assert out == expected -class ColorsApp(cmd2.Cmd): +class AnsiApp(cmd2.Cmd): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def do_echo(self, args): self.poutput(args) - self.perror(args, False) + self.perror(args) def do_echo_error(self, args): - color_on = Fore.RED + Back.BLACK - color_off = Style.RESET_ALL - self.poutput(color_on + args + color_off) + self.poutput(ansi.style(args, fg='red')) # perror uses colors by default - self.perror(args, False) - -def test_colors_default(): - app = ColorsApp() - assert app.colors == cmd2.constants.COLORS_TERMINAL + self.perror(args) -def test_colors_pouterr_always_tty(mocker, capsys): - app = ColorsApp() - app.colors = cmd2.constants.COLORS_ALWAYS +def test_ansi_pouterr_always_tty(mocker, capsys): + app = AnsiApp() + ansi.allow_ansi = ansi.ANSI_ALWAYS mocker.patch.object(app.stdout, 'isatty', return_value=True) mocker.patch.object(sys.stderr, 'isatty', return_value=True) @@ -1938,9 +1959,9 @@ def test_colors_pouterr_always_tty(mocker, capsys): assert len(err) > len('oopsie\n') assert 'oopsie' in err -def test_colors_pouterr_always_notty(mocker, capsys): - app = ColorsApp() - app.colors = cmd2.constants.COLORS_ALWAYS +def test_ansi_pouterr_always_notty(mocker, capsys): + app = AnsiApp() + ansi.allow_ansi = ansi.ANSI_ALWAYS mocker.patch.object(app.stdout, 'isatty', return_value=False) mocker.patch.object(sys.stderr, 'isatty', return_value=False) @@ -1960,9 +1981,9 @@ def test_colors_pouterr_always_notty(mocker, capsys): assert len(err) > len('oopsie\n') assert 'oopsie' in err -def test_colors_terminal_tty(mocker, capsys): - app = ColorsApp() - app.colors = cmd2.constants.COLORS_TERMINAL +def test_ansi_terminal_tty(mocker, capsys): + app = AnsiApp() + ansi.allow_ansi = ansi.ANSI_TERMINAL mocker.patch.object(app.stdout, 'isatty', return_value=True) mocker.patch.object(sys.stderr, 'isatty', return_value=True) @@ -1981,9 +2002,9 @@ def test_colors_terminal_tty(mocker, capsys): assert len(err) > len('oopsie\n') assert 'oopsie' in err -def test_colors_terminal_notty(mocker, capsys): - app = ColorsApp() - app.colors = cmd2.constants.COLORS_TERMINAL +def test_ansi_terminal_notty(mocker, capsys): + app = AnsiApp() + ansi.allow_ansi = ansi.ANSI_TERMINAL mocker.patch.object(app.stdout, 'isatty', return_value=False) mocker.patch.object(sys.stderr, 'isatty', return_value=False) @@ -1995,9 +2016,9 @@ def test_colors_terminal_notty(mocker, capsys): out, err = capsys.readouterr() assert out == err == 'oopsie\n' -def test_colors_never_tty(mocker, capsys): - app = ColorsApp() - app.colors = cmd2.constants.COLORS_NEVER +def test_ansi_never_tty(mocker, capsys): + app = AnsiApp() + ansi.allow_ansi = ansi.ANSI_NEVER mocker.patch.object(app.stdout, 'isatty', return_value=True) mocker.patch.object(sys.stderr, 'isatty', return_value=True) @@ -2009,9 +2030,9 @@ def test_colors_never_tty(mocker, capsys): out, err = capsys.readouterr() assert out == err == 'oopsie\n' -def test_colors_never_notty(mocker, capsys): - app = ColorsApp() - app.colors = cmd2.constants.COLORS_NEVER +def test_ansi_never_notty(mocker, capsys): + app = AnsiApp() + ansi.allow_ansi = ansi.ANSI_NEVER mocker.patch.object(app.stdout, 'isatty', return_value=False) mocker.patch.object(sys.stderr, 'isatty', return_value=False) diff --git a/tests/test_history.py b/tests/test_history.py index 973d8cff..add93ea6 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -461,6 +461,17 @@ def test_history_output_file(base_app): content = normalize(f.read()) assert content == expected +def test_history_bad_output_file(base_app): + run_cmd(base_app, 'help') + run_cmd(base_app, 'shortcuts') + run_cmd(base_app, 'help history') + + fname = os.path.join(os.path.sep, "fake", "fake", "fake") + out, err = run_cmd(base_app, 'history -o "{}"'.format(fname)) + + assert not out + assert "Error saving" in err[0] + def test_history_edit(base_app, monkeypatch): # Set a fake editor just to make sure we have one. We aren't really # going to call it due to the mock @@ -492,21 +503,23 @@ def test_history_run_one_command(base_app): out2, err2 = run_cmd(base_app, 'history -r 1') assert out1 == out2 -def test_history_clear(base_app): +def test_history_clear(hist_file): # Add commands to history - run_cmd(base_app, 'help') - run_cmd(base_app, 'alias') + app = cmd2.Cmd(persistent_history_file=hist_file) + run_cmd(app, 'help') + run_cmd(app, 'alias') # Make sure history has items - out, err = run_cmd(base_app, 'history') + out, err = run_cmd(app, 'history') assert out # Clear the history - run_cmd(base_app, 'history --clear') + run_cmd(app, 'history --clear') - # Make sure history is empty - out, err = run_cmd(base_app, 'history') + # Make sure history is empty and its file is gone + out, err = run_cmd(app, 'history') assert out == [] + assert not os.path.exists(hist_file) def test_history_verbose_with_other_options(base_app): # make sure -v shows a usage error if any other options are present diff --git a/tests/test_utils.py b/tests/test_utils.py index 44421b93..262e6c54 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -8,23 +8,11 @@ import sys import pytest -from colorama import Fore import cmd2.utils as cu HELLO_WORLD = 'Hello, world!' -def test_strip_ansi(): - base_str = HELLO_WORLD - ansi_str = Fore.GREEN + base_str + Fore.RESET - assert base_str != ansi_str - assert base_str == cu.strip_ansi(ansi_str) - -def test_ansi_safe_wcswidth(): - base_str = HELLO_WORLD - ansi_str = Fore.GREEN + base_str + Fore.RESET - assert cu.ansi_safe_wcswidth(ansi_str) != len(ansi_str) - def test_strip_quotes_no_quotes(): base_str = HELLO_WORLD stripped = cu.strip_quotes(base_str) @@ -257,7 +245,8 @@ def test_proc_reader_terminate(pr_none): else: assert ret_code == -signal.SIGTERM -@pytest.mark.skipif(sys.platform == 'linux', reason="Test doesn't work correctly on TravisCI") +@pytest.mark.skipif(not sys.platform.startswith('win'), + reason="Test doesn't work correctly on TravisCI and is unreliable on Azure DevOps macOS") def test_proc_reader_wait(pr_none): assert pr_none._proc.poll() is None pr_none.wait() diff --git a/tests/transcripts/regex_set.txt b/tests/transcripts/regex_set.txt index d45672a7..02bc9875 100644 --- a/tests/transcripts/regex_set.txt +++ b/tests/transcripts/regex_set.txt @@ -4,7 +4,7 @@ # Regexes on prompts just make the trailing space obvious (Cmd) set -colors: /(Terminal|Always|Never)/ +allow_ansi: /(Terminal|Always|Never)/ continuation_prompt: >/ / debug: False echo: False |