diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 979f562ea..7aa08dc25 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -475,8 +475,11 @@ def __init__( # The multiline command currently being typed which is used to tab complete multiline commands. self._multiline_in_progress = '' - # Set the header used for the help function's listing of documented functions - self.doc_header = "Documented commands (use 'help -v' for verbose/'help ' for details)" + # Set text which prints right before all of the help topics are listed. + self.doc_leader = "" + + # Set header for table listing documented commands. + self.doc_header = "Documented Commands" # Set header for table listing help topics not related to a command. self.misc_header = "Miscellaneous Help Topics" @@ -484,6 +487,10 @@ def __init__( # Set header for table listing commands that have no help info. self.undoc_header = "Undocumented Commands" + # If any command has been categorized, then all other commands that haven't been categorized + # will display under this section in the help output. + self.default_category = "Uncategorized Commands" + # The error that prints when no help information can be found self.help_error = "No help on {}" @@ -551,10 +558,6 @@ def __init__( # values are DisabledCommand objects. self.disabled_commands: dict[str, DisabledCommand] = {} - # If any command has been categorized, then all other commands that haven't been categorized - # will display under this section in the help output. - self.default_category = 'Uncategorized' - # The default key for sorting string results. Its default value performs a case-insensitive alphabetical sort. # If natural sorting is preferred, then set this to NATURAL_SORT_KEY. # cmd2 uses this key for sorting: @@ -4039,6 +4042,45 @@ def complete_help_subcommands( completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self) return completer.complete_subcommand_help(text, line, begidx, endidx, arg_tokens['subcommands']) + def _build_command_info(self) -> tuple[dict[str, list[str]], list[str], list[str], list[str]]: + """Categorizes and sorts visible commands and help topics for display. + + :return: tuple containing: + - dictionary mapping category names to lists of command names + - list of documented command names + - list of undocumented command names + - list of help topic names that are not also commands + """ + # Get a sorted list of help topics + help_topics = sorted(self.get_help_topics(), key=self.default_sort_key) + + # Get a sorted list of visible command names + visible_commands = sorted(self.get_visible_commands(), key=self.default_sort_key) + cmds_doc: list[str] = [] + cmds_undoc: list[str] = [] + cmds_cats: dict[str, list[str]] = {} + for command in visible_commands: + func = cast(CommandFunc, self.cmd_func(command)) + has_help_func = False + has_parser = func in self._command_parsers + + 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 + has_help_func = not has_parser + + if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY): + category: str = getattr(func, constants.CMD_ATTR_HELP_CATEGORY) + cmds_cats.setdefault(category, []) + cmds_cats[category].append(command) + elif func.__doc__ or has_help_func or has_parser: + cmds_doc.append(command) + else: + cmds_undoc.append(command) + return cmds_cats, cmds_doc, cmds_undoc, help_topics + @classmethod def _build_help_parser(cls) -> Cmd2ArgumentParser: help_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( @@ -4074,7 +4116,24 @@ def do_help(self, args: argparse.Namespace) -> None: self.last_result = True if not args.command or args.verbose: - self._help_menu(args.verbose) + cmds_cats, cmds_doc, cmds_undoc, help_topics = self._build_command_info() + + if self.doc_leader: + self.poutput() + self.poutput(self.doc_leader, style=Cmd2Style.HELP_LEADER, soft_wrap=False) + self.poutput() + + if not cmds_cats: + # No categories found, fall back to standard behavior + self._print_documented_command_topics(self.doc_header, cmds_doc, args.verbose) + else: + # Categories found, Organize all commands by category + for category in sorted(cmds_cats.keys(), key=self.default_sort_key): + self._print_documented_command_topics(category, cmds_cats[category], args.verbose) + self._print_documented_command_topics(self.default_category, cmds_doc, args.verbose) + + self.print_topics(self.misc_header, help_topics, 15, 80) + self.print_topics(self.undoc_header, cmds_undoc, 15, 80) else: # Getting help for a specific command @@ -4111,14 +4170,77 @@ def print_topics(self, header: str, cmds: list[str] | None, cmdlen: int, maxcol: :param cmdlen: unused, even by cmd's version :param maxcol: max number of display columns to fit into """ - if cmds: - header_grid = Table.grid() - header_grid.add_row(header, style=Cmd2Style.HELP_TITLE) - if self.ruler: - header_grid.add_row(Rule(characters=self.ruler)) - self.poutput(header_grid) - self.columnize(cmds, maxcol - 1) - self.poutput() + if not cmds: + return + + header_grid = Table.grid() + header_grid.add_row(header, style=Cmd2Style.HELP_HEADER) + if self.ruler: + header_grid.add_row(Rule(characters=self.ruler)) + self.poutput(header_grid) + self.columnize(cmds, maxcol - 1) + self.poutput() + + def _print_documented_command_topics(self, header: str, cmds: list[str], verbose: bool) -> None: + """Print topics which are documented commands, switching between verbose or traditional output.""" + import io + + if not cmds: + return + + if not verbose: + self.print_topics(header, cmds, 15, 80) + return + + category_grid = Table.grid() + category_grid.add_row(header, style=Cmd2Style.HELP_HEADER) + category_grid.add_row(Rule(characters=self.ruler)) + topics_table = Table( + Column("Name", no_wrap=True), + Column("Description", overflow="fold"), + box=SIMPLE_HEAD, + border_style=Cmd2Style.RULE_LINE, + show_edge=False, + ) + + # Try to get the documentation string for each command + topics = self.get_help_topics() + for command in cmds: + if (cmd_func := self.cmd_func(command)) is None: + continue + + doc: str | None + + # Non-argparse commands can have help_functions for their documentation + if command in topics: + help_func = getattr(self, constants.HELP_FUNC_PREFIX + command) + result = io.StringIO() + + # try to redirect system stdout + with contextlib.redirect_stdout(result): + # save our internal stdout + stdout_orig = self.stdout + try: + # redirect our internal stdout + self.stdout = cast(TextIO, result) + help_func() + finally: + with self.sigint_protection: + # restore internal stdout + self.stdout = stdout_orig + doc = result.getvalue() + + else: + doc = cmd_func.__doc__ + + # Attempt to locate the first documentation block + cmd_desc = strip_doc_annotations(doc) if doc else '' + + # Add this command to the table + topics_table.add_row(command, cmd_desc) + + category_grid.add_row(topics_table) + self.poutput(category_grid, "") def columnize(self, str_list: list[str] | None, display_width: int = 80) -> None: """Display a list of single-line strings as a compact set of columns. @@ -4132,9 +4254,6 @@ def columnize(self, str_list: list[str] | None, display_width: int = 80) -> None self.poutput("") return - nonstrings = [i for i in range(len(str_list)) if not isinstance(str_list[i], str)] - if nonstrings: - raise TypeError(f"str_list[i] not a string for i in {nonstrings}") size = len(str_list) if size == 1: self.poutput(str_list[0]) @@ -4162,7 +4281,8 @@ def columnize(self, str_list: list[str] | None, display_width: int = 80) -> None # The output is wider than display_width. Print 1 column with each string on its own row. nrows = len(str_list) ncols = 1 - colwidths = [1] + max_width = max(su.str_width(s) for s in str_list) + colwidths = [max_width] for row in range(nrows): texts = [] for col in range(ncols): @@ -4175,114 +4295,6 @@ def columnize(self, str_list: list[str] | None, display_width: int = 80) -> None texts[col] = su.align_left(texts[col], width=colwidths[col]) self.poutput(" ".join(texts)) - def _help_menu(self, verbose: bool = False) -> None: - """Show a list of commands which help can be displayed for.""" - cmds_cats, cmds_doc, cmds_undoc, help_topics = self._build_command_info() - - if not cmds_cats: - # No categories found, fall back to standard behavior - self.poutput(self.doc_leader, soft_wrap=False) - self._print_topics(self.doc_header, cmds_doc, verbose) - else: - # Categories found, Organize all commands by category - self.poutput(self.doc_leader, style=Cmd2Style.HELP_HEADER, soft_wrap=False) - self.poutput(self.doc_header, style=Cmd2Style.HELP_HEADER, end="\n\n", soft_wrap=False) - for category in sorted(cmds_cats.keys(), key=self.default_sort_key): - self._print_topics(category, cmds_cats[category], verbose) - self._print_topics(self.default_category, cmds_doc, verbose) - - self.print_topics(self.misc_header, help_topics, 15, 80) - self.print_topics(self.undoc_header, cmds_undoc, 15, 80) - - def _build_command_info(self) -> tuple[dict[str, list[str]], list[str], list[str], list[str]]: - # Get a sorted list of help topics - help_topics = sorted(self.get_help_topics(), key=self.default_sort_key) - - # Get a sorted list of visible command names - visible_commands = sorted(self.get_visible_commands(), key=self.default_sort_key) - cmds_doc: list[str] = [] - cmds_undoc: list[str] = [] - cmds_cats: dict[str, list[str]] = {} - for command in visible_commands: - func = cast(CommandFunc, self.cmd_func(command)) - has_help_func = False - has_parser = func in self._command_parsers - - 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 - has_help_func = not has_parser - - if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY): - category: str = getattr(func, constants.CMD_ATTR_HELP_CATEGORY) - cmds_cats.setdefault(category, []) - cmds_cats[category].append(command) - elif func.__doc__ or has_help_func or has_parser: - cmds_doc.append(command) - else: - cmds_undoc.append(command) - return cmds_cats, cmds_doc, cmds_undoc, help_topics - - def _print_topics(self, header: str, cmds: list[str], verbose: bool) -> None: - """Print topics, switching between verbose or traditional output.""" - import io - - if cmds: - if not verbose: - self.print_topics(header, cmds, 15, 80) - else: - category_grid = Table.grid() - category_grid.add_row(header, style=Cmd2Style.HELP_TITLE) - category_grid.add_row(Rule(characters=self.ruler)) - topics_table = Table( - Column("Name", no_wrap=True), - Column("Description", overflow="fold"), - box=SIMPLE_HEAD, - border_style=Cmd2Style.RULE_LINE, - show_edge=False, - ) - - # Try to get the documentation string for each command - topics = self.get_help_topics() - for command in cmds: - if (cmd_func := self.cmd_func(command)) is None: - continue - - doc: str | None - - # Non-argparse commands can have help_functions for their documentation - if command in topics: - help_func = getattr(self, constants.HELP_FUNC_PREFIX + command) - result = io.StringIO() - - # try to redirect system stdout - with contextlib.redirect_stdout(result): - # save our internal stdout - stdout_orig = self.stdout - try: - # redirect our internal stdout - self.stdout = cast(TextIO, result) - help_func() - finally: - with self.sigint_protection: - # restore internal stdout - self.stdout = stdout_orig - doc = result.getvalue() - - else: - doc = cmd_func.__doc__ - - # Attempt to locate the first documentation block - cmd_desc = strip_doc_annotations(doc) if doc else '' - - # Add this command to the table - topics_table.add_row(command, cmd_desc) - - category_grid.add_row(topics_table) - self.poutput(category_grid, "") - @staticmethod def _build_shortcuts_parser() -> Cmd2ArgumentParser: return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="List available shortcuts.") diff --git a/cmd2/styles.py b/cmd2/styles.py index f11ab7245..22cba9f93 100644 --- a/cmd2/styles.py +++ b/cmd2/styles.py @@ -33,7 +33,7 @@ class Cmd2Style(StrEnum): ERROR = "cmd2.error" EXAMPLE = "cmd2.example" HELP_HEADER = "cmd2.help.header" - HELP_TITLE = "cmd2.help.title" + HELP_LEADER = "cmd2.help.leader" RULE_LINE = "cmd2.rule.line" SUCCESS = "cmd2.success" WARNING = "cmd2.warning" @@ -43,8 +43,8 @@ class Cmd2Style(StrEnum): DEFAULT_CMD2_STYLES: dict[str, StyleType] = { Cmd2Style.ERROR: Style(color=Color.BRIGHT_RED), Cmd2Style.EXAMPLE: Style(color=Color.CYAN, bold=True), - Cmd2Style.HELP_HEADER: Style(color=Color.CYAN, bold=True), - Cmd2Style.HELP_TITLE: Style(color=Color.BRIGHT_GREEN, bold=True), + Cmd2Style.HELP_HEADER: Style(color=Color.BRIGHT_GREEN, bold=True), + Cmd2Style.HELP_LEADER: Style(color=Color.CYAN, bold=True), Cmd2Style.RULE_LINE: Style(color=Color.BRIGHT_GREEN), Cmd2Style.SUCCESS: Style(color=Color.GREEN), Cmd2Style.WARNING: Style(color=Color.BRIGHT_YELLOW), diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index bb9172e0c..341f132cd 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1245,6 +1245,10 @@ class HelpApp(cmd2.Cmd): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) + self.doc_leader = "I now present you with a list of help topics." + self.doc_header = "My very custom doc header." + self.misc_header = "Various topics found here." + self.undoc_header = "Why did no one document these?" def do_squat(self, arg) -> None: """This docstring help will never be shown because the help_squat method overrides it.""" @@ -1266,6 +1270,10 @@ def do_multiline_docstr(self, arg) -> None: tabs """ + def help_physics(self): + """A miscellaneous help topic.""" + self.poutput("Here is some help on physics.") + parser_cmd_parser = cmd2.Cmd2ArgumentParser(description="This is the description.") @cmd2.with_argparser(parser_cmd_parser) @@ -1278,6 +1286,18 @@ def help_app(): return HelpApp() +def test_help_headers(capsys) -> None: + help_app = HelpApp() + help_app.onecmd_plus_hooks('help') + out, err = capsys.readouterr() + + assert help_app.doc_leader in out + assert help_app.doc_header in out + assert help_app.misc_header in out + assert help_app.undoc_header in out + assert help_app.last_result is True + + def test_custom_command_help(help_app) -> None: out, err = run_cmd(help_app, 'help squat') expected = normalize('This command does diddly squat...') @@ -1288,6 +1308,7 @@ def test_custom_command_help(help_app) -> None: def test_custom_help_menu(help_app) -> None: out, err = run_cmd(help_app, 'help') verify_help_text(help_app, out) + assert help_app.last_result is True def test_help_undocumented(help_app) -> None: @@ -1310,12 +1331,48 @@ def test_help_multiline_docstring(help_app) -> None: assert help_app.last_result is True +def test_miscellaneous_help_topic(help_app) -> None: + out, err = run_cmd(help_app, 'help physics') + expected = normalize("Here is some help on physics.") + assert out == expected + assert help_app.last_result is True + + def test_help_verbose_uses_parser_description(help_app: HelpApp) -> None: out, err = run_cmd(help_app, 'help --verbose') expected_verbose = utils.strip_doc_annotations(help_app.do_parser_cmd.__doc__) verify_help_text(help_app, out, verbose_strings=[expected_verbose]) +def test_help_verbose_with_fake_command(capsys) -> None: + """Verify that only actual command functions appear in verbose output.""" + help_app = HelpApp() + + cmds = ["alias", "fake_command"] + help_app._print_documented_command_topics(help_app.doc_header, cmds, verbose=True) + out, err = capsys.readouterr() + assert cmds[0] in out + assert cmds[1] not in out + + +def test_columnize_empty_list(capsys) -> None: + help_app = HelpApp() + no_strs = [] + help_app.columnize(no_strs) + out, err = capsys.readouterr() + assert "" in out + + +def test_columnize_too_wide(capsys) -> None: + help_app = HelpApp() + commands = ["kind_of_long_string", "a_slightly_longer_string"] + help_app.columnize(commands, display_width=10) + out, err = capsys.readouterr() + + expected = "kind_of_long_string \na_slightly_longer_string\n" + assert expected == out + + class HelpCategoriesApp(cmd2.Cmd): """Class for testing custom help_* methods which override docstring help."""