Source code for pwkit.cli.multitool

# -*- 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 six import itervalues
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 itervalues (self.commands) 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 Multitool (DelegatingCommand): """A command-line tool with multiple sub-commands. Attributes: cli_name - The usual name of this tool on the command line. more_help - Additional help text. summary - A one-line summary of this tool's functionality. Functions: commandline - Execute a command as if invoked from the command-line. register - Register a new command. populate - Register many commands automatically. """ cli_name = '<no name>' cmd_desc = 'command' def __init__ (self): super (Multitool, self).__init__ (populate_from_self=False)
[docs] def commandline (self, argv): """Run as if invoked from the command line. 'argv' is a Unix-style list of arguments, where the zeroth item is the program name (which is ignored here). Usage help is printed if deemed appropriate (e.g., no arguments are given). This function always terminates with an exception, with the exception being a SystemExit(0) in case of success. Note that we don't actually use `argv[0]` to set `argv0` because it will generally be the full path to the script name, which is unattractive. """ self.invoke_with_usage (argv[1:], tool=self, argv0=self.cli_name)
[docs]def invoke_tool (namespace, tool_class=None): """Invoke a tool and exit. `namespace` is a namespace-type dict from which the tool is initialized. It should contain exactly one value that is a `Multitool` subclass, and this subclass will be instantiated and populated (see `Multitool.populate()`) using the other items in the namespace. Instances and subclasses of `Command` will therefore be registered with the `Multitool`. The tool is then invoked. `pwkit.cli.propagate_sigint()` and `pwkit.cli.unicode_stdio()` are called at the start of this function. It should therefore be only called immediately upon startup of the Python interpreter. This function always exits with an exception. The exception will be SystemExit (0) in case of success. The intended invocation is `invoke_tool (globals ())` in some module that defines a `Multitool` subclass and multiple `Command` subclasses. If `tool_class` is not None, this is used as the tool class rather than searching `namespace`, potentially avoiding problems with modules containing multiple `Multitool` implementations. """ import sys from .. import cli cli.propagate_sigint () cli.unicode_stdio () cli.backtrace_on_usr1 () if tool_class is None: for value in itervalues (namespace): if is_strict_subclass (value, Multitool): if tool_class is not None: raise PKError ('do not know which Multitool implementation to use') tool_class = value if tool_class is None: raise PKError ('no Multitool implementation to use') tool = tool_class () tool.populate (itervalues (namespace)) tool.commandline (sys.argv)
[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)