Repository: python-cmd2/cmd2

Issue:
Consider refactoring how colorize and colors work in cmd2
Currently ``cmd2`` has:
* a required dependency on [colorama](https://github.com/tartley/colorama)
    * provides cross-platform colored terminal text on Window, macOS, and Linux
* a built-in ``colorize`` method in ``Cmd`` class that provides colored terminal text on macOS and Linux
    * uses an internal dictionary which maps string color names to ANSI color escape codes

Having both of these feels awkward and redundant.  There are several possible options I could see for refactoring this including the following:
1.  Make ``colorama`` an optional dependency and refactor the **colorize** method to use ``colorama`` if present and to provide no color if it isn't
2. Keep ``colorama`` as a required dependency and refactor the **colorize** method to use it
3. Remove the dependency on ``colorama`` and use **colorize** where it is currently being used

Other possible additions include things such as:
- adding an optional **color** argument to methods such as ``poutput``
- refactoring the underlying implementation of how colors are stored
- creating a separate module which provides string color codes based on a color name lookup and wraps ``colorama`` and/or ``colored``

I would be curious to hear other people's opinions regarding how to best proceed with this.  What are your thoughts?


Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f5dff203..3015b793 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,15 @@
         * These allow you to provide feedback to the user in an asychronous fashion, meaning alerts can
         display when the user is still entering text at the prompt. See [async_printing.py](https://github.com/python-cmd2/cmd2/blob/master/examples/async_printing.py)
         for an example.
+    * Cross-platform colored output support 
+        * ``colorama`` gets initialized properly in ``Cmd.__init()``
+        * The ``Cmd.colors`` setting is no longer platform dependent and now has three values:
+            * Terminal (default) - output methods do not strip any ANSI escape sequences when output is a terminal, but 
+            if the output is a pipe or a file the escape sequences are stripped
+            * Always - output methods **never** strip ANSI escape sequences, regardless of the output destination
+            * Never - output methods strip all ANSI escape sequences
+* Deprecations
+    * Deprecated the builtin ``cmd2`` suport for colors including ``Cmd.colorize()`` and ``Cmd._colorcodes``
 * Deletions
     * The ``preparse``, ``postparsing_precmd``, and ``postparsing_postcmd`` methods *deprecated* in the previous release 
     have been deleted
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index ed478b0d..7d8ac7dc 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -32,16 +32,16 @@ Git repository on GitHub at https://github.com/python-cmd2/cmd2
 import argparse
 import cmd
 import collections
+import colorama
 from colorama import Fore
 import glob
 import inspect
 import os
-import platform
 import re
 import shlex
 import sys
 import threading
-from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Type, Union
+from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Type, Union, IO
 
 from . import constants
 from . import utils
@@ -318,7 +318,7 @@ class Cmd(cmd.Cmd):
     reserved_words = []
 
     # Attributes which ARE dynamically settable at runtime
-    colors = (platform.system() != 'Windows')
+    colors = constants.COLORS_TERMINAL
     continuation_prompt = '> '
     debug = False
     echo = False
@@ -338,7 +338,7 @@ class Cmd(cmd.Cmd):
 
     # To make an attribute settable with the "do_set" command, add it to this ...
     # This starts out as a dictionary but gets converted to an OrderedDict sorted alphabetically by key
-    settable = {'colors': 'Colorized output (*nix only)',
+    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',
@@ -370,6 +370,9 @@ class Cmd(cmd.Cmd):
             except AttributeError:
                 pass
 
+        # Override whether ansi codes should be stripped from the output since cmd2 has its own logic for doing this
+        colorama.init(strip=False)
+
         # initialize plugin system
         # needs to be done before we call __init__(0)
         self._initialize_plugin_system()
@@ -418,13 +421,13 @@ class Cmd(cmd.Cmd):
         self._STOP_AND_EXIT = True  # cmd convention
 
         self._colorcodes = {'bold': {True: '\x1b[1m', False: '\x1b[22m'},
-                            'cyan': {True: '\x1b[36m', False: '\x1b[39m'},
-                            'blue': {True: '\x1b[34m', False: '\x1b[39m'},
-                            'red': {True: '\x1b[31m', False: '\x1b[39m'},
-                            'magenta': {True: '\x1b[35m', False: '\x1b[39m'},
-                            'green': {True: '\x1b[32m', False: '\x1b[39m'},
-                            'underline': {True: '\x1b[4m', False: '\x1b[24m'},
-                            'yellow': {True: '\x1b[33m', False: '\x1b[39m'}}
+                            'cyan': {True: Fore.CYAN, False: Fore.RESET},
+                            'blue': {True: Fore.BLUE, False: Fore.RESET},
+                            'red': {True: Fore.RED, False: Fore.RESET},
+                            'magenta': {True: Fore.MAGENTA, False: Fore.RESET},
+                            'green': {True: Fore.GREEN, False: Fore.RESET},
+                            'underline': {True: '\x1b[4m', False: Fore.RESET},
+                            'yellow': {True: Fore.YELLOW, False: Fore.RESET}}
 
         # Used load command to store the current script dir as a LIFO queue to support _relative_load command
         self._script_dir = []
@@ -554,34 +557,53 @@ class Cmd(cmd.Cmd):
         # Make sure settable parameters are sorted alphabetically by key
         self.settable = collections.OrderedDict(sorted(self.settable.items(), key=lambda t: t[0]))
 
-    def poutput(self, msg: str, end: str='\n') -> None:
-        """Convenient shortcut for self.stdout.write(); by default adds newline to end if not already present.
+    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)
 
-        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.
+    def poutput(self, msg: Any, end: str='\n', color: str='') -> None:
+        """Smarter self.stdout.write(); color aware and adds newline of not present.
+
+        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: string appended after the end of the message if not already present, default a newline
+        :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
         """
         if msg is not None and msg != '':
             try:
                 msg_str = '{}'.format(msg)
-                self.stdout.write(msg_str)
                 if not msg_str.endswith(end):
-                    self.stdout.write(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.
+                # 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, err: Union[str, Exception], traceback_war: bool=True) -> None:
+    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.
 
         :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
-        :return:
+        :param err_color: (optional) color escape to output error with
+        :param war_color: (optional) color escape to output warning with
         """
         if self.debug:
             import traceback
@@ -589,14 +611,15 @@ class Cmd(cmd.Cmd):
 
         if isinstance(err, Exception):
             err_msg = "EXCEPTION of type '{}' occurred with message: '{}'\n".format(type(err).__name__, err)
-            sys.stderr.write(self.colorize(err_msg, 'red'))
         else:
-            err_msg = self.colorize("ERROR: {}\n".format(err), 'red')
-            sys.stderr.write(err_msg)
+            err_msg = "ERROR: {}\n".format(err)
+        err_msg = err_color + err_msg + Fore.RESET
+        self.decolorized_write(sys.stderr, err_msg)
 
         if traceback_war:
             war = "To enable full traceback, run the following command:  'set debug true'\n"
-            sys.stderr.write(self.colorize(war, 'yellow'))
+            war = war_color + war + Fore.RESET
+            self.decolorized_write(sys.stderr, war)
 
     def pfeedback(self, msg: str) -> None:
         """For printing nonessential feedback.  Can be silenced with `quiet`.
@@ -605,7 +628,7 @@ class Cmd(cmd.Cmd):
             if self.feedback_to_output:
                 self.poutput(msg)
             else:
-                sys.stderr.write("{}\n".format(msg))
+                self.decolorized_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.
@@ -641,6 +664,9 @@ 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)
+
                     pager = self.pager
                     if chop:
                         pager = self.pager_chop
@@ -665,7 +691,7 @@ class Cmd(cmd.Cmd):
             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.
+                # broken_pipe_warning attribute to the message you want printed.`
                 if self.broken_pipe_warning:
                     sys.stderr.write(self.broken_pipe_warning)
 
@@ -676,7 +702,7 @@ class Cmd(cmd.Cmd):
            is running on Windows, will return ``val`` unchanged.
            ``color`` should be one of the supported strings (or styles):
            red/blue/green/cyan/magenta, bold, underline"""
-        if self.colors and (self.stdout == self.initial_stdout):
+        if self.colors.lower() != constants.COLORS_NEVER.lower() and (self.stdout == self.initial_stdout):
             return self._colorcodes[color][True] + val + self._colorcodes[color][False]
         return val
 
diff --git a/cmd2/constants.py b/cmd2/constants.py
index d3e8a125..3c133b70 100644
--- a/cmd2/constants.py
+++ b/cmd2/constants.py
@@ -17,3 +17,8 @@ REDIRECTION_TOKENS = [REDIRECTION_PIPE, REDIRECTION_OUTPUT, REDIRECTION_APPEND]
 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'
diff --git a/docs/settingchanges.rst b/docs/settingchanges.rst
index 02955273..e08b6026 100644
--- a/docs/settingchanges.rst
+++ b/docs/settingchanges.rst
@@ -137,7 +137,7 @@ comments, is viewable from within a running application
 with::
 
     (Cmd) set --long
-    colors: True                   # Colorized output (*nix only)
+    colors: Terminal               # Allow colorized output
     continuation_prompt: >         # On 2nd+ line of input
     debug: False                   # Show full error stack on error
     echo: False                    # Echo command issued into output
@@ -150,5 +150,5 @@ with::
 
 Any of these user-settable parameters can be set while running your app with the ``set`` command like so::
 
-    set colors False
+    set colors Never
 
diff --git a/docs/unfreefeatures.rst b/docs/unfreefeatures.rst
index b5f9415d..364addc6 100644
--- a/docs/unfreefeatures.rst
+++ b/docs/unfreefeatures.rst
@@ -139,23 +139,43 @@ instead.  These methods have these advantages:
 .. automethod:: cmd2.cmd2.Cmd.ppaged
 
 
-color
-=====
+Colored Output
+==============
 
-Text output can be colored by wrapping it in the ``colorize`` method.
+The output methods in the previous section all honor the ``colors`` setting,
+which has three possible values:
+
+Never
+    poutput() and pfeedback() strip all ANSI escape sequences
+    which instruct the terminal to colorize output
+
+Terminal
+    (the default value) poutput() and pfeedback() 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`.
+
+Always
+    poutput() and pfeedback() never strip ANSI escape sequences, regardless of
+    the output destination
+
+
+The previously recommended ``colorize`` method is now deprecated.
 
-.. automethod:: cmd2.cmd2.Cmd.colorize
 
 .. _quiet:
 
+Suppressing non-essential output
+================================
 
-quiet
-=====
+The ``quiet`` setting controls whether ``self.pfeedback()`` actually produces
+any output. If ``quiet`` is ``False``, then the output will be produced. If
+``quiet`` is ``True``, no output will be produced.
 
-Controls whether ``self.pfeedback('message')`` output is suppressed;
-useful for non-essential feedback that the user may not always want
-to read.  ``quiet`` is only relevant if
-``app.pfeedback`` is sometimes used.
+This makes ``self.pfeedback()`` useful for non-essential output like status
+messages. Users can control whether they would like to see these messages by changing
+the value of the ``quiet`` setting.
 
 
 select
diff --git a/examples/colors.py b/examples/colors.py
new file mode 100755
index 00000000..8765aee0
--- /dev/null
+++ b/examples/colors.py
@@ -0,0 +1,142 @@
+#!/usr/bin/env python
+# coding=utf-8
+"""
+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:
+
+Never
+    poutput() and pfeedback() strip all ANSI escape sequences
+    which instruct the terminal to colorize output
+
+Terminal
+    (the default value) poutput() and pfeedback() 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`.
+
+Always
+    poutput() and pfeedback() 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,
+}
+
+
+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):
+        self.multiline_commands = ['orate']
+        self.maxrepeats = 3
+
+        # Add stuff to settable and shortcuts before calling base class initializer
+        self.settable['maxrepeats'] = 'max repetitions for speak command'
+        self.shortcuts.update({'&': 'speak'})
+
+        # Set use_ipython to True to enable the "ipy" command which embeds and interactive IPython shell
+        super().__init__(use_ipython=True)
+
+    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('words', nargs='+', help='words to say')
+
+    @cmd2.with_argparser(speak_parser)
+    def do_speak(self, args):
+        """Repeats what you tell me to."""
+        words = []
+        for word in args.words:
+            if args.piglatin:
+                word = '%s%say' % (word[1:], word[0])
+            if args.shout:
+                word = word.upper()
+            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
+
+        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)
+
+
+if __name__ == '__main__':
+    c = CmdLineApp()
+    c.cmdloop()
diff --git a/examples/pirate.py b/examples/pirate.py
index 34906a9f..22274dbf 100755
--- a/examples/pirate.py
+++ b/examples/pirate.py
@@ -8,8 +8,21 @@ It demonstrates many features of cmd2.
 """
 import argparse
 
+from colorama import Fore
+
 import cmd2
 
+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."""
@@ -17,10 +30,10 @@ class Pirate(cmd2.Cmd):
         self.default_to_shell = True
         self.multiline_commands = ['sing']
         self.terminators = self.terminators + ['...']
