Repository: python-cmd2/cmd2

Issue:
Backport bug in help completion handling of commands using argparser_with_unknown_args
During the autocompleter development I found a bug in handling of help completion that affects the 0.8.x line so I'm going to port those changes back to the python2 branch.

Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c0c1601..d382fc75 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,6 @@
 ## 0.8.6 (TBD)
     * Bug Fixes
-        * TBD
+        * Commands using the @with_argparser_and_unknown_args were not correctly recognized when tab completing help
 
 ## 0.8.5 (April 15, 2018)
 * Bug Fixes
diff --git a/cmd2.py b/cmd2.py
index ec07510e..4c91a6a5 100755
--- a/cmd2.py
+++ b/cmd2.py
@@ -420,8 +420,18 @@ def with_argparser_and_unknown_args(argparser):
 
         # If there are subcommands, store their names in a list to support tab-completion of subcommand names
         if argparser._subparsers is not None:
-            subcommand_names = argparser._subparsers._group_actions[0]._name_parser_map.keys()
-            cmd_wrapper.__dict__['subcommand_names'] = subcommand_names
+            # Key is subcommand name and value is completer function
+            subcommands = collections.OrderedDict()
+
+            # Get all subcommands and check if they have completer functions
+            for name, parser in argparser._subparsers._group_actions[0]._name_parser_map.items():
+                if 'completer' in parser._defaults:
+                    completer = parser._defaults['completer']
+                else:
+                    completer = None
+                subcommands[name] = completer
+
+            cmd_wrapper.__dict__['subcommands'] = subcommands
 
         return cmd_wrapper
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 837e7504..1433d425 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -8,9 +8,24 @@ Released under MIT license, see LICENSE file
 import sys
 
 from pytest import fixture
+try:
+    from unittest import mock
+except ImportError:
+    import mock
 
 import cmd2
 
