Skip to content

Commit 2026747

Browse files
authored
Merge pull request #348 from python-cmd2/help_categories
Help categories
2 parents 322a7c2 + e4ca6c8 commit 2026747

File tree

5 files changed

+535
-17
lines changed

5 files changed

+535
-17
lines changed

cmd2.py

Lines changed: 141 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
# Collection is a container that is sizable and iterable
5757
# It was introduced in Python 3.6. We will try to import it, otherwise use our implementation
5858
try:
59-
from collections.abc import Collection
59+
from collections.abc import Collection, Iterable
6060
except ImportError:
6161

6262
if six.PY3:
@@ -79,7 +79,6 @@ def __subclasshook__(cls, C):
7979
return True
8080
return NotImplemented
8181

82-
8382
# Newer versions of pyperclip are released as a single file, but older versions had a more complicated structure
8483
try:
8584
from pyperclip.exceptions import PyperclipException
@@ -113,6 +112,11 @@ def __subclasshook__(cls, C):
113112
else:
114113
from contextlib import redirect_stdout, redirect_stderr
115114

115+
if sys.version_info > (3, 0):
116+
from io import StringIO # Python3
117+
else:
118+
from io import BytesIO as StringIO # Python2
119+
116120
# Detect whether IPython is installed to determine if the built-in "ipy" command should be included
117121
ipython_available = True
118122
try:
@@ -183,6 +187,7 @@ class RlType(Enum):
183187
except ImportError:
184188
pass
185189

190+
186191
__version__ = '0.8.4'
187192

188193
# Pyparsing enablePackrat() can greatly speed up parsing, but problems have been seen in Python 3 in the past
@@ -210,6 +215,25 @@ class RlType(Enum):
210215
QUOTES = ['"', "'"]
211216
REDIRECTION_CHARS = ['|', '<', '>']
212217

218+
# optional attribute, when tagged on a function, allows cmd2 to categorize commands
219+
HELP_CATEGORY = 'help_category'
220+
HELP_SUMMARY = 'help_summary'
221+
222+
223+
def categorize(func, category):
224+
"""Categorize a function.
225+
226+
The help command output will group this function under the specified category heading
227+
228+
:param func: Union[Callable, Iterable] - function to categorize
229+
:param category: str - category to put it in
230+
"""
231+
if isinstance(func, Iterable):
232+
for item in func:
233+
setattr(item, HELP_CATEGORY, category)
234+
else:
235+
setattr(func, HELP_CATEGORY, category)
236+
213237

214238
def set_posix_shlex(val):
215239
""" Allows user of cmd2 to choose between POSIX and non-POSIX splitting of args for decorated commands.
@@ -340,6 +364,14 @@ def parse_quoted_string(cmdline):
340364
return lexed_arglist
341365

342366

367+
def with_category(category):
368+
"""A decorator to apply a category to a command function"""
369+
def cat_decorator(func):
370+
categorize(func, category)
371+
return func
372+
return cat_decorator
373+
374+
343375
def with_argument_list(func):
344376
"""A decorator to alter the arguments passed to a do_* cmd2
345377
method. Default passes a string of whatever the user typed.
@@ -378,6 +410,9 @@ def cmd_wrapper(instance, cmdline):
378410
if argparser.description is None and func.__doc__:
379411
argparser.description = func.__doc__
380412

413+
if func.__doc__:
414+
setattr(cmd_wrapper, HELP_SUMMARY, func.__doc__)
415+
381416
cmd_wrapper.__doc__ = argparser.format_help()
382417

383418
# Mark this function as having an argparse ArgumentParser (used by do_help)
@@ -417,6 +452,9 @@ def cmd_wrapper(instance, cmdline):
417452
if argparser.description is None and func.__doc__:
418453
argparser.description = func.__doc__
419454