-        self.songcolor = 'blue'
+        self.songcolor = Fore.BLUE
 
         # Add stuff to settable and/or shortcuts before calling base class initializer
-        self.settable['songcolor'] = 'Color to ``sing`` in (red/blue/green/cyan/magenta, bold, underline)'
+        self.settable['songcolor'] = 'Color to ``sing`` in (black/red/green/yellow/blue/magenta/cyan/white)'
         self.shortcuts.update({'~': 'sing'})
 
         """Initialize the base class as well as this one"""
@@ -68,7 +81,8 @@ class Pirate(cmd2.Cmd):
 
     def do_sing(self, arg):
         """Sing a colorful song."""
-        self.poutput(self.colorize(arg, self.songcolor))
+        color_escape = COLORS.get(self.songcolor, default=Fore.RESET)
+        self.poutput(arg, color=color_escape)
 
     yo_parser = argparse.ArgumentParser()
     yo_parser.add_argument('--ho', type=int, default=2, help="How often to chant 'ho'")
diff --git a/examples/plumbum_colors.py b/examples/plumbum_colors.py
new file mode 100755
index 00000000..942eaf80
--- /dev/null
+++ b/examples/plumbum_colors.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python
+# coding=utf-8
+"""
+A sample application for cmd2. Demonstrating colorized output using the plumbum package.
+
+Experiment with the command line options on the `speak` command to see how
+different output colors ca
+
+The colors setting has three possible values:
+
+Never
+    poutput() and pfeedback() strip all ANSI escape sequences
+    which instruct the terminal to colorize output
+
+Terminal
+    (the default value) poutput() and pfeedback() 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`.
+
+Always
+    poutput() and pfeedback() never strip ANSI escape sequences, regardless of
+    the output destination
+
+WARNING: This example requires the plumbum package, which isn't normally required by cmd2.
+"""
+
+import random
+import argparse
+
+import cmd2
+from plumbum.colors import fg, bg, reset
+
+FG_COLORS = {
+    'black': fg.Black,
+    'red': fg.DarkRedA,
+    'green': fg.MediumSpringGreen,
+    'yellow': fg.LightYellow,
+    'blue': fg.RoyalBlue1,
+    'magenta': fg.Purple,
+    'cyan': fg.SkyBlue1,
+    'white': fg.White,
+}
+BG_COLORS = {
+    'black': bg.BLACK,
+    'red': bg.DarkRedA,
+    'green': bg.MediumSpringGreen,
+    'yellow': bg.LightYellow,
+    'blue': bg.RoyalBlue1,
+    'magenta': bg.Purple,
+    'cyan': bg.SkyBlue1,
+    'white': bg.White,
+}
+
+
+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):
+        self.multiline_commands = ['orate']
+        self.maxrepeats = 3
+
+        # Add stuff to settable and shortcuts before calling base class initializer
+        self.settable['maxrepeats'] = 'max repetitions for speak command'
+        self.shortcuts.update({'&': 'speak'})
+
+        # Set use_ipython to True to enable the "ipy" command which embeds and interactive IPython shell
+        super().__init__(use_ipython=True)
+
+    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('words', nargs='+', help='words to say')
+
+    @cmd2.with_argparser(speak_parser)
+    def do_speak(self, args):
+        """Repeats what you tell me to."""
+        words = []
+        for word in args.words:
+            if args.piglatin:
+                word = '%s%say' % (word[1:], word[0])
+            if args.shout:
+                word = word.upper()
+            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
+
+        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)
+
+
+if __name__ == '__main__':
+    c = CmdLineApp()
+    c.cmdloop()
diff --git a/examples/python_scripting.py b/examples/python_scripting.py
index 4c959f58..0b0030a5 100755
--- a/examples/python_scripting.py
+++ b/examples/python_scripting.py
@@ -17,6 +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
 
 
@@ -33,7 +35,7 @@ class CmdLineApp(cmd2.Cmd):
     def _set_prompt(self):
         """Set prompt so it displays the current working directory."""
         self.cwd = os.getcwd()
