Repository: python-cmd2/cmd2

Issue:
RecursionError when printing argparse Namespace
If I call `print(args)` in a do_* command function, I get the following stack trace.

> Traceback (most recent call last):
>   File "/home/user/PycharmProjects/cmd2/cmd2/cmd2.py", line 2021, in onecmd_plus_hooks
>     stop = self.onecmd(statement, add_to_history=add_to_history)
>   File "/home/user/PycharmProjects/cmd2/cmd2/cmd2.py", line 2451, in onecmd
>     stop = func(statement)
>   File "/home/user/PycharmProjects/cmd2/cmd2/decorators.py", line 309, in cmd_wrapper
>     return func(*args_list, **kwargs)
>   File "/home/user/PycharmProjects/cmd2/cmd2/cmd2.py", line 2686, in do_alias
>     print(args)
>   File "/home/user/miniconda3/envs/cmd2/lib/python3.7/argparse.py", line 122, in __repr__
>     arg_strings.append('%s=%r' % (name, value))
>   File "/home/user/miniconda3/envs/cmd2/lib/python3.7/argparse.py", line 122, in __repr__
>     arg_strings.append('%s=%r' % (name, value))
>   File "/home/user/miniconda3/envs/cmd2/lib/python3.7/argparse.py", line 122, in __repr__
>     arg_strings.append('%s=%r' % (name, value))
>   [Previous line repeated 993 more times]
>   File "/home/user/miniconda3/envs/cmd2/lib/python3.7/argparse.py", line 120, in __repr__
>     for name, value in self._get_kwargs():
>   File "/home/user/miniconda3/envs/cmd2/lib/python3.7/argparse.py", line 130, in _get_kwargs
>     return sorted(self.__dict__.items())
> RecursionError: maximum recursion depth exceeded while calling a Python object
> EXCEPTION of type 'RecursionError' occurred with message: 'maximum recursion depth exceeded while calling a Python object'

