mdwsetup.py: Turn off Python's usual `SIGINT' handler.
[cfd] / mdwsetup.py
CommitLineData
a6bb85c1
MW
1### -*-python-*-
2###
3### Utility module for Python build systems
4###
5### (c) 2009 Straylight/Edgeware
6###
7
8###----- Licensing notice ---------------------------------------------------
9###
f0c2c8a0 10### This file is part of the Common Files Distribution (`common')
a6bb85c1 11###
f0c2c8a0 12### `Common' is free software; you can redistribute it and/or modify
a6bb85c1
MW
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###
f0c2c8a0 17### `Common' is distributed in the hope that it will be useful,
a6bb85c1
MW
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
f0c2c8a0 23### along with `common'; if not, write to the Free Software Foundation,
a6bb85c1
MW
24### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
25
2f8140f9
MW
26from __future__ import with_statement
27
a6bb85c1
MW
28import sys as SYS
29import os as OS
30import re as RE
e638c0a3 31import signal as SIG
a6bb85c1
MW
32import subprocess as SUB
33
34import distutils.core as DC
f74ba2bb 35import distutils.log as DL
a6bb85c1
MW
36
37###--------------------------------------------------------------------------
e638c0a3
MW
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.
44SIG.signal(SIG.SIGINT, SIG.SIG_DFL)
45
46###--------------------------------------------------------------------------
4e236859
MW
47### Compatibility hacks.
48
49def with_metaclass(meta, *supers):
50 return meta("#<anonymous base %s>" % meta.__name__,
51 supers or (object,), dict())
52
53###--------------------------------------------------------------------------
a6bb85c1
MW
54### Random utilities.
55
56def 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
73class 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):
25d89871
MW
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))
a6bb85c1
MW
83 else:
84 return '%s died inexplicably' % (me.file)
85
86def 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 """
4e236859 93 kid = SUB.Popen(command, stdout = SUB.PIPE, universal_newlines = True)
f3c13bfa
MW
94 try:
95 out = kid.stdout.readline()
514f1de6 96 junk = kid.stdout.read(1)
f3c13bfa
MW
97 finally:
98 kid.stdout.close()
207202a3
MW
99 if junk != '': raise ValueError \
100 ("Child process `%s' produced unspected output %r" % (command, junk))
a6bb85c1 101 rc = kid.wait()
207202a3 102 if rc != 0: raise SubprocessFailure(command, rc)
a6bb85c1
MW
103 return out.rstrip('\n')
104
105###--------------------------------------------------------------------------
106### External library packages.
107
108INCLUDEDIRS = []
109LIBDIRS = []
110LIBS = []
111
112def 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 """
6f9cf579 120
a6bb85c1 121 def weird(what, word):
207202a3
MW
122 raise ValueError \
123 ("Unexpected `%s' item `%s' from package `%s'" % (what, word, pkg))
6f9cf579
MW
124
125 spec = '%s >= %s' % (pkg, version)
126
ab5cf08d
MW
127 try: cflags = OS.environ["%s_CFLAGS" % pkg]
128 except KeyError: cflags = progoutput(['pkg-config', '--cflags', spec])
129 for word in cflags.split():
6f9cf579 130 if word.startswith('-I'): INCLUDEDIRS.append(word[2:])
94291754 131 else: weird('CFLAGS', word)
ab5cf08d
MW
132 try: libs = OS.environ["%s_LIBS" % pkg]
133 except KeyError: libs = progoutput(['pkg-config', '--libs', spec])
134 for word in libs.split():
6f9cf579
MW
135 if word.startswith('-L'): LIBDIRS.append(word[2:])
136 elif word.startswith('-l'): LIBS.append(word[2:])
94291754 137 else: weird('LIBS', word)
a6bb85c1
MW
138
139###--------------------------------------------------------------------------
140### Substituting variables in files.
141
f74ba2bb 142class BaseGenFile (object):
a6bb85c1 143 """
f74ba2bb 144 A base class for file generators.
a6bb85c1 145
f74ba2bb
MW
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.
a6bb85c1 152 """
f74ba2bb
MW
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)
a6bb85c1 171
f74ba2bb 172class Derive (BaseGenFile):
a6bb85c1
MW
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 """
f74ba2bb
MW
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)
a6bb85c1 190
f74ba2bb 191class Generate (BaseGenFile):
a6bb85c1
MW
192 """
193 Generate TARGET by running the SOURCE Python script.
194
195 If SOURCE is omitted, replace the extension of TARGET by `.py'.
196 """
f74ba2bb
MW
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)
207202a3 204 if rc != 0: raise SubprocessFailure(me.sources[0], rc << 8)
f74ba2bb
MW
205 OS.rename(temp, me.target)
206
207## Backward compatibility.
208def derive(target, source, substmap): Derive(target, source, substmap).gen()
209def generate(target, source = None): Generate(target, source).gen()
a6bb85c1
MW
210
211###--------------------------------------------------------------------------
212### Discovering version numbers.
213
214def 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.
f74ba2bb
MW
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.
a6bb85c1
MW
224 """
225 version = progoutput(['./auto-version'])
226 if writep:
2f8140f9 227 with open('RELEASE.new', 'w') as ft: ft.write('%s\n' % version)
a6bb85c1
MW
228 OS.rename('RELEASE.new', 'RELEASE')
229 return version
230
f74ba2bb
MW
231###--------------------------------------------------------------------------
232### Adding new commands.
233
234CMDS = {}
235
236class 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
4e236859 247class Command (with_metaclass(CommandClass, DC.Command, object)):
f74ba2bb
MW
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
265class distdir (Command):
266 NAME = 'distdir'
267 description = "print the distribution directory name to stdout"
268 def run(me):
269 d = me.distribution
4e236859 270 print('%s-%s' % (d.get_name(), d.get_version()))
f74ba2bb 271
80ed3c35 272class build_gen (Command):
f74ba2bb
MW
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
294from distutils.command.build import build as _build
295class 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
8a060232
MW
301class 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
80ed3c35 341class clean_gen (Command):
f74ba2bb
MW
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
80ed3c35 353class clean_others (Command):
f74ba2bb
MW
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
366from distutils.command.clean import clean as _clean
367class 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
377from distutils.command.sdist import sdist as _sdist
378class 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
399class 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 = []
8a060232
MW
404 me.unittest_dir = None
405 me.unittests = []
f74ba2bb
MW
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
415def 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
a6bb85c1 423###----- That's all, folks --------------------------------------------------