diff options
author | Kevin Van Brunt <kmvanbrunt@gmail.com> | 2018-09-26 14:37:13 -0400 |
---|---|---|
committer | Kevin Van Brunt <kmvanbrunt@gmail.com> | 2018-09-26 14:37:13 -0400 |
commit | 8aeb29cf1fd027093b87b8f9f9c640cf50595db7 (patch) | |
tree | 23cb1ef06b4f9e52fe8b6da0b7e9f51a05e609e1 | |
parent | 149f6eba2620b1623f6071227318450c779b2b50 (diff) | |
parent | c8983d9d6df4d057672166a7e8df544199788b9a (diff) | |
download | cmd2-git-8aeb29cf1fd027093b87b8f9f9c640cf50595db7.tar.gz |
Merge branch 'macro' into argparse_conversion
-rw-r--r-- | CHANGELOG.md | 9 | ||||
-rw-r--r-- | CONTRIBUTING.md | 2 | ||||
-rwxr-xr-x | cmd2/argparse_completer.py | 3 | ||||
-rw-r--r-- | cmd2/cmd2.py | 295 | ||||
-rw-r--r-- | cmd2/constants.py | 5 | ||||
-rw-r--r-- | cmd2/parsing.py | 13 | ||||
-rw-r--r-- | cmd2/utils.py | 12 | ||||
-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 | 18 | ||||
-rw-r--r-- | tests/scripts/postcmds.txt | 2 | ||||
-rw-r--r-- | tests/scripts/precmds.txt | 2 | ||||
-rw-r--r-- | tests/test_cmd2.py | 209 | ||||
-rw-r--r-- | tests/transcripts/regex_set.txt | 4 |
20 files changed, 743 insertions, 189 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/argparse_completer.py b/cmd2/argparse_completer.py index 21e116ed..ca50bba9 100755 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -891,6 +891,9 @@ class ACHelpFormatter(argparse.RawTextHelpFormatter): result = super()._format_args(action, default_metavar) return result + def format_help(self): + return super().format_help() + '\n' + # noinspection PyCompatibility class ACArgumentParser(argparse.ArgumentParser): diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 292969ca..f40b1508 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 @@ -139,19 +139,21 @@ def categorize(func: Union[Callable, Iterable], category: str) -> None: setattr(func, HELP_CATEGORY, category) -def parse_quoted_string(cmdline: str) -> List[str]: - """Parse a quoted string into a list of arguments.""" - if isinstance(cmdline, list): +def parse_quoted_string(string: str, preserve_quotes: bool) -> List[str]: + """ + Parse a quoted string into a list of arguments + :param string: the string being parsed + :param preserve_quotes: if True, then quotes will not be stripped + """ + if isinstance(string, list): # arguments are already a list, return the list we were passed - lexed_arglist = cmdline + lexed_arglist = string else: # Use shlex to split the command line into a list of arguments based on shell rules - lexed_arglist = shlex.split(cmdline, posix=False) - # strip off outer quotes for convenience - temp_arglist = [] - for arg in lexed_arglist: - temp_arglist.append(utils.strip_quotes(arg)) - lexed_arglist = temp_arglist + lexed_arglist = shlex.split(string, posix=False) + + if not preserve_quotes: + lexed_arglist = [utils.strip_quotes(arg) for arg in lexed_arglist] return lexed_arglist @@ -163,7 +165,7 @@ def with_category(category: str) -> Callable: return cat_decorator -def with_argument_list(func: Callable) -> Callable: +def with_argument_list(func: Callable, preserve_quotes: bool=False) -> Callable: """A decorator to alter the arguments passed to a do_* cmd2 method. Default passes a string of whatever the user typed. With this decorator, the decorated method will receive a list @@ -172,18 +174,19 @@ def with_argument_list(func: Callable) -> Callable: @functools.wraps(func) def cmd_wrapper(self, cmdline): - lexed_arglist = parse_quoted_string(cmdline) + lexed_arglist = parse_quoted_string(cmdline, preserve_quotes) return func(self, lexed_arglist) cmd_wrapper.__doc__ = func.__doc__ return cmd_wrapper -def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser) -> Callable: +def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser, preserve_quotes: bool=False) -> Callable: """A decorator to alter a cmd2 method to populate its ``args`` argument by parsing arguments with the given instance of argparse.ArgumentParser, but also returning unknown args as a list. :param argparser: given instance of ArgumentParser + :param preserve_quotes: if True, then the arguments passed to arparse be maintain their quotes :return: function that gets passed parsed args and a list of unknown args """ import functools @@ -192,7 +195,7 @@ def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser) -> Calla def arg_decorator(func: Callable): @functools.wraps(func) def cmd_wrapper(instance, cmdline): - lexed_arglist = parse_quoted_string(cmdline) + lexed_arglist = parse_quoted_string(cmdline, preserve_quotes) try: args, unknown = argparser.parse_known_args(lexed_arglist) except SystemExit: @@ -221,11 +224,12 @@ def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser) -> Calla return arg_decorator -def with_argparser(argparser: argparse.ArgumentParser) -> Callable: +def with_argparser(argparser: argparse.ArgumentParser, preserve_quotes: bool=False) -> Callable: """A decorator to alter a cmd2 method to populate its ``args`` argument by parsing arguments with the given instance of argparse.ArgumentParser. :param argparser: given instance of ArgumentParser + :param preserve_quotes: if True, then the arguments passed to arparse be maintain their quotes :return: function that gets passed parsed args """ import functools @@ -234,7 +238,7 @@ def with_argparser(argparser: argparse.ArgumentParser) -> Callable: def arg_decorator(func: Callable): @functools.wraps(func) def cmd_wrapper(instance, cmdline): - lexed_arglist = parse_quoted_string(cmdline) + lexed_arglist = parse_quoted_string(cmdline, preserve_quotes) try: args = argparser.parse_args(lexed_arglist) except SystemExit: @@ -320,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 @@ -340,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', @@ -372,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() @@ -420,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 = [] @@ -556,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 - 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. + 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. + + 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 @@ -591,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`. @@ -607,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. @@ -643,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 @@ -667,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) @@ -678,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 @@ -1452,6 +1482,10 @@ class Cmd(cmd.Cmd): except AttributeError: compfunc = self.completedefault + # Check if a macro was entered + elif command in self.macros: + compfunc = self.path_complete + # A valid command was not entered else: # Check if this command should be run as a shell command @@ -2201,9 +2235,9 @@ class Cmd(cmd.Cmd): def alias_create(self, args: argparse.Namespace): """ Creates or overwrites an alias """ # Validate the alias name - valid, errmsg = self.statement_parser.is_valid_command(args.name) + valid, errmsg = self.statement_parser.is_valid_command(args.name, allow_shortcut=False) if not valid: - errmsg = "Alias names {}".format(errmsg) + errmsg = "Invalid alias name: {}".format(errmsg) self.perror(errmsg, traceback_war=False) return @@ -2212,19 +2246,23 @@ class Cmd(cmd.Cmd): self.perror(errmsg, traceback_war=False) return - if not args.command.strip(): - errmsg = "Aliases cannot resolve to an empty command" + valid, errmsg = self.statement_parser.is_valid_command(args.command, allow_shortcut=True) + if not valid: + errmsg = "Invalid alias target: {}".format(errmsg) self.perror(errmsg, traceback_war=False) return + utils.unquote_redirection_tokens(args.command_args) + # Build the alias value string - value = utils.quote_string_if_needed(args.command) + value = args.command for cur_arg in args.command_args: - value += ' ' + utils.quote_string_if_needed(cur_arg) + value += ' ' + cur_arg # Set the alias + result = "overwritten" if args.name in self.aliases else "created" self.aliases[args.name] = value - self.poutput("Alias {!r} created".format(args.name)) + self.poutput("Alias {!r} {}".format(args.name, result)) def alias_delete(self, args: argparse.Namespace): """ Deletes aliases """ @@ -2259,7 +2297,12 @@ class Cmd(cmd.Cmd): self.poutput("alias create {} {}".format(cur_alias, self.aliases[cur_alias])) # Top-level parser for alias - alias_parser = ACArgumentParser(description="Manage aliases", prog='alias') + alias_description = ("Manage aliases\n" + "\n" + "An alias is a command that enables replacement of a word by another string.") + alias_epilog = ("See also:\n" + " macro") + alias_parser = ACArgumentParser(description=alias_description, epilog=alias_epilog, prog='alias') # Add subcommands to alias alias_subparsers = alias_parser.add_subparsers() @@ -2268,14 +2311,17 @@ class Cmd(cmd.Cmd): alias_create_help = "create or overwrite an alias" alias_create_description = "Create or overwrite an alias" - alias_create_epilog = "Notes:\n" - alias_create_epilog += " If you want to use redirection or pipes in the alias, then quote them to prevent\n" - alias_create_epilog += " the alias command itself from being redirected\n" - alias_create_epilog += "\n" - alias_create_epilog += "Examples:\n" - alias_create_epilog += " alias ls !ls -lF\n" - alias_create_epilog += " alias create show_log !cat \"log file.txt\"\n" - alias_create_epilog += " alias create save_results print_results \">\" out.txt\n" + alias_create_epilog = ("Notes:\n" + " If you want to use redirection or pipes in the alias, then quote them to prevent\n" + " the 'alias create' command itself from being redirected\n" + "\n" + " Since aliases are resolved during parsing, tab completion will function as it would\n" + " for the actual command the alias resolves to." + "\n" + "Examples:\n" + " alias ls !ls -lF\n" + " alias create show_log !cat \"log file.txt\"\n" + " alias create save_results print_results \">\" out.txt\n") alias_create_parser = alias_subparsers.add_parser('create', help=alias_create_help, description=alias_create_description, @@ -2301,10 +2347,10 @@ class Cmd(cmd.Cmd): # alias -> list alias_list_help = "list aliases" - alias_list_description = "List specified aliases in a reusable form that can be saved to\n" - alias_list_description += "a startup_script to preserve aliases across sessions\n" - alias_list_description += "\n" - alias_list_description += "Without arguments, all aliases will be listed" + alias_list_description = ("List specified aliases in a reusable form that can be saved to\n" + "a startup_script to preserve aliases across sessions\n" + "\n" + "Without arguments, all aliases will be listed") alias_list_parser = alias_subparsers.add_parser('list', help=alias_list_help, description=alias_list_description) @@ -2312,7 +2358,7 @@ class Cmd(cmd.Cmd): ACTION_ARG_CHOICES, aliases) alias_list_parser.set_defaults(func=alias_list) - @with_argparser(alias_parser) + @with_argparser(alias_parser, preserve_quotes=True) def do_alias(self, args: argparse.Namespace): """Manage aliases""" func = getattr(args, 'func', None) @@ -2328,9 +2374,9 @@ class Cmd(cmd.Cmd): def macro_create(self, args: argparse.Namespace): """ Creates or overwrites a macro """ # Validate the macro name - valid, errmsg = self.statement_parser.is_valid_command(args.name) + valid, errmsg = self.statement_parser.is_valid_command(args.name, allow_shortcut=False) if not valid: - errmsg = "Macro names {}".format(errmsg) + errmsg = "Invalid macro name: {}".format(errmsg) self.perror(errmsg, traceback_war=False) return @@ -2344,15 +2390,18 @@ class Cmd(cmd.Cmd): self.perror(errmsg, traceback_war=False) return - if not args.command.strip(): - errmsg = "Macros cannot resolve to an empty command" + valid, errmsg = self.statement_parser.is_valid_command(args.command, allow_shortcut=True) + if not valid: + errmsg = "Invalid macro target: {}".format(errmsg) self.perror(errmsg, traceback_war=False) return + utils.unquote_redirection_tokens(args.command_args) + # Build the macro value string - value = utils.quote_string_if_needed(args.command) + value = args.command for cur_arg in args.command_args: - value += ' ' + utils.quote_string_if_needed(cur_arg) + value += ' ' + cur_arg # Find all normal arguments arg_list = [] @@ -2400,8 +2449,9 @@ class Cmd(cmd.Cmd): break # Set the macro + result = "overwritten" if args.name in self.macros else "created" self.macros[args.name] = Macro(name=args.name, value=value, required_arg_count=max_arg_num, arg_list=arg_list) - self.poutput("Macro {!r} created".format(args.name)) + self.poutput("Macro {!r} {}".format(args.name, result)) def macro_delete(self, args: argparse.Namespace): """ Deletes macros """ @@ -2436,7 +2486,12 @@ class Cmd(cmd.Cmd): self.poutput("macro create {} {}".format(cur_macro, self.macros[cur_macro].value)) # Top-level parser for macro - macro_parser = ACArgumentParser(description="Manage macros", prog='macro') + macro_description = ("Manage macros\n" + "\n" + "A macro is similar to an alias, but it can take arguments when called.") + macro_epilog = ("See also:\n" + " alias") + macro_parser = ACArgumentParser(description=macro_description, epilog=macro_epilog, prog='macro') # Add subcommands to macro macro_subparsers = macro_parser.add_subparsers() @@ -2445,41 +2500,41 @@ class Cmd(cmd.Cmd): macro_create_help = "create or overwrite a macro" macro_create_description = "Create or overwrite a macro" - macro_create_epilog = "A macro is similar to an alias, but it can take arguments when called. Arguments are\n" - macro_create_epilog += "expressed when creating a macro using {#} notation where {1} means the first argument\n" - macro_create_epilog += "while {8} would mean the eighth.\n" - macro_create_epilog += "\n" - macro_create_epilog += "The following creates a macro called my_macro that expects two arguments:\n" - macro_create_epilog += "\n" - macro_create_epilog += "macro create my_macro make_dinner -meat {1} -veggie {2}\n" - macro_create_epilog += "\n" - macro_create_epilog += "When the macro is called, the provided arguments are resolved and the assembled\n" - macro_create_epilog += "command is run. The following table shows how my_macro would be resolved.\n" - macro_create_epilog += "\n" - macro_create_epilog += "Macro Usage Command Run\n" - macro_create_epilog += "my_macro beef broccoli make_dinner -meat beef -veggie broccoli\n" - macro_create_epilog += "my_macro chicken spinach make_dinner -meat chicken -veggie spinach\n" - macro_create_epilog += "\n" - macro_create_epilog += "Notes:\n" - macro_create_epilog += " To use the literal string {1} in your command, escape it this way: {{1}}\n" - macro_create_epilog += "\n" - macro_create_epilog += " An argument number can be repeated in a macro. In the following example the first\n" - macro_create_epilog += " argument will populate both {1} instances\n" - macro_create_epilog += "\n" - macro_create_epilog += " macro create ft file_taxes -p {1} -q {2} -r {1}\n" - macro_create_epilog += "\n" - macro_create_epilog += " To quote an argument in the resolved command, quote it during creation:\n" - macro_create_epilog += "\n" - macro_create_epilog += " macro create del_file !rm -f \"{1}\"\n" - macro_create_epilog += "\n" - macro_create_epilog += " Macros can resolve into commands, aliases, and other macros. Therefore it is\n" - macro_create_epilog += " possible to create a macro that results in infinite recursion if a macro ends up\n" - macro_create_epilog += " resolving back to itself. So be careful.\n" - macro_create_epilog += "\n" - macro_create_epilog += " If you want to use redirection or pipes in the macro, then quote them as in the\n" - macro_create_epilog += " following example to prevent the macro command itself from being redirected\n" - macro_create_epilog += "\n" - macro_create_epilog += " macro create save_results print_results -type {1} \">\" \"{2}\"" + macro_create_epilog = ("A macro is similar to an alias, but it can take arguments when called. Arguments are\n" + "expressed when creating a macro using {#} notation where {1} means the first argument.\n" + "\n" + "The following creates a macro called my_macro that expects two arguments:\n" + "\n" + " macro create my_macro make_dinner -meat {1} -veggie {2}\n" + "\n" + "When the macro is called, the provided arguments are resolved and the assembled\n" + "command is run. For example:\n" + "\n" + " my_macro beef broccoli ---> make_dinner -meat beef -veggie broccoli\n" + "\n" + "Notes:\n" + " To use the literal string {1} in your command, escape it this way: {{1}}.\n" + "\n" + " An argument number can be repeated in a macro. In the following example the first\n" + " argument will populate both {1} instances.\n" + "\n" + " macro create ft file_taxes -p {1} -q {2} -r {1}\n" + "\n" + " To quote an argument in the resolved command, quote it during creation.\n" + "\n" + " macro create backup !cp \"{1}\" \"{1}.orig\"\n" + "\n" + " Macros can resolve into commands, aliases, and other macros. Therefore it is\n" + " possible to create a macro that results in infinite recursion if a macro ends up\n" + " resolving back to itself. So be careful.\n" + "\n" + " If you want to use redirection or pipes in the macro, then quote them as in the\n" + " following example to prevent the 'macro create' command itself from being redirected.\n" + "\n" + " macro create save_results print_results -type {1} \">\" \"{2}\"\n" + "\n" + " Because macros do not resolve until after parsing (hitting Enter), tab completion\n" + " will only complete paths.") macro_create_parser = macro_subparsers.add_parser('create', help=macro_create_help, description=macro_create_description, @@ -2505,10 +2560,10 @@ class Cmd(cmd.Cmd): # macro -> list macro_list_help = "list macros" - macro_list_description = "List specified macros in a reusable form that can be saved to\n" - macro_list_description += "a startup_script to preserve macros across sessions\n" - macro_list_description += "\n" - macro_list_description += "Without arguments, all macros will be listed" + macro_list_description = ("List specified macros in a reusable form that can be saved to\n" + "a startup_script to preserve macros across sessions\n" + "\n" + "Without arguments, all macros will be listed.") macro_list_parser = macro_subparsers.add_parser('list', help=macro_list_help, description=macro_list_description) @@ -2516,7 +2571,7 @@ class Cmd(cmd.Cmd): ACTION_ARG_CHOICES, macros) macro_list_parser.set_defaults(func=macro_list) - @with_argparser(macro_parser) + @with_argparser(macro_parser, preserve_quotes=True) def do_macro(self, args: argparse.Namespace): """Manage macros""" func = getattr(args, 'func', None) @@ -2766,10 +2821,10 @@ class Cmd(cmd.Cmd): else: raise LookupError("Parameter '{}' not supported (type 'set' for list of parameters).".format(param)) - set_description = "Set a settable parameter or show current settings of parameters.\n" - set_description += "\n" - set_description += "Accepts abbreviated parameter names so long as there is no ambiguity.\n" - set_description += "Call without arguments for a list of settable parameters with their values." + set_description = ("Set a settable parameter or show current settings of parameters.\n" + "\n" + "Accepts abbreviated parameter names so long as there is no ambiguity.\n" + "Call without arguments for a list of settable parameters with their values.") set_parser = ACArgumentParser(description=set_description) set_parser.add_argument('-a', '--all', action='store_true', help='display read-only settings as well') @@ -2780,7 +2835,7 @@ class Cmd(cmd.Cmd): @with_argparser(set_parser) def do_set(self, args: argparse.Namespace) -> None: - """Set a settable parameter or shows current settings of parameters""" + """Set a settable parameter or show current settings of parameters""" # Check if param was passed in if not args.param: 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/cmd2/parsing.py b/cmd2/parsing.py index 498834cf..82e8ee39 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -291,7 +291,7 @@ class StatementParser: expr = r'\A\s*(\S*?)({})'.format(second_group) self._command_pattern = re.compile(expr) - def is_valid_command(self, word: str) -> Tuple[bool, str]: + def is_valid_command(self, word: str, allow_shortcut: bool) -> Tuple[bool, str]: """Determine whether a word is a valid name for a command. Commands can not include redirection characters, whitespace, @@ -311,11 +311,12 @@ class StatementParser: if not word: return False, 'cannot be an empty string' - errmsg = 'cannot start with a shortcut: ' - errmsg += ', '.join(shortcut for (shortcut, expansion) in self.shortcuts) - for (shortcut, expansion) in self.shortcuts: - if word.startswith(shortcut): - return False, errmsg + if not allow_shortcut: + errmsg = 'cannot start with a shortcut: ' + errmsg += ', '.join(shortcut for (shortcut, expansion) in self.shortcuts) + for (shortcut, expansion) in self.shortcuts: + if word.startswith(shortcut): + return False, errmsg errmsg = 'cannot contain: whitespace, quotes, ' errchars = [] diff --git a/cmd2/utils.py b/cmd2/utils.py index 3527236f..a20f0b66 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -304,3 +304,15 @@ class StdSim(object): return self.__dict__[item] else: return getattr(self.inner_stream, item) + + +def unquote_redirection_tokens(args: List[str]) -> None: + """ + Used to unquote redirection tokens in a list of command line arguments + This is used when redirection tokens have to be passed to another command + :param args: the command line args + """ + for i, arg in enumerate(args): + unquoted_arg = strip_quotes(arg) + if unquoted_arg in constants.REDIRECTION_TOKENS: + args[i] = unquoted_arg 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 1295a633..da6ffcd6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,7 +45,7 @@ macro Manage macros py Invoke python command or shell pyscript Run a python script file inside the console quit Exit this application -set Set a settable parameter or shows current settings of parameters +set Set a settable parameter or show current settings of parameters shell Execute a command as if at the OS prompt shortcuts List shortcuts available """ @@ -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 d250af26..d3d1d585 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' @@ -1111,22 +1107,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): @@ -1263,7 +1259,7 @@ macro Manage macros py Invoke python command or shell pyscript Run a python script file inside the console quit Exit this application -set Set a settable parameter or shows current settings of parameters +set Set a settable parameter or show current settings of parameters shell Execute a command as if at the OS prompt shortcuts List shortcuts available @@ -1752,6 +1748,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 @@ -1767,7 +1781,7 @@ def test_alias_create(base_app, capsys): out = run_cmd(base_app, 'alias list') assert out == normalize('alias create fake pyscript') - # Lookup the new alias + # Look up the new alias out = run_cmd(base_app, 'alias list fake') assert out == normalize('alias create fake pyscript') @@ -1776,7 +1790,7 @@ def test_alias_create_with_quotes(base_app, capsys): out = run_cmd(base_app, 'alias create fake help ">" "out file.txt"') assert out == normalize("Alias 'fake' created") - # Lookup the new alias (Only the redirector should be unquoted) + # Look up the new alias (Only the redirector should be unquoted) out = run_cmd(base_app, 'alias list fake') assert out == normalize('alias create fake help > "out file.txt"') @@ -1793,7 +1807,7 @@ def test_alias_create_with_quotes(base_app, capsys): def test_alias_create_invalid_name(base_app, alias_name, capsys): run_cmd(base_app, 'alias create {} help'.format(alias_name)) out, err = capsys.readouterr() - assert "cannot" in err + assert "Invalid alias name" in err def test_alias_create_with_macro_name(base_app, capsys): macro = "my_macro" @@ -1802,13 +1816,22 @@ def test_alias_create_with_macro_name(base_app, capsys): out, err = capsys.readouterr() assert "cannot have the same name" in err -def test_alias_create_with_empty_command(base_app, capsys): - run_cmd(base_app, 'alias create my_alias ""') +@pytest.mark.parametrize('alias_target', [ + '""', # Blank name + '">"', + '"no>pe"', + '"no spaces"', + '"nopipe|"', + '"noterm;"', + 'noembedded"quotes', +]) +def test_alias_create_with_invalid_command(base_app, alias_target, capsys): + run_cmd(base_app, 'alias create my_alias {}'.format(alias_target)) out, err = capsys.readouterr() - assert "cannot resolve to an empty command" in err + assert "Invalid alias target" in err def test_alias_list_invalid_alias(base_app, capsys): - # Lookup invalid alias + # Look up invalid alias out = run_cmd(base_app, 'alias list invalid') out, err = capsys.readouterr() assert "not found" in err @@ -1965,7 +1988,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() @@ -2052,3 +2074,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 |