chiark / gitweb /
service.py: Yet more unqualified names needing qualification.
[chopwood] / subcommand.py
CommitLineData
a2916c06
MW
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
26from __future__ import with_statement
27
28import optparse as OP
29from cStringIO import StringIO
30import sys as SYS
31
32from output import OUT
33import 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.
79OPTS = None
80
81class 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
93class 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
118class 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
130class 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
276class 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.
393COMMANDS = []
394
395def 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 --------------------------------------------------