From ab1bdfadd5bce05d589380a99bd94eb98c4a1994 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 19 Aug 2025 15:30:55 -0400 Subject: [PATCH 1/4] Refactored the help functions. --- cmd2/cmd2.py | 230 ++++++++++++++++++++++----------------------- cmd2/styles.py | 6 +- tests/test_cmd2.py | 44 +++++++++ 3 files changed, 161 insertions(+), 119 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 979f562ea..70fafb193 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,37 @@ 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]]: + # 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 +4108,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 @@ -4113,120 +4164,15 @@ def print_topics(self, header: str, cmds: list[str] | None, cmdlen: int, maxcol: """ if cmds: header_grid = Table.grid() - header_grid.add_row(header, style=Cmd2Style.HELP_TITLE) + 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 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. - - Override of cmd's columnize() to handle strings with ANSI style sequences and wide characters. - - Each column is only as wide as necessary. - Columns are separated by two spaces (one was not legible enough). - """ - if not str_list: - 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]) - return - # Try every row count from 1 upwards - for nrows in range(1, len(str_list)): - ncols = (size + nrows - 1) // nrows - colwidths = [] - totwidth = -2 - for col in range(ncols): - colwidth = 0 - for row in range(nrows): - i = row + nrows * col - if i >= size: - break - x = str_list[i] - colwidth = max(colwidth, su.str_width(x)) - colwidths.append(colwidth) - totwidth += colwidth + 2 - if totwidth > display_width: - break - if totwidth <= display_width: - break - else: - # 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] - for row in range(nrows): - texts = [] - for col in range(ncols): - i = row + nrows * col - x = "" if i >= size else str_list[i] - texts.append(x) - while texts and not texts[-1]: - del texts[-1] - for col in range(len(texts)): - 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.""" + 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 cmds: @@ -4234,7 +4180,7 @@ def _print_topics(self, header: str, cmds: list[str], verbose: bool) -> None: 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(header, style=Cmd2Style.HELP_HEADER) category_grid.add_row(Rule(characters=self.ruler)) topics_table = Table( Column("Name", no_wrap=True), @@ -4283,6 +4229,58 @@ def _print_topics(self, header: str, cmds: list[str], verbose: bool) -> None: 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. + + Override of cmd's columnize() to handle strings with ANSI style sequences and wide characters. + + Each column is only as wide as necessary. + Columns are separated by two spaces (one was not legible enough). + """ + if not str_list: + self.poutput("") + return + + size = len(str_list) + if size == 1: + self.poutput(str_list[0]) + return + # Try every row count from 1 upwards + for nrows in range(1, len(str_list)): + ncols = (size + nrows - 1) // nrows + colwidths = [] + totwidth = -2 + for col in range(ncols): + colwidth = 0 + for row in range(nrows): + i = row + nrows * col + if i >= size: + break + x = str_list[i] + colwidth = max(colwidth, su.str_width(x)) + colwidths.append(colwidth) + totwidth += colwidth + 2 + if totwidth > display_width: + break + if totwidth <= display_width: + break + else: + # 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] + for row in range(nrows): + texts = [] + for col in range(ncols): + i = row + nrows * col + x = "" if i >= size else str_list[i] + texts.append(x) + while texts and not texts[-1]: + del texts[-1] + for col in range(len(texts)): + texts[col] = su.align_left(texts[col], width=colwidths[col]) + self.poutput(" ".join(texts)) + @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..34c2d486d 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1245,6 +1245,7 @@ class HelpApp(cmd2.Cmd): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) + self.doc_leader = "I now present you a list of help topics." def do_squat(self, arg) -> None: """This docstring help will never be shown because the help_squat method overrides it.""" @@ -1266,6 +1267,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 +1283,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 +1305,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 +1328,38 @@ 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 + + class HelpCategoriesApp(cmd2.Cmd): """Class for testing custom help_* methods which override docstring help.""" From 653a76723ade15ce4f620ad99d10242ba0522bfd Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 19 Aug 2025 16:17:30 -0400 Subject: [PATCH 2/4] Updated unit tests and comments. --- cmd2/cmd2.py | 11 ++++++++++- tests/test_cmd2.py | 15 ++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 70fafb193..269e6a324 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -4043,6 +4043,14 @@ def complete_help_subcommands( 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) @@ -4268,7 +4276,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): diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 34c2d486d..341f132cd 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1245,7 +1245,10 @@ class HelpApp(cmd2.Cmd): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - self.doc_leader = "I now present you a list of help topics." + 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.""" @@ -1360,6 +1363,16 @@ def test_columnize_empty_list(capsys) -> None: 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.""" From 0492c6d67e1ca95fa0b6036a6e81b0c134060965 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 19 Aug 2025 16:33:20 -0400 Subject: [PATCH 3/4] More code cleanup. --- cmd2/cmd2.py | 116 ++++++++++++++++++++++++++------------------------- 1 file changed, 60 insertions(+), 56 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 269e6a324..828d72d9d 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -4170,72 +4170,76 @@ 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_HEADER) - 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 cmds: - if not verbose: - self.print_topics(header, cmds, 15, 80) - else: - 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, - ) + if not cmds: + return - # 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() + if not verbose: + self.print_topics(header, cmds, 15, 80) + else: + 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, + ) - else: - doc = cmd_func.__doc__ + # 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 '' + # 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) + # Add this command to the table + topics_table.add_row(command, cmd_desc) - category_grid.add_row(topics_table) - self.poutput(category_grid, "") + 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. From 837a3d47f4ad2fc69fae278afe1f25ea2a0d9d35 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 19 Aug 2025 16:58:15 -0400 Subject: [PATCH 4/4] Even more code cleanup. --- cmd2/cmd2.py | 87 ++++++++++++++++++++++++++-------------------------- 1 file changed, 44 insertions(+), 43 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 828d72d9d..7aa08dc25 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -4190,56 +4190,57 @@ def _print_documented_command_topics(self, header: str, cmds: list[str], verbose if not verbose: self.print_topics(header, cmds, 15, 80) - else: - 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, - ) + return - # 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 + 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, + ) - doc: str | None + # 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 - # 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() + 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__ + else: + doc = cmd_func.__doc__ - # Attempt to locate the first documentation block - cmd_desc = strip_doc_annotations(doc) if doc else '' + # 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) + # Add this command to the table + topics_table.add_row(command, cmd_desc) - category_grid.add_row(topics_table) - self.poutput(category_grid, "") + 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.