Commit | Line | Data |
---|---|---|
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 | ||
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 | ||
7d0eb62c | 395 | def subcommand(name, contexts, desc, cls = Subcommand, *args, **kw): |
a2916c06 MW |
396 | """Decorator for defining subcommands.""" |
397 | def _(func): | |
7d0eb62c | 398 | COMMANDS.append(cls(name, contexts, desc, func, *args, **kw)) |
a2916c06 MW |
399 | return _ |
400 | ||
401 | ###----- That's all, folks -------------------------------------------------- |