Skip to content

Commit ab1bdfa

Browse files
committed
Refactored the help functions.
1 parent 2ef7b9a commit ab1bdfa

File tree

3 files changed

+161
-119
lines changed

3 files changed

+161
-119
lines changed

cmd2/cmd2.py

Lines changed: 114 additions & 116 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,37 @@ 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+
# Get a sorted list of help topics
4047+
help_topics = sorted(self.get_help_topics(), key=self.default_sort_key)
4048+
4049+
# Get a sorted list of visible command names
4050+
visible_commands = sorted(self.get_visible_commands(), key=self.default_sort_key)
4051+
cmds_doc: list[str] = []
4052+
cmds_undoc: list[str] = []
4053+
cmds_cats: dict[str, list[str]] = {}
4054+
for command in visible_commands:
4055+
func = cast(CommandFunc, self.cmd_func(command))
4056+
has_help_func = False
4057+
has_parser = func in self._command_parsers
4058+
4059+
if command in help_topics:
4060+
# Prevent the command from showing as both a command and help topic in the output
4061+
help_topics.remove(command)
4062+
4063+
# Non-argparse commands can have help_functions for their documentation
4064+
has_help_func = not has_parser
4065+
4066+
if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY):
4067+
category: str = getattr(func, constants.CMD_ATTR_HELP_CATEGORY)
4068+
cmds_cats.setdefault(category, [])
4069+
cmds_cats[category].append(command)
4070+
elif func.__doc__ or has_help_func or has_parser:
4071+
cmds_doc.append(command)
4072+
else:
4073+
cmds_undoc.append(command)
4074+
return cmds_cats, cmds_doc, cmds_undoc, help_topics
4075+
40424076
@classmethod
40434077
def _build_help_parser(cls) -> Cmd2ArgumentParser:
40444078
help_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(
@@ -4074,7 +4108,24 @@ def do_help(self, args: argparse.Namespace) -> None:
40744108
self.last_result = True
40754109

40764110
if not args.command or args.verbose:
4077-
self._help_menu(args.verbose)
4111+
cmds_cats, cmds_doc, cmds_undoc, help_topics = self._build_command_info()
4112+
4113+
if self.doc_leader:
4114+
self.poutput()
4115+
self.poutput(self.doc_leader, style=Cmd2Style.HELP_LEADER, soft_wrap=False)
4116+
self.poutput()
4117+
4118+
if not cmds_cats:
4119+
# No categories found, fall back to standard behavior
4120+
self._print_documented_command_topics(self.doc_header, cmds_doc, args.verbose)
4121+
else:
4122+
# Categories found, Organize all commands by category
4123+
for category in sorted(cmds_cats.keys(), key=self.default_sort_key):
4124+
self._print_documented_command_topics(category, cmds_cats[category], args.verbose)
4125+
self._print_documented_command_topics(self.default_category, cmds_doc, args.verbose)
4126+
4127+
self.print_topics(self.misc_header, help_topics, 15, 80)
4128+
self.print_topics(self.undoc_header, cmds_undoc, 15, 80)
40784129

40794130
else:
40804131
# Getting help for a specific command
@@ -4113,128 +4164,23 @@ def print_topics(self, header: str, cmds: list[str] | None, cmdlen: int, maxcol:
41134164
"""
41144165
if cmds:
41154166
header_grid = Table.grid()
4116-
header_grid.add_row(header, style=Cmd2Style.HELP_TITLE)
4167+
header_grid.add_row(header, style=Cmd2Style.HELP_HEADER)
41174168
if self.ruler:
41184169
header_grid.add_row(Rule(characters=self.ruler))
41194170
self.poutput(header_grid)
41204171
self.columnize(cmds, maxcol - 1)
41214172
self.poutput()
41224173

4123-
def columnize(self, str_list: list[str] | None, display_width: int = 80) -> None:
4124-
"""Display a list of single-line strings as a compact set of columns.
4125-
4126-
Override of cmd's columnize() to handle strings with ANSI style sequences and wide characters.
4127-
4128-
Each column is only as wide as necessary.
4129-
Columns are separated by two spaces (one was not legible enough).
4130-
"""
4131-
if not str_list:
4132-
self.poutput("<empty>")
4133-
return
4134-
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}")
4138-
size = len(str_list)
4139-
if size == 1:
4140-
self.poutput(str_list[0])
4141-
return
4142-
# Try every row count from 1 upwards
4143-
for nrows in range(1, len(str_list)):
4144-
ncols = (size + nrows - 1) // nrows
4145-
colwidths = []
4146-
totwidth = -2
4147-
for col in range(ncols):
4148-
colwidth = 0
4149-
for row in range(nrows):
4150-
i = row + nrows * col
4151-
if i >= size:
4152-
break
4153-
x = str_list[i]
4154-
colwidth = max(colwidth, su.str_width(x))
4155-
colwidths.append(colwidth)
4156-
totwidth += colwidth + 2
4157-
if totwidth > display_width:
4158-
break
4159-
if totwidth <= display_width:
4160-
break
4161-
else:
4162-
# The output is wider than display_width. Print 1 column with each string on its own row.
4163-
nrows = len(str_list)
4164-
ncols = 1
4165-
colwidths = [1]
4166-
for row in range(nrows):
4167-
texts = []
4168-
for col in range(ncols):
4169-
i = row + nrows * col
4170-
x = "" if i >= size else str_list[i]
4171-
texts.append(x)
4172-
while texts and not texts[-1]:
4173-
del texts[-1]
4174-
for col in range(len(texts)):
4175-
texts[col] = su.align_left(texts[col], width=colwidths[col])
4176-
self.poutput(" ".join(texts))
4177-
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."""
4174+
def _print_documented_command_topics(self, header: str, cmds: list[str], verbose: bool) -> None:
4175+
"""Print topics which are documented commands, switching between verbose or traditional output."""
42304176
import io
42314177

42324178
if cmds:
42334179
if not verbose:
42344180
self.print_topics(header, cmds, 15, 80)
42354181
else:
42364182
category_grid = Table.grid()
4237-
category_grid.add_row(header, style=Cmd2Style.HELP_TITLE)
4183+
category_grid.add_row(header, style=Cmd2Style.HELP_HEADER)
42384184
category_grid.add_row(Rule(characters=self.ruler))
42394185
topics_table = Table(
42404186
Column("Name", no_wrap=True),
@@ -4283,6 +4229,58 @@ def _print_topics(self, header: str, cmds: list[str], verbose: bool) -> None:
42834229
category_grid.add_row(topics_table)
42844230
self.poutput(category_grid, "")
42854231

4232+
def columnize(self, str_list: list[str] | None, display_width: int = 80) -> None:
4233+
"""Display a list of single-line strings as a compact set of columns.
4234+
4235+
Override of cmd's columnize() to handle strings with ANSI style sequences and wide characters.
4236+
4237+
Each column is only as wide as necessary.
4238+
Columns are separated by two spaces (one was not legible enough).
4239+
"""
4240+
if not str_list:
4241+
self.poutput("<empty>")
4242+
return
4243+
4244+
size = len(str_list)
4245+
if size == 1:
4246+
self.poutput(str_list[0])
4247+
return
4248+
# Try every row count from 1 upwards
4249+
for nrows in range(1, len(str_list)):
4250+
ncols = (size + nrows - 1) // nrows
4251+
colwidths = []
4252+
totwidth = -2
4253+
for col in range(ncols):
4254+
colwidth = 0
4255+
for row in range(nrows):
4256+
i = row + nrows * col
4257+
if i >= size:
4258+
break
4259+
x = str_list[i]
4260+
colwidth = max(colwidth, su.str_width(x))
4261+
colwidths.append(colwidth)
4262+
totwidth += colwidth + 2
4263+
if totwidth > display_width:
4264+
break
4265+
if totwidth <= display_width:
4266+
break
4267+
else:
4268+
# The output is wider than display_width. Print 1 column with each string on its own row.
4269+
nrows = len(str_list)
4270+
ncols = 1
4271+
colwidths = [1]
4272+
for row in range(nrows):
4273+
texts = []
4274+
for col in range(ncols):
4275+
i = row + nrows * col
4276+
x = "" if i >= size else str_list[i]
4277+
texts.append(x)
4278+
while texts and not texts[-1]:
4279+
del texts[-1]
4280+
for col in range(len(texts)):
4281+
texts[col] = su.align_left(texts[col], width=colwidths[col])
4282+
self.poutput(" ".join(texts))
4283+
42864284
@staticmethod
42874285
def _build_shortcuts_parser() -> Cmd2ArgumentParser:
42884286
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),

tests/test_cmd2.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1245,6 +1245,7 @@ class HelpApp(cmd2.Cmd):
12451245

12461246
def __init__(self, *args, **kwargs) -> None:
12471247
super().__init__(*args, **kwargs)
1248+
self.doc_leader = "I now present you a list of help topics."
12481249

12491250
def do_squat(self, arg) -> None:
12501251
"""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:
12661267
tabs
12671268
"""
12681269

1270+
def help_physics(self):
1271+
"""A miscellaneous help topic."""
1272+
self.poutput("Here is some help on physics.")
1273+
12691274
parser_cmd_parser = cmd2.Cmd2ArgumentParser(description="This is the description.")
12701275

12711276
@cmd2.with_argparser(parser_cmd_parser)
@@ -1278,6 +1283,18 @@ def help_app():
12781283
return HelpApp()
12791284

12801285

1286+
def test_help_headers(capsys) -> None:
1287+
help_app = HelpApp()
1288+
help_app.onecmd_plus_hooks('help')
1289+
out, err = capsys.readouterr()
1290+
1291+
assert help_app.doc_leader in out
1292+
assert help_app.doc_header in out
1293+
assert help_app.misc_header in out
1294+
assert help_app.undoc_header in out
1295+
assert help_app.last_result is True
1296+
1297+
12811298
def test_custom_command_help(help_app) -> None:
12821299
out, err = run_cmd(help_app, 'help squat')
12831300
expected = normalize('This command does diddly squat...')
@@ -1288,6 +1305,7 @@ def test_custom_command_help(help_app) -> None:
12881305
def test_custom_help_menu(help_app) -> None:
12891306
out, err = run_cmd(help_app, 'help')
12901307
verify_help_text(help_app, out)
1308+
assert help_app.last_result is True
12911309

12921310

12931311
def test_help_undocumented(help_app) -> None:
@@ -1310,12 +1328,38 @@ def test_help_multiline_docstring(help_app) -> None:
13101328
assert help_app.last_result is True
13111329

13121330

1331+
def test_miscellaneous_help_topic(help_app) -> None:
1332+
out, err = run_cmd(help_app, 'help physics')
1333+
expected = normalize("Here is some help on physics.")
1334+
assert out == expected
1335+
assert help_app.last_result is True
1336+
1337+
13131338
def test_help_verbose_uses_parser_description(help_app: HelpApp) -> None:
13141339
out, err = run_cmd(help_app, 'help --verbose')
13151340
expected_verbose = utils.strip_doc_annotations(help_app.do_parser_cmd.__doc__)
13161341
verify_help_text(help_app, out, verbose_strings=[expected_verbose])
13171342

13181343

1344+
def test_help_verbose_with_fake_command(capsys) -> None:
1345+
"""Verify that only actual command functions appear in verbose output."""
1346+
help_app = HelpApp()
1347+
1348+
cmds = ["alias", "fake_command"]
1349+
help_app._print_documented_command_topics(help_app.doc_header, cmds, verbose=True)
1350+
out, err = capsys.readouterr()
1351+
assert cmds[0] in out
1352+
assert cmds[1] not in out
1353+
1354+
1355+
def test_columnize_empty_list(capsys) -> None:
1356+
help_app = HelpApp()
1357+
no_strs = []
1358+
help_app.columnize(no_strs)
1359+
out, err = capsys.readouterr()
1360+
assert "<empty>" in out
1361+
1362+
13191363
class HelpCategoriesApp(cmd2.Cmd):
13201364
"""Class for testing custom help_* methods which override docstring help."""
13211365

0 commit comments

Comments
 (0)