Repository: python-cmd2/cmd2

Issue:
Allow customization of the default argument parser used by cmd2
We have the ability to modify the argument parser used by `do_XX` commands with `@with_parser` decorator.

The parsers used by the inbuilt cmd2 commands such as `macro`, `alias` etc are hardcoded to use `Cmd2ArgumentParser`
https://github.com/python-cmd2/cmd2/blob/master/cmd2/cmd2.py#L2384

Since I have my custom argument parser for all my `do_XX` commands (which inherits from Cmd2ArgumentParser), the output of my custom commands while parsing commands (color/format) doesn't match with the output of the default commands like `alias`, `macro`.

Currently, I have a class similar to,

````python

class CustomCmd2(cmd2.Cmd):
    def __init__(self, connection, *args, **kwargs):
        # Dynamically add all do_XX functions from the package 'modules' since it is cumbersome
        # to write every method in the class.
        for module_name in modules.__all__:
            module = importlib.import_module(modules.__name__ + "." + module_name)
            predicate = lambda obj: inspect.isfunction(obj) and obj.__name__.startswith("do_")
            functions = inspect.getmembers(module, predicate)
            for function_name, function in functions:
                setattr(self, function_name, types.MethodType(function, self))

        cmd2.Cmd.__init__(self, *args, **kwargs)

        # Modify the parsers after initialization.
        # Cast the parser to my custom parser for added functionality.
        def cast(obj, req_type, obj_type = None):
            if obj_type:
                assert isinstance(self.alias_parser, obj_type), "The object type is different. Dangerous to  cast."
            obj.__class__ = req_type

        cast(self.alias_parser, ColoredArgParser, cmd2.Cmd2ArgumentParser)
        .........

    def get_names(self):
        """
        The default implementation from cmd (cpython implementation) looks like,
            def get_names(self):
                return dir(self.__class__)
        Since we are modifying the class instance so that functions are added at runtime, we have to return dir(self)
        This fixes the issue of the commands not registering in do_help().
        """
        return dir(self) 
````

To change the default parser to my custom ColoredArgParser, I am hacking the class with,

```python
def obj_cast(obj, req_type, obj_type = None):
    if obj_type:
        assert isinstance(self.alias_parser, obj_type), "The object type is different. Dangerous to  cast."
    obj.__class__ = req_type

obj_cast(self.alias_parser, ColoredArgParser, cmd2.Cmd2ArgumentParser)

```

This seems very hackish, and casting is not pythonic.
I believe there should be a way to specify the default argument parser used.

This would also match the API used in the rest of the project.
We have the ability to modify the HelpFormatter in cmd2 https://github.com/python-cmd2/cmd2/blob/master/cmd2/argparse_custom.py#L694
There must also be a way to modify the default argument parser.
Maybe in a similar fashion while constructing the object?

If there is a simpler way to achieve this, it would be helpful to know the method.
I am willing to contribute if necessary.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8264049b..d78bf0ea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@
 * Enhancements
     * Added `read_input()` function that is used to read from stdin. Unlike the Python built-in `input()`, it also has
     an argument to disable tab completion while input is being entered.
+    * Added capability to override the argument parser class used by cmd2 built-in commands. See override_parser.py
+    example for more details.
 
 ## 0.9.20 (November 12, 2019)
 * Bug Fixes
diff --git a/cmd2/__init__.py b/cmd2/__init__.py
index 8e8a8845..8fc5e9f2 100644
--- a/cmd2/__init__.py
+++ b/cmd2/__init__.py
@@ -11,7 +11,17 @@ except DistributionNotFound:
     pass
 
 from .ansi import style