455+
if func.__doc__:
456+
setattr(cmd_wrapper, HELP_SUMMARY, func.__doc__)
457+
420458
cmd_wrapper.__doc__ = argparser.format_help()
421459

422460
# Mark this function as having an argparse ArgumentParser (used by do_help)
@@ -2875,7 +2913,10 @@ def complete_unalias(self, text, line, begidx, endidx):
28752913
@with_argument_list
28762914
def do_help(self, arglist):
28772915
"""List available commands with "help" or detailed help with "help cmd"."""
2878-
if arglist:
2916+
if not arglist or (len(arglist) == 1 and arglist[0] in ('--verbose', '-v')):
2917+
verbose = len(arglist) == 1 and arglist[0] in ('--verbose', '-v')
2918+
self._help_menu(verbose)
2919+
else:
28792920
# Getting help for a specific command
28802921
funcname = self._func_named(arglist[0])
28812922
if funcname:
@@ -2896,11 +2937,8 @@ def do_help(self, arglist):
28962937
else:
28972938
# This could be a help topic
28982939
cmd.Cmd.do_help(self, arglist[0])
2899-
else:
2900-
# Show a menu of what commands help can be gotten for
2901-
self._help_menu()
29022940

2903-
def _help_menu(self):
2941+
def _help_menu(self, verbose=False):
29042942
"""Show a list of commands which help can be displayed for.
29052943
"""
29062944
# Get a sorted list of help topics
@@ -2913,21 +2951,107 @@ def _help_menu(self):
29132951

29142952
cmds_doc = []
29152953
cmds_undoc = []
2954+
cmds_cats = {}
29162955

29172956
for command in visible_commands:
2918-
if command in help_topics:
2919-
cmds_doc.append(command)
2920-
help_topics.remove(command)
2921-
elif getattr(self, self._func_named(command)).__doc__:
2922-
cmds_doc.append(command)
2957+
if command in help_topics or getattr(self, self._func_named(command)).__doc__:
2958+
if command in help_topics:
2959+
help_topics.remove(command)
2960+
if hasattr(getattr(self, self._func_named(command)), HELP_CATEGORY):
2961+
category = getattr(getattr(self, self._func_named(command)), HELP_CATEGORY)
2962+
cmds_cats.setdefault(category, [])
2963+
cmds_cats[category].append(command)
2964+
else:
2965+
cmds_doc.append(command)
29232966
else:
29242967
cmds_undoc.append(command)
29252968

2926-
self.poutput("%s\n" % str(self.doc_leader))
2927-
self.print_topics(self.doc_header, cmds_doc, 15, 80)
2969+
if len(cmds_cats) == 0:
2970+
# No categories found, fall back to standard behavior
2971+
self.poutput("{}\n".format(str(self.doc_leader)))
2972+
self._print_topics(self.doc_header, cmds_doc, verbose)
2973+
else:
2974+
# Categories found, Organize all commands by category
2975+
self.poutput('{}\n'.format(str(self.doc_leader)))
2976+
self.poutput('{}\n\n'.format(str(self.doc_header)))
2977+
for category in sorted(cmds_cats.keys()):
2978+
self._print_topics(category, cmds_cats[category], verbose)
2979+
self._print_topics('Other', cmds_doc, verbose)
2980+
29282981
self.print_topics(self.misc_header, help_topics, 15, 80)
29292982
self.print_topics(self.undoc_header, cmds_undoc, 15, 80)
29302983

