From 5d1e981ee18136d41574c20e10a77f1b0e8b30a1 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Mon, 9 Apr 2018 17:47:16 -0400 Subject: [PATCH 1/9] Added ability to group commands by category when printing the help menu. Added example of multiple commands grouped by categories --- cmd2.py | 23 ++++++- examples/help_categories.py | 131 ++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 3 deletions(-) create mode 100755 examples/help_categories.py diff --git a/cmd2.py b/cmd2.py index 2c23c57a8..c2cea6597 100755 --- a/cmd2.py +++ b/cmd2.py @@ -209,6 +209,7 @@ class RlType(Enum): # Used for tab completion and word breaks. Do not change. QUOTES = ['"', "'"] REDIRECTION_CHARS = ['|', '<', '>'] +HELP_CATEGORY = 'help_category' def set_posix_shlex(val): @@ -2913,18 +2914,34 @@ def _help_menu(self): cmds_doc = [] cmds_undoc = [] + cmds_cats = {} for command in visible_commands: if command in help_topics: cmds_doc.append(command) help_topics.remove(command) elif getattr(self, self._func_named(command)).__doc__: - cmds_doc.append(command) + if hasattr(getattr(self, self._func_named(command)), HELP_CATEGORY): + category = getattr(getattr(self, self._func_named(command)), HELP_CATEGORY) + cmds_cats.setdefault(category, []) + cmds_cats[category].append(command) + else: + cmds_doc.append(command) else: cmds_undoc.append(command) - self.poutput("%s\n" % str(self.doc_leader)) - self.print_topics(self.doc_header, cmds_doc, 15, 80) + if len(cmds_cats) == 0: + # No categories found, fall back to standard behavior + self.poutput("%s\n" % str(self.doc_leader)) + self.print_topics(self.doc_header, cmds_doc, 15, 80) + else: + # Categories found, Organize all commands by category + self.poutput("%s\n" % str(self.doc_leader)) + self.poutput("%s\n\n" % str(self.doc_header)) + for category in cmds_cats: + self.print_topics(category, cmds_cats[category], 15, 80) + self.print_topics('Other', cmds_doc, 15, 80) + self.print_topics(self.misc_header, help_topics, 15, 80) self.print_topics(self.undoc_header, cmds_undoc, 15, 80) diff --git a/examples/help_categories.py b/examples/help_categories.py new file mode 100755 index 000000000..272344f73 --- /dev/null +++ b/examples/help_categories.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python +# coding=utf-8 +""" +A sample application for tagging categories on commands. +""" + +from cmd2 import Cmd, HELP_CATEGORY, __version__ + + +class HelpCategories(Cmd): + """ Example cmd2 application. """ + + # Command categories + CMD_CAT_CONNECTING = 'Connecting' + CMD_CAT_APP_MGMT = 'Application Management' + CMD_CAT_SERVER_INFO = 'Server Information' + + def __init__(self): + # Set use_ipython to True to enable the "ipy" command which embeds and interactive IPython shell + Cmd.__init__(self, use_ipython=False) + + def do_connect(self, _): + """Connect command""" + self.poutput('Connect') + + def do_which(self, _): + """Which command""" + self.poutput('Which') + + # Tag the above command functions under the category Connecting + setattr(do_connect, HELP_CATEGORY, CMD_CAT_CONNECTING) + setattr(do_which, HELP_CATEGORY, CMD_CAT_CONNECTING) + + def do_list(self, _): + """List command""" + self.poutput('List') + + def do_deploy(self, _): + """Deploy command""" + self.poutput('Which') + + def do_start(self, _): + """Start command""" + self.poutput('Start') + + def do_sessions(self, _): + """Sessions command""" + self.poutput('Sessions') + + def do_redeploy(self, _): + """Redeploy command""" + self.poutput('Redeploy') + + def do_restart(self, _): + """Restart command""" + self.poutput('Restart') + + def do_expire(self, _): + """Expire command""" + self.poutput('Expire') + + def do_undeploy(self, _): + """Undeploy command""" + self.poutput('Undeploy') + + def do_stop(self, _): + """Stop command""" + self.poutput('Stop') + + def do_findleakers(self, _): + """Find Leakers command""" + self.poutput('Find Leakers') + + # Tag the above command functions under the category Application Management + setattr(do_list, HELP_CATEGORY, CMD_CAT_APP_MGMT) + setattr(do_deploy, HELP_CATEGORY, CMD_CAT_APP_MGMT) + setattr(do_start, HELP_CATEGORY, CMD_CAT_APP_MGMT) + setattr(do_sessions, HELP_CATEGORY, CMD_CAT_APP_MGMT) + setattr(do_redeploy, HELP_CATEGORY, CMD_CAT_APP_MGMT) + setattr(do_restart, HELP_CATEGORY, CMD_CAT_APP_MGMT) + setattr(do_expire, HELP_CATEGORY, CMD_CAT_APP_MGMT) + setattr(do_undeploy, HELP_CATEGORY, CMD_CAT_APP_MGMT) + setattr(do_stop, HELP_CATEGORY, CMD_CAT_APP_MGMT) + setattr(do_findleakers, HELP_CATEGORY, CMD_CAT_APP_MGMT) + + def do_resources(self, _): + """Resources command""" + self.poutput('Resources') + + def do_status(self, _): + """Status command""" + self.poutput('Status') + + def do_serverinfo(self, _): + """Server Info command""" + self.poutput('Server Info') + + def do_thread_dump(self, _): + """Thread Dump command""" + self.poutput('Thread Dump') + + def do_sslconnectorciphers(self, _): + """SSL Connector Ciphers command""" + self.poutput('SSL Connector Ciphers') + + def do_vminfo(self, _): + """VM Info command""" + self.poutput('VM Info') + + # Tag the above command functions under the category Server Information + setattr(do_resources, HELP_CATEGORY, CMD_CAT_SERVER_INFO) + setattr(do_status, HELP_CATEGORY, CMD_CAT_SERVER_INFO) + setattr(do_serverinfo, HELP_CATEGORY, CMD_CAT_SERVER_INFO) + setattr(do_thread_dump, HELP_CATEGORY, CMD_CAT_SERVER_INFO) + setattr(do_sslconnectorciphers, HELP_CATEGORY, CMD_CAT_SERVER_INFO) + setattr(do_vminfo, HELP_CATEGORY, CMD_CAT_SERVER_INFO) + + # The following command functions don't have the HELP_CATEGORY attribute set + # and show up in the 'Other' group + def do_config(self, _): + """Config command""" + self.poutput('Config') + + def do_version(self, _): + """Version command""" + self.poutput(__version__) + + +if __name__ == '__main__': + c = HelpCategories() + c.cmdloop() From ef23633daba9538360af61d7547e8d0d0f6c1139 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Mon, 9 Apr 2018 18:14:31 -0400 Subject: [PATCH 2/9] Added a convenience function for tagging command categories. --- cmd2.py | 13 +++++++++++++ examples/help_categories.py | 38 ++++++++++++++++++------------------- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/cmd2.py b/cmd2.py index c2cea6597..1d2751c4e 100755 --- a/cmd2.py +++ b/cmd2.py @@ -42,6 +42,7 @@ import sys import tempfile import traceback +from typing import Union, Callable import unittest from code import InteractiveConsole @@ -212,6 +213,18 @@ class RlType(Enum): HELP_CATEGORY = 'help_category' +def categorize(func: Union[Callable, Iterable], category: str): + """ + Categorize a function + The help command output will group this function under the specified category heading + """ + if isinstance(func, Iterable): + for item in func: + setattr(item, HELP_CATEGORY, category) + else: + setattr(func, HELP_CATEGORY, category) + + def set_posix_shlex(val): """ Allows user of cmd2 to choose between POSIX and non-POSIX splitting of args for decorated commands. diff --git a/examples/help_categories.py b/examples/help_categories.py index 272344f73..6583d7957 100755 --- a/examples/help_categories.py +++ b/examples/help_categories.py @@ -4,7 +4,7 @@ A sample application for tagging categories on commands. """ -from cmd2 import Cmd, HELP_CATEGORY, __version__ +from cmd2 import Cmd, categorize, __version__ class HelpCategories(Cmd): @@ -28,8 +28,8 @@ def do_which(self, _): self.poutput('Which') # Tag the above command functions under the category Connecting - setattr(do_connect, HELP_CATEGORY, CMD_CAT_CONNECTING) - setattr(do_which, HELP_CATEGORY, CMD_CAT_CONNECTING) + categorize(do_connect, CMD_CAT_CONNECTING) + categorize(do_which, CMD_CAT_CONNECTING) def do_list(self, _): """List command""" @@ -72,16 +72,16 @@ def do_findleakers(self, _): self.poutput('Find Leakers') # Tag the above command functions under the category Application Management - setattr(do_list, HELP_CATEGORY, CMD_CAT_APP_MGMT) - setattr(do_deploy, HELP_CATEGORY, CMD_CAT_APP_MGMT) - setattr(do_start, HELP_CATEGORY, CMD_CAT_APP_MGMT) - setattr(do_sessions, HELP_CATEGORY, CMD_CAT_APP_MGMT) - setattr(do_redeploy, HELP_CATEGORY, CMD_CAT_APP_MGMT) - setattr(do_restart, HELP_CATEGORY, CMD_CAT_APP_MGMT) - setattr(do_expire, HELP_CATEGORY, CMD_CAT_APP_MGMT) - setattr(do_undeploy, HELP_CATEGORY, CMD_CAT_APP_MGMT) - setattr(do_stop, HELP_CATEGORY, CMD_CAT_APP_MGMT) - setattr(do_findleakers, HELP_CATEGORY, CMD_CAT_APP_MGMT) + categorize((do_list, + do_deploy, + do_start, + do_sessions, + do_redeploy, + do_restart, + do_expire, + do_undeploy, + do_stop, + do_findleakers), CMD_CAT_APP_MGMT) def do_resources(self, _): """Resources command""" @@ -108,12 +108,12 @@ def do_vminfo(self, _): self.poutput('VM Info') # Tag the above command functions under the category Server Information - setattr(do_resources, HELP_CATEGORY, CMD_CAT_SERVER_INFO) - setattr(do_status, HELP_CATEGORY, CMD_CAT_SERVER_INFO) - setattr(do_serverinfo, HELP_CATEGORY, CMD_CAT_SERVER_INFO) - setattr(do_thread_dump, HELP_CATEGORY, CMD_CAT_SERVER_INFO) - setattr(do_sslconnectorciphers, HELP_CATEGORY, CMD_CAT_SERVER_INFO) - setattr(do_vminfo, HELP_CATEGORY, CMD_CAT_SERVER_INFO) + categorize(do_resources, CMD_CAT_SERVER_INFO) + categorize(do_status, CMD_CAT_SERVER_INFO) + categorize(do_serverinfo, CMD_CAT_SERVER_INFO) + categorize(do_thread_dump, CMD_CAT_SERVER_INFO) + categorize(do_sslconnectorciphers, CMD_CAT_SERVER_INFO) + categorize(do_vminfo, CMD_CAT_SERVER_INFO) # The following command functions don't have the HELP_CATEGORY attribute set # and show up in the 'Other' group From 5e349dc4ffcad2a5d30219b56b3ac33dcd1aadc7 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Mon, 9 Apr 2018 22:34:24 -0700 Subject: [PATCH 3/9] Remove usage of optional type hinting which is not allowed in Python 2.7 --- cmd2.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cmd2.py b/cmd2.py index 5e9266f7c..b461868ac 100755 --- a/cmd2.py +++ b/cmd2.py @@ -42,7 +42,6 @@ import sys import tempfile import traceback -from typing import Union, Callable import unittest from code import InteractiveConsole @@ -213,10 +212,13 @@ class RlType(Enum): HELP_CATEGORY = 'help_category' -def categorize(func: Union[Callable, Iterable], category: str): - """ - Categorize a function +def categorize(func, category): + """Categorize a function. + The help command output will group this function under the specified category heading + + :param func: Union[Callable, Iterable] - function to categorize + :param category: str - category to put it in """ if isinstance(func, Iterable): for item in func: From 4f68eb2a9f037d2369558cb7de232b4ce3898cf0 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Mon, 9 Apr 2018 22:46:48 -0700 Subject: [PATCH 4/9] Fixed imports since new categorization feature requires Iterable --- cmd2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd2.py b/cmd2.py index b461868ac..6179df699 100755 --- a/cmd2.py +++ b/cmd2.py @@ -56,7 +56,7 @@ # Collection is a container that is sizable and iterable # It was introduced in Python 3.6. We will try to import it, otherwise use our implementation try: - from collections.abc import Collection + from collections.abc import Collection, Iterable except ImportError: if six.PY3: From f4f2b9ede5f34459d84dee21400cc23bc6008776 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 10 Apr 2018 14:53:53 -0400 Subject: [PATCH 5/9] Added verbose help output with help -v or help --verbose Reads the __doc__ for a command function and provides the first block of text for each command. Updated help_categories.py to demonstrate a multi-line comment block for a command. --- cmd2.py | 71 ++++++++++++++++++++++++++++++------- examples/help_categories.py | 9 ++++- 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/cmd2.py b/cmd2.py index 230de1a67..d6c7109de 100755 --- a/cmd2.py +++ b/cmd2.py @@ -209,6 +209,8 @@ class RlType(Enum): # Used for tab completion and word breaks. Do not change. QUOTES = ['"', "'"] REDIRECTION_CHARS = ['|', '<', '>'] + +# optional attribute, when tagged on a function, allows cmd2 to categorize commands HELP_CATEGORY = 'help_category' @@ -2891,7 +2893,10 @@ def complete_unalias(self, text, line, begidx, endidx): @with_argument_list def do_help(self, arglist): """List available commands with "help" or detailed help with "help cmd".""" - if arglist: + if not arglist or (len(arglist) == 1 and arglist[0] in ('--verbose', '-v')): + verbose = len(arglist) == 1 and arglist[0] in ('--verbose', '-v') + self._help_menu(verbose) + else: # Getting help for a specific command funcname = self._func_named(arglist[0]) if funcname: @@ -2912,11 +2917,8 @@ def do_help(self, arglist): else: # This could be a help topic cmd.Cmd.do_help(self, arglist[0]) - else: - # Show a menu of what commands help can be gotten for - self._help_menu() - def _help_menu(self): + def _help_menu(self, verbose=False): """Show a list of commands which help can be displayed for. """ # Get a sorted list of help topics @@ -2947,19 +2949,62 @@ def _help_menu(self): if len(cmds_cats) == 0: # No categories found, fall back to standard behavior - self.poutput("%s\n" % str(self.doc_leader)) - self.print_topics(self.doc_header, cmds_doc, 15, 80) + self.poutput("{}\n".format(str(self.doc_leader))) + self._print_topics(self.doc_header, cmds_doc, verbose) else: # Categories found, Organize all commands by category - self.poutput("%s\n" % str(self.doc_leader)) - self.poutput("%s\n\n" % str(self.doc_header)) - for category in cmds_cats: - self.print_topics(category, cmds_cats[category], 15, 80) - self.print_topics('Other', cmds_doc, 15, 80) + self.poutput('{}\n'.format(str(self.doc_leader))) + self.poutput('{}\n\n'.format(str(self.doc_header))) + for category in sorted(cmds_cats.keys()): + self._print_topics(category, cmds_cats[category], verbose) + self._print_topics('Other', cmds_doc, verbose) + self.print_topics(self.misc_header, help_topics, 15, 80) self.print_topics(self.undoc_header, cmds_undoc, 15, 80) + def _print_topics(self, header, cmds, verbose): + """Customized version of print_topics that can switch between verbose or traditional output""" + if cmds: + if not verbose: + self.print_topics(header, cmds, 15, 80) + else: + self.stdout.write('{}\n'.format(str(header))) + widest = 0 + # measure the commands + for command in cmds: + width = wcswidth(command) + if width > widest: + widest = width + # add a 4-space pad + widest += 4 + if widest < 20: + widest = 20 + + if self.ruler: + self.stdout.write('{:{ruler}<{width}}\n'.format('', ruler=self.ruler, width=80)) + + for command in cmds: + # Attempt to locate the first documentation block + doc = getattr(self, self._func_named(command)).__doc__ + doc_block = [] + found_first = False + for doc_line in doc.splitlines(): + str(doc_line).strip() + if len(doc_line.strip()) > 0: + doc_block.append(doc_line.strip()) + found_first = True + else: + if found_first: + break + + for doc_line in doc_block: + self.stdout.write('{: <{col_width}}{doc}\n'.format(command, + col_width=widest, + doc=doc_line)) + command = '' + self.stdout.write("\n") + def do_shortcuts(self, _): """Lists shortcuts (aliases) available.""" result = "\n".join('%s: %s' % (sc[0], sc[1]) for sc in sorted(self.shortcuts)) @@ -3226,6 +3271,8 @@ def cmd_with_subs_completer(self, text, line, begidx, endidx): # noinspection PyBroadException def do_py(self, arg): """ + Invoke python command, shell, or script + py : Executes a Python command. py: Enters interactive Python mode. End with ``Ctrl-D`` (Unix) / ``Ctrl-Z`` (Windows), ``quit()``, '`exit()``. diff --git a/examples/help_categories.py b/examples/help_categories.py index 6583d7957..2a88edba7 100755 --- a/examples/help_categories.py +++ b/examples/help_categories.py @@ -100,7 +100,14 @@ def do_thread_dump(self, _): self.poutput('Thread Dump') def do_sslconnectorciphers(self, _): - """SSL Connector Ciphers command""" + """ + SSL Connector Ciphers command is an example of a command that contains + multiple lines of help information for the user. Each line of help in a + contiguous set of lines will be printed and aligned in the verbose output + provided with 'help --verbose' + + This is after a blank line and won't de displayed in the verbose help + """ self.poutput('SSL Connector Ciphers') def do_vminfo(self, _): From 2ffd342e7523e36f2e0536beca6cf36db5070362 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 11 Apr 2018 01:10:04 -0400 Subject: [PATCH 6/9] Removed extra new line --- cmd2.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd2.py b/cmd2.py index d6c7109de..7084dc5e4 100755 --- a/cmd2.py +++ b/cmd2.py @@ -2959,7 +2959,6 @@ def _help_menu(self, verbose=False): self._print_topics(category, cmds_cats[category], verbose) self._print_topics('Other', cmds_doc, verbose) - self.print_topics(self.misc_header, help_topics, 15, 80) self.print_topics(self.undoc_header, cmds_undoc, 15, 80) From 52bf16c412eb7933eac159ed0fc6363ccc37a82c Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Wed, 11 Apr 2018 11:44:18 -0400 Subject: [PATCH 7/9] Fixed issue where categorization is skipped when there's a help_ function provided. In verbose help, added check for argparse usage block (starting with 'usage: '), to skip that block and move to the next comment block Added unit tests for new categorization code Updated example to demonstrate skipping of argparse usage statement --- cmd2.py | 17 +++--- examples/help_categories.py | 9 +++- tests/conftest.py | 18 +++++++ tests/test_cmd2.py | 103 +++++++++++++++++++++++++++++++++++- 4 files changed, 138 insertions(+), 9 deletions(-) diff --git a/cmd2.py b/cmd2.py index 7084dc5e4..201d608bb 100755 --- a/cmd2.py +++ b/cmd2.py @@ -2934,10 +2934,9 @@ def _help_menu(self, verbose=False): cmds_cats = {} for command in visible_commands: - if command in help_topics: - cmds_doc.append(command) - help_topics.remove(command) - elif getattr(self, self._func_named(command)).__doc__: + if command in help_topics or getattr(self, self._func_named(command)).__doc__: + if command in help_topics: + help_topics.remove(command) if hasattr(getattr(self, self._func_named(command)), HELP_CATEGORY): category = getattr(getattr(self, self._func_named(command)), HELP_CATEGORY) cmds_cats.setdefault(category, []) @@ -2972,7 +2971,7 @@ def _print_topics(self, header, cmds, verbose): widest = 0 # measure the commands for command in cmds: - width = wcswidth(command) + width = len(command) if width > widest: widest = width # add a 4-space pad @@ -2983,17 +2982,23 @@ def _print_topics(self, header, cmds, verbose): if self.ruler: self.stdout.write('{:{ruler}<{width}}\n'.format('', ruler=self.ruler, width=80)) + help_topics = self.get_help_topics() for command in cmds: # Attempt to locate the first documentation block doc = getattr(self, self._func_named(command)).__doc__ doc_block = [] found_first = False + in_usage = False for doc_line in doc.splitlines(): str(doc_line).strip() if len(doc_line.strip()) > 0: + if in_usage or doc_line.startswith('usage: '): + in_usage = True + continue doc_block.append(doc_line.strip()) found_first = True else: + in_usage = False if found_first: break @@ -3101,7 +3106,7 @@ def show(self, args, parameter): else: raise LookupError("Parameter '%s' not supported (type 'show' for list of parameters)." % param) - set_parser = argparse.ArgumentParser() + set_parser = argparse.ArgumentParser(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]') diff --git a/examples/help_categories.py b/examples/help_categories.py index 2a88edba7..8a33e62ca 100755 --- a/examples/help_categories.py +++ b/examples/help_categories.py @@ -4,7 +4,8 @@ A sample application for tagging categories on commands. """ -from cmd2 import Cmd, categorize, __version__ +from cmd2 import Cmd, categorize, __version__, with_argparser +import argparse class HelpCategories(Cmd): @@ -51,6 +52,12 @@ def do_redeploy(self, _): """Redeploy command""" self.poutput('Redeploy') + restart_parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter) + restart_parser.add_argument('when', default='now', + choices=['now', 'later', 'sometime', 'whenever'], + help='Specify when to restart') + + @with_argparser(restart_parser) def do_restart(self, _): """Restart command""" self.poutput('Restart') diff --git a/tests/conftest.py b/tests/conftest.py index 58ec8ee01..4170a5e12 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,24 @@ edit history py quit shell unalias """ +BASE_HELP_VERBOSE = """ +Documented commands (type help ): +================================================================================ +alias Define or display aliases +edit Edit a file in a text editor. +help List available commands with "help" or detailed help with "help cmd". +history View, run, edit, and save previously entered commands. +load Runs commands in script file that is encoded as either ASCII or UTF-8 text. +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 + +""" + # Help text for the history command HELP_HISTORY = """usage: history [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT] [arg] diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 545cf1aaf..0861c073f 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -21,7 +21,8 @@ import six.moves as sm import cmd2 -from conftest import run_cmd, normalize, BASE_HELP, HELP_HISTORY, SHORTCUTS_TXT, SHOW_TXT, SHOW_LONG, StdOut +from conftest import run_cmd, normalize, BASE_HELP, BASE_HELP_VERBOSE, \ + HELP_HISTORY, SHORTCUTS_TXT, SHOW_TXT, SHOW_LONG, StdOut def test_ver(): @@ -38,6 +39,13 @@ def test_base_help(base_app): expected = normalize(BASE_HELP) assert out == expected +def test_base_help_verbose(base_app): + out = run_cmd(base_app, 'help -v') + expected = normalize(BASE_HELP_VERBOSE) + assert out == expected + + out = run_cmd(base_app, 'help --verbose') + assert out == expected def test_base_help_history(base_app): out = run_cmd(base_app, 'help history') @@ -47,7 +55,7 @@ def test_base_argparse_help(base_app, capsys): # Verify that "set -h" gives the same output as "help set" and that it starts in a way that makes sense run_cmd(base_app, 'set -h') out, err = capsys.readouterr() - out1 = out.splitlines() + out1 = normalize(out) out2 = run_cmd(base_app, 'help set') @@ -1066,6 +1074,97 @@ def test_help_overridden_method(help_app): assert out == expected +class HelpCategoriesApp(cmd2.Cmd): + """Class for testing custom help_* methods which override docstring help.""" + def __init__(self, *args, **kwargs): + # Need to use this older form of invoking super class constructor to support Python 2.x and Python 3.x + cmd2.Cmd.__init__(self, *args, **kwargs) + + def do_diddly(self, arg): + """This command does diddly""" + pass + + cmd2.categorize(do_diddly, "Some Category") + + def do_squat(self, arg): + """This docstring help will never be shown because the help_squat method overrides it.""" + pass + + def help_squat(self): + self.stdout.write('This command does diddly squat...\n') + + def do_edit(self, arg): + """This overrides the edit command and does nothing.""" + pass + + cmd2.categorize((do_squat, do_edit), 'Custom Category') + + # This command will be in the "undocumented" section of the help menu + def do_undoc(self, arg): + pass + +@pytest.fixture +def helpcat_app(): + app = HelpCategoriesApp() + app.stdout = StdOut() + return app + +def test_help_cat_base(helpcat_app): + out = run_cmd(helpcat_app, 'help') + expected = normalize("""Documented commands (type help ): + +Custom Category +=============== +edit squat + +Some Category +============= +diddly + +Other +===== +alias help history load py pyscript quit set shell shortcuts unalias + +Undocumented commands: +====================== +undoc +""") + assert out == expected + +def test_help_cat_verbose(helpcat_app): + out = run_cmd(helpcat_app, 'help --verbose') + expected = normalize("""Documented commands (type help ): + +Custom Category +================================================================================ +edit This overrides the edit command and does nothing. +squat This docstring help will never be shown because the help_squat method overrides it. + +Some Category +================================================================================ +diddly This command does diddly + +Other +================================================================================ +alias Define or display aliases +help List available commands with "help" or detailed help with "help cmd". +history View, run, edit, and save previously entered commands. +load Runs commands in script file that is encoded as either ASCII or UTF-8 text. +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 + +Undocumented commands: +====================== +undoc +""") + assert out == expected + + class SelectApp(cmd2.Cmd): def do_eat(self, arg): """Eat something, with a selection of sauces to choose from.""" From 33decb449c61e2a78877563309929c4686ea081e Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Wed, 11 Apr 2018 15:11:52 -0400 Subject: [PATCH 8/9] Added a with_category decorator that can be used to tag a command category. Changed the detection of with_argparse decorated commands to be less hacky/brittle. Now it tags the function with help_summary. Fixed issue with handling commands that provide a custom help_ function. We can now redirect the output to a string to be formatted with the other commands. Added some documentation explaining the new help categories. Updated unit tests. --- cmd2.py | 58 +++++++++++++-- docs/argument_processing.rst | 132 +++++++++++++++++++++++++++++++++++ examples/help_categories.py | 12 ++-- tests/conftest.py | 1 - tests/test_cmd2.py | 7 +- 5 files changed, 192 insertions(+), 18 deletions(-) diff --git a/cmd2.py b/cmd2.py index 201d608bb..629eee822 100755 --- a/cmd2.py +++ b/cmd2.py @@ -79,7 +79,6 @@ def __subclasshook__(cls, C): return True return NotImplemented - # Newer versions of pyperclip are released as a single file, but older versions had a more complicated structure try: from pyperclip.exceptions import PyperclipException @@ -113,6 +112,12 @@ def __subclasshook__(cls, C): else: from contextlib import redirect_stdout, redirect_stderr +if sys.version_info > (3, 0): + from io import StringIO # Python3 +else: + from io import BytesIO as StringIO # Python2 + + # Detect whether IPython is installed to determine if the built-in "ipy" command should be included ipython_available = True try: @@ -183,6 +188,7 @@ class RlType(Enum): except ImportError: pass + __version__ = '0.8.4' # Pyparsing enablePackrat() can greatly speed up parsing, but problems have been seen in Python 3 in the past @@ -212,6 +218,7 @@ class RlType(Enum): # optional attribute, when tagged on a function, allows cmd2 to categorize commands HELP_CATEGORY = 'help_category' +HELP_SUMMARY = 'help_summary' def categorize(func, category): @@ -358,6 +365,14 @@ def parse_quoted_string(cmdline): return lexed_arglist +def with_category(category): + """A decorator to apply a category to a command function""" + def cat_decorator(func): + categorize(func, category) + return func + return cat_decorator + + def with_argument_list(func): """A decorator to alter the arguments passed to a do_* cmd2 method. Default passes a string of whatever the user typed. @@ -396,6 +411,9 @@ def cmd_wrapper(instance, cmdline): 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() # Mark this function as having an argparse ArgumentParser (used by do_help) @@ -435,6 +453,9 @@ def cmd_wrapper(instance, cmdline): 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() # Mark this function as having an argparse ArgumentParser (used by do_help) @@ -2984,21 +3005,44 @@ def _print_topics(self, header, cmds, verbose): help_topics = self.get_help_topics() for command in cmds: + doc = '' + # 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 + try: + # Now see if help_summary has been set + doc = getattr(self, self._func_named(command)).help_summary + except AttributeError: + # Last, try to directly ac cess the function's doc-string + doc = getattr(self, self._func_named(command)).__doc__ + else: + # we found the help function + result = StringIO() + # try to redirect system stdout + with redirect_stdout(result): + # save our internal stdout + stdout_orig = self.stdout + try: + # redirect our internal stdout + self.stdout = result + func() + finally: + # restore internal stdout + self.stdout = stdout_orig + doc = result.getvalue() + # Attempt to locate the first documentation block - doc = getattr(self, self._func_named(command)).__doc__ doc_block = [] found_first = False - in_usage = False for doc_line in doc.splitlines(): str(doc_line).strip() if len(doc_line.strip()) > 0: - if in_usage or doc_line.startswith('usage: '): - in_usage = True - continue doc_block.append(doc_line.strip()) found_first = True else: - in_usage = False if found_first: break diff --git a/docs/argument_processing.rst b/docs/argument_processing.rst index 15c947fbe..08f866b21 100644 --- a/docs/argument_processing.rst +++ b/docs/argument_processing.rst @@ -160,6 +160,138 @@ Which yields: This command can not generate tags with no content, like
+Grouping Commands +================= + +By default, the ``help`` command displays:: + + Documented commands (type help ): + ======================================== + alias findleakers pyscript sessions status vminfo + config help quit set stop which + connect history redeploy shell thread_dump + deploy list resources shortcuts unalias + edit load restart sslconnectorciphers undeploy + expire py serverinfo start version + +If you have a large number of commands, you can optionally group your commands into categories. +Here's the output from the example ``help_categories.py``:: + + Documented commands (type help ): + + Application Management + ====================== + deploy findleakers redeploy sessions stop + expire list restart start undeploy + + Connecting + ========== + connect which + + Server Information + ================== + resources serverinfo sslconnectorciphers status thread_dump vminfo + + Other + ===== + alias edit history py quit shell unalias + config help load pyscript set shortcuts version + + +There are 2 methods of specifying command categories, using the ``@with_category`` decorator or with the +``categorize()`` function. Once a single command category is detected, the help output switches to a categorized +mode of display. All commands with an explicit category defined default to the category `Other`. + +Using the ``@with_category`` decorator:: + + @with_category(CMD_CAT_CONNECTING) + def do_which(self, _): + """Which command""" + self.poutput('Which') + +Using the ``categorize()`` function: + + You can call with a single function:: + + def do_connect(self, _): + """Connect command""" + self.poutput('Connect') + + # Tag the above command functions under the category Connecting + categorize(do_connect, CMD_CAT_CONNECTING) + + Or with an Iterable container of functions:: + + def do_undeploy(self, _): + """Undeploy command""" + self.poutput('Undeploy') + + def do_stop(self, _): + """Stop command""" + self.poutput('Stop') + + def do_findleakers(self, _): + """Find Leakers command""" + self.poutput('Find Leakers') + + # Tag the above command functions under the category Application Management + categorize((do_undeploy, + do_stop, + do_findleakers), CMD_CAT_APP_MGMT) + +The ``help`` command also has a verbose option (``help -v`` or ``help --verbose``) that combines +the help categories with per-command Help Messages:: + + Documented commands (type help ): + + Application Management + ================================================================================ + deploy Deploy command + expire Expire command + findleakers Find Leakers command + list List command + redeploy Redeploy command + restart usage: restart [-h] {now,later,sometime,whenever} + sessions Sessions command + start Start command + stop Stop command + undeploy Undeploy command + + Connecting + ================================================================================ + connect Connect command + which Which command + + Server Information + ================================================================================ + resources Resources command + serverinfo Server Info command + sslconnectorciphers SSL Connector Ciphers command is an example of a command that contains + multiple lines of help information for the user. Each line of help in a + contiguous set of lines will be printed and aligned in the verbose output + provided with 'help --verbose' + status Status command + thread_dump Thread Dump command + vminfo VM Info command + + Other + ================================================================================ + alias Define or display aliases + config Config command + edit Edit a file in a text editor. + help List available commands with "help" or detailed help with "help cmd". + history usage: history [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT] [arg] + load Runs commands in script file that is encoded as either ASCII or UTF-8 text. + py Invoke python command, shell, or script + pyscript Runs a python script file inside the console + quit Exits this application. + set usage: set [-h] [-a] [-l] [settable [settable ...]] + shell Execute a command as if at the OS prompt. + shortcuts Lists shortcuts (aliases) available. + unalias Unsets aliases + version Version command + + Receiving an argument list ========================== diff --git a/examples/help_categories.py b/examples/help_categories.py index 8a33e62ca..e7e3373d9 100755 --- a/examples/help_categories.py +++ b/examples/help_categories.py @@ -4,7 +4,7 @@ A sample application for tagging categories on commands. """ -from cmd2 import Cmd, categorize, __version__, with_argparser +from cmd2 import Cmd, categorize, __version__, with_argparser, with_category import argparse @@ -24,14 +24,14 @@ def do_connect(self, _): """Connect command""" self.poutput('Connect') + # Tag the above command functions under the category Connecting + categorize(do_connect, CMD_CAT_CONNECTING) + + @with_category(CMD_CAT_CONNECTING) def do_which(self, _): """Which command""" self.poutput('Which') - # Tag the above command functions under the category Connecting - categorize(do_connect, CMD_CAT_CONNECTING) - categorize(do_which, CMD_CAT_CONNECTING) - def do_list(self, _): """List command""" self.poutput('List') @@ -58,6 +58,7 @@ def do_redeploy(self, _): help='Specify when to restart') @with_argparser(restart_parser) + @with_category(CMD_CAT_APP_MGMT) def do_restart(self, _): """Restart command""" self.poutput('Restart') @@ -84,7 +85,6 @@ def do_findleakers(self, _): do_start, do_sessions, do_redeploy, - do_restart, do_expire, do_undeploy, do_stop, diff --git a/tests/conftest.py b/tests/conftest.py index 4170a5e12..837e75047 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,7 +34,6 @@ shell Execute a command as if at the OS prompt. shortcuts Lists shortcuts (aliases) available. unalias Unsets aliases - """ # Help text for the history command diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 0861c073f..75d27869c 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -55,7 +55,7 @@ def test_base_argparse_help(base_app, capsys): # Verify that "set -h" gives the same output as "help set" and that it starts in a way that makes sense run_cmd(base_app, 'set -h') out, err = capsys.readouterr() - out1 = normalize(out) + out1 = normalize(str(out)) out2 = run_cmd(base_app, 'help set') @@ -1080,12 +1080,11 @@ def __init__(self, *args, **kwargs): # Need to use this older form of invoking super class constructor to support Python 2.x and Python 3.x cmd2.Cmd.__init__(self, *args, **kwargs) + @cmd2.with_category('Some Category') def do_diddly(self, arg): """This command does diddly""" pass - cmd2.categorize(do_diddly, "Some Category") - def do_squat(self, arg): """This docstring help will never be shown because the help_squat method overrides it.""" pass @@ -1138,7 +1137,7 @@ def test_help_cat_verbose(helpcat_app): Custom Category ================================================================================ edit This overrides the edit command and does nothing. -squat This docstring help will never be shown because the help_squat method overrides it. +squat This command does diddly squat... Some Category ================================================================================ From e4ca6c807ce8bd8e4272b6d724c71952845b2adf Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Wed, 11 Apr 2018 18:40:44 -0400 Subject: [PATCH 9/9] Removed an emty line. --- cmd2.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd2.py b/cmd2.py index 629eee822..1cf137478 100755 --- a/cmd2.py +++ b/cmd2.py @@ -117,7 +117,6 @@ def __subclasshook__(cls, C): else: from io import BytesIO as StringIO # Python2 - # Detect whether IPython is installed to determine if the built-in "ipy" command should be included ipython_available = True try: