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
156 changes: 141 additions & 15 deletions cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
# Collection is a container that is sizable and iterable
# It was introduced in Python 3.6. We will try to import it, otherwise use our implementation
try:
from collections.abc import Collection
from collections.abc import Collection, Iterable
except ImportError:

if six.PY3:
Expand All @@ -79,7 +79,6 @@ def __subclasshook__(cls, C):
return True
return NotImplemented


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

if sys.version_info > (3, 0):
Copy link
Member

Choose a reason for hiding this comment

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

FYI, using the six module can help avoid Python 2 vs 3 compatibility gymnastics such as this.

six is a Python 2 vs 3 compatibility module (6 == 2 * 3 == 3 * 2).

from io import StringIO # Python3
else:
from io import BytesIO as StringIO # Python2

# Detect whether IPython is installed to determine if the built-in "ipy" command should be included
ipython_available = True
try:
Expand Down Expand Up @@ -183,6 +187,7 @@ class RlType(Enum):
except ImportError:
pass


__version__ = '0.8.4'

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

# optional attribute, when tagged on a function, allows cmd2 to categorize commands
HELP_CATEGORY = 'help_category'
HELP_SUMMARY = 'help_summary'


def categorize(func, category):
"""Categorize a function.

The help command output will group this function under the specified category heading

:param func: Union[Callable, Iterable] - function to categorize
:param category: str - category to put it in
"""
if isinstance(func, Iterable):
for item in func:
setattr(item, HELP_CATEGORY, category)
else:
setattr(func, HELP_CATEGORY, category)


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


def with_category(category):
Copy link
Member

Choose a reason for hiding this comment

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

I like that you added a decorator, that should make the code clean and easy to read.

I strongly encourage you to make one simple change to your decorator to help avoid order-of-operations dependencies when multiple decorators are involved.

You should apply the "@functools.wraps(func)" decorator to your internal cat_decorator function - see how we do it in other decorators below such as with_argument_list:

def with_category(category):
    """A decorator to apply a category to a command function"""
    @functools.wraps(func)
    def cat_decorator(func):
        categorize(func, category)
        return func
    return cat_decorator

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think that's necessary in this situation because this decorator function is truly a decorator function rather than a wrapping function. This adds an attribute the the passed-in function and returns it.

Copy link
Member

Choose a reason for hiding this comment

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

You make a good point, your decorator is mutating and returning the original function. I think you are fine as-is.

"""A decorator to apply a category to a command function"""
def cat_decorator(func):
categorize(func, category)
return func
return cat_decorator


