chiark / gitweb /
Found in crybaby's working tree.
[distorted-keys] / extract-profile.in
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
26 from __future__ import with_statement
27
28 import sys as SYS
29 import os as OS
30 import UserDict as UD
31 import optparse as O
32 from cStringIO import StringIO
33
34 PACKAGE = "@PACKAGE@"
35 VERSION = "@VERSION@"
36
37 ###--------------------------------------------------------------------------
38 ### Utilities.
39
40 class struct (object):
41   def __init__(me, **kw):
42     me.__dict__.update(kw)
43
44 class UserError (Exception): pass
45
46 ###--------------------------------------------------------------------------
47 ### Configuration section management.
48
49 class prop (struct): pass
50
51 class 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
280         if left < n and string[left] == '@':
281           left += 1
282         while left < n and (string[left].isalnum() or string[left] in '%-_'):
283           left += 1
284         prop = string[dol + 1:left]
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.
292       prop = prop.replace('-', '_')
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.
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
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
318 def 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.
361 SPECIALS = set(['@include'])
362
363 def 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]
384         if sect not in d:
385           d[sect] = Section(sect)
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
399                all(map(lambda ch: ch in '%_' or ch.isalnum(), name)))):
400         raise UserError, "%s:%d: bad name `%s'" % (filename, n, name)
401
402       ## Store the assignment.
403       d[sect][name] = value
404
405 ###--------------------------------------------------------------------------
406 ### Main program.
407
408 OP = O.OptionParser(
409   usage = '%prog SECTION FILE|DIRECTORY ...',
410   version = '%%prog, %s version %s' % (PACKAGE, VERSION),
411   description = '''\
412 Parse the configurations FILE and DIRECTORY contents, and output the named
413 SECTION as a sequence of simple assignments.
414 ''')
415
416 def 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')
423     sect = args[0]
424     files = args[1:]
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():
451       if '%' in k: continue
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
466 if __name__ == '__main__':
467   main(SYS.argv)
468
469 ###----- That's all, folks --------------------------------------------------