2984+
def _print_topics(self, header, cmds, verbose):
2985+
"""Customized version of print_topics that can switch between verbose or traditional output"""
2986+
if cmds:
2987+
if not verbose:
2988+
self.print_topics(header, cmds, 15, 80)
2989+
else:
2990+
self.stdout.write('{}\n'.format(str(header)))
2991+
widest = 0
2992+
# measure the commands
2993+
for command in cmds:
2994+
width = len(command)
2995+
if width > widest:
2996+
widest = width
2997+
# add a 4-space pad
2998+
widest += 4
2999+
if widest < 20:
3000+
widest = 20
3001+
3002+
if self.ruler:
3003+
self.stdout.write('{:{ruler}<{width}}\n'.format('', ruler=self.ruler, width=80))
3004+
3005+
help_topics = self.get_help_topics()
3006+
for command in cmds:
3007+
doc = ''
3008+
# Try to get the documentation string
3009+
try:
3010+
# first see if there's a help function implemented
3011+
func = getattr(self, 'help_' + command)
3012+
except AttributeError:
3013+
# Couldn't find a help function
3014+
try:
3015+
# Now see if help_summary has been set
3016+
doc = getattr(self, self._func_named(command)).help_summary
3017+
except AttributeError:
3018+
# Last, try to directly ac cess the function's doc-string
3019+
doc = getattr(self, self._func_named(command)).__doc__
3020+
else:
3021+
# we found the help function
3022+
result = StringIO()
3023+
# try to redirect system stdout
3024+
with redirect_stdout(result):
3025+
# save our internal stdout
3026+
stdout_orig = self.stdout
3027+
try:
3028+
# redirect our internal stdout
3029+
self.stdout = result
3030+
func()
3031+
finally:
3032+
# restore internal stdout
3033+
self.stdout = stdout_orig
3034+
doc = result.getvalue()
3035+
3036+
# Attempt to locate the first documentation block
3037+
doc_block = []
3038+
found_first = False
3039+
for doc_line in doc.splitlines():
3040+
str(doc_line).strip()
3041+
if len(doc_line.strip()) > 0:
3042+
doc_block.append(doc_line.strip())
3043+
found_first = True
3044+
else:
3045+
if found_first:
3046+
break
3047+
3048+
for doc_line in doc_block:
3049+
self.stdout.write('{: <{col_width}}{doc}\n'.format(command,
3050+
col_width=widest,
3051+
doc=doc_line))
3052+
command = ''
3053+
self.stdout.write("\n")
3054+
29313055
def do_shortcuts(self, _):
29323056
"""Lists shortcuts (aliases) available."""
29333057
result = "\n".join('%s: %s' % (sc[0], sc[1]) for sc in sorted(self.shortcuts))
@@ -3025,7 +3149,7 @@ def show(self, args, parameter):
30253149
else:
30263150
raise LookupError("Parameter '%s' not supported (type 'show' for list of parameters)." % param)
30273151