-from .argparse_custom import Cmd2ArgumentParser, CompletionError, CompletionItem
+from .argparse_custom import Cmd2ArgumentParser, CompletionError, CompletionItem, set_default_argument_parser
+
+# Check if user has defined a module that sets a custom value for argparse_custom.DEFAULT_ARGUMENT_PARSER
+import argparse
+cmd2_parser_module = getattr(argparse, 'cmd2_parser_module', None)
+if cmd2_parser_module is not None:
+    import importlib
+    importlib.import_module(cmd2_parser_module)
+
+# Get the current value for argparse_custom.DEFAULT_ARGUMENT_PARSER
+from .argparse_custom import DEFAULT_ARGUMENT_PARSER
 from .cmd2 import Cmd, EmptyStatement
 from .constants import COMMAND_NAME, DEFAULT_SHORTCUTS
 from .decorators import categorize, with_argument_list, with_argparser, with_argparser_and_unknown_args, with_category
diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py
index f7dbc8a3..c6aa6550 100644
--- a/cmd2/argparse_custom.py
+++ b/cmd2/argparse_custom.py
@@ -182,7 +182,7 @@ import re
 import sys
 # noinspection PyUnresolvedReferences,PyProtectedMember
 from argparse import ZERO_OR_MORE, ONE_OR_MORE, ArgumentError, _
-from typing import Callable, Optional, Tuple, Union
+from typing import Callable, Optional, Tuple, Type, Union
 
 from .ansi import ansi_aware_write, style_error
 
@@ -806,3 +806,13 @@ class Cmd2ArgumentParser(argparse.ArgumentParser):
             if file is None:
                 file = sys.stderr
             ansi_aware_write(file, message)
+
+
+# The default ArgumentParser class for a cmd2 app
+DEFAULT_ARGUMENT_PARSER = Cmd2ArgumentParser
+
+
+def set_default_argument_parser(parser: Type[argparse.ArgumentParser]) -> None:
+    """Set the default ArgumentParser class for a cmd2 app"""
+    global DEFAULT_ARGUMENT_PARSER
+    DEFAULT_ARGUMENT_PARSER = parser
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index 29bf9529..bd9dd4ff 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -47,7 +47,7 @@ from . import ansi
 from . import constants
 from . import plugin
 from . import utils
-from .argparse_custom import Cmd2ArgumentParser, CompletionItem
+from .argparse_custom import CompletionItem, DEFAULT_ARGUMENT_PARSER
 from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer
 from .decorators import with_argparser
 from .history import History, HistoryItem
@@ -2244,7 +2244,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)
+    alias_parser = DEFAULT_ARGUMENT_PARSER(description=alias_description, epilog=alias_epilog)
 
     # Add subcommands to alias
     alias_subparsers = alias_parser.add_subparsers(dest='subcommand')
@@ -2421,7 +2421,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)
+    macro_parser = DEFAULT_ARGUMENT_PARSER(description=macro_description, epilog=macro_epilog)
 
     # Add subcommands to macro
     macro_subparsers = macro_parser.add_subparsers(dest='subcommand')
@@ -2537,8 +2537,8 @@ class Cmd(cmd.Cmd):
         completer = AutoCompleter(argparser, self)
         return completer.complete_subcommand_help(tokens, text, line, begidx, endidx)
 
