Repository: python-cmd2/cmd2
Pull request #796 (2019-10-30 23:05:38)

Recursively set parser.prog
It is no longer necessary to set the `prog` attribute of an argparser with subcommands. cmd2 now automatically sets the prog value of it and all its subparsers so that all usage statements contain the top level command name and not sys.argv[0].


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 45b726eb..93bede62 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,9 @@
     showed no record of the run_script command in history. 
     * Made it easier for developers to override `edit` command by having `do_history` no longer call `do_edit`. This
     also removes the need to exclude `edit` command from history list.
+    * It is no longer necessary to set the `prog` attribute of an argparser with subcommands. cmd2 now automatically
+    sets the prog value of it and all its subparsers so that all usage statements contain the top level command name
+    and not sys.argv[0].
 
 ## 0.9.19 (October 14, 2019)
 * Bug Fixes
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index 1af5e932..0a7097ba 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -177,14 +177,38 @@ def with_argument_list(*args: List[Callable], preserve_quotes: bool = False) ->
         return arg_decorator
 
 
-def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser, *,
+# noinspection PyProtectedMember
+def set_parser_prog(parser: argparse.ArgumentParser, prog: str):
+    """
+    Recursively set prog attribute of a parser and all of its subparsers so that the root command
+    is a command name and not sys.argv[0].
+    :param parser: the parser being edited
+    :param prog: value for the current parsers prog attribute
+    """
+    # Set the prog value for this parser
+    parser.prog = prog
+
+    # Set the prog value for the parser's subcommands
+    for action in parser._actions:
+        if isinstance(action, argparse._SubParsersAction):
+
+            # Set the prog value for each subcommand
+            for sub_cmd, sub_cmd_parser in action.choices.items():
+                sub_cmd_prog = parser.prog + ' ' + sub_cmd
+                set_parser_prog(sub_cmd_parser, sub_cmd_prog)
+
+            # We can break since argparse only allows 1 group of subcommands per level
+            break
+
+
+def with_argparser_and_unknown_args(parser: argparse.ArgumentParser, *,
                                     ns_provider: Optional[Callable[..., argparse.Namespace]] = None,
                                     preserve_quotes: bool = False) -> \
         Callable[[argparse.Namespace, List], Optional[bool]]:
     """A decorator to alter a cmd2 method to populate its ``args`` argument by parsing arguments with the given
     instance of argparse.ArgumentParser, but also returning unknown args as a list.
 
-    :param argparser: unique instance of 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.
@@ -209,27 +233,26 @@ def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser, *,
                 namespace = ns_provider(cmd2_app)
 
             try:
-                args, unknown = argparser.parse_known_args(parsed_arglist, namespace)
+                args, unknown = parser.parse_known_args(parsed_arglist, namespace)
             except SystemExit:
                 return
             else:
                 setattr(args, '__statement__', statement)
                 return func(cmd2_app, args, unknown)
 
-        # argparser defaults the program name to sys.argv[0]
-        # we want it to be the name of our command
+        # 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(COMMAND_FUNC_PREFIX):]
-        argparser.prog = command_name
+        set_parser_prog(parser, 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__:
-            argparser.description = func.__doc__
+        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__ = argparser.description
+        cmd_wrapper.__doc__ = parser.description
 
         # Set some custom attributes for this command
-        setattr(cmd_wrapper, CMD_ATTR_ARGPARSER, argparser)
+        setattr(cmd_wrapper, CMD_ATTR_ARGPARSER, parser)
         setattr(cmd_wrapper, CMD_ATTR_PRESERVE_QUOTES, preserve_quotes)
 
         return cmd_wrapper
@@ -238,13 +261,13 @@ def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser, *,
     return arg_decorator
 
 
-def with_argparser(argparser: argparse.ArgumentParser, *,
+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 argparser: unique instance of 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.
@@ -268,27 +291,26 @@ def with_argparser(argparser: argparse.ArgumentParser, *,
                 namespace = ns_provider(cmd2_app)
 
             try:
-                args = argparser.parse_args(parsed_arglist, namespace)
+                args = parser.parse_args(parsed_arglist, namespace)
             except SystemExit:
                 return
             else:
                 setattr(args, '__statement__', statement)
                 return func(cmd2_app, args)
 
-        # argparser defaults the program name to sys.argv[0]
-        # we want it to be the name of our command
+        # 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(COMMAND_FUNC_PREFIX):]
-        argparser.prog = command_name
+        set_parser_prog(parser, 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__:
-            argparser.description = func.__doc__
+        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__ = argparser.description
+        cmd_wrapper.__doc__ = parser.description
 
         # Set some custom attributes for this command
-        setattr(cmd_wrapper, CMD_ATTR_ARGPARSER, argparser)
+        setattr(cmd_wrapper, CMD_ATTR_ARGPARSER, parser)
         setattr(cmd_wrapper, CMD_ATTR_PRESERVE_QUOTES, preserve_quotes)
 
         return cmd_wrapper
@@ -2396,7 +2418,7 @@ class Cmd(cmd.Cmd):
                          "An alias is a command that enables replacement of a word by another string.")
     alias_epilog = ("See also:\n"
                     "  macro")
-    alias_parser = Cmd2ArgumentParser(description=alias_description, epilog=alias_epilog, prog='alias')
+    alias_parser = Cmd2ArgumentParser(description=alias_description, epilog=alias_epilog)
 
     # Add subcommands to alias
     alias_subparsers = alias_parser.add_subparsers(dest='subcommand')
@@ -2573,7 +2595,7 @@ class Cmd(cmd.Cmd):
                          "A macro is similar to an alias, but it can contain argument placeholders.")
     macro_epilog = ("See also:\n"
                     "  alias")
-    macro_parser = Cmd2ArgumentParser(description=macro_description, epilog=macro_epilog, prog='macro')
+    macro_parser = Cmd2ArgumentParser(description=macro_description, epilog=macro_epilog)
 
     # Add subcommands to macro
     macro_subparsers = macro_parser.add_subparsers(dest='subcommand')
diff --git a/examples/subcommands.py b/examples/subcommands.py
index 0b228e79..4f569b1e 100755
--- a/examples/subcommands.py
+++ b/examples/subcommands.py
@@ -12,7 +12,7 @@ import cmd2
 sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball']
 
 # create the top-level parser for the base command
-base_parser = argparse.ArgumentParser(prog='base')
+base_parser = argparse.ArgumentParser()
 base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcommand help')
 
 # create the parser for the "foo" subcommand
@@ -38,7 +38,7 @@ sport_arg = parser_sport.add_argument('sport', help='Enter name of a sport', cho
 
 # create the top-level parser for the alternate command
 # The alternate command doesn't provide its own help flag
-base2_parser = argparse.ArgumentParser(prog='alternate', add_help=False)
+base2_parser = argparse.ArgumentParser(add_help=False)
 base2_subparsers = base2_parser.add_subparsers(title='subcommands', help='subcommand help')
 
 # create the parser for the "foo" subcommand
diff --git a/examples/tab_autocompletion.py b/examples/tab_autocompletion.py
index 2142fe2e..3561f968 100755
--- a/examples/tab_autocompletion.py
+++ b/examples/tab_autocompletion.py
@@ -204,7 +204,7 @@ class TabCompleteExample(cmd2.Cmd):
                                   '\n    '.join(ep_list)))
                 print()
 
-    video_parser = Cmd2ArgumentParser(prog='media')
+    video_parser = Cmd2ArgumentParser()
 
     video_types_subparsers = video_parser.add_subparsers(title='Media Types', dest='type')
 


Test patch:
diff --git a/tests/test_argparse.py b/tests/test_argparse.py
index e5fa6dd0..21ec17e8 100644
--- a/tests/test_argparse.py
+++ b/tests/test_argparse.py
@@ -207,7 +207,7 @@ class SubcommandApp(cmd2.Cmd):
         self.poutput('((%s))' % args.z)
 
     # create the top-level parser for the base command
-    base_parser = argparse.ArgumentParser(prog='base')
+    base_parser = argparse.ArgumentParser()
     base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcommand help')
 
     # create the parser for the "foo" subcommand
diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py
index e54e49c2..b904a6ac 100644
--- a/tests/test_argparse_completer.py
+++ b/tests/test_argparse_completer.py
@@ -63,7 +63,7 @@ class AutoCompleteTester(cmd2.Cmd):
     # Begin code related to help and command name completion
     ############################################################################################################
     # Top level parser for music command
-    music_parser = Cmd2ArgumentParser(description='Manage music', prog='music')
+    music_parser = Cmd2ArgumentParser(description='Manage music')
 
     # Add subcommands to music
     music_subparsers = music_parser.add_subparsers()
diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py
index 99afe2dd..65cbc8da 100644
--- a/tests/test_argparse_custom.py
+++ b/tests/test_argparse_custom.py
@@ -49,7 +49,7 @@ def fake_func():
     ({'completer_function': fake_func, 'completer_method': fake_func}, False),
 ])
 def test_apcustom_choices_callable_count(kwargs, is_valid):
-    parser = Cmd2ArgumentParser(prog='test')
+    parser = Cmd2ArgumentParser()
     try:
         parser.add_argument('name', **kwargs)
         assert is_valid
@@ -66,7 +66,7 @@ def test_apcustom_choices_callable_count(kwargs, is_valid):
 ])
 def test_apcustom_no_choices_callables_alongside_choices(kwargs):
     with pytest.raises(TypeError) as excinfo:
-        parser = Cmd2ArgumentParser(prog='test')
+        parser = Cmd2ArgumentParser()
         parser.add_argument('name', choices=['my', 'choices', 'list'], **kwargs)
     assert 'None of the following parameters can be used alongside a choices parameter' in str(excinfo.value)
 
@@ -79,7 +79,7 @@ def test_apcustom_no_choices_callables_alongside_choices(kwargs):
 ])
 def test_apcustom_no_choices_callables_when_nargs_is_0(kwargs):
     with pytest.raises(TypeError) as excinfo:
-        parser = Cmd2ArgumentParser(prog='test')
+        parser = Cmd2ArgumentParser()
         parser.add_argument('name', action='store_true', **kwargs)
     assert 'None of the following parameters can be used on an action that takes no arguments' in str(excinfo.value)
 
@@ -126,40 +126,40 @@ def test_apcustom_nargs_range_validation(cust_app):
 ])
 def test_apcustom_narg_invalid_tuples(nargs_tuple):
     with pytest.raises(ValueError) as excinfo:
-        parser = Cmd2ArgumentParser(prog='test')
+        parser = Cmd2ArgumentParser()
         parser.add_argument('invalid_tuple', nargs=nargs_tuple)
     assert 'Ranged values for nargs must be a tuple of 1 or 2 integers' in str(excinfo.value)
 
 
 def test_apcustom_narg_tuple_order():
     with pytest.raises(ValueError) as excinfo:
-        parser = Cmd2ArgumentParser(prog='test')
+        parser = Cmd2ArgumentParser()
         parser.add_argument('invalid_tuple', nargs=(2, 1))
     assert 'Invalid nargs range. The first value must be less than the second' in str(excinfo.value)
 
 
 def test_apcustom_narg_tuple_negative():
     with pytest.raises(ValueError) as excinfo:
-        parser = Cmd2ArgumentParser(prog='test')
+        parser = Cmd2ArgumentParser()
         parser.add_argument('invalid_tuple', nargs=(-1, 1))
     assert 'Negative numbers are invalid for nargs range' in str(excinfo.value)
 
 
 # noinspection PyUnresolvedReferences
 def test_apcustom_narg_tuple_zero_base():
-    parser = Cmd2ArgumentParser(prog='test')
+    parser = Cmd2ArgumentParser()
     arg = parser.add_argument('arg', nargs=(0,))
     assert arg.nargs == argparse.ZERO_OR_MORE
     assert arg.nargs_range is None
     assert "[arg [...]]" in parser.format_help()
 
-    parser = Cmd2ArgumentParser(prog='test')
+    parser = Cmd2ArgumentParser()
     arg = parser.add_argument('arg', nargs=(0, 1))
     assert arg.nargs == argparse.OPTIONAL
     assert arg.nargs_range is None
     assert "[arg]" in parser.format_help()
 
-    parser = Cmd2ArgumentParser(prog='test')
+    parser = Cmd2ArgumentParser()
     arg = parser.add_argument('arg', nargs=(0, 3))
     assert arg.nargs == argparse.ZERO_OR_MORE
     assert arg.nargs_range == (0, 3)
@@ -168,13 +168,13 @@ def test_apcustom_narg_tuple_zero_base():
 
 # noinspection PyUnresolvedReferences
 def test_apcustom_narg_tuple_one_base():
-    parser = Cmd2ArgumentParser(prog='test')
+    parser = Cmd2ArgumentParser()
     arg = parser.add_argument('arg', nargs=(1,))
     assert arg.nargs == argparse.ONE_OR_MORE
     assert arg.nargs_range is None
     assert "arg [...]" in parser.format_help()
 
-    parser = Cmd2ArgumentParser(prog='test')
+    parser = Cmd2ArgumentParser()
     arg = parser.add_argument('arg', nargs=(1, 5))
     assert arg.nargs == argparse.ONE_OR_MORE
     assert arg.nargs_range == (1, 5)
@@ -185,13 +185,13 @@ def test_apcustom_narg_tuple_one_base():
 def test_apcustom_narg_tuple_other_ranges():
 
     # Test range with no upper bound on max
-    parser = Cmd2ArgumentParser(prog='test')
+    parser = Cmd2ArgumentParser()
     arg = parser.add_argument('arg', nargs=(2,))
     assert arg.nargs == argparse.ONE_OR_MORE
     assert arg.nargs_range == (2, INFINITY)
 
     # Test finite range
-    parser = Cmd2ArgumentParser(prog='test')
+    parser = Cmd2ArgumentParser()
     arg = parser.add_argument('arg', nargs=(2, 5))
     assert arg.nargs == argparse.ONE_OR_MORE
     assert arg.nargs_range == (2, 5)
@@ -202,13 +202,13 @@ def test_apcustom_print_message(capsys):
     test_message = 'The test message'
 
     # Specify the file
-    parser = Cmd2ArgumentParser(prog='test')
+    parser = Cmd2ArgumentParser()
     parser._print_message(test_message, file=sys.stdout)
     out, err = capsys.readouterr()
     assert test_message in out
 
     # Make sure file defaults to sys.stderr
-    parser = Cmd2ArgumentParser(prog='test')
+    parser = Cmd2ArgumentParser()
     parser._print_message(test_message)
     out, err = capsys.readouterr()
     assert test_message in err
@@ -239,6 +239,6 @@ def test_generate_range_error():
 
 def test_apcustom_required_options():
     # Make sure a 'required arguments' section shows when a flag is marked required
-    parser = Cmd2ArgumentParser(prog='test')
+    parser = Cmd2ArgumentParser()
     parser.add_argument('--required_flag', required=True)
     assert 'required arguments' in parser.format_help()
diff --git a/tests/test_completion.py b/tests/test_completion.py
index c7d9bd21..3b26b044 100755
--- a/tests/test_completion.py
+++ b/tests/test_completion.py
@@ -1078,7 +1078,7 @@ class SubcommandsWithUnknownExample(cmd2.Cmd):
         self.poutput('Sport is {}'.format(args.sport))
 
     # create the top-level parser for the base command
-    base_parser = argparse.ArgumentParser(prog='base')
+    base_parser = argparse.ArgumentParser()
     base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcommand help')
 
     # create the parser for the "foo" subcommand