Repository: python-cmd2/cmd2
Pull request #649 (2019-03-17 04:06:45)

Cmd line
Added Statement object to argparse Namespace passed to wrapped functions


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6d21b33c..01b30027 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,9 @@
     also be colored.
         * `help_error` - the error that prints when no help information can be found
         * `default_error` - the error that prints when a non-existent command is run
+    * The `with_argparser` decorators now add the Statement object created when parsing the command line to the
+    `argparse.Namespace` object they pass to the `do_*` methods. It is stored in an attribute called `__statement__`.
+    This can be useful if a command function needs to know the command line for things like logging.
 * Potentially breaking changes
     * The following commands now write to stderr instead of stdout when printing an error. This will make catching
     errors easier in pyscript.
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index e41d947d..13278b44 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -49,7 +49,7 @@ from . import utils
 from .argparse_completer import AutoCompleter, ACArgumentParser, ACTION_ARG_CHOICES
 from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer
 from .history import History, HistoryItem
-from .parsing import StatementParser, Statement, Macro, MacroArg, shlex_split, get_command_arg_list
+from .parsing import StatementParser, Statement, Macro, MacroArg, shlex_split
 
 # Set up readline
 from .rl_utils import rl_type, RlType, rl_get_point, rl_set_prompt, vt100_support, rl_make_safe_prompt
@@ -174,9 +174,13 @@ def with_argument_list(*args: List[Callable], preserve_quotes: bool = False) ->
     def arg_decorator(func: Callable):
         @functools.wraps(func)
         def cmd_wrapper(cmd2_instance, statement: Union[Statement, str]):
-            parsed_arglist = get_command_arg_list(statement, preserve_quotes)
+            _, parsed_arglist = cmd2_instance.statement_parser.get_command_arg_list(command_name,
+                                                                                    statement,
+                                                                                    preserve_quotes)
+
             return func(cmd2_instance, parsed_arglist)
 
+        command_name = func.__name__[len(COMMAND_FUNC_PREFIX):]
         cmd_wrapper.__doc__ = func.__doc__
         return cmd_wrapper
 
@@ -193,7 +197,10 @@ def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser, preserve
 
     :param argparser: unique instance of ArgumentParser
     :param preserve_quotes: if True, then arguments passed to argparse maintain their quotes
-    :return: function that gets passed argparse-parsed args and a list of unknown argument strings
+    :return: function that gets passed argparse-parsed args in a Namespace and a list of unknown argument strings
+             A member called __statement__ is added to the Namespace to provide command functions access to the
+             Statement object. This can be useful if the command function needs to know the command line.
+
     """
     import functools
 
@@ -201,18 +208,22 @@ def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser, preserve
     def arg_decorator(func: Callable):
         @functools.wraps(func)
         def cmd_wrapper(cmd2_instance, statement: Union[Statement, str]):
-            parsed_arglist = get_command_arg_list(statement, preserve_quotes)
+            statement, parsed_arglist = cmd2_instance.statement_parser.get_command_arg_list(command_name,
+                                                                                            statement,
+                                                                                            preserve_quotes)
 
             try:
                 args, unknown = argparser.parse_known_args(parsed_arglist)
             except SystemExit:
                 return
             else:
+                setattr(args, '__statement__', statement)
                 return func(cmd2_instance, args, unknown)
 
         # argparser defaults the program name to sys.argv[0]
         # we want it to be the name of our command
-        argparser.prog = func.__name__[len(COMMAND_FUNC_PREFIX):]
+        command_name = func.__name__[len(COMMAND_FUNC_PREFIX):]
+        argparser.prog = command_name
 
         # If the description has not been set, then use the method docstring if one exists
         if argparser.description is None and func.__doc__:
@@ -236,7 +247,9 @@ def with_argparser(argparser: argparse.ArgumentParser,
 
     :param argparser: unique instance of ArgumentParser
     :param preserve_quotes: if True, then arguments passed to argparse maintain their quotes
-    :return: function that gets passed the argparse-parsed args
+    :return: function that gets passed the argparse-parsed args in a Namespace
+             A member called __statement__ is added to the Namespace to provide command functions access to the
+             Statement object. This can be useful if the command function needs to know the command line.
     """
     import functools
 