-    help_parser = Cmd2ArgumentParser(description="List available commands or provide "
-                                                 "detailed help for a specific command")
+    help_parser = DEFAULT_ARGUMENT_PARSER(description="List available commands or provide "
+                                                      "detailed help for a specific command")
     help_parser.add_argument('command', nargs=argparse.OPTIONAL, help="command to retrieve help for",
                              completer_method=complete_help_command)
     help_parser.add_argument('subcommands', nargs=argparse.REMAINDER, help="subcommand(s) to retrieve help for",
@@ -2707,7 +2707,7 @@ class Cmd(cmd.Cmd):
                         command = ''
                 self.stdout.write("\n")
 
-    @with_argparser(Cmd2ArgumentParser(description="List available shortcuts"))
+    @with_argparser(DEFAULT_ARGUMENT_PARSER(description="List available shortcuts"))
     def do_shortcuts(self, _: argparse.Namespace) -> None:
         """List available shortcuts"""
         # Sort the shortcut tuples by name
@@ -2715,13 +2715,13 @@ class Cmd(cmd.Cmd):
         result = "\n".join('{}: {}'.format(sc[0], sc[1]) for sc in sorted_shortcuts)
         self.poutput("Shortcuts for other commands:\n{}".format(result))
 
-    @with_argparser(Cmd2ArgumentParser(epilog=INTERNAL_COMMAND_EPILOG))
+    @with_argparser(DEFAULT_ARGUMENT_PARSER(epilog=INTERNAL_COMMAND_EPILOG))
     def do_eof(self, _: argparse.Namespace) -> bool:
         """Called when <Ctrl>-D is pressed"""
         # Return True to stop the command loop
         return True
 
-    @with_argparser(Cmd2ArgumentParser(description="Exit this application"))
+    @with_argparser(DEFAULT_ARGUMENT_PARSER(description="Exit this application"))
     def do_quit(self, _: argparse.Namespace) -> bool:
         """Exit this application"""
         # Return True to stop the command loop
@@ -2824,7 +2824,7 @@ class Cmd(cmd.Cmd):
                        "Accepts abbreviated parameter names so long as there is no ambiguity.\n"
                        "Call without arguments for a list of settable parameters with their values.")
 
-    set_parser = Cmd2ArgumentParser(description=set_description)
+    set_parser = DEFAULT_ARGUMENT_PARSER(description=set_description)
     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('param', nargs=argparse.OPTIONAL, help='parameter to set or view',
@@ -2869,7 +2869,7 @@ class Cmd(cmd.Cmd):
             if onchange_hook is not None:
                 onchange_hook(old=orig_value, new=new_value)  # pylint: disable=not-callable
 
-    shell_parser = Cmd2ArgumentParser(description="Execute a command as if at the OS prompt")
+    shell_parser = DEFAULT_ARGUMENT_PARSER(description="Execute a command as if at the OS prompt")
     shell_parser.add_argument('command', help='the command to run', completer_method=shell_cmd_complete)
     shell_parser.add_argument('command_args', nargs=argparse.REMAINDER, help='arguments to pass to command',
                               completer_method=path_complete)
@@ -3034,7 +3034,7 @@ class Cmd(cmd.Cmd):
                       "If you see strange parsing behavior, it's best to just open the Python shell\n"
                       "by providing no arguments to py and run more complex statements there.")
 
-    py_parser = Cmd2ArgumentParser(description=py_description)
+    py_parser = DEFAULT_ARGUMENT_PARSER(description=py_description)
     py_parser.add_argument('command', nargs=argparse.OPTIONAL, help="command to run")
     py_parser.add_argument('remainder', nargs=argparse.REMAINDER, help="remainder of command")
 
@@ -3156,7 +3156,7 @@ class Cmd(cmd.Cmd):
 
         return py_bridge.stop
 
-    run_pyscript_parser = Cmd2ArgumentParser(description="Run a Python script file inside the console")
+    run_pyscript_parser = 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_method=path_complete)
     run_pyscript_parser.add_argument('script_arguments', nargs=argparse.REMAINDER,
                                      help='arguments to pass to script', completer_method=path_complete)
@@ -3201,7 +3201,7 @@ class Cmd(cmd.Cmd):
 
     # Only include the do_ipy() method if IPython is available on the system
     if ipython_available:  # pragma: no cover
-        @with_argparser(Cmd2ArgumentParser(description="Enter an interactive IPython shell"))
+        @with_argparser(DEFAULT_ARGUMENT_PARSER(description="Enter an interactive IPython shell"))
         def do_ipy(self, _: argparse.Namespace) -> None:
             """Enter an interactive IPython shell"""
             from .py_bridge import PyBridge
@@ -3232,7 +3232,7 @@ class Cmd(cmd.Cmd):
 
     history_description = "View, run, edit, save, or clear previously entered commands"
 
