Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 139 additions & 127 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,15 +475,22 @@ 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 <topic>' 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"

# 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 {}"

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice cleanup. Consider taking it further and doing:

if not verbose:
    self.print_topics(header, cmds, 15, 80)
    return

And then getting rid of the else: block and un-indenting the rest of the code to the main level.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

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.
Expand All @@ -4132,9 +4254,6 @@ def columnize(self, str_list: list[str] | None, display_width: int = 80) -> None
self.poutput("<empty>")
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])
Expand Down Expand Up @@ -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):
Expand All @@ -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.")
Expand Down
6 changes: 3 additions & 3 deletions cmd2/styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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),
Expand Down
Loading
Loading