### -*-python-*-
###
### Subcommand dispatch
###
### (c) 2013 Mark Wooding
###
###----- Licensing notice ---------------------------------------------------
###
### This file is part of Chopwood: a password-changing service.
###
### Chopwood is free software; you can redistribute it and/or modify
### it under the terms of the GNU Affero General Public License as
### published by the Free Software Foundation; either version 3 of the
### License, or (at your option) any later version.
###
### Chopwood is distributed in the hope that it will be useful,
### but WITHOUT ANY WARRANTY; without even the implied warranty of
### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
### GNU Affero General Public License for more details.
###
### You should have received a copy of the GNU Affero General Public
### License along with Chopwood; if not, see
### .
from __future__ import with_statement
import optparse as OP
from cStringIO import StringIO
import sys as SYS
from output import OUT
import util as U
### We've built enough infrastructure now: it's time to move on to user
### interface stuff.
###
### Everything is done in terms of `subcommands'. A subcommand has a name, a
### set of `contexts' in which it's active (see below), a description (for
### help), a function, and a bunch of parameters. There are a few different
### kinds of parameters, but the basic idea is that they have names and
### values. When we invoke a subcommand, we'll pass the parameter values as
### keyword arguments to the function.
###
### We have a fair number of different interfaces to provide: there's an
### administration interface for adding and removing new users and accounts;
### there's a GNU Userv interface for local users to change their passwords;
### there's an SSH interface for remote users and for acting as a remote
### service; and there's a CGI interface. To make life a little more
### confusing, sets of commands don't map one-to-one with these various
### interfaces: for example, the remote-user SSH interface is (basically) the
### same as the Userv interface, and the CGI interface offers two distinct
### command sets depending on whether the user has authenticated.
###
### We call these various command sets `contexts'. To be useful, a
### subcommand must be active within at least one context. Command lookup
### takes place with a specific context in mind, and command names only need
### be unique within a particular context. Commands from a different context
### are simply unavailable.
###
### When it comes to parameters, we have simple positional arguments, and
### fancy options. Positional arguments are called this because on the
### command line they're only distinguished by their order. Like Lisp
### functions, a subcommand has some of mandatory formal arguments, followed
### by some optional arguments, and finally maybe a `rest' argument which
### gobbles up any remaining actual arguments as a list. To make things more
### fun, we also have options, which conform to the usual Unix command-line
### conventions.
###
### Finally, there's a global set of options, always read from the command
### line, which affects stuff like which configuration file to use, and can
### also be useful in testing and debugging.
###--------------------------------------------------------------------------
### Parameters.
## The global options. This will carry the option values once they've been
## parsed.
OPTS = None
class Parameter (object):
"""
Base class for parameters.
Currently only stores the parameter's name, which does double duty as the
name of the handler function's keyword argument which will receive this
parameter's value, and the parameter name in the CGI interface from which
the value is read.
"""
def __init__(me, name):
me.name = name
class Opt (Parameter):
"""
An option, i.e., one which is presented as an option-flag in command-line
interfaces.
The SHORT and LONG strings are the option flags for this parameter. The
SHORT string should be a single `-' followed by a single character (usually
a letter. The LONG string should be a pair `--' followed by a name
(usually words, joined with hyphens).
The HELP is presented to the user as a description of the option.
The ARGNAME may be either `None' to indicate that this is a simple boolean
switch (the value passed to the handler function will be `True' or
`False'), or a string (conventionally in uppercase, used as a metasyntactic
variable in the generated usage synopsis) to indicate that the option takes
a general string argument (passed literally to the handler function).
"""
def __init__(me, name, short, long, help, argname = None):
Parameter.__init__(me, name)
me.short = short
me.long = long
me.help = help
me.argname = argname
class Arg (Parameter):
"""
A (positional) argument. Nothing much to do here.
The parameter name, converted to upper case, is used as a metasyntactic
variable in the generated usage synopsis.
"""
pass
###--------------------------------------------------------------------------
### Subcommands.
class Subcommand (object):
"""
A subcommand object.
Many interesting things about the subcommand are made available as
attributes.
`name'
The subcommand name. Used to look the command up (see
the `lookup_subcommand' method of `SubcommandOptionParser'), and in
usage and help messages.
`contexts'
A set (coerced from any iterable provided to the constructor) of
contexts in which this subcommand is available.
`desc'
A description of the subcommand, provided if the user requests
detailed help.
`func'
The handler function, invoked to actually carry out the subcommand.
`opts'
A list of `Opt' objects, used to build the option parser.
`params', `oparams', `rparam'
`Arg' objects for the positional parameters. `params' is a list of
mandatory parameters; `oparams' is a list of optional parameters; and
`rparam' is either an `Arg' for the `rest' parameter, or `None' if
there is no `rest' parameter.
"""
def __init__(me, name, contexts, desc, func, opts = [],
params = [], oparams = [], rparam = None):
"""
Initialize a subcommand object. The constructors arguments are used to
initialize attributes on the object; see the class docstring for details.
"""
me.name = name
me.contexts = set(contexts)
me.desc = desc
me.opts = opts
me.params = params
me.oparams = oparams
me.rparam = rparam
me.func = func
def usage(me):
"""Generate a suitable usage summary for the subcommand."""
## Cache the summary in an attribute.
try: return me._usage
except AttributeError: pass
## Gather up a list of switches and options with arguments.
u = []
sw = []
for o in me.opts:
if o.argname:
if o.short: u.append('[%s %s]' % (o.short, o.argname.upper()))
else: u.append('%s=%s' % (o.long, o.argname.upper()))
else:
if o.short: sw.append(o.short[1])
else: u.append(o.long)
## Generate the usage message.
me._usage = ' '.join(
[me.name] + # The command name.
(sw and ['[-%s]' % ''.join(sorted(sw))] or []) +
# Switches, in order.
sorted(u) + # Options with arguments, and
# options without short names.
[p.name.upper() for p in me.params] +
# Required arguments, in order.
['[%s]' % p.name.upper() for p in me.oparams] +
# Optional arguments, in order.
(me.rparam and ['[%s ...]' % me.rparam.name.upper()] or []))
# The `rest' argument, if present.
## And return it.
return me._usage
def mkoptparse(me):
"""
Make and return an `OptionParser' object for this subcommand.
This is used for dispatching through a command-line interface, and for
generating subcommand-specific help.
"""
op = OP.OptionParser(usage = 'usage: %%prog %s' % me.usage(),
description = me.desc)
for o in me.opts:
op.add_option(o.short, o.long, dest = o.name, help = o.help,
action = o.argname and 'store' or 'store_true',
metavar = o.argname)
return op
def cmdline(me, args):
"""
Invoke the subcommand given a list ARGS of command-line arguments.
"""
## Parse any options.
op = me.mkoptparse()
opts, args = op.parse_args(args)
## Count up the remaining positional arguments supplied, and how many
## mandatory and optional arguments we want.
na = len(args)
np = len(me.params)
nop = len(me.oparams)
## Complain if there's a mismatch.
if na < np or (not me.rparam and na > np + nop):
raise U.ExpectedError, (400, 'Wrong number of arguments')
## Now we want to gather the parameters into a dictionary.
kw = {}
## First, work through the various options. The option parser tends to
## define attributes for omitted options with the value `None': we leave
## this out of the keywords dictionary so that the subcommand can provide
## its own default values.
for o in me.opts:
try: v = getattr(opts, o.name)
except AttributeError: pass
else:
if v is not None: kw[o.name] = v
## Next, assign values from positional arguments to the corresponding
## parameters.
for a, p in zip(args, me.params + me.oparams):
kw[p.name] = a
## If we have a `rest' parameter then set it to any arguments which
## haven't yet been consumed.
if me.rparam:
kw[me.rparam.name] = na > np + nop and args[np + nop:] or []
## Call the handler function.
me.func(**kw)
###--------------------------------------------------------------------------
### Option parsing with subcommands.
class SubcommandOptionParser (OP.OptionParser, object):
"""
A subclass of `OptionParser' with some additional knowledge about
subcommands.
The current context is maintained in the `context' attribute, which can be
freely assigned by the client. The initial value is chosen as the first in
the CONTEXTS list, which is otherwise only used to set up the `help'
command.
"""
def __init__(me, usage = '%prog [-OPTIONS] COMMAND [ARGS ...]',
contexts = ['cli'], commands = [], *args, **kw):
"""
Constructor for the options parser. As for the superclass, but with an
additional argument CONTEXTS used for initializing the `help' command.
"""
super(SubcommandOptionParser, me).__init__(usage = usage, *args, **kw)
me._cmds = commands
## We must turn of the `interspersed arguments' feature: otherwise we'll
## eat the subcommand's arguments.
me.disable_interspersed_args()
me.context = list(contexts)[0]
## Provide a default `help' command.
me._cmds = {}
me.addsubcmd(Subcommand(
'help', contexts,
func = me.cmd_help,
desc = 'Show help for %prog, or for the COMMANDs.',
rparam = Arg('commands')))
for sub in commands: me.addsubcmd(sub)
def addsubcmd(me, sub):
"""Add a subcommand to the main map."""
for c in sub.contexts:
me._cmds[sub.name, c] = sub
def print_help(me, file = None, *args, **kw):
"""
Print a help message. This augments the superclass behaviour by printing
synopses for the available subcommands.
"""
if file is None: file = SYS.stdout
super(SubcommandOptionParser, me).print_help(file = file, *args, **kw)
file.write('\nCommands:\n')
for sub in sorted(set(me._cmds.values()), key = lambda c: c.name):
if sub.desc is None or me.context not in sub.contexts: continue
file.write('\t%s\n' % sub.usage())
def cmd_help(me, commands = []):
"""
A default `help' command. With arguments, print help about those;
otherwise just print help on the main program, as for `--help'.
"""
s = StringIO()
if not commands:
me.print_help(file = s)
else:
sep = ''
for name in commands:
s.write(sep)
sep = '\n'
c = me.lookup_subcommand(name)
c.mkoptparse().print_help(file = s)
OUT.write(s.getvalue())
def lookup_subcommand(me, name, exactp = False, context = None):
"""
Find the subcommand with the given NAME in the CONTEXT (default the
current context). Unless EXACTP, accept a command for which NAME is an
unambiguous prefix. Return the subcommand object, or raise an
appropriate `ExpectedError'.
"""
if context is None: context = me.context
## See if we can find an exact match.
try: c = me._cmds[name, context]
except KeyError: pass
else: return c
## No. Maybe we'll find a prefix match.
match = []
if not exactp:
for c in set(me._cmds.values()):
if context in c.contexts and \
c.name.startswith(name):
match.append(c)
## See what we came up with.
if len(match) == 0:
raise U.ExpectedError, (404, "Unknown command `%s'" % name)
elif len(match) > 1:
raise U.ExpectedError, (
404,
("Ambiguous command `%s': could be any of %s" %
(name, ', '.join("`%s'" % c.name for c in match))))
else:
return match[0]
def dispatch(me, context, args):
"""
Invoke the appropriate subcommand, indicated by ARGS, within the CONTEXT.
"""
global OPTS
if not args: raise U.ExpectedError, (400, "Missing command")
me.context = context
c = me.lookup_subcommand(args[0])
c.cmdline(args[1:])
###--------------------------------------------------------------------------
### Registry of subcommands.
## Our list of commands. We'll attach this to the options parser when we're
## ready to roll.
COMMANDS = []
def subcommand(name, contexts, desc, cls = Subcommand,
opts = [], params = [], oparams = [], rparam = None):
"""Decorator for defining subcommands."""
def _(func):
COMMANDS.append(cls(name, contexts, desc, func,
opts, params, oparams, rparam))
return _
###----- That's all, folks --------------------------------------------------