Repository: python-cmd2/cmd2
Pull request #676 (2019-05-13 17:23:05)

Pipe chaining
- Added capability to redirect pipe commands and chain them together. This involved changing `Statement.pipe_to` from a list to a string.
- Fixed parsing issue in case where output redirection (e.g. > file) appears before a pipe. In that case, the pipe was given precedence even though it appeared later in the command.
- Preserving originally typed quotes of `Statement.output_to` for use in `Statement.post_command()`


Patch:
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)


Test patch:
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 == ''