summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md2
-rw-r--r--cmd2/argcomplete_bridge.py15
-rwxr-xr-xcmd2/argparse_completer.py4
-rwxr-xr-xcmd2/cmd2.py526
-rw-r--r--cmd2/constants.py9
-rw-r--r--cmd2/parsing.py170
-rw-r--r--cmd2/rl_utils.py10
-rw-r--r--docs/freefeatures.rst24
-rw-r--r--docs/unfreefeatures.rst6
-rwxr-xr-xexamples/subcommands.py3
-rwxr-xr-xexamples/submenus.py109
-rw-r--r--tests/test_argparse.py13
-rw-r--r--tests/test_bashcompletion.py232
-rw-r--r--tests/test_cmd2.py38
-rw-r--r--tests/test_completion.py133
-rw-r--r--tests/test_parsing.py177
-rw-r--r--tests/test_submenu.py181
-rw-r--r--tests/test_transcript.py175
-rw-r--r--tests/transcripts/from_cmdloop.txt1
-rw-r--r--tox.ini8
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
diff --git a/tox.ini b/tox.ini
index e74ce16f..c7ccdeac 100644
--- a/tox.ini
+++ b/tox.ini
@@ -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