-        self.prompt = self.colorize('{!r} $ '.format(self.cwd), 'cyan')
+        self.prompt = Fore.CYAN + '{!r} $ '.format(self.cwd) + Fore.RESET
 
     def postcmd(self, stop: bool, line: str) -> bool:
         """Hook method executed just after a command dispatch is finished.
diff --git a/examples/transcripts/exampleSession.txt b/examples/transcripts/exampleSession.txt
index 6318776f..38fb0659 100644
--- a/examples/transcripts/exampleSession.txt
+++ b/examples/transcripts/exampleSession.txt
@@ -3,7 +3,7 @@
 # The regex for editor will match whatever program you use.
 # regexes on prompts just make the trailing space obvious
 (Cmd) set
-colors: /(True|False)/
+colors: /(Terminal|Always|Never)/
 continuation_prompt: >/ /
 debug: False
 echo: False
diff --git a/examples/transcripts/transcript_regex.txt b/examples/transcripts/transcript_regex.txt
index 08588ab1..6980fac6 100644
--- a/examples/transcripts/transcript_regex.txt
+++ b/examples/transcripts/transcript_regex.txt
@@ -3,7 +3,7 @@
 # The regex for editor will match whatever program you use.
 # regexes on prompts just make the trailing space obvious
 (Cmd) set
-colors: /(True|False)/
+colors: /(Terminal|Always|Never)/
 continuation_prompt: >/ /
 debug: False
 echo: False


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 561f281b..39aa3473 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -82,11 +82,8 @@ SHORTCUTS_TXT = """Shortcuts for other commands:
 @@: _relative_load
 """
 
-expect_colors = True
-if sys.platform.startswith('win'):
-    expect_colors = False
 # Output from the show command with default settings
-SHOW_TXT = """colors: {}
+SHOW_TXT = """colors: Terminal
 continuation_prompt: >
 debug: False
 echo: False
@@ -96,14 +93,10 @@ locals_in_py: False
 prompt: (Cmd)
 quiet: False
 timing: False
-""".format(expect_colors)
+"""
 
-if expect_colors:
-    color_str = 'True '
-else:
-    color_str = 'False'
 SHOW_LONG = """
