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