Commit | Line | Data |
---|---|---|
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 |
26 | from __future__ import with_statement |
27 | ||
a6bb85c1 MW |
28 | import sys as SYS |
29 | import os as OS | |
30 | import re as RE | |
e638c0a3 | 31 | import signal as SIG |
a6bb85c1 MW |
32 | import subprocess as SUB |
33 | ||
34 | import distutils.core as DC | |
f74ba2bb | 35 | import 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. | |
44 | SIG.signal(SIG.SIGINT, SIG.SIG_DFL) | |
45 | ||
46 | ###-------------------------------------------------------------------------- | |
4e236859 MW |
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 | ###-------------------------------------------------------------------------- | |
a6bb85c1 MW |
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): | |
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 | ||
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 | """ | |
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 | ||
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 | """ | |
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 | 142 | class 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 | 172 | class 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 | 191 | class 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. | |
208 | def derive(target, source, substmap): Derive(target, source, substmap).gen() | |
209 | def generate(target, source = None): Generate(target, source).gen() | |
a6bb85c1 MW |
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. | |
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 | ||
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 | ||
4e236859 | 247 | class 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 | ||
265 | class 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 | 272 | class 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 | ||
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 | ||
8a060232 MW |
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 | ||
80ed3c35 | 341 | class 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 | 353 | class 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 | ||
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 = [] | |
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 | ||
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 | ||
a6bb85c1 | 423 | ###----- That's all, folks -------------------------------------------------- |