Skip to content

Commit c8a8935

Browse files
authored
Refactored the help functions. (#1478)
1 parent 2ef7b9a commit c8a8935

File tree

3 files changed

+199
-130
lines changed

3 files changed

+199
-130
lines changed

cmd2/cmd2.py

Lines changed: 139 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -475,15 +475,22 @@ def __init__(
475475
# The multiline command currently being typed which is used to tab complete multiline commands.
476476
self._multiline_in_progress = ''
477477

478-
# Set the header used for the help function's listing of documented functions
479-
self.doc_header = "Documented commands (use 'help -v' for verbose/'help <topic>' for details)"
478+
# Set text which prints right before all of the help topics are listed.
479+
self.doc_leader = ""
480+
481+
# Set header for table listing documented commands.
482+
self.doc_header = "Documented Commands"
480483

481484
# Set header for table listing help topics not related to a command.
482485
self.misc_header = "Miscellaneous Help Topics"
483486

484487
# Set header for table listing commands that have no help info.
485488
self.undoc_header = "Undocumented Commands"
486489

490+
# If any command has been categorized, then all other commands that haven't been categorized
491+
# will display under this section in the help output.
492+
self.default_category = "Uncategorized Commands"
493+
487494
# The error that prints when no help information can be found
488495
self.help_error = "No help on {}"
489496

@@ -551,10 +558,6 @@ def __init__(
551558
# values are DisabledCommand objects.
552559
self.disabled_commands: dict[str, DisabledCommand] = {}
553560

554-
# If any command has been categorized, then all other commands that haven't been categorized
555-
# will display under this section in the help output.
556-
self.default_category = 'Uncategorized'
557-
558561
# The default key for sorting string results. Its default value performs a case-insensitive alphabetical sort.
559562
# If natural sorting is preferred, then set this to NATURAL_SORT_KEY.
560563
# cmd2 uses this key for sorting:
@@ -4039,6 +4042,45 @@ def complete_help_subcommands(
40394042
completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self)
40404043
return completer.complete_subcommand_help(text, line, begidx, endidx, arg_tokens['subcommands'])
40414044

4045+
def _build_command_info(self) -> tuple[dict[str, list[str]], list[str], list[str], list[str]]:
4046+
"""Categorizes and sorts visible commands and help topics for display.
4047+
4048+
:return: tuple containing:
4049+
- dictionary mapping category names to lists of command names
4050+
- list of documented command names
4051+
- list of undocumented command names
4052+
- list of help topic names that are not also commands
4053+
"""
4054+
# Get a sorted list of help topics
4055+
help_topics = sorted(self.get_help_topics(), key=self.default_sort_key)
4056+
4057+
# Get a sorted list of visible command names
4058+
visible_commands = sorted(self.get_visible_commands(), key=self.default_sort_key)
4059+
cmds_doc: list[str] = []
4060+
cmds_undoc: list[str] = []
4061+
cmds_cats: dict[str, list[str]] = {}
4062+
for command in visible_commands:
4063+
func = cast(CommandFunc, self.cmd_func(command))
4064+
has_help_func = False
4065+
has_parser = func in self._command_parsers
4066+
4067+
if command in help_topics:
4068+
# Prevent the command from showing as both a command and help topic in the output
4069+
help_topics.remove(command)
4070+
4071+
# Non-argparse commands can have help_functions for their documentation
4072+
has_help_func = not has_parser
4073+
4074+
if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY):
4075+
category: str = getattr(func, constants.CMD_ATTR_HELP_CATEGORY)
4076+
cmds_cats.setdefault(category, [])
4077+
cmds_cats[category].append(command)
4078+
elif func.__doc__ or has_help_func or has_parser:
4079+
cmds_doc.append(command)
4080+
else:
4081+
cmds_undoc.append(command)
4082+
return cmds_cats, cmds_doc, cmds_undoc, help_topics
4083+
40424084
@classmethod
40434085
def _build_help_parser(cls) -> Cmd2ArgumentParser:
40444086
help_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(
@@ -4074,7 +4116,24 @@ def do_help(self, args: argparse.Namespace) -> None:
40744116
self.last_result = True
40754117

40764118
if not args.command or args.verbose:
4077-
self._help_menu(args.verbose)
4119+
cmds_cats, cmds_doc, cmds_undoc, help_topics = self._build_command_info()
4120+
4121+
if self.doc_leader:
4122+
self.poutput()
4123+
self.poutput(self.doc_leader, style=Cmd2Style.HELP_LEADER, soft_wrap=False)
4124+
self.poutput()
4125+
4126+
if not cmds_cats:
4127+
# No categories found, fall back to standard behavior
4128+
self._print_documented_command_topics(self.doc_header, cmds_doc, args.verbose)
4129+
else:
4130+
# Categories found, Organize all commands by category
4131+
for category in sorted(cmds_cats.keys(), key=self.default_sort_key):
4132+
self._print_documented_command_topics(category, cmds_cats[category], args.verbose)
4133+
self._print_documented_command_topics(self.default_category, cmds_doc, args.verbose)
4134+
4135+
self.print_topics(self.misc_header, help_topics, 15, 80)
4136+
self.print_topics(self.undoc_header, cmds_undoc, 15, 80)
40784137

40794138
else:
40804139
# Getting help for a specific command
@@ -4111,14 +4170,77 @@ def print_topics(self, header: str, cmds: list[str] | None, cmdlen: int, maxcol:
41114170
:param cmdlen: unused, even by cmd's version
41124171
:param maxcol: max number of display columns to fit into
41134172
"""
4114-
if cmds:
4115-
header_grid = Table.grid()
4116-
header_grid.add_row(header, style=Cmd2Style.HELP_TITLE)
4117-
if self.ruler:
4118-
header_grid.add_row(Rule(characters=self.ruler))
4119-
self.poutput(header_grid)
4120-
self.columnize(cmds, maxcol - 1)
4121-
self.poutput()
4173+
if not cmds:
4174+
return
4175+
4176+
header_grid = Table.grid()
4177+
header_grid.add_row(header, style=Cmd2Style.HELP_HEADER)
4178+
if self.ruler:
4179+
header_grid.add_row(Rule(characters=self.ruler))
4180+
self.poutput(header_grid)
4181+
self.columnize(cmds, maxcol - 1)
4182+
self.poutput()
4183+
4184+
def _print_documented_command_topics(self, header: str, cmds: list[str], verbose: bool) -> None:
4185+
"""Print topics which are documented commands, switching between verbose or traditional output."""
4186+
import io
4187+
4188+
if not cmds:
4189+
return
4190+
4191+
if not verbose:
4192+
self.print_topics(header, cmds, 15, 80)
4193+
return
4194+
4195+
category_grid = Table.grid()
4196+
category_grid.add_row(header, style=Cmd2Style.HELP_HEADER)
4197+
category_grid.add_row(Rule(characters=self.ruler))
4198+
topics_table = Table(
4199+
Column("Name", no_wrap=True),
4200+
Column("Description", overflow="fold"),
4201+
box=SIMPLE_HEAD,
4202+
border_style=Cmd2Style.RULE_LINE,
4203+
show_edge=False,
4204+
)
4205+
4206+
# Try to get the documentation string for each command
4207+
topics = self.get_help_topics()
4208+
for command in cmds:
4209+
if (cmd_func := self.cmd_func(command)) is None:
4210+
continue
4211+
4212+
doc: str | None
4213+
4214+
# Non-argparse commands can have help_functions for their documentation
4215+
if command in topics:
4216+
help_func = getattr(self, constants.HELP_FUNC_PREFIX + command)
4217+
result = io.StringIO()
4218+
4219+
# try to redirect system stdout
4220+
with contextlib.redirect_stdout(result):
4221+
# save our internal stdout
4222+
stdout_orig = self.stdout
4223+
try:
4224+
# redirect our internal stdout
4225+
self.stdout = cast(TextIO, result)
4226+
help_func()
4227+
finally:
4228+
with self.sigint_protection:
4229+
# restore internal stdout
4230+
self.stdout = stdout_orig
4231+
doc = result.getvalue()
4232+
4233+
else:
4234+
doc = cmd_func.__doc__
4235+
4236+
# Attempt to locate the first documentation block
4237+
cmd_desc = strip_doc_annotations(doc) if doc else ''
4238+
4239+
# Add this command to the table
4240+
topics_table.add_row(command, cmd_desc)
4241+
4242+
category_grid.add_row(topics_table)
4243+
self.poutput(category_grid, "")
41224244

41234245
def columnize(self, str_list: list[str] | None, display_width: int = 80) -> None:
41244246
"""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
41324254
self.poutput("<empty>")
41334255
return
41344256

4135-
nonstrings = [i for i in range(len(str_list)) if not isinstance(str_list[i], str)]
4136-
if nonstrings:
4137-
raise TypeError(f"str_list[i] not a string for i in {nonstrings}")
41384257
size = len(str_list)
41394258
if size == 1:
41404259
self.poutput(str_list[0])
@@ -4162,7 +4281,8 @@ def columnize(self, str_list: list[str] | None, display_width: int = 80) -> None
41624281
# The output is wider than display_width. Print 1 column with each string on its own row.
41634282
nrows = len(str_list)
41644283
ncols = 1
4165-
colwidths = [1]
4284+
max_width = max(su.str_width(s) for s in str_list)
4285+
colwidths = [max_width]
41664286
for row in range(nrows):
41674287
texts = []
41684288
for col in range(ncols):
@@ -4175,114 +4295,6 @@ def columnize(self, str_list: list[str] | None, display_width: int = 80) -> None
41754295
texts[col] = su.align_left(texts[col], width=colwidths[col])
41764296
self.poutput(" ".join(texts))
41774297

4178-
def _help_menu(self, verbose: bool = False) -> None:
4179-
"""Show a list of commands which help can be displayed for."""
4180-
cmds_cats, cmds_doc, cmds_undoc, help_topics = self._build_command_info()
4181-
4182-
if not cmds_cats:
4183-
# No categories found, fall back to standard behavior
4184-
self.poutput(self.doc_leader, soft_wrap=False)
4185-
self._print_topics(self.doc_header, cmds_doc, verbose)
4186-
else:
4187-
# Categories found, Organize all commands by category
4188-
self.poutput(self.doc_leader, style=Cmd2Style.HELP_HEADER, soft_wrap=False)
4189-
self.poutput(self.doc_header, style=Cmd2Style.HELP_HEADER, end="\n\n", soft_wrap=False)
4190-
for category in sorted(cmds_cats.keys(), key=self.default_sort_key):
4191-
self._print_topics(category, cmds_cats[category], verbose)
4192-
self._print_topics(self.default_category, cmds_doc, verbose)
4193-
4194-
self.print_topics(self.misc_header, help_topics, 15, 80)
4195-
self.print_topics(self.undoc_header, cmds_undoc, 15, 80)
4196-
4197-
def _build_command_info(self) -> tuple[dict[str, list[str]], list[str], list[str], list[str]]:
4198-
# Get a sorted list of help topics
4199-
help_topics = sorted(self.get_help_topics(), key=self.default_sort_key)
4200-
4201-
# Get a sorted list of visible command names
4202-
visible_commands = sorted(self.get_visible_commands(), key=self.default_sort_key)
4203-
cmds_doc: list[str] = []
4204-
cmds_undoc: list[str] = []
4205-
cmds_cats: dict[str, list[str]] = {}
4206-
for command in visible_commands:
4207-
func = cast(CommandFunc, self.cmd_func(command))
4208-
has_help_func = False
4209-
has_parser = func in self._command_parsers
4210-
4211-
if command in help_topics:
4212-
# Prevent the command from showing as both a command and help topic in the output
4213-
help_topics.remove(command)
4214-
4215-
# Non-argparse commands can have help_functions for their documentation
4216-
has_help_func = not has_parser
4217-
4218-
if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY):
4219-
category: str = getattr(func, constants.CMD_ATTR_HELP_CATEGORY)
4220-
cmds_cats.setdefault(category, [])
4221-
cmds_cats[category].append(command)
4222-
elif func.__doc__ or has_help_func or has_parser:
4223-
cmds_doc.append(command)
4224-
else:
4225-
cmds_undoc.append(command)
4226-
return cmds_cats, cmds_doc, cmds_undoc, help_topics
4227-
4228-
def _print_topics(self, header: str, cmds: list[str], verbose: bool) -> None:
4229-
"""Print topics, switching between verbose or traditional output."""
4230-
import io
4231-
4232-
if cmds:
4233-
if not verbose:
4234-
self.print_topics(header, cmds, 15, 80)
4235-
else:
4236-
category_grid = Table.grid()
4237-
category_grid.add_row(header, style=Cmd2Style.HELP_TITLE)
4238-
category_grid.add_row(Rule(characters=self.ruler))
4239-
topics_table = Table(
4240-
Column("Name", no_wrap=True),
4241-
Column("Description", overflow="fold"),
4242-
box=SIMPLE_HEAD,
4243-
border_style=Cmd2Style.RULE_LINE,
4244-
show_edge=False,
4245-
)
4246-
4247-
# Try to get the documentation string for each command
4248-
topics = self.get_help_topics()
4249-
for command in cmds:
4250-
if (cmd_func := self.cmd_func(command)) is None:
4251-
continue
4252-
4253-
doc: str | None
4254-
4255-
# Non-argparse commands can have help_functions for their documentation
4256-
if command in topics:
4257-
help_func = getattr(self, constants.HELP_FUNC_PREFIX + command)
4258-
result = io.StringIO()
4259-
4260-
# try to redirect system stdout
4261-
with contextlib.redirect_stdout(result):
4262-
# save our internal stdout
4263-
stdout_orig = self.stdout
4264-
try:
4265-
# redirect our internal stdout
4266-
self.stdout = cast(TextIO, result)
4267-
help_func()
4268-
finally:
4269-
with self.sigint_protection:
4270-
# restore internal stdout
4271-
self.stdout = stdout_orig
4272-
doc = result.getvalue()
4273-
4274-
else:
4275-
doc = cmd_func.__doc__
4276-
4277-
# Attempt to locate the first documentation block
4278-
cmd_desc = strip_doc_annotations(doc) if doc else ''
4279-
4280-
# Add this command to the table
4281-
topics_table.add_row(command, cmd_desc)
4282-
4283-
category_grid.add_row(topics_table)
4284-
self.poutput(category_grid, "")
4285-
42864298
@staticmethod
42874299
def _build_shortcuts_parser() -> Cmd2ArgumentParser:
42884300
return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="List available shortcuts.")

cmd2/styles.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ class Cmd2Style(StrEnum):
3333
ERROR = "cmd2.error"
3434
EXAMPLE = "cmd2.example"
3535
HELP_HEADER = "cmd2.help.header"
36-
HELP_TITLE = "cmd2.help.title"
36+
HELP_LEADER = "cmd2.help.leader"
3737
RULE_LINE = "cmd2.rule.line"
3838
SUCCESS = "cmd2.success"
3939
WARNING = "cmd2.warning"
@@ -43,8 +43,8 @@ class Cmd2Style(StrEnum):
4343
DEFAULT_CMD2_STYLES: dict[str, StyleType] = {
4444
Cmd2Style.ERROR: Style(color=Color.BRIGHT_RED),
4545
Cmd2Style.EXAMPLE: Style(color=Color.CYAN, bold=True),
46-
Cmd2Style.HELP_HEADER: Style(color=Color.CYAN, bold=True),
47-
Cmd2Style.HELP_TITLE: Style(color=Color.BRIGHT_GREEN, bold=True),
46+
Cmd2Style.HELP_HEADER: Style(color=Color.BRIGHT_GREEN, bold=True),
47+
Cmd2Style.HELP_LEADER: Style(color=Color.CYAN, bold=True),
4848
Cmd2Style.RULE_LINE: Style(color=Color.BRIGHT_GREEN),
4949
Cmd2Style.SUCCESS: Style(color=Color.GREEN),
5050
Cmd2Style.WARNING: Style(color=Color.BRIGHT_YELLOW),

0 commit comments

Comments
 (0)