3028-
set_parser = argparse.ArgumentParser()
3152+
set_parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter)
30293153
set_parser.add_argument('-a', '--all', action='store_true', help='display read-only settings as well')
30303154
set_parser.add_argument('-l', '--long', action='store_true', help='describe function of parameter')
30313155
set_parser.add_argument('settable', nargs='*', help='[param_name] [value]')
@@ -3194,6 +3318,8 @@ def cmd_with_subs_completer(self, text, line, begidx, endidx):
31943318
# noinspection PyBroadException
31953319
def do_py(self, arg):
31963320
"""
3321+
Invoke python command, shell, or script
3322+
31973323
py <command>: Executes a Python command.
31983324
py: Enters interactive Python mode.
31993325
End with ``Ctrl-D`` (Unix) / ``Ctrl-Z`` (Windows), ``quit()``, '`exit()``.

docs/argument_processing.rst

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,138 @@ Which yields:
160160
This command can not generate tags with no content, like <br/>
161161
162162
163+
Grouping Commands
164+
=================
165+
166+
By default, the ``help`` command displays::
167+
168+
Documented commands (type help <topic>):
169+
========================================
170+
alias findleakers pyscript sessions status vminfo
171+
config help quit set stop which
172+
connect history redeploy shell thread_dump
173+
deploy list resources shortcuts unalias
174+
edit load restart sslconnectorciphers undeploy
175+
expire py serverinfo start version
176+
177+
If you have a large number of commands, you can optionally group your commands into categories.
178+
Here's the output from the example ``help_categories.py``::
179+
180+
Documented commands (type help <topic>):
181+
182+
Application Management
183+
======================
184+
deploy findleakers redeploy sessions stop
185+
expire list restart start undeploy
186+
187+
Connecting
188+
==========
189+
connect which
190+
191+
Server Information
192+
==================
193+
resources serverinfo sslconnectorciphers status thread_dump vminfo
194+
195+
Other
196+
=====
197+
alias edit history py quit shell unalias
198+
config help load pyscript set shortcuts version
199+
200+
201+
There are 2 methods of specifying command categories, using the ``@with_category`` decorator or with the
202+
``categorize()`` function. Once a single command category is detected, the help output switches to a categorized
203+
mode of display. All commands with an explicit category defined default to the category `Other`.
204+
205+
Using the ``@with_category`` decorator::
206+
207+
@with_category(CMD_CAT_CONNECTING)
208+
def do_which(self, _):
209+
"""Which command"""
210+
self.poutput('Which')
211+
212+
Using the ``categorize()`` function:
213+
214+
You can call with a single function::
215+
216+
def do_connect(self, _):
217+
"""Connect command"""
218+
self.poutput('Connect')
219+
220+
# Tag the above command functions under the category Connecting
221+
categorize(do_connect, CMD_CAT_CONNECTING)
222+
223+
Or with an Iterable container of functions::
224+
225+
def do_undeploy(self, _):
226+
"""Undeploy command"""
227+
self.poutput('Undeploy')
228+
229+
def do_stop(self, _):
230+
"""Stop command"""
231+
self.poutput('Stop')
232+
233+
def do_findleakers(self, _):
234+
"""Find Leakers command"""
235+
self.poutput('Find Leakers')
236+
237+
# Tag the above command functions under the category Application Management
238+
categorize((do_undeploy,
239+
do_stop,
240+
do_findleakers), CMD_CAT_APP_MGMT)
241+
242+
The ``help`` command also has a verbose option (``help -v`` or ``help --verbose``) that combines
243+
the help categories with per-command Help Messages::
244+
245+
Documented commands (type help <topic>):
246+
247+
Application Management
248+
================================================================================
249+
deploy Deploy command
250+
expire Expire command
251+
findleakers Find Leakers command
252+
list List command
253+
redeploy Redeploy command
254+
restart usage: restart [-h] {now,later,sometime,whenever}
255+
sessions Sessions command
256+
start Start command
257+
stop Stop command
258+
undeploy Undeploy command
259+
260+
Connecting
261+
================================================================================
262+
connect Connect command
263+
which Which command
264+
265+
Server Information
266+
================================================================================
267+
resources Resources command
268+
serverinfo Server Info command
269+
sslconnectorciphers SSL Connector Ciphers command is an example of a command that contains
270+
multiple lines of help information for the user. Each line of help in a
271+
contiguous set of lines will be printed and aligned in the verbose output
272+
provided with 'help --verbose'
273+
status Status command
274+
thread_dump Thread Dump command
275+
vminfo VM Info command
276+
277+
Other
278+
================================================================================
279+
alias Define or display aliases
280+
config Config command
281+
edit Edit a file in a text editor.
282+
help List available commands with "help" or detailed help with "help cmd".
283+
history usage: history [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT] [arg]
284+
load Runs commands in script file that is encoded as either ASCII or UTF-8 text.
285+
py Invoke python command, shell, or script
286+
pyscript Runs a python script file inside the console
287+
quit Exits this application.
288+
set usage: set [-h] [-a] [-l] [settable [settable ...]]
289+
shell Execute a command as if at the OS prompt.
290+
shortcuts Lists shortcuts (aliases) available.
291+
unalias Unsets aliases
292+
version Version command
293+
294+
163295
Receiving an argument list
164296
==========================
165297

0 commit comments

Comments
 (0)