Repository: python-cmd2/cmd2

Issue:
Should help verbose use description from ArgumentParser?
The `help -v` details for the built-in commands are all short descriptions, however the details for argparse-wrapped commands describe the syntax of the commands. This seems sort of inconsistent for a default, would make more sense for it to prefer the ArgumentParser description first? I mean, I could copy the descriptions to the docstrings if I wanted to just fix this myself, but I am wondering if a change in the way cmd2 does it is more appropriate.

Example "weird-looking" result of this:

```
Documented commands (type help <topic>):
================================================================================
alias               Define or display aliases
catann              usage: catann [-h] [-c COURSE] [ids [ids ...]]
cc                  usage: cc [-h] [course]
cd                  usage: cd [-h] [directory]
edit                Edit a file in a text editor.
help                List available commands with "help" or detailed help with "help cmd".
history             View, run, edit, save, or clear previously entered commands.
la                  usage: la [-h] [-c COURSE] [-l] [-s] [-u]
lann                usage: lann [-h] [-c COURSE] [-n NUMBER] [-d DAYS]
lc                  usage: lc [-h] [-a] [-l] [-i]
lg                  usage: lg [-h] [-c COURSE] [-l] [-u] [-a]
load                Runs commands in script file that is encoded as either ASCII or UTF-8 text.
login               usage: login [-h] [-q] url token
pullf               usage: pullf [-h] [-c COURSE] [-o OUTPUT]
py                  Invoke python command, shell, or script
pyscript            Runs a python script file inside the console
quit                Exits this application.
set                 Sets a settable parameter or shows current settings of parameters.
shell               Execute a command as if at the OS prompt.
shortcuts           Lists shortcuts (aliases) available.
unalias             Unsets aliases
whoami              usage: whoami [-h] [-v]
wopen               usage: wopen [-h] [-c COURSE] [tabs [tabs ...]]
```

Discussion:

Patch:
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index e04138fa..cc4c4bbc 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -120,14 +120,16 @@ except ImportError:  # pragma: no cover
 
 # optional attribute, when tagged on a function, allows cmd2 to categorize commands
 HELP_CATEGORY = 'help_category'
-HELP_SUMMARY = 'help_summary'
 
 INTERNAL_COMMAND_EPILOG = ("Notes:\n"
                            "  This command is for internal use and is not intended to be called from the\n"
                            "  command line.")
 
 # All command functions start with this
-COMMAND_PREFIX = 'do_'
+COMMAND_FUNC_PREFIX = 'do_'
+
+# All help functions start with this
+HELP_FUNC_PREFIX = 'help_'
 
 
 def categorize(func: Union[Callable, Iterable], category: str) -> None:
@@ -211,16 +213,14 @@ def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser, preserve
 
         # argparser defaults the program name to sys.argv[0]
         # we want it to be the name of our command
-        argparser.prog = func.__name__[3:]
+        argparser.prog = func.__name__[len(COMMAND_FUNC_PREFIX):]
 
         # If the description has not been set, then use the method docstring if one exists
         if argparser.description is None and func.__doc__:
             argparser.description = func.__doc__
 
-        if func.__doc__:
-            setattr(cmd_wrapper, HELP_SUMMARY, func.__doc__)
-
-        cmd_wrapper.__doc__ = argparser.format_help()
+        # Set the command's help text as argparser.description (which can be None)
+        cmd_wrapper.__doc__ = argparser.description
 
         # Mark this function as having an argparse ArgumentParser
         setattr(cmd_wrapper, 'argparser', argparser)
