Skip to content

Commit

Permalink
Merge pull request #984 from python-cmd2/recursion_error
Browse files Browse the repository at this point in the history
Fixed RecursionError when printing an argparse.Namespace
  • Loading branch information
kmvanbrunt authored Aug 25, 2020
2 parents a540cfc + 9b5a988 commit ae7e67c
Show file tree
Hide file tree
Showing 8 changed files with 48 additions and 17 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 3 additions & 1 deletion cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -663,7 +663,9 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
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:
Expand Down
11 changes: 4 additions & 7 deletions cmd2/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,6 @@
# 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'

Expand All @@ -50,13 +46,14 @@
# 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'
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__'
14 changes: 8 additions & 6 deletions cmd2/decorators.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -297,12 +298,13 @@ def cmd_wrapper(*args: Any, **kwargs: Dict[str, Any]) -> Optional[bool]:
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)
Expand Down
3 changes: 2 additions & 1 deletion docs/features/argument_processing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion examples/decorator_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def do_speak(self, args: argparse.Namespace):
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:")
Expand Down
23 changes: 23 additions & 0 deletions tests/test_argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,24 @@ def do_base(self, args):
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()
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ def do_skip_postcmd_hooks(self, _):
@with_argparser(parser)
def do_argparse_cmd(self, namespace: argparse.Namespace):
"""Repeat back the arguments"""
self.poutput(namespace.__statement__)
self.poutput(namespace.get_statement())

###
#
Expand Down

0 comments on commit ae7e67c

Please sign in to comment.