I've narrowed down the cause to this line in [decorators.py](https://github.com/python-cmd2/cmd2/blob/master/cmd2/decorators.py)
```Python
setattr(ns, 'get_handler', types.MethodType(get_handler, ns))
```
This change fixes it
```Python
setattr(ns, 'get_handler', functools.partial(get_handler, ns))
```

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/python-cmd2/cmd2/pull/984?src=pr&el=h1) Report
> Merging [#984](https://codecov.io/gh/python-cmd2/cmd2/pull/984?src=pr&el=desc) into [master](https://codecov.io/gh/python-cmd2/cmd2/commit/a540cfc5373cee2272de6c81be8b6fa8a78c6462?el=desc) will **increase** coverage by `0.00%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/python-cmd2/cmd2/pull/984/graphs/tree.svg?width=650&height=150&src=pr&token=eks2PUwa6A)](https://codecov.io/gh/python-cmd2/cmd2/pull/984?src=pr&el=tree)

```diff
@@           Coverage Diff           @@
##           master     #984   +/-   ##
=======================================
  Coverage   97.83%   97.83%           
=======================================
  Files          22       22           
  Lines        4522     4523    +1     
=======================================
+ Hits         4424     4425    +1     
  Misses         98       98           
```


| [Impacted Files](https://codecov.io/gh/python-cmd2/cmd2/pull/984?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [cmd2/cmd2.py](https://codecov.io/gh/python-cmd2/cmd2/pull/984/diff?src=pr&el=tree#diff-Y21kMi9jbWQyLnB5) | `97.21% <100.00%> (+<0.01%)` | :arrow_up: |
| [cmd2/constants.py](https://codecov.io/gh/python-cmd2/cmd2/pull/984/diff?src=pr&el=tree#diff-Y21kMi9jb25zdGFudHMucHk=) | `100.00% <100.00%> (ø)` | |
| [cmd2/decorators.py](https://codecov.io/gh/python-cmd2/cmd2/pull/984/diff?src=pr&el=tree#diff-Y21kMi9kZWNvcmF0b3JzLnB5) | `97.19% <100.00%> (-0.03%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/python-cmd2/cmd2/pull/984?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/python-cmd2/cmd2/pull/984?src=pr&el=footer). Last update [a540cfc...ac1639e](https://codecov.io/gh/python-cmd2/cmd2/pull/984?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 161a058d..f2ba2710 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,9 @@
+## 1.3.5 (TBD)
+* Bug Fixes
+    * Fixed `RecursionError` when printing an `argparse.Namespace` caused by custom attribute cmd2 was adding
+* Enhancements
+    * Added `get_statement()` function to `argparse.Namespace` which returns `__statement__` attribute
+
 ## 1.3.4 (August 20, 2020)
 * Bug Fixes
     * Fixed `AttributeError` when `CommandSet` that uses `as_subcommand_to` decorator is loaded during
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index da576805..bc6691f6 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -663,7 +663,9 @@ class Cmd(cmd.Cmd):
                 raise CommandSetRegistrationError('Could not find argparser for command "{}" needed by subcommand: {}'
                                                   .format(command_name, str(method)))
 
-            subcmd_parser.set_defaults(cmd2_handler=method)
+            # Set the subcommand handler function
+            defaults = {constants.NS_ATTR_SUBCMD_HANDLER: method}
+            subcmd_parser.set_defaults(**defaults)
 
             def find_subcommand(action: argparse.ArgumentParser, subcmd_names: List[str]) -> argparse.ArgumentParser:
                 if not subcmd_names:
diff --git a/cmd2/constants.py b/cmd2/constants.py
index 9eaa9957..037a7cab 100644
--- a/cmd2/constants.py
+++ b/cmd2/constants.py
@@ -37,10 +37,6 @@ HELP_FUNC_PREFIX = 'help_'
 # All command completer functions start with this
 COMPLETER_FUNC_PREFIX = 'complete_'
 
-##############################################################################
-# The following are optional attributes added to do_* command functions
-##############################################################################
-
 # The custom help category a command belongs to
 CMD_ATTR_HELP_CATEGORY = 'help_category'
 
@@ -50,9 +46,6 @@ CMD_ATTR_ARGPARSER = 'argparser'
 # Whether or not tokens are unquoted before sending to argparse
 CMD_ATTR_PRESERVE_QUOTES = 'preserve_quotes'
 
-# optional attribute
-SUBCMD_HANDLER = 'cmd2_handler'
-
 # subcommand attributes for the base command name and the subcommand name
 SUBCMD_ATTR_COMMAND = 'parent_command'
 SUBCMD_ATTR_NAME = 'subcommand_name'
@@ -60,3 +53,7 @@ SUBCMD_ATTR_ADD_PARSER_KWARGS = 'subcommand_add_parser_kwargs'
 
 # arpparse attribute linking to command set instance
 PARSER_ATTR_COMMANDSET = 'command_set'
+
+# custom attributes added to argparse Namespaces
+NS_ATTR_SUBCMD_HANDLER = '__subcmd_handler__'
+NS_ATTR_STATEMENT = '__statement__'
diff --git a/cmd2/decorators.py b/cmd2/decorators.py
index 689f29c5..ccbbd832 100644
--- a/cmd2/decorators.py
+++ b/cmd2/decorators.py
@@ -1,7 +1,6 @@
 # coding=utf-8
 """Decorators for ``cmd2`` commands"""
 import argparse
-import types
 from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Tuple, Union
 
 from . import constants
@@ -190,6 +189,7 @@ def with_argparser_and_unknown_args(parser: argparse.ArgumentParser, *,
              of unknown argument strings. A member called ``__statement__`` is added to the
              ``Namespace`` to provide command functions access to the :class:`cmd2.Statement`
              object. This can be useful if the command function needs to know the command line.
+             ``__statement__`` can also be retrieved by calling ``get_statement()`` on the ``Namespace``.
 
     :Example:
 
@@ -228,6 +228,7 @@ def with_argparser(parser: argparse.ArgumentParser, *,
     :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.
+             ``__statement__`` can also be retrieved by calling ``get_statement()`` on the ``Namespace``.
 
     :Example:
 
@@ -297,12 +298,13 @@ def with_argparser(parser: argparse.ArgumentParser, *,
             except SystemExit:
                 raise Cmd2ArgparseError
             else:
-                setattr(ns, '__statement__', statement)
+                # Add statement to Namespace and a getter function for it
+                setattr(ns, constants.NS_ATTR_STATEMENT, statement)
+                setattr(ns, 'get_statement', lambda: statement)
 
-                def get_handler(ns_self: argparse.Namespace) -> Optional[Callable]:
-                    return getattr(ns_self, constants.SUBCMD_HANDLER, None)
-
-                setattr(ns, 'get_handler', types.MethodType(get_handler, ns))
+                # Add getter function for subcmd handler, which can be None
+                subcmd_handler = getattr(ns, constants.NS_ATTR_SUBCMD_HANDLER, None)
+                setattr(ns, 'get_handler', lambda: subcmd_handler)
 
                 args_list = _arg_swap(args, statement, *new_args)
                 return func(*args_list, **kwargs)
diff --git a/docs/features/argument_processing.rst b/docs/features/argument_processing.rst
index 9e65742e..16170239 100644
--- a/docs/features/argument_processing.rst
+++ b/docs/features/argument_processing.rst
@@ -14,7 +14,8 @@ handles the following for you:
 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``.
+   the ``Namespace`` and can also be retrieved by calling ``get_statement()``
+   on the ``Namespace``.
 
 4. Adds the usage message from the argument parser to your command.
 
diff --git a/examples/decorator_example.py b/examples/decorator_example.py
index 0f5374ce..5b721da6 100755
--- a/examples/decorator_example.py
+++ b/examples/decorator_example.py
@@ -67,7 +67,7 @@ class CmdLineApp(cmd2.Cmd):
     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__
+        statement = args.get_statement()
 
         self.poutput("The command line you ran was: {}".format(statement.command_and_args))
         self.poutput("It generated this tag:")


Test patch:
diff --git a/tests/test_argparse.py b/tests/test_argparse.py
index 0d46b15a..9806c9b5 100644
--- a/tests/test_argparse.py
+++ b/tests/test_argparse.py
@@ -290,6 +290,24 @@ class SubcommandApp(cmd2.Cmd):
         func(self, args)
 
 
+    # Add a subcommand using as_subcommand_to decorator
+    has_subcmd_parser = cmd2.Cmd2ArgumentParser(description="Tests as_subcmd_to decorator")
+    has_subcmd_subparsers = has_subcmd_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND')
+    has_subcmd_subparsers.required = True
+
+    @cmd2.with_argparser(has_subcmd_parser)
+    def do_test_subcmd_decorator(self, args: argparse.Namespace):
+        handler = args.get_handler()
+        handler(args)
+
+    subcmd_parser = cmd2.Cmd2ArgumentParser(add_help=False, description="The subcommand")
+
+    @cmd2.as_subcommand_to('test_subcmd_decorator', 'subcmd', subcmd_parser, help='the subcommand')
+    def subcmd_func(self, args: argparse.Namespace):
+        # Make sure printing the Namespace works. The way we originally added get_hander()
+        # to it resulted in a RecursionError when printing.
+        print(args)
+
 @pytest.fixture
 def subcommand_app():
     app = SubcommandApp()
@@ -373,6 +391,11 @@ def test_add_another_subcommand(subcommand_app):
     assert new_parser.prog == "base new_sub"
 
 
+def test_subcmd_decorator(subcommand_app):
+    out, err = run_cmd(subcommand_app, 'test_subcmd_decorator subcmd')
+    assert out[0].startswith('Namespace(')
+
+
 def test_unittest_mock():
     from unittest import mock
     from cmd2 import CommandSetRegistrationError
diff --git a/tests/test_plugin.py b/tests/test_plugin.py
index e49cbbfc..d40a9b61 100644
--- a/tests/test_plugin.py
+++ b/tests/test_plugin.py
@@ -279,7 +279,7 @@ class PluggedApp(Plugin, cmd2.Cmd):
     @with_argparser(parser)
     def do_argparse_cmd(self, namespace: argparse.Namespace):
         """Repeat back the arguments"""
-        self.poutput(namespace.__statement__)
+        self.poutput(namespace.get_statement())
 
 ###
 #