+# Prefer statically linked gnureadline if available (for macOS compatibility due to issues with libedit)
+try:
+    import gnureadline as readline
+except ImportError:
+    # Try to import readline, but allow failure for convenience in Windows unit testing
+    # Note: If this actually fails, you should install readline on Linux or Mac or pyreadline on Windows
+    try:
+        # noinspection PyUnresolvedReferences
+        import readline
+    except ImportError:
+        pass
 
 # Help text for base cmd2.Cmd application
 BASE_HELP = """Documented commands (type help <topic>):
@@ -141,3 +156,38 @@ def base_app():
     c = cmd2.Cmd()
     c.stdout = StdOut()
     return c
+
+
+def complete_tester(text, line, begidx, endidx, app):
+    """
+    This is a convenience function to test cmd2.complete() since
+    in a unit test environment there is no actual console readline
+    is monitoring. Therefore we use mock to provide readline data
+    to complete().
+
+    :param text: str - the string prefix we are attempting to match
+    :param line: str - the current input line with leading whitespace removed
+    :param begidx: int - the beginning index of the prefix text
+    :param endidx: int - the ending index of the prefix text
+    :param app: the cmd2 app that will run completions
+    :return: The first matched string or None if there are no matches
+             Matches are stored in app.completion_matches
+             These matches also have been sorted by complete()
+    """
+    def get_line():
+        return line
+
+    def get_begidx():
+        return begidx
+
+    def get_endidx():
+        return endidx
+
+    first_match = None
+    with mock.patch.object(readline, 'get_line_buffer', get_line):
+        with mock.patch.object(readline, 'get_begidx', get_begidx):
+            with mock.patch.object(readline, 'get_endidx', get_endidx):
+                # Run the readline tab-completion function with readline mocks in place
+                first_match = app.complete(text, 0)
+
+    return first_match
diff --git a/tests/test_completion.py b/tests/test_completion.py
index b102bc0a..839e1de2 100644
--- a/tests/test_completion.py
+++ b/tests/test_completion.py
@@ -13,21 +13,8 @@ import os
 import sys
 
 import cmd2
-import mock
 import pytest
-
-# Prefer statically linked gnureadline if available (for macOS compatibility due to issues with libedit)
-try:
-    import gnureadline as readline
-except ImportError:
-    # Try to import readline, but allow failure for convenience in Windows unit testing
-    # Note: If this actually fails, you should install readline on Linux or Mac or pyreadline on Windows
-    try:
-        # noinspection PyUnresolvedReferences
-        import readline
-    except ImportError:
-        pass
-
+from conftest import complete_tester
 
 # List of strings used with completion functions
 food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato']
@@ -87,41 +74,6 @@ def cmd2_app():
     return c
 
 
-def complete_tester(text, line, begidx, endidx, app):
-    """
-    This is a convenience function to test cmd2.complete() since
-    in a unit test environment there is no actual console readline
-    is monitoring. Therefore we use mock to provide readline data
-    to complete().
-
-    :param text: str - the string prefix we are attempting to match
-    :param line: str - the current input line with leading whitespace removed
-    :param begidx: int - the beginning index of the prefix text
-    :param endidx: int - the ending index of the prefix text
-    :param app: the cmd2 app that will run completions
-    :return: The first matched string or None if there are no matches
-             Matches are stored in app.completion_matches
-             These matches also have been sorted by complete()
-    """
-    def get_line():
-        return line
-
-    def get_begidx():
-        return begidx
-
-    def get_endidx():
-        return endidx
-
-    first_match = None
-    with mock.patch.object(readline, 'get_line_buffer', get_line):
-        with mock.patch.object(readline, 'get_begidx', get_begidx):
-            with mock.patch.object(readline, 'get_endidx', get_endidx):
-                # Run the readline tab-completion function with readline mocks in place
-                first_match = app.complete(text, 0)
-
-    return first_match
-
-
 def test_cmd2_command_completion_single(cmd2_app):
     text = 'he'
     line = text
@@ -911,6 +863,7 @@ def test_subcommand_tab_completion(sc_app):
     # It is at end of line, so extra space is present
     assert first_match is not None and sc_app.completion_matches == ['Football ']
 
+
 def test_subcommand_tab_completion_with_no_completer(sc_app):
     # This tests what happens when a subcommand has no completer
     # In this case, the foo subcommand has no completer defined
@@ -922,6 +875,7 @@ def test_subcommand_tab_completion_with_no_completer(sc_app):
     first_match = complete_tester(text, line, begidx, endidx, sc_app)
     assert first_match is None
 
+
 def test_subcommand_tab_completion_space_in_text(sc_app):
     text = 'B'
     line = 'base sport "Space {}'.format(text)
@@ -934,6 +888,179 @@ def test_subcommand_tab_completion_space_in_text(sc_app):
            sc_app.completion_matches == ['Ball" '] and \
            sc_app.display_matches == ['Space Ball']
 
+####################################################
+
+
+class SubcommandsWithUnknownExample(cmd2.Cmd):
+    """
+    Example cmd2 application where we a base command which has a couple subcommands
+    and the "sport" subcommand has tab completion enabled.
+    """
+
+    def __init__(self):
+        cmd2.Cmd.__init__(self)
+
+    # subcommand functions for the base command
+    def base_foo(self, args):
+        """foo subcommand of base command"""
+        self.poutput(args.x * args.y)
+
+    def base_bar(self, args):
+        """bar subcommand of base command"""
+        self.poutput('((%s))' % args.z)
+
+    def base_sport(self, args):
+        """sport subcommand of base command"""
+        self.poutput('Sport is {}'.format(args.sport))
+
+    # noinspection PyUnusedLocal
+    def complete_base_sport(self, text, line, begidx, endidx):
+        """ Adds tab completion to base sport subcommand """
+        index_dict = {1: sport_item_strs}
+        return self.index_based_complete(text, line, begidx, endidx, index_dict)
+
+    # create the top-level parser for the base command
+    base_parser = argparse.ArgumentParser(prog='base')
+    base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcommand help')
+
+    # create the parser for the "foo" subcommand
+    parser_foo = base_subparsers.add_parser('foo', help='foo help')
+    parser_foo.add_argument('-x', type=int, default=1, help='integer')
+    parser_foo.add_argument('y', type=float, help='float')
+    parser_foo.set_defaults(func=base_foo)
+
+    # create the parser for the "bar" subcommand
+    parser_bar = base_subparsers.add_parser('bar', help='bar help')
+    parser_bar.add_argument('z', help='string')
+    parser_bar.set_defaults(func=base_bar)
+
+    # create the parser for the "sport" subcommand
+    parser_sport = base_subparsers.add_parser('sport', help='sport help')
+    parser_sport.add_argument('sport', help='Enter name of a sport')
+
+    # Set both a function and tab completer for the "sport" subcommand
+    parser_sport.set_defaults(func=base_sport, completer=complete_base_sport)
+
+    @cmd2.with_argparser_and_unknown_args(base_parser)
+    def do_base(self, args):
+        """Base command help"""
+        func = getattr(args, 'func', None)
+        if func is not None:
+            # Call whatever subcommand function was selected
+            func(self, args)
+        else:
+            # No subcommand was provided, so call help
+            self.do_help('base')
+
+    # Enable tab completion of base to make sure the subcommands' completers get called.
+    complete_base = cmd2.Cmd.cmd_with_subs_completer
+
+
+@pytest.fixture
+def scu_app():
+    """Declare test fixture for with_argparser_and_unknown_args"""
+    app = SubcommandsWithUnknownExample()
+    return app
+
+
+def test_cmd2_subcmd_with_unknown_completion_single_end(scu_app):
+    text = 'f'
+    line = 'base {}'.format(text)
+    endidx = len(line)
+    begidx = endidx - len(text)
+
+    first_match = complete_tester(text, line, begidx, endidx, scu_app)
+
+    # It is at end of line, so extra space is present
+    assert first_match is not None and scu_app.completion_matches == ['foo ']
+
+
+def test_cmd2_subcmd_with_unknown_completion_multiple(scu_app):
+    text = ''
+    line = 'base {}'.format(text)
+    endidx = len(line)
+    begidx = endidx - len(text)
+
+    first_match = complete_tester(text, line, begidx, endidx, scu_app)
+    assert first_match is not None and scu_app.completion_matches == ['bar', 'foo', 'sport']
+
+
+def test_cmd2_subcmd_with_unknown_completion_nomatch(scu_app):
+    text = 'z'
+    line = 'base {}'.format(text)
+    endidx = len(line)
+    begidx = endidx - len(text)
+
+    first_match = complete_tester(text, line, begidx, endidx, scu_app)
+    assert first_match is None
+
+
+def test_cmd2_help_subcommand_completion_single(scu_app):
+    text = 'base'
+    line = 'help {}'.format(text)
+    endidx = len(line)
+    begidx = endidx - len(text)
+    assert scu_app.complete_help(text, line, begidx, endidx) == ['base']
+
+
+def test_cmd2_help_subcommand_completion_multiple(scu_app):
+    text = ''
+    line = 'help base {}'.format(text)
+    endidx = len(line)
+    begidx = endidx - len(text)
+
+    matches = sorted(scu_app.complete_help(text, line, begidx, endidx))
+    assert matches == ['bar', 'foo', 'sport']
+
+
+def test_cmd2_help_subcommand_completion_nomatch(scu_app):
+    text = 'z'
+    line = 'help base {}'.format(text)
+    endidx = len(line)
+    begidx = endidx - len(text)
+    assert scu_app.complete_help(text, line, begidx, endidx) == []
+
+
+def test_subcommand_tab_completion(scu_app):
+    # This makes sure the correct completer for the sport subcommand is called
+    text = 'Foot'
+    line = 'base sport {}'.format(text)
+    endidx = len(line)
+    begidx = endidx - len(text)
+
+    first_match = complete_tester(text, line, begidx, endidx, scu_app)
+
+    # It is at end of line, so extra space is present
+    assert first_match is not None and scu_app.completion_matches == ['Football ']
+
+
+def test_subcommand_tab_completion_with_no_completer(scu_app):
+    # This tests what happens when a subcommand has no completer
+    # In this case, the foo subcommand has no completer defined
+    text = 'Foot'
+    line = 'base foo {}'.format(text)
+    endidx = len(line)
+    begidx = endidx - len(text)
+
+    first_match = complete_tester(text, line, begidx, endidx, scu_app)
+    assert first_match is None
+
+
+def test_subcommand_tab_completion_space_in_text(scu_app):
+    text = 'B'
+    line = 'base sport "Space {}'.format(text)
+    endidx = len(line)
+    begidx = endidx - len(text)
+
+    first_match = complete_tester(text, line, begidx, endidx, scu_app)
+
+    assert first_match is not None and \
+           scu_app.completion_matches == ['Ball" '] and \
+           scu_app.display_matches == ['Space Ball']
+
+####################################################
+
+
 class SecondLevel(cmd2.Cmd):
     """To be used as a second level command class. """