-colors: {}             # Colorized output (*nix only)
+colors: Terminal          # Allow colorized 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
@@ -113,8 +106,7 @@ locals_in_py: False       # Allow access to your application in py via self
 prompt: (Cmd)             # The prompt issued to solicit input
 quiet: False              # Don't print nonessential feedback
 timing: False             # Report execution times
-""".format(color_str)
-
+"""
 
 def normalize(block):
     """ Normalize a block of text to perform comparison.
diff --git a/tests/scripts/postcmds.txt b/tests/scripts/postcmds.txt
index 2b478b57..dea8f265 100644
--- a/tests/scripts/postcmds.txt
+++ b/tests/scripts/postcmds.txt
@@ -1,1 +1,1 @@
-set colors off
+set colors Never
diff --git a/tests/scripts/precmds.txt b/tests/scripts/precmds.txt
index d0b27fb6..0ae7eae8 100644
--- a/tests/scripts/precmds.txt
+++ b/tests/scripts/precmds.txt
@@ -1,1 +1,1 @@
-set colors on
+set colors Always
diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py
index c6a90fdf..dece1ab4 100644
--- a/tests/test_cmd2.py
+++ b/tests/test_cmd2.py
@@ -13,6 +13,7 @@ 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
@@ -564,11 +565,11 @@ def test_load_nested_loads(base_app, request):
     expected = """
 %s
 _relative_load precmds.txt
