chiark / gitweb /
Found in crybaby's working tree.
[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 = [], show_global_opts = True,
289                *args, **kw):
290     """
291     Constructor for the options parser.  As for the superclass, but with an
292     additional argument CONTEXTS used for initializing the `help' command.
293     """
294     super(SubcommandOptionParser, me).__init__(usage = usage, *args, **kw)
295     me._cmds = commands
296
297     ## We must turn of the `interspersed arguments' feature: otherwise we'll
298     ## eat the subcommand's arguments.
299     me.disable_interspersed_args()
300     me.context = list(contexts)[0]
301     me.show_global_opts = show_global_opts
302
303     ## Provide a default `help' command.
304     me._cmds = {}
305     me.addsubcmd(Subcommand(
306         'help', contexts,
307         func = me.cmd_help,
308         desc = 'Show help for %prog, or for the COMMANDs.',
309         rparam = Arg('commands')))
310     for sub in commands: me.addsubcmd(sub)
311
312   def addsubcmd(me, sub):
313     """Add a subcommand to the main map."""
314     for c in sub.contexts:
315       me._cmds[sub.name, c] = sub
316
317   def print_help(me, file = None, *args, **kw):
318     """
319     Print a help message.  This augments the superclass behaviour by printing
320     synopses for the available subcommands.
321     """
322     if file is None: file = SYS.stdout
323     if me.show_global_opts:
324       super(SubcommandOptionParser, me).print_help(file = file, *args, **kw)
325     else:
326       file.write(me.get_usage() + '\n')
327       file.write(me.get_description())
328     file.write('\nCommands:\n')
329     for sub in sorted(set(me._cmds.values()), key = lambda c: c.name):
330       if sub.desc is None or me.context not in sub.contexts: continue
331       file.write('\t%s\n' % sub.usage())
332
333   def cmd_help(me, commands = []):
334     """
335     A default `help' command.  With arguments, print help about those;
336     otherwise just print help on the main program, as for `--help'.
337     """
338     s = StringIO()
339     if not commands:
340       me.print_help(file = s)
341     else:
342       sep = ''
343       for name in commands:
344         s.write(sep)
345         sep = '\n'
346         c = me.lookup_subcommand(name)
347         c.mkoptparse().print_help(file = s)
348     OUT.write(s.getvalue())
349
350   def lookup_subcommand(me, name, exactp = False, context = None):
351     """
352     Find the subcommand with the given NAME in the CONTEXT (default the
353     current context).  Unless EXACTP, accept a command for which NAME is an
354     unambiguous prefix.  Return the subcommand object, or raise an
355     appropriate `ExpectedError'.
356     """
357
358     if context is None: context = me.context
359
360     ## See if we can find an exact match.
361     try: c = me._cmds[name, context]
362     except KeyError: pass
363     else: return c
364
365     ## No.  Maybe we'll find a prefix match.
366     match = []
367     if not exactp:
368       for c in set(me._cmds.values()):
369         if context in c.contexts and \
370            c.name.startswith(name):
371           match.append(c)
372
373     ## See what we came up with.
374     if len(match) == 0:
375       raise U.ExpectedError, (404, "Unknown command `%s'" % name)
376     elif len(match) > 1:
377       raise U.ExpectedError, (
378         404,
379         ("Ambiguous command `%s': could be any of %s" %
380          (name, ', '.join("`%s'" % c.name for c in match))))
381     else:
382       return match[0]
383
384   def dispatch(me, context, args):
385     """
386     Invoke the appropriate subcommand, indicated by ARGS, within the CONTEXT.
387     """
388     global OPTS
389     if not args: raise U.ExpectedError, (400, "Missing command")
390     me.context = context
391     c = me.lookup_subcommand(args[0])
392     c.cmdline(args[1:])
393
394 ###--------------------------------------------------------------------------
395 ### Registry of subcommands.
396
397 ## Our list of commands.  We'll attach this to the options parser when we're
398 ## ready to roll.
399 COMMANDS = []
400
401 def subcommand(name, contexts, desc, cls = Subcommand, *args, **kw):
402   """Decorator for defining subcommands."""
403   def _(func):
404     COMMANDS.append(cls(name, contexts, desc, func, *args, **kw))
405   return _
406
407 ###----- That's all, folks --------------------------------------------------