def with_argument_list(func):
"""A decorator to alter the arguments passed to a do_* cmd2
method. Default passes a string of whatever the user typed.
Expand Down Expand Up @@ -378,6 +410,9 @@ def cmd_wrapper(instance, cmdline):
if argparser.description is None and func.__doc__:
argparser.description = func.__doc__

if func.__doc__:
setattr(cmd_wrapper, HELP_SUMMARY, func.__doc__)

cmd_wrapper.__doc__ = argparser.format_help()

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

if func.__doc__:
setattr(cmd_wrapper, HELP_SUMMARY, func.__doc__)

cmd_wrapper.__doc__ = argparser.format_help()

# Mark this function as having an argparse ArgumentParser (used by do_help)
Expand Down Expand Up @@ -2875,7 +2913,10 @@ def complete_unalias(self, text, line, begidx, endidx):
@with_argument_list
def do_help(self, arglist):
"""List available commands with "help" or detailed help with "help cmd"."""
if arglist:
if not arglist or (len(arglist) == 1 and arglist[0] in ('--verbose', '-v')):
verbose = len(arglist) == 1 and arglist[0] in ('--verbose', '-v')
self._help_menu(verbose)
else:
# Getting help for a specific command
funcname = self._func_named(arglist[0])
if funcname:
Expand All @@ -2896,11 +2937,8 @@ def do_help(self, arglist):
else:
# This could be a help topic
cmd.Cmd.do_help(self, arglist[0])
else:
# Show a menu of what commands help can be gotten for
self._help_menu()

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

cmds_doc = []
cmds_undoc = []
cmds_cats = {}

for command in visible_commands:
if command in help_topics:
cmds_doc.append(command)
help_topics.remove(command)
elif getattr(self, self._func_named(command)).__doc__:
cmds_doc.append(command)
if command in help_topics or getattr(self, self._func_named(command)).__doc__:
if command in help_topics:
help_topics.remove(command)
if hasattr(getattr(self, self._func_named(command)), HELP_CATEGORY):
category = getattr(getattr(self, self._func_named(command)), HELP_CATEGORY)
cmds_cats.setdefault(category, [])
cmds_cats[category].append(command)
else:
cmds_doc.append(command)
else:
cmds_undoc.append(command)

self.poutput("%s\n" % str(self.doc_leader))
self.print_topics(self.doc_header, cmds_doc, 15, 80)
if len(cmds_cats) == 0:
# No categories found, fall back to standard behavior
self.poutput("{}\n".format(str(self.doc_leader)))
self._print_topics(self.doc_header, cmds_doc, verbose)
else:
# Categories found, Organize all commands by category
self.poutput('{}\n'.format(str(self.doc_leader)))
self.poutput('{}\n\n'.format(str(self.doc_header)))
for category in sorted(cmds_cats.keys()):
self._print_topics(category, cmds_cats[category], verbose)
self._print_topics('Other', cmds_doc, verbose)

self.print_topics(self.misc_header, help_topics, 15, 80)
self.print_topics(self.undoc_header, cmds_undoc, 15, 80)

def _print_topics(self, header, cmds, verbose):
"""Customized version of print_topics that can switch between verbose or traditional output"""
if cmds:
if not verbose:
self.print_topics(header, cmds, 15, 80)
else:
self.stdout.write('{}\n'.format(str(header)))
widest = 0
# measure the commands
for command in cmds:
width = len(command)
if width > widest:
widest = width
# add a 4-space pad
widest += 4
if widest < 20:
widest = 20

if self.ruler:
self.stdout.write('{:{ruler}<{width}}\n'.format('', ruler=self.ruler, width=80))

help_topics = self.get_help_topics()
for command in cmds:
doc = ''
# Try to get the documentation string
try:
# first see if there's a help function implemented
func = getattr(self, 'help_' + command)
except AttributeError:
# Couldn't find a help function
try:
# Now see if help_summary has been set
doc = getattr(self, self._func_named(command)).help_summary
except AttributeError:
# Last, try to directly ac cess the function's doc-string
doc = getattr(self, self._func_named(command)).__doc__
else:
# we found the help function
result = StringIO()
# try to redirect system stdout
with redirect_stdout(result):
# save our internal stdout
stdout_orig = self.stdout
try:
# redirect our internal stdout
self.stdout = result
func()
finally:
# restore internal stdout
self.stdout = stdout_orig
doc = result.getvalue()

# Attempt to locate the first documentation block
doc_block = []
found_first = False
for doc_line in doc.splitlines():
str(doc_line).strip()
if len(doc_line.strip()) > 0:
doc_block.append(doc_line.strip())
found_first = True
else:
if found_first:
break

for doc_line in doc_block:
self.stdout.write('{: <{col_width}}{doc}\n'.format(command,
col_width=widest,
doc=doc_line))
command = ''
self.stdout.write("\n")

def do_shortcuts(self, _):
"""Lists shortcuts (aliases) available."""
result = "\n".join('%s: %s' % (sc[0], sc[1]) for sc in sorted(self.shortcuts))
Expand Down Expand Up @@ -3025,7 +3149,7 @@ def show(self, args, parameter):
else:
raise LookupError("Parameter '%s' not supported (type 'show' for list of parameters)." % param)

set_parser = argparse.ArgumentParser()
set_parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter)
set_parser.add_argument('-a', '--all', action='store_true', help='display read-only settings as well')
set_parser.add_argument('-l', '--long', action='store_true', help='describe function of parameter')
set_parser.add_argument('settable', nargs='*', help='[param_name] [value]')
Expand Down Expand Up @@ -3194,6 +3318,8 @@ def cmd_with_subs_completer(self, text, line, begidx, endidx):
# noinspection PyBroadException
def do_py(self, arg):
"""
Invoke python command, shell, or script

