Repository: python-cmd2/cmd2
Pull request #1135 (2021-08-23 17:30:53)

Custom command completer

codecov[bot]: # [Codecov](https://codecov.io/gh/python-cmd2/cmd2/pull/1135?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2) Report
> Merging [#1135](https://codecov.io/gh/python-cmd2/cmd2/pull/1135?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2) (548c97c) into [master](https://codecov.io/gh/python-cmd2/cmd2/commit/6d771e96c0507d9ef4ad3eaaf4bc83669396e591?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2) (6d771e9) will **decrease** coverage by `1.22%`.
> The diff coverage is `93.67%`.

[![Impacted file tree graph](https://codecov.io/gh/python-cmd2/cmd2/pull/1135/graphs/tree.svg?width=650&height=150&src=pr&token=eks2PUwa6A&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2)](https://codecov.io/gh/python-cmd2/cmd2/pull/1135?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2)

```diff
@@            Coverage Diff             @@
##           master    #1135      +/-   ##
==========================================
- Coverage   98.50%   97.28%   -1.23%     
==========================================
  Files          22       22              
  Lines        4832     4861      +29     
==========================================
- Hits         4760     4729      -31     
- Misses         72      132      +60     
```


| [Impacted Files](https://codecov.io/gh/python-cmd2/cmd2/pull/1135?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2) | Coverage Δ | |
|---|---|---|
| [cmd2/argparse\_custom.py](https://codecov.io/gh/python-cmd2/cmd2/pull/1135/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2#diff-Y21kMi9hcmdwYXJzZV9jdXN0b20ucHk=) | `94.81% <89.65%> (-0.70%)` | :arrow_down: |
| [cmd2/argparse\_completer.py](https://codecov.io/gh/python-cmd2/cmd2/pull/1135/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2#diff-Y21kMi9hcmdwYXJzZV9jb21wbGV0ZXIucHk=) | `99.68% <92.30%> (-0.32%)` | :arrow_down: |
| [cmd2/cmd2.py](https://codecov.io/gh/python-cmd2/cmd2/pull/1135/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2#diff-Y21kMi9jbWQyLnB5) | `96.98% <96.87%> (-1.42%)` | :arrow_down: |
| [cmd2/\_\_init\_\_.py](https://codecov.io/gh/python-cmd2/cmd2/pull/1135/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2#diff-Y21kMi9fX2luaXRfXy5weQ==) | `100.00% <100.00%> (ø)` | |
| [cmd2/constants.py](https://codecov.io/gh/python-cmd2/cmd2/pull/1135/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2#diff-Y21kMi9jb25zdGFudHMucHk=) | `100.00% <100.00%> (ø)` | |
| [cmd2/decorators.py](https://codecov.io/gh/python-cmd2/cmd2/pull/1135/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2#diff-Y21kMi9kZWNvcmF0b3JzLnB5) | `100.00% <100.00%> (ø)` | |
| [cmd2/rl\_utils.py](https://codecov.io/gh/python-cmd2/cmd2/pull/1135/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2#diff-Y21kMi9ybF91dGlscy5weQ==) | `58.53% <0.00%> (-41.47%)` | :arrow_down: |
| [cmd2/clipboard.py](https://codecov.io/gh/python-cmd2/cmd2/pull/1135/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2#diff-Y21kMi9jbGlwYm9hcmQucHk=) | `71.42% <0.00%> (-28.58%)` | :arrow_down: |
| [cmd2/utils.py](https://codecov.io/gh/python-cmd2/cmd2/pull/1135/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2#diff-Y21kMi91dGlscy5weQ==) | `97.22% <0.00%> (-0.70%)` | :arrow_down: |
| ... and [2 more](https://codecov.io/gh/python-cmd2/cmd2/pull/1135/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/python-cmd2/cmd2/pull/1135?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/python-cmd2/cmd2/pull/1135?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2). Last update [6d771e9...548c97c](https://codecov.io/gh/python-cmd2/cmd2/pull/1135?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2).


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a782f55f..d6f47dec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,10 @@
 ## 2.2.0 (TBD, 2021)
+* Enhancements
+  * New function `set_default_command_completer_type()` allows developer to extend and modify the
+    behavior of `ArgparseCompleter`. 
+  * New function `register_argparse_argument_parameter()` allows developers to specify custom 
+    parameters to be passed to the argparse parser's `add_argument()` method. These parameters will
+    become accessible in the resulting argparse Action object when modifying `ArgparseCompleter` behavior.
 * Deletions (potentially breaking changes)
     * Deleted ``set_choices_provider()`` and ``set_completer()`` which were deprecated in 2.1.2
     
diff --git a/cmd2/__init__.py b/cmd2/__init__.py
index e545f394..a23b7a36 100644
--- a/cmd2/__init__.py
+++ b/cmd2/__init__.py
@@ -5,7 +5,7 @@
 
 import sys
 
-# For python 3.8 and late
+# For python 3.8 and later
 if sys.version_info >= (3, 8):
     import importlib.metadata as importlib_metadata
 else:
@@ -20,9 +20,16 @@ except importlib_metadata.PackageNotFoundError:  # pragma: no cover
 from typing import List
 
 from .ansi import style, fg, bg
-from .argparse_custom import Cmd2ArgumentParser, Cmd2AttributeWrapper, CompletionItem, set_default_argument_parser
+from .argparse_custom import (
+    Cmd2ArgumentParser,
+    Cmd2AttributeWrapper,
+    CompletionItem,
+    register_argparse_argument_parameter,
+    set_default_argument_parser,
+)
 
-# Check if user has defined a module that sets a custom value for argparse_custom.DEFAULT_ARGUMENT_PARSER
+# Check if user has defined a module that sets a custom value for argparse_custom.DEFAULT_ARGUMENT_PARSER.
+# Do this before loading cmd2.Cmd class so its commands use the custom parser.
 import argparse
 
 cmd2_parser_module = getattr(argparse, 'cmd2_parser_module', None)
@@ -31,8 +38,8 @@ if cmd2_parser_module is not None:
 
     importlib.import_module(cmd2_parser_module)
 
-# Get the current value for argparse_custom.DEFAULT_ARGUMENT_PARSER
-from .argparse_custom import DEFAULT_ARGUMENT_PARSER
+from .argparse_completer import set_default_command_completer_type
+
 from .cmd2 import Cmd
 from .command_definition import CommandSet, with_default_category
 from .constants import COMMAND_NAME, DEFAULT_SHORTCUTS
@@ -46,7 +53,6 @@ from .utils import categorize, CompletionMode, CustomCompletionSettings, Settabl
 
 __all__: List[str] = [
     'COMMAND_NAME',
-    'DEFAULT_ARGUMENT_PARSER',
     'DEFAULT_SHORTCUTS',
     # ANSI Style exports
     'bg',
@@ -56,7 +62,9 @@ __all__: List[str] = [
     'Cmd2ArgumentParser',
     'Cmd2AttributeWrapper',
     'CompletionItem',
+    'register_argparse_argument_parameter',
     'set_default_argument_parser',
+    'set_default_command_completer_type',
     # Cmd2
     'Cmd',
     'CommandResult',
diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py
index a244941c..584eb74e 100644
--- a/cmd2/argparse_completer.py
+++ b/cmd2/argparse_completer.py
@@ -13,18 +13,28 @@ from collections import (
     deque,
 )
 from typing import (
+    TYPE_CHECKING,
     Dict,
     List,
     Optional,
+    Type,
     Union,
     cast,
 )
 
-from . import (
-    ansi,
-    cmd2,
-    constants,
+from .ansi import (
+    style_aware_wcswidth,
+    widest_line,
 )
+from .constants import (
+    INFINITY,
+)
+
+if TYPE_CHECKING:
+    from .cmd2 import (
+        Cmd,
+    )
+
 from .argparse_custom import (
     ChoicesCallable,
     ChoicesProviderFuncWithTokens,
@@ -124,10 +134,10 @@ class _ArgumentState:
             self.max = 1
         elif self.action.nargs == argparse.ZERO_OR_MORE or self.action.nargs == argparse.REMAINDER:
             self.min = 0
-            self.max = constants.INFINITY
+            self.max = INFINITY
         elif self.action.nargs == argparse.ONE_OR_MORE:
             self.min = 1
-            self.max = constants.INFINITY
+            self.max = INFINITY
         else:
             self.min = self.action.nargs
             self.max = self.action.nargs
@@ -165,7 +175,7 @@ class ArgparseCompleter:
     """Automatic command line tab completion based on argparse parameters"""
 
     def __init__(
-        self, parser: argparse.ArgumentParser, cmd2_app: cmd2.Cmd, *, parent_tokens: Optional[Dict[str, List[str]]] = None
+        self, parser: argparse.ArgumentParser, cmd2_app: 'Cmd', *, parent_tokens: Optional[Dict[str, List[str]]] = None
     ) -> None:
         """
         Create an ArgparseCompleter
@@ -564,15 +574,15 @@ class ArgparseCompleter:
             desc_header = desc_header.replace('\t', four_spaces)
 
             # Calculate needed widths for the token and description columns of the table
-            token_width = ansi.style_aware_wcswidth(destination)
-            desc_width = ansi.widest_line(desc_header)
+            token_width = style_aware_wcswidth(destination)
+            desc_width = widest_line(desc_header)
 
             for item in completion_items:
-                token_width = max(ansi.style_aware_wcswidth(item), token_width)
+                token_width = max(style_aware_wcswidth(item), token_width)
 
                 # Replace tabs with 4 spaces so we can calculate width
                 item.description = item.description.replace('\t', four_spaces)
-                desc_width = max(ansi.widest_line(item.description), desc_width)
+                desc_width = max(widest_line(item.description), desc_width)
 
             cols = list()
             cols.append(Column(destination.upper(), width=token_width))
@@ -728,3 +738,16 @@ class ArgparseCompleter:
             return []
 
         return self._format_completions(arg_state, results)
+
+
+DEFAULT_COMMAND_COMPLETER: Type[ArgparseCompleter] = ArgparseCompleter
+
+
+def set_default_command_completer_type(completer_type: Type[ArgparseCompleter]) -> None:
+    """
+    Set the default command completer type. It must be a sub-class of the ArgparseCompleter.
+
+    :param completer_type: Type that is a subclass of ArgparseCompleter.
+    """
+    global DEFAULT_COMMAND_COMPLETER
+    DEFAULT_COMMAND_COMPLETER = completer_type
diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py
index 770b1e5c..4f2ef116 100644
--- a/cmd2/argparse_custom.py
+++ b/cmd2/argparse_custom.py
@@ -238,6 +238,7 @@ from typing import (
     NoReturn,
     Optional,
     Sequence,
+    Set,
     Tuple,
     Type,
     Union,
@@ -638,10 +639,75 @@ def _action_set_suppress_tab_hint(self: argparse.Action, suppress_tab_hint: bool
 setattr(argparse.Action, 'set_suppress_tab_hint', _action_set_suppress_tab_hint)
 
 
+############################################################################################################
+# Allow developers to add custom action attributes
+############################################################################################################
+
+CUSTOM_ACTION_ATTRIBS: Set[str] = set()
+_CUSTOM_ATTRIB_PFX = '_attr_'
+
+
+def register_argparse_argument_parameter(param_name: str, param_type: Optional[Type[Any]]) -> None:
+    """
+    Registers a custom argparse argument parameter.
+
+    The registered name will then be a recognized keyword parameter to the parser's `add_argument()` function.
+
+    An accessor functions will be added to the parameter's Action object in the form of: ``get_{param_name}()``
+    and ``set_{param_name}(value)``.
+
+    :param param_name: Name of the parameter to add.
+    """
+    attr_name = f'{_CUSTOM_ATTRIB_PFX}{param_name}'
+    if param_name in CUSTOM_ACTION_ATTRIBS or hasattr(argparse.Action, attr_name):
+        raise KeyError(f'Custom parameter {param_name} already exists')
+    if not re.search('^[A-Za-z_][A-Za-z0-9_]*$', param_name):
+        raise KeyError(f'Invalid parameter name {param_name} - cannot be used as a python identifier')
+
+    getter_name = f'get_{param_name}'
+
+    def _action_get_custom_parameter(self: argparse.Action) -> Any:
+        f"""
+        Get the custom {param_name} attribute of an argparse Action.
+
+        This function is added by cmd2 as a method called ``{getter_name}()`` to ``argparse.Action`` class.
+
+        To call: ``action.{getter_name}()``
+
+        :param self: argparse Action being queried
+        :return: The value of {param_name} or None if attribute does not exist
+        """
+        return getattr(self, attr_name, None)
+
+    setattr(argparse.Action, getter_name, _action_get_custom_parameter)
+
+    setter_name = f'set_{param_name}'
+
+    def _action_set_custom_parameter(self: argparse.Action, value: Any) -> None:
+        f"""
+        Set the custom {param_name} attribute of an argparse Action.
+
+        This function is added by cmd2 as a method called ``{setter_name}()`` to ``argparse.Action`` class.
+
+        To call: ``action.{setter_name}({param_name})``
+
+        :param self: argparse Action being updated
+        :param value: value being assigned
+        """
+        if param_type and not isinstance(value, param_type):
+            raise TypeError(f'{param_name} must be of type {param_type}, got: {value} ({type(value)})')
+        setattr(self, attr_name, value)
+
+    setattr(argparse.Action, setter_name, _action_set_custom_parameter)
+
+    CUSTOM_ACTION_ATTRIBS.add(param_name)
+
+
 ############################################################################################################
 # Patch _ActionsContainer.add_argument with our wrapper to support more arguments
 ############################################################################################################
 
+
 # Save original _ActionsContainer.add_argument so we can call it in our wrapper
 # noinspection PyProtectedMember
 orig_actions_container_add_argument = argparse._ActionsContainer.add_argument
@@ -751,6 +817,14 @@ def _add_argument_wrapper(
         # Add the argparse-recognized version of nargs to kwargs
         kwargs['nargs'] = nargs_adjusted
 
+    # Extract registered custom keyword arguments
+    custom_attribs: Dict[str, Any] = {}
+    for keyword, value in kwargs.items():
+        if keyword in CUSTOM_ACTION_ATTRIBS:
+            custom_attribs[keyword] = value
+    for keyword in custom_attribs:
+        del kwargs[keyword]
+
     # Create the argument using the original add_argument function
     new_arg = orig_actions_container_add_argument(self, *args, **kwargs)
 
@@ -765,6 +839,11 @@ def _add_argument_wrapper(
     new_arg.set_suppress_tab_hint(suppress_tab_hint)  # type: ignore[attr-defined]
     new_arg.set_descriptive_header(descriptive_header)  # type: ignore[attr-defined]
 
+    for keyword, value in custom_attribs.items():
+        attr_setter = getattr(new_arg, f'set_{keyword}', None)
+        if attr_setter is not None:
+            attr_setter(value)
+
     return new_arg
 
 
@@ -1241,6 +1320,9 @@ DEFAULT_ARGUMENT_PARSER: Type[argparse.ArgumentParser] = Cmd2ArgumentParser
 
 
 def set_default_argument_parser(parser: Type[argparse.ArgumentParser]) -> None:
-    """Set the default ArgumentParser class for a cmd2 app"""
+    """
+    Set the default ArgumentParser class for a cmd2 app. This must be called prior to loading cmd2.py if
+    you want to override the parser for cmd2's built-in commands. See examples/override_parser.py.
+    """
     global DEFAULT_ARGUMENT_PARSER
     DEFAULT_ARGUMENT_PARSER = parser
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index df4d1dc2..b0637ad2 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -71,12 +71,13 @@ from typing import (
 
 from . import (
     ansi,
+    argparse_completer,
+    argparse_custom,
     constants,
     plugin,
     utils,
 )
 from .argparse_custom import (
-    DEFAULT_ARGUMENT_PARSER,
     ChoicesProviderFunc,
     CompleterFunc,
     CompletionItem,
@@ -405,7 +406,7 @@ class Cmd(cmd.Cmd):
 
         # Check for command line args
         if allow_cli_args:
-            parser = DEFAULT_ARGUMENT_PARSER()
+            parser = argparse_custom.DEFAULT_ARGUMENT_PARSER()
             parser.add_argument('-t', '--test', action="store_true", help='Test against transcript(s) in FILE (wildcards OK)')
             callopts, callargs = parser.parse_known_args()
 
@@ -1900,10 +1901,16 @@ class Cmd(cmd.Cmd):
                     # There's no completer function, next see if the command uses argparse
                     func = self.cmd_func(command)
                     argparser = getattr(func, constants.CMD_ATTR_ARGPARSER, None)
+                    completer_type = getattr(func, constants.CMD_ATTR_COMPLETER, argparse_completer.DEFAULT_COMMAND_COMPLETER)
+                    if completer_type is None:
+                        completer_type = argparse_completer.DEFAULT_COMMAND_COMPLETER
 
                     if func is not None and argparser is not None:
                         cmd_set = self._cmd_to_command_sets[command] if command in self._cmd_to_command_sets else None
-                        completer = ArgparseCompleter(argparser, self)
+                        if completer_type is not None:
+                            completer = completer_type(argparser, self)
+                        else:
+                            completer = ArgparseCompleter(argparser, self)
                         preserve_quotes = getattr(func, constants.CMD_ATTR_PRESERVE_QUOTES)
 
                         completer_func = functools.partial(
@@ -2072,7 +2079,7 @@ class Cmd(cmd.Cmd):
                             break
                     else:
                         # No shortcut was found. Complete the command token.
-                        parser = DEFAULT_ARGUMENT_PARSER(add_help=False)
+                        parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(add_help=False)
                         parser.add_argument(
                             'command',
                             metavar="COMMAND",
@@ -2879,7 +2886,7 @@ class Cmd(cmd.Cmd):
                 # Set custom completion settings
                 else:
                     if parser is None:
-                        parser = DEFAULT_ARGUMENT_PARSER(add_help=False)
+                        parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(add_help=False)
                         parser.add_argument(
                             'arg',
                             suppress_tab_hint=True,
@@ -3081,7 +3088,7 @@ class Cmd(cmd.Cmd):
     # Top-level parser for alias
     alias_description = "Manage aliases\n" "\n" "An alias is a command that enables replacement of a word by another string."
     alias_epilog = "See also:\n" "  macro"
-    alias_parser = DEFAULT_ARGUMENT_PARSER(description=alias_description, epilog=alias_epilog)
+    alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description, epilog=alias_epilog)
     alias_subparsers = alias_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND')
     alias_subparsers.required = True
 
@@ -3110,7 +3117,9 @@ class Cmd(cmd.Cmd):
         "  alias create save_results print_results \">\" out.txt\n"
     )
 
-    alias_create_parser = DEFAULT_ARGUMENT_PARSER(description=alias_create_description, epilog=alias_create_epilog)
+    alias_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(
+        description=alias_create_description, epilog=alias_create_epilog
+    )
     alias_create_parser.add_argument('name', help='name of this alias')
     alias_create_parser.add_argument(
         'command', help='what the alias resolves to', choices_provider=_get_commands_aliases_and_macros_for_completion
@@ -3156,7 +3165,7 @@ class Cmd(cmd.Cmd):
     alias_delete_help = "delete aliases"
     alias_delete_description = "Delete specified aliases or all aliases if --all is used"
 
-    alias_delete_parser = DEFAULT_ARGUMENT_PARSER(description=alias_delete_description)
+    alias_delete_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_delete_description)
     alias_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all aliases")
     alias_delete_parser.add_argument(
         'names',
@@ -3191,7 +3200,7 @@ class Cmd(cmd.Cmd):
         "Without arguments, all aliases will be listed."
     )
 
-    alias_list_parser = DEFAULT_ARGUMENT_PARSER(description=alias_list_description)
+    alias_list_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_list_description)
     alias_list_parser.add_argument(
         'names',
         nargs=argparse.ZERO_OR_MORE,
@@ -3239,7 +3248,7 @@ class Cmd(cmd.Cmd):
     # Top-level parser for macro
     macro_description = "Manage macros\n" "\n" "A macro is similar to an alias, but it can contain argument placeholders."
     macro_epilog = "See also:\n" "  alias"
-    macro_parser = DEFAULT_ARGUMENT_PARSER(description=macro_description, epilog=macro_epilog)
+    macro_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_description, epilog=macro_epilog)
     macro_subparsers = macro_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND')
     macro_subparsers.required = True
 
@@ -3292,7 +3301,9 @@ class Cmd(cmd.Cmd):
         "  will only complete paths while typing a macro."
     )
 
-    macro_create_parser = DEFAULT_ARGUMENT_PARSER(description=macro_create_description, epilog=macro_create_epilog)
+    macro_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(
+        description=macro_create_description, epilog=macro_create_epilog
+    )
     macro_create_parser.add_argument('name', help='name of this macro')
     macro_create_parser.add_argument(
         'command', help='what the macro resolves to', choices_provider=_get_commands_aliases_and_macros_for_completion
@@ -3382,7 +3393,7 @@ class Cmd(cmd.Cmd):
     # macro -> delete
     macro_delete_help = "delete macros"
     macro_delete_description = "Delete specified macros or all macros if --all is used"
-    macro_delete_parser = DEFAULT_ARGUMENT_PARSER(description=macro_delete_description)
+    macro_delete_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_delete_description)
     macro_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all macros")
     macro_delete_parser.add_argument(
         'names',
@@ -3417,7 +3428,7 @@ class Cmd(cmd.Cmd):
         "Without arguments, all macros will be listed."
     )
 
-    macro_list_parser = DEFAULT_ARGUMENT_PARSER(description=macro_list_description)
+    macro_list_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_list_description)
     macro_list_parser.add_argument(
         'names',
         nargs=argparse.ZERO_OR_MORE,
@@ -3490,7 +3501,7 @@ class Cmd(cmd.Cmd):
         completer = ArgparseCompleter(argparser, self)
         return completer.complete_subcommand_help(text, line, begidx, endidx, arg_tokens['subcommands'])
 
-    help_parser = DEFAULT_ARGUMENT_PARSER(
+    help_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(
         description="List available commands or provide " "detailed help for a specific command"
     )
     help_parser.add_argument(
@@ -3670,7 +3681,7 @@ class Cmd(cmd.Cmd):
                         command = ''
                 self.poutput()
 
-    shortcuts_parser = DEFAULT_ARGUMENT_PARSER(description="List available shortcuts")
+    shortcuts_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="List available shortcuts")
 
     @with_argparser(shortcuts_parser)
     def do_shortcuts(self, _: argparse.Namespace) -> None:
@@ -3680,7 +3691,9 @@ class Cmd(cmd.Cmd):
         result = "\n".join('{}: {}'.format(sc[0], sc[1]) for sc in sorted_shortcuts)
         self.poutput(f"Shortcuts for other commands:\n{result}")
 
-    eof_parser = DEFAULT_ARGUMENT_PARSER(description="Called when Ctrl-D is pressed", epilog=INTERNAL_COMMAND_EPILOG)
+    eof_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(
+        description="Called when Ctrl-D is pressed", epilog=INTERNAL_COMMAND_EPILOG
+    )
 
     @with_argparser(eof_parser)
     def do_eof(self, _: argparse.Namespace) -> Optional[bool]:
@@ -3693,7 +3706,7 @@ class Cmd(cmd.Cmd):
         # noinspection PyTypeChecker
         return self.do_quit('')
 
-    quit_parser = DEFAULT_ARGUMENT_PARSER(description="Exit this application")
+    quit_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Exit this application")
 
     @with_argparser(quit_parser)
     def do_quit(self, _: argparse.Namespace) -> Optional[bool]:
@@ -3761,7 +3774,7 @@ class Cmd(cmd.Cmd):
             raise CompletionError(param + " is not a settable parameter")
 
         # Create a parser with a value field based on this settable
-        settable_parser = DEFAULT_ARGUMENT_PARSER(parents=[Cmd.set_parser_parent])
+        settable_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(parents=[Cmd.set_parser_parent])
 
         # Settables with choices list the values of those choices instead of the arg name
         # in help text and this shows in tab completion hints. Set metavar to avoid this.
@@ -3792,7 +3805,7 @@ class Cmd(cmd.Cmd):
         "Call without arguments for a list of all settable parameters with their values.\n"
         "Call with just param to view that parameter's value."
     )
-    set_parser_parent = DEFAULT_ARGUMENT_PARSER(description=set_description, add_help=False)
+    set_parser_parent = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=set_description, add_help=False)
     set_parser_parent.add_argument(
         '-v', '--verbose', action='store_true', help='include description of parameters when viewing'
     )
@@ -3805,7 +3818,7 @@ class Cmd(cmd.Cmd):
     )
 
     # Create the parser for the set command
-    set_parser = DEFAULT_ARGUMENT_PARSER(parents=[set_parser_parent])
+    set_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(parents=[set_parser_parent])
     set_parser.add_argument(
         'value', nargs=argparse.OPTIONAL, help='new value for settable', completer=complete_set_value, suppress_tab_hint=True
     )
@@ -3859,7 +3872,7 @@ class Cmd(cmd.Cmd):
             else:
                 self.poutput(result_str)
 
-    shell_parser = DEFAULT_ARGUMENT_PARSER(description="Execute a command as if at the OS prompt")
+    shell_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Execute a command as if at the OS prompt")
     shell_parser.add_argument('command', help='the command to run', completer=shell_cmd_complete)
     shell_parser.add_argument(
         'command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', completer=path_complete
@@ -4167,7 +4180,7 @@ class Cmd(cmd.Cmd):
 
         return py_bridge.stop
 
-    py_parser = DEFAULT_ARGUMENT_PARSER(description="Run an interactive Python shell")
+    py_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive Python shell")
 
     @with_argparser(py_parser)
     def do_py(self, _: argparse.Namespace) -> Optional[bool]:
@@ -4177,7 +4190,7 @@ class Cmd(cmd.Cmd):
         """
         return self._run_python()
 
-    run_pyscript_parser = DEFAULT_ARGUMENT_PARSER(description="Run a Python script file inside the console")
+    run_pyscript_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run a Python script file inside the console")
     run_pyscript_parser.add_argument('script_path', help='path to the script file', completer=path_complete)
     run_pyscript_parser.add_argument(
         'script_arguments', nargs=argparse.REMAINDER, help='arguments to pass to script', completer=path_complete
@@ -4214,7 +4227,7 @@ class Cmd(cmd.Cmd):
 
         return py_return
 
-    ipython_parser = DEFAULT_ARGUMENT_PARSER(description="Run an interactive IPython shell")
+    ipython_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive IPython shell")
 
     # noinspection PyPackageRequirements
     @with_argparser(ipython_parser)
@@ -4284,7 +4297,7 @@ class Cmd(cmd.Cmd):
 
     history_description = "View, run, edit, save, or clear previously entered commands"
 
-    history_parser = DEFAULT_ARGUMENT_PARSER(description=history_description)
+    history_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=history_description)
     history_action_group = history_parser.add_mutually_exclusive_group()
     history_action_group.add_argument('-r', '--run', action='store_true', help='run selected history items')
     history_action_group.add_argument('-e', '--edit', action='store_true', help='edit and then run selected history items')
@@ -4621,7 +4634,7 @@ class Cmd(cmd.Cmd):
         "  set editor (program-name)"
     )
 
-    edit_parser = DEFAULT_ARGUMENT_PARSER(description=edit_description)
+    edit_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=edit_description)
     edit_parser.add_argument(
         'file_path', nargs=argparse.OPTIONAL, help="optional path to a file to open in editor", completer=path_complete
     )
@@ -4666,7 +4679,7 @@ class Cmd(cmd.Cmd):
         "the output of the script commands to a transcript for testing purposes.\n"
     )
 
-    run_script_parser = DEFAULT_ARGUMENT_PARSER(description=run_script_description)
+    run_script_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=run_script_description)
     run_script_parser.add_argument(
         '-t',
         '--transcript',
@@ -4735,7 +4748,7 @@ class Cmd(cmd.Cmd):
 
     relative_run_script_epilog = "Notes:\n" "  This command is intended to only be used within text file scripts."
 
-    relative_run_script_parser = DEFAULT_ARGUMENT_PARSER(
+    relative_run_script_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(
         description=relative_run_script_description, epilog=relative_run_script_epilog
     )
     relative_run_script_parser.add_argument('file_path', help='a file path pointing to a script')
diff --git a/cmd2/constants.py b/cmd2/constants.py
index 9f29be86..7656ae58 100644
--- a/cmd2/constants.py
+++ b/cmd2/constants.py
@@ -43,6 +43,7 @@ CLASS_ATTR_DEFAULT_HELP_CATEGORY = 'cmd2_default_help_category'
 
 # The argparse parser for the command
 CMD_ATTR_ARGPARSER = 'argparser'
+CMD_ATTR_COMPLETER = 'command_completer'
 
 # Whether or not tokens are unquoted before sending to argparse
 CMD_ATTR_PRESERVE_QUOTES = 'preserve_quotes'
diff --git a/cmd2/decorators.py b/cmd2/decorators.py
index 1ff0bdbe..644a8add 100644
--- a/cmd2/decorators.py
+++ b/cmd2/decorators.py
@@ -10,12 +10,16 @@ from typing import (
     Optional,
     Sequence,
     Tuple,
+    Type,
     Union,
 )
 
 from . import (
     constants,
 )
+from .argparse_completer import (
+    ArgparseCompleter,
+)
 from .argparse_custom import (
     Cmd2AttributeWrapper,
 )
@@ -271,6 +275,7 @@ def with_argparser(
     ns_provider: Optional[Callable[..., argparse.Namespace]] = None,
     preserve_quotes: bool = False,
     with_unknown_args: bool = False,
+    completer: Optional[Type[ArgparseCompleter]] = None,
 ) -> Callable[[ArgparseCommandFunc], RawCommandFuncOptionalBoolReturn]:
     """A decorator to alter a cmd2 method to populate its ``args`` argument by parsing arguments
     with the given instance of argparse.ArgumentParser.
@@ -281,6 +286,7 @@ def with_argparser(
                         state data that affects parsing.
     :param preserve_quotes: if ``True``, then arguments passed to argparse maintain their quotes
     :param with_unknown_args: if true, then capture unknown args
+    :param completer: CommandCompleter type. Defaults to ArgparseCompleter if unspecified.
     :return: function that gets passed argparse-parsed args in a ``Namespace``
              A :class:`cmd2.argparse_custom.Cmd2AttributeWrapper` called ``cmd2_statement`` is included
              in the ``Namespace`` to provide access to the :class:`cmd2.Statement` object that was created when
@@ -391,6 +397,7 @@ def with_argparser(
 
         # Set some custom attributes for this command
         setattr(cmd_wrapper, constants.CMD_ATTR_ARGPARSER, parser)
+        setattr(cmd_wrapper, constants.CMD_ATTR_COMPLETER, completer)
         setattr(cmd_wrapper, constants.CMD_ATTR_PRESERVE_QUOTES, preserve_quotes)
 
         return cmd_wrapper
diff --git a/examples/custom_parser.py b/examples/custom_parser.py
index 6df68bfa..ea66e7e1 100644
--- a/examples/custom_parser.py
+++ b/examples/custom_parser.py
@@ -31,6 +31,8 @@ class CustomParser(Cmd2ArgumentParser):
             linum += 1
 
         self.print_usage(sys.stderr)
+
+        # Format errors with style_warning()
         formatted_message = ansi.style_warning(formatted_message)
         self.exit(2, '{}\n\n'.format(formatted_message))
 
diff --git a/examples/override_parser.py b/examples/override_parser.py
index 00161ef6..2e778c07 100755
--- a/examples/override_parser.py
+++ b/examples/override_parser.py
@@ -6,20 +6,19 @@ The standard parser used by cmd2 built-in commands is Cmd2ArgumentParser.
 The following code shows how to override it with your own parser class.
 """
 
-# First set a value called argparse.cmd2_parser_module with the module that defines the custom parser
+# First set a value called argparse.cmd2_parser_module with the module that defines the custom parser.
 # See the code for custom_parser.py. It simply defines a parser and calls cmd2.set_default_argument_parser()
 # with the custom parser's type.
 import argparse
 
-# Next import stuff from cmd2. It will import your module just before the cmd2.Cmd class file is imported
+argparse.cmd2_parser_module = 'examples.custom_parser'
+
+# Next import from cmd2. It will import your module just before the cmd2.Cmd class file is imported
 # and therefore override the parser class it uses on its commands.
 from cmd2 import (
     cmd2,
 )
 
-argparse.cmd2_parser_module = 'examples.custom_parser'
-
-
 if __name__ == '__main__':
     import sys
 
diff --git a/setup.py b/setup.py
index d2d2e955..15b0ae7b 100755
--- a/setup.py
+++ b/setup.py
@@ -83,6 +83,7 @@ EXTRAS_REQUIRE = {
     'validate': [
         'flake8',
         'mypy==0.902',
+        'types-pkg-resources',
     ],
 }
 


Test patch:
diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py
index 6002a856..11ae2a42 100644
--- a/tests/test_argparse_completer.py
+++ b/tests/test_argparse_completer.py
@@ -16,6 +16,8 @@ from cmd2 import (
     Cmd2ArgumentParser,
     CompletionError,
     CompletionItem,
+    argparse_completer,
+    argparse_custom,
     with_argparser,
 )
 from cmd2.utils import (
@@ -1144,3 +1146,54 @@ def test_complete_standalone(ac_app, flag, completions):
     first_match = complete_tester(text, line, begidx, endidx, ac_app)
     assert first_match is not None
     assert ac_app.completion_matches == sorted(completions, key=ac_app.default_sort_key)
+
+
+class CustomCompleter(argparse_completer.ArgparseCompleter):
+    def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, matched_flags: List[str]) -> List[str]:
+        """Override so arguments with 'always_complete' set to True will always be completed"""
+        for flag in matched_flags:
+            action = self._flag_to_action[flag]
+            if action.get_always_complete() is True:
+                matched_flags.remove(flag)
+        return super(CustomCompleter, self)._complete_flags(text, line, begidx, endidx, matched_flags)
+
+
+argparse_custom.register_argparse_argument_parameter('always_complete', bool)
+
+
+class CustomCompleterApp(cmd2.Cmd):
+    _parser = Cmd2ArgumentParser(description="Testing manually wrapping")
+    _parser.add_argument('--myflag', always_complete=True, nargs=1)
+
+    @with_argparser(_parser)
+    def do_mycommand(self, cmd: 'CustomCompleterApp', args: argparse.Namespace) -> None:
+        """Test command that will be manually wrapped to use argparse"""
+        print(args)
+
+
+@pytest.fixture
+def custom_completer_app():
+
+    argparse_completer.set_default_command_completer_type(CustomCompleter)
+    app = CustomCompleterApp()
+    app.stdout = StdSim(app.stdout)
+    yield app
+    argparse_completer.set_default_command_completer_type(argparse_completer.ArgparseCompleter)
+
+
+@pytest.mark.parametrize(
+    'command_and_args, text, output_contains, first_match',
+    [
+        ('mycommand', '--my', '', '--myflag '),
+        ('mycommand --myflag 5', '--my', '', '--myflag '),
+    ],
+)
+def test_custom_completer_type(custom_completer_app, command_and_args, text, output_contains, first_match, capsys):
+    line = '{} {}'.format(command_and_args, text)
+    endidx = len(line)
+    begidx = endidx - len(text)
+
+    assert first_match == complete_tester(text, line, begidx, endidx, custom_completer_app)
+
+    out, err = capsys.readouterr()
+    assert output_contains in out
diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py
index dcfb7d9f..fccccd43 100644
--- a/tests/test_argparse_custom.py
+++ b/tests/test_argparse_custom.py
@@ -247,28 +247,26 @@ def test_apcustom_required_options():
 
 
 def test_override_parser():
+    """Test overriding argparse_custom.DEFAULT_ARGUMENT_PARSER"""
     import importlib
 
     from cmd2 import (
-        DEFAULT_ARGUMENT_PARSER,
+        argparse_custom,
     )
 
     # The standard parser is Cmd2ArgumentParser
-    assert DEFAULT_ARGUMENT_PARSER == Cmd2ArgumentParser
+    assert argparse_custom.DEFAULT_ARGUMENT_PARSER == Cmd2ArgumentParser
 
     # Set our parser module and force a reload of cmd2 so it loads the module
     argparse.cmd2_parser_module = 'examples.custom_parser'
     importlib.reload(cmd2)
-    from cmd2 import (
-        DEFAULT_ARGUMENT_PARSER,
-    )
 
     # Verify DEFAULT_ARGUMENT_PARSER is now our CustomParser
     from examples.custom_parser import (
         CustomParser,
     )
 
-    assert DEFAULT_ARGUMENT_PARSER == CustomParser
+    assert argparse_custom.DEFAULT_ARGUMENT_PARSER == CustomParser
 
 
 def test_apcustom_metavar_tuple():