# -*- mode: python; coding: utf-8 -*-
# Copyright 2015 Peter Williams <peter@newton.cx> and collaborators.
# Licensed under the MIT License.
"""pwkit.cli.multitool - Framework for command-line tools with sub-commands
This module provides a framework for quickly creating command-line programs
that have multiple independent sub-commands (similar to the way Git's
interface works).
Classes:
Command
A command supported by the tool.
DelegatingCommand
A command that delegates to named sub-commands.
HelpCommand
A command that prints the help for other commands.
Multitool
The tool itself.
UsageError
Raised if illegal command-line arguments are used.
Functions:
invoke_tool
Run as a tool and exit.
Standard usage::
class MyCommand (multitool.Command):
name = 'info'
summary = 'Do something useful.'
def invoke (self, args, **kwargs):
print ('hello')
class MyTool (multitool.MultiTool):
cli_name = 'mytool'
summary = 'Do several useful things.'
HelpCommand = multitool.HelpCommand # optional
def commandline ():
multitool.invoke_tool (globals ())
"""
from __future__ import absolute_import, division, print_function, unicode_literals
__all__ = str(
"""invoke_tool Command DelegatingCommand HelpCommand Multitool
UsageError"""
).split()
from .. import PKError
from . import check_usage, wrong_usage
[docs]
class UsageError(PKError):
"""Raised if illegal command-line arguments are used in a Multitool
program."""
[docs]
class Command(object):
"""A command in a multifunctional CLI tool.
For historical reasons, this class defaults to a homebrew argument parsing
system. Use `ArgparsingCommand` for a better system based on the
`argparse` module.
Attributes:
argspec
One-line string summarizing the command-line arguments
that should be passed to this command.
help_if_no_args
If True, usage help will automatically be displayed if
no command-line arguments are given.
more_help
Additional help text to be displayed below the summary
(optional).
name
The command's name, as should be specified at the CLI.
summary
A one-line summary of this command's functionality.
Functions:
``invoke(self, args, **kwargs)``
Execute this command.
'name' must be set; other attributes are optional, although at least
'summary' and 'argspec' should be set. 'invoke()' must be implemented.
"""
name = None
argspec = ""
summary = ""
more_help = ""
help_if_no_args = True
[docs]
def invoke(self, args, **kwargs):
"""Invoke this command. 'args' is a list of the remaining command-line
arguments. 'kwargs' contains at least 'argv0', which is the equivalent
of, well, `argv[0]` for this command; 'tool', the originating
Multitool instance; and 'parent', the parent DelegatingCommand
instance. Other kwargs may be added in an application-specific manner.
Basic processing of '--help' will already have been done if invoked
through invoke_with_usage().
"""
raise NotImplementedError()
[docs]
def invoke_with_usage(self, args, **kwargs):
"""Invoke the command with standardized usage-help processing. Same calling
convention as `Command.invoke()`.
"""
argv0 = kwargs["argv0"]
usage = self._usage(argv0)
argv = [argv0] + args
uina = "long" if self.help_if_no_args else False
check_usage(usage, argv, usageifnoargs=uina)
try:
return self.invoke(args, **kwargs)
except UsageError as e:
wrong_usage(usage, str(e))
def _usage(self, argv0):
text = "%s %s" % (argv0, self.argspec)
if len(self.summary):
text += "\n\n" + self.summary
if len(self.more_help):
text += "\n\n" + self.more_help
return text
class ArgparsingCommand(Command):
"""A multifunctional CLI command that uses the "argparse" module.
Attributes:
name
The command's name, as should be specified at the CLI.
summary
A one-line summary of this command's functionality.
Functions:
``get_arg_parser(self, **kwargs)``
Get the `argparse.ArgumentParser` instance used to parse this
command's textual arguments.
``invoke(self, args, **kwargs)``
Execute this command.
'name' must be set; other attributes are optional. 'invoke()' must be
implemented.
"""
name = None
summary = ""
def get_arg_parser(self, **kwargs):
"""Return an instance of `argparse.ArgumentParser` used to process
this tool's command-line arguments.
"""
import argparse
ap = argparse.ArgumentParser(
prog=kwargs["argv0"],
description=self.summary,
)
return ap
def invoke_with_usage(self, args, **kwargs):
"""Invoke the command with standardized usage-help processing. Same
calling convention as `Command.invoke()`, except here *args* is an
un-parsed list of strings.
"""
ap = self.get_arg_parser(**kwargs)
args = ap.parse_args(args)
return self.invoke(args, **kwargs)
def is_strict_subclass(value, klass):
"""Check that `value` is a subclass of `klass` but that it is not actually
`klass`. Unlike issubclass(), does not raise an exception if `value` is
not a type.
"""
return isinstance(value, type) and issubclass(value, klass) and value is not klass
[docs]
class DelegatingCommand(Command):
"""A command that delegates to sub-commands.
Attributes:
cmd_desc
The noun used to desribe the sub-commands.
usage_tmpl
A formatting template for long tool usage. The default
is almost surely acceptable.
Functions:
register
Register a new sub-command.
populate
Register many sub-commands automatically.
"""
argspec = "<command> [arguments...]"
cmd_desc = "sub-command"
usage_tmpl = """%(argv0)s %(argspec)s
%(summary)s
Commands are:
%(indented_command_help)s
%(more_help)s
"""
more_help = "Most commands will give help if run with no arguments."
def __init__(self, populate_from_self=True):
self.commands = {}
if populate_from_self:
# Avoiding '_' items is important; otherwise we'll recurse
# infinitely on self.__class__!
self.populate(getattr(self, n) for n in dir(self) if not n.startswith("_"))
[docs]
def register(self, cmd):
"""Register a new command with the tool. 'cmd' is expected to be an instance
of `Command`, although here only the `cmd.name` attribute is
investigated. Multiple commands with the same name are not allowed to
be registered. Returns 'self'.
"""
if cmd.name is None:
raise ValueError("no name set for Command object %r" % cmd)
if cmd.name in self.commands:
raise ValueError(
'a command named "%s" has already been ' "registered" % cmd.name
)
self.commands[cmd.name] = cmd
return self
[docs]
def populate(self, values):
"""Register multiple new commands by investigating the iterable `values`. For
each item in `values`, instances of `Command` are registered, and
subclasses of `Command` are instantiated (with no arguments passed to
the constructor) and registered. Other kinds of values are ignored.
Returns 'self'.
"""
for value in values:
if isinstance(value, Command):
self.register(value)
elif (
is_strict_subclass(value, Command)
and getattr(value, "name") is not None
):
self.register(value())
return self
[docs]
def invoke_command(self, cmd, args, **kwargs):
"""This function mainly exists to be overridden by subclasses."""
new_kwargs = kwargs.copy()
new_kwargs["argv0"] = kwargs["argv0"] + " " + cmd.name
new_kwargs["parent"] = self
new_kwargs["parent_kwargs"] = kwargs
return cmd.invoke_with_usage(args, **new_kwargs)
[docs]
def invoke(self, args, **kwargs):
if len(args) < 1:
raise UsageError("need to specify a %s", self.cmd_desc)
cmdname = args[0]
cmd = self.commands.get(cmdname)
if cmd is None:
raise UsageError('no such %s "%s"', self.cmd_desc, cmdname)
self.invoke_command(cmd, args[1:], **kwargs)
def _usage(self, argv0):
return self.usage_tmpl % self._usage_keys(argv0)
def _usage_keys(self, argv0):
scmds = sorted(
(cmd for cmd in self.commands.values() if cmd.name[0] != "_"),
key=lambda c: c.name,
)
maxlen = 0
for cmd in scmds:
maxlen = max(maxlen, len(cmd.name))
ich = "\n".join(
" %s %-*s - %s" % (argv0, maxlen, cmd.name, cmd.summary) for cmd in scmds
)
return dict(
argspec=self.argspec,
argv0=argv0,
indented_command_help=ich,
more_help=self.more_help,
summary=self.summary,
)
[docs]
class HelpCommand(Command):
name = "help"
argspec = "<command name>"
summary = "Show help on other commands."
help_if_no_args = False
[docs]
def invoke(self, args, parent=None, parent_kwargs=None, **kwargs):
# This will Do The Right Thing if someone does the equivalent of "git
# help remote show". Other than that it's kind of open to weird
# misusage ...
parent.invoke_with_usage(args + ["--help"], **parent_kwargs)