-    history_parser = Cmd2ArgumentParser(description=history_description)
+    history_parser = 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',
@@ -3543,7 +3543,7 @@ class Cmd(cmd.Cmd):
                         "\n"
                         "  set editor (program-name)")
 
-    edit_parser = Cmd2ArgumentParser(description=edit_description)
+    edit_parser = 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_method=path_complete)
 
@@ -3584,7 +3584,7 @@ class Cmd(cmd.Cmd):
                               "If the -t/--transcript flag is used, this command instead records\n"
                               "the output of the script commands to a transcript for testing purposes.\n")
 
-    run_script_parser = Cmd2ArgumentParser(description=run_script_description)
+    run_script_parser = DEFAULT_ARGUMENT_PARSER(description=run_script_description)
     run_script_parser.add_argument('-t', '--transcript', metavar='TRANSCRIPT_FILE',
                                    help='record the output of the script as a transcript file',
                                    completer_method=path_complete)
@@ -3658,8 +3658,8 @@ 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 = Cmd2ArgumentParser(description=relative_run_script_description,
-                                                    epilog=relative_run_script_epilog)
+    relative_run_script_parser = 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')
 
     @with_argparser(relative_run_script_parser)
diff --git a/examples/custom_parser.py b/examples/custom_parser.py
new file mode 100644
index 00000000..efeb362e
--- /dev/null
+++ b/examples/custom_parser.py
@@ -0,0 +1,35 @@
+# coding=utf-8
+"""
+Defines the CustomParser used with override_parser.py example
+"""
+import sys
+
+from cmd2 import Cmd2ArgumentParser, set_default_argument_parser
+from cmd2.ansi import style_warning
+
+
+# First define the parser
+class CustomParser(Cmd2ArgumentParser):
+    """Overrides error class"""
+    def __init__(self, *args, **kwargs) -> None:
+        super().__init__(*args, **kwargs)
+
+    def error(self, message: str) -> None:
+        """Custom override that applies custom formatting to the error message"""
+        lines = message.split('\n')
+        linum = 0
+        formatted_message = ''
+        for line in lines:
+            if linum == 0:
+                formatted_message = 'Error: ' + line
+            else:
+                formatted_message += '\n       ' + line
+            linum += 1
+
+        self.print_usage(sys.stderr)
+        formatted_message = style_warning(formatted_message)
+        self.exit(2, '{}\n\n'.format(formatted_message))
+
+
+# Now set the default parser for a cmd2 app
+set_default_argument_parser(CustomParser)
diff --git a/examples/override_parser.py b/examples/override_parser.py
new file mode 100755
index 00000000..ddfa8323
--- /dev/null
+++ b/examples/override_parser.py
@@ -0,0 +1,24 @@
+#!/usr/bin/env python
+# coding=utf-8
+# flake8: noqa F402
+"""
+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
+# 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
+argparse.cmd2_parser_module = 'examples.custom_parser'
+
+# Next import stuff 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
+
+if __name__ == '__main__':
+    import sys
+    app = cmd2.Cmd(use_ipython=True, persistent_history_file='cmd2_history.dat')
+    app.locals_in_py = True     # Enable access to "self" within the py command
+    app.debug = True            # Show traceback if/when an exception occurs
+    sys.exit(app.cmdloop())


Test patch:
diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py
index 65cbc8da..ce789f8e 100644
--- a/tests/test_argparse_custom.py
+++ b/tests/test_argparse_custom.py
@@ -242,3 +242,20 @@ def test_apcustom_required_options():
     parser = Cmd2ArgumentParser()
     parser.add_argument('--required_flag', required=True)
     assert 'required arguments' in parser.format_help()
+
+
+def test_override_parser():
+    import importlib
+    from cmd2 import DEFAULT_ARGUMENT_PARSER
+
+    # The standard parser is Cmd2ArgumentParser
+    assert 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