chiark / gitweb /
format.py: Allow general format controls more widely.
[chopwood] / subcommand.py
1 ### -*-python-*-
2 ###
3 ### Subcommand dispatch
4 ###
5 ### (c) 2013 Mark Wooding
6 ###
7
8 ###----- Licensing notice ---------------------------------------------------
9 ###
10 ### This file is part of Chopwood: a password-changing service.
11 ###
12 ### Chopwood is free software; you can redistribute it and/or modify
13 ### it under the terms of the GNU Affero General Public License as
14 ### published by the Free Software Foundation; either version 3 of the
15 ### License, or (at your option) any later version.
16 ###
17 ### Chopwood is distributed in the hope that it will be useful,
18 ### but WITHOUT ANY WARRANTY; without even the implied warranty of
19 ### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
20 ### GNU Affero General Public License for more details.
21 ###
22 ### You should have received a copy of the GNU Affero General Public
23 ### License along with Chopwood; if not, see
24 ### <http://www.gnu.org/licenses/>.
25
26 from __future__ import with_statement
27
28 import optparse as OP
29 from cStringIO import StringIO
30 import sys as SYS
31
32 from output import OUT
33 import util as U
34
35 ### We've built enough infrastructure now: it's time to move on to user
36 ### interface stuff.
37 ###
38 ### Everything is done in terms of `subcommands'.  A subcommand has a name, a
39 ### set of `contexts' in which it's active (see below), a description (for
40 ### help), a function, and a bunch of parameters.  There are a few different
41 ### kinds of parameters, but the basic idea is that they have names and
42 ### values.  When we invoke a subcommand, we'll pass the parameter values as
43 ### keyword arguments to the function.
44 ###
45 ### We have a fair number of different interfaces to provide: there's an
46 ### administration interface for adding and removing new users and accounts;
47 ### there's a GNU Userv interface for local users to change their passwords;
48 ### there's an SSH interface for remote users and for acting as a remote
49 ### service; and there's a CGI interface.  To make life a little more
50 ### confusing, sets of commands don't map one-to-one with these various
51 ### interfaces: for example, the remote-user SSH interface is (basically) the
52 ### same as the Userv interface, and the CGI interface offers two distinct
53 ### command sets depending on whether the user has authenticated.
54 ###
55 ### We call these various command sets `contexts'.  To be useful, a
56 ### subcommand must be active within at least one context.  Command lookup
57 ### takes place with a specific context in mind, and command names only need
58 ### be unique within a particular context.  Commands from a different context
59 ### are simply unavailable.
60 ###
61 ### When it comes to parameters, we have simple positional arguments, and
62 ### fancy options.  Positional arguments are called this because on the
63 ### command line they're only distinguished by their order.  Like Lisp
64 ### functions, a subcommand has some of mandatory formal arguments, followed
65 ### by some optional arguments, and finally maybe a `rest' argument which
66 ### gobbles up any remaining actual arguments as a list.  To make things more
67 ### fun, we also have options, which conform to the usual Unix command-line
68 ### conventions.
69 ###
70 ### Finally, there's a global set of options, always read from the command
71 ### line, which affects stuff like which configuration file to use, and can
72 ### also be useful in testing and debugging.
73
74 ###--------------------------------------------------------------------------
75 ### Parameters.
76
77 ## The global options.  This will carry the option values once they've been
78 ## parsed.
79 OPTS = None
80
81 class Parameter (object):
82   """
83   Base class for parameters.
84
85   Currently only stores the parameter's name, which does double duty as the
86   name of the handler function's keyword argument which will receive this
87   parameter's value, and the parameter name in the CGI interface from which
88   the value is read.
89   """
90   def __init__(me, name):
91     me.name = name
92
93 class Opt (Parameter):
94   """
95   An option, i.e., one which is presented as an option-flag in command-line
96   interfaces.
97
98   The SHORT and LONG strings are the option flags for this parameter.  The
99   SHORT string should be a single `-' followed by a single character (usually
100   a letter.  The LONG string should be a pair `--' followed by a name
101   (usually words, joined with hyphens).
102
103   The HELP is presented to the user as a description of the option.
104
105   The ARGNAME may be either `None' to indicate that this is a simple boolean
106   switch (the value passed to the handler function will be `True' or
107   `False'), or a string (conventionally in uppercase, used as a metasyntactic
108   variable in the generated usage synopsis) to indicate that the option takes
109   a general string argument (passed literally to the handler function).
110   """
111   def __init__(me, name, short, long, help, argname = None):
112     Parameter.__init__(me, name)
113     me.short = short
114     me.long = long
115     me.help = help
116     me.argname = argname
117
118 class Arg (Parameter):
119   """
120   A (positional) argument.  Nothing much to do here.
121
122   The parameter name, converted to upper case, is used as a metasyntactic
123   variable in the generated usage synopsis.
124   """
125   pass
126
127 ###--------------------------------------------------------------------------
128 ### Subcommands.
129
130 class Subcommand (object):
131   """
132   A subcommand object.
133
134   Many interesting things about the subcommand are made available as
135   attributes.
136
137   `name'
138         The subcommand name.  Used to look the command up (see
139         the `lookup_subcommand' method of `SubcommandOptionParser'), and in
140         usage and help messages.
141
142   `contexts'
143         A set (coerced from any iterable provided to the constructor) of
144         contexts in which this subcommand is available.
145
146   `desc'
147         A description of the subcommand, provided if the user requests
148         detailed help.
149
150   `func'
151         The handler function, invoked to actually carry out the subcommand.
152
153   `opts'
154         A list of `Opt' objects, used to build the option parser.
155
156   `params', `oparams', `rparam'
157         `Arg' objects for the positional parameters.  `params' is a list of
158         mandatory parameters; `oparams' is a list of optional parameters; and
159         `rparam' is either an `Arg' for the `rest' parameter, or `None' if
160         there is no `rest' parameter.
161   """
162
163   def __init__(me, name, contexts, desc, func, opts = [],
164                params = [], oparams = [], rparam = None):
165     """
166     Initialize a subcommand object.  The constructors arguments are used to
167     initialize attributes on the object; see the class docstring for details.
168     """
169     me.name = name
170     me.contexts = set(contexts)
171     me.desc = desc
172     me.opts = opts
173     me.params = params
174     me.oparams = oparams
175     me.rparam = rparam
176     me.func = func
177
178   def usage(me):
179     """Generate a suitable usage summary for the subcommand."""
180
181     ## Cache the summary in an attribute.
182     try: return me._usage
183     except AttributeError: pass
184
185     ## Gather up a list of switches and options with arguments.
186     u = []
187     sw = []
188     for o in me.opts:
189       if o.argname:
190         if o.short: u.append('[%s %s]' % (o.short, o.argname.upper()))
191         else: u.append('%s=%s' % (o.long, o.argname.upper()))
192       else:
193         if o.short: sw.append(o.short[1])
194         else: u.append(o.long)
195
196     ## Generate the usage message.
197     me._usage = ' '.join(
198       [me.name] +                       # The command name.
199       (sw and ['[-%s]' % ''.join(sorted(sw))] or []) +
200                                         # Switches, in order.
201       sorted(u) +                       # Options with arguments, and
202                                         # options without short names.
203       [p.name.upper() for p in me.params] +
204                                         # Required arguments, in order.
205       ['[%s]' % p.name.upper() for p in me.oparams] +
206                                         # Optional arguments, in order.
207       (me.rparam and ['[%s ...]' % me.rparam.name.upper()] or []))
208                                         # The `rest' argument, if present.
209
210     ## And return it.
211     return me._usage
212
213   def mkoptparse(me):
214     """
215     Make and return an `OptionParser' object for this subcommand.
216
217     This is used for dispatching through a command-line interface, and for
218     generating subcommand-specific help.
219     """
220     op = OP.OptionParser(usage = 'usage: %%prog %s' % me.usage(),
221                          description = me.desc)
222     for o in me.opts:
223       op.add_option(o.short, o.long, dest = o.name, help = o.help,
224                     action = o.argname and 'store' or 'store_true',
225                     metavar = o.argname)
226     return op
227
228   def cmdline(me, args):
229     """
230     Invoke the subcommand given a list ARGS of command-line arguments.
231     """
232
233     ## Parse any options.
234     op = me.mkoptparse()
235     opts, args = op.parse_args(args)
236
237     ## Count up the remaining positional arguments supplied, and how many
238     ## mandatory and optional arguments we want.
239     na = len(args)
240     np = len(me.params)
241     nop = len(me.oparams)
242
243     ## Complain if there's a mismatch.
244     if na < np or (not me.rparam and na > np + nop):
245       raise U.ExpectedError, (400, 'Wrong number of arguments')
246
247     ## Now we want to gather the parameters into a dictionary.
248     kw = {}
249
250     ## First, work through the various options.  The option parser tends to
251     ## define attributes for omitted options with the value `None': we leave
252     ## this out of the keywords dictionary so that the subcommand can provide
253     ## its own default values.
254     for o in me.opts:
255       try: v = getattr(opts, o.name)
256       except AttributeError: pass
257       else:
258         if v is not None: kw[o.name] = v
259
260     ## Next, assign values from positional arguments to the corresponding
261     ## parameters.
262     for a, p in zip(args, me.params + me.oparams):
263       kw[p.name] = a
264
265     ## If we have a `rest' parameter then set it to any arguments which
266     ## haven't yet been consumed.
267     if me.rparam:
268       kw[me.rparam.name] = na > np + nop and args[np + nop:] or []
269
270     ## Call the handler function.
271     me.func(**kw)
272
273 ###--------------------------------------------------------------------------
274 ### Option parsing with subcommands.
275
276 class SubcommandOptionParser (OP.OptionParser, object):
277   """
278   A subclass of `OptionParser' with some additional knowledge about
279   subcommands.
280
281   The current context is maintained in the `context' attribute, which can be
282   freely assigned by the client.  The initial value is chosen as the first in
283   the CONTEXTS list, which is otherwise only used to set up the `help'
284   command.
285   """
286
287   def __init__(me, usage = '%prog [-OPTIONS] COMMAND [ARGS ...]',
288                contexts = ['cli'], commands = [], *args, **kw):
289     """
290     Constructor for the options parser.  As for the superclass, but with an
291     additional argument CONTEXTS used for initializing the `help' command.
292     """
293     super(SubcommandOptionParser, me).__init__(usage = usage, *args, **kw)
294     me._cmds = commands
295
296     ## We must turn of the `interspersed arguments' feature: otherwise we'll
297     ## eat the subcommand's arguments.
298     me.disable_interspersed_args()
299     me.context = list(contexts)[0]
300
301     ## Provide a default `help' command.
302     me._cmds = {}
303     me.addsubcmd(Subcommand(
304         'help', contexts,
305         func = me.cmd_help,
306         desc = 'Show help for %prog, or for the COMMANDs.',
307         rparam = Arg('commands')))
308     for sub in commands: me.addsubcmd(sub)
309
310   def addsubcmd(me, sub):
311     """Add a subcommand to the main map."""
312     for c in sub.contexts:
313       me._cmds[sub.name, c] = sub
314
315   def print_help(me, file = None, *args, **kw):
316     """
317     Print a help message.  This augments the superclass behaviour by printing
318     synopses for the available subcommands.
319     """
320     if file is None: file = SYS.stdout
321     super(SubcommandOptionParser, me).print_help(file = file, *args, **kw)
322     file.write('\nCommands:\n')
323     for sub in sorted(set(me._cmds.values()), key = lambda c: c.name):
324       if sub.desc is None or me.context not in sub.contexts: continue
325       file.write('\t%s\n' % sub.usage())
326
327   def cmd_help(me, commands = []):
328     """
329     A default `help' command.  With arguments, print help about those;
330     otherwise just print help on the main program, as for `--help'.
331     """
332     s = StringIO()
333     if not commands:
334       me.print_help(file = s)
335     else:
336       sep = ''
337       for name in commands:
338         s.write(sep)
339         sep = '\n'
340         c = me.lookup_subcommand(name)
341         c.mkoptparse().print_help(file = s)
342     OUT.write(s.getvalue())
343
344   def lookup_subcommand(me, name, exactp = False, context = None):
345     """
346     Find the subcommand with the given NAME in the CONTEXT (default the
347     current context).  Unless EXACTP, accept a command for which NAME is an
348     unambiguous prefix.  Return the subcommand object, or raise an
349     appropriate `ExpectedError'.
350     """
351
352     if context is None: context = me.context
353
354     ## See if we can find an exact match.
355     try: c = me._cmds[name, context]
356     except KeyError: pass
357     else: return c
358
359     ## No.  Maybe we'll find a prefix match.
360     match = []
361     if not exactp:
362       for c in set(me._cmds.values()):
363         if context in c.contexts and \
364            c.name.startswith(name):
365           match.append(c)
366
367     ## See what we came up with.
368     if len(match) == 0:
369       raise U.ExpectedError, (404, "Unknown command `%s'" % name)
370     elif len(match) > 1:
371       raise U.ExpectedError, (
372         404,
373         ("Ambiguous command `%s': could be any of %s" %
374          (name, ', '.join("`%s'" % c.name for c in match))))
375     else:
376       return match[0]
377
378   def dispatch(me, context, args):
379     """
380     Invoke the appropriate subcommand, indicated by ARGS, within the CONTEXT.
381     """
382     global OPTS
383     if not args: raise U.ExpectedError, (400, "Missing command")
384     me.context = context
385     c = me.lookup_subcommand(args[0])
386     c.cmdline(args[1:])
387
388 ###--------------------------------------------------------------------------
389 ### Registry of subcommands.
390
391 ## Our list of commands.  We'll attach this to the options parser when we're
392 ## ready to roll.
393 COMMANDS = []
394
395 def subcommand(name, contexts, desc, cls = Subcommand,
396                opts = [], params = [], oparams = [], rparam = None):
397   """Decorator for defining subcommands."""
398   def _(func):
399     COMMANDS.append(cls(name, contexts, desc, func,
400                         opts, params, oparams, rparam))
401   return _
402
403 ###----- That's all, folks --------------------------------------------------