X-Git-Url: https://git.distorted.org.uk/~mdw/runlisp/blobdiff_plain/a6bb85c11b83e759dd74a58a1003fb16ab151d70..8a060232a9ca770d898eb0dc9cf4b4e174bf3f8a:/mdwsetup.py diff --git a/mdwsetup.py b/mdwsetup.py index 9ce67e6..55120fb 100644 --- a/mdwsetup.py +++ b/mdwsetup.py @@ -7,28 +7,38 @@ ###----- Licensing notice --------------------------------------------------- ### -### This file is part of the Python interface to mLib. +### This file is part of the Common Files Distribution (`common') ### -### mLib/Python is free software; you can redistribute it and/or modify +### `Common' is free software; you can redistribute it and/or modify ### it under the terms of the GNU General Public License as published by ### the Free Software Foundation; either version 2 of the License, or ### (at your option) any later version. ### -### mLib/Python is distributed in the hope that it will be useful, +### `Common' is distributed in the hope that it will be useful, ### but WITHOUT ANY WARRANTY; without even the implied warranty of ### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ### GNU General Public License for more details. ### ### You should have received a copy of the GNU General Public License -### along with mLib/Python; if not, write to the Free Software Foundation, +### along with `common'; if not, write to the Free Software Foundation, ### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +from __future__ import with_statement + import sys as SYS import os as OS import re as RE import subprocess as SUB import distutils.core as DC +import distutils.log as DL + +###-------------------------------------------------------------------------- +### Compatibility hacks. + +def with_metaclass(meta, *supers): + return meta("#" % meta.__name__, + supers or (object,), dict()) ###-------------------------------------------------------------------------- ### Random utilities. @@ -56,10 +66,10 @@ class SubprocessFailure (Exception): me.file = file me.rc = rc def __str__(me): - if WIFEXITED(me.rc): - return '%s failed (rc = %d)' % (me.file, WEXITSTATUS(me.rc)) - elif WIFSIGNALED(me.rc): - return '%s died (signal %d)' % (me.file, WTERMSIG(me.rc)) + if OS.WIFEXITED(me.rc): + return '%s failed (rc = %d)' % (me.file, OS.WEXITSTATUS(me.rc)) + elif OS.WIFSIGNALED(me.rc): + return '%s died (signal %d)' % (me.file, OS.WTERMSIG(me.rc)) else: return '%s died inexplicably' % (me.file) @@ -70,15 +80,16 @@ def progoutput(command): The COMMAND must produce exactly one line of output, and must exit with status zero. """ - kid = SUB.Popen(command, stdout = SUB.PIPE) - out = kid.stdout.readline() - junk = kid.stdout.read() - if junk != '': - raise ValueError, \ - "Child process `%s' produced unspected output %r" % (command, junk) + kid = SUB.Popen(command, stdout = SUB.PIPE, universal_newlines = True) + try: + out = kid.stdout.readline() + junk = kid.stdout.read(1) + finally: + kid.stdout.close() + if junk != '': raise ValueError \ + ("Child process `%s' produced unspected output %r" % (command, junk)) rc = kid.wait() - if rc != 0: - raise SubprocessFailure, (command, rc) + if rc != 0: raise SubprocessFailure(command, rc) return out.rstrip('\n') ###-------------------------------------------------------------------------- @@ -96,85 +107,96 @@ def pkg_config(pkg, version): library-directory names are in LIBDIRS; and the library names themselves are in LIBS. """ - spec = '%s >= %s' % (pkg, version) + def weird(what, word): - raise ValueError, \ - "Unexpected `%s' item `%s' from package `%s'" % (what, word, pkg) - for word in progoutput(['pkg-config', '--cflags', spec]).split(): - if word.startswith('-I'): - INCLUDEDIRS.append(word[2:]) - else: - weird('--cflags', word) - for word in progoutput(['pkg-config', '--libs', spec]).split(): - if word.startswith('-L'): - LIBDIRS.append(word[2:]) - elif word.startswith('-l'): - LIBS.append(word[2:]) - else: - weird('--libs', word) + raise ValueError \ + ("Unexpected `%s' item `%s' from package `%s'" % (what, word, pkg)) + + spec = '%s >= %s' % (pkg, version) + + try: cflags = OS.environ["%s_CFLAGS" % pkg] + except KeyError: cflags = progoutput(['pkg-config', '--cflags', spec]) + for word in cflags.split(): + if word.startswith('-I'): INCLUDEDIRS.append(word[2:]) + else: weird('CFLAGS', word) + try: libs = OS.environ["%s_LIBS" % pkg] + except KeyError: libs = progoutput(['pkg-config', '--libs', spec]) + for word in libs.split(): + if word.startswith('-L'): LIBDIRS.append(word[2:]) + elif word.startswith('-l'): LIBS.append(word[2:]) + else: weird('LIBS', word) ###-------------------------------------------------------------------------- ### Substituting variables in files. -def needs_update_p(target, sources): +class BaseGenFile (object): """ - Returns whether TARGET is out of date relative to its SOURCES. + A base class for file generators. + + Instances of subclasses are suitable for listing in the `genfiles' + attribute, passed to `setup'. - If TARGET exists and was modified more recentently than any of its SOURCES - then it doesn't need updating. + Subclasses need to implement `_gen', which should simply do the work of + generating the target file from its sources. This class will print + progress messages and check whether the target actually needs regenerating. """ - if not OS.path.exists(target): - return True - t_target = OS.stat(target).st_mtime - for source in sources: - if OS.stat(source).st_mtime >= t_target: - return True - return False + def __init__(me, target, sources = []): + me.target = target + me.sources = sources + def _needs_update_p(me): + if not OS.path.exists(me.target): return True + t_target = OS.stat(me.target).st_mtime + for s in me.sources: + if OS.stat(s).st_mtime >= t_target: return True + return False + def gen(me, dry_run_p = False): + if not me._needs_update_p(): return + DL.log(DL.INFO, "generate `%s' from %s", me.target, + ', '.join("`%s'" % s for s in me.sources)) + if not dry_run_p: me._gen() + def clean(me, dry_run_p): + if not OS.path.exists(me.target): return + DL.log(DL.INFO, "delete `%s'", me.target) + if not dry_run_p: OS.remove(me.target) -RX_SUBST = RE.compile(r'\%(\w+)\%') -def derive(target, source, substmap): +class Derive (BaseGenFile): """ Derive TARGET from SOURCE by making simple substitutions. The SOURCE may contain markers %FOO%; these are replaced by SUBSTMAP['FOO'] in the TARGET file. """ - if not needs_update_p(target, [source]): - return False - print "making `%s' from `%s'" % (target, source) - temp = target + '.new' - ft = open(temp, 'w') - try: - fs = open(source, 'r') - try: - for line in fs: - ft.write(RX_SUBST.sub((lambda m: substmap[m.group(1)]), line)) - finally: - fs.close() - finally: - ft.close() - OS.rename(temp, target) + RX_SUBST = RE.compile(r'\%(\w+)\%') + def __init__(me, target, source, substmap): + BaseGenFile.__init__(me, target, [source]) + me._map = substmap + def _gen(me): + temp = me.target + '.new' + with open(temp, 'w') as ft: + with open(me.sources[0], 'r') as fs: + for line in fs: + ft.write(me.RX_SUBST.sub((lambda m: me._map[m.group(1)]), line)) + OS.rename(temp, me.target) -def generate(target, source = None): +class Generate (BaseGenFile): """ Generate TARGET by running the SOURCE Python script. If SOURCE is omitted, replace the extension of TARGET by `.py'. """ - if source is None: - source = OS.path.splitext(target)[0] + '.py' - if not needs_update_p(target, [source]): - return - print "making `%s' using `%s'" % (target, source) - temp = target + '.new' - ft = open(temp, 'w') - try: - rc = SUB.call([SYS.executable, source], stdout = ft) - finally: - ft.close() - if rc != 0: - raise SubprocessFailure, (source, rc) - OS.rename(temp, target) + def __init__(me, target, source = None): + if source is None: source = OS.path.splitext(target)[0] + '.py' + BaseGenFile.__init__(me, target, [source]) + def _gen(me): + temp = me.target + '.new' + with open(temp, 'w') as ft: + rc = SUB.call([SYS.executable, me.sources[0]], stdout = ft) + if rc != 0: raise SubprocessFailure(me.sources[0], rc << 8) + OS.rename(temp, me.target) + +## Backward compatibility. +def derive(target, source, substmap): Derive(target, source, substmap).gen() +def generate(target, source = None): Generate(target, source).gen() ###-------------------------------------------------------------------------- ### Discovering version numbers. @@ -185,15 +207,207 @@ def auto_version(writep = True): As a side-effect, if WRITEP is true, then write the version number to the RELEASE file so that it gets included in distributions. + + All of this is for backwards compatibility. New projects should omit the + `version' keyword entirely and let `setup' discover it and write it into + tarballs automatically. """ version = progoutput(['./auto-version']) if writep: - ft = open('RELEASE.new', 'w') - try: - ft.write('%s\n' % version) - finally: - ft.close() + with open('RELEASE.new', 'w') as ft: ft.write('%s\n' % version) OS.rename('RELEASE.new', 'RELEASE') return version +###-------------------------------------------------------------------------- +### Adding new commands. + +CMDS = {} + +class CommandClass (type): + """ + Metaclass for command classes: automatically adds them to the `CMDS' map. + """ + def __new__(cls, name, supers, dict): + c = super(CommandClass, cls).__new__(cls, name, supers, dict) + try: name = c.NAME + except AttributeError: pass + else: CMDS[name] = c + return c + +class Command (with_metaclass(CommandClass, DC.Command, object)): + """ + Base class for `mdwsetup' command classes. + + This provides the automatic registration machinery, via the metaclass, and + also trivial implementations of various responsibilities of `DC.Command' + methods and attributes. + """ + __metaclass__ = CommandClass + user_options = [] + def initialize_options(me): pass + def finalize_options(me): pass + def run_subs(me): + for s in me.get_sub_commands(): me.run_command(s) + +###-------------------------------------------------------------------------- +### Some handy new commands. + +class distdir (Command): + NAME = 'distdir' + description = "print the distribution directory name to stdout" + def run(me): + d = me.distribution + print('%s-%s' % (d.get_name(), d.get_version())) + +class build_gen (Command): + """ + Generate files, according to the `genfiles'. + + The `genfiles' keyword argument to `setup' lists a number of objects which + guide the generation of output files. These objects must implement the + following methods. + + clean(DRY_RUN_P) Remove the output files. + + gen(DRY_RUN_P) Generate the output files, if they don't exist or are + out of date with respect to their prerequisites. + + If DRY_RUN_P is true then the methods must not actually do anything with a + lasting effect, but should print progress messages as usual. + """ + NAME = 'build_gen' + description = "build generated source files" + def run(me): + d = me.distribution + for g in d.genfiles: g.gen(dry_run_p = me.dry_run) + +from distutils.command.build import build as _build +class build (_build, Command): + ## Add `build_gen' early in the list of subcommands. + NAME = 'build' + sub_commands = [('build_gen', lambda me: me.distribution.genfiles)] + sub_commands += _build.sub_commands + +class test (Command): + """ + Run unit tests, according to the `unittests'. + + The `unittests' keyword argument to `setup' lists module names (or other + things acceptable to the `loadTestsFromNames' test-loader method) to be + run. The build library directory is prepended to the load path before + running the tests to ensure that the newly built modules are tested. If + `unittest_dir' is set, then this is appended to the load path so that test + modules can be found there. + """ + NAME = "test" + description = "run the included test suite" + + user_options = \ + [('build-lib=', 'b', "directory containing compiled moules"), + ('tests=', 't', "tests to run"), + ('verbose-test', 'V', "run tests verbosely")] + + def initialize_options(me): + me.build_lib = None + me.verbose_test = False + me.tests = None + def finalize_options(me): + me.set_undefined_options('build', ('build_lib', 'build_lib')) + def run(me): + import unittest as U + d = me.distribution + SYS.path = [me.build_lib] + SYS.path + if d.unittest_dir is not None: SYS.path.append(d.unittest_dir) + if me.tests is not None: tests = me.tests.split(",") + else: tests = d.unittests + suite = U.defaultTestLoader.loadTestsFromNames(tests) + runner = U.TextTestRunner(verbosity = me.verbose_test and 2 or 1) + if me.dry_run: return + result = runner.run(suite) + if result.errors or result.failures or \ + getattr(result, "unexpectedSuccesses", 0): + SYS.exit(2) + +class clean_gen (Command): + """ + Remove the generated files, as listed in `genfiles'. + + See the `build_gen' command for more detailed information. + """ + NAME = 'clean_gen' + description = "clean generated source files" + def run(me): + d = me.distribution + for g in d.genfiles: g.clean(dry_run_p = me.dry_run) + +class clean_others (Command): + """ + Remove the files listed in the `cleanfiles' argument to `setup'. + """ + NAME = 'clean_others' + description = "clean miscellaneous output files" + def run(me): + d = me.distribution + for f in d.cleanfiles: + if not OS.path.exists(f): continue + DL.log(DL.INFO, "delete `%s'", f) + if not me.dry_run: OS.remove(f) + +from distutils.command.clean import clean as _clean +class clean (_clean, Command): + ## Add `clean_gen' and `clean_others' to the list of subcommands. + NAME = 'clean' + sub_commands = [('clean_gen', lambda me: me.distribution.genfiles), + ('clean_others', lambda me: me.distribution.cleanfiles)] + sub_commands += _clean.sub_commands + def run(me): + me.run_subs() + _clean.run(me) + +from distutils.command.sdist import sdist as _sdist +class sdist (_sdist, Command): + ## Write a `RELEASE' file to the output, if we extracted the version number + ## from version control. Also arrange to dereference symbolic links while + ## copying. Symlinks to directories will go horribly wrong, so don't do + ## that. + NAME = 'sdist' + def make_release_tree(me, base_dir, files): + _sdist.make_release_tree(me, base_dir, files) + d = me.distribution + if d._auto_version_p: + v = d.metadata.get_version() + DL.log(DL.INFO, "write `RELEASE' file: %s" % v) + with open(OS.path.join(base_dir, 'RELEASE'), 'w') as f: + f.write('%s\n' % v) + def copy_file(me, infile, outfile, link = None, *args, **kw): + if OS.path.islink(infile): link = None + return _sdist.copy_file(me, infile, outfile, link = link, *args, **kw) + +###-------------------------------------------------------------------------- +### Our own version of `setup'. + +class Dist (DC.Distribution): + ## Like the usual version, but with some additional attributes to support + ## our enhanced commands. + def __init__(me, attrs = None): + me.genfiles = [] + me.unittest_dir = None + me.unittests = [] + me.cleanfiles = [] + me._auto_version_p = False + DC.Distribution.__init__(me, attrs) + if me.metadata.version is None: + me.metadata.version = auto_version(writep = False) + me._auto_version_p = True + me.cleanfiles = set(me.cleanfiles) + me.cleanfiles.add('MANIFEST') + +def setup(cmdclass = {}, distclass = Dist, **kw): + ## Like the usual version, but provides defaults more suited to our + ## purposes. + cmds = dict() + cmds.update(CMDS) + cmds.update(cmdclass) + DC.setup(cmdclass = cmds, distclass = distclass, **kw) + ###----- That's all, folks --------------------------------------------------