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 | |
31 | import subprocess as SUB | |
32 | ||
33 | import distutils.core as DC | |
f74ba2bb | 34 | import distutils.log as DL |
a6bb85c1 MW |
35 | |
36 | ###-------------------------------------------------------------------------- | |
4e236859 MW |
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 | ###-------------------------------------------------------------------------- | |
a6bb85c1 MW |
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): | |
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 | ||
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 | """ | |
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 | ||
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 | """ | |
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 | 132 | class 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 | 162 | class 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 | 181 | class 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. | |
198 | def derive(target, source, substmap): Derive(target, source, substmap).gen() | |
199 | def generate(target, source = None): Generate(target, source).gen() | |
a6bb85c1 MW |
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. | |
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 | ||
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 | ||
4e236859 | 237 | class 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 | ||
255 | class 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 | 262 | class 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 | ||
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 | ||
8a060232 MW |
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 | ||
80ed3c35 | 331 | class 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 | 343 | class 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 | ||
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 = [] | |
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 | ||
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 | ||
a6bb85c1 | 413 | ###----- That's all, folks -------------------------------------------------- |