Repository: python-cmd2/cmd2

Issue:
Alias command is dropping quotes of arguments
Because `@with_argument_list` returns a list of args in their unquoted state, the `alias` command isn't saving quotes.

Ex: `alias fake !cat "out file.txt"` becomes `alias fake out file.txt`

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9f201664..8d49d34e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,7 @@
-## 0.9.5 (TBD, 2018)
+## 0.9.5 (September TBD, 2018)
 * Bug Fixes
     * Fixed bug where ``get_all_commands`` could return non-callable attributes
+    * Fixed bug where **alias** command was dropping quotes around arguments
 * Enhancements
     * Added ``exit_code`` attribute of ``cmd2.Cmd`` class
         * Enables applications to return a non-zero exit code when exiting from ``cmdloop``
diff --git a/README.md b/README.md
index b2eb314c..d725ca9b 100755
--- a/README.md
+++ b/README.md
@@ -43,9 +43,9 @@ Main Features
 
 Python 2.7 support is EOL
 -------------------------
-Support for adding new features to the Python 2.7 release of ``cmd2`` was discontinued on April 15, 2018.  Bug fixes will be supported for Python 2.7 via 0.8.x until August 31, 2018.
+The last version of cmd2 to support Python 2.7 is [0.8.9](https://pypi.org/project/cmd2/0.8.9/), released on August 21, 2018.
 
-Supporting Python 2 was an increasing burden on our limited resources.  Switching to support only Python 3 will allow
+Supporting Python 2 was an increasing burden on our limited resources.  Switching to support only Python 3 is allowing
 us to clean up the codebase, remove some cruft, and focus on developing new features.
 
 Installation
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index c49ec0cc..cc656e01 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -612,7 +612,7 @@ class Cmd(cmd.Cmd):
                               - truncated text is still accessible by scrolling with the right & left arrow keys
                               - chopping is ideal for displaying wide tabular data as is done in utilities like pgcli
                      False -> causes lines longer than the screen width to wrap to the next line
-                              - wrapping is ideal when you want to avoid users having to use horizontal scrolling
+                              - wrapping is ideal when you want to keep users from having to use horizontal scrolling
 
         WARNING: On Windows, the text always wraps regardless of what the chop argument is set to
         """
@@ -692,8 +692,7 @@ class Cmd(cmd.Cmd):
         elif rl_type == RlType.PYREADLINE:
             readline.rl.mode._display_completions = self._display_matches_pyreadline
 
-    def tokens_for_completion(self, line: str, begidx: int, endidx: int) -> Tuple[Optional[List[str]],
-                                                                                  Optional[List[str]]]:
+    def tokens_for_completion(self, line: str, begidx: int, endidx: int) -> Tuple[List[str], List[str]]:
         """
         Used by tab completion functions to get all tokens through the one being completed
         :param line: the current input line with leading whitespace removed
@@ -710,7 +709,7 @@ class Cmd(cmd.Cmd):
                      The last item in both lists is the token being tab completed
 
                  On Failure
-                    Both items are None
+                    Two empty lists
         """
         import copy
         unclosed_quote = ''
@@ -730,21 +729,21 @@ class Cmd(cmd.Cmd):
                 if not unclosed_quote and begidx == tmp_endidx:
                     initial_tokens.append('')
                 break
-            except ValueError:
-                # ValueError can be caused by missing closing quote
-                if not quotes_to_try:
-                    # Since we have no more quotes to try, something else
-                    # is causing the parsing error. Return None since
-                    # this means the line is malformed.
-                    return None, None
-
-                # Add a closing quote and try to parse again
-                unclosed_quote = quotes_to_try[0]
-                quotes_to_try = quotes_to_try[1:]
-
-                tmp_line = line[:endidx]
-                tmp_line += unclosed_quote
-                tmp_endidx = endidx + 1
+            except ValueError as ex:
+                # Make sure the exception was due to an unclosed quote and
+                # we haven't exhausted the closing quotes to try
+                if str(ex) == "No closing quotation" and quotes_to_try:
+                    # Add a closing quote and try to parse again
+                    unclosed_quote = quotes_to_try[0]
+                    quotes_to_try = quotes_to_try[1:]
+
+                    tmp_line = line[:endidx]
+                    tmp_line += unclosed_quote
+                    tmp_endidx = endidx + 1
+                else:
+                    # The parsing error is not caused by unclosed quotes.
+                    # Return empty lists since this means the line is malformed.
+                    return [], []
 
         if self.allow_redirection:
 
@@ -910,7 +909,7 @@ class Cmd(cmd.Cmd):
         """
         # Get all tokens through the one being completed
         tokens, _ = self.tokens_for_completion(line, begidx, endidx)
-        if tokens is None:
+        if not tokens:
             return []
 
         completions_matches = []
@@ -953,7 +952,7 @@ class Cmd(cmd.Cmd):
         """
         # Get all tokens through the one being completed
         tokens, _ = self.tokens_for_completion(line, begidx, endidx)
-        if tokens is None:
+        if not tokens:
             return []
 
         matches = []
@@ -1190,7 +1189,7 @@ class Cmd(cmd.Cmd):
             # Get all tokens through the one being completed. We want the raw tokens
             # so we can tell if redirection strings are quoted and ignore them.
             _, raw_tokens = self.tokens_for_completion(line, begidx, endidx)
-            if raw_tokens is None:
+            if not raw_tokens:
                 return []
 
             if len(raw_tokens) > 1:
@@ -1398,9 +1397,9 @@ class Cmd(cmd.Cmd):
                 # Get all tokens through the one being completed
                 tokens, raw_tokens = self.tokens_for_completion(line, begidx, endidx)
 
-                # Either had a parsing error or are trying to complete the command token
+                # Check if we either had a parsing error or are trying to complete the command token
                 # The latter can happen if " or ' was entered as the command
-                if tokens is None or len(tokens) == 1:
+                if len(tokens) <= 1:
                     self.completion_matches = []
                     return None
 
@@ -1550,9 +1549,10 @@ class Cmd(cmd.Cmd):
         completer = AutoCompleter(argparser, cmd2_app=self)
 
         tokens, _ = self.tokens_for_completion(line, begidx, endidx)
-        results = completer.complete_command(tokens, text, line, begidx, endidx)
+        if not tokens:
+            return []
 
-        return results
+        return completer.complete_command(tokens, text, line, begidx, endidx)
 
     def get_all_commands(self) -> List[str]:
         """Returns a list of all commands."""
@@ -1589,7 +1589,7 @@ class Cmd(cmd.Cmd):
 
         # Get all tokens through the one being completed
         tokens, _ = self.tokens_for_completion(line, begidx, endidx)
-        if tokens is None:
+        if not tokens:
             return []
 
         matches = []
@@ -2217,8 +2217,7 @@ class Cmd(cmd.Cmd):
 
             return stop
 
-    @with_argument_list
-    def do_alias(self, arglist: List[str]) -> None:
+    def do_alias(self, statement: Statement) -> None:
         """Define or display aliases
 
 Usage:  Usage: alias [name] | [<name> <value>]
@@ -2237,22 +2236,27 @@ Usage:  Usage: alias [name] | [<name> <value>]
 
     Example: alias ls !ls -lF
 
-    If you want to use redirection or pipes in the alias, then either quote the tokens with these
-    characters or quote the entire alias value.
+    If you want to use redirection or pipes in the alias, then quote them to prevent
+    the alias command itself from being redirected
 
     Examples:
         alias save_results print_results ">" out.txt
-        alias save_results print_results "> out.txt"
-        alias save_results "print_results > out.txt"
+        alias save_results print_results '>' out.txt
 """
+        # Get alias arguments as a list with quotes preserved
+        alias_arg_list = statement.arg_list
+
         # If no args were given, then print a list of current aliases
-        if not arglist:
+        if not alias_arg_list:
             for cur_alias in self.aliases:
                 self.poutput("alias {} {}".format(cur_alias, self.aliases[cur_alias]))
+            return
+
+        # Get the alias name
+        name = alias_arg_list[0]
 
         # The user is looking up an alias
-        elif len(arglist) == 1:
-            name = arglist[0]
+        if len(alias_arg_list) == 1:
             if name in self.aliases:
                 self.poutput("alias {} {}".format(name, self.aliases[name]))
             else:
@@ -2260,8 +2264,16 @@ Usage:  Usage: alias [name] | [<name> <value>]
 
         # The user is creating an alias
         else:
-            name = arglist[0]
-            value = ' '.join(arglist[1:])
+            # Unquote redirection and pipes
+            index = 1
+            while index < len(alias_arg_list):
+                unquoted_arg = utils.strip_quotes(alias_arg_list[index])
+                if unquoted_arg in constants.REDIRECTION_TOKENS:
+                    alias_arg_list[index] = unquoted_arg
+                index += 1
+
+            # Build the alias value string
+            value = ' '.join(alias_arg_list[1:])
 
             # Validate the alias to ensure it doesn't include weird characters
             # like terminators, output redirection, or whitespace
@@ -2321,7 +2333,7 @@ Usage:  Usage: unalias [-a] name [name ...]
 
     @with_argument_list
     def do_help(self, arglist: List[str]) -> None:
-        """List available commands with "help" or detailed help with "help cmd"."""
+        """ List available commands with "help" or detailed help with "help cmd" """
         if not arglist or (len(arglist) == 1 and arglist[0] in ('--verbose', '-v')):
             verbose = len(arglist) == 1 and arglist[0] in ('--verbose', '-v')
             self._help_menu(verbose)
@@ -2460,22 +2472,22 @@ Usage:  Usage: unalias [-a] name [name ...]
                 self.stdout.write("\n")
 
     def do_shortcuts(self, _: str) -> None:
-        """Lists shortcuts (aliases) available."""
+        """Lists shortcuts available"""
         result = "\n".join('%s: %s' % (sc[0], sc[1]) for sc in sorted(self.shortcuts))
         self.poutput("Shortcuts for other commands:\n{}\n".format(result))
 
     def do_eof(self, _: str) -> bool:
-        """Called when <Ctrl>-D is pressed."""
+        """Called when <Ctrl>-D is pressed"""
         # End of script should not exit app, but <Ctrl>-D should.
         return self._STOP_AND_EXIT
 
     def do_quit(self, _: str) -> bool:
-        """Exits this application."""
+        """Exits this application"""
         self._should_quit = True
         return self._STOP_AND_EXIT
 
     def select(self, opts: Union[str, List[str], List[Tuple[Any, Optional[str]]]], prompt: str='Your choice? ') -> str:
-        """Presents a numbered menu to the user.  Modelled after
+        """Presents a numbered menu to the user.  Modeled after
            the bash shell's SELECT.  Returns the item chosen.
 
            Argument ``opts`` can be:
@@ -2598,24 +2610,20 @@ Usage:  Usage: unalias [-a] name [name ...]
                 param = args.settable[0]
             self.show(args, param)
 
-    def do_shell(self, command: str) -> None:
-        """Execute a command as if at the OS prompt.
+    def do_shell(self, statement: Statement) -> None:
+        """Execute a command as if at the OS prompt
 
     Usage:  shell <command> [arguments]"""
-
         import subprocess
-        try:
-            # Use non-POSIX parsing to keep the quotes around the tokens
-            tokens = shlex.split(command, posix=False)
-        except ValueError as err:
-            self.perror(err, traceback_war=False)
-            return
+
+        # Get list of arguments to shell with quotes preserved
+        tokens = statement.arg_list
 
         # Support expanding ~ in quoted paths
         for index, _ in enumerate(tokens):
             if tokens[index]:
-                # Check if the token is quoted. Since shlex.split() passed, there isn't
-                # an unclosed quote, so we only need to check the first character.
+                # 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])
@@ -2901,7 +2909,7 @@ a..b, a:b, a:, ..b  items by indices (inclusive)
 
     @with_argparser(history_parser)
     def do_history(self, args: argparse.Namespace) -> None:
-        """View, run, edit, save, or clear previously entered commands."""
+        """View, run, edit, save, or clear previously entered commands"""
 
         if args.clear:
             # Clear command and readline history
@@ -3045,7 +3053,7 @@ a..b, a:b, a:, ..b  items by indices (inclusive)
 
     @with_argument_list
     def do_edit(self, arglist: List[str]) -> None:
-        """Edit a file in a text editor.
+        """Edit a file in a text editor
 
 Usage:  edit [file_path]
     Where:
@@ -3077,7 +3085,7 @@ The editor used is determined by the ``editor`` settable parameter.
 
     @with_argument_list
     def do__relative_load(self, arglist: List[str]) -> None:
-        """Runs commands in script file that is encoded as either ASCII or UTF-8 text.
+        """Runs commands in script file that is encoded as either ASCII or UTF-8 text
 
     Usage:  _relative_load <file_path>
 
@@ -3102,13 +3110,13 @@ NOTE: This command is intended to only be used within text file scripts.
         self.do_load([relative_path])
 
     def do_eos(self, _: str) -> None:
-        """Handles cleanup when a script has finished executing."""
+        """Handles cleanup when a script has finished executing"""
         if self._script_dir:
             self._script_dir.pop()
 
     @with_argument_list
     def do_load(self, arglist: List[str]) -> None:
-        """Runs commands in script file that is encoded as either ASCII or UTF-8 text.
+        """Runs commands in script file that is encoded as either ASCII or UTF-8 text
 
     Usage:  load <file_path>
 
diff --git a/cmd2/parsing.py b/cmd2/parsing.py
index b67cef10..8edfacb9 100644
--- a/cmd2/parsing.py
+++ b/cmd2/parsing.py
@@ -7,10 +7,13 @@ import re
 import shlex
 from typing import List, Tuple, Dict
 
+import attr
+
 from . import constants
 from . import utils
 
 
+@attr.s(frozen=True)
 class Statement(str):
     """String subclass with additional attributes to store the results of parsing.
 
@@ -26,98 +29,137 @@ class Statement(str):
     The string portion of the class contains the arguments, but not the command, nor
     the output redirection clauses.
 
-    :var raw:               string containing exactly what we input by the user
-    :type raw:              str
-    :var command:           the command, i.e. the first whitespace delimited word
-    :type command:          str or None
-    :var multiline_command: if the command is a multiline command, the name of the
-                            command, otherwise None
-    :type command:          str or None
-    :var args:              the arguments to the command, not including any output
-                            redirection or terminators. quoted arguments remain
-                            quoted.
-    :type args:             str or None
-    :var: argv:             a list of arguments a la sys.argv. Quotes, if any, are removed
-                            from the elements of the list, and aliases and shortcuts
-                            are expanded
-    :type argv:             list
-    :var terminator:        the character which terminated the multiline command, if
-                            there was one
-    :type terminator:       str or None
-    :var suffix:            characters appearing after the terminator but before output
-                            redirection, if any
-    :type suffix:           str or None
-    :var pipe_to:           if output was piped to a shell command, the shell command
-                            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 file
-    :type output_to:        str or None
-
+    Here's some suggestions and best practices for how to use the attributes of this
+    object:
+
+    command - the name of the command, shortcuts and aliases have already been
+              expanded
+
+    args - the arguments to the command, excluding output redirection and command
+           terminators. If the user used quotes in their input, they remain here,
+           and you will have to handle them on your own.
+
+    arg_list - the arguments to the command, excluding output redirection and
+               command terminators. Each argument is represented as an element
+               in the list. Quoted arguments remain quoted. If you want to
+               remove the quotes, use `cmd2.utils.strip_quotes()` or use
+               `argv[1:]`
+
+    command_and_args - join the args and the command together with a space. Output
+                       redirection is excluded.
+
+    argv - this is a list of arguments in the style of `sys.argv`. The first element
+           of the list is the command. Subsequent elements of the list contain any
+           additional arguments, with quotes removed, just like bash would. This
+           is very useful if you are going to use `argparse.parse_args()`:
+           ```
+           def do_mycommand(stmt):
+               mycommand_argparser.parse_args(stmt.argv)
+               ...
+            ```
+
+    raw - if you want full access to exactly what the user typed at the input prompt
+          you can get it, but you'll have to parse it on your own, including:
+             - shortcuts and aliases
+             - quoted commands and arguments
+             - output redirection
+             - multi-line command terminator handling
+          if you use multiline commands, all the input will be passed to you in
+          this string, but there will be embedded newlines where
+          the user hit return to continue the command on the next line.
+
+    Tips:
+
+    1. `argparse` is your friend for anything complex. `cmd2` has two decorators
+       (`with_argparser`, and `with_argparser_and_unknown_args`) which you can use
+       to make your command method receive a namespace of parsed arguments, whether
+       positional or denoted with switches.
+
+    2. For commands with simple positional arguments, use `args` or `arg_list`
+
+    3. If you don't want to have to worry about quoted arguments, use
+       argv[1:], which strips them all off for you.
     """
-    def __new__(cls,
-                obj: object,
-                *,
-                raw: str = None,
-                command: str = None,
-                args: str = None,
-                argv: List[str] = None,
-                multiline_command: str = None,
-                terminator: str = None,
-                suffix: str = None,
-                pipe_to: str = None,
-                output: str = None,
-                output_to: str = None
-                ):
-        """Create a new instance of Statement
+    # the arguments, but not the command, nor the output redirection clauses.
+    args = attr.ib(default='', validator=attr.validators.instance_of(str), type=str)
+
+    # string containing exactly what we input by the user
+    raw = attr.ib(default='', validator=attr.validators.instance_of(str), type=str)
+
+    # the command, i.e. the first whitespace delimited word
+    command = attr.ib(default='', validator=attr.validators.instance_of(str), type=str)
+
+    # list of arguments to the command, not including any output redirection or terminators; quoted args remain quoted
+    arg_list = attr.ib(factory=list, validator=attr.validators.instance_of(list), type=List[str])
+
+    # if the command is a multiline command, the name of the command, otherwise empty
+    multiline_command = attr.ib(default='', validator=attr.validators.instance_of(str), type=str)
+
+    # the character which terminated the multiline command, if there was one
+    terminator = attr.ib(default='', validator=attr.validators.instance_of(str), type=str)
+
+    # characters appearing after the terminator but before output redirection, if any
+    suffix = attr.ib(default='', validator=attr.validators.instance_of(str), type=str)
+
+    # if output was piped to a shell command, the shell command as a list of tokens
+    pipe_to = attr.ib(factory=list, validator=attr.validators.instance_of(list), type=List[str])
+
+    # if output was redirected, the redirection token, i.e. '>>'
+    output = attr.ib(default='', validator=attr.validators.instance_of(str), type=str)
+
+    # if output was redirected, the destination file
+    output_to = attr.ib(default='', validator=attr.validators.instance_of(str), type=str)
+
+    def __new__(cls, value: object, *pos_args, **kw_args):
+        """Create a new instance of Statement.
 
         We must override __new__ because we are subclassing `str` which is
-        immutable.
+        immutable and takes a different number of arguments as Statement.
+
+        NOTE:  attrs takes care of initializing other members in the __init__ it
+        generates.
         """
-        stmt = str.__new__(cls, obj)
-        object.__setattr__(stmt, "raw", raw)
-        object.__setattr__(stmt, "command", command)
-        object.__setattr__(stmt, "args", args)
-        if argv is None:
-            argv = []
-        object.__setattr__(stmt, "argv", argv)
-        object.__setattr__(stmt, "multiline_command", multiline_command)
-        object.__setattr__(stmt, "terminator", terminator)
-        object.__setattr__(stmt, "suffix", suffix)
-        object.__setattr__(stmt, "pipe_to", pipe_to)
-        object.__setattr__(stmt, "output", output)
-        object.__setattr__(stmt, "output_to", output_to)
+        stmt = super().__new__(cls, value)
         return stmt
 
     @property
-    def command_and_args(self):
+    def command_and_args(self) -> str:
         """Combine command and args with a space separating them.
 
-        Quoted arguments remain quoted.
+        Quoted arguments remain quoted. Output redirection and piping are
+        excluded, as are any multiline command terminators.
         """
         if self.command and self.args:
             rtn = '{} {}'.format(self.command, self.args)
         elif self.command:
-            # we are trusting that if we get here that self.args is None
+            # there were no arguments to the command
             rtn = self.command
         else:
-            rtn = None
+            rtn = ''
         return rtn
 
-    def __setattr__(self, name, value):
-        """Statement instances should feel immutable; raise ValueError"""
-        raise ValueError
+    @property
+    def argv(self) -> List[str]:
+        """a list of arguments a la sys.argv.
 
-    def __delattr__(self, name):
-        """Statement instances should feel immutable; raise ValueError"""
-        raise ValueError
+        Quotes, if any, are removed from the elements of the list, and aliases
+        and shortcuts are expanded
+        """
+        if self.command:
+            rtn = [utils.strip_quotes(self.command)]
+            for cur_token in self.arg_list:
+                rtn.append(utils.strip_quotes(cur_token))
+        else:
+            rtn = []
+
+        return rtn
 
 
 class StatementParser:
     """Parse raw text into command components.
 
-    Shortcuts is a list of tuples with each tuple containing the shortcut and the expansion.
+    Shortcuts is a list of tuples with each tuple containing the shortcut and
+    the expansion.
     """
     def __init__(
             self,
@@ -231,7 +273,7 @@ class StatementParser:
         if match:
             if word == match.group(1):
                 valid = True
-                errmsg = None
+                errmsg = ''
         return valid, errmsg
 
     def tokenize(self, line: str) -> List[str]:
@@ -268,13 +310,13 @@ class StatementParser:
         # handle the special case/hardcoded terminator of a blank line
         # we have to do this before we tokenize because tokenizing
         # destroys all unquoted whitespace in the input
-        terminator = None
+        terminator = ''
         if line[-1:] == constants.LINE_FEED:
             terminator = constants.LINE_FEED
 
-        command = None
-        args = None
-        argv = None
+        command = ''
+        args = ''
+        arg_list = []
 
         # lex the input into a list of tokens
         tokens = self.tokenize(line)
@@ -302,8 +344,8 @@ class StatementParser:
                 terminator_pos = len(tokens)+1
 
             # everything before the first terminator is the command and the args
-            argv = tokens[:terminator_pos]
-            (command, args) = self._command_and_args(argv)
+            (command, args) = self._command_and_args(tokens[:terminator_pos])
+            arg_list = tokens[1:terminator_pos]
             # we will set the suffix later
             # remove all the tokens before and including the terminator
             tokens = tokens[terminator_pos+1:]
@@ -315,7 +357,7 @@ class StatementParser:
                 # because redirectors can only be after a terminator
                 command = testcommand
                 args = testargs
-                argv = tokens
+                arg_list = tokens[1:]
                 tokens = []
 
         # check for a pipe to a shell process
@@ -336,11 +378,11 @@ class StatementParser:
             tokens = tokens[:pipe_pos]
         except ValueError:
             # no pipe in the tokens
-            pipe_to = None
+            pipe_to = []
 
         # check for output redirect
-        output = None
-        output_to = None
+        output = ''
+        output_to = ''
         try:
             output_pos = tokens.index(constants.REDIRECTION_OUTPUT)
             output = constants.REDIRECTION_OUTPUT
@@ -374,26 +416,23 @@ class StatementParser:
             suffix = ' '.join(tokens)
         else:
             # no terminator, so whatever is left is the command and the args
-            suffix = None
+            suffix = ''
             if not command:
                 # command could already have been set, if so, don't set it again
-                argv = tokens
-                (command, args) = self._command_and_args(argv)
+                (command, args) = self._command_and_args(tokens)
+                arg_list = tokens[1:]
 
         # set multiline
         if command in self.multiline_commands:
             multiline_command = command
         else:
-            multiline_command = None
+            multiline_command = ''
 
         # build the statement
-        # string representation of args must be an empty string instead of
-        # None for compatibility with standard library cmd
-        statement = Statement('' if args is None else args,
+        statement = Statement(args,
                               raw=line,
                               command=command,
-                              args=args,
-                              argv=list(map(lambda x: utils.strip_quotes(x), argv)),
+                              arg_list=arg_list,
                               multiline_command=multiline_command,
                               terminator=terminator,
                               suffix=suffix,
@@ -413,53 +452,50 @@ class StatementParser:
         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:
+        The `Statement` object returned by this method can at most contain values
+        in the following attributes:
+          - args
           - raw
           - command
-          - args
+          - multiline_command
+
+        `Statement.args` includes all output redirection clauses and command
+        terminators.
 
         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.
+        within args. However, it does ensure args has no leading or trailing
+        whitespace.
         """
         # expand shortcuts and aliases
         line = self._expand(rawinput)
 
-        command = None
-        args = None
+        command = ''
+        args = ''
         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
+
+            # take everything from the end of the first match group to
+            # the end of the line as the arguments (stripping leading
+            # and trailing spaces)
+            args = line[match.end(1):].strip()
+            # if the command is empty that means the input was either empty
+            # or something weird like '>'. args should be empty if we couldn't
             # parse a command
             if not command or not args:
-                args = None
+                args = ''
 
         # set multiline
         if command in self.multiline_commands:
             multiline_command = command
         else:
-            multiline_command = None
+            multiline_command = ''
 
         # build the statement
-        # string representation of args must be an empty string instead of
-        # None for compatibility with standard library cmd
-        statement = Statement('' if args is None else args,
+        statement = Statement(args,
                               raw=rawinput,
                               command=command,
-                              args=args,
                               multiline_command=multiline_command,
                               )
         return statement
@@ -503,12 +539,9 @@ class StatementParser:
     def _command_and_args(tokens: List[str]) -> Tuple[str, str]:
         """Given a list of tokens, return a tuple of the command
         and the args as a string.
-
-        The args string will be '' instead of None to retain backwards compatibility
-        with cmd in the standard library.
         """
-        command = None
-        args = None
+        command = ''
+        args = ''
 
         if tokens:
             command = tokens[0]
@@ -528,10 +561,11 @@ class StatementParser:
         return matched_string
 
     def _split_on_punctuation(self, tokens: List[str]) -> List[str]:
-        """
-        # Further splits tokens from a command line using punctuation characters
-        # as word breaks when they are in unquoted strings. Each run of punctuation
-        # characters is treated as a single token.
+        """Further splits tokens from a command line using punctuation characters
+
+        Punctuation characters are treated as word breaks when they are in
+        unquoted strings. Each run of punctuation characters is treated as a
+        single token.
 
         :param tokens: the tokens as parsed by shlex
         :return: the punctuated tokens
diff --git a/docs/argument_processing.rst b/docs/argument_processing.rst
index 5aef3720..8aed7498 100644
--- a/docs/argument_processing.rst
+++ b/docs/argument_processing.rst
@@ -278,16 +278,16 @@ the help categories with per-command Help Messages::
     ================================================================================
     alias               Define or display aliases
     config              Config command
-    edit                Edit a file in a text editor.
-    help                List available commands with "help" or detailed help with "help cmd".
+    edit                Edit a file in a text editor
+    help                List available commands with "help" or detailed help with "help cmd"
     history             usage: history [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT] [arg]
-    load                Runs commands in script file that is encoded as either ASCII or UTF-8 text.
+    load                Runs commands in script file that is encoded as either ASCII or UTF-8 text
     py                  Invoke python command, shell, or script
     pyscript            Runs a python script file inside the console
-    quit                Exits this application.
+    quit                Exits this application
     set                 usage: set [-h] [-a] [-l] [settable [settable ...]]
-    shell               Execute a command as if at the OS prompt.
-    shortcuts           Lists shortcuts (aliases) available.
+    shell               Execute a command as if at the OS prompt
+    shortcuts           Lists shortcuts available
     unalias             Unsets aliases
     version             Version command
 
@@ -296,7 +296,36 @@ Receiving an argument list
 ==========================
 
 The default behavior of ``cmd2`` is to pass the user input directly to your
-``do_*`` methods as a string. If you don't want to use the full argument parser support outlined above, you can still have ``cmd2`` apply shell parsing rules to the user input and pass you a list of arguments instead of a string. Apply the ``@with_argument_list`` decorator to those methods that should receive an argument list instead of a string::
+``do_*`` methods as a string. The object passed to your method is actually a
+``Statement`` object, which has additional attributes that may be helpful,
+including ``arg_list`` and ``argv``::
+
+    class CmdLineApp(cmd2.Cmd):
+        """ Example cmd2 application. """
+
+        def do_say(self, statement):
+            # statement contains a string
+            self.poutput(statement)
+
+        def do_speak(self, statement):
+            # statement also has a list of arguments
+            # quoted arguments remain quoted
+            for arg in statement.arg_list:
+                self.poutput(arg)
+
+        def do_articulate(self, statement):
+            # statement.argv contains the command
+            # and the arguments, which have had quotes
+            # stripped
+            for arg in statement.argv:
+                self.poutput(arg)
+
+
+If you don't want to access the additional attributes on the string passed to
+you``do_*`` method you can still have ``cmd2`` apply shell parsing rules to the
+user input and pass you a list of arguments instead of a string. Apply the
+``@with_argument_list`` decorator to those methods that should receive an
+argument list instead of a string::
 
     from cmd2 import with_argument_list
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index f86a4c63..a23c44d0 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -35,23 +35,23 @@ BASE_HELP_VERBOSE = """
 Documented commands (type help <topic>):
 ================================================================================
 alias               Define or display aliases
-edit                Edit a file in a text editor.
-help                List available commands with "help" or detailed help with "help cmd".
-history             View, run, edit, save, or clear previously entered commands.
-load                Runs commands in script file that is encoded as either ASCII or UTF-8 text.
+edit                Edit a file in a text editor
+help                List available commands with "help" or detailed help with "help cmd"
+history             View, run, edit, save, or clear previously entered commands
+load                Runs commands in script file that is encoded as either ASCII or UTF-8 text
 py                  Invoke python command, shell, or script
 pyscript            Runs a python script file inside the console
-quit                Exits this application.
+quit                Exits this application
 set                 Sets a settable parameter or shows current settings of parameters
-shell               Execute a command as if at the OS prompt.
-shortcuts           Lists shortcuts (aliases) available.
+shell               Execute a command as if at the OS prompt
+shortcuts           Lists shortcuts available
 unalias             Unsets aliases
 """
 
 # Help text for the history command
 HELP_HISTORY = """Usage: history [arg] [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT | -c]
 
-View, run, edit, save, or clear previously entered commands.
+View, run, edit, save, or clear previously entered commands
 
 positional arguments:
   arg                   empty               all history items
diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py
index b75cd102..fdf0f661 100644
--- a/tests/test_cmd2.py
+++ b/tests/test_cmd2.py
@@ -1244,15 +1244,15 @@ diddly              This command does diddly
 Other
 ================================================================================
 alias               Define or display aliases
-help                List available commands with "help" or detailed help with "help cmd".
-history             View, run, edit, save, or clear previously entered commands.
-load                Runs commands in script file that is encoded as either ASCII or UTF-8 text.
+help                List available commands with "help" or detailed help with "help cmd"
+history             View, run, edit, save, or clear previously entered commands
+load                Runs commands in script file that is encoded as either ASCII or UTF-8 text
 py                  Invoke python command, shell, or script
 pyscript            Runs a python script file inside the console
-quit                Exits this application.
+quit                Exits this application
 set                 Sets a settable parameter or shows current settings of parameters
-shell               Execute a command as if at the OS prompt.
-shortcuts           Lists shortcuts (aliases) available.
+shell               Execute a command as if at the OS prompt
+shortcuts           Lists shortcuts available
 unalias             Unsets aliases
 
 Undocumented commands:
@@ -1759,6 +1759,15 @@ def test_alias(base_app, capsys):
     out = run_cmd(base_app, 'alias fake')
     assert out == normalize('alias fake pyscript')
 
+def test_alias_with_quotes(base_app, capsys):
+    # Create the alias
+    out = run_cmd(base_app, 'alias fake help ">" "out file.txt"')
+    assert out == normalize("Alias 'fake' created")
+
+    # Lookup the new alias (Only the redirector should be unquoted)
+    out = run_cmd(base_app, 'alias fake')
+    assert out == normalize('alias fake help > "out file.txt"')
+
 def test_alias_lookup_invalid_alias(base_app, capsys):
     # Lookup invalid alias
     out = run_cmd(base_app, 'alias invalid')
diff --git a/tests/test_parsing.py b/tests/test_parsing.py
index de4c637e..9cf9429a 100644
--- a/tests/test_parsing.py
+++ b/tests/test_parsing.py
@@ -5,6 +5,7 @@ Test the parsing logic in parsing.py
 Copyright 2017 Todd Leonhardt <todd.leonhardt@gmail.com>
 Released under MIT license, see LICENSE file
 """
+import attr
 import pytest
 
 import cmd2
@@ -31,12 +32,40 @@ def default_parser():
     parser = StatementParser()
     return parser
 
+
+def test_parse_empty_string(parser):
+    line = ''
+    statement = parser.parse(line)
+    assert statement == ''
+    assert statement.args == statement
+    assert statement.raw == line
+    assert statement.command == ''
+    assert statement.arg_list == []
+    assert statement.multiline_command == ''
+    assert statement.terminator == ''
+    assert statement.suffix == ''
+    assert statement.pipe_to == []
+    assert statement.output == ''
+    assert statement.output_to == ''
+    assert statement.command_and_args == line
+    assert statement.argv == statement.arg_list
+
 def test_parse_empty_string_default(default_parser):
-    statement = default_parser.parse('')
-    assert not statement.command
-    assert not statement.args
+    line = ''
+    statement = default_parser.parse(line)
     assert statement == ''
-    assert statement.raw == ''
+    assert statement.args == statement
+    assert statement.raw == line
+    assert statement.command == ''
+    assert statement.arg_list == []
+    assert statement.multiline_command == ''
+    assert statement.terminator == ''
+    assert statement.suffix == ''
+    assert statement.pipe_to == []
+    assert statement.output == ''
+    assert statement.output_to == ''
+    assert statement.command_and_args == line
+    assert statement.argv == statement.arg_list
 
 @pytest.mark.parametrize('line,tokens', [
     ('command', ['command']),
@@ -52,13 +81,6 @@ def test_tokenize_default(default_parser, line, tokens):
     tokens_to_test = default_parser.tokenize(line)
     assert tokens_to_test == tokens
 
-def test_parse_empty_string(parser):
-    statement = parser.parse('')
-    assert not statement.command
-    assert not statement.args
-    assert statement == ''
-    assert statement.raw == ''
-
 @pytest.mark.parametrize('line,tokens', [
     ('command', ['command']),
     ('command /* with some comment */ arg', ['command', 'arg']),
@@ -81,8 +103,8 @@ def test_tokenize_unclosed_quotes(parser):
         _ = parser.tokenize('command with "unclosed quotes')
 
 @pytest.mark.parametrize('tokens,command,args', [
-    ([], None, None),
-    (['command'], 'command', None),
+    ([], '', ''),
+    (['command'], 'command', ''),
     (['command', 'arg1', 'arg2'], 'command', 'arg1 arg2')
 ])
 def test_command_and_args(parser, tokens, command, args):
@@ -98,9 +120,18 @@ def test_command_and_args(parser, tokens, command, args):
 def test_parse_single_word(parser, line):
     statement = parser.parse(line)
     assert statement.command == line
-    assert statement.args is None
     assert statement == ''
     assert statement.argv == [utils.strip_quotes(line)]
+    assert not statement.arg_list
+    assert statement.args == statement
+    assert statement.raw == line
+    assert statement.multiline_command == ''
+    assert statement.terminator == ''
+    assert statement.suffix == ''
+    assert statement.pipe_to == []
+    assert statement.output == ''
+    assert statement.output_to == ''
+    assert statement.command_and_args == line
 
 @pytest.mark.parametrize('line,terminator', [
     ('termbare;', ';'),
@@ -111,9 +142,9 @@ def test_parse_single_word(parser, line):
 def test_parse_word_plus_terminator(parser, line, terminator):
     statement = parser.parse(line)
     assert statement.command == 'termbare'
-    assert statement.args is None
     assert statement == ''
     assert statement.argv == ['termbare']
+    assert not statement.arg_list
     assert statement.terminator == terminator
 
 @pytest.mark.parametrize('line,terminator', [
@@ -125,9 +156,10 @@ def test_parse_word_plus_terminator(parser, line, terminator):
 def test_parse_suffix_after_terminator(parser, line, terminator):
     statement = parser.parse(line)
     assert statement.command == 'termbare'
-    assert statement.args is None
     assert statement == ''
+    assert statement.args == statement
     assert statement.argv == ['termbare']
+    assert not statement.arg_list
     assert statement.terminator == terminator
     assert statement.suffix == 'suffx'
 
@@ -135,71 +167,82 @@ 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 == statement.args
+    assert statement == 'with args'
+    assert statement.args == statement
     assert statement.argv == ['command', 'with', 'args']
+    assert statement.arg_list == statement.argv[1:]
 
 def test_parse_command_with_quoted_args(parser):
     line = 'command with "quoted args" and "some not"'
     statement = parser.parse(line)
     assert statement.command == 'command'
-    assert statement.args == 'with "quoted args" and "some not"'
-    assert statement == statement.args
+    assert statement == 'with "quoted args" and "some not"'
+    assert statement.args == statement
     assert statement.argv == ['command', 'with', 'quoted args', 'and', 'some not']
+    assert statement.arg_list == ['with', '"quoted args"', 'and', '"some not"']
 
 def test_parse_command_with_args_terminator_and_suffix(parser):
     line = 'command with args and terminator; and suffix'
     statement = parser.parse(line)
     assert statement.command == 'command'
-    assert statement.args == "with args and terminator"
+    assert statement == "with args and terminator"
+    assert statement.args == statement
     assert statement.argv == ['command', 'with', 'args', 'and', 'terminator']
-    assert statement == statement.args
+    assert statement.arg_list == statement.argv[1:]
     assert statement.terminator == ';'
     assert statement.suffix == 'and suffix'
 
 def test_parse_hashcomment(parser):
     statement = parser.parse('hi # this is all a comment')
     assert statement.command == 'hi'
-    assert statement.args is None
     assert statement == ''
+    assert statement.args == statement
     assert statement.argv == ['hi']
+    assert not statement.arg_list
 
 def test_parse_c_comment(parser):
     statement = parser.parse('hi /* this is | all a comment */')
     assert statement.command == 'hi'
-    assert statement.args is None
-    assert statement.argv == ['hi']
     assert statement == ''
+    assert statement.args == statement
+    assert statement.argv == ['hi']
+    assert not statement.arg_list
     assert not statement.pipe_to
 
 def test_parse_c_comment_empty(parser):
     statement = parser.parse('/* this is | all a comment */')
-    assert not statement.command
-    assert not statement.args
+    assert statement.command == ''
+    assert statement.args == statement
     assert not statement.pipe_to
     assert not statement.argv
+    assert not statement.arg_list
     assert statement == ''
 
 def test_parse_c_comment_no_closing(parser):
     statement = parser.parse('cat /tmp/*.txt')
     assert statement.command == 'cat'
-    assert statement.args == '/tmp/*.txt'
+    assert statement == '/tmp/*.txt'
+    assert statement.args == statement
     assert not statement.pipe_to
     assert statement.argv == ['cat', '/tmp/*.txt']
+    assert statement.arg_list == statement.argv[1:]
 
 def test_parse_c_comment_multiple_opening(parser):
     statement = parser.parse('cat /tmp/*.txt /tmp/*.cfg')
     assert statement.command == 'cat'
-    assert statement.args == '/tmp/*.txt /tmp/*.cfg'
+    assert statement == '/tmp/*.txt /tmp/*.cfg'
+    assert statement.args == statement
     assert not statement.pipe_to
     assert statement.argv == ['cat', '/tmp/*.txt', '/tmp/*.cfg']
+    assert statement.arg_list == statement.argv[1:]
 
 def test_parse_what_if_quoted_strings_seem_to_start_comments(parser):
     statement = parser.parse('what if "quoted strings /* seem to " start comments?')
     assert statement.command == 'what'
-    assert statement.args == 'if "quoted strings /* seem to " start comments?'
+    assert statement == 'if "quoted strings /* seem to " start comments?'
+    assert statement.args == statement
     assert statement.argv == ['what', 'if', 'quoted strings /* seem to ', 'start', 'comments?']
-    assert statement == statement.args
+    assert statement.arg_list == ['if', '"quoted strings /* seem to "', 'start', 'comments?']
     assert not statement.pipe_to
 
 @pytest.mark.parametrize('line',[
@@ -209,27 +252,30 @@ def test_parse_what_if_quoted_strings_seem_to_start_comments(parser):
 def test_parse_simple_pipe(parser, line):
     statement = parser.parse(line)
     assert statement.command == 'simple'
-    assert statement.args is None
     assert statement == ''
+    assert statement.args == statement
     assert statement.argv == ['simple']
+    assert not statement.arg_list
     assert statement.pipe_to == ['piped']
 
 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'
-    assert statement.args == '|| is not a pipe'
-    assert statement == statement.args
+    assert statement == '|| is not a pipe'
+    assert statement.args == statement
     assert statement.argv == ['double-pipe', '||', 'is', 'not', 'a', 'pipe']
+    assert statement.arg_list == statement.argv[1:]
     assert not statement.pipe_to
 
 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 == "with args, terminator"
+    assert statement.args == statement
     assert statement.argv == ['command', 'with', 'args,', 'terminator']
-    assert statement == statement.args
+    assert statement.arg_list == statement.argv[1:]
     assert statement.terminator == '&'
     assert statement.suffix == 'sufx'
     assert statement.pipe_to == ['piped']
@@ -243,8 +289,8 @@ def test_parse_complex_pipe(parser):
 def test_parse_redirect(parser,line, output):
     statement = parser.parse(line)
     assert statement.command == 'help'
-    assert statement.args is None
     assert statement == ''
+    assert statement.args == statement
     assert statement.output == output
     assert statement.output_to == 'out.txt'
 
@@ -252,9 +298,10 @@ def test_parse_redirect_with_args(parser):
     line = 'output into > afile.txt'
     statement = parser.parse(line)
     assert statement.command == 'output'
-    assert statement.args == 'into'
-    assert statement == statement.args
+    assert statement == 'into'
+    assert statement.args == statement
     assert statement.argv == ['output', 'into']
+    assert statement.arg_list == statement.argv[1:]
     assert statement.output == '>'
     assert statement.output_to == 'afile.txt'
 
@@ -262,9 +309,10 @@ def test_parse_redirect_with_dash_in_path(parser):
     line = 'output into > python-cmd2/afile.txt'
     statement = parser.parse(line)
     assert statement.command == 'output'
-    assert statement.args == 'into'
-    assert statement == statement.args
+    assert statement == 'into'
+    assert statement.args == statement
     assert statement.argv == ['output', 'into']
+    assert statement.arg_list == statement.argv[1:]
     assert statement.output == '>'
     assert statement.output_to == 'python-cmd2/afile.txt'
 
@@ -272,9 +320,10 @@ def test_parse_redirect_append(parser):
     line = 'output appended to >> /tmp/afile.txt'
     statement = parser.parse(line)
     assert statement.command == 'output'
-    assert statement.args == 'appended to'
-    assert statement == statement.args
+    assert statement == 'appended to'
+    assert statement.args == statement
     assert statement.argv == ['output', 'appended', 'to']
+    assert statement.arg_list == statement.argv[1:]
     assert statement.output == '>>'
     assert statement.output_to == '/tmp/afile.txt'
 
@@ -282,22 +331,24 @@ def test_parse_pipe_and_redirect(parser):
     line = 'output into;sufx | pipethrume plz > afile.txt'
     statement = parser.parse(line)
     assert statement.command == 'output'
-    assert statement.args == 'into'
-    assert statement == statement.args
+    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', '>', 'afile.txt']
-    assert not statement.output
-    assert not statement.output_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)
     assert statement.command == 'output'
-    assert statement.args == 'to paste buffer'
-    assert statement == statement.args
+    assert statement == 'to paste buffer'
+    assert statement.args == statement
     assert statement.argv == ['output', 'to', 'paste', 'buffer']
+    assert statement.arg_list == statement.argv[1:]
     assert statement.output == '>>'
 
 def test_parse_redirect_inside_terminator(parser):
@@ -307,9 +358,10 @@ def test_parse_redirect_inside_terminator(parser):
     line = 'has > inside;'
     statement = parser.parse(line)
     assert statement.command == 'has'
-    assert statement.args == '> inside'
-    assert statement == statement.args
+    assert statement == '> inside'
+    assert statement.args == statement
     assert statement.argv == ['has', '>', 'inside']
+    assert statement.arg_list == statement.argv[1:]
     assert statement.terminator == ';'
 
 @pytest.mark.parametrize('line,terminator',[
@@ -325,9 +377,10 @@ def test_parse_redirect_inside_terminator(parser):
 def test_parse_multiple_terminators(parser, line, terminator):
     statement = parser.parse(line)
     assert statement.multiline_command == 'multiline'
-    assert statement.args == 'with | inside'
-    assert statement == statement.args
+    assert statement == 'with | inside'
+    assert statement.args == statement
     assert statement.argv == ['multiline', 'with', '|', 'inside']
+    assert statement.arg_list == statement.argv[1:]
     assert statement.terminator == terminator
 
 def test_parse_unfinished_multiliine_command(parser):
@@ -335,10 +388,11 @@ def test_parse_unfinished_multiliine_command(parser):
     statement = parser.parse(line)
     assert statement.multiline_command == 'multiline'
     assert statement.command == 'multiline'
-    assert statement.args == 'has > inside an unfinished command'
-    assert statement == statement.args
+    assert statement == 'has > inside an unfinished command'
+    assert statement.args == statement
     assert statement.argv == ['multiline', 'has', '>', 'inside', 'an', 'unfinished', 'command']
-    assert not statement.terminator
+    assert statement.arg_list == statement.argv[1:]
+    assert statement.terminator == ''
 
 @pytest.mark.parametrize('line,terminator',[
     ('multiline has > inside;', ';'),
@@ -350,9 +404,10 @@ def test_parse_unfinished_multiliine_command(parser):
 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 == statement.args
+    assert statement == 'has > inside'
+    assert statement.args == statement
     assert statement.argv == ['multiline', 'has', '>', 'inside']
+    assert statement.arg_list == statement.argv[1:]
     assert statement.terminator == terminator
 
 def test_parse_multiline_with_incomplete_comment(parser):
@@ -362,8 +417,10 @@ def test_parse_multiline_with_incomplete_comment(parser):
     statement = parser.parse(line)
     assert statement.multiline_command == 'multiline'
     assert statement.command == 'multiline'
-    assert statement.args == 'command /* with unclosed comment'
+    assert statement == 'command /* with unclosed comment'
+    assert statement.args == statement
     assert statement.argv == ['multiline', 'command', '/*', 'with', 'unclosed', 'comment']
+    assert statement.arg_list == statement.argv[1:]
     assert statement.terminator == ';'
 
 def test_parse_multiline_with_complete_comment(parser):
@@ -371,9 +428,10 @@ def test_parse_multiline_with_complete_comment(parser):
     statement = parser.parse(line)
     assert statement.multiline_command == 'multiline'
     assert statement.command == 'multiline'
-    assert statement.args == 'command is done'
-    assert statement == statement.args
+    assert statement == 'command is done'
+    assert statement.args == statement
     assert statement.argv == ['multiline', 'command', 'is', 'done']
+    assert statement.arg_list == statement.argv[1:]
     assert statement.terminator == ';'
 
 def test_parse_multiline_terminated_by_empty_line(parser):
@@ -381,9 +439,10 @@ def test_parse_multiline_terminated_by_empty_line(parser):
     statement = parser.parse(line)
     assert statement.multiline_command == 'multiline'
     assert statement.command == 'multiline'
-    assert statement.args == 'command ends'
-    assert statement == statement.args
+    assert statement == 'command ends'
+    assert statement.args == statement
     assert statement.argv == ['multiline', 'command', 'ends']
+    assert statement.arg_list == statement.argv[1:]
     assert statement.terminator == '\n'
 
 @pytest.mark.parametrize('line,terminator',[
@@ -398,9 +457,10 @@ def test_parse_multiline_with_embedded_newline(parser, line, terminator):
     statement = parser.parse(line)
     assert statement.multiline_command == 'multiline'
     assert statement.command == 'multiline'
-    assert statement.args == 'command "with\nembedded newline"'
-    assert statement == statement.args
+    assert statement == 'command "with\nembedded newline"'
+    assert statement.args == statement
     assert statement.argv == ['multiline', 'command', 'with\nembedded newline']
+    assert statement.arg_list == ['command', '"with\nembedded newline"']
     assert statement.terminator == terminator
 
 def test_parse_multiline_ignores_terminators_in_comments(parser):
@@ -408,34 +468,38 @@ def test_parse_multiline_ignores_terminators_in_comments(parser):
     statement = parser.parse(line)
     assert statement.multiline_command == 'multiline'
     assert statement.command == 'multiline'
-    assert statement.args == 'command "with term; ends" now'
-    assert statement == statement.args
+    assert statement == 'command "with term; ends" now'
+    assert statement.args == statement
     assert statement.argv == ['multiline', 'command', 'with term; ends', 'now']
+    assert statement.arg_list == ['command', '"with term; ends"', 'now']
     assert statement.terminator == '\n'
 
 def test_parse_command_with_unicode_args(parser):
     line = 'drink café'
     statement = parser.parse(line)
     assert statement.command == 'drink'
-    assert statement.args == 'café'
-    assert statement == statement.args
+    assert statement == 'café'
+    assert statement.args == statement
     assert statement.argv == ['drink', 'café']
+    assert statement.arg_list == statement.argv[1:]
 
 def test_parse_unicode_command(parser):
     line = 'café au lait'
     statement = parser.parse(line)
     assert statement.command == 'café'
-    assert statement.args == 'au lait'
-    assert statement == statement.args
+    assert statement == 'au lait'
+    assert statement.args == statement
     assert statement.argv == ['café', 'au', 'lait']
+    assert statement.arg_list == statement.argv[1:]
 
 def test_parse_redirect_to_unicode_filename(parser):
     line = 'dir home > café'
     statement = parser.parse(line)
     assert statement.command == 'dir'
-    assert statement.args == 'home'
-    assert statement == statement.args
+    assert statement == 'home'
+    assert statement.args == statement
     assert statement.argv == ['dir', 'home']
+    assert statement.arg_list == statement.argv[1:]
     assert statement.output == '>'
     assert statement.output_to == 'café'
 
@@ -452,9 +516,9 @@ def test_empty_statement_raises_exception():
         app._complete_statement(' ')
 
 @pytest.mark.parametrize('line,command,args', [
-    ('helpalias', 'help', None),
+    ('helpalias', 'help', ''),
     ('helpalias mycommand', 'help', 'mycommand'),
-    ('42', 'theanswer', None),
+    ('42', 'theanswer', ''),
     ('42 arg1 arg2', 'theanswer', 'arg1 arg2'),
     ('!ls', 'shell', 'ls'),
     ('!ls -al /tmp', 'shell', 'ls -al /tmp'),
@@ -463,20 +527,17 @@ def test_empty_statement_raises_exception():
 def test_parse_alias_and_shortcut_expansion(parser, line, command, args):
     statement = parser.parse(line)
     assert statement.command == command
-    assert statement.args == args
-    if statement.args is None:
-        assert statement == ''
-    else:
-        assert statement == statement.args
+    assert statement == args
+    assert statement.args == statement
 
 def test_parse_alias_on_multiline_command(parser):
     line = 'anothermultiline has > inside an unfinished command'
     statement = parser.parse(line)
     assert statement.multiline_command == 'multiline'
     assert statement.command == 'multiline'
-    assert statement.args == 'has > inside an unfinished command'
-    assert statement == statement.args
-    assert not statement.terminator
+    assert statement.args == statement
+    assert statement == 'has > inside an unfinished command'
+    assert statement.terminator == ''
 
 @pytest.mark.parametrize('line,output', [
     ('helpalias > out.txt', '>'),
@@ -487,8 +548,8 @@ def test_parse_alias_on_multiline_command(parser):
 def test_parse_alias_redirection(parser, line, output):
     statement = parser.parse(line)
     assert statement.command == 'help'
-    assert statement.args is None
     assert statement == ''
+    assert statement.args == statement
     assert statement.output == output
     assert statement.output_to == 'out.txt'
 
@@ -499,8 +560,8 @@ def test_parse_alias_redirection(parser, line, output):
 def test_parse_alias_pipe(parser, line):
     statement = parser.parse(line)
     assert statement.command == 'help'
-    assert statement.args is None
     assert statement == ''
+    assert statement.args == statement
     assert statement.pipe_to == ['less']
 
 @pytest.mark.parametrize('line', [
@@ -514,76 +575,118 @@ def test_parse_alias_pipe(parser, line):
 def test_parse_alias_terminator_no_whitespace(parser, line):
     statement = parser.parse(line)
     assert statement.command == 'help'
-    assert statement.args is None
     assert statement == ''
+    assert statement.args == statement
     assert statement.terminator == ';'
 
 def test_parse_command_only_command_and_args(parser):
     line = 'help history'
     statement = parser.parse_command_only(line)
+    assert statement == 'history'
+    assert statement.args == statement
+    assert statement.arg_list == []
     assert statement.command == 'help'
-    assert statement.args == 'history'
-    assert statement == statement.args
     assert statement.command_and_args == line
-
-def test_parse_command_only_emptyline(parser):
-    line = ''
-    statement = parser.parse_command_only(line)
-    # statement is a subclass of str(), the value of the str
-    # should be '', to retain backwards compatibility with
-    # the cmd in the standard library
-    assert statement.command is None
-    assert statement.args is None
-    assert statement == ''
-    assert not statement.argv
-    assert statement.command_and_args == None
+    assert statement.multiline_command == ''
+    assert statement.raw == line
+    assert statement.terminator == ''
+    assert statement.suffix == ''
+    assert statement.pipe_to == []
+    assert statement.output == ''
+    assert statement.output_to == ''
 
 def test_parse_command_only_strips_line(parser):
     line = '  help history  '
     statement = parser.parse_command_only(line)
+    assert statement == 'history'
+    assert statement.args == statement
+    assert statement.arg_list == []
     assert statement.command == 'help'
-    assert statement.args == 'history'
-    assert statement == statement.args
     assert statement.command_and_args == line.strip()
+    assert statement.multiline_command == ''
+    assert statement.raw == line
+    assert statement.terminator == ''
+    assert statement.suffix == ''
+    assert statement.pipe_to == []
+    assert statement.output == ''
+    assert statement.output_to == ''
 
 def test_parse_command_only_expands_alias(parser):
-    line = 'fake foobar.py'
+    line = 'fake foobar.py "somebody.py'
     statement = parser.parse_command_only(line)
+    assert statement == 'foobar.py "somebody.py'
+    assert statement.args == statement
+    assert statement.arg_list == []
     assert statement.command == 'pyscript'
-    assert statement.args == 'foobar.py'
-    assert statement == statement.args
+    assert statement.command_and_args == 'pyscript foobar.py "somebody.py'
+    assert statement.multiline_command == ''
+    assert statement.raw == line
+    assert statement.terminator == ''
+    assert statement.suffix == ''
+    assert statement.pipe_to == []
+    assert statement.output == ''
+    assert statement.output_to == ''
 
 def test_parse_command_only_expands_shortcuts(parser):
     line = '!cat foobar.txt'
     statement = parser.parse_command_only(line)
+    assert statement == 'cat foobar.txt'
+    assert statement.args == statement
+    assert statement.arg_list == []
     assert statement.command == 'shell'
-    assert statement.args == 'cat foobar.txt'
-    assert statement == statement.args
     assert statement.command_and_args == 'shell cat foobar.txt'
+    assert statement.multiline_command == ''
+    assert statement.raw == line
+    assert statement.multiline_command == ''
+    assert statement.terminator == ''
+    assert statement.suffix == ''
+    assert statement.pipe_to == []
+    assert statement.output == ''
+    assert statement.output_to == ''
 
 def test_parse_command_only_quoted_args(parser):
     line = 'l "/tmp/directory with spaces/doit.sh"'
     statement = parser.parse_command_only(line)
+    assert statement == 'ls -al "/tmp/directory with spaces/doit.sh"'
+    assert statement.args == statement
+    assert statement.arg_list == []
     assert statement.command == 'shell'
-    assert statement.args == 'ls -al "/tmp/directory with spaces/doit.sh"'
-    assert statement == statement.args
     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;',
-    'help ;;',
-    'help; ;;',
+    assert statement.multiline_command == ''
+    assert statement.raw == line
+    assert statement.multiline_command == ''
+    assert statement.terminator == ''
+    assert statement.suffix == ''
+    assert statement.pipe_to == []
+    assert statement.output == ''
+    assert statement.output_to == ''
+
+@pytest.mark.parametrize('line,args', [
+    ('helpalias > out.txt', '> out.txt'),
+    ('helpalias>out.txt', '>out.txt'),
+    ('helpalias >> out.txt', '>> out.txt'),
+    ('helpalias>>out.txt', '>>out.txt'),
+    ('help|less', '|less'),
+    ('helpalias;', ';'),
+    ('help ;;', ';;'),
+    ('help; ;;', '; ;;'),
 ])
-def test_parse_command_only_specialchars(parser, line):
+def test_parse_command_only_specialchars(parser, line, args):
     statement = parser.parse_command_only(line)
+    assert statement == args
+    assert statement.args == args
     assert statement.command == 'help'
+    assert statement.multiline_command == ''
+    assert statement.raw == line
+    assert statement.multiline_command == ''
+    assert statement.terminator == ''
+    assert statement.suffix == ''
+    assert statement.pipe_to == []
+    assert statement.output == ''
+    assert statement.output_to == ''
 
 @pytest.mark.parametrize('line', [
+    '',
     ';',
     ';;',
     ';; ;',
@@ -595,34 +698,59 @@ def test_parse_command_only_specialchars(parser, line):
     '"',
     '|',
 ])
-def test_parse_command_only_none(parser, line):
+def test_parse_command_only_empty(parser, line):
     statement = parser.parse_command_only(line)
-    assert statement.command is None
-    assert statement.args is None
     assert statement == ''
+    assert statement.args == statement
+    assert statement.arg_list == []
+    assert statement.command == ''
+    assert statement.command_and_args == ''
+    assert statement.multiline_command == ''
+    assert statement.raw == line
+    assert statement.multiline_command == ''
+    assert statement.terminator == ''
+    assert statement.suffix == ''
+    assert statement.pipe_to == []
+    assert statement.output == ''
+    assert statement.output_to == ''
 
 def test_parse_command_only_multiline(parser):
     line = 'multiline with partially "open quotes and no terminator'
     statement = parser.parse_command_only(line)
     assert statement.command == 'multiline'
     assert statement.multiline_command == 'multiline'
-    assert statement.args == 'with partially "open quotes and no terminator'
-    assert statement == statement.args
+    assert statement == 'with partially "open quotes and no terminator'
     assert statement.command_and_args == line
+    assert statement.args == statement
 
 
-def test_statement_initialization(parser):
+def test_statement_initialization():
     string = 'alias'
     statement = cmd2.Statement(string)
     assert string == statement
-    assert statement.raw is None
-    assert statement.command is None
-    assert statement.args is None
+    assert statement.args == statement
+    assert statement.raw == ''
+    assert statement.command == ''
+    assert isinstance(statement.arg_list, list)
+    assert not statement.arg_list
     assert isinstance(statement.argv, list)
     assert not statement.argv
-    assert statement.multiline_command is None
-    assert statement.terminator is None
-    assert statement.suffix is None
-    assert statement.pipe_to is None
-    assert statement.output is None
-    assert statement.output_to is None
+    assert statement.multiline_command == ''
+    assert statement.terminator == ''
+    assert statement.suffix == ''
+    assert isinstance(statement.pipe_to, list)
+    assert not statement.pipe_to
+    assert statement.output == ''
+    assert statement.output_to == ''
+
+
+def test_statement_is_immutable():
+    string = 'foo'
+    statement = cmd2.Statement(string)
+    assert string == statement
+    assert statement.args == statement
+    assert statement.raw == ''
+    with pytest.raises(attr.exceptions.FrozenInstanceError):
+        statement.args = 'bar'
+    with pytest.raises(attr.exceptions.FrozenInstanceError):
+        statement.raw = 'baz'