Repository: python-cmd2/cmd2

Issue:
Allow passing extra args to **do_** functions
Please allow passing extra args to **do_** functions

In this example, I add __**kwargs__ to the decorator.

```python
def with_argparser(parser: argparse.ArgumentParser, *,
                   ns_provider: Optional[Callable[..., argparse.Namespace]] = None,
                   preserve_quotes: bool = False) -> Callable[[argparse.Namespace], Optional[bool]]:
    """A decorator to alter a cmd2 method to populate its ``args`` argument by parsing arguments
    with the given instance of argparse.ArgumentParser.

    :param parser: unique instance of ArgumentParser
    :param ns_provider: An optional function that accepts a cmd2.Cmd object as an argument and returns an
                        argparse.Namespace. This is useful if the Namespace needs to be prepopulated with
                        state data that affects parsing.
    :param preserve_quotes: if True, then arguments passed to argparse maintain their quotes
    :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.

    :Example:

    >>> parser = argparse.ArgumentParser()
    >>> parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay')
    >>> parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE')
    >>> parser.add_argument('-r', '--repeat', type=int, help='output [n] times')
    >>> parser.add_argument('words', nargs='+', help='words to print')
    >>>
    >>> class MyApp(cmd2.Cmd):
    >>>     @cmd2.with_argparser(parser, preserve_quotes=True)
    >>>     def do_argprint(self, args):
    >>>         "Print the options and argument list this options command was called with."
    >>>         self.poutput('args: {!r}'.format(args))
    """
    import functools

    def arg_decorator(func: Callable):
        @functools.wraps(func)
        def cmd_wrapper(cmd2_app, statement: Union[Statement, str], **kwargs):
            statement, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(command_name,
                                                                                       statement,
                                                                                       preserve_quotes)

            if ns_provider is None:
                namespace = None
            else:
                namespace = ns_provider(cmd2_app)

            try:
                args = parser.parse_args(parsed_arglist, namespace)
            except SystemExit:
                raise Cmd2ArgparseError
            else:
                setattr(args, '__statement__', statement)
                return func(cmd2_app, args, **kwargs)

        # argparser defaults the program name to sys.argv[0], but we want it to be the name of our command
        command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX):]
        _set_parser_prog(parser, command_name)

        # If the description has not been set, then use the method docstring if one exists
        if parser.description is None and func.__doc__:
            parser.description = func.__doc__

        # Set the command's help text as argparser.description (which can be None)
        cmd_wrapper.__doc__ = parser.description

        # Set some custom attributes for this command
        setattr(cmd_wrapper, constants.CMD_ATTR_ARGPARSER, parser)
        setattr(cmd_wrapper, constants.CMD_ATTR_PRESERVE_QUOTES, preserve_quotes)

        return cmd_wrapper

    # noinspection PyTypeChecker
    return arg_decorator
```

**run_pyscript** now call like this:
```
self.do_stuff("arg1 --xxx --yyy hhh", stuff=True)
```

To do the same thing without **kwargs**, actually I must pass arguments like that:
```python
parser.add_argument('--stuff', action='store_true', help=argparse.SUPPRESS)
self.do_stuff("search xx --stuff")
```

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 01180001..03b88d03 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,7 +3,9 @@
     * Made `ipy` consistent with `py` in the following ways
         * `ipy` returns whether any of the commands run in it returned True to stop command loop
         * `Cmd.in_pyscript()` returns True while in `ipy`.
-        * Starting `ipy` when `Cmd.in_pyscript()` is already True is not allowed.  
+        * Starting `ipy` when `Cmd.in_pyscript()` is already True is not allowed.
+    * `with_argument_list`, `with_argparser`, and `with_argparser_and_unknown_args` wrappers now pass
+      `kwargs` through to their wrapped command function.
     
 ## 1.0.2 (April 06, 2020)
 * Bug Fixes
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index 98969d10..34f53044 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -3169,17 +3169,16 @@ class Cmd(cmd.Cmd):
     py_parser.add_argument('command', nargs=argparse.OPTIONAL, help="command to run")
     py_parser.add_argument('remainder', nargs=argparse.REMAINDER, help="remainder of command")
 
-    # This is a hidden flag for telling do_py to run a pyscript. It is intended only to be used by run_pyscript
-    # after it sets up sys.argv for the script being run. When this flag is present, it takes precedence over all
-    # other arguments.
-    py_parser.add_argument('--pyscript', help=argparse.SUPPRESS)
-
     # Preserve quotes since we are passing these strings to Python
     @with_argparser(py_parser, preserve_quotes=True)
-    def do_py(self, args: argparse.Namespace) -> Optional[bool]:
+    def do_py(self, args: argparse.Namespace, *, pyscript: Optional[str] = None) -> Optional[bool]:
         """
         Enter an interactive Python shell
 
+        :param args: Namespace of args on the command line
+        :param pyscript: optional path to a pyscript file to run. This is intended only to be used by run_pyscript
+                         after it sets up sys.argv for the script. If populated, this takes precedence over all
+                         other arguments. (Defaults to None)
         :return: True if running of commands should stop
         """
         def py_quit():