-set colors on
+set colors Always
 help
 shortcuts
 _relative_load postcmds.txt
-set colors off""" % initial_load
+set colors Never""" % initial_load
     assert run_cmd(base_app, 'history -s') == normalize(expected)
 
 
@@ -586,11 +587,11 @@ def test_base_runcmds_plus_hooks(base_app, request):
                                  'load ' + postfilepath])
     expected = """
 load %s
-set colors on
+set colors Always
 help
 shortcuts
 load %s
-set colors off""" % (prefilepath, postfilepath)
+set colors Never""" % (prefilepath, postfilepath)
     assert run_cmd(base_app, 'history -s') == normalize(expected)
 
 
@@ -817,12 +818,7 @@ def test_base_colorize(base_app):
     # But if we create a fresh Cmd() instance, it will
     fresh_app = cmd2.Cmd()
     color_test = fresh_app.colorize('Test', 'red')
-    # Actually, colorization only ANSI escape codes is only applied on non-Windows systems
-    if sys.platform == 'win32':
-        assert color_test == 'Test'
-    else:
-        assert color_test == '\x1b[31mTest\x1b[39m'
-
+    assert color_test == '\x1b[31mTest\x1b[39m'
 
 def _expected_no_editor_error():
     expected_exception = 'OSError'
@@ -1109,22 +1105,22 @@ 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 = 'cyan'
+    color = Fore.CYAN
     prompt = 'InColor'
-    color_prompt = app.colorize(prompt, color)
+    color_prompt = color + prompt + Fore.RESET
 
     readline_hack_start = "\x01"
     readline_hack_end = "\x02"
 
     readline_safe_prompt = rl_make_safe_prompt(color_prompt)
+    assert prompt != color_prompt
     if sys.platform.startswith('win'):
-        # colorize() does nothing on Windows due to lack of ANSI color support
-        assert prompt == color_prompt
-        assert readline_safe_prompt == prompt
+        # 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)
     else:
-        assert prompt != color_prompt
-        assert readline_safe_prompt.startswith(readline_hack_start + app._colorcodes[color][True] + readline_hack_end)
-        assert readline_safe_prompt.endswith(readline_hack_start + app._colorcodes[color][False] + readline_hack_end)
+        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)
 
 
 class HelpApp(cmd2.Cmd):
@@ -1750,6 +1746,24 @@ def test_poutput_none(base_app):
     expected = ''
     assert out == expected
 
+def test_poutput_color_always(base_app):
+    msg = 'Hello World'
+    color = Fore.CYAN
+    base_app.colors = 'Always'
+    base_app.poutput(msg, color=color)
+    out = base_app.stdout.getvalue()
+    expected = color + msg + '\n' + Fore.RESET
+    assert out == expected
+
+def test_poutput_color_never(base_app):
+    msg = 'Hello World'
+    color = Fore.CYAN
+    base_app.colors = 'Never'
+    base_app.poutput(msg, color=color)
+    out = base_app.stdout.getvalue()
+    expected = msg + '\n'
+    assert out == expected
+
 
 def test_alias(base_app, capsys):
     # Create the alias
@@ -1968,7 +1982,6 @@ def test_bad_history_file_path(capsys, request):
         assert 'readline cannot read' in err
 
 
-
 def test_get_all_commands(base_app):
     # Verify that the base app has the expected commands
     commands = base_app.get_all_commands()
@@ -2055,3 +2068,136 @@ def test_exit_code_nonzero(exit_code_repl):
             app.cmdloop()
     out = app.stdout.getvalue()
     assert out == expected
+
+
+class ColorsApp(cmd2.Cmd):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+    def do_echo(self, args):
+        self.poutput(args)
+        self.perror(args, False)
+
+    def do_echo_error(self, args):
+        color_on = Fore.RED + Back.BLACK
+        color_off = Style.RESET_ALL
+        self.poutput(color_on + args + color_off)
+        # perror uses colors by default
+        self.perror(args, False)
+
+def test_colors_default():
+    app = ColorsApp()
+    assert app.colors == cmd2.constants.COLORS_TERMINAL
+
+def test_colors_pouterr_always_tty(mocker, capsys):
+    app = ColorsApp()
+    app.colors = cmd2.constants.COLORS_ALWAYS
+    mocker.patch.object(app.stdout, 'isatty', return_value=True)
+    mocker.patch.object(sys.stderr, 'isatty', return_value=True)
+
+    app.onecmd_plus_hooks('echo_error oopsie')
+    out, err = capsys.readouterr()
+    # if colors are on, the output should have some escape sequences in it
+    assert len(out) > len('oopsie\n')
+    assert 'oopsie' in out
+    assert len(err) > len('Error: oopsie\n')
+    assert 'ERROR: oopsie' in err
+
+    # but this one shouldn't
+    app.onecmd_plus_hooks('echo oopsie')
+    out, err = capsys.readouterr()
+    assert out == 'oopsie\n'
+    # errors always have colors
+    assert len(err) > len('Error: oopsie\n')
+    assert 'ERROR: oopsie' in err
+
+def test_colors_pouterr_always_notty(mocker, capsys):
+    app = ColorsApp()
+    app.colors = cmd2.constants.COLORS_ALWAYS
+    mocker.patch.object(app.stdout, 'isatty', return_value=False)
+    mocker.patch.object(sys.stderr, 'isatty', return_value=False)
+
+    app.onecmd_plus_hooks('echo_error oopsie')
+    out, err = capsys.readouterr()
+    # if colors are on, the output should have some escape sequences in it
+    assert len(out) > len('oopsie\n')
+    assert 'oopsie' in out
+    assert len(err) > len('Error: oopsie\n')
+    assert 'ERROR: oopsie' in err
+
+    # but this one shouldn't
+    app.onecmd_plus_hooks('echo oopsie')
+    out, err = capsys.readouterr()
+    assert out == 'oopsie\n'
+    # errors always have colors
+    assert len(err) > len('Error: oopsie\n')
+    assert 'ERROR: oopsie' in err
+
+def test_colors_terminal_tty(mocker, capsys):
+    app = ColorsApp()
+    app.colors = cmd2.constants.COLORS_TERMINAL
+    mocker.patch.object(app.stdout, 'isatty', return_value=True)
+    mocker.patch.object(sys.stderr, 'isatty', return_value=True)
+
+    app.onecmd_plus_hooks('echo_error oopsie')
+    # if colors are on, the output should have some escape sequences in it
+    out, err = capsys.readouterr()
+    assert len(out) > len('oopsie\n')
+    assert 'oopsie' in out
+    assert len(err) > len('Error: oopsie\n')
+    assert 'ERROR: oopsie' in err
+
+    # but this one shouldn't
+    app.onecmd_plus_hooks('echo oopsie')
+    out, err = capsys.readouterr()
+    assert out == 'oopsie\n'
+    assert len(err) > len('Error: oopsie\n')
+    assert 'ERROR: oopsie' in err
+
+def test_colors_terminal_notty(mocker, capsys):
+    app = ColorsApp()
+    app.colors = cmd2.constants.COLORS_TERMINAL
+    mocker.patch.object(app.stdout, 'isatty', return_value=False)
+    mocker.patch.object(sys.stderr, 'isatty', return_value=False)
+
+    app.onecmd_plus_hooks('echo_error oopsie')
+    out, err = capsys.readouterr()
+    assert out == 'oopsie\n'
+    assert err == 'ERROR: oopsie\n'
+
+    app.onecmd_plus_hooks('echo oopsie')
+    out, err = capsys.readouterr()
+    assert out == 'oopsie\n'
+    assert err == 'ERROR: oopsie\n'
+
+def test_colors_never_tty(mocker, capsys):
+    app = ColorsApp()
+    app.colors = cmd2.constants.COLORS_NEVER
+    mocker.patch.object(app.stdout, 'isatty', return_value=True)
+    mocker.patch.object(sys.stderr, 'isatty', return_value=True)
+
+    app.onecmd_plus_hooks('echo_error oopsie')
+    out, err = capsys.readouterr()
+    assert out == 'oopsie\n'
+    assert err == 'ERROR: oopsie\n'
+
+    app.onecmd_plus_hooks('echo oopsie')
+    out, err = capsys.readouterr()
+    assert out == 'oopsie\n'
+    assert err == 'ERROR: oopsie\n'
+
+def test_colors_never_notty(mocker, capsys):
+    app = ColorsApp()
+    app.colors = cmd2.constants.COLORS_NEVER
+    mocker.patch.object(app.stdout, 'isatty', return_value=False)
+    mocker.patch.object(sys.stderr, 'isatty', return_value=False)
+
+    app.onecmd_plus_hooks('echo_error oopsie')
+    out, err = capsys.readouterr()
+    assert out == 'oopsie\n'
+    assert err == 'ERROR: oopsie\n'
+
+    app.onecmd_plus_hooks('echo oopsie')
+    out, err = capsys.readouterr()
+    assert out == 'oopsie\n'
+    assert err == 'ERROR: oopsie\n'
diff --git a/tests/transcripts/regex_set.txt b/tests/transcripts/regex_set.txt
index b818c464..d45672a7 100644
--- a/tests/transcripts/regex_set.txt
+++ b/tests/transcripts/regex_set.txt
@@ -1,10 +1,10 @@
 # Run this transcript with "python example.py -t transcript_regex.txt"
-# The regex for colors is because no color on Windows.
+# The regex for colors shows all possible settings for colors
 # The regex for editor will match whatever program you use.
 # Regexes on prompts just make the trailing space obvious
 
 (Cmd) set
-colors: /(True|False)/
+colors: /(Terminal|Always|Never)/
 continuation_prompt: >/ /
 debug: False
 echo: False