@@ -254,16 +254,14 @@ def with_argparser(argparser: argparse.ArgumentParser, preserve_quotes: bool=Fal
 
         # argparser defaults the program name to sys.argv[0]
         # we want it to be the name of our command
-        argparser.prog = func.__name__[3:]
+        argparser.prog = func.__name__[len(COMMAND_FUNC_PREFIX):]
 
         # If the description has not been set, then use the method docstring if one exists
         if argparser.description is None and func.__doc__:
             argparser.description = func.__doc__
 
-        if func.__doc__:
-            setattr(cmd_wrapper, HELP_SUMMARY, func.__doc__)
-
-        cmd_wrapper.__doc__ = argparser.format_help()
+        # Set the command's help text as argparser.description (which can be None)
+        cmd_wrapper.__doc__ = argparser.description
 
         # Mark this function as having an argparse ArgumentParser
         setattr(cmd_wrapper, 'argparser', argparser)
@@ -1606,8 +1604,8 @@ class Cmd(cmd.Cmd):
 
     def get_all_commands(self) -> List[str]:
         """Returns a list of all commands."""
-        return [name[len(COMMAND_PREFIX):] for name in self.get_names()
-                if name.startswith(COMMAND_PREFIX) and callable(getattr(self, name))]
+        return [name[len(COMMAND_FUNC_PREFIX):] for name in self.get_names()
+                if name.startswith(COMMAND_FUNC_PREFIX) and callable(getattr(self, name))]
 
     def get_visible_commands(self) -> List[str]:
         """Returns a list of commands that have not been hidden."""
@@ -1637,8 +1635,8 @@ class Cmd(cmd.Cmd):
 
     def get_help_topics(self) -> List[str]:
         """ Returns a list of help topics """
-        return [name[5:] for name in self.get_names()
-                if name.startswith('help_') and callable(getattr(self, name))]
+        return [name[len(HELP_FUNC_PREFIX):] for name in self.get_names()
+                if name.startswith(HELP_FUNC_PREFIX) and callable(getattr(self, name))]
 
     # noinspection PyUnusedLocal
     def sigint_handler(self, signum: int, frame) -> None:
@@ -1985,7 +1983,7 @@ class Cmd(cmd.Cmd):
         :param command: command to look up method name which implements it
         :return: method name which implements the given command
         """
-        target = COMMAND_PREFIX + command
+        target = COMMAND_FUNC_PREFIX + command
         return target if callable(getattr(self, target, None)) else ''
 
     def onecmd(self, statement: Union[Statement, str]) -> bool:
@@ -2635,15 +2633,22 @@ class Cmd(cmd.Cmd):
 
         for command in visible_commands:
             func = self.cmd_func(command)
-            if command in help_topics or func.__doc__:
-                if command in help_topics:
-                    help_topics.remove(command)
-                if hasattr(func, HELP_CATEGORY):
-                    category = getattr(func, HELP_CATEGORY)
-                    cmds_cats.setdefault(category, [])
-                    cmds_cats[category].append(command)
-                else:
-                    cmds_doc.append(command)
+            has_help_func = False
+
+            if command in help_topics:
+                # Prevent the command from showing as both a command and help topic in the output
+                help_topics.remove(command)
+
+                # Non-argparse commands can have help_functions for their documentation
+                if not hasattr(func, 'argparser'):
+                    has_help_func = True
+
+            if hasattr(func, HELP_CATEGORY):
+                category = getattr(func, HELP_CATEGORY)
+                cmds_cats.setdefault(category, [])
+                cmds_cats[category].append(command)
+            elif func.__doc__ or has_help_func:
+                cmds_doc.append(command)
             else:
                 cmds_undoc.append(command)
 
@@ -2685,23 +2690,17 @@ class Cmd(cmd.Cmd):
                 if self.ruler:
                     self.stdout.write('{:{ruler}<{width}}\n'.format('', ruler=self.ruler, width=80))
 
+                # Try to get the documentation string for each command
+                topics = self.get_help_topics()
+
                 for command in cmds:
-                    # Try to get the documentation string
-                    try:
-                        # first see if there's a help function implemented
-                        func = getattr(self, 'help_' + command)
-                    except AttributeError:
-                        # Couldn't find a help function
-                        func = self.cmd_func(command)
-                        try:
-                            # Now see if help_summary has been set
-                            doc = func.help_summary
-                        except AttributeError:
-                            # Last, try to directly access the function's doc-string
-                            doc = func.__doc__
-                    else:
-                        # we found the help function
+                    cmd_func = self.cmd_func(command)
+
+                    # Non-argparse commands can have help_functions for their documentation
+                    if not hasattr(cmd_func, 'argparser') and command in topics:
+                        help_func = getattr(self, HELP_FUNC_PREFIX + command)
                         result = io.StringIO()
+
                         # try to redirect system stdout
                         with redirect_stdout(result):
                             # save our internal stdout
@@ -2709,27 +2708,33 @@ class Cmd(cmd.Cmd):
                             try:
                                 # redirect our internal stdout
                                 self.stdout = result
-                                func()
+                                help_func()
                             finally:
                                 # restore internal stdout
                                 self.stdout = stdout_orig
                         doc = result.getvalue()
 
+                    else:
+                        doc = cmd_func.__doc__
+
                     # Attempt to locate the first documentation block
-                    doc_block = []
-                    found_first = False
-                    for doc_line in doc.splitlines():
-                        stripped_line = doc_line.strip()
-
-                        # Don't include :param type lines
-                        if stripped_line.startswith(':'):
-                            if found_first:
+                    if not doc:
+                        doc_block = ['']
+                    else:
+                        doc_block = []
+                        found_first = False
+                        for doc_line in doc.splitlines():
+                            stripped_line = doc_line.strip()
+
+                            # Don't include :param type lines
+                            if stripped_line.startswith(':'):
+                                if found_first:
+                                    break
+                            elif stripped_line:
+                                doc_block.append(stripped_line)
+                                found_first = True
+                            elif found_first:
                                 break
-                        elif stripped_line:
-                            doc_block.append(stripped_line)
-                            found_first = True
-                        elif found_first:
-                            break
 
                     for doc_line in doc_block:
                         self.stdout.write('{: <{col_width}}{doc}\n'.format(command,
@@ -3408,7 +3413,7 @@ a..b, a:b, a:, ..b  items by indices (inclusive)
 
     @with_argparser(relative_load_parser)
     def do__relative_load(self, args: argparse.Namespace) -> None:
-        """"""
+        """Run commands in script file that is encoded as either ASCII or UTF-8 text"""
         file_path = args.file_path
         # NOTE: Relative path is an absolute path, it is just relative to the current script directory
         relative_path = os.path.join(self._current_script_dir or '', file_path)


Test patch:
diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py
index 3d57a105..ed281024 100644
--- a/tests/test_cmd2.py
+++ b/tests/test_cmd2.py
@@ -1236,6 +1236,11 @@ class HelpCategoriesApp(cmd2.Cmd):
         """This command does diddly"""
         pass
 
+    # This command will be in the "Some Category" section of the help menu even though it has no docstring
+    @cmd2.with_category("Some Category")
+    def do_cat_nodoc(self, arg):
+        pass
+
     def do_squat(self, arg):
         """This docstring help will never be shown because the help_squat method overrides it."""
         pass
@@ -1269,7 +1274,7 @@ edit  squat
 
 Some Category
 =============
-diddly
+cat_nodoc  diddly
 
 Other
 =====
@@ -1292,6 +1297,7 @@ squat               This command does diddly squat...
 
 Some Category
 ================================================================================
+cat_nodoc
 diddly              This command does diddly
 
 Other