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