Repository: python-cmd2/cmd2
Pull request #704 (2019-06-25 04:14:42)

#698 - Revisiting Color Support
Addresses #698 

Changes:
- `poutput` and `perror` no longer use `color` kwarg, which has been split into `fg` and `bg`.
- `fg` and `bg` use human names of color like `blue` or `red`.
- `perror` is split into 2 functions `perror` and `pexcept`. `pexcept` will behave identical to the previous `perror`, but `perror` will only print to stderr with no stacktraces or Exception messages.



Patch:
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`
diff --git a/README.md b/README.md
index 139c911d..18bcc081 100755
--- a/README.md
+++ b/README.md
@@ -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']
@@ -550,6 +556,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
 


Test patch:
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 +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 +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