diff options
-rw-r--r-- | CHANGELOG.md | 9 | ||||
-rw-r--r-- | CONTRIBUTING.md | 2 | ||||
-rw-r--r-- | cmd2/cmd2.py | 86 | ||||
-rw-r--r-- | cmd2/constants.py | 5 | ||||
-rw-r--r-- | docs/settingchanges.rst | 4 | ||||
-rw-r--r-- | docs/unfreefeatures.rst | 40 | ||||
-rwxr-xr-x | examples/colors.py | 142 | ||||
-rwxr-xr-x | examples/pirate.py | 20 | ||||
-rwxr-xr-x | examples/plumbum_colors.py | 144 | ||||
-rwxr-xr-x | examples/python_scripting.py | 4 | ||||
-rw-r--r-- | examples/transcripts/exampleSession.txt | 2 | ||||
-rw-r--r-- | examples/transcripts/transcript_regex.txt | 2 | ||||
-rw-r--r-- | tests/conftest.py | 16 | ||||
-rw-r--r-- | tests/scripts/postcmds.txt | 2 | ||||
-rw-r--r-- | tests/scripts/precmds.txt | 2 | ||||
-rw-r--r-- | tests/test_cmd2.py | 184 | ||||
-rw-r--r-- | tests/transcripts/regex_set.txt | 4 |
17 files changed, 584 insertions, 84 deletions
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/CONTRIBUTING.md b/CONTRIBUTING.md index e4de4fd4..5ba66b14 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ Remember to feel free to ask for help by leaving a comment within the Issue. Working on your first pull request? You can learn how from this *free* series [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github). -###### If you've found a bug that is not on the board, [follow these steps](Readme.md#found-a-bug). +###### If you've found a bug that is not on the board, [follow these steps](README.md#found-a-bug). -------------------------------------------------------------------------------- diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 47b6cb1c..8b7262a2 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 @@ -324,7 +324,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 @@ -344,7 +344,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', @@ -376,6 +376,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() @@ -424,13 +427,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 = [] @@ -560,34 +563,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 @@ -595,14 +617,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`. @@ -611,7 +634,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. @@ -647,6 +670,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 @@ -671,7 +697,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) @@ -682,7 +708,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 diff --git a/tests/conftest.py b/tests/conftest.py index 124b482a..b86622ac 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 @@ -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 @@ -set colors on +set colors Always diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 5523d801..3cbde311 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_create(base_app, capsys): # Create the alias @@ -1972,7 +1986,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() @@ -2059,3 +2072,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 |