py <command>: Executes a Python command.
py: Enters interactive Python mode.
End with ``Ctrl-D`` (Unix) / ``Ctrl-Z`` (Windows), ``quit()``, '`exit()``.
Expand Down
132 changes: 132 additions & 0 deletions docs/argument_processing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,138 @@ Which yields:
This command can not generate tags with no content, like <br/>


Grouping Commands
=================

By default, the ``help`` command displays::

Documented commands (type help <topic>):
========================================
alias findleakers pyscript sessions status vminfo
config help quit set stop which
connect history redeploy shell thread_dump
deploy list resources shortcuts unalias
edit load restart sslconnectorciphers undeploy
expire py serverinfo start version

If you have a large number of commands, you can optionally group your commands into categories.
Here's the output from the example ``help_categories.py``::

Documented commands (type help <topic>):

Application Management
======================
deploy findleakers redeploy sessions stop
expire list restart start undeploy

Connecting
==========
connect which

Server Information
==================
resources serverinfo sslconnectorciphers status thread_dump vminfo

Other
=====
alias edit history py quit shell unalias
config help load pyscript set shortcuts version


There are 2 methods of specifying command categories, using the ``@with_category`` decorator or with the
``categorize()`` function. Once a single command category is detected, the help output switches to a categorized
mode of display. All commands with an explicit category defined default to the category `Other`.

Using the ``@with_category`` decorator::

@with_category(CMD_CAT_CONNECTING)
def do_which(self, _):
"""Which command"""
self.poutput('Which')

Using the ``categorize()`` function:

You can call with a single function::

def do_connect(self, _):
"""Connect command"""
self.poutput('Connect')

# Tag the above command functions under the category Connecting
categorize(do_connect, CMD_CAT_CONNECTING)

Or with an Iterable container of functions::

def do_undeploy(self, _):
"""Undeploy command"""
self.poutput('Undeploy')

def do_stop(self, _):
"""Stop command"""
self.poutput('Stop')

def do_findleakers(self, _):
"""Find Leakers command"""
self.poutput('Find Leakers')

# Tag the above command functions under the category Application Management
categorize((do_undeploy,
do_stop,
do_findleakers), CMD_CAT_APP_MGMT)

The ``help`` command also has a verbose option (``help -v`` or ``help --verbose``) that combines
the help categories with per-command Help Messages::

Documented commands (type help <topic>):

Application Management
================================================================================
deploy Deploy command
expire Expire command
findleakers Find Leakers command
list List command
redeploy Redeploy command
restart usage: restart [-h] {now,later,sometime,whenever}
sessions Sessions command
start Start command
stop Stop command
undeploy Undeploy command

Connecting
================================================================================
connect Connect command
which Which command

Server Information
================================================================================
resources Resources command
serverinfo Server Info command
sslconnectorciphers SSL Connector Ciphers command is an example of a command that contains
multiple lines of help information for the user. Each line of help in a
contiguous set of lines will be printed and aligned in the verbose output
provided with 'help --verbose'
status Status command
thread_dump Thread Dump command
vminfo VM Info command

Other
================================================================================
alias Define or display aliases
config Config command
edit Edit a file in a text editor.
help List available commands with "help" or detailed help with "help cmd".
history usage: history [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT] [arg]
load Runs commands in script file that is encoded as either ASCII or UTF-8 text.
py Invoke python command, shell, or script
pyscript Runs a python script file inside the console
quit Exits this application.
set usage: set [-h] [-a] [-l] [settable [settable ...]]
shell Execute a command as if at the OS prompt.
shortcuts Lists shortcuts (aliases) available.
unalias Unsets aliases
version Version command


Receiving an argument list
==========================

Expand Down
Loading