@@ -3211,9 +3210,9 @@ class Cmd(cmd.Cmd):
                 localvars['self'] = self
 
             # Handle case where we were called by run_pyscript
-            if args.pyscript:
+            if pyscript is not None:
                 # Read the script file
-                expanded_filename = os.path.expanduser(utils.strip_quotes(args.pyscript))
+                expanded_filename = os.path.expanduser(pyscript)
 
                 try:
                     with open(expanded_filename) as f:
@@ -3320,7 +3319,7 @@ class Cmd(cmd.Cmd):
             sys.argv = [args.script_path] + args.script_arguments
 
             # noinspection PyTypeChecker
-            py_return = self.do_py('--pyscript {}'.format(utils.quote_string(args.script_path)))
+            py_return = self.do_py('', pyscript=args.script_path)
 
         finally:
             # Restore command line arguments to original state
diff --git a/cmd2/decorators.py b/cmd2/decorators.py
index deac4701..dc196032 100644
--- a/cmd2/decorators.py
+++ b/cmd2/decorators.py
@@ -1,7 +1,7 @@
 # coding=utf-8
 """Decorators for ``cmd2`` commands"""
 import argparse
-from typing import Callable, List, Optional, Union
+from typing import Any, Callable, Dict, List, Optional, Union
 
 from . import constants
 from .exceptions import Cmd2ArgparseError
@@ -53,12 +53,20 @@ def with_argument_list(*args: List[Callable], preserve_quotes: bool = False) ->
 
     def arg_decorator(func: Callable):
         @functools.wraps(func)
-        def cmd_wrapper(cmd2_app, statement: Union[Statement, str]):
+        def cmd_wrapper(cmd2_app, statement: Union[Statement, str], **kwargs: Dict[str, Any]) -> Optional[bool]:
+            """
+            Command function wrapper which translates command line into an argument list and calls actual command function
+
+            :param cmd2_app: CLI instance passed as self parameter to command function
+            :param statement: command line string or already generated Statement
+            :param kwargs: any keyword arguments being passed to command function
+            :return: return value of command function
+            """
             _, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(command_name,
                                                                                statement,
                                                                                preserve_quotes)
 
-            return func(cmd2_app, parsed_arglist)
+            return func(cmd2_app, parsed_arglist, **kwargs)
 
         command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX):]
         cmd_wrapper.__doc__ = func.__doc__
@@ -132,7 +140,17 @@ def with_argparser_and_unknown_args(parser: argparse.ArgumentParser, *,
 
     def arg_decorator(func: Callable):
         @functools.wraps(func)
-        def cmd_wrapper(cmd2_app, statement: Union[Statement, str]):
+        def cmd_wrapper(cmd2_app, statement: Union[Statement, str], **kwargs: Dict[str, Any]) -> Optional[bool]:
+            """
+            Command function wrapper which translates command line into argparse Namespace and calls actual
+            command function
+
+            :param cmd2_app: CLI instance passed as self parameter to command function
+            :param statement: command line string or already generated Statement
+            :param kwargs: any keyword arguments being passed to command function
+            :return: return value of command function
+            :raises: Cmd2ArgparseError if argparse has error parsing command line
+            """
             statement, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(command_name,
                                                                                        statement,
                                                                                        preserve_quotes)
@@ -148,7 +166,7 @@ def with_argparser_and_unknown_args(parser: argparse.ArgumentParser, *,
                 raise Cmd2ArgparseError
             else:
                 setattr(args, '__statement__', statement)
-                return func(cmd2_app, args, unknown)
+                return func(cmd2_app, args, unknown, **kwargs)
 
         # argparser defaults the program name to sys.argv[0], but we want it to be the name of our command
         command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX):]
@@ -204,7 +222,17 @@ def with_argparser(parser: argparse.ArgumentParser, *,
 
     def arg_decorator(func: Callable):
         @functools.wraps(func)
-        def cmd_wrapper(cmd2_app, statement: Union[Statement, str]):
+        def cmd_wrapper(cmd2_app, statement: Union[Statement, str], **kwargs: Dict[str, Any]) -> Optional[bool]:
+            """
+            Command function wrapper which translates command line into argparse Namespace and calls actual
+            command function
+
+            :param cmd2_app: CLI instance passed as self parameter to command function
+            :param statement: command line string or already generated Statement
+            :param kwargs: any keyword arguments being passed to command function
+            :return: return value of command function
+            :raises: Cmd2ArgparseError if argparse has error parsing command line
+            """
             statement, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(command_name,
                                                                                        statement,
                                                                                        preserve_quotes)
@@ -220,7 +248,7 @@ def with_argparser(parser: argparse.ArgumentParser, *,
                 raise Cmd2ArgparseError
             else:
                 setattr(args, '__statement__', statement)
-                return func(cmd2_app, args)
+                return func(cmd2_app, args, **kwargs)
 
         # argparser defaults the program name to sys.argv[0], but we want it to be the name of our command
         command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX):]


