diff options
author | Eric Lin <anselor@gmail.com> | 2018-05-16 10:39:49 -0400 |
---|---|---|
committer | Eric Lin <anselor@gmail.com> | 2018-05-16 10:39:49 -0400 |
commit | 371284d20370a8e85dd8527d9bbcc6267b335cde (patch) | |
tree | 002ac709eca15522a0b1abaa235aae04975b08e6 | |
parent | ab8194e92b9c3728d8f86cb9c81de180b6884eee (diff) | |
parent | a9b712108e5af49937b0af3aa51db2ebe5c159e4 (diff) | |
download | cmd2-git-371284d20370a8e85dd8527d9bbcc6267b335cde.tar.gz |
Merge branch 'master' into pyscript
-rw-r--r-- | CHANGELOG.md | 2 | ||||
-rw-r--r-- | cmd2/argcomplete_bridge.py | 15 | ||||
-rwxr-xr-x | cmd2/argparse_completer.py | 4 | ||||
-rwxr-xr-x | cmd2/cmd2.py | 526 | ||||
-rw-r--r-- | cmd2/constants.py | 9 | ||||
-rw-r--r-- | cmd2/parsing.py | 170 | ||||
-rw-r--r-- | cmd2/rl_utils.py | 10 | ||||
-rw-r--r-- | docs/freefeatures.rst | 24 | ||||
-rw-r--r-- | docs/unfreefeatures.rst | 6 | ||||
-rwxr-xr-x | examples/subcommands.py | 3 | ||||
-rwxr-xr-x | examples/submenus.py | 109 | ||||
-rw-r--r-- | tests/test_argparse.py | 13 | ||||
-rw-r--r-- | tests/test_bashcompletion.py | 232 | ||||
-rw-r--r-- | tests/test_cmd2.py | 38 | ||||
-rw-r--r-- | tests/test_completion.py | 133 | ||||
-rw-r--r-- | tests/test_parsing.py | 177 | ||||
-rw-r--r-- | tests/test_submenu.py | 181 | ||||
-rw-r--r-- | tests/test_transcript.py | 175 | ||||
-rw-r--r-- | tests/transcripts/from_cmdloop.txt | 1 | ||||
-rw-r--r-- | tox.ini | 8 |
20 files changed, 753 insertions, 1083 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index dac0756f..f9627194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ * Changes * ``strip_ansi()`` and ``strip_quotes()`` functions have moved to new utils module * Several constants moved to new constants module + * Submenu support has been moved to a new [cmd2-submenu](https://github.com/python-cmd2/cmd2-submenu) plugin. If you use submenus, you will need to update your dependencies and modify your imports. * Deletions (potentially breaking changes) * Deleted all ``optparse`` code which had previously been deprecated in release 0.8.0 * The ``options`` decorator no longer exists @@ -28,6 +29,7 @@ * Deleted ``cmd_with_subs_completer``, ``get_subcommands``, and ``get_subcommand_completer`` * Replaced by default AutoCompleter implementation for all commands using argparse * Deleted support for old method of calling application commands with ``cmd()`` and ``self`` + * ``cmd2.redirector`` is no longer supported. Output redirection can only be done with '>' or '>>' * Python 2 no longer supported * ``cmd2`` now supports Python 3.4+ diff --git a/cmd2/argcomplete_bridge.py b/cmd2/argcomplete_bridge.py index 583f3345..a036af1e 100644 --- a/cmd2/argcomplete_bridge.py +++ b/cmd2/argcomplete_bridge.py @@ -4,7 +4,7 @@ try: # check if argcomplete is installed import argcomplete -except ImportError: +except ImportError: # pragma: no cover # not installed, skip the rest of the file pass @@ -70,7 +70,7 @@ else: break except ValueError: # ValueError can be caused by missing closing quote - if not quotes_to_try: + if not quotes_to_try: # pragma: no cover # Since we have no more quotes to try, something else # is causing the parsing error. Return None since # this means the line is malformed. @@ -228,15 +228,14 @@ else: output_stream.write(ifs.join(completions).encode(argcomplete.sys_encoding)) elif outstr: # if there are no completions, but we got something from stdout, try to print help - # trick the bash completion into thinking there are 2 completions that are unlikely # to ever match. - outstr = outstr.replace('\n', ' ').replace('\t', ' ').replace(' ', ' ').strip() - # generate a filler entry that should always sort first - filler = ' {0:><{width}}'.format('', width=len(outstr)/2) - outstr = ifs.join([filler, outstr]) - output_stream.write(outstr.encode(argcomplete.sys_encoding)) + comp_type = int(os.environ["COMP_TYPE"]) + if comp_type == 63: # type is 63 for second tab press + print(outstr.rstrip(), file=argcomplete.debug_stream, end='') + + output_stream.write(ifs.join([ifs, ' ']).encode(argcomplete.sys_encoding)) else: # if completions is None we assume we don't know how to handle it so let bash # go forward with normal filesystem completion diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 4964b1ec..a8a0f24a 100755 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -877,7 +877,9 @@ class ACArgumentParser(argparse.ArgumentParser): return super(ACArgumentParser, self)._match_argument(action, arg_strings_pattern) - def _parse_known_args(self, arg_strings, namespace): + # This is the official python implementation with a 5 year old patch applied + # See the comment below describing the patch + def _parse_known_args(self, arg_strings, namespace): # pragma: no cover # replace arg strings that are file references if self.fromfile_prefix_chars is not None: arg_strings = self._read_args_from_files(arg_strings) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 63a3cbe3..a2d67def 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -27,6 +27,7 @@ import atexit import cmd import codecs import collections +from colorama import Fore import copy import datetime import functools @@ -51,26 +52,34 @@ import pyperclip from . import constants from . import utils +from cmd2.parsing import StatementParser, Statement + # Set up readline -from .rl_utils import rl_force_redisplay, readline, rl_type, RlType -from .argparse_completer import AutoCompleter, ACArgumentParser +from .rl_utils import rl_type, RlType +if rl_type == RlType.NONE: + rl_warning = "Readline features including tab completion have been disabled since no \n" \ + "supported version of readline was found. To resolve this, install \n" \ + "pyreadline on Windows or gnureadline on Mac.\n\n" + sys.stderr.write(Fore.LIGHTYELLOW_EX + rl_warning + Fore.RESET) +else: + from .rl_utils import rl_force_redisplay, readline -from cmd2.parsing import StatementParser, Statement + if rl_type == RlType.PYREADLINE: -if rl_type == RlType.PYREADLINE: + # Save the original pyreadline display completion function since we need to override it and restore it + # noinspection PyProtectedMember + orig_pyreadline_display = readline.rl.mode._display_completions - # Save the original pyreadline display completion function since we need to override it and restore it - # noinspection PyProtectedMember - orig_pyreadline_display = readline.rl.mode._display_completions + elif rl_type == RlType.GNU: -elif rl_type == RlType.GNU: + # We need wcswidth to calculate display width of tab completions + from wcwidth import wcswidth - # We need wcswidth to calculate display width of tab completions - from wcwidth import wcswidth + # Get the readline lib so we can make changes to it + import ctypes + from .rl_utils import readline_lib - # Get the readline lib so we can make changes to it - import ctypes - from .rl_utils import readline_lib +from .argparse_completer import AutoCompleter, ACArgumentParser # Newer versions of pyperclip are released as a single file, but older versions had a more complicated structure try: @@ -318,266 +327,6 @@ class EmptyStatement(Exception): pass -def _pop_readline_history(clear_history: bool=True) -> List[str]: - """Returns a copy of readline's history and optionally clears it (default)""" - # noinspection PyArgumentList - history = [ - readline.get_history_item(i) - for i in range(1, 1 + readline.get_current_history_length()) - ] - if clear_history: - readline.clear_history() - return history - - -def _push_readline_history(history, clear_history=True): - """Restores readline's history and optionally clears it first (default)""" - if clear_history: - readline.clear_history() - for line in history: - readline.add_history(line) - - -def _complete_from_cmd(cmd_obj, text, line, begidx, endidx): - """Complete as though the user was typing inside cmd's cmdloop()""" - from itertools import takewhile - command_subcommand_params = line.split(None, 3) - - if len(command_subcommand_params) < (3 if text else 2): - n = len(command_subcommand_params[0]) - n += sum(1 for _ in takewhile(str.isspace, line[n:])) - return cmd_obj.completenames(text, line[n:], begidx - n, endidx - n) - - command, subcommand = command_subcommand_params[:2] - n = len(command) + sum(1 for _ in takewhile(str.isspace, line)) - cfun = getattr(cmd_obj, 'complete_' + subcommand, cmd_obj.complete) - return cfun(text, line[n:], begidx - n, endidx - n) - - -class AddSubmenu(object): - """Conveniently add a submenu (Cmd-like class) to a Cmd - - e.g. given "class SubMenu(Cmd): ..." then - - @AddSubmenu(SubMenu(), 'sub') - class MyCmd(cmd.Cmd): - .... - - will have the following effects: - 1. 'sub' will interactively enter the cmdloop of a SubMenu instance - 2. 'sub cmd args' will call do_cmd(args) in a SubMenu instance - 3. 'sub ... [TAB]' will have the same behavior as [TAB] in a SubMenu cmdloop - i.e., autocompletion works the way you think it should - 4. 'help sub [cmd]' will print SubMenu's help (calls its do_help()) - """ - - class _Nonexistent(object): - """ - Used to mark missing attributes. - Disable __dict__ creation since this class does nothing - """ - __slots__ = () # - - def __init__(self, - submenu, - command, - aliases=(), - reformat_prompt="{super_prompt}>> {sub_prompt}", - shared_attributes=None, - require_predefined_shares=True, - create_subclass=False, - preserve_shares=False, - persistent_history_file=None - ): - """Set up the class decorator - - submenu (Cmd): Instance of something cmd.Cmd-like - - command (str): The command the user types to access the SubMenu instance - - aliases (iterable): More commands that will behave like "command" - - reformat_prompt (str): Format str or None to disable - if it's a string, it should contain one or more of: - {super_prompt}: The current cmd's prompt - {command}: The command in the current cmd with which it was called - {sub_prompt}: The subordinate cmd's original prompt - the default is "{super_prompt}{command} {sub_prompt}" - - shared_attributes (dict): dict of the form {'subordinate_attr': 'parent_attr'} - the attributes are copied to the submenu at the last moment; the submenu's - attributes are backed up before this and restored afterward - - require_predefined_shares: The shared attributes above must be independently - defined in the subordinate Cmd (default: True) - - create_subclass: put the modifications in a subclass rather than modifying - the existing class (default: False) - """ - self.submenu = submenu - self.command = command - self.aliases = aliases - if persistent_history_file: - self.persistent_history_file = os.path.expanduser(persistent_history_file) - else: - self.persistent_history_file = None - - if reformat_prompt is not None and not isinstance(reformat_prompt, str): - raise ValueError("reformat_prompt should be either a format string or None") - self.reformat_prompt = reformat_prompt - - self.shared_attributes = {} if shared_attributes is None else shared_attributes - if require_predefined_shares: - for attr in self.shared_attributes.keys(): - if not hasattr(submenu, attr): - raise AttributeError("The shared attribute '{attr}' is not defined in {cmd}. Either define {attr} " - "in {cmd} or set require_predefined_shares=False." - .format(cmd=submenu.__class__.__name__, attr=attr)) - - self.create_subclass = create_subclass - self.preserve_shares = preserve_shares - - def _get_original_attributes(self): - return { - attr: getattr(self.submenu, attr, AddSubmenu._Nonexistent) - for attr in self.shared_attributes.keys() - } - - def _copy_in_shared_attrs(self, parent_cmd): - for sub_attr, par_attr in self.shared_attributes.items(): - setattr(self.submenu, sub_attr, getattr(parent_cmd, par_attr)) - - def _copy_out_shared_attrs(self, parent_cmd, original_attributes): - if self.preserve_shares: - for sub_attr, par_attr in self.shared_attributes.items(): - setattr(parent_cmd, par_attr, getattr(self.submenu, sub_attr)) - else: - for attr, value in original_attributes.items(): - if attr is not AddSubmenu._Nonexistent: - setattr(self.submenu, attr, value) - else: - delattr(self.submenu, attr) - - def __call__(self, cmd_obj): - """Creates a subclass of Cmd wherein the given submenu can be accessed via the given command""" - def enter_submenu(parent_cmd, statement): - """ - This function will be bound to do_<submenu> and will change the scope of the CLI to that of the - submenu. - """ - submenu = self.submenu - original_attributes = self._get_original_attributes() - history = _pop_readline_history() - - if self.persistent_history_file: - try: - readline.read_history_file(self.persistent_history_file) - except FileNotFoundError: - pass - - try: - # copy over any shared attributes - self._copy_in_shared_attrs(parent_cmd) - - if statement.args: - # Remove the menu argument and execute the command in the submenu - submenu.onecmd_plus_hooks(statement.args) - else: - if self.reformat_prompt is not None: - prompt = submenu.prompt - submenu.prompt = self.reformat_prompt.format( - super_prompt=parent_cmd.prompt, - command=self.command, - sub_prompt=prompt, - ) - submenu.cmdloop() - if self.reformat_prompt is not None: - # noinspection PyUnboundLocalVariable - self.submenu.prompt = prompt - finally: - # copy back original attributes - self._copy_out_shared_attrs(parent_cmd, original_attributes) - - # write submenu history - if self.persistent_history_file: - readline.write_history_file(self.persistent_history_file) - # reset main app history before exit - _push_readline_history(history) - - def complete_submenu(_self, text, line, begidx, endidx): - """ - This function will be bound to complete_<submenu> and will perform the complete commands of the submenu. - """ - submenu = self.submenu - original_attributes = self._get_original_attributes() - try: - # copy over any shared attributes - self._copy_in_shared_attrs(_self) - - # Reset the submenu's tab completion parameters - submenu.allow_appended_space = True - submenu.allow_closing_quote = True - submenu.display_matches = [] - - return _complete_from_cmd(submenu, text, line, begidx, endidx) - finally: - # copy back original attributes - self._copy_out_shared_attrs(_self, original_attributes) - - # Pass the submenu's tab completion parameters back up to the menu that called complete() - _self.allow_appended_space = submenu.allow_appended_space - _self.allow_closing_quote = submenu.allow_closing_quote - _self.display_matches = copy.copy(submenu.display_matches) - - original_do_help = cmd_obj.do_help - original_complete_help = cmd_obj.complete_help - - def help_submenu(_self, line): - """ - This function will be bound to help_<submenu> and will call the help commands of the submenu. - """ - tokens = line.split(None, 1) - if tokens and (tokens[0] == self.command or tokens[0] in self.aliases): - self.submenu.do_help(tokens[1] if len(tokens) == 2 else '') - else: - original_do_help(_self, line) - - def _complete_submenu_help(_self, text, line, begidx, endidx): - """autocomplete to match help_submenu()'s behavior""" - tokens = line.split(None, 1) - if len(tokens) == 2 and ( - not (not tokens[1].startswith(self.command) and not any( - tokens[1].startswith(alias) for alias in self.aliases)) - ): - return self.submenu.complete_help( - text, - tokens[1], - begidx - line.index(tokens[1]), - endidx - line.index(tokens[1]), - ) - else: - return original_complete_help(_self, text, line, begidx, endidx) - - if self.create_subclass: - class _Cmd(cmd_obj): - do_help = help_submenu - complete_help = _complete_submenu_help - else: - _Cmd = cmd_obj - _Cmd.do_help = help_submenu - _Cmd.complete_help = _complete_submenu_help - - # Create bindings in the parent command to the submenus commands. - setattr(_Cmd, 'do_' + self.command, enter_submenu) - setattr(_Cmd, 'complete_' + self.command, complete_submenu) - - # Create additional bindings for aliases - for _alias in self.aliases: - setattr(_Cmd, 'do_' + _alias, enter_submenu) - setattr(_Cmd, 'complete_' + _alias, complete_submenu) - return _Cmd - - class Cmd(cmd.Cmd): """An easy but powerful framework for writing line-oriented command interpreters. @@ -589,7 +338,6 @@ class Cmd(cmd.Cmd): # Attributes used to configure the StatementParser, best not to change these at runtime blankLinesAllowed = False multiline_commands = [] - redirector = '>' # for sending output to file shortcuts = {'?': 'help', '!': 'shell', '@': 'load', '@@': '_relative_load'} aliases = dict() terminators = [';'] @@ -654,7 +402,7 @@ class Cmd(cmd.Cmd): pass # If persistent readline history is enabled, then read history from file and register to write to file at exit - if persistent_history_file: + if persistent_history_file and rl_type != RlType.NONE: persistent_history_file = os.path.expanduser(persistent_history_file) try: readline.read_history_file(persistent_history_file) @@ -1400,29 +1148,26 @@ class Cmd(cmd.Cmd): if len(raw_tokens) > 1: - # Build a list of all redirection tokens - all_redirects = constants.REDIRECTION_CHARS + ['>>'] - # Check if there are redirection strings prior to the token being completed seen_pipe = False has_redirection = False for cur_token in raw_tokens[:-1]: - if cur_token in all_redirects: + if cur_token in constants.REDIRECTION_TOKENS: has_redirection = True - if cur_token == '|': + if cur_token == constants.REDIRECTION_PIPE: seen_pipe = True # Get token prior to the one being completed prior_token = raw_tokens[-2] # If a pipe is right before the token being completed, complete a shell command as the piped process - if prior_token == '|': + if prior_token == constants.REDIRECTION_PIPE: return self.shell_cmd_complete(text, line, begidx, endidx) # Otherwise do path completion either as files to redirectors or arguments to the piped process - elif prior_token in all_redirects or seen_pipe: + elif prior_token in constants.REDIRECTION_TOKENS or seen_pipe: return self.path_complete(text, line, begidx, endidx) # If there were redirection strings anywhere on the command line, then we @@ -1544,7 +1289,7 @@ class Cmd(cmd.Cmd): :param text: str - the current word that user is typing :param state: int - non-negative integer """ - if state == 0: + if state == 0 and rl_type != RlType.NONE: unclosed_quote = '' self.set_completion_defaults() @@ -1577,12 +1322,9 @@ class Cmd(cmd.Cmd): if begidx > 0: # Parse the command line - command, args, expanded_line = self.parseline(line) - - # use these lines instead of the one above - # statement = self.command_parser.parse_command_only(line) - # command = statement.command - # expanded_line = statement.command_and_args + statement = self.statement_parser.parse_command_only(line) + command = statement.command + expanded_line = statement.command_and_args # We overwrote line with a properly formatted but fully stripped version # Restore the end spaces since line is only supposed to be lstripped when @@ -1603,8 +1345,7 @@ class Cmd(cmd.Cmd): tokens, raw_tokens = self.tokens_for_completion(line, begidx, endidx) # Either had a parsing error or are trying to complete the command token - # The latter can happen if default_to_shell is True and parseline() allowed - # assumed something like " or ' was a command. + # The latter can happen if " or ' was entered as the command if tokens is None or len(tokens) == 1: self.completion_matches = [] return None @@ -1924,66 +1665,16 @@ class Cmd(cmd.Cmd): def parseline(self, line): """Parse the line into a command name and a string containing the arguments. - NOTE: This is an override of a parent class method. It is only used by other parent class methods. But - we do need to override it here so that the additional shortcuts present in cmd2 get properly expanded for - purposes of tab completion. + NOTE: This is an override of a parent class method. It is only used by other parent class methods. - Used for command tab completion. Returns a tuple containing (command, args, line). - 'command' and 'args' may be None if the line couldn't be parsed. + Different from the parent class method, this ignores self.identchars. :param line: str - line read by readline :return: (str, str, str) - tuple containing (command, args, line) """ - line = line.strip() - - if not line: - # Deal with empty line or all whitespace line - return None, None, line - # Make a copy of aliases so we can edit it - tmp_aliases = list(self.aliases.keys()) - keep_expanding = len(tmp_aliases) > 0 - - # Expand aliases - while keep_expanding: - for cur_alias in tmp_aliases: - keep_expanding = False - - if line == cur_alias or line.startswith(cur_alias + ' '): - line = line.replace(cur_alias, self.aliases[cur_alias], 1) - - # Do not expand the same alias more than once - tmp_aliases.remove(cur_alias) - keep_expanding = len(tmp_aliases) > 0 - break - - # Expand command shortcut to its full command name - for (shortcut, expansion) in self.shortcuts: - if line.startswith(shortcut): - # If the next character after the shortcut isn't a space, then insert one - shortcut_len = len(shortcut) - if len(line) == shortcut_len or line[shortcut_len] != ' ': - expansion += ' ' - - # Expand the shortcut - line = line.replace(shortcut, expansion, 1) - break - - i, n = 0, len(line) - - # If we are allowing shell commands, then allow any character in the command - if self.default_to_shell: - while i < n and line[i] != ' ': - i += 1 - - # Otherwise only allow those in identchars - else: - while i < n and line[i] in self.identchars: - i += 1 - - command, arg = line[:i], line[i:].strip() - - return command, arg, line + statement = self.statement_parser.parse_command_only(line) + return statement.command, statement.args, statement.command_and_args def onecmd_plus_hooks(self, line): """Top-level function called by cmdloop() to handle parsing a line and running the command and all of its hooks. @@ -2125,7 +1816,7 @@ class Cmd(cmd.Cmd): # We want Popen to raise an exception if it fails to open the process. Thus we don't set shell to True. try: - self.pipe_proc = subprocess.Popen(shlex.split(statement.pipe_to), stdin=subproc_stdin) + self.pipe_proc = subprocess.Popen(statement.pipe_to, stdin=subproc_stdin) except Exception as ex: # Restore stdout to what it was and close the pipe self.stdout.close() @@ -2139,24 +1830,30 @@ class Cmd(cmd.Cmd): raise ex elif statement.output: if (not statement.output_to) and (not can_clip): - raise EnvironmentError('Cannot redirect to paste buffer; install ``xclip`` and re-run to enable') + raise EnvironmentError("Cannot redirect to paste buffer; install 'pyperclip' and re-run to enable") self.kept_state = Statekeeper(self, ('stdout',)) self.kept_sys = Statekeeper(sys, ('stdout',)) self.redirecting = True if statement.output_to: + # going to a file mode = 'w' - if statement.output == 2 * self.redirector: + # statement.output can only contain + # REDIRECTION_APPEND or REDIRECTION_OUTPUT + if statement.output == constants.REDIRECTION_APPEND: mode = 'a' sys.stdout = self.stdout = open(os.path.expanduser(statement.output_to), mode) else: + # going to a paste buffer sys.stdout = self.stdout = tempfile.TemporaryFile(mode="w+") - if statement.output == '>>': + if statement.output == constants.REDIRECTION_APPEND: self.poutput(get_paste_buffer()) def _restore_output(self, statement): - """Handles restoring state after output redirection as well as the actual pipe operation if present. + """Handles restoring state after output redirection as well as + the actual pipe operation if present. - :param statement: Statement object which contains the parsed input from the user + :param statement: Statement object which contains the parsed + input from the user """ # If we have redirected output to a file or the clipboard or piped it to a shell command, then restore state if self.kept_state is not None: @@ -2434,23 +2131,26 @@ Usage: Usage: alias [name] | [<name> <value>] name = arglist[0] value = ' '.join(arglist[1:]) - # Check for a valid name - for cur_char in name: - if cur_char not in self.identchars: - self.perror("Alias names can only contain the following characters: {}".format(self.identchars), - traceback_war=False) - return - - # Set the alias - self.aliases[name] = value - self.poutput("Alias {!r} created".format(name)) + # Validate the alias to ensure it doesn't include weird characters + # like terminators, output redirection, or whitespace + valid, invalidchars = self.statement_parser.is_valid_command(name) + if valid: + # Set the alias + self.aliases[name] = value + self.poutput("Alias {!r} created".format(name)) + else: + errmsg = "Aliases can not contain: {}".format(invalidchars) + self.perror(errmsg, traceback_war=False) def complete_alias(self, text, line, begidx, endidx): """ Tab completion for alias """ + alias_names = set(self.aliases.keys()) + visible_commands = set(self.get_visible_commands()) + index_dict = \ { - 1: self.aliases, - 2: self.get_visible_commands() + 1: alias_names, + 2: list(alias_names | visible_commands) } return self.index_based_complete(text, line, begidx, endidx, index_dict, self.path_complete) @@ -2670,9 +2370,12 @@ Usage: Usage: unalias [-a] name [name ...] self.poutput(' %2d. %s\n' % (idx + 1, text)) while True: response = input(prompt) - hlen = readline.get_current_history_length() - if hlen >= 1 and response != '': - readline.remove_history_item(hlen - 1) + + if rl_type != RlType.NONE: + hlen = readline.get_current_history_length() + if hlen >= 1 and response != '': + readline.remove_history_item(hlen - 1) + try: response = int(response) result = fulloptions[response - 1][0] @@ -3014,34 +2717,7 @@ a..b, a:b, a:, ..b items by indices (inclusive) except Exception as e: self.perror('Saving {!r} - {}'.format(args.output_file, e), traceback_war=False) elif args.transcript: - # Make sure echo is on so commands print to standard out - saved_echo = self.echo - self.echo = True - - # Redirect stdout to the transcript file - saved_self_stdout = self.stdout - self.stdout = open(args.transcript, 'w') - - # Run all of the commands in the history with output redirected to transcript and echo on - self.runcmds_plus_hooks(history) - - # Restore stdout to its original state - self.stdout.close() - self.stdout = saved_self_stdout - - # Set echo back to its original state - self.echo = saved_echo - - # Post-process the file to escape un-escaped "/" regex escapes - with open(args.transcript, 'r') as fin: - data = fin.read() - post_processed_data = data.replace('/', '\/') - with open(args.transcript, 'w') as fout: - fout.write(post_processed_data) - - plural = 's' if len(history) > 1 else '' - self.pfeedback('{} command{} and outputs saved to transcript file {!r}'.format(len(history), plural, - args.transcript)) + self._generate_transcript(history, args.transcript) else: # Display the history items retrieved for hi in history: @@ -3050,6 +2726,68 @@ a..b, a:b, a:, ..b items by indices (inclusive) else: self.poutput(hi.pr()) + def _generate_transcript(self, history, transcript_file): + """Generate a transcript file from a given history of commands.""" + # Save the current echo state, and turn it off. We inject commands into the + # output using a different mechanism + saved_echo = self.echo + self.echo = False + + # Redirect stdout to the transcript file + saved_self_stdout = self.stdout + + # The problem with supporting regular expressions in transcripts + # is that they shouldn't be processed in the command, just the output. + # In addition, when we generate a transcript, any slashes in the output + # are not really intended to indicate regular expressions, so they should + # be escaped. + # + # We have to jump through some hoops here in order to catch the commands + # separately from the output and escape the slashes in the output. + transcript = '' + for history_item in history: + # build the command, complete with prompts. When we replay + # the transcript, we look for the prompts to separate + # the command from the output + first = True + command = '' + for line in history_item.splitlines(): + if first: + command += '{}{}\n'.format(self.prompt, line) + first = False + else: + command += '{}{}\n'.format(self.continuation_prompt, line) + transcript += command + # create a new string buffer and set it to stdout to catch the output + # of the command + membuf = io.StringIO() + self.stdout = membuf + # then run the command and let the output go into our buffer + self.onecmd_plus_hooks(history_item) + # rewind the buffer to the beginning + membuf.seek(0) + # get the output out of the buffer + output = membuf.read() + # and add the regex-escaped output to the transcript + transcript += output.replace('/', '\/') + + # Restore stdout to its original state + self.stdout = saved_self_stdout + # Set echo back to its original state + self.echo = saved_echo + + # finally, we can write the transcript out to the file + with open(transcript_file, 'w') as fout: + fout.write(transcript) + + # and let the user know what we did + if len(history) > 1: + plural = 'commands and their outputs' + else: + plural = 'command and its output' + msg = '{} {} saved to transcript file {!r}' + self.pfeedback(msg.format(len(history), plural, transcript_file)) + @with_argument_list def do_edit(self, arglist): """Edit a file in a text editor. diff --git a/cmd2/constants.py b/cmd2/constants.py index 838650e5..b829000f 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -4,9 +4,14 @@ import re -# Used for command parsing, tab completion and word breaks. Do not change. +# Used for command parsing, output redirection, tab completion and word +# breaks. Do not change. QUOTES = ['"', "'"] -REDIRECTION_CHARS = ['|', '>'] +REDIRECTION_PIPE = '|' +REDIRECTION_OUTPUT = '>' +REDIRECTION_APPEND = '>>' +REDIRECTION_CHARS = [REDIRECTION_PIPE, REDIRECTION_OUTPUT] +REDIRECTION_TOKENS = [REDIRECTION_PIPE, REDIRECTION_OUTPUT, REDIRECTION_APPEND] # Regular expression to match ANSI escape codes ANSI_ESCAPE_RE = re.compile(r'\x1b[^m]*m') diff --git a/cmd2/parsing.py b/cmd2/parsing.py index 908e9272..ce15bd38 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -45,7 +45,8 @@ class Statement(str): redirection, if any :type suffix: str or None :var pipe_to: if output was piped to a shell command, the shell command - :type pipe_to: str or None + as a list of tokens + :type pipe_to: list :var output: if output was redirected, the redirection token, i.e. '>>' :type output: str or None :var output_to: if output was redirected, the destination, usually a filename @@ -81,7 +82,7 @@ class Statement(str): return rtn -class StatementParser(): +class StatementParser: """Parse raw text into command components. Shortcuts is a list of tuples with each tuple containing the shortcut and the expansion. @@ -93,7 +94,7 @@ class StatementParser(): multiline_commands=None, aliases=None, shortcuts=None, - ): + ): self.allow_redirection = allow_redirection if terminators is None: self.terminators = [';'] @@ -141,21 +142,74 @@ class StatementParser(): re.DOTALL | re.MULTILINE ) - # aliases have to be a word, so make a regular expression - # that matches the first word in the line. This regex has two - # parts, the first parenthesis enclosed group matches one - # or more non-whitespace characters, and the second group - # matches either a whitespace character or the end of the - # string. We use \A and \Z to ensure we always match the - # beginning and end of a string that may have multiple - # lines - self.command_pattern = re.compile(r'\A(\S+)(\s|\Z)') + # commands have to be a word, so make a regular expression + # that matches the first word in the line. This regex has three + # parts: + # - the '\A\s*' matches the beginning of the string (even + # if contains multiple lines) and gobbles up any leading + # whitespace + # - the first parenthesis enclosed group matches one + # or more non-whitespace characters with a non-greedy match + # (that's what the '+?' part does). The non-greedy match + # ensures that this first group doesn't include anything + # matched by the second group + # - the second parenthesis group must be dynamically created + # because it needs to match either whitespace, something in + # REDIRECTION_CHARS, one of the terminators, or the end of + # the string (\Z matches the end of the string even if it + # contains multiple lines) + # + invalid_command_chars = [] + invalid_command_chars.extend(constants.QUOTES) + invalid_command_chars.extend(constants.REDIRECTION_CHARS) + invalid_command_chars.extend(terminators) + # escape each item so it will for sure get treated as a literal + second_group_items = [re.escape(x) for x in invalid_command_chars] + # add the whitespace and end of string, not escaped because they + # are not literals + second_group_items.extend([r'\s', r'\Z']) + # join them up with a pipe + second_group = '|'.join(second_group_items) + # build the regular expression + expr = r'\A\s*(\S*?)({})'.format(second_group) + self._command_pattern = re.compile(expr) + + def is_valid_command(self, word: str) -> Tuple[bool, str]: + """Determine whether a word is a valid alias. + + Aliases can not include redirection characters, whitespace, + or termination characters. + + If word is not a valid command, return False and a comma + separated string of characters that can not appear in a command. + This string is suitable for inclusion in an error message of your + choice: + + valid, invalidchars = statement_parser.is_valid_command('>') + if not valid: + errmsg = "Aliases can not contain: {}".format(invalidchars) + """ + valid = False + errmsg = 'whitespace, quotes, ' + errchars = [] + errchars.extend(constants.REDIRECTION_CHARS) + errchars.extend(self.terminators) + errmsg += ', '.join([shlex.quote(x) for x in errchars]) + + match = self._command_pattern.search(word) + if match: + if word == match.group(1): + valid = True + errmsg = None + return valid, errmsg def tokenize(self, line: str) -> List[str]: """Lex a string into a list of tokens. Comments are removed, and shortcuts and aliases are expanded. + + Raises ValueError if there are unclosed quotation marks. """ # strip C-style comments @@ -177,6 +231,8 @@ class StatementParser(): """Tokenize the input and parse it into a Statement object, stripping comments, expanding aliases and shortcuts, and extracting output redirection directives. + + Raises ValueError if there are unclosed quotation marks. """ # handle the special case/hardcoded terminator of a blank line @@ -228,12 +284,27 @@ class StatementParser(): argv = tokens tokens = [] + # check for a pipe to a shell process + # if there is a pipe, everything after the pipe needs to be passed + # to the shell, even redirected output + # this allows '(Cmd) say hello | wc > countit.txt' + try: + # find the first pipe if it exists + pipe_pos = tokens.index(constants.REDIRECTION_PIPE) + # save everything after the first pipe as tokens + pipe_to = tokens[pipe_pos+1:] + # remove all the tokens after the pipe + tokens = tokens[:pipe_pos] + except ValueError: + # no pipe in the tokens + pipe_to = None + # check for output redirect output = None output_to = None try: - output_pos = tokens.index('>') - output = '>' + output_pos = tokens.index(constants.REDIRECTION_OUTPUT) + output = constants.REDIRECTION_OUTPUT output_to = ' '.join(tokens[output_pos+1:]) # remove all the tokens after the output redirect tokens = tokens[:output_pos] @@ -241,26 +312,14 @@ class StatementParser(): pass try: - output_pos = tokens.index('>>') - output = '>>' + output_pos = tokens.index(constants.REDIRECTION_APPEND) + output = constants.REDIRECTION_APPEND output_to = ' '.join(tokens[output_pos+1:]) # remove all tokens after the output redirect tokens = tokens[:output_pos] except ValueError: pass - # check for pipes - try: - # find the first pipe if it exists - pipe_pos = tokens.index('|') - # save everything after the first pipe - pipe_to = ' '.join(tokens[pipe_pos+1:]) - # remove all the tokens after the pipe - tokens = tokens[:pipe_pos] - except ValueError: - # no pipe in the tokens - pipe_to = None - if terminator: # whatever is left is the suffix suffix = ' '.join(tokens) @@ -297,16 +356,48 @@ class StatementParser(): return statement def parse_command_only(self, rawinput: str) -> Statement: - """Partially parse input into a Statement object. The command is - identified, and shortcuts and aliases are expanded. + """Partially parse input into a Statement object. + + The command is identified, and shortcuts and aliases are expanded. Terminators, multiline commands, and output redirection are not parsed. + + This method is used by tab completion code and therefore must not + generate an exception if there are unclosed quotes. + + The Statement object returned by this method can at most contained + values in the following attributes: + - raw + - command + - args + + Different from parse(), this method does not remove redundant whitespace + within statement.args. It does however, ensure args does not have leading + or trailing whitespace. """ - # lex the input into a list of tokens - tokens = self.tokenize(rawinput) + # expand shortcuts and aliases + line = self._expand(rawinput) - # parse out the command and everything else - (command, args) = self._command_and_args(tokens) + command = None + args = None + match = self._command_pattern.search(line) + if match: + # we got a match, extract the command + command = match.group(1) + # the match could be an empty string, if so, turn it into none + if not command: + command = None + # the _command_pattern regex is designed to match the spaces + # between command and args with a second match group. Using + # the end of the second match group ensures that args has + # no leading whitespace. The rstrip() makes sure there is + # no trailing whitespace + args = line[match.end(2):].rstrip() + # if the command is none that means the input was either empty + # or something wierd like '>'. args should be None if we couldn't + # parse a command + if not command or not args: + args = None # build the statement # string representation of args must be an empty string instead of @@ -315,7 +406,6 @@ class StatementParser(): statement.raw = rawinput statement.command = command statement.args = args - statement.argv = tokens return statement def _expand(self, line: str) -> str: @@ -329,11 +419,11 @@ class StatementParser(): for cur_alias in tmp_aliases: keep_expanding = False # apply our regex to line - match = self.command_pattern.search(line) + match = self._command_pattern.search(line) if match: # we got a match, extract the command command = match.group(1) - if command == cur_alias: + if command and command == cur_alias: # rebuild line with the expanded alias line = self.aliases[cur_alias] + match.group(2) + line[match.end(2):] tmp_aliases.remove(cur_alias) @@ -342,7 +432,7 @@ class StatementParser(): # expand shortcuts for (shortcut, expansion) in self.shortcuts: - if line.startswith(shortcut): + if line.startswith(shortcut): # If the next character after the shortcut isn't a space, then insert one shortcut_len = len(shortcut) if len(line) == shortcut_len or line[shortcut_len] != ' ': @@ -370,7 +460,7 @@ class StatementParser(): if len(tokens) > 1: args = ' '.join(tokens[1:]) - return (command, args) + return command, args @staticmethod def _comment_replacer(match): @@ -387,7 +477,7 @@ class StatementParser(): # as word breaks when they are in unquoted strings. Each run of punctuation # characters is treated as a single token. - :param initial_tokens: the tokens as parsed by shlex + :param tokens: the tokens as parsed by shlex :return: the punctuated tokens """ punctuation = [] diff --git a/cmd2/rl_utils.py b/cmd2/rl_utils.py index 465fcaea..8ef65d28 100644 --- a/cmd2/rl_utils.py +++ b/cmd2/rl_utils.py @@ -34,11 +34,13 @@ if 'pyreadline' in sys.modules: rl_type = RlType.PYREADLINE elif 'gnureadline' in sys.modules or 'readline' in sys.modules: - rl_type = RlType.GNU + # We don't support libedit + if 'libedit' not in readline.__doc__: + rl_type = RlType.GNU - # Load the readline lib so we can access members of it - import ctypes - readline_lib = ctypes.CDLL(readline.__file__) + # Load the readline lib so we can access members of it + import ctypes + readline_lib = ctypes.CDLL(readline.__file__) def rl_force_redisplay() -> None: diff --git a/docs/freefeatures.rst b/docs/freefeatures.rst index 95ae127c..a03a1d08 100644 --- a/docs/freefeatures.rst +++ b/docs/freefeatures.rst @@ -100,26 +100,8 @@ As in a Unix shell, output of a command can be redirected: - appended to a file with ``>>``, as in ``mycommand args >> filename.txt`` - piped (``|``) as input to operating-system commands, as in ``mycommand args | wc`` - - sent to the paste buffer, ready for the next Copy operation, by - ending with a bare ``>``, as in ``mycommand args >``.. Redirecting - to paste buffer requires software to be installed on the operating - system, pywin32_ on Windows or xclip_ on \*nix. + - sent to the operating system paste buffer, by ending with a bare ``>``, as in ``mycommand args >``. You can even append output to the current contents of the paste buffer by ending your command with ``>>``. -If your application depends on mathematical syntax, ``>`` may be a bad -choice for redirecting output - it will prevent you from using the -greater-than sign in your actual user commands. You can override your -app's value of ``self.redirector`` to use a different string for output redirection:: - - class MyApp(cmd2.Cmd): - redirector = '->' - -:: - - (Cmd) say line1 -> out.txt - (Cmd) say line2 ->-> out.txt - (Cmd) !cat out.txt - line1 - line2 .. note:: @@ -136,8 +118,8 @@ app's value of ``self.redirector`` to use a different string for output redirect arguments after them from the command line arguments accordingly. But output from a command will not be redirected to a file or piped to a shell command. -.. _pywin32: http://sourceforge.net/projects/pywin32/ -.. _xclip: http://www.cyberciti.biz/faq/xclip-linux-insert-files-command-output-intoclipboard/ +If you need to include any of these redirection characters in your command, +you can enclose them in quotation marks, ``mycommand 'with > in the argument'``. Python ====== diff --git a/docs/unfreefeatures.rst b/docs/unfreefeatures.rst index a4776a53..41144c8f 100644 --- a/docs/unfreefeatures.rst +++ b/docs/unfreefeatures.rst @@ -10,13 +10,17 @@ commands whose names are listed in the parameter ``app.multiline_commands``. These commands will be executed only after the user has entered a *terminator*. -By default, the command terminators is +By default, the command terminator is ``;``; replacing or appending to the list ``app.terminators`` allows different terminators. A blank line is *always* considered a command terminator (cannot be overridden). +In multiline commands, output redirection characters +like ``>`` and ``|`` are part of the command +arguments unless they appear after the terminator. + Parsed statements ================= diff --git a/examples/subcommands.py b/examples/subcommands.py index 9bf6c666..3dd2c683 100755 --- a/examples/subcommands.py +++ b/examples/subcommands.py @@ -41,9 +41,6 @@ try: from cmd2.argcomplete_bridge import CompletionFinder from cmd2.argparse_completer import AutoCompleter if __name__ == '__main__': - with open('out.txt', 'a') as f: - f.write('Here 1') - f.flush() completer = CompletionFinder() completer(base_parser, AutoCompleter(base_parser)) except ImportError: diff --git a/examples/submenus.py b/examples/submenus.py deleted file mode 100755 index 27c8cb10..00000000 --- a/examples/submenus.py +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env python -# coding=utf-8 -""" -Create a CLI with a nested command structure as follows. The commands 'second' and 'third' navigate the CLI to the scope -of the submenu. Nesting of the submenus is done with the cmd2.AddSubmenu() decorator. - - (Top Level)----second----->(2nd Level)----third----->(3rd Level) - | | | - ---> say ---> say ---> say -""" -from __future__ import print_function -import sys - -from cmd2 import cmd2 -from IPython import embed - - -class ThirdLevel(cmd2.Cmd): - """To be used as a third level command class. """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.prompt = '3rdLevel ' - self.top_level_attr = None - self.second_level_attr = None - - def do_say(self, line): - print("You called a command in ThirdLevel with '%s'. " - "It has access to top_level_attr: %s " - "and second_level_attr: %s" % (line, self.top_level_attr, self.second_level_attr)) - - def help_say(self): - print("This is a third level submenu (submenu_ab). Options are qwe, asd, zxc") - - def complete_say(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - -@cmd2.AddSubmenu(ThirdLevel(), - command='third', - aliases=('third_alias',), - shared_attributes=dict(second_level_attr='second_level_attr', top_level_attr='top_level_attr')) -class SecondLevel(cmd2.Cmd): - """To be used as a second level command class. """ - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = '2ndLevel ' - self.top_level_attr = None - self.second_level_attr = 987654321 - - def do_ipy(self, arg): - """Enters an interactive IPython shell. - - Run python code from external files with ``run filename.py`` - End with ``Ctrl-D`` (Unix) / ``Ctrl-Z`` (Windows), ``quit()``, '`exit()``. - """ - banner = 'Entering an embedded IPython shell type quit() or <Ctrl>-d to exit ...' - exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0]) - embed(banner1=banner, exit_msg=exit_msg) - - def do_say(self, line): - print("You called a command in SecondLevel with '%s'. " - "It has access to top_level_attr: %s" % (line, self.top_level_attr)) - - def help_say(self): - print("This is a SecondLevel menu. Options are qwe, asd, zxc") - - def complete_say(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - -@cmd2.AddSubmenu(SecondLevel(), - command='second', - aliases=('second_alias',), - shared_attributes=dict(top_level_attr='top_level_attr')) -class TopLevel(cmd2.Cmd): - """To be used as the main / top level command class that will contain other submenus.""" - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = 'TopLevel ' - self.top_level_attr = 123456789 - - def do_ipy(self, arg): - """Enters an interactive IPython shell. - - Run python code from external files with ``run filename.py`` - End with ``Ctrl-D`` (Unix) / ``Ctrl-Z`` (Windows), ``quit()``, '`exit()``. - """ - banner = 'Entering an embedded IPython shell type quit() or <Ctrl>-d to exit ...' - exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0]) - embed(banner1=banner, exit_msg=exit_msg) - - def do_say(self, line): - print("You called a command in TopLevel with '%s'. " - "TopLevel has attribute top_level_attr=%s" % (line, self.top_level_attr)) - - def help_say(self): - print("This is a top level submenu. Options are qwe, asd, zxc") - - def complete_say(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - -if __name__ == '__main__': - - root = TopLevel() - root.cmdloop() - diff --git a/tests/test_argparse.py b/tests/test_argparse.py index 94a7b5ed..f1a2b357 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -119,6 +119,11 @@ def argparse_app(): return app +def test_invalid_syntax(argparse_app, capsys): + run_cmd(argparse_app, 'speak "') + out, err = capsys.readouterr() + assert err == "ERROR: Invalid syntax: No closing quotation\n" + def test_argparse_basic_command(argparse_app): out = run_cmd(argparse_app, 'say hello') assert out == ['hello'] @@ -135,6 +140,14 @@ def test_argparse_with_list_and_empty_doc(argparse_app): out = run_cmd(argparse_app, 'speak -s hello world!') assert out == ['HELLO WORLD!'] +def test_argparse_comment_stripping(argparse_app): + out = run_cmd(argparse_app, 'speak it was /* not */ delicious! # Yuck!') + assert out == ['it was delicious!'] + +def test_argparser_correct_args_with_quotes_and_midline_options(argparse_app): + out = run_cmd(argparse_app, "speak 'This is a' -s test of the emergency broadcast system!") + assert out == ['THIS IS A TEST OF THE EMERGENCY BROADCAST SYSTEM!'] + def test_argparse_quoted_arguments_multiple(argparse_app): out = run_cmd(argparse_app, 'say "hello there" "rick & morty"') assert out == ['hello there rick & morty'] diff --git a/tests/test_bashcompletion.py b/tests/test_bashcompletion.py new file mode 100644 index 00000000..ceae2aa9 --- /dev/null +++ b/tests/test_bashcompletion.py @@ -0,0 +1,232 @@ +# coding=utf-8 +""" +Unit/functional testing for argparse completer in cmd2 + +Copyright 2018 Eric Lin <anselor@gmail.com> +Released under MIT license, see LICENSE file +""" +import os +import pytest +import sys +from typing import List + +from cmd2.argparse_completer import ACArgumentParser, AutoCompleter + + +try: + from cmd2.argcomplete_bridge import CompletionFinder + skip_reason1 = False + skip_reason = '' +except ImportError: + # Don't test if argcomplete isn't present (likely on Windows) + skip_reason1 = True + skip_reason = "argcomplete isn't installed\n" + +skip_reason2 = "TRAVIS" in os.environ and os.environ["TRAVIS"] == "true" +if skip_reason2: + skip_reason += 'These tests cannot run on TRAVIS\n' + +skip_reason3 = sys.platform.startswith('win') +if skip_reason3: + skip_reason = 'argcomplete doesn\'t support Windows' + +skip = skip_reason1 or skip_reason2 or skip_reason3 + +skip_mac = sys.platform.startswith('dar') + + +actors = ['Mark Hamill', 'Harrison Ford', 'Carrie Fisher', 'Alec Guinness', 'Peter Mayhew', + 'Anthony Daniels', 'Adam Driver', 'Daisy Ridley', 'John Boyega', 'Oscar Isaac', + 'Lupita Nyong\'o', 'Andy Serkis', 'Liam Neeson', 'Ewan McGregor', 'Natalie Portman', + 'Jake Lloyd', 'Hayden Christensen', 'Christopher Lee'] + + +def query_actors() -> List[str]: + """Simulating a function that queries and returns a completion values""" + return actors + + +@pytest.fixture +def parser1(): + """creates a argparse object to test completion against""" + ratings_types = ['G', 'PG', 'PG-13', 'R', 'NC-17'] + + def _do_media_movies(self, args) -> None: + if not args.command: + self.do_help('media movies') + else: + print('media movies ' + str(args.__dict__)) + + def _do_media_shows(self, args) -> None: + if not args.command: + self.do_help('media shows') + + if not args.command: + self.do_help('media shows') + else: + print('media shows ' + str(args.__dict__)) + + media_parser = ACArgumentParser(prog='media') + + media_types_subparsers = media_parser.add_subparsers(title='Media Types', dest='type') + + movies_parser = media_types_subparsers.add_parser('movies') + movies_parser.set_defaults(func=_do_media_movies) + + movies_commands_subparsers = movies_parser.add_subparsers(title='Commands', dest='command') + + movies_list_parser = movies_commands_subparsers.add_parser('list') + + movies_list_parser.add_argument('-t', '--title', help='Title Filter') + movies_list_parser.add_argument('-r', '--rating', help='Rating Filter', nargs='+', + choices=ratings_types) + movies_list_parser.add_argument('-d', '--director', help='Director Filter') + movies_list_parser.add_argument('-a', '--actor', help='Actor Filter', action='append') + + movies_add_parser = movies_commands_subparsers.add_parser('add') + movies_add_parser.add_argument('title', help='Movie Title') + movies_add_parser.add_argument('rating', help='Movie Rating', choices=ratings_types) + movies_add_parser.add_argument('-d', '--director', help='Director', nargs=(1, 2), required=True) + movies_add_parser.add_argument('actor', help='Actors', nargs='*') + + movies_commands_subparsers.add_parser('delete') + + shows_parser = media_types_subparsers.add_parser('shows') + shows_parser.set_defaults(func=_do_media_shows) + + shows_commands_subparsers = shows_parser.add_subparsers(title='Commands', dest='command') + + shows_commands_subparsers.add_parser('list') + + return media_parser + + +# noinspection PyShadowingNames +@pytest.mark.skipif(skip, reason=skip_reason) +def test_bash_nocomplete(parser1): + completer = CompletionFinder() + result = completer(parser1, AutoCompleter(parser1)) + assert result is None + + +# save the real os.fdopen +os_fdopen = os.fdopen + + +def my_fdopen(fd, mode, *args): + """mock fdopen that redirects 8 and 9 from argcomplete to stdin/stdout for testing""" + if fd > 7: + return os_fdopen(fd - 7, mode, *args) + return os_fdopen(fd, mode) + + +# noinspection PyShadowingNames +@pytest.mark.skipif(skip, reason=skip_reason) +def test_invalid_ifs(parser1, mock): + completer = CompletionFinder() + + mock.patch.dict(os.environ, {'_ARGCOMPLETE': '1', + '_ARGCOMPLETE_IFS': '\013\013'}) + + mock.patch.object(os, 'fdopen', my_fdopen) + + with pytest.raises(SystemExit): + completer(parser1, AutoCompleter(parser1), exit_method=sys.exit) + + +# noinspection PyShadowingNames +@pytest.mark.skipif(skip or skip_mac, reason=skip_reason) +@pytest.mark.parametrize('comp_line, exp_out, exp_err', [ + ('media ', 'movies\013shows', ''), + ('media mo', 'movies', ''), + ('media movies add ', '\013\013 ', ''' +Hint: + TITLE Movie Title'''), + ('media movies list -a "J', '"John Boyega"\013"Jake Lloyd"', ''), + ('media movies list ', '', '') +]) +def test_commands(parser1, capfd, mock, comp_line, exp_out, exp_err): + completer = CompletionFinder() + + mock.patch.dict(os.environ, {'_ARGCOMPLETE': '1', + '_ARGCOMPLETE_IFS': '\013', + 'COMP_TYPE': '63', + 'COMP_LINE': comp_line, + 'COMP_POINT': str(len(comp_line))}) + + mock.patch.object(os, 'fdopen', my_fdopen) + + with pytest.raises(SystemExit): + choices = {'actor': query_actors, # function + } + autocompleter = AutoCompleter(parser1, arg_choices=choices) + completer(parser1, autocompleter, exit_method=sys.exit) + + out, err = capfd.readouterr() + assert out == exp_out + assert err == exp_err + + +def fdopen_fail_8(fd, mode, *args): + """mock fdopen that forces failure if fd == 8""" + if fd == 8: + raise IOError() + return my_fdopen(fd, mode, *args) + + +# noinspection PyShadowingNames +@pytest.mark.skipif(skip, reason=skip_reason) +def test_fail_alt_stdout(parser1, mock): + completer = CompletionFinder() + + comp_line = 'media movies list ' + mock.patch.dict(os.environ, {'_ARGCOMPLETE': '1', + '_ARGCOMPLETE_IFS': '\013', + 'COMP_TYPE': '63', + 'COMP_LINE': comp_line, + 'COMP_POINT': str(len(comp_line))}) + mock.patch.object(os, 'fdopen', fdopen_fail_8) + + try: + choices = {'actor': query_actors, # function + } + autocompleter = AutoCompleter(parser1, arg_choices=choices) + completer(parser1, autocompleter, exit_method=sys.exit) + except SystemExit as err: + assert err.code == 1 + + +def fdopen_fail_9(fd, mode, *args): + """mock fdopen that forces failure if fd == 9""" + if fd == 9: + raise IOError() + return my_fdopen(fd, mode, *args) + + +# noinspection PyShadowingNames +@pytest.mark.skipif(skip or skip_mac, reason=skip_reason) +def test_fail_alt_stderr(parser1, capfd, mock): + completer = CompletionFinder() + + comp_line = 'media movies add ' + exp_out = '\013\013 ' + exp_err = ''' +Hint: + TITLE Movie Title''' + + mock.patch.dict(os.environ, {'_ARGCOMPLETE': '1', + '_ARGCOMPLETE_IFS': '\013', + 'COMP_TYPE': '63', + 'COMP_LINE': comp_line, + 'COMP_POINT': str(len(comp_line))}) + mock.patch.object(os, 'fdopen', fdopen_fail_9) + + with pytest.raises(SystemExit): + choices = {'actor': query_actors, # function + } + autocompleter = AutoCompleter(parser1, arg_choices=choices) + completer(parser1, autocompleter, exit_method=sys.exit) + + out, err = capfd.readouterr() + assert out == exp_out + assert err == exp_err diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index b6416005..6e4a5a3e 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1430,7 +1430,7 @@ def test_clipboard_failure(capsys): # Make sure we got the error output out, err = capsys.readouterr() assert out == '' - assert 'Cannot redirect to paste buffer; install ``xclip`` and re-run to enable' in err + assert "Cannot redirect to paste buffer; install 'pyperclip' and re-run to enable" in err class CmdResultApp(cmd2.Cmd): @@ -1688,12 +1688,6 @@ def test_alias_lookup_invalid_alias(base_app, capsys): out, err = capsys.readouterr() assert "not found" in err -def test_alias_with_invalid_name(base_app, capsys): - run_cmd(base_app, 'alias @ help') - out, err = capsys.readouterr() - assert "can only contain the following characters" in err - - def test_unalias(base_app): # Create an alias run_cmd(base_app, 'alias fake pyscript') @@ -1711,6 +1705,18 @@ def test_unalias_non_existing(base_app, capsys): out, err = capsys.readouterr() assert "does not exist" in err +@pytest.mark.parametrize('alias_name', [ + '">"', + '"no>pe"', + '"no spaces"', + '"nopipe|"', + '"noterm;"', + 'noembedded"quotes', +]) +def test_create_invalid_alias(base_app, alias_name, capsys): + run_cmd(base_app, 'alias {} help'.format(alias_name)) + out, err = capsys.readouterr() + assert "can not contain" in err def test_ppaged(base_app): msg = 'testing...' @@ -1718,3 +1724,21 @@ def test_ppaged(base_app): base_app.ppaged(msg) out = base_app.stdout.buffer assert out == msg + end + +# we override cmd.parseline() so we always get consistent +# command parsing by parent methods we don't override +# don't need to test all the parsing logic here, because +# parseline just calls StatementParser.parse_command_only() +def test_parseline_empty(base_app): + statement = '' + command, args, line = base_app.parseline(statement) + assert not command + assert not args + assert not line + +def test_parseline(base_app): + statement = " command with 'partially completed quotes " + command, args, line = base_app.parseline(statement) + assert command == 'command' + assert args == "with 'partially completed quotes" + assert line == statement.strip() diff --git a/tests/test_completion.py b/tests/test_completion.py index a027d780..c7650dbb 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -887,7 +887,7 @@ def test_cmd2_subcmd_with_unknown_completion_nomatch(scu_app): assert first_match is None -def test_cmd2_help_subcommand_completion_single(scu_app): +def test_cmd2_help_subcommand_completion_single_scu(scu_app): text = 'base' line = 'help {}'.format(text) endidx = len(line) @@ -895,7 +895,7 @@ def test_cmd2_help_subcommand_completion_single(scu_app): assert scu_app.complete_help(text, line, begidx, endidx) == ['base'] -def test_cmd2_help_subcommand_completion_multiple(scu_app): +def test_cmd2_help_subcommand_completion_multiple_scu(scu_app): text = '' line = 'help base {}'.format(text) endidx = len(line) @@ -905,7 +905,7 @@ def test_cmd2_help_subcommand_completion_multiple(scu_app): assert matches == ['bar', 'foo', 'sport'] -def test_cmd2_help_subcommand_completion_nomatch(scu_app): +def test_cmd2_help_subcommand_completion_nomatch_scu(scu_app): text = 'z' line = 'help base {}'.format(text) endidx = len(line) @@ -913,7 +913,7 @@ def test_cmd2_help_subcommand_completion_nomatch(scu_app): assert scu_app.complete_help(text, line, begidx, endidx) == [] -def test_subcommand_tab_completion(scu_app): +def test_subcommand_tab_completion_scu(scu_app): # This makes sure the correct completer for the sport subcommand is called text = 'Foot' line = 'base sport {}'.format(text) @@ -926,7 +926,7 @@ def test_subcommand_tab_completion(scu_app): assert first_match is not None and scu_app.completion_matches == ['Football '] -def test_subcommand_tab_completion_with_no_completer(scu_app): +def test_subcommand_tab_completion_with_no_completer_scu(scu_app): # This tests what happens when a subcommand has no completer # In this case, the foo subcommand has no completer defined text = 'Foot' @@ -938,7 +938,7 @@ def test_subcommand_tab_completion_with_no_completer(scu_app): assert first_match is None -def test_subcommand_tab_completion_space_in_text(scu_app): +def test_subcommand_tab_completion_space_in_text_scu(scu_app): text = 'B' line = 'base sport "Space {}'.format(text) endidx = len(line) @@ -949,124 +949,3 @@ def test_subcommand_tab_completion_space_in_text(scu_app): assert first_match is not None and \ scu_app.completion_matches == ['Ball" '] and \ scu_app.display_matches == ['Space Ball'] - -#################################################### - - -class SecondLevel(cmd2.Cmd): - """To be used as a second level command class. """ - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = '2ndLevel ' - - def do_foo(self, line): - self.poutput("You called a command in SecondLevel with '%s'. " % line) - - def help_foo(self): - self.poutput("This is a second level menu. Options are qwe, asd, zxc") - - def complete_foo(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - -second_level_cmd = SecondLevel() - - -@cmd2.AddSubmenu(second_level_cmd, - command='second', - require_predefined_shares=False) -class SubmenuApp(cmd2.Cmd): - """To be used as the main / top level command class that will contain other submenus.""" - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = 'TopLevel ' - - -@pytest.fixture -def sb_app(): - app = SubmenuApp() - return app - - -def test_cmd2_submenu_completion_single_end(sb_app): - text = 'f' - line = 'second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, sb_app) - - # It is at end of line, so extra space is present - assert first_match is not None and sb_app.completion_matches == ['foo '] - - -def test_cmd2_submenu_completion_multiple(sb_app): - text = 'e' - line = 'second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - expected = ['edit', 'eof', 'eos'] - first_match = complete_tester(text, line, begidx, endidx, sb_app) - - assert first_match is not None and sb_app.completion_matches == expected - - -def test_cmd2_submenu_completion_nomatch(sb_app): - text = 'z' - line = 'second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, sb_app) - assert first_match is None - - -def test_cmd2_submenu_completion_after_submenu_match(sb_app): - text = 'a' - line = 'second foo {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, sb_app) - assert first_match is not None and sb_app.completion_matches == ['asd '] - - -def test_cmd2_submenu_completion_after_submenu_nomatch(sb_app): - text = 'b' - line = 'second foo {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, sb_app) - assert first_match is None - - -def test_cmd2_help_submenu_completion_multiple(sb_app): - text = 'p' - line = 'help second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - matches = sorted(sb_app.complete_help(text, line, begidx, endidx)) - assert matches == ['py', 'pyscript'] - - -def test_cmd2_help_submenu_completion_nomatch(sb_app): - text = 'fake' - line = 'help second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - assert sb_app.complete_help(text, line, begidx, endidx) == [] - - -def test_cmd2_help_submenu_completion_subcommands(sb_app): - text = 'p' - line = 'help second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - matches = sorted(sb_app.complete_help(text, line, begidx, endidx)) - assert matches == ['py', 'pyscript'] diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 7940bbd8..41966c71 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -16,7 +16,7 @@ from cmd2 import utils def parser(): parser = StatementParser( allow_redirection=True, - terminators=[';'], + terminators=[';', '&'], multiline_commands=['multiline'], aliases={'helpalias': 'help', '42': 'theanswer', @@ -38,12 +38,22 @@ def test_parse_empty_string(parser): ('command /* with some comment */ arg', ['command', 'arg']), ('command arg1 arg2 # comment at the end', ['command', 'arg1', 'arg2']), ('42 arg1 arg2', ['theanswer', 'arg1', 'arg2']), - ('l', ['shell', 'ls', '-al']) + ('l', ['shell', 'ls', '-al']), + ('termbare ; > /tmp/output', ['termbare', ';', '>', '/tmp/output']), + ('termbare; > /tmp/output', ['termbare', ';', '>', '/tmp/output']), + ('termbare & > /tmp/output', ['termbare', '&', '>', '/tmp/output']), + ('termbare& > /tmp/output', ['termbare', '&', '>', '/tmp/output']), + ('help|less', ['help', '|', 'less']), + ('l|less', ['shell', 'ls', '-al', '|', 'less']), ]) def test_tokenize(parser, line, tokens): tokens_to_test = parser.tokenize(line) assert tokens_to_test == tokens +def test_tokenize_unclosed_quotes(parser): + with pytest.raises(ValueError): + _ = parser.tokenize('command with "unclosed quotes') + @pytest.mark.parametrize('tokens,command,args', [ ([], None, None), (['command'], 'command', None), @@ -59,35 +69,45 @@ def test_command_and_args(parser, tokens, command, args): '"one word"', "'one word'", ]) -def test_single_word(parser, line): +def test_parse_single_word(parser, line): statement = parser.parse(line) assert statement.command == line assert not statement.args assert statement.argv == [utils.strip_quotes(line)] -def test_word_plus_terminator(parser): - line = 'termbare;' +@pytest.mark.parametrize('line,terminator', [ + ('termbare;', ';'), + ('termbare ;', ';'), + ('termbare&', '&'), + ('termbare &', '&'), +]) +def test_parse_word_plus_terminator(parser, line, terminator): statement = parser.parse(line) assert statement.command == 'termbare' - assert statement.terminator == ';' + assert statement.terminator == terminator assert statement.argv == ['termbare'] -def test_suffix_after_terminator(parser): - line = 'termbare; suffx' +@pytest.mark.parametrize('line,terminator', [ + ('termbare; suffx', ';'), + ('termbare ;suffx', ';'), + ('termbare& suffx', '&'), + ('termbare &suffx', '&'), +]) +def test_parse_suffix_after_terminator(parser, line, terminator): statement = parser.parse(line) assert statement.command == 'termbare' - assert statement.terminator == ';' + assert statement.terminator == terminator assert statement.suffix == 'suffx' assert statement.argv == ['termbare'] -def test_command_with_args(parser): +def test_parse_command_with_args(parser): line = 'command with args' statement = parser.parse(line) assert statement.command == 'command' assert statement.args == 'with args' assert statement.argv == ['command', 'with', 'args'] -def test_command_with_quoted_args(parser): +def test_parse_command_with_quoted_args(parser): line = 'command with "quoted args" and "some not"' statement = parser.parse(line) assert statement.command == 'command' @@ -103,20 +123,20 @@ def test_parse_command_with_args_terminator_and_suffix(parser): assert statement.suffix == 'and suffix' assert statement.argv == ['command', 'with', 'args', 'and', 'terminator'] -def test_hashcomment(parser): +def test_parse_hashcomment(parser): statement = parser.parse('hi # this is all a comment') assert statement.command == 'hi' assert not statement.args assert statement.argv == ['hi'] -def test_c_comment(parser): +def test_parse_c_comment(parser): statement = parser.parse('hi /* this is | all a comment */') assert statement.command == 'hi' assert not statement.args assert not statement.pipe_to assert statement.argv == ['hi'] -def test_c_comment_empty(parser): +def test_parse_c_comment_empty(parser): statement = parser.parse('/* this is | all a comment */') assert not statement.command assert not statement.args @@ -130,14 +150,18 @@ def test_parse_what_if_quoted_strings_seem_to_start_comments(parser): assert not statement.pipe_to assert statement.argv == ['what', 'if', 'quoted strings /* seem to ', 'start', 'comments?'] -def test_simple_piped(parser): - statement = parser.parse('simple | piped') +@pytest.mark.parametrize('line',[ + 'simple | piped', + 'simple|piped', +]) +def test_parse_simple_pipe(parser, line): + statement = parser.parse(line) assert statement.command == 'simple' assert not statement.args assert statement.argv == ['simple'] - assert statement.pipe_to == 'piped' + assert statement.pipe_to == ['piped'] -def test_double_pipe_is_not_a_pipe(parser): +def test_parse_double_pipe_is_not_a_pipe(parser): line = 'double-pipe || is not a pipe' statement = parser.parse(line) assert statement.command == 'double-pipe' @@ -145,17 +169,30 @@ def test_double_pipe_is_not_a_pipe(parser): assert statement.argv == ['double-pipe', '||', 'is', 'not', 'a', 'pipe'] assert not statement.pipe_to -def test_complex_pipe(parser): - line = 'command with args, terminator;sufx | piped' +def test_parse_complex_pipe(parser): + line = 'command with args, terminator&sufx | piped' statement = parser.parse(line) assert statement.command == 'command' assert statement.args == "with args, terminator" assert statement.argv == ['command', 'with', 'args,', 'terminator'] - assert statement.terminator == ';' + assert statement.terminator == '&' assert statement.suffix == 'sufx' - assert statement.pipe_to == 'piped' + assert statement.pipe_to == ['piped'] + +@pytest.mark.parametrize('line,output', [ + ('help > out.txt', '>'), + ('help>out.txt', '>'), + ('help >> out.txt', '>>'), + ('help>>out.txt', '>>'), +]) +def test_parse_redirect(parser,line, output): + statement = parser.parse(line) + assert statement.command == 'help' + assert not statement.args + assert statement.output == output + assert statement.output_to == 'out.txt' -def test_output_redirect(parser): +def test_parse_redirect_with_args(parser): line = 'output into > afile.txt' statement = parser.parse(line) assert statement.command == 'output' @@ -164,7 +201,7 @@ def test_output_redirect(parser): assert statement.output == '>' assert statement.output_to == 'afile.txt' -def test_output_redirect_with_dash_in_path(parser): +def test_parse_redirect_with_dash_in_path(parser): line = 'output into > python-cmd2/afile.txt' statement = parser.parse(line) assert statement.command == 'output' @@ -173,7 +210,7 @@ def test_output_redirect_with_dash_in_path(parser): assert statement.output == '>' assert statement.output_to == 'python-cmd2/afile.txt' -def test_output_redirect_append(parser): +def test_parse_redirect_append(parser): line = 'output appended to >> /tmp/afile.txt' statement = parser.parse(line) assert statement.command == 'output' @@ -182,7 +219,7 @@ def test_output_redirect_append(parser): assert statement.output == '>>' assert statement.output_to == '/tmp/afile.txt' -def test_pipe_and_redirect(parser): +def test_parse_pipe_and_redirect(parser): line = 'output into;sufx | pipethrume plz > afile.txt' statement = parser.parse(line) assert statement.command == 'output' @@ -190,9 +227,9 @@ def test_pipe_and_redirect(parser): assert statement.argv == ['output', 'into'] assert statement.terminator == ';' assert statement.suffix == 'sufx' - assert statement.pipe_to == 'pipethrume plz' - assert statement.output == '>' - assert statement.output_to == 'afile.txt' + assert statement.pipe_to == ['pipethrume', 'plz', '>', 'afile.txt'] + assert not statement.output + assert not statement.output_to def test_parse_output_to_paste_buffer(parser): line = 'output to paste buffer >> ' @@ -202,9 +239,10 @@ def test_parse_output_to_paste_buffer(parser): assert statement.argv == ['output', 'to', 'paste', 'buffer'] assert statement.output == '>>' -def test_has_redirect_inside_terminator(parser): - """The terminator designates the end of the commmand/arguments portion. If a redirector - occurs before a terminator, then it will be treated as part of the arguments and not as a redirector.""" +def test_parse_redirect_inside_terminator(parser): + """The terminator designates the end of the commmand/arguments portion. + If a redirector occurs before a terminator, then it will be treated as + part of the arguments and not as a redirector.""" line = 'has > inside;' statement = parser.parse(line) assert statement.command == 'has' @@ -221,13 +259,16 @@ def test_parse_unfinished_multiliine_command(parser): assert statement.argv == ['multiline', 'has', '>', 'inside', 'an', 'unfinished', 'command'] assert not statement.terminator -def test_parse_multiline_command_ignores_redirectors_within_it(parser): - line = 'multiline has > inside;' +@pytest.mark.parametrize('line,terminator',[ + ('multiline has > inside;', ';'), + ('multiline has > inside &', '&'), +]) +def test_parse_multiline_command_ignores_redirectors_within_it(parser, line, terminator): statement = parser.parse(line) assert statement.multiline_command == 'multiline' assert statement.args == 'has > inside' assert statement.argv == ['multiline', 'has', '>', 'inside'] - assert statement.terminator == ';' + assert statement.terminator == terminator def test_parse_multiline_with_incomplete_comment(parser): """A terminator within a comment will be ignored and won't terminate a multiline command. @@ -290,6 +331,10 @@ def test_parse_redirect_to_unicode_filename(parser): assert statement.output == '>' assert statement.output_to == 'café' +def test_parse_unclosed_quotes(parser): + with pytest.raises(ValueError): + _ = parser.tokenize("command with 'unclosed quotes") + def test_empty_statement_raises_exception(): app = cmd2.Cmd() with pytest.raises(cmd2.EmptyStatement): @@ -307,12 +352,12 @@ def test_empty_statement_raises_exception(): ('!ls -al /tmp', 'shell', 'ls -al /tmp'), ('l', 'shell', 'ls -al') ]) -def test_alias_and_shortcut_expansion(parser, line, command, args): +def test_parse_alias_and_shortcut_expansion(parser, line, command, args): statement = parser.parse(line) assert statement.command == command assert statement.args == args -def test_alias_on_multiline_command(parser): +def test_parse_alias_on_multiline_command(parser): line = 'anothermultiline has > inside an unfinished command' statement = parser.parse(line) assert statement.multiline_command == 'multiline' @@ -320,12 +365,41 @@ def test_alias_on_multiline_command(parser): assert statement.args == 'has > inside an unfinished command' assert not statement.terminator +@pytest.mark.parametrize('line,output', [ + ('helpalias > out.txt', '>'), + ('helpalias>out.txt', '>'), + ('helpalias >> out.txt', '>>'), + ('helpalias>>out.txt', '>>'), +]) +def test_parse_alias_redirection(parser, line, output): + statement = parser.parse(line) + assert statement.command == 'help' + assert not statement.args + assert statement.output == output + assert statement.output_to == 'out.txt' + +@pytest.mark.parametrize('line', [ + 'helpalias | less', + 'helpalias|less', +]) +def test_parse_alias_pipe(parser, line): + statement = parser.parse(line) + assert statement.command == 'help' + assert not statement.args + assert statement.pipe_to == ['less'] + +def test_parse_alias_terminator_no_whitespace(parser): + line = 'helpalias;' + statement = parser.parse(line) + assert statement.command == 'help' + assert not statement.args + assert statement.terminator == ';' + def test_parse_command_only_command_and_args(parser): line = 'help history' statement = parser.parse_command_only(line) assert statement.command == 'help' assert statement.args == 'history' - assert statement.argv == ['help', 'history'] assert statement.command_and_args == line def test_parse_command_only_emptyline(parser): @@ -345,7 +419,6 @@ def test_parse_command_only_strips_line(parser): statement = parser.parse_command_only(line) assert statement.command == 'help' assert statement.args == 'history' - assert statement.argv == ['help', 'history'] assert statement.command_and_args == line.strip() def test_parse_command_only_expands_alias(parser): @@ -353,14 +426,12 @@ def test_parse_command_only_expands_alias(parser): statement = parser.parse_command_only(line) assert statement.command == 'pyscript' assert statement.args == 'foobar.py' - assert statement.argv == ['pyscript', 'foobar.py'] def test_parse_command_only_expands_shortcuts(parser): line = '!cat foobar.txt' statement = parser.parse_command_only(line) assert statement.command == 'shell' assert statement.args == 'cat foobar.txt' - assert statement.argv == ['shell', 'cat', 'foobar.txt'] assert statement.command_and_args == 'shell cat foobar.txt' def test_parse_command_only_quoted_args(parser): @@ -369,3 +440,27 @@ def test_parse_command_only_quoted_args(parser): assert statement.command == 'shell' assert statement.args == 'ls -al "/tmp/directory with spaces/doit.sh"' assert statement.command_and_args == line.replace('l', 'shell ls -al') + +@pytest.mark.parametrize('line', [ + 'helpalias > out.txt', + 'helpalias>out.txt', + 'helpalias >> out.txt', + 'helpalias>>out.txt', + 'help|less', + 'helpalias;', +]) +def test_parse_command_only_specialchars(parser, line): + statement = parser.parse_command_only(line) + assert statement.command == 'help' + +@pytest.mark.parametrize('line', [ + ';', + '>', + "'", + '"', + '|', +]) +def test_parse_command_only_none(parser, line): + statement = parser.parse_command_only(line) + assert statement.command == None + assert statement.args == None diff --git a/tests/test_submenu.py b/tests/test_submenu.py deleted file mode 100644 index db334daa..00000000 --- a/tests/test_submenu.py +++ /dev/null @@ -1,181 +0,0 @@ -# coding=utf-8 -""" -Cmd2 testing for argument parsing -""" -import pytest - -from cmd2 import cmd2 -from .conftest import run_cmd, StdOut, normalize - - -class SecondLevelB(cmd2.Cmd): - """To be used as a second level command class. """ - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = '2ndLevel B ' - - def do_get_top_level_attr(self, line): - self.poutput(str(self.top_level_attr)) - - def do_set_top_level_attr(self, line): - self.top_level_attr = 987654321 - - -class SecondLevel(cmd2.Cmd): - """To be used as a second level command class. """ - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = '2ndLevel ' - self.top_level_attr = None - - def do_say(self, line): - self.poutput("You called a command in SecondLevel with '%s'. " % line) - - def help_say(self): - self.poutput("This is a second level menu. Options are qwe, asd, zxc") - - def complete_say(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - def do_get_top_level_attr(self, line): - self.poutput(str(self.top_level_attr)) - - def do_get_prompt(self, line): - self.poutput(self.prompt) - - -second_level_cmd = SecondLevel() -second_level_b_cmd = SecondLevelB() - - -@cmd2.AddSubmenu(SecondLevelB(), - command='should_work_with_default_kwargs') -@cmd2.AddSubmenu(second_level_b_cmd, - command='secondb', - shared_attributes=dict(top_level_attr='top_level_attr'), - require_predefined_shares=False, - preserve_shares=True - ) -@cmd2.AddSubmenu(second_level_cmd, - command='second', - aliases=('second_alias',), - shared_attributes=dict(top_level_attr='top_level_attr')) -class SubmenuApp(cmd2.Cmd): - """To be used as the main / top level command class that will contain other submenus.""" - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = 'TopLevel ' - self.top_level_attr = 123456789 - - def do_say(self, line): - self.poutput("You called a command in TopLevel with '%s'. " % line) - - def help_say(self): - self.poutput("This is a top level submenu. Options are qwe, asd, zxc") - - def complete_say(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - -@pytest.fixture -def submenu_app(): - app = SubmenuApp() - app.stdout = StdOut() - second_level_cmd.stdout = StdOut() - second_level_b_cmd.stdout = StdOut() - return app - - -@pytest.fixture -def secondlevel_app(): - app = SecondLevel() - app.stdout = StdOut() - return app - - -@pytest.fixture -def secondlevel_app_b(): - app = SecondLevelB() - app.stdout = StdOut() - return app - - -def run_submenu_cmd(app, second_level_app, cmd): - """ Clear StdOut buffers, run the command, extract the buffer contents.""" - app.stdout.clear() - second_level_app.stdout.clear() - app.onecmd_plus_hooks(cmd) - out1 = app.stdout.buffer - out2 = second_level_app.stdout.buffer - app.stdout.clear() - second_level_app.stdout.clear() - return normalize(out1), normalize(out2) - - -def test_submenu_say_from_top_level(submenu_app): - line = 'testing' - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'say ' + line) - assert len(out1) == 1 - assert len(out2) == 0 - assert out1[0] == "You called a command in TopLevel with {!r}.".format(line) - - -def test_submenu_second_say_from_top_level(submenu_app): - line = 'testing' - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'second say ' + line) - - # No output expected from the top level - assert out1 == [] - - # Output expected from the second level - assert len(out2) == 1 - assert out2[0] == "You called a command in SecondLevel with {!r}.".format(line) - - -def test_submenu_say_from_second_level(secondlevel_app): - line = 'testing' - out = run_cmd(secondlevel_app, 'say ' + line) - assert out == ["You called a command in SecondLevel with '%s'." % line] - - -def test_submenu_help_second_say_from_top_level(submenu_app): - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'help second say') - # No output expected from the top level - assert out1 == [] - - # Output expected from the second level - assert out2 == ["This is a second level menu. Options are qwe, asd, zxc"] - - -def test_submenu_help_say_from_second_level(secondlevel_app): - out = run_cmd(secondlevel_app, 'help say') - assert out == ["This is a second level menu. Options are qwe, asd, zxc"] - - -def test_submenu_help_second(submenu_app): - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'help second') - out3 = run_cmd(second_level_cmd, 'help') - assert out2 == out3 - - -def test_submenu_from_top_help_second_say(submenu_app): - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'help second say') - out3 = run_cmd(second_level_cmd, 'help say') - assert out2 == out3 - - -def test_submenu_shared_attribute(submenu_app): - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'second get_top_level_attr') - assert out2 == [str(submenu_app.top_level_attr)] - - -def test_submenu_shared_attribute_preserve(submenu_app): - out1, out2 = run_submenu_cmd(submenu_app, second_level_b_cmd, 'secondb get_top_level_attr') - assert out2 == [str(submenu_app.top_level_attr)] - out1, out2 = run_submenu_cmd(submenu_app, second_level_b_cmd, 'secondb set_top_level_attr') - assert submenu_app.top_level_attr == 987654321 - out1, out2 = run_submenu_cmd(submenu_app, second_level_b_cmd, 'secondb get_top_level_attr') - assert out2 == [str(987654321)] diff --git a/tests/test_transcript.py b/tests/test_transcript.py index c0fb49c1..70658161 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -10,12 +10,13 @@ import os import sys import re import random +import tempfile from unittest import mock import pytest from cmd2 import cmd2 -from .conftest import run_cmd, StdOut, normalize +from .conftest import run_cmd, StdOut class CmdLineApp(cmd2.Cmd): @@ -26,7 +27,6 @@ class CmdLineApp(cmd2.Cmd): def __init__(self, *args, **kwargs): self.multiline_commands = ['orate'] self.maxrepeats = 3 - self.redirector = '->' # Add stuff to settable and/or shortcuts before calling base class initializer self.settable['maxrepeats'] = 'Max number of `--repeat`s allowed' @@ -63,7 +63,7 @@ class CmdLineApp(cmd2.Cmd): def do_mumble(self, opts, arg): """Mumbles what you tell me to.""" repetitions = opts.repeat or 1 - arg = arg.split() + #arg = arg.split() for i in range(min(repetitions, self.maxrepeats)): output = [] if random.random() < .33: @@ -77,136 +77,6 @@ class CmdLineApp(cmd2.Cmd): self.poutput(' '.join(output)) -class DemoApp(cmd2.Cmd): - hello_parser = argparse.ArgumentParser() - hello_parser.add_argument('-n', '--name', help="your name") - @cmd2.with_argparser_and_unknown_args(hello_parser) - def do_hello(self, opts, arg): - """Says hello.""" - if opts.name: - self.stdout.write('Hello {}\n'.format(opts.name)) - else: - self.stdout.write('Hello Nobody\n') - - -@pytest.fixture -def _cmdline_app(): - c = CmdLineApp() - c.stdout = StdOut() - return c - - -@pytest.fixture -def _demo_app(): - c = DemoApp() - c.stdout = StdOut() - return c - - -def _get_transcript_blocks(transcript): - cmd = None - expected = '' - for line in transcript.splitlines(): - if line.startswith('(Cmd) '): - if cmd is not None: - yield cmd, normalize(expected) - - cmd = line[6:] - expected = '' - else: - expected += line + '\n' - yield cmd, normalize(expected) - - -def test_base_with_transcript(_cmdline_app): - app = _cmdline_app - transcript = """ -(Cmd) help - -Documented commands (type help <topic>): -======================================== -alias help load orate pyscript say shell speak -edit history mumble py quit set shortcuts unalias - -(Cmd) help say -usage: speak [-h] [-p] [-s] [-r REPEAT] - -Repeats what you tell me to. - -optional arguments: - -h, --help show this help message and exit - -p, --piglatin atinLay - -s, --shout N00B EMULATION MODE - -r REPEAT, --repeat REPEAT - output [n] times - -(Cmd) say goodnight, Gracie -goodnight, Gracie -(Cmd) say -ps --repeat=5 goodnight, Gracie -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -(Cmd) set maxrepeats 5 -maxrepeats - was: 3 -now: 5 -(Cmd) say -ps --repeat=5 goodnight, Gracie -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -(Cmd) history --------------------------[1] -help --------------------------[2] -help say --------------------------[3] -say goodnight, Gracie --------------------------[4] -say -ps --repeat=5 goodnight, Gracie --------------------------[5] -set maxrepeats 5 --------------------------[6] -say -ps --repeat=5 goodnight, Gracie -(Cmd) history -r 4 -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -(Cmd) set prompt "---> " -prompt - was: (Cmd) -now: ---> -""" - - for cmd, expected in _get_transcript_blocks(transcript): - out = run_cmd(app, cmd) - assert out == expected - - -class TestMyAppCase(cmd2.Cmd2TestCase): - CmdApp = CmdLineApp - CmdApp.testfiles = ['tests/transcript.txt'] - - -def test_comment_stripping(_cmdline_app): - out = run_cmd(_cmdline_app, 'speak it was /* not */ delicious! # Yuck!') - expected = normalize("""it was delicious!""") - assert out == expected - - -def test_argparser_correct_args_with_quotes_and_midline_options(_cmdline_app): - out = run_cmd(_cmdline_app, "speak 'This is a' -s test of the emergency broadcast system!") - expected = normalize("""THIS IS A TEST OF THE EMERGENCY BROADCAST SYSTEM!""") - assert out == expected - - -def test_argparser_options_with_spaces_in_quotes(_demo_app): - out = run_cmd(_demo_app, "hello foo -n 'Bugs Bunny' bar baz") - expected = normalize("""Hello Bugs Bunny""") - assert out == expected - - def test_commands_at_invocation(): testargs = ["prog", "say hello", "say Gracie", "quit"] expected = "This is an intro banner ...\nhello\nGracie\n" @@ -217,19 +87,12 @@ def test_commands_at_invocation(): out = app.stdout.buffer assert out == expected -def test_invalid_syntax(_cmdline_app, capsys): - run_cmd(_cmdline_app, 'speak "') - out, err = capsys.readouterr() - expected = normalize("""ERROR: Invalid syntax: No closing quotation""") - assert normalize(str(err)) == expected - - -@pytest.mark.parametrize('filename, feedback_to_output', [ +@pytest.mark.parametrize('filename,feedback_to_output', [ ('bol_eol.txt', False), ('characterclass.txt', False), ('dotstar.txt', False), ('extension_notation.txt', False), - # ('from_cmdloop.txt', True), + ('from_cmdloop.txt', True), ('multiline_no_regex.txt', False), ('multiline_regex.txt', False), ('regex_set.txt', False), @@ -237,7 +100,7 @@ def test_invalid_syntax(_cmdline_app, capsys): ('slashes_escaped.txt', False), ('slashslash.txt', False), ('spaces.txt', False), - # ('word_boundaries.txt', False), + ('word_boundaries.txt', False), ]) def test_transcript(request, capsys, filename, feedback_to_output): # Create a cmd2.Cmd() instance and make sure basic settings are @@ -263,6 +126,32 @@ def test_transcript(request, capsys, filename, feedback_to_output): assert err.startswith(expected_start) assert err.endswith(expected_end) +def test_history_transcript(request, capsys): + app = CmdLineApp() + app.stdout = StdOut() + run_cmd(app, 'orate this is\na /multiline/\ncommand;\n') + run_cmd(app, 'speak /tmp/file.txt is not a regex') + + expected = r"""(Cmd) orate this is +> a /multiline/ +> command; +this is a \/multiline\/ command +(Cmd) speak /tmp/file.txt is not a regex +\/tmp\/file.txt is not a regex +""" + + # make a tmp file + fd, history_fname = tempfile.mkstemp(prefix='', suffix='.txt') + os.close(fd) + + # tell the history command to create a transcript + run_cmd(app, 'history -t "{}"'.format(history_fname)) + + # read in the transcript created by the history command + with open(history_fname) as f: + transcript = f.read() + + assert transcript == expected @pytest.mark.parametrize('expected, transformed', [ # strings with zero or one slash or with escaped slashes means no regular diff --git a/tests/transcripts/from_cmdloop.txt b/tests/transcripts/from_cmdloop.txt index 13b61b00..5f22d756 100644 --- a/tests/transcripts/from_cmdloop.txt +++ b/tests/transcripts/from_cmdloop.txt @@ -19,7 +19,6 @@ optional arguments:/ */ -s, --shout N00B EMULATION MODE/ */ -r REPEAT, --repeat REPEAT/ */ output [n] times - (Cmd) say goodnight, Gracie goodnight, Gracie (Cmd) say -ps --repeat=5 goodnight, Gracie @@ -15,6 +15,8 @@ deps = pyperclip pytest pytest-cov + pytest-mock + argcomplete wcwidth commands = py.test {posargs} --cov @@ -25,6 +27,8 @@ deps = mock pyperclip pytest + pytest-mock + argcomplete wcwidth commands = py.test -v @@ -42,6 +46,8 @@ deps = pyperclip pytest pytest-cov + pytest-mock + argcomplete wcwidth commands = py.test {posargs} --cov @@ -62,6 +68,8 @@ commands = deps = pyperclip pytest + pytest-mock + argcomplete wcwidth commands = py.test -v |