@@ -244,19 +257,21 @@ def with_argparser(argparser: argparse.ArgumentParser,
     def arg_decorator(func: Callable):
         @functools.wraps(func)
         def cmd_wrapper(cmd2_instance, statement: Union[Statement, str]):
-
-            parsed_arglist = get_command_arg_list(statement, preserve_quotes)
-
+            statement, parsed_arglist = cmd2_instance.statement_parser.get_command_arg_list(command_name,
+                                                                                            statement,
+                                                                                            preserve_quotes)
             try:
                 args = argparser.parse_args(parsed_arglist)
             except SystemExit:
                 return
             else:
+                setattr(args, '__statement__', statement)
                 return func(cmd2_instance, args)
 
         # argparser defaults the program name to sys.argv[0]
         # we want it to be the name of our command
-        argparser.prog = func.__name__[len(COMMAND_FUNC_PREFIX):]
+        command_name = func.__name__[len(COMMAND_FUNC_PREFIX):]
+        argparser.prog = command_name
 
         # If the description has not been set, then use the method docstring if one exists
         if argparser.description is None and func.__doc__:
diff --git a/cmd2/parsing.py b/cmd2/parsing.py
index d72ca4ec..514f5faf 100644
--- a/cmd2/parsing.py
+++ b/cmd2/parsing.py
@@ -236,34 +236,6 @@ class Statement(str):
         return rtn
 
 
-def get_command_arg_list(to_parse: Union[Statement, str], preserve_quotes: bool) -> List[str]:
-    """
-    Called by the argument_list and argparse wrappers to retrieve just the arguments being
-    passed to their do_* methods as a list.
-
-    :param to_parse: what is being passed to the do_* method. It can be one of two types:
-                     1. An already parsed Statement
-                     2. An argument string in cases where a do_* method is explicitly called
-                        e.g.: Calling do_help('alias create') would cause to_parse to be 'alias create'
-
-    :param preserve_quotes: if True, then quotes will not be stripped from the arguments
-    :return: the arguments in a list
-    """
-    if isinstance(to_parse, Statement):
-        # In the case of a Statement, we already have what we need
-        if preserve_quotes:
-            return to_parse.arg_list
-        else:
-            return to_parse.argv[1:]
-    else:
-        # We have the arguments in a string. Use shlex to split it.
-        parsed_arglist = shlex_split(to_parse)
-        if not preserve_quotes:
-            parsed_arglist = [utils.strip_quotes(arg) for arg in parsed_arglist]
-
-        return parsed_arglist
-
-
 class StatementParser:
     """Parse raw text into command components.
 
@@ -371,16 +343,22 @@ class StatementParser:
                 errmsg = ''
         return valid, errmsg
 
-    def tokenize(self, line: str) -> List[str]:
-        """Lex a string into a list of tokens.
-
-        shortcuts and aliases are expanded and comments are removed
-
-        Raises ValueError if there are unclosed quotation marks.
+    def tokenize(self, line: str, expand: bool = True) -> List[str]:
+        """
+        Lex a string into a list of tokens. Shortcuts and aliases are expanded and comments are removed
+
+        :param line: the command line being lexed
+        :param expand: If True, then aliases and shortcuts will be expanded.
+                       Set this to False if no expansion should occur because the command name is already known.
+                       Otherwise the command could be expanded if it matched an alias name. This is for cases where
+                       a do_* method was called manually (e.g do_help('alias').
+        :return: A list of tokens
+        :raises ValueError if there are unclosed quotation marks.
         """
 
         # expand shortcuts and aliases
-        line = self._expand(line)
+        if expand:
+            line = self._expand(line)
 
         # check if this line is a comment
         if line.strip().startswith(constants.COMMENT_CHAR):
@@ -393,12 +371,19 @@ class StatementParser:
         tokens = self._split_on_punctuation(tokens)
         return tokens
 
-    def parse(self, line: str) -> Statement:
-        """Tokenize the input and parse it into a Statement object, stripping
+    def parse(self, line: str, expand: bool = True) -> Statement:
+        """
+        Tokenize the input and parse it into a Statement object, stripping
         comments, expanding aliases and shortcuts, and extracting output
         redirection directives.
 
-        Raises ValueError if there are unclosed quotation marks.
+        :param line: the command line being parsed
+        :param expand: If True, then aliases and shortcuts will be expanded.
+                       Set this to False if no expansion should occur because the command name is already known.
+                       Otherwise the command could be expanded if it matched an alias name. This is for cases where
+                       a do_* method was called manually (e.g do_help('alias').
+        :return: A parsed Statement
+        :raises ValueError if there are unclosed quotation marks
         """
 
         # handle the special case/hardcoded terminator of a blank line
@@ -413,7 +398,7 @@ class StatementParser:
         arg_list = []
 
         # lex the input into a list of tokens
-        tokens = self.tokenize(line)
+        tokens = self.tokenize(line, expand)
 
         # of the valid terminators, find the first one to occur in the input
         terminator_pos = len(tokens) + 1
@@ -594,6 +579,35 @@ class StatementParser:
                               )
         return statement
 
+    def get_command_arg_list(self, command_name: str, to_parse: Union[Statement, str],
+                             preserve_quotes: bool) -> Tuple[Statement, List[str]]:
+        """
+        Called by the argument_list and argparse wrappers to retrieve just the arguments being
+        passed to their do_* methods as a list.
+
+        :param command_name: name of the command being run
+        :param to_parse: what is being passed to the do_* method. It can be one of two types:
+                         1. An already parsed Statement
+                         2. An argument string in cases where a do_* method is explicitly called
+                            e.g.: Calling do_help('alias create') would cause to_parse to be 'alias create'
+
+                            In this case, the string will be converted to a Statement and returned along
+                            with the argument list.
+
+        :param preserve_quotes: if True, then quotes will not be stripped from the arguments
+        :return: A tuple containing:
+                    The Statement used to retrieve the arguments
+                    The argument list
+        """
+        # Check if to_parse needs to be converted to a Statement
+        if not isinstance(to_parse, Statement):
+            to_parse = self.parse(command_name + ' ' + to_parse, expand=False)
+
+        if preserve_quotes:
+            return to_parse, to_parse.arg_list
+        else:
+            return to_parse, to_parse.argv[1:]
+
     def _expand(self, line: str) -> str:
         """Expand shortcuts and aliases"""
 
diff --git a/docs/argument_processing.rst b/docs/argument_processing.rst
index bad683bf..fc1f2433 100644
--- a/docs/argument_processing.rst
+++ b/docs/argument_processing.rst
@@ -9,7 +9,9 @@ Argument Processing
 
 1. Parsing input and quoted strings like the Unix shell
 2. Parse the resulting argument list using an instance of ``argparse.ArgumentParser`` that you provide
-3. Passes the resulting ``argparse.Namespace`` object to your command function
+3. Passes the resulting ``argparse.Namespace`` object to your command function. The ``Namespace`` includes the
+   ``Statement`` object that was created when parsing the command line. It is stored in the ``__statement__``
+   attribute of the ``Namespace``.
 4. Adds the usage message from the argument parser to your command.
 5. Checks if the ``-h/--help`` option is present, and if so, display the help message for the command
 
diff --git a/examples/decorator_example.py b/examples/decorator_example.py
index 5d127619..79bd7633 100755
--- a/examples/decorator_example.py
+++ b/examples/decorator_example.py
@@ -12,6 +12,7 @@ verifying that the output produced matches the transcript.
 """
 import argparse
 import sys
+from typing import List
 
 import cmd2
 
@@ -46,7 +47,7 @@ class CmdLineApp(cmd2.Cmd):
     speak_parser.add_argument('words', nargs='+', help='words to say')
 
     @cmd2.with_argparser(speak_parser)
-    def do_speak(self, args):
+    def do_speak(self, args: argparse.Namespace):
         """Repeats what you tell me to."""
         words = []
         for word in args.words:
@@ -67,13 +68,18 @@ class CmdLineApp(cmd2.Cmd):
     tag_parser.add_argument('content', nargs='+', help='content to surround with tag')
 
     @cmd2.with_argparser(tag_parser)
-    def do_tag(self, args):
-        """create a html tag"""
+    def do_tag(self, args: argparse.Namespace):
+        """create an html tag"""
+        # The Namespace always includes the Statement object created when parsing the command line
+        statement = args.__statement__
+
+        self.poutput("The command line you ran was: {}".format(statement.command_and_args))
+        self.poutput("It generated this tag:")
         self.poutput('<{0}>{1}</{0}>'.format(args.tag, ' '.join(args.content)))
 
     @cmd2.with_argument_list
-    def do_tagg(self, arglist):
-        """verion of creating an html tag using arglist instead of argparser"""
+    def do_tagg(self, arglist: List[str]):
+        """version of creating an html tag using arglist instead of argparser"""
         if len(arglist) >= 2:
             tag = arglist[0]
             content = arglist[1:]


Test patch:
diff --git a/tests/test_parsing.py b/tests/test_parsing.py
index 85ee0765..8cea3305 100644
--- a/tests/test_parsing.py
+++ b/tests/test_parsing.py
@@ -471,11 +471,18 @@ def test_empty_statement_raises_exception():
     ('l', 'shell', 'ls -al')
 ])
 def test_parse_alias_and_shortcut_expansion(parser, line, command, args):
+    # Test first with expansion
     statement = parser.parse(line)
     assert statement.command == command
     assert statement == args
     assert statement.args == statement
 
+    # Now allow no expansion
+    statement = parser.parse(line, expand=False)
+    assert statement.command == line.split()[0]
+    assert statement.split() == line.split()[1:]
+    assert statement.args == statement
+
 def test_parse_alias_on_multiline_command(parser):
     line = 'anothermultiline has > inside an unfinished command'
     statement = parser.parse(line)