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