mdwsetup.py: Add a command for running tests.
[runlisp] / 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
31import subprocess as SUB
32
33import distutils.core as DC
f74ba2bb 34import distutils.log as DL
a6bb85c1
MW
35
36###--------------------------------------------------------------------------
4e236859
MW
37### Compatibility hacks.
38
39def with_metaclass(meta, *supers):
40 return meta("#<anonymous base %s>" % meta.__name__,
41 supers or (object,), dict())
42
43###--------------------------------------------------------------------------
a6bb85c1
MW
44### Random utilities.
45
46def 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
63class 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):
25d89871
MW
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))
a6bb85c1
MW
73 else:
74 return '%s died inexplicably' % (me.file)
75
76def 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 """
4e236859 83 kid = SUB.Popen(command, stdout = SUB.PIPE, universal_newlines = True)
f3c13bfa
MW
84 try:
85 out = kid.stdout.readline()
514f1de6 86 junk = kid.stdout.read(1)
f3c13bfa
MW
87 finally:
88 kid.stdout.close()
207202a3
MW
89 if junk != '': raise ValueError \
90 ("Child process `%s' produced unspected output %r" % (command, junk))
a6bb85c1 91 rc = kid.wait()
207202a3 92 if rc != 0: raise SubprocessFailure(command, rc)
a6bb85c1
MW
93 return out.rstrip('\n')
94
95###--------------------------------------------------------------------------
96### External library packages.
97
98INCLUDEDIRS = []
99LIBDIRS = []
100LIBS = []
101
102def 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 """
6f9cf579 110
a6bb85c1 111 def weird(what, word):
207202a3
MW
112 raise ValueError \
113 ("Unexpected `%s' item `%s' from package `%s'" % (what, word, pkg))
6f9cf579
MW
114
115 spec = '%s >= %s' % (pkg, version)
116
ab5cf08d
MW
117 try: cflags = OS.environ["%s_CFLAGS" % pkg]
118 except KeyError: cflags = progoutput(['pkg-config', '--cflags', spec])
119 for word in cflags.split():
6f9cf579 120 if word.startswith('-I'): INCLUDEDIRS.append(word[2:])
94291754 121 else: weird('CFLAGS', word)
ab5cf08d
MW
122 try: libs = OS.environ["%s_LIBS" % pkg]
123 except KeyError: libs = progoutput(['pkg-config', '--libs', spec])
124 for word in libs.split():
6f9cf579
MW
125 if word.startswith('-L'): LIBDIRS.append(word[2:])
126 elif word.startswith('-l'): LIBS.append(word[2:])
94291754 127 else: weird('LIBS', word)
a6bb85c1
MW
128
129###--------------------------------------------------------------------------
130### Substituting variables in files.
131
f74ba2bb 132class BaseGenFile (object):
a6bb85c1 133 """
f74ba2bb 134 A base class for file generators.
a6bb85c1 135
f74ba2bb
MW
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.
a6bb85c1 142 """
f74ba2bb
MW
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)
a6bb85c1 161
f74ba2bb 162class Derive (BaseGenFile):
a6bb85c1
MW
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 """
f74ba2bb
MW
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)
a6bb85c1 180
f74ba2bb 181class Generate (BaseGenFile):
a6bb85c1
MW
182 """
183 Generate TARGET by running the SOURCE Python script.
184
185 If SOURCE is omitted, replace the extension of TARGET by `.py'.
186 """
f74ba2bb
MW
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)
207202a3 194 if rc != 0: raise SubprocessFailure(me.sources[0], rc << 8)
f74ba2bb
MW
195 OS.rename(temp, me.target)
196
197## Backward compatibility.
198def derive(target, source, substmap): Derive(target, source, substmap).gen()
199def generate(target, source = None): Generate(target, source).gen()
a6bb85c1
MW
200
201###--------------------------------------------------------------------------
202### Discovering version numbers.
203
204def 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.
f74ba2bb
MW
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.
a6bb85c1
MW
214 """
215 version = progoutput(['./auto-version'])
216 if writep:
2f8140f9 217 with open('RELEASE.new', 'w') as ft: ft.write('%s\n' % version)
a6bb85c1
MW
218 OS.rename('RELEASE.new', 'RELEASE')
219 return version
220
f74ba2bb
MW
221###--------------------------------------------------------------------------
222### Adding new commands.
223
224CMDS = {}
225
226class 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
4e236859 237class Command (with_metaclass(CommandClass, DC.Command, object)):
f74ba2bb
MW
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
255class distdir (Command):
256 NAME = 'distdir'
257 description = "print the distribution directory name to stdout"
258 def run(me):
259 d = me.distribution
4e236859 260 print('%s-%s' % (d.get_name(), d.get_version()))
f74ba2bb 261
80ed3c35 262class build_gen (Command):
f74ba2bb
MW
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
284from distutils.command.build import build as _build
285class 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
8a060232
MW
291class test (Command):
292 """
293 Run unit tests, according to the `unittests'.
294
295 The `unittests' keyword argument to `setup' lists module names (or other
296 things acceptable to the `loadTestsFromNames' test-loader method) to be
297 run. The build library directory is prepended to the load path before
298 running the tests to ensure that the newly built modules are tested. If
299 `unittest_dir' is set, then this is appended to the load path so that test
300 modules can be found there.
301 """
302 NAME = "test"
303 description = "run the included test suite"
304
305 user_options = \
306 [('build-lib=', 'b', "directory containing compiled moules"),
307 ('tests=', 't', "tests to run"),
308 ('verbose-test', 'V', "run tests verbosely")]
309
310 def initialize_options(me):
311 me.build_lib = None
312 me.verbose_test = False
313 me.tests = None
314 def finalize_options(me):
315 me.set_undefined_options('build', ('build_lib', 'build_lib'))
316 def run(me):
317 import unittest as U
318 d = me.distribution
319 SYS.path = [me.build_lib] + SYS.path
320 if d.unittest_dir is not None: SYS.path.append(d.unittest_dir)
321 if me.tests is not None: tests = me.tests.split(",")
322 else: tests = d.unittests
323 suite = U.defaultTestLoader.loadTestsFromNames(tests)
324 runner = U.TextTestRunner(verbosity = me.verbose_test and 2 or 1)
325 if me.dry_run: return
326 result = runner.run(suite)
327 if result.errors or result.failures or \
328 getattr(result, "unexpectedSuccesses", 0):
329 SYS.exit(2)
330
80ed3c35 331class clean_gen (Command):
f74ba2bb
MW
332 """
333 Remove the generated files, as listed in `genfiles'.
334
335 See the `build_gen' command for more detailed information.
336 """
337 NAME = 'clean_gen'
338 description = "clean generated source files"
339 def run(me):
340 d = me.distribution
341 for g in d.genfiles: g.clean(dry_run_p = me.dry_run)
342
80ed3c35 343class clean_others (Command):
f74ba2bb
MW
344 """
345 Remove the files listed in the `cleanfiles' argument to `setup'.
346 """
347 NAME = 'clean_others'
348 description = "clean miscellaneous output files"
349 def run(me):
350 d = me.distribution
351 for f in d.cleanfiles:
352 if not OS.path.exists(f): continue
353 DL.log(DL.INFO, "delete `%s'", f)
354 if not me.dry_run: OS.remove(f)
355
356from distutils.command.clean import clean as _clean
357class clean (_clean, Command):
358 ## Add `clean_gen' and `clean_others' to the list of subcommands.
359 NAME = 'clean'
360 sub_commands = [('clean_gen', lambda me: me.distribution.genfiles),
361 ('clean_others', lambda me: me.distribution.cleanfiles)]
362 sub_commands += _clean.sub_commands
363 def run(me):
364 me.run_subs()
365 _clean.run(me)
366
367from distutils.command.sdist import sdist as _sdist
368class sdist (_sdist, Command):
369 ## Write a `RELEASE' file to the output, if we extracted the version number
370 ## from version control. Also arrange to dereference symbolic links while
371 ## copying. Symlinks to directories will go horribly wrong, so don't do
372 ## that.
373 NAME = 'sdist'
374 def make_release_tree(me, base_dir, files):
375 _sdist.make_release_tree(me, base_dir, files)
376 d = me.distribution
377 if d._auto_version_p:
378 v = d.metadata.get_version()
379 DL.log(DL.INFO, "write `RELEASE' file: %s" % v)
380 with open(OS.path.join(base_dir, 'RELEASE'), 'w') as f:
381 f.write('%s\n' % v)
382 def copy_file(me, infile, outfile, link = None, *args, **kw):
383 if OS.path.islink(infile): link = None
384 return _sdist.copy_file(me, infile, outfile, link = link, *args, **kw)
385
386###--------------------------------------------------------------------------
387### Our own version of `setup'.
388
389class Dist (DC.Distribution):
390 ## Like the usual version, but with some additional attributes to support
391 ## our enhanced commands.
392 def __init__(me, attrs = None):
393 me.genfiles = []
8a060232
MW
394 me.unittest_dir = None
395 me.unittests = []
f74ba2bb
MW
396 me.cleanfiles = []
397 me._auto_version_p = False
398 DC.Distribution.__init__(me, attrs)
399 if me.metadata.version is None:
400 me.metadata.version = auto_version(writep = False)
401 me._auto_version_p = True
402 me.cleanfiles = set(me.cleanfiles)
403 me.cleanfiles.add('MANIFEST')
404
405def setup(cmdclass = {}, distclass = Dist, **kw):
406 ## Like the usual version, but provides defaults more suited to our
407 ## purposes.
408 cmds = dict()
409 cmds.update(CMDS)
410 cmds.update(cmdclass)
411 DC.setup(cmdclass = cmds, distclass = distclass, **kw)
412
a6bb85c1 413###----- That's all, folks --------------------------------------------------