Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
230 changes: 114 additions & 116 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,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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -4113,128 +4164,23 @@ 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("<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])
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:
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(header, style=Cmd2Style.HELP_HEADER)
category_grid.add_row(Rule(characters=self.ruler))
topics_table = Table(
Column("Name", no_wrap=True),
Expand Down Expand Up @@ -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("<empty>")
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.")
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
44 changes: 44 additions & 0 deletions tests/test_cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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)
Expand All @@ -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...')
Expand All @@ -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:
Expand All @@ -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 "<empty>" in out


class HelpCategoriesApp(cmd2.Cmd):
"""Class for testing custom help_* methods which override docstring help."""

Expand Down
Loading