chiark / gitweb /
a1f56b628f200ae326e7594a39ccf3cc779afa98
[cfd] / mdwsetup.py
1 ### -*-python-*-
2 ###
3 ### Utility module for Python build systems
4 ###
5 ### (c) 2009 Straylight/Edgeware
6 ###
7
8 ###----- Licensing notice ---------------------------------------------------
9 ###
10 ### This file is part of the Common Files Distribution (`common')
11 ###
12 ### `Common' 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 ### `Common' 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 `common'; 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 re as RE
31 import subprocess as SUB
32
33 import distutils.core as DC
34 import distutils.log as DL
35
36 ###--------------------------------------------------------------------------
37 ### Random utilities.
38
39 def uniquify(seq):
40   """
41   Return a list of the elements of SEQ, with duplicates removed.
42
43   Only the first occurrence (according to `==') is left.
44   """
45   seen = {}
46   out = []
47   for item in seq:
48     if item not in seen:
49       seen[item] = True
50       out.append(item)
51   return out
52
53 ###--------------------------------------------------------------------------
54 ### Subprocess hacking.
55
56 class SubprocessFailure (Exception):
57   def __init__(me, file, rc):
58     me.args = (file, rc)
59     me.file = file
60     me.rc = rc
61   def __str__(me):
62     if OS.WIFEXITED(me.rc):
63       return '%s failed (rc = %d)' % (me.file, OS.WEXITSTATUS(me.rc))
64     elif OS.WIFSIGNALED(me.rc):
65       return '%s died (signal %d)' % (me.file, OS.WTERMSIG(me.rc))
66     else:
67       return '%s died inexplicably' % (me.file)
68
69 def progoutput(command):
70   """
71   Run the shell COMMAND and return its standard output.
72
73   The COMMAND must produce exactly one line of output, and must exit with
74   status zero.
75   """
76   kid = SUB.Popen(command, stdout = SUB.PIPE)
77   out = kid.stdout.readline()
78   junk = kid.stdout.read()
79   if junk != '': raise ValueError \
80     ("Child process `%s' produced unspected output %r" % (command, junk))
81   rc = kid.wait()
82   if rc != 0: raise SubprocessFailure(command, rc)
83   return out.rstrip('\n')
84
85 ###--------------------------------------------------------------------------
86 ### External library packages.
87
88 INCLUDEDIRS = []
89 LIBDIRS = []
90 LIBS = []
91
92 def pkg_config(pkg, version):
93   """
94   Find the external package PKG and store the necessary compiler flags.
95
96   The include-directory names are stored in INCLUDEDIRS; the
97   library-directory names are in LIBDIRS; and the library names themselves
98   are in LIBS.
99   """
100   spec = '%s >= %s' % (pkg, version)
101   def weird(what, word):
102     raise ValueError \
103       ("Unexpected `%s' item `%s' from package `%s'" % (what, word, pkg))
104   for word in progoutput(['pkg-config', '--cflags', spec]).split():
105     if word.startswith('-I'):
106       INCLUDEDIRS.append(word[2:])
107     else:
108       weird('--cflags', word)
109   for word in progoutput(['pkg-config', '--libs', spec]).split():
110     if word.startswith('-L'):
111       LIBDIRS.append(word[2:])
112     elif word.startswith('-l'):
113       LIBS.append(word[2:])
114     else:
115       weird('--libs', word)
116
117 ###--------------------------------------------------------------------------
118 ### Substituting variables in files.
119
120 class BaseGenFile (object):
121   """
122   A base class for file generators.
123
124   Instances of subclasses are suitable for listing in the `genfiles'
125   attribute, passed to `setup'.
126
127   Subclasses need to implement `_gen', which should simply do the work of
128   generating the target file from its sources.  This class will print
129   progress messages and check whether the target actually needs regenerating.
130   """
131   def __init__(me, target, sources = []):
132     me.target = target
133     me.sources = sources
134   def _needs_update_p(me):
135     if not OS.path.exists(me.target): return True
136     t_target = OS.stat(me.target).st_mtime
137     for s in me.sources:
138       if OS.stat(s).st_mtime >= t_target: return True
139     return False
140   def gen(me, dry_run_p = False):
141     if not me._needs_update_p(): return
142     DL.log(DL.INFO, "generate `%s' from %s", me.target,
143            ', '.join("`%s'" % s for s in me.sources))
144     if not dry_run_p: me._gen()
145   def clean(me, dry_run_p):
146     if not OS.path.exists(me.target): return
147     DL.log(DL.INFO, "delete `%s'", me.target)
148     if not dry_run_p: OS.remove(me.target)
149
150 class Derive (BaseGenFile):
151   """
152   Derive TARGET from SOURCE by making simple substitutions.
153
154   The SOURCE may contain markers %FOO%; these are replaced by SUBSTMAP['FOO']
155   in the TARGET file.
156   """
157   RX_SUBST = RE.compile(r'\%(\w+)\%')
158   def __init__(me, target, source, substmap):
159     BaseGenFile.__init__(me, target, [source])
160     me._map = substmap
161   def _gen(me):
162     temp = me.target + '.new'
163     with open(temp, 'w') as ft:
164       with open(me.sources[0], 'r') as fs:
165         for line in fs:
166           ft.write(me.RX_SUBST.sub((lambda m: me._map[m.group(1)]), line))
167     OS.rename(temp, me.target)
168
169 class Generate (BaseGenFile):
170   """
171   Generate TARGET by running the SOURCE Python script.
172
173   If SOURCE is omitted, replace the extension of TARGET by `.py'.
174   """
175   def __init__(me, target, source = None):
176     if source is None: source = OS.path.splitext(target)[0] + '.py'
177     BaseGenFile.__init__(me, target, [source])
178   def _gen(me):
179     temp = me.target + '.new'
180     with open(temp, 'w') as ft:
181       rc = SUB.call([SYS.executable, me.sources[0]], stdout = ft)
182     if rc != 0: raise SubprocessFailure(me.sources[0], rc << 8)
183     OS.rename(temp, me.target)
184
185 ## Backward compatibility.
186 def derive(target, source, substmap): Derive(target, source, substmap).gen()
187 def generate(target, source = None): Generate(target, source).gen()
188
189 ###--------------------------------------------------------------------------
190 ### Discovering version numbers.
191
192 def auto_version(writep = True):
193   """
194   Returns the package version number.
195
196   As a side-effect, if WRITEP is true, then write the version number to the
197   RELEASE file so that it gets included in distributions.
198
199   All of this is for backwards compatibility.  New projects should omit the
200   `version' keyword entirely and let `setup' discover it and write it into
201   tarballs automatically.
202   """
203   version = progoutput(['./auto-version'])
204   if writep:
205     with open('RELEASE.new', 'w') as ft: ft.write('%s\n' % version)
206     OS.rename('RELEASE.new', 'RELEASE')
207   return version
208
209 ###--------------------------------------------------------------------------
210 ### Adding new commands.
211
212 CMDS = {}
213
214 class CommandClass (type):
215   """
216   Metaclass for command classes: automatically adds them to the `CMDS' map.
217   """
218   def __new__(cls, name, supers, dict):
219     c = super(CommandClass, cls).__new__(cls, name, supers, dict)
220     try: name = c.NAME
221     except AttributeError: pass
222     else: CMDS[name] = c
223     return c
224
225 class Command (DC.Command, object):
226   """
227   Base class for `mdwsetup' command classes.
228
229   This provides the automatic registration machinery, via the metaclass, and
230   also trivial implementations of various responsibilities of `DC.Command'
231   methods and attributes.
232   """
233   __metaclass__ = CommandClass
234   user_options = []
235   def initialize_options(me): pass
236   def finalize_options(me): pass
237   def run_subs(me):
238     for s in me.get_sub_commands(): me.run_command(s)
239
240 ###--------------------------------------------------------------------------
241 ### Some handy new commands.
242
243 class distdir (Command):
244   NAME = 'distdir'
245   description = "print the distribution directory name to stdout"
246   def run(me):
247     d = me.distribution
248     print '%s-%s' % (d.get_name(), d.get_version())
249
250 class build_gen(Command):
251   """
252   Generate files, according to the `genfiles'.
253
254   The `genfiles' keyword argument to `setup' lists a number of objects which
255   guide the generation of output files.  These objects must implement the
256   following methods.
257
258   clean(DRY_RUN_P)      Remove the output files.
259
260   gen(DRY_RUN_P)        Generate the output files, if they don't exist or are
261                         out of date with respect to their prerequisites.
262
263   If DRY_RUN_P is true then the methods must not actually do anything with a
264   lasting effect, but should print progress messages as usual.
265   """
266   NAME = 'build_gen'
267   description = "build generated source files"
268   def run(me):
269     d = me.distribution
270     for g in d.genfiles: g.gen(dry_run_p = me.dry_run)
271
272 from distutils.command.build import build as _build
273 class build (_build, Command):
274   ## Add `build_gen' early in the list of subcommands.
275   NAME = 'build'
276   sub_commands = [('build_gen', lambda me: me.distribution.genfiles)]
277   sub_commands += _build.sub_commands
278
279 class clean_gen(Command):
280   """
281   Remove the generated files, as listed in `genfiles'.
282
283   See the `build_gen' command for more detailed information.
284   """
285   NAME = 'clean_gen'
286   description = "clean generated source files"
287   def run(me):
288     d = me.distribution
289     for g in d.genfiles: g.clean(dry_run_p = me.dry_run)
290
291 class clean_others(Command):
292   """
293   Remove the files listed in the `cleanfiles' argument to `setup'.
294   """
295   NAME = 'clean_others'
296   description = "clean miscellaneous output files"
297   def run(me):
298     d = me.distribution
299     for f in d.cleanfiles:
300       if not OS.path.exists(f): continue
301       DL.log(DL.INFO, "delete `%s'", f)
302       if not me.dry_run: OS.remove(f)
303
304 from distutils.command.clean import clean as _clean
305 class clean (_clean, Command):
306   ## Add `clean_gen' and `clean_others' to the list of subcommands.
307   NAME = 'clean'
308   sub_commands = [('clean_gen', lambda me: me.distribution.genfiles),
309                   ('clean_others', lambda me: me.distribution.cleanfiles)]
310   sub_commands += _clean.sub_commands
311   def run(me):
312     me.run_subs()
313     _clean.run(me)
314
315 from distutils.command.sdist import sdist as _sdist
316 class sdist (_sdist, Command):
317   ## Write a `RELEASE' file to the output, if we extracted the version number
318   ## from version control.  Also arrange to dereference symbolic links while
319   ## copying.  Symlinks to directories will go horribly wrong, so don't do
320   ## that.
321   NAME = 'sdist'
322   def make_release_tree(me, base_dir, files):
323     _sdist.make_release_tree(me, base_dir, files)
324     d = me.distribution
325     if d._auto_version_p:
326       v = d.metadata.get_version()
327       DL.log(DL.INFO, "write `RELEASE' file: %s" % v)
328       with open(OS.path.join(base_dir, 'RELEASE'), 'w') as f:
329         f.write('%s\n' % v)
330   def copy_file(me, infile, outfile, link = None, *args, **kw):
331     if OS.path.islink(infile): link = None
332     return _sdist.copy_file(me, infile, outfile, link = link, *args, **kw)
333
334 ###--------------------------------------------------------------------------
335 ### Our own version of `setup'.
336
337 class Dist (DC.Distribution):
338   ## Like the usual version, but with some additional attributes to support
339   ## our enhanced commands.
340   def __init__(me, attrs = None):
341     me.genfiles = []
342     me.cleanfiles = []
343     me._auto_version_p = False
344     DC.Distribution.__init__(me, attrs)
345     if me.metadata.version is None:
346       me.metadata.version = auto_version(writep = False)
347       me._auto_version_p = True
348     me.cleanfiles = set(me.cleanfiles)
349     me.cleanfiles.add('MANIFEST')
350
351 def setup(cmdclass = {}, distclass = Dist, **kw):
352   ## Like the usual version, but provides defaults more suited to our
353   ## purposes.
354   cmds = dict()
355   cmds.update(CMDS)
356   cmds.update(cmdclass)
357   DC.setup(cmdclass = cmds, distclass = distclass, **kw)
358
359 ###----- That's all, folks --------------------------------------------------