chiark / gitweb /
Scatter the useful files into subdirectories by theme.
[runlisp] / build / 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 --------------------------------------------------