Repository: python-cmd2/cmd2

Issue:
argparse tab autocompleter
I'm working on a tab completion implementation that automatically generates completions based on an argparse definition. Creating this issue to enable discussions.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aa2e785f..bb577994 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,8 @@
         * All ``cmd2`` code should be ported to use the new ``argparse``-based decorators
         * See the [Argument Processing](http://cmd2.readthedocs.io/en/latest/argument_processing.html) section of the documentation for more information on these decorators
         * Alternatively, see the [argparse_example.py](https://github.com/python-cmd2/cmd2/blob/master/examples/argparse_example.py)
+    * Deleted ``cmd_with_subs_completer``, ``get_subcommands``, and ``get_subcommand_completer``
+        * Replaced by default AutoCompleter implementation for all commands using argparse
 * Python 2 no longer supported
     * ``cmd2`` now supports Python 3.4+
 
diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py
index 35f9342b..e87e9c04 100755
--- a/cmd2/argparse_completer.py
+++ b/cmd2/argparse_completer.py
@@ -71,6 +71,10 @@ import re as _re
 from .rl_utils import rl_force_redisplay
 
 
+# attribute that can optionally added to an argparse argument (called an Action) to
+# define the completion choices for the argument. You may provide a Collection or a Function.
+ACTION_ARG_CHOICES = 'arg_choices'
+
 class _RangeAction(object):
     def __init__(self, nargs: Union[int, str, Tuple[int, int], None]):
         self.nargs_min = None
@@ -220,6 +224,10 @@ class AutoCompleter(object):
             # if there are choices defined, record them in the arguments dictionary
             if action.choices is not None:
                 self._arg_choices[action.dest] = action.choices
+            # if completion choices are tagged on the action, record them
+            elif hasattr(action, ACTION_ARG_CHOICES):
+                action_arg_choices = getattr(action, ACTION_ARG_CHOICES)
+                self._arg_choices[action.dest] = action_arg_choices
 
             # if the parameter is flag based, it will have option_strings
             if action.option_strings:
@@ -406,6 +414,21 @@ class AutoCompleter(object):
 
         return completion_results
 
+    def complete_command_help(self, tokens: List[str], text: str, line: str, begidx: int, endidx: int) -> List[str]:
+        """Supports the completion of sub-commands for commands through the cmd2 help command."""
+        for idx, token in enumerate(tokens):
+            if idx >= self._token_start_index:
+                if self._positional_completers:
+                    # For now argparse only allows 1 sub-command group per level
+                    # so this will only loop once.
+                    for completers in self._positional_completers.values():
+                        if token in completers:
+                            return completers[token].complete_command_help(tokens, text, line, begidx, endidx)
+                        else:
+                            return self.basic_complete(text, line, begidx, endidx, completers.keys())
+        return []
+
+
     @staticmethod
     def _process_action_nargs(action: argparse.Action, arg_state: _ArgumentState) -> None:
         if isinstance(action, _RangeAction):
@@ -467,6 +490,7 @@ class AutoCompleter(object):
     def _resolve_choices_for_arg(self, action: argparse.Action, used_values=()) -> List[str]:
         if action.dest in self._arg_choices:
             args = self._arg_choices[action.dest]
+
             if callable(args):
                 args = args()
 
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index 60d1dbf8..288a506b 100755
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -51,6 +51,7 @@ import pyperclip
 
 # Set up readline
 from .rl_utils import rl_force_redisplay, readline, rl_type, RlType
+from .argparse_completer import AutoCompleter, ACArgumentParser
 
 if rl_type == RlType.PYREADLINE:
 
@@ -266,23 +267,8 @@ def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser) -> Calla
 
         cmd_wrapper.__doc__ = argparser.format_help()
 
-        # Mark this function as having an argparse ArgumentParser (used by do_help)
-        cmd_wrapper.__dict__['has_parser'] = True
-
-        # If there are subcommands, store their names in a list to support tab-completion of subcommand names
-        if argparser._subparsers is not None:
-            # 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
+        # Mark this function as having an argparse ArgumentParser
+        setattr(cmd_wrapper, 'argparser', argparser)
 
         return cmd_wrapper
 
@@ -318,24 +304,8 @@ def with_argparser(argparser: argparse.ArgumentParser) -> Callable:
 
         cmd_wrapper.__doc__ = argparser.format_help()
 
-        # Mark this function as having an argparse ArgumentParser (used by do_help)
-        cmd_wrapper.__dict__['has_parser'] = True
-
-        # If there are subcommands, store their names in a list to support tab-completion of subcommand names
-        if argparser._subparsers is not None:
-
-            # 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
+        # Mark this function as having an argparse ArgumentParser
+        setattr(cmd_wrapper, 'argparser', argparser)
 
         return cmd_wrapper
 
@@ -1020,49 +990,6 @@ class Cmd(cmd.Cmd):
             return self._colorcodes[color][True] + val + self._colorcodes[color][False]
         return val
 
-    def get_subcommands(self, command):
-        """
-        Returns a list of a command's subcommand names if they exist
-        :param command: the command we are querying
-        :return: A subcommand list or None
-        """
-
-        subcommand_names = None
-
-        # Check if is a valid command
-        funcname = self._func_named(command)
-
-        if funcname:
-            # Check to see if this function was decorated with an argparse ArgumentParser
-            func = getattr(self, funcname)
-            subcommands = func.__dict__.get('subcommands', None)
-            if subcommands is not None:
-                subcommand_names = subcommands.keys()
-
-        return subcommand_names
-
-    def get_subcommand_completer(self, command, subcommand):
-        """
-        Returns a subcommand's tab completion function if one exists
-        :param command: command which owns the subcommand
-        :param subcommand: the subcommand we are querying
-        :return: A completer or None
-        """
-
-        completer = None
-
-        # Check if is a valid command
-        funcname = self._func_named(command)
-
-        if funcname:
-            # Check to see if this function was decorated with an argparse ArgumentParser
-            func = getattr(self, funcname)
-            subcommands = func.__dict__.get('subcommands', None)
-            if subcommands is not None:
-                completer = subcommands[subcommand]
-
-        return completer
-
     # -----  Methods related to tab completion -----
 
     def set_completion_defaults(self):
@@ -1794,16 +1721,14 @@ class Cmd(cmd.Cmd):
                     try:
                         compfunc = getattr(self, 'complete_' + command)
                     except AttributeError:
-                        compfunc = self.completedefault
-
-                    subcommands = self.get_subcommands(command)
-                    if subcommands is not None:
-                        # Since there are subcommands, then try completing those if the cursor is in
-                        # the token at index 1, otherwise default to using compfunc
-                        index_dict = {1: subcommands}
-                        compfunc = functools.partial(self.index_based_complete,
-                                                     index_dict=index_dict,
-                                                     all_else=compfunc)
+                        # There's no completer function, next see if the command uses argparser
+                        try:
+                            cmd_func = getattr(self, 'do_' + command)
+                            argparser = getattr(cmd_func, 'argparser')
+                            # Command uses argparser, switch to the default argparse completer
+                            compfunc = functools.partial(self._autocomplete_default, argparser=argparser)
+                        except AttributeError:
+                            compfunc = self.completedefault
 
                 # A valid command was not entered
                 else:
@@ -1910,6 +1835,16 @@ class Cmd(cmd.Cmd):
         except IndexError:
             return None
 
+    def _autocomplete_default(self, text: str, line: str, begidx: int, endidx: int,
+                              argparser: argparse.ArgumentParser) -> List[str]:
+        """Default completion function for argparse commands."""
+        completer = AutoCompleter(argparser)
+
+        tokens, _ = self.tokens_for_completion(line, begidx, endidx)
+        results = completer.complete_command(tokens, text, line, begidx, endidx)
+
+        return results
+
     def get_all_commands(self):
         """
         Returns a list of all commands
@@ -1964,12 +1899,15 @@ class Cmd(cmd.Cmd):
             strs_to_match = list(topics | visible_commands)
             matches = self.basic_complete(text, line, begidx, endidx, strs_to_match)
 
-        # Check if we are completing a subcommand
-        elif index == subcmd_index:
-
-            # Match subcommands if any exist
-            command = tokens[cmd_index]
-            matches = self.basic_complete(text, line, begidx, endidx, self.get_subcommands(command))
+        # check if the command uses argparser
+        elif index >= subcmd_index:
+            try:
+                cmd_func = getattr(self, 'do_' + tokens[cmd_index])
+                parser = getattr(cmd_func, 'argparser')
+                completer = AutoCompleter(parser)
+                matches = completer.complete_command_help(tokens[1:], text, line, begidx, endidx)
+            except AttributeError:
+                pass
 
         return matches
 
@@ -2620,7 +2558,7 @@ Usage:  Usage: unalias [-a] name [name ...]
             if funcname:
                 # Check to see if this function was decorated with an argparse ArgumentParser
                 func = getattr(self, funcname)
-                if func.__dict__.get('has_parser', False):
+                if hasattr(func, 'argparser'):
                     # Function has an argparser, so get help based on all the arguments in case there are sub-commands
                     new_arglist = arglist[1:]
                     new_arglist.append('-h')
@@ -2843,10 +2781,10 @@ Usage:  Usage: unalias [-a] name [name ...]
         else:
             raise LookupError("Parameter '%s' not supported (type 'show' for list of parameters)." % param)
 
-    set_parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter)
+    set_parser = ACArgumentParser(formatter_class=argparse.RawTextHelpFormatter)
     set_parser.add_argument('-a', '--all', action='store_true', help='display read-only settings as well')
     set_parser.add_argument('-l', '--long', action='store_true', help='describe function of parameter')
-    set_parser.add_argument('settable', nargs='*', help='[param_name] [value]')
+    set_parser.add_argument('settable', nargs=(0,2), help='[param_name] [value]')
 
     @with_argparser(set_parser)
     def do_set(self, args):
@@ -2927,87 +2865,6 @@ Usage:  Usage: unalias [-a] name [name ...]
         index_dict = {1: self.shell_cmd_complete}
         return self.index_based_complete(text, line, begidx, endidx, index_dict, self.path_complete)
 
-    def cmd_with_subs_completer(self, text, line, begidx, endidx):
-        """
-        This is a function provided for convenience to those who want an easy way to add
-        tab completion to functions that implement subcommands. By setting this as the
-        completer of the base command function, the correct completer for the chosen subcommand
-        will be called.
-
-        The use of this function requires assigning a completer function to the subcommand's parser
-        Example:
-            A command called print has a subcommands called 'names' that needs a tab completer
-            When you create the parser for names, include the completer function in the parser's defaults.
-
-            names_parser.set_defaults(func=print_names, completer=complete_print_names)
-
-            To make sure the names completer gets called, set the completer for the print function
-            in a similar fashion to what follows.
-
-            complete_print = cmd2.Cmd.cmd_with_subs_completer
-
-        When the subcommand's completer is called, this function will have stripped off all content from the
-        beginning of the command line before the subcommand, meaning the line parameter always starts with the
-        subcommand name and the index parameters reflect this change.
-
-        For instance, the command "print names -d 2" becomes "names -d 2"
-        begidx and endidx are incremented accordingly
-
-        :param text: str - the string prefix we are attempting to match (all returned matches must begin with it)
-        :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
-        :return: List[str] - a list of possible tab completions
-        """
-        # The command is the token at index 0 in the command line
-        cmd_index = 0
-
-        # The subcommand is the token at index 1 in the command line
-        subcmd_index = 1
-
-        # Get all tokens through the one being completed
-        tokens, _ = self.tokens_for_completion(line, begidx, endidx)
-        if tokens is None:
-            return []
-
-        matches = []
-
-        # Get the index of the token being completed
-        index = len(tokens) - 1
-
-        # If the token being completed is past the subcommand name, then do subcommand specific tab-completion
-        if index > subcmd_index:
-
-            # Get the command name
-            command = tokens[cmd_index]
-
-            # Get the subcommand name
-            subcommand = tokens[subcmd_index]
-
-            # Find the offset into line where the subcommand name begins
-            subcmd_start = 0
-            for cur_index in range(0, subcmd_index + 1):
-                cur_token = tokens[cur_index]
-                subcmd_start = line.find(cur_token, subcmd_start)
-
-                if cur_index != subcmd_index:
-                    subcmd_start += len(cur_token)
-
-            # Strip off everything before subcommand name
-            orig_line = line
-            line = line[subcmd_start:]
-
-            # Update the indexes
-            diff = len(orig_line) - len(line)
-            begidx -= diff
-            endidx -= diff
-
-            # Call the subcommand specific completer if it exists
-            compfunc = self.get_subcommand_completer(command, subcommand)
-            if compfunc is not None:
-                matches = compfunc(self, text, line, begidx, endidx)
-
-        return matches
 
     # noinspection PyBroadException
     def do_py(self, arg):
diff --git a/docs/argument_processing.rst b/docs/argument_processing.rst
index 183dde4e..ecf59504 100644
--- a/docs/argument_processing.rst
+++ b/docs/argument_processing.rst
@@ -346,12 +346,10 @@ Sub-commands
 Sub-commands are supported for commands using either the ``@with_argparser`` or
 ``@with_argparser_and_unknown_args`` decorator.  The syntax for supporting them is based on argparse sub-parsers.
 
-Also, a convenience function called ``cmd_with_subs_completer`` is available to easily add tab completion to functions
-that implement subcommands. By setting this as the completer of the base command function, the correct completer for
-the chosen subcommand will be called.
+You may add multiple layers of sub-commands for your command. Cmd2 will automatically traverse and tab-complete
+sub-commands for all commands using argparse.
 
-See the subcommands_ example to learn more about how to use sub-commands in your ``cmd2`` application.
-This example also demonstrates usage of ``cmd_with_subs_completer``. In addition, the docstring for
-``cmd_with_subs_completer`` offers more details.
+See the subcommands_ and tab_autocompletion_ example to learn more about how to use sub-commands in your ``cmd2`` application.
 
 .. _subcommands: https://github.com/python-cmd2/cmd2/blob/master/examples/subcommands.py
+.. _tab_autocompletion: https://github.com/python-cmd2/cmd2/blob/master/examples/tab_autocompletion.py
diff --git a/examples/subcommands.py b/examples/subcommands.py
index 031b17b2..75c0733e 100755
--- a/examples/subcommands.py
+++ b/examples/subcommands.py
@@ -35,12 +35,6 @@ class SubcommandsExample(cmd2.Cmd):
         """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')
@@ -53,15 +47,22 @@ class SubcommandsExample(cmd2.Cmd):
 
     # 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)
 
+    bar_subparsers = parser_bar.add_subparsers(title='layer3', help='help for 3rd layer of commands')
+    parser_bar.add_argument('z', help='string')
+
+    bar_subparsers.add_parser('apple', help='apple help')
+    bar_subparsers.add_parser('artichoke', help='artichoke help')
+    bar_subparsers.add_parser('cranberries', help='cranberries help')
+
     # 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')
+    sport_arg = parser_sport.add_argument('sport', help='Enter name of a sport')
+    setattr(sport_arg, 'arg_choices', sport_item_strs)
 
     # Set both a function and tab completer for the "sport" subcommand
-    parser_sport.set_defaults(func=base_sport, completer=complete_base_sport)
+    parser_sport.set_defaults(func=base_sport)
 
     @with_argparser(base_parser)
     def do_base(self, args):
@@ -74,9 +75,6 @@ class SubcommandsExample(cmd2.Cmd):
             # 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
-
 
 if __name__ == '__main__':
     app = SubcommandsExample()
diff --git a/examples/tab_autocompletion.py b/examples/tab_autocompletion.py
index c704908f..17c8391d 100755
--- a/examples/tab_autocompletion.py
+++ b/examples/tab_autocompletion.py
@@ -13,6 +13,15 @@ from typing import List
 import cmd2
 from cmd2 import with_argparser, with_category, argparse_completer
 
+actors = ['Mark Hamill', 'Harrison Ford', 'Carrie Fisher', 'Alec Guinness', 'Peter Mayhew',
+          'Anthony Daniels', 'Adam Driver', 'Daisy Ridley', 'John Boyega', 'Oscar Isaac',
+          'Lupita Nyong\'o', 'Andy Serkis', 'Liam Neeson', 'Ewan McGregor', 'Natalie Portman',
+          'Jake Lloyd', 'Hayden Christensen', 'Christopher Lee']
+
+def query_actors() -> List[str]:
+    """Simulating a function that queries and returns a completion values"""
+    return actors
+
 
 class TabCompleteExample(cmd2.Cmd):
     """ Example cmd2 application where we a base command which has a couple subcommands."""
@@ -27,10 +36,6 @@ class TabCompleteExample(cmd2.Cmd):
     show_ratings = ['TV-Y', 'TV-Y7', 'TV-G', 'TV-PG', 'TV-14', 'TV-MA']
     static_list_directors = ['J. J. Abrams', 'Irvin Kershner', 'George Lucas', 'Richard Marquand',
                              'Rian Johnson', 'Gareth Edwards']
-    actors = ['Mark Hamill', 'Harrison Ford', 'Carrie Fisher', 'Alec Guinness', 'Peter Mayhew',
-              'Anthony Daniels', 'Adam Driver', 'Daisy Ridley', 'John Boyega', 'Oscar Isaac',
-              'Lupita Nyong\'o', 'Andy Serkis', 'Liam Neeson', 'Ewan McGregor', 'Natalie Portman',
-              'Jake Lloyd', 'Hayden Christensen', 'Christopher Lee']
     USER_MOVIE_LIBRARY = ['ROGUE1', 'SW_EP04', 'SW_EP05']
     MOVIE_DATABASE_IDS = ['SW_EP01', 'SW_EP02', 'SW_EP03', 'ROGUE1', 'SW_EP04',
                           'SW_EP05', 'SW_EP06', 'SW_EP07', 'SW_EP08', 'SW_EP09']
@@ -115,15 +120,6 @@ class TabCompleteExample(cmd2.Cmd):
         if not args.type:
             self.do_help('suggest')
 
-    def complete_suggest(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
-        """ Adds tab completion to media"""
-        completer = argparse_completer.AutoCompleter(TabCompleteExample.suggest_parser, 1)
-
-        tokens, _ = self.tokens_for_completion(line, begidx, endidx)
-        results = completer.complete_command(tokens, text, line, begidx, endidx)
-
-        return results
-
     # If you prefer the original argparse help output but would like narg ranges, it's possible
     # to enable narg ranges without the help changes using this method
 
@@ -143,15 +139,6 @@ class TabCompleteExample(cmd2.Cmd):
         if not args.type:
             self.do_help('orig_suggest')
 
-    def complete_hybrid_suggest(self, text, line, begidx, endidx):
-        """ Adds tab completion to media"""
-        completer = argparse_completer.AutoCompleter(TabCompleteExample.suggest_parser_hybrid)
-
-        tokens, _ = self.tokens_for_completion(line, begidx, endidx)
-        results = completer.complete_command(tokens, text, line, begidx, endidx)
-
-        return results
-
     # This variant demonstrates the AutoCompleter working with the orginial argparse.
     # Base argparse is unable to specify narg ranges. Autocompleter will keep expecting additional arguments
     # for the -d/--duration flag until you specify a new flaw or end the list it with '--'
@@ -170,23 +157,98 @@ class TabCompleteExample(cmd2.Cmd):
         if not args.type:
             self.do_help('orig_suggest')
 
-    def complete_orig_suggest(self, text, line, begidx, endidx) -> List[str]:
-        """ Adds tab completion to media"""
-        completer = argparse_completer.AutoCompleter(TabCompleteExample.suggest_parser_orig)
+    ###################################################################################
+    # The media command demonstrates a completer with multiple layers of subcommands
+    #   - This example demonstrates how to tag a completion attribute on each action, enabling argument
+    #       completion without implementing a complete_COMMAND function
 
-        tokens, _ = self.tokens_for_completion(line, begidx, endidx)
-        results = completer.complete_command(tokens, text, line, begidx, endidx)
+    def _do_vid_media_movies(self, args) -> None:
+        if not args.command:
+            self.do_help('media movies')
+        elif args.command == 'list':
+            for movie_id in TabCompleteExample.MOVIE_DATABASE:
+                movie = TabCompleteExample.MOVIE_DATABASE[movie_id]
+                print('{}\n-----------------------------\n{}   ID: {}\nDirector: {}\nCast:\n    {}\n\n'
+                      .format(movie['title'], movie['rating'], movie_id,
+                              ', '.join(movie['director']),
+                              '\n    '.join(movie['actor'])))
 
-        return results
+    def _do_vid_media_shows(self, args) -> None:
+        if not args.command:
+            self.do_help('media shows')
+
+        elif args.command == 'list':
+            for show_id in TabCompleteExample.SHOW_DATABASE:
+                show = TabCompleteExample.SHOW_DATABASE[show_id]
+                print('{}\n-----------------------------\n{}   ID: {}'
+                      .format(show['title'], show['rating'], show_id))
+                for season in show['seasons']:
+                    ep_list = show['seasons'][season]
+                    print('  Season {}:\n    {}'
+                          .format(season,
+                                  '\n    '.join(ep_list)))
+                print()
+
+    video_parser = argparse_completer.ACArgumentParser(prog='media')
+
+    video_types_subparsers = video_parser.add_subparsers(title='Media Types', dest='type')
+
+    vid_movies_parser = video_types_subparsers.add_parser('movies')
+    vid_movies_parser.set_defaults(func=_do_vid_media_movies)
+
+    vid_movies_commands_subparsers = vid_movies_parser.add_subparsers(title='Commands', dest='command')
+
+    vid_movies_list_parser = vid_movies_commands_subparsers.add_parser('list')
+
+    vid_movies_list_parser.add_argument('-t', '--title', help='Title Filter')
+    vid_movies_list_parser.add_argument('-r', '--rating', help='Rating Filter', nargs='+',
+                                        choices=ratings_types)
+    # save a reference to the action object
+    director_action = vid_movies_list_parser.add_argument('-d', '--director', help='Director Filter')
+    actor_action = vid_movies_list_parser.add_argument('-a', '--actor', help='Actor Filter', action='append')
+
+    # tag the action objects with completion providers. This can be a collection or a callable
+    setattr(director_action, argparse_completer.ACTION_ARG_CHOICES, static_list_directors)
+    setattr(actor_action, argparse_completer.ACTION_ARG_CHOICES, query_actors)
+
+    vid_movies_add_parser = vid_movies_commands_subparsers.add_parser('add')
+    vid_movies_add_parser.add_argument('title', help='Movie Title')
+    vid_movies_add_parser.add_argument('rating', help='Movie Rating', choices=ratings_types)
+
+    # save a reference to the action object
+    director_action = vid_movies_add_parser.add_argument('-d', '--director', help='Director', nargs=(1, 2),
+                                                         required=True)
+    actor_action = vid_movies_add_parser.add_argument('actor', help='Actors', nargs='*')
+
+    # tag the action objects with completion providers. This can be a collection or a callable
+    setattr(director_action, argparse_completer.ACTION_ARG_CHOICES, static_list_directors)
+    setattr(actor_action, argparse_completer.ACTION_ARG_CHOICES, query_actors)
+
+    vid_movies_delete_parser = vid_movies_commands_subparsers.add_parser('delete')
+
+    vid_shows_parser = video_types_subparsers.add_parser('shows')
+    vid_shows_parser.set_defaults(func=_do_vid_media_shows)
+
+    vid_shows_commands_subparsers = vid_shows_parser.add_subparsers(title='Commands', dest='command')
+
+    vid_shows_list_parser = vid_shows_commands_subparsers.add_parser('list')
+
+    @with_category(CAT_AUTOCOMPLETE)
+    @with_argparser(video_parser)
+    def do_video(self, args):
+        """Video management command demonstrates multiple layers of subcommands being handled by AutoCompleter"""
+        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('video')
 
     ###################################################################################
     # The media command demonstrates a completer with multiple layers of subcommands
     #   - This example uses a flat completion lookup dictionary
 
-    def query_actors(self) -> List[str]:
-        """Simulating a function that queries and returns a completion values"""
-        return TabCompleteExample.actors
-
     def _do_media_movies(self, args) -> None:
         if not args.command:
             self.do_help('media movies')
@@ -264,7 +326,7 @@ class TabCompleteExample(cmd2.Cmd):
     # name collisions.
     def complete_media(self, text, line, begidx, endidx):
         """ Adds tab completion to media"""
-        choices = {'actor': self.query_actors,  # function
+        choices = {'actor': query_actors,  # function
                    'director': TabCompleteExample.static_list_directors  # static list
                    }
         completer = argparse_completer.AutoCompleter(TabCompleteExample.media_parser, arg_choices=choices)


Test patch:
diff --git a/tests/test_autocompletion.py b/tests/test_autocompletion.py
index e68bc104..1d0c9678 100644
--- a/tests/test_autocompletion.py
+++ b/tests/test_autocompletion.py
@@ -213,6 +213,27 @@ def test_autocomp_subcmd_flag_comp_list(cmd2_app):
     assert first_match is not None and first_match == '"Gareth Edwards'
 
 
+def test_autocomp_subcmd_flag_comp_func_attr(cmd2_app):
+    text = 'A'
+    line = 'video movies list -a "{}'.format(text)
+    endidx = len(line)
+    begidx = endidx - len(text)
+
+    first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+    assert first_match is not None and \
+           cmd2_app.completion_matches == ['Adam Driver', 'Alec Guinness', 'Andy Serkis', 'Anthony Daniels']
+
+
+def test_autocomp_subcmd_flag_comp_list_attr(cmd2_app):
+    text = 'G'
+    line = 'video movies list -d {}'.format(text)
+    endidx = len(line)
+    begidx = endidx - len(text)
+
+    first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+    assert first_match is not None and first_match == '"Gareth Edwards'
+
+
 def test_autcomp_pos_consumed(cmd2_app):
     text = ''
     line = 'library movie add SW_EP01 {}'.format(text)
@@ -254,3 +275,5 @@ def test_autcomp_custom_func_list_and_dict_arg(cmd2_app):
     first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
     assert first_match is not None and \
            cmd2_app.completion_matches == ['S01E02', 'S01E03', 'S02E01', 'S02E03']
+
+
diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py
index 35ef4c0f..48f50bdc 100644
--- a/tests/test_cmd2.py
+++ b/tests/test_cmd2.py
@@ -68,8 +68,12 @@ def test_base_argparse_help(base_app, capsys):
 def test_base_invalid_option(base_app, capsys):
     run_cmd(base_app, 'set -z')
     out, err = capsys.readouterr()
-    expected = ['usage: set [-h] [-a] [-l] [settable [settable ...]]', 'set: error: unrecognized arguments: -z']
-    assert normalize(str(err)) == expected
+    out = normalize(out)
+    err = normalize(err)
+    assert len(err) == 3
+    assert len(out) == 15
+    assert 'Error: unrecognized arguments: -z' in err[0]
+    assert out[0] == 'usage: set [-h] [-a] [-l] [settable [settable ...]]'
 
 def test_base_shortcuts(base_app):
     out = run_cmd(base_app, 'shortcuts')
diff --git a/tests/test_completion.py b/tests/test_completion.py
index a01d1166..cf45f281 100644
--- a/tests/test_completion.py
+++ b/tests/test_completion.py
@@ -14,7 +14,8 @@ import sys
 
 import cmd2
 import pytest
-from .conftest import complete_tester
+from .conftest import complete_tester, StdOut
+from examples.subcommands import SubcommandsExample
 
 # List of strings used with completion functions
 food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato']
@@ -726,76 +727,13 @@ def test_add_opening_quote_delimited_space_in_prefix(cmd2_app):
            os.path.commonprefix(cmd2_app.completion_matches) == expected_common_prefix and \
            cmd2_app.display_matches == expected_display
 
-class SubcommandsExample(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(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 sc_app():
-    app = SubcommandsExample()
-    return app
+    c = SubcommandsExample()
+    c.stdout = StdOut()
 
+    return c
 
 def test_cmd2_subcommand_completion_single_end(sc_app):
     text = 'f'
@@ -913,12 +851,6 @@ class SubcommandsWithUnknownExample(cmd2.Cmd):
         """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')
@@ -936,10 +868,8 @@ class SubcommandsWithUnknownExample(cmd2.Cmd):
 
     # 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)
+    sport_arg = parser_sport.add_argument('sport', help='Enter name of a sport')
+    setattr(sport_arg, 'arg_choices', sport_item_strs)
 
     @cmd2.with_argparser_and_unknown_args(base_parser)
     def do_base(self, args):
@@ -952,9 +882,6 @@ class SubcommandsWithUnknownExample(cmd2.Cmd):
             # 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():
@@ -971,6 +898,8 @@ def test_cmd2_subcmd_with_unknown_completion_single_end(scu_app):
 
     first_match = complete_tester(text, line, begidx, endidx, scu_app)
 
+    print('first_match: {}'.format(first_match))
+
     # It is at end of line, so extra space is present
     assert first_match is not None and scu_app.completion_matches == ['foo ']