chiark / gitweb /
mdwsetup.py: Add a command for running tests.
[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 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
331 class clean_gen (Command):
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
343 class clean_others (Command):
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
356 from distutils.command.clean import clean as _clean
357 class 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
367 from distutils.command.sdist import sdist as _sdist
368 class 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
389 class 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 = []
394     me.unittest_dir = None
395     me.unittests = []
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
405 def 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
413 ###----- That's all, folks --------------------------------------------------