summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKevin Van Brunt <kmvanbrunt@gmail.com>2019-05-13 23:59:03 -0400
committerGitHub <noreply@github.com>2019-05-13 23:59:03 -0400
commit3ee97d121887d3055fc6326b1d9bc290f5235866 (patch)
treef5695aece2c4e6173513da3f436df73099b88c09
parentcbf0313306c99c02f3c503f60d70df4bda2cce64 (diff)
parent6c051808d83b75108c0549acbc97fe2201f8de63 (diff)
downloadcmd2-git-3ee97d121887d3055fc6326b1d9bc290f5235866.tar.gz
Merge pull request #676 from python-cmd2/pipe_chaining
Pipe chaining
-rw-r--r--.travis.yml2
-rw-r--r--CHANGELOG.md7
-rw-r--r--cmd2/argparse_completer.py6
-rw-r--r--cmd2/cmd2.py106
-rw-r--r--cmd2/parsing.py87
-rw-r--r--cmd2/utils.py30
-rw-r--r--tasks.py2
-rw-r--r--tests/conftest.py12
-rw-r--r--tests/test_cmd2.py18
-rw-r--r--tests/test_completion.py45
-rw-r--r--tests/test_parsing.py161
11 files changed, 346 insertions, 130 deletions
diff --git a/.travis.yml b/.travis.yml
index fda08075..fc9c65bd 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -46,7 +46,7 @@ before_script:
# stop the build if there are Python syntax errors or undefined names
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
if [[ $TOXENV == py37 ]]; then
- flake8 . --count --ignore=E252 --max-complexity=31 --max-line-length=127 --show-source --statistics ;
+ flake8 . --count --ignore=E252,W503 --max-complexity=31 --max-line-length=127 --show-source --statistics ;
fi
script:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 26dd2041..a6f56821 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,11 @@
* History now shows what was typed for macros and not the resolved value by default. This is consistent with
the behavior of aliases. Use the `expanded` or `verbose` arguments to `history` to see the resolved value for
the macro.
+ * Fixed parsing issue in case where output redirection appears before a pipe. In that case, the pipe was given
+ precedence even though it appeared later in the command.
+ * Fixed issue where quotes around redirection file paths were being lost in `Statement.expanded_command_line()`
* Enhancements
+ * Added capability to chain pipe commands and redirect their output (e.g. !ls -l | grep user | wc -l > out.txt)
* `pyscript` limits a command's stdout capture to the same period that redirection does.
Therefore output from a command's postparsing and finalization hooks isn't saved in the StdSim object.
* `StdSim.buffer.write()` now flushes when the wrapped stream uses line buffering and the bytes being written
@@ -18,6 +22,7 @@
* Potentially breaking changes
* Replaced `unquote_redirection_tokens()` with `unquote_specific_tokens()`. This was to support the fix
that allows terminators in alias and macro values.
+ * Changed `Statement.pipe_to` to a string instead of a list
* **Python 3.4 EOL notice**
* Python 3.4 reached its [end of life](https://www.python.org/dev/peps/pep-0429/) on March 18, 2019
* This is the last release of `cmd2` which will support Python 3.4
@@ -87,7 +92,7 @@
sorted the ``CompletionItem`` list. Otherwise it will be sorted using ``self.matches_sort_key``.
* Removed support for bash completion since this feature had slow performance. Also it relied on
``AutoCompleter`` which has since developed a dependency on ``cmd2`` methods.
- * Removed ability to call commands in ``pyscript`` as if they were functions (e.g ``app.help()``) in favor
+ * Removed ability to call commands in ``pyscript`` as if they were functions (e.g. ``app.help()``) in favor
of only supporting one ``pyscript`` interface. This simplifies future maintenance.
* No longer supporting C-style comments. Hash (#) is the only valid comment marker.
* No longer supporting comments embedded in a command. Only command line input where the first
diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py
index edfaeec4..feff4835 100644
--- a/cmd2/argparse_completer.py
+++ b/cmd2/argparse_completer.py
@@ -999,9 +999,7 @@ class ACArgumentParser(argparse.ArgumentParser):
linum += 1
self.print_usage(sys.stderr)
- sys.stderr.write(Fore.LIGHTRED_EX + '{}\n'.format(formatted_message) + Fore.RESET)
-
- sys.exit(1)
+ self.exit(2, Fore.LIGHTRED_EX + '{}\n\n'.format(formatted_message) + Fore.RESET)
def format_help(self) -> str:
"""Copy of format_help() from argparse.ArgumentParser with tweaks to separately display required parameters"""
@@ -1051,7 +1049,7 @@ class ACArgumentParser(argparse.ArgumentParser):
formatter.add_text(self.epilog)
# determine help from format above
- return formatter.format_help()
+ return formatter.format_help() + '\n'
def _get_nargs_pattern(self, action) -> str:
# Override _get_nargs_pattern behavior to use the nargs ranges provided by AutoCompleter
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index 431c51ae..c29a1812 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -1519,18 +1519,19 @@ class Cmd(cmd.Cmd):
# Check if any portion of the display matches appears in the tab completion
display_prefix = os.path.commonprefix(self.display_matches)
- # For delimited matches, we check what appears before the display
- # matches (common_prefix) as well as the display matches themselves.
- if (' ' in common_prefix) or (display_prefix and ' ' in ''.join(self.display_matches)):
+ # For delimited matches, we check for a space in what appears before the display
+ # matches (common_prefix) as well as in the display matches themselves.
+ if ' ' in common_prefix or (display_prefix
+ and any(' ' in match for match in self.display_matches)):
add_quote = True
# If there is a tab completion and any match has a space, then add an opening quote
- elif common_prefix and ' ' in ''.join(self.completion_matches):
+ elif common_prefix and any(' ' in match for match in self.completion_matches):
add_quote = True
if add_quote:
# Figure out what kind of quote to add and save it as the unclosed_quote
- if '"' in ''.join(self.completion_matches):
+ if any('"' in match for match in self.completion_matches):
unclosed_quote = "'"
else:
unclosed_quote = '"'
@@ -1540,7 +1541,7 @@ class Cmd(cmd.Cmd):
# Check if we need to remove text from the beginning of tab completions
elif text_to_remove:
self.completion_matches = \
- [m.replace(text_to_remove, '', 1) for m in self.completion_matches]
+ [match.replace(text_to_remove, '', 1) for match in self.completion_matches]
# Check if we need to restore a shortcut in the tab completions
# so it doesn't get erased from the command line
@@ -2027,35 +2028,44 @@ class Cmd(cmd.Cmd):
subproc_stdin = io.open(read_fd, 'r')
new_stdout = io.open(write_fd, 'w')
- # We want Popen to raise an exception if it fails to open the process. Thus we don't set shell to True.
+ # Set options to not forward signals to the pipe process. If a Ctrl-C event occurs,
+ # our sigint handler will forward it only to the most recent pipe process. This makes
+ # sure pipe processes close in the right order (most recent first).
+ if sys.platform == 'win32':
+ creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
+ start_new_session = False
+ else:
+ creationflags = 0
+ start_new_session = True
+
+ # For any stream that is a StdSim, we will use a pipe so we can capture its output
+ proc = subprocess.Popen(statement.pipe_to,
+ stdin=subproc_stdin,
+ stdout=subprocess.PIPE if isinstance(self.stdout, utils.StdSim) else self.stdout,
+ stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr,
+ creationflags=creationflags,
+ start_new_session=start_new_session,
+ shell=True)
+
+ # Popen was called with shell=True so the user can chain pipe commands and redirect their output
+ # like: !ls -l | grep user | wc -l > out.txt. But this makes it difficult to know if the pipe process
+ # started OK, since the shell itself always starts. Therefore, we will wait a short time and check
+ # if the pipe process is still running.
try:
- # Set options to not forward signals to the pipe process. If a Ctrl-C event occurs,
- # our sigint handler will forward it only to the most recent pipe process. This makes
- # sure pipe processes close in the right order (most recent first).
- if sys.platform == 'win32':
- creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
- start_new_session = False
- else:
- creationflags = 0
- start_new_session = True
-
- # For any stream that is a StdSim, we will use a pipe so we can capture its output
- proc = \
- subprocess.Popen(statement.pipe_to,
- stdin=subproc_stdin,
- stdout=subprocess.PIPE if isinstance(self.stdout, utils.StdSim) else self.stdout,
- stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr,
- creationflags=creationflags,
- start_new_session=start_new_session)
+ proc.wait(0.2)
+ except subprocess.TimeoutExpired:
+ pass
- saved_state.redirecting = True
- saved_state.pipe_proc_reader = utils.ProcReader(proc, self.stdout, sys.stderr)
- sys.stdout = self.stdout = new_stdout
- except Exception as ex:
- self.perror('Failed to open pipe because - {}'.format(ex), traceback_war=False)
+ # Check if the pipe process already exited
+ if proc.returncode is not None:
+ self.perror('Pipe process exited with code {} before command could run'.format(proc.returncode))
subproc_stdin.close()
new_stdout.close()
redir_error = True
+ else:
+ saved_state.redirecting = True
+ saved_state.pipe_proc_reader = utils.ProcReader(proc, self.stdout, sys.stderr)
+ sys.stdout = self.stdout = new_stdout
elif statement.output:
import tempfile
@@ -2072,7 +2082,7 @@ class Cmd(cmd.Cmd):
if statement.output == constants.REDIRECTION_APPEND:
mode = 'a'
try:
- new_stdout = open(statement.output_to, mode)
+ new_stdout = open(utils.strip_quotes(statement.output_to), mode)
saved_state.redirecting = True
sys.stdout = self.stdout = new_stdout
except OSError as ex:
@@ -3021,21 +3031,8 @@ class Cmd(cmd.Cmd):
# Create a list of arguments to shell
tokens = [args.command] + args.command_args
- # Support expanding ~ in quoted paths
- for index, _ in enumerate(tokens):
- if tokens[index]:
- # Check if the token is quoted. Since parsing already passed, there isn't
- # an unclosed quote. So we only need to check the first character.
- first_char = tokens[index][0]
- if first_char in constants.QUOTES:
- tokens[index] = utils.strip_quotes(tokens[index])
-
- tokens[index] = os.path.expanduser(tokens[index])
-
- # Restore the quotes
- if first_char in constants.QUOTES:
- tokens[index] = first_char + tokens[index] + first_char
-
+ # Expand ~ where needed
+ utils.expand_user_in_tokens(tokens)
expanded_command = ' '.join(tokens)
# Prevent KeyboardInterrupts while in the shell process. The shell process will
@@ -3334,18 +3331,21 @@ class Cmd(cmd.Cmd):
help='output commands to a script file, implies -s'),
ACTION_ARG_CHOICES, ('path_complete',))
setattr(history_action_group.add_argument('-t', '--transcript',
- help='output commands and results to a transcript file, implies -s'),
+ help='output commands and results to a transcript file,\n'
+ 'implies -s'),
ACTION_ARG_CHOICES, ('path_complete',))
history_action_group.add_argument('-c', '--clear', action='store_true', help='clear all history')
history_format_group = history_parser.add_argument_group(title='formatting')
- history_script_help = 'output commands in script format, i.e. without command numbers'
- history_format_group.add_argument('-s', '--script', action='store_true', help=history_script_help)
- history_expand_help = 'output expanded commands instead of entered command'
- history_format_group.add_argument('-x', '--expanded', action='store_true', help=history_expand_help)
+ history_format_group.add_argument('-s', '--script', action='store_true',
+ help='output commands in script format, i.e. without command\n'
+ 'numbers')
+ history_format_group.add_argument('-x', '--expanded', action='store_true',
+ help='output fully parsed commands with any aliases and\n'
+ 'macros expanded, instead of typed commands')
history_format_group.add_argument('-v', '--verbose', action='store_true',
- help='display history and include expanded commands if they'
- ' differ from the typed command')
+ help='display history and include expanded commands if they\n'
+ 'differ from the typed command')
history_arg_help = ("empty all history items\n"
"a one history item by number\n"
diff --git a/cmd2/parsing.py b/cmd2/parsing.py
index 934f1d26..8febd270 100644
--- a/cmd2/parsing.py
+++ b/cmd2/parsing.py
@@ -2,7 +2,6 @@
# -*- coding: utf-8 -*-
"""Statement parsing classes for cmd2"""
-import os
import re
import shlex
from typing import Dict, Iterable, List, Optional, Tuple, Union
@@ -160,13 +159,13 @@ class Statement(str):
# characters appearing after the terminator but before output redirection, if any
suffix = attr.ib(default='', validator=attr.validators.instance_of(str))
- # if output was piped to a shell command, the shell command as a list of tokens
- pipe_to = attr.ib(default=attr.Factory(list), validator=attr.validators.instance_of(list))
+ # if output was piped to a shell command, the shell command as a string
+ pipe_to = attr.ib(default='', validator=attr.validators.instance_of(str))
# if output was redirected, the redirection token, i.e. '>>'
output = attr.ib(default='', validator=attr.validators.instance_of(str))
- # if output was redirected, the destination file
+ # if output was redirected, the destination file token (quotes preserved)
output_to = attr.ib(default='', validator=attr.validators.instance_of(str))
def __new__(cls, value: object, *pos_args, **kw_args):
@@ -208,7 +207,7 @@ class Statement(str):
rtn += ' ' + self.suffix
if self.pipe_to:
- rtn += ' | ' + ' '.join(self.pipe_to)
+ rtn += ' | ' + self.pipe_to
if self.output:
rtn += ' ' + self.output
@@ -453,56 +452,56 @@ class StatementParser:
arg_list = tokens[1:]
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:]
+ pipe_to = ''
+ output = ''
+ output_to = ''
- for pos, cur_token in enumerate(pipe_to):
- unquoted_token = utils.strip_quotes(cur_token)
- pipe_to[pos] = os.path.expanduser(unquoted_token)
+ # Find which redirector character appears first in the command
+ try:
+ pipe_index = tokens.index(constants.REDIRECTION_PIPE)
+ except ValueError:
+ pipe_index = len(tokens)
- # remove all the tokens after the pipe
- tokens = tokens[:pipe_pos]
+ try:
+ redir_index = tokens.index(constants.REDIRECTION_OUTPUT)
except ValueError:
- # no pipe in the tokens
- pipe_to = []
+ redir_index = len(tokens)
- # check for output redirect
- output = ''
- output_to = ''
try:
- output_pos = tokens.index(constants.REDIRECTION_OUTPUT)
- output = constants.REDIRECTION_OUTPUT
+ append_index = tokens.index(constants.REDIRECTION_APPEND)
+ except ValueError:
+ append_index = len(tokens)
- # Check if we are redirecting to a file
- if len(tokens) > output_pos + 1:
- unquoted_path = utils.strip_quotes(tokens[output_pos + 1])
- output_to = os.path.expanduser(unquoted_path)
+ # Check if output should be piped to a shell command
+ if pipe_index < redir_index and pipe_index < append_index:
- # remove all the tokens after the output redirect
- tokens = tokens[:output_pos]
- except ValueError:
- pass
+ # Get the tokens for the pipe command and expand ~ where needed
+ pipe_to_tokens = tokens[pipe_index + 1:]
+ utils.expand_user_in_tokens(pipe_to_tokens)
- try:
- output_pos = tokens.index(constants.REDIRECTION_APPEND)
- output = constants.REDIRECTION_APPEND
+ # Build the pipe command line string
+ pipe_to = ' '.join(pipe_to_tokens)
+
+ # remove all the tokens after the pipe
+ tokens = tokens[:pipe_index]
+
+ # Check for output redirect/append
+ elif redir_index != append_index:
+ if redir_index < append_index:
+ output = constants.REDIRECTION_OUTPUT
+ output_index = redir_index
+ else:
+ output = constants.REDIRECTION_APPEND
+ output_index = append_index
# Check if we are redirecting to a file
- if len(tokens) > output_pos + 1:
- unquoted_path = utils.strip_quotes(tokens[output_pos + 1])
- output_to = os.path.expanduser(unquoted_path)
+ if len(tokens) > output_index + 1:
+ unquoted_path = utils.strip_quotes(tokens[output_index + 1])
+ if unquoted_path:
+ output_to = utils.expand_user(tokens[output_index + 1])
- # remove all tokens after the output redirect
- tokens = tokens[:output_pos]
- except ValueError:
- pass
+ # remove all the tokens after the output redirect
+ tokens = tokens[:output_index]
if terminator:
# whatever is left is the suffix
diff --git a/cmd2/utils.py b/cmd2/utils.py
index e8e8a611..54ad763d 100644
--- a/cmd2/utils.py
+++ b/cmd2/utils.py
@@ -275,6 +275,36 @@ def unquote_specific_tokens(args: List[str], tokens_to_unquote: List[str]) -> No
args[i] = unquoted_arg
+def expand_user(token: str) -> str:
+ """
+ Wrap os.expanduser() to support expanding ~ in quoted strings
+ :param token: the string to expand
+ """
+ if token:
+ if is_quoted(token):
+ quote_char = token[0]
+ token = strip_quotes(token)
+ else:
+ quote_char = ''
+
+ token = os.path.expanduser(token)
+
+ # Restore the quotes even if not needed to preserve what the user typed
+ if quote_char:
+ token = quote_char + token + quote_char
+
+ return token
+
+
+def expand_user_in_tokens(tokens: List[str]) -> None:
+ """
+ Call expand_user() on all tokens in a list of strings
+ :param tokens: tokens to expand
+ """
+ for index, _ in enumerate(tokens):
+ tokens[index] = expand_user(tokens[index])
+
+
def find_editor() -> str:
"""Find a reasonable editor to use by default for the system that the cmd2 application is running on."""
editor = os.environ.get('EDITOR')
diff --git a/tasks.py b/tasks.py
index d5f5976b..4d5a5b64 100644
--- a/tasks.py
+++ b/tasks.py
@@ -233,5 +233,5 @@ namespace.add_task(pypi_test)
@invoke.task
def flake8(context):
"Run flake8 linter and tool for style guide enforcement"
- context.run("flake8 --ignore=E252 --max-complexity=31 --max-line-length=127 --show-source --statistics")
+ context.run("flake8 --ignore=E252,W503 --max-complexity=31 --max-line-length=127 --show-source --statistics")
namespace.add_task(flake8)
diff --git a/tests/conftest.py b/tests/conftest.py
index 56538718..9d55eb4d 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -77,13 +77,17 @@ optional arguments:
-o, --output-file FILE
output commands to a script file, implies -s
-t, --transcript TRANSCRIPT
- output commands and results to a transcript file, implies -s
+ output commands and results to a transcript file,
+ implies -s
-c, --clear clear all history
formatting:
- -s, --script output commands in script format, i.e. without command numbers
- -x, --expanded output expanded commands instead of entered command
- -v, --verbose display history and include expanded commands if they differ from the typed command
+ -s, --script output commands in script format, i.e. without command
+ numbers
+ -x, --expanded output fully parsed commands with any aliases and
+ macros expanded, instead of typed commands
+ -v, --verbose display history and include expanded commands if they
+ differ from the typed command
"""
diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py
index 5f6af8c5..7a17cfac 100644
--- a/tests/test_cmd2.py
+++ b/tests/test_cmd2.py
@@ -576,11 +576,26 @@ def test_pipe_to_shell(base_app):
out, err = run_cmd(base_app, command)
assert out and not err
+def test_pipe_to_shell_and_redirect(base_app):
+ filename = 'out.txt'
+ if sys.platform == "win32":
+ # Windows
+ command = 'help | sort > {}'.format(filename)
+ else:
+ # Mac and Linux
+ # Get help on help and pipe it's output to the input of the word count shell command
+ command = 'help help | wc > {}'.format(filename)
+
+ out, err = run_cmd(base_app, command)
+ assert not out and not err
+ assert os.path.exists(filename)
+ os.remove(filename)
+
def test_pipe_to_shell_error(base_app):
# Try to pipe command output to a shell command that doesn't exist in order to produce an error
out, err = run_cmd(base_app, 'help | foobarbaz.this_does_not_exist')
assert not out
- assert "Failed to open pipe because" in err[0]
+ assert "Pipe process exited with code" in err[0]
@pytest.mark.skipif(not clipboard.can_clip,
reason="Pyperclip could not find a copy/paste mechanism for your system")
@@ -1713,7 +1728,6 @@ def test_macro_create_with_alias_name(base_app):
assert "Macro cannot have the same name as an alias" in err[0]
def test_macro_create_with_command_name(base_app):
- macro = "my_macro"
out, err = run_cmd(base_app, 'macro create help stuff')
assert "Macro cannot have the same name as a command" in err[0]
diff --git a/tests/test_completion.py b/tests/test_completion.py
index 158856ec..6fd45ff9 100644
--- a/tests/test_completion.py
+++ b/tests/test_completion.py
@@ -20,7 +20,7 @@ from .conftest import base_app, complete_tester, normalize, run_cmd
from examples.subcommands import SubcommandsExample
# List of strings used with completion functions
-food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato']
+food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato', 'Cheese "Pizza"']
sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball']
delimited_strs = \
[
@@ -83,6 +83,17 @@ class CompletionsExample(cmd2.Cmd):
def complete_test_raise_exception(self, text, line, begidx, endidx):
raise IndexError("You are out of bounds!!")
+ def do_test_no_completer(self, args):
+ """Completing this should result in completedefault() being called"""
+ pass
+
+ def completedefault(self, *ignored):
+ """Method called to complete an input line when no command-specific
+ complete_*() method is available.
+
+ """
+ return ['default']
+
@pytest.fixture
def cmd2_app():
@@ -123,8 +134,9 @@ def test_complete_bogus_command(cmd2_app):
endidx = len(line)
begidx = endidx - len(text)
+ expected = ['default ']
first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is None
+ assert first_match is not None and cmd2_app.completion_matches == expected
def test_complete_exception(cmd2_app, capsys):
text = ''
@@ -737,6 +749,16 @@ def test_add_opening_quote_basic_quote_added(cmd2_app):
first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
assert first_match is not None and cmd2_app.completion_matches == expected
+def test_add_opening_quote_basic_single_quote_added(cmd2_app):
+ text = 'Ch'
+ line = 'test_basic {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ expected = ["'Cheese \"Pizza\"' "]
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ assert first_match is not None and cmd2_app.completion_matches == expected
+
def test_add_opening_quote_basic_text_is_common_prefix(cmd2_app):
# This tests when the text entered is the same as the common prefix of the matches
text = 'Ham'
@@ -816,6 +838,25 @@ def test_add_opening_quote_delimited_space_in_prefix(cmd2_app):
os.path.commonprefix(cmd2_app.completion_matches) == expected_common_prefix and \
cmd2_app.display_matches == expected_display
+def test_no_completer(cmd2_app):
+ text = ''
+ line = 'test_no_completer {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ expected = ['default ']
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ assert first_match is not None and cmd2_app.completion_matches == expected
+
+def test_quote_as_command(cmd2_app):
+ text = ''
+ line = '" {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ assert first_match is None and not cmd2_app.completion_matches
+
@pytest.fixture
def sc_app():
c = SubcommandsExample()
diff --git a/tests/test_parsing.py b/tests/test_parsing.py
index 09215804..de8d67af 100644
--- a/tests/test_parsing.py
+++ b/tests/test_parsing.py
@@ -11,7 +11,6 @@ import pytest
import cmd2
from cmd2 import constants, utils
-from cmd2.constants import MULTILINE_TERMINATOR
from cmd2.parsing import StatementParser, shlex_split
@pytest.fixture
@@ -46,7 +45,7 @@ def test_parse_empty_string(parser):
assert statement.multiline_command == ''
assert statement.terminator == ''
assert statement.suffix == ''
- assert statement.pipe_to == []
+ assert statement.pipe_to == ''
assert statement.output == ''
assert statement.output_to == ''
assert statement.command_and_args == line
@@ -63,7 +62,7 @@ def test_parse_empty_string_default(default_parser):
assert statement.multiline_command == ''
assert statement.terminator == ''
assert statement.suffix == ''
- assert statement.pipe_to == []
+ assert statement.pipe_to == ''
assert statement.output == ''
assert statement.output_to == ''
assert statement.command_and_args == line
@@ -130,7 +129,7 @@ def test_parse_single_word(parser, line):
assert statement.multiline_command == ''
assert statement.terminator == ''
assert statement.suffix == ''
- assert statement.pipe_to == []
+ assert statement.pipe_to == ''
assert statement.output == ''
assert statement.output_to == ''
assert statement.command_and_args == line
@@ -224,8 +223,8 @@ def test_parse_simple_pipe(parser, line):
assert statement.args == statement
assert statement.argv == ['simple']
assert not statement.arg_list
- assert statement.pipe_to == ['piped']
- assert statement.expanded_command_line == statement.command + ' | ' + ' '.join(statement.pipe_to)
+ assert statement.pipe_to == 'piped'
+ assert statement.expanded_command_line == statement.command + ' | ' + statement.pipe_to
def test_parse_double_pipe_is_not_a_pipe(parser):
line = 'double-pipe || is not a pipe'
@@ -247,7 +246,7 @@ def test_parse_complex_pipe(parser):
assert statement.arg_list == statement.argv[1:]
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', '>'),
@@ -297,7 +296,7 @@ def test_parse_redirect_append(parser):
assert statement.output == '>>'
assert statement.output_to == '/tmp/afile.txt'
-def test_parse_pipe_and_redirect(parser):
+def test_parse_pipe_then_redirect(parser):
line = 'output into;sufx | pipethrume plz > afile.txt'
statement = parser.parse(line)
assert statement.command == 'output'
@@ -307,10 +306,136 @@ def test_parse_pipe_and_redirect(parser):
assert statement.arg_list == statement.argv[1:]
assert statement.terminator == ';'
assert statement.suffix == 'sufx'
- assert statement.pipe_to == ['pipethrume', 'plz', '>', 'afile.txt']
+ assert statement.pipe_to == 'pipethrume plz > afile.txt'
assert statement.output == ''
assert statement.output_to == ''
+def test_parse_multiple_pipes(parser):
+ line = 'output into;sufx | pipethrume plz | grep blah'
+ statement = parser.parse(line)
+ assert statement.command == 'output'
+ assert statement == 'into'
+ assert statement.args == statement
+ assert statement.argv == ['output', 'into']
+ assert statement.arg_list == statement.argv[1:]
+ assert statement.terminator == ';'
+ assert statement.suffix == 'sufx'
+ assert statement.pipe_to == 'pipethrume plz | grep blah'
+ assert statement.output == ''
+ assert statement.output_to == ''
+
+def test_redirect_then_pipe(parser):
+ line = 'help alias > file.txt | grep blah'
+ statement = parser.parse(line)
+ assert statement.command == 'help'
+ assert statement == 'alias'
+ assert statement.args == statement
+ assert statement.argv == ['help', 'alias']
+ assert statement.arg_list == statement.argv[1:]
+ assert statement.terminator == ''
+ assert statement.suffix == ''
+ assert statement.pipe_to == ''
+ assert statement.output == '>'
+ assert statement.output_to == 'file.txt'
+
+def test_append_then_pipe(parser):
+ line = 'help alias >> file.txt | grep blah'
+ statement = parser.parse(line)
+ assert statement.command == 'help'
+ assert statement == 'alias'
+ assert statement.args == statement
+ assert statement.argv == ['help', 'alias']
+ assert statement.arg_list == statement.argv[1:]
+ assert statement.terminator == ''
+ assert statement.suffix == ''
+ assert statement.pipe_to == ''
+ assert statement.output == '>>'
+ assert statement.output_to == 'file.txt'
+
+def test_append_then_redirect(parser):
+ line = 'help alias >> file.txt > file2.txt'
+ statement = parser.parse(line)
+ assert statement.command == 'help'
+ assert statement == 'alias'
+ assert statement.args == statement
+ assert statement.argv == ['help', 'alias']
+ assert statement.arg_list == statement.argv[1:]
+ assert statement.terminator == ''
+ assert statement.suffix == ''
+ assert statement.pipe_to == ''
+ assert statement.output == '>>'
+ assert statement.output_to == 'file.txt'
+
+def test_redirect_then_append(parser):
+ line = 'help alias > file.txt >> file2.txt'
+ statement = parser.parse(line)
+ assert statement.command == 'help'
+ assert statement == 'alias'
+ assert statement.args == statement
+ assert statement.argv == ['help', 'alias']
+ assert statement.arg_list == statement.argv[1:]
+ assert statement.terminator == ''
+ assert statement.suffix == ''
+ assert statement.pipe_to == ''
+ assert statement.output == '>'
+ assert statement.output_to == 'file.txt'
+
+def test_redirect_to_quoted_string(parser):
+ line = 'help alias > "file.txt"'
+ statement = parser.parse(line)
+ assert statement.command == 'help'
+ assert statement == 'alias'
+ assert statement.args == statement
+ assert statement.argv == ['help', 'alias']
+ assert statement.arg_list == statement.argv[1:]
+ assert statement.terminator == ''
+ assert statement.suffix == ''
+ assert statement.pipe_to == ''
+ assert statement.output == '>'
+ assert statement.output_to == '"file.txt"'
+
+def test_redirect_to_single_quoted_string(parser):
+ line = "help alias > 'file.txt'"
+ statement = parser.parse(line)
+ assert statement.command == 'help'
+ assert statement == 'alias'
+ assert statement.args == statement
+ assert statement.argv == ['help', 'alias']
+ assert statement.arg_list == statement.argv[1:]
+ assert statement.terminator == ''
+ assert statement.suffix == ''
+ assert statement.pipe_to == ''
+ assert statement.output == '>'
+ assert statement.output_to == "'file.txt'"
+
+def test_redirect_to_empty_quoted_string(parser):
+ line = 'help alias > ""'
+ statement = parser.parse(line)
+ assert statement.command == 'help'
+ assert statement == 'alias'
+ assert statement.args == statement
+ assert statement.argv == ['help', 'alias']
+ assert statement.arg_list == statement.argv[1:]
+ assert statement.terminator == ''
+ assert statement.suffix == ''
+ assert statement.pipe_to == ''
+ assert statement.output == '>'
+ assert statement.output_to == ''
+
+def test_redirect_to_empty_single_quoted_string(parser):
+ line = "help alias > ''"
+ statement = parser.parse(line)
+ assert statement.command == 'help'
+ assert statement == 'alias'
+ assert statement.args == statement
+ assert statement.argv == ['help', 'alias']
+ assert statement.arg_list == statement.argv[1:]
+ assert statement.terminator == ''
+ assert statement.suffix == ''
+ assert statement.pipe_to == ''
+ assert statement.output == '>'
+ assert statement.output_to == ''
+
def test_parse_output_to_paste_buffer(parser):
line = 'output to paste buffer >> '
statement = parser.parse(line)
@@ -516,7 +641,7 @@ def test_parse_alias_pipe(parser, line):
assert statement.command == 'help'
assert statement == ''
assert statement.args == statement
- assert statement.pipe_to == ['less']
+ assert statement.pipe_to == 'less'
@pytest.mark.parametrize('line', [
'helpalias;',
@@ -545,7 +670,7 @@ def test_parse_command_only_command_and_args(parser):
assert statement.raw == line
assert statement.terminator == ''
assert statement.suffix == ''
- assert statement.pipe_to == []
+ assert statement.pipe_to == ''
assert statement.output == ''
assert statement.output_to == ''
@@ -561,7 +686,7 @@ def test_parse_command_only_strips_line(parser):
assert statement.raw == line
assert statement.terminator == ''
assert statement.suffix == ''
- assert statement.pipe_to == []
+ assert statement.pipe_to == ''
assert statement.output == ''
assert statement.output_to == ''
@@ -577,7 +702,7 @@ def test_parse_command_only_expands_alias(parser):
assert statement.raw == line
assert statement.terminator == ''
assert statement.suffix == ''
- assert statement.pipe_to == []
+ assert statement.pipe_to == ''
assert statement.output == ''
assert statement.output_to == ''
@@ -594,7 +719,7 @@ def test_parse_command_only_expands_shortcuts(parser):
assert statement.multiline_command == ''
assert statement.terminator == ''
assert statement.suffix == ''
- assert statement.pipe_to == []
+ assert statement.pipe_to == ''
assert statement.output == ''
assert statement.output_to == ''
@@ -611,7 +736,7 @@ def test_parse_command_only_quoted_args(parser):
assert statement.multiline_command == ''
assert statement.terminator == ''
assert statement.suffix == ''
- assert statement.pipe_to == []
+ assert statement.pipe_to == ''
assert statement.output == ''
assert statement.output_to == ''
@@ -635,7 +760,7 @@ def test_parse_command_only_specialchars(parser, line, args):
assert statement.multiline_command == ''
assert statement.terminator == ''
assert statement.suffix == ''
- assert statement.pipe_to == []
+ assert statement.pipe_to == ''
assert statement.output == ''
assert statement.output_to == ''
@@ -664,7 +789,7 @@ def test_parse_command_only_empty(parser, line):
assert statement.multiline_command == ''
assert statement.terminator == ''
assert statement.suffix == ''
- assert statement.pipe_to == []
+ assert statement.pipe_to == ''
assert statement.output == ''
assert statement.output_to == ''
@@ -692,7 +817,7 @@ def test_statement_initialization():
assert statement.multiline_command == ''
assert statement.terminator == ''
assert statement.suffix == ''
- assert isinstance(statement.pipe_to, list)
+ assert isinstance(statement.pipe_to, str)
assert not statement.pipe_to
assert statement.output == ''
assert statement.output_to == ''