chiark / gitweb /
userv/distorted-keys.in: Rename from distorted-keys.userv.in.
[distorted-keys] / extract-profile.in
CommitLineData
c47f2aba
MW
1#! @PYTHON@
2###
3### Parse a profile definition file and emit a particular section
4###
5### (c) 2011 Mark Wooding
6###
7
8###----- Licensing notice ---------------------------------------------------
9###
10### This file is part of the distorted.org.uk key management suite.
11###
12### distorted-keys is free software; you can redistribute it and/or modify
13### it under the terms of the GNU General Public License as published by
14### the Free Software Foundation; either version 2 of the License, or
15### (at your option) any later version.
16###
17### distorted-keys 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 General Public License for more details.
21###
22### You should have received a copy of the GNU General Public License
23### along with distorted-keys; if not, write to the Free Software Foundation,
24### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
25
26from __future__ import with_statement
27
28import sys as SYS
29import os as OS
30import UserDict as UD
31import optparse as O
32from cStringIO import StringIO
33
34PACKAGE = "@PACKAGE@"
35VERSION = "@VERSION@"
36
37###--------------------------------------------------------------------------
38### Utilities.
39
40class struct (object):
41 def __init__(me, **kw):
42 me.__dict__.update(kw)
43
44class UserError (Exception): pass
45
46###--------------------------------------------------------------------------
47### Configuration section management.
48
49class prop (struct): pass
50
51class Section (object, UD.DictMixin):
52 """
53 A section of a profile configuration file.
54 """
55
56 ## States for depth-first traversal.
57 V_WHITE = 0 # Not yet visited.
58 V_GREY = 1 # Currently being visited.
59 V_BLACK = 2 # Visited previously and processed.
60
61 def __init__(me, name, dict = None):
62 """
63 Initialize a Section object.
64
65 The DICT provides the initial (direct) contents of the Section. The NAME
66 is used for presentation purposes, e.g., when reporting error conditions.
67 """
68 super(Section, me).__init__()
69 if dict is None: me._dict = {}
70 else: me._dict = dict
71 me._visited = me.V_WHITE
72 me.name = name
73 me.includes = set()
74 me.inferiors = set()
75 me.inherited = {}
76
77 ## Dictionary methods for UD.DictMixin. The built-in `dict' class provides
78 ## equality, and is therefore not hashable. By doing things this way, we
79 ## can retain reference equality and stay hashable, which will be useful
80 ## later.
81 def __getitem__(me, key):
82 return me._dict[key]
83 def __setitem__(me, key, value):
84 me._dict[key] = value
85 def __delitem__(me, key):
86 del me._dict[key]
87 def keys(me):
88 return me._dict.keys()
89 def __contains__(me, key):
90 return key in me._dict
91 def __iter__(me):
92 return me._dict.__iter__()
93 def iteritems(me):
94 return me._dict.iteritems()
95 def __repr__(me):
96 return 'Section(%r, %r)' % (me.name, me.inherited)
97
98 def transit(me, seen = None, path = None):
99 """
100 Visit the Section for the purposes of computing transitive inclusion.
101
102 If this completes successfully, the Section's inferiors slot is set up to
103 contain all of its (non-strict) inferiors. A section's inferiors consist
104 of itself, together with the union of the inferiors of all of its
105 included Sections.
106
107 If the Section's visited state is black, nothing happens; if it's white
108 then it will be coloured grey temporarily, and its included Sections
109 processed recursively; if it's grey to begin with then we have
110 encountered a cycle.
111
112 The SEEN dictionary and PATH list are used for detecting and reporting
113 cycles. The PATH contains a list of the currently grey Sections, in the
114 order in which they were encountered; SEEN maps Section names to their
115 indices in the PATH list.
116
117 It is possible to make this work in the presence of cycles, but it's more
118 effort than it's worth.
119 """
120
121 ## Extend the path to include us. This will be useful when reporting
122 ## cycles.
123 if seen is None: seen = {}
124 if path is None: path = []
125 path.append(me)
126
127 ## Already been here: nothing to do.
128 if me._visited == me.V_BLACK:
129 pass
130
131 ## We've found a cycle: report it to the user.
132 elif me._visited == me.V_GREY:
133 raise UserError, 'detected inclusion cycle:\n\t%s' % \
134 ' -> '.join(["`%s'" % s.name for s in path[seen[me]:]])
135
136 ## Not done this one yet: process my included Sections, and compute the
137 ## union of their inferiors.
138 else:
139 seen[me] = len(path) - 1
140 me._visited = me.V_GREY
141 me.inferiors = set([me])
142 for s in me.includes:
143 s.transit(seen, path)
144 me.inferiors.update(s.inferiors)
145 me._visited = me.V_BLACK
146
147 ## Remove myself from the path.
148 path.pop()
149
150 def inherit(me):
151 """
152 Compute the inherited properties for this Section.
153
154 A Section has an inherited property named P if any inferior has a direct
155 property named P. The value of the property is determined as follows.
156 Firstly, determine the set A of all inferiors which have a direct
157 property P. Secondly, determine a /reduced/ set containing only the
158 maximal elements of A: if R contains a pair of distinct inferiors I and J
159 such that I is an inferior of J, then R does not contain I; R contains
160 all elements A not so excluded. If all inferiors in R define the same
161 value for the property, then that is the value of the inherited property;
162 if two inferiors disagree, then the situation is erroneous.
163
164 Note that if a Section defines a direct property then it has an inherited
165 property with the same value: in this case, the reduced set is a
166 singleton.
167 """
168
169 ## First pass: for each property name, determine the reduced set of
170 ## inferiors defining that property, and the values they have for it.
171 ## Here, D maps property names to lists of `prop' records.
172 d = {}
173 for s in me.inferiors:
174
175 ## Work through the direct properties of inferior S.
176 for k, v in s.iteritems():
177
178 ## Ignore `special' properties.
179 if k.startswith('@'):
180 continue
181
182 ## Work through the current reduced set. Discard entries from
183 ## sections inferior to S. If an entry exists for a section T to
184 ## which S is inferior, then don't add S itself.
185 addp = True
186 pp = []
187 try:
188 for q in d[k]:
189 if s in q.source.inferiors:
190 addp = False
191 if q.source not in s.inferiors:
192 pp.append(q)
193 except KeyError:
194 pass
195 if addp:
196 pp.append(prop(value = v, source = s))
197 d[k] = pp
198
199 ## Second pass: check that the reduced set defines a unique value for
200 ## each inherited property.
201 for k, vv in d.iteritems():
202 c = {}
203
204 ## Build in C a dictionary mapping candidate values to lists of
205 ## inferiors asserting those values.
206 for p in vv:
207 c.setdefault(p.value, []).append(p.source)
208
209 ## Now C should have only one key. If not, C records enough
210 ## information that we can give a useful error report.
211 if len(c) != 1:
212 raise UserError, \
213 "inconsistent values for property `%s' in section `%s': %s" % \
214 (k, me.name,
215 ''.join(["\n\t`%s' via %s" %
216 (v, ', '.join(["`%s'" % s.name for s in ss]))
217 for v, ss in c.iteritems()]))
218
219 ## Insert the computed property value.
220 me.inherited[k] = c.keys()[0]
221
222 def expand(me, string, seen = None, path = None):
223 """
224 Expand placeholders in STRING and return the result.
225
226 A placeholder has the form $PROP or ${PROP} (the latter syntax identifies
227 the property name unambiguously), and is replaced by the value of the
228 (inherited) property named PROP. A token $$ is replaced with a single $.
229
230 The SEEN and PATH parameters work the same way as in the `transit'
231 method.
232 """
233
234 if seen is None: seen = {}
235 if path is None: path = []
236
237 ## Prepare stuff for the loop.
238 out = StringIO()
239 left = 0
240 n = len(string)
241
242 ## Pick out placeholders and expand them.
243 while True:
244
245 ## Find a placeholder.
246 dol = string.find('$', left)
247
248 ## None: commit the rest of the string and we're done.
249 if dol < 0:
250 out.write(string[left:])
251 break
252
253 ## Commit the portion before the placeholder.
254 out.write(string[left:dol])
255
256 ## Check for a trailing `$'. After this, we can be sure of at least
257 ## one more character.
258 if dol + 1 >= n:
259 prop = ''
260
261 ## If there's a left brace, find a right brace: the property name is
262 ## between them.
263 elif string[dol + 1] == '{':
264 ace = string.find('}', dol + 2)
265 if ace < 0:
266 raise UserError, \
267 "invalid placeholder (missing `}') in `%s'" % string
268 prop = string[dol + 2:ace]
269 left = ace + 1
270
271 ## If there's a dollar, just commit it and go round again.
272 elif string[dol + 1] == '$':
273 left = dol + 2
274 out.write('$')
275 continue
276
277 ## Otherwise take as many constituent characters as we can.
278 else:
279 left = dol + 1
3253034b
MW
280 if left < n and string[left] == '@':
281 left += 1
282 while left < n and (string[left].isalnum() or string[left] in '%-_'):
c47f2aba 283 left += 1
ddf8e79a 284 prop = string[dol + 1:left]
c47f2aba
MW
285
286 ## If we came up empty, report an error.
287 if prop == '':
288 raise UserError, \
289 "invalid placeholder (empty name) in `%s'" % string
290
291 ## Extend the path: we're going to do a recursive expansion.
ddf8e79a 292 prop = prop.replace('-', '_')
c47f2aba
MW
293 path.append(prop)
294
295 ## Report a cycle if we found one.
296 if prop in seen:
297 raise UserError, 'substitution cycle:\n\t%s' % \
298 (' -> '.join(["`%s'" % p for p in path[seen[prop]:]]))
299
300 ## Look up the raw value.
3253034b
MW
301 if prop == '@name':
302 value = me.name
303 else:
304 try:
305 value = me.inherited[prop]
306 except KeyError:
307 raise UserError, "unknown property `%s'" % prop
c47f2aba
MW
308
309 ## Recursively expand, and unwind the PATH and SEEN stuff.
310 seen[prop] = len(path) - 1
311 out.write(me.expand(value, seen, path))
312 path.pop()
313 del seen[prop]
314
315 ## Done: return the accumulated result.
316 return out.getvalue()
317
318def link(d):
319 """
320 Link together the Sections in D according to their inclusions.
321
322 If a Section S has an `@include' special property, then set S's `includes'
323 slot to be the set of sections named in that property's value. Then
324 compute the inferiors and inherited properties for all of the Sections.
325 """
326
327 ## Capture the global section.
328 g = d['@GLOBAL']
329
330 ## Walk through all of the sections.
331 for sect in d.itervalues():
332
333 ## If this isn't the global section, then add the global section as an
334 ## implicit inclusion.
335 if sect is not g:
336 sect.includes.add(g)
337
338 ## If there are explicit inclusions, then add them to the included set.
339 try:
340 inc = sect['@include']
341 except KeyError:
342 pass
343 else:
344 for s in inc.split():
345 try:
346 sect.includes.add(d[s])
347 except KeyError:
348 raise UserError, \
349 "unknown section `%s' included in `%s'" % (s, sect.name)
350
351 ## Compute the inferiors and inherited properties.
352 for sect in d.itervalues():
353 sect.transit()
354 for sect in d.itervalues():
355 sect.inherit()
356
357###--------------------------------------------------------------------------
358### Parsing input files.
359
360## Names of special properties. All of these begin with an `@' sign.
361SPECIALS = set(['@include'])
362
363def parse(filename, d):
364 """
365 Parse a profile file FILENAME, updating dictionary D.
366
367 Each entry in the dictionary maps a section name to the section's contents;
368 the contents are in turn represented as a dictionary mapping properties to
369 values. Inter-section references, defaults, and so on are not processed
370 here.
371 """
372
373 sect = '@GLOBAL'
374
375 with open(filename) as f:
376 n = 0
377 for line in f:
378 n += 1
379 line = line.strip()
380 if not line or line[0] in ';#':
381 continue
382 if line[0] == '[' and line[-1] == ']':
383 sect = line[1:-1]
b5991f05
MW
384 if sect not in d:
385 d[sect] = Section(sect)
c47f2aba
MW
386 continue
387
388 ## Parse an assignment.
389 eq = line.find('=')
390 colon = line.find(':')
391 if eq < 0 or 0 <= colon < eq: eq = colon
392 if eq < 0: raise UserError, '%s:%d: no assignment' % (filename, n)
393 name, value = line[:eq].strip(), line[eq + 1:].strip()
394
395 ## Check that the name is well-formed.
396 name = name.replace('-', '_')
397 if not (name and
398 (name in SPECIALS or
3253034b 399 all(map(lambda ch: ch in '%_' or ch.isalnum(), name)))):
c47f2aba
MW
400 raise UserError, "%s:%d: bad name `%s'" % (filename, n, name)
401
402 ## Store the assignment.
b5991f05 403 d[sect][name] = value
c47f2aba
MW
404
405###--------------------------------------------------------------------------
406### Main program.
407
408OP = O.OptionParser(
b65e1f93 409 usage = '%prog SECTION FILE|DIRECTORY ...',
c47f2aba
MW
410 version = '%%prog, version %s' % VERSION,
411 description = '''\
412Parse the configurations FILE and DIRECTORY contents, and output the named
413SECTION as a sequence of simple assignments.
414''')
415
416def main(args):
417 try:
418
419 ## Check the arguments.
420 opts, args = OP.parse_args(args[1:])
421 if len(args) < 2:
422 OP.error('not enough positional parameters')
b65e1f93
MW
423 sect = args[0]
424 files = args[1:]
c47f2aba
MW
425
426 ## Read in the inputs.
427 d = { '@GLOBAL': Section('@GLOBAL') }
428 for f in files:
429
430 ## It's a directory: pick out the files contained.
431 if OS.path.isdir(f):
432 for sf in sorted(OS.listdir(f)):
433 if not all(map(lambda ch: ch in '_-' or ch.isalnum(), sf)):
434 continue
435 ff = OS.path.join(f, sf)
436 if not OS.path.isfile(ff):
437 continue
438 parse(ff, d)
439
440 ## Not a directory: just try to parse it.
441 else:
442 parse(f, d)
443
444 ## Print the contents.
445 link(d)
446 try:
447 s = d[sect]
448 except KeyError:
449 raise UserError, "unknown section `%s'" % sect
450 for k, v in s.inherited.iteritems():
3253034b 451 if '%' in k: continue
c47f2aba
MW
452 print '%s=%s' % (k, s.expand(v))
453
454 ## Report errors for expected problems.
455 except UserError, e:
456 SYS.stderr.write('%s: %s\n' % (OP.get_prog_name(), e.args[0]))
457 SYS.exit(1)
458 except OSError, e:
459 SYS.stderr.write('%s: %s\n' % (OP.get_prog_name(), e.args[1]))
460 SYS.exit(1)
461 except IOError, e:
462 SYS.stderr.write('%s: %s: %s\n' %
463 (OP.get_prog_name(), e.filename, e.strerror))
464 SYS.exit(1)
465
466if __name__ == '__main__':
467 main(SYS.argv)
468
469###----- That's all, folks --------------------------------------------------