Test patch:
diff --git a/tests/test_argparse.py b/tests/test_argparse.py
index 21ec17e8..9510e8f2 100644
--- a/tests/test_argparse.py
+++ b/tests/test_argparse.py
@@ -4,11 +4,11 @@
 Cmd2 testing for argument parsing
 """
 import argparse
+from typing import Optional
+
 import pytest
 
 import cmd2
-from cmd2.utils import StdSim
-
 from .conftest import run_cmd
 
 # Prefer statically linked gnureadline if available (for macOS compatibility due to issues with libedit)
@@ -41,7 +41,7 @@ class ArgparseApp(cmd2.Cmd):
     say_parser.add_argument('words', nargs='+', help='words to say')
 
     @cmd2.with_argparser(say_parser)
-    def do_say(self, args):
+    def do_say(self, args, *, keyword_arg: Optional[str] = None):
         """Repeat what you tell me to."""
         words = []
         for word in args.words:
@@ -57,6 +57,9 @@ class ArgparseApp(cmd2.Cmd):
             self.stdout.write(' '.join(words))
             self.stdout.write('\n')
 
+        if keyword_arg is not None:
+            print(keyword_arg)
+
     tag_parser = argparse.ArgumentParser(description='create a html tag')
     tag_parser.add_argument('tag', help='tag')
     tag_parser.add_argument('content', nargs='+', help='content to surround with tag')
@@ -71,12 +74,15 @@ class ArgparseApp(cmd2.Cmd):
         self.stdout.write('{}'.format(args.custom_stuff))
 
     @cmd2.with_argument_list
-    def do_arglist(self, arglist):
+    def do_arglist(self, arglist, *, keyword_arg: Optional[str] = None):
         if isinstance(arglist, list):
             self.stdout.write('True')
         else:
             self.stdout.write('False')
 
+        if keyword_arg is not None:
+            print(keyword_arg)
+
     @cmd2.with_argument_list(preserve_quotes=True)
     def do_preservelist(self, arglist):
         self.stdout.write('{}'.format(arglist))
@@ -86,7 +92,7 @@ class ArgparseApp(cmd2.Cmd):
     known_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE')
     known_parser.add_argument('-r', '--repeat', type=int, help='output [n] times')
     @cmd2.with_argparser_and_unknown_args(known_parser)
-    def do_speak(self, args, extra):
+    def do_speak(self, args, extra, *, keyword_arg: Optional[str] = None):
         """Repeat what you tell me to."""
         words = []
         for word in extra:
@@ -102,6 +108,9 @@ class ArgparseApp(cmd2.Cmd):
             self.stdout.write(' '.join(words))
             self.stdout.write('\n')
 
+        if keyword_arg is not None:
+            print(keyword_arg)
+
     @cmd2.with_argparser_and_unknown_args(argparse.ArgumentParser(), preserve_quotes=True)
     def do_test_argparse_with_list_quotes(self, args, extra):
         self.stdout.write('{}'.format(' '.join(extra)))
@@ -129,6 +138,12 @@ def test_argparse_remove_quotes(argparse_app):
     out, err = run_cmd(argparse_app, 'say "hello there"')
     assert out == ['hello there']
 
+def test_argparser_kwargs(argparse_app, capsys):
+    """Test with_argparser wrapper passes through kwargs to command function"""
+    argparse_app.do_say('word', keyword_arg="foo")
+    out, err = capsys.readouterr()
+    assert out == "foo\n"
+
 def test_argparse_preserve_quotes(argparse_app):
     out, err = run_cmd(argparse_app, 'tag mytag "hello"')
     assert out[0] == '<mytag>"hello"</mytag>'
@@ -161,6 +176,12 @@ def test_argparser_correct_args_with_quotes_and_midline_options(argparse_app):
     out, err = run_cmd(argparse_app, "speak 'This  is a' -s test of the emergency broadcast system!")
     assert out == ['THIS  IS A TEST OF THE EMERGENCY BROADCAST SYSTEM!']
 
+def test_argparser_and_unknown_args_kwargs(argparse_app, capsys):
+    """Test with_argparser_and_unknown_args wrapper passes through kwargs to command function"""
+    argparse_app.do_speak('', keyword_arg="foo")
+    out, err = capsys.readouterr()
+    assert out == "foo\n"
+
 def test_argparse_quoted_arguments_multiple(argparse_app):
     out, err = run_cmd(argparse_app, 'say "hello  there" "rick & morty"')
     assert out == ['hello  there rick & morty']
@@ -186,6 +207,12 @@ def test_arglist(argparse_app):
     out, err = run_cmd(argparse_app, 'arglist "we  should" get these')
     assert out[0] == 'True'
 
+def test_arglist_kwargs(argparse_app, capsys):
+    """Test with_argument_list wrapper passes through kwargs to command function"""
+    argparse_app.do_arglist('arg', keyword_arg="foo")
+    out, err = capsys.readouterr()
+    assert out == "foo\n"
+
 def test_preservelist(argparse_app):
     out, err = run_cmd(argparse_app, 'preservelist foo "bar baz"')
     assert out[0] == "['foo', '\"bar baz\"']"