michael@0: #!/usr/bin/env python michael@0: # Copyright (c) 2002-2005 ActiveState michael@0: # See LICENSE.txt for license details. michael@0: michael@0: """ michael@0: which.py dev build script michael@0: michael@0: Usage: michael@0: python build.py [...] [...] michael@0: michael@0: Options: michael@0: --help, -h Print this help and exit. michael@0: --targets, -t List all available targets. michael@0: michael@0: This is the primary build script for the which.py project. It exists michael@0: to assist in building, maintaining, and distributing this project. michael@0: michael@0: It is intended to have Makefile semantics. I.e. 'python build.py' michael@0: will build execute the default target, 'python build.py foo' will michael@0: build target foo, etc. However, there is no intelligent target michael@0: interdependency tracking (I suppose I could do that with function michael@0: attributes). michael@0: """ michael@0: michael@0: import os michael@0: from os.path import basename, dirname, splitext, isfile, isdir, exists, \ michael@0: join, abspath, normpath michael@0: import sys michael@0: import getopt michael@0: import types michael@0: import getpass michael@0: import shutil michael@0: import glob michael@0: import logging michael@0: import re michael@0: michael@0: michael@0: michael@0: #---- exceptions michael@0: michael@0: class Error(Exception): michael@0: pass michael@0: michael@0: michael@0: michael@0: #---- globals michael@0: michael@0: log = logging.getLogger("build") michael@0: michael@0: michael@0: michael@0: michael@0: #---- globals michael@0: michael@0: _project_name_ = "which" michael@0: michael@0: michael@0: michael@0: #---- internal support routines michael@0: michael@0: def _get_trentm_com_dir(): michael@0: """Return the path to the local trentm.com source tree.""" michael@0: d = normpath(join(dirname(__file__), os.pardir, "trentm.com")) michael@0: if not isdir(d): michael@0: raise Error("could not find 'trentm.com' src dir at '%s'" % d) michael@0: return d michael@0: michael@0: def _get_local_bits_dir(): michael@0: import imp michael@0: info = imp.find_module("tmconfig", [_get_trentm_com_dir()]) michael@0: tmconfig = imp.load_module("tmconfig", *info) michael@0: return tmconfig.bitsDir michael@0: michael@0: def _get_project_bits_dir(): michael@0: d = normpath(join(dirname(__file__), "bits")) michael@0: return d michael@0: michael@0: def _get_project_version(): michael@0: import imp, os michael@0: data = imp.find_module(_project_name_, [os.path.dirname(__file__)]) michael@0: mod = imp.load_module(_project_name_, *data) michael@0: return mod.__version__ michael@0: michael@0: michael@0: # Recipe: run (0.5.1) in /Users/trentm/tm/recipes/cookbook michael@0: _RUN_DEFAULT_LOGSTREAM = ("RUN", "DEFAULT", "LOGSTREAM") michael@0: def __run_log(logstream, msg, *args, **kwargs): michael@0: if not logstream: michael@0: pass michael@0: elif logstream is _RUN_DEFAULT_LOGSTREAM: michael@0: try: michael@0: log.debug(msg, *args, **kwargs) michael@0: except NameError: michael@0: pass michael@0: else: michael@0: logstream(msg, *args, **kwargs) michael@0: michael@0: def _run(cmd, logstream=_RUN_DEFAULT_LOGSTREAM): michael@0: """Run the given command. michael@0: michael@0: "cmd" is the command to run michael@0: "logstream" is an optional logging stream on which to log the command. michael@0: If None, no logging is done. If unspecifed, this looks for a Logger michael@0: instance named 'log' and logs the command on log.debug(). michael@0: michael@0: Raises OSError is the command returns a non-zero exit status. michael@0: """ michael@0: __run_log(logstream, "running '%s'", cmd) michael@0: retval = os.system(cmd) michael@0: if hasattr(os, "WEXITSTATUS"): michael@0: status = os.WEXITSTATUS(retval) michael@0: else: michael@0: status = retval michael@0: if status: michael@0: #TODO: add std OSError attributes or pick more approp. exception michael@0: raise OSError("error running '%s': %r" % (cmd, status)) michael@0: michael@0: def _run_in_dir(cmd, cwd, logstream=_RUN_DEFAULT_LOGSTREAM): michael@0: old_dir = os.getcwd() michael@0: try: michael@0: os.chdir(cwd) michael@0: __run_log(logstream, "running '%s' in '%s'", cmd, cwd) michael@0: _run(cmd, logstream=None) michael@0: finally: michael@0: os.chdir(old_dir) michael@0: michael@0: michael@0: # Recipe: rmtree (0.5) in /Users/trentm/tm/recipes/cookbook michael@0: def _rmtree_OnError(rmFunction, filePath, excInfo): michael@0: if excInfo[0] == OSError: michael@0: # presuming because file is read-only michael@0: os.chmod(filePath, 0777) michael@0: rmFunction(filePath) michael@0: def _rmtree(dirname): michael@0: import shutil michael@0: shutil.rmtree(dirname, 0, _rmtree_OnError) michael@0: michael@0: michael@0: # Recipe: pretty_logging (0.1) in /Users/trentm/tm/recipes/cookbook michael@0: class _PerLevelFormatter(logging.Formatter): michael@0: """Allow multiple format string -- depending on the log level. michael@0: michael@0: A "fmtFromLevel" optional arg is added to the constructor. It can be michael@0: a dictionary mapping a log record level to a format string. The michael@0: usual "fmt" argument acts as the default. michael@0: """ michael@0: def __init__(self, fmt=None, datefmt=None, fmtFromLevel=None): michael@0: logging.Formatter.__init__(self, fmt, datefmt) michael@0: if fmtFromLevel is None: michael@0: self.fmtFromLevel = {} michael@0: else: michael@0: self.fmtFromLevel = fmtFromLevel michael@0: def format(self, record): michael@0: record.levelname = record.levelname.lower() michael@0: if record.levelno in self.fmtFromLevel: michael@0: #XXX This is a non-threadsafe HACK. Really the base Formatter michael@0: # class should provide a hook accessor for the _fmt michael@0: # attribute. *Could* add a lock guard here (overkill?). michael@0: _saved_fmt = self._fmt michael@0: self._fmt = self.fmtFromLevel[record.levelno] michael@0: try: michael@0: return logging.Formatter.format(self, record) michael@0: finally: michael@0: self._fmt = _saved_fmt michael@0: else: michael@0: return logging.Formatter.format(self, record) michael@0: michael@0: def _setup_logging(): michael@0: hdlr = logging.StreamHandler() michael@0: defaultFmt = "%(name)s: %(levelname)s: %(message)s" michael@0: infoFmt = "%(name)s: %(message)s" michael@0: fmtr = _PerLevelFormatter(fmt=defaultFmt, michael@0: fmtFromLevel={logging.INFO: infoFmt}) michael@0: hdlr.setFormatter(fmtr) michael@0: logging.root.addHandler(hdlr) michael@0: log.setLevel(logging.INFO) michael@0: michael@0: michael@0: def _getTargets(): michael@0: """Find all targets and return a dict of targetName:targetFunc items.""" michael@0: targets = {} michael@0: for name, attr in sys.modules[__name__].__dict__.items(): michael@0: if name.startswith('target_'): michael@0: targets[ name[len('target_'):] ] = attr michael@0: return targets michael@0: michael@0: def _listTargets(targets): michael@0: """Pretty print a list of targets.""" michael@0: width = 77 michael@0: nameWidth = 15 # min width michael@0: for name in targets.keys(): michael@0: nameWidth = max(nameWidth, len(name)) michael@0: nameWidth += 2 # space btwn name and doc michael@0: format = "%%-%ds%%s" % nameWidth michael@0: print format % ("TARGET", "DESCRIPTION") michael@0: for name, func in sorted(targets.items()): michael@0: doc = _first_paragraph(func.__doc__ or "", True) michael@0: if len(doc) > (width - nameWidth): michael@0: doc = doc[:(width-nameWidth-3)] + "..." michael@0: print format % (name, doc) michael@0: michael@0: michael@0: # Recipe: first_paragraph (1.0.1) in /Users/trentm/tm/recipes/cookbook michael@0: def _first_paragraph(text, join_lines=False): michael@0: """Return the first paragraph of the given text.""" michael@0: para = text.lstrip().split('\n\n', 1)[0] michael@0: if join_lines: michael@0: lines = [line.strip() for line in para.splitlines(0)] michael@0: para = ' '.join(lines) michael@0: return para michael@0: michael@0: michael@0: michael@0: #---- build targets michael@0: michael@0: def target_default(): michael@0: target_all() michael@0: michael@0: def target_all(): michael@0: """Build all release packages.""" michael@0: log.info("target: default") michael@0: if sys.platform == "win32": michael@0: target_launcher() michael@0: target_sdist() michael@0: target_webdist() michael@0: michael@0: michael@0: def target_clean(): michael@0: """remove all build/generated bits""" michael@0: log.info("target: clean") michael@0: if sys.platform == "win32": michael@0: _run("nmake -f Makefile.win clean") michael@0: michael@0: ver = _get_project_version() michael@0: dirs = ["dist", "build", "%s-%s" % (_project_name_, ver)] michael@0: for d in dirs: michael@0: print "removing '%s'" % d michael@0: if os.path.isdir(d): _rmtree(d) michael@0: michael@0: patterns = ["*.pyc", "*~", "MANIFEST", michael@0: os.path.join("test", "*~"), michael@0: os.path.join("test", "*.pyc"), michael@0: ] michael@0: for pattern in patterns: michael@0: for file in glob.glob(pattern): michael@0: print "removing '%s'" % file michael@0: os.unlink(file) michael@0: michael@0: michael@0: def target_launcher(): michael@0: """Build the Windows launcher executable.""" michael@0: log.info("target: launcher") michael@0: assert sys.platform == "win32", "'launcher' target only supported on Windows" michael@0: _run("nmake -f Makefile.win") michael@0: michael@0: michael@0: def target_docs(): michael@0: """Regenerate some doc bits from project-info.xml.""" michael@0: log.info("target: docs") michael@0: _run("projinfo -f project-info.xml -R -o README.txt --force") michael@0: _run("projinfo -f project-info.xml --index-markdown -o index.markdown --force") michael@0: michael@0: michael@0: def target_sdist(): michael@0: """Build a source distribution.""" michael@0: log.info("target: sdist") michael@0: target_docs() michael@0: bitsDir = _get_project_bits_dir() michael@0: _run("python setup.py sdist -f --formats zip -d %s" % bitsDir, michael@0: log.info) michael@0: michael@0: michael@0: def target_webdist(): michael@0: """Build a web dist package. michael@0: michael@0: "Web dist" packages are zip files with '.web' package. All files in michael@0: the zip must be under a dir named after the project. There must be a michael@0: webinfo.xml file at /webinfo.xml. This file is "defined" michael@0: by the parsing in trentm.com/build.py. michael@0: """ michael@0: assert sys.platform != "win32", "'webdist' not implemented for win32" michael@0: log.info("target: webdist") michael@0: bitsDir = _get_project_bits_dir() michael@0: buildDir = join("build", "webdist") michael@0: distDir = join(buildDir, _project_name_) michael@0: if exists(buildDir): michael@0: _rmtree(buildDir) michael@0: os.makedirs(distDir) michael@0: michael@0: target_docs() michael@0: michael@0: # Copy the webdist bits to the build tree. michael@0: manifest = [ michael@0: "project-info.xml", michael@0: "index.markdown", michael@0: "LICENSE.txt", michael@0: "which.py", michael@0: "logo.jpg", michael@0: ] michael@0: for src in manifest: michael@0: if dirname(src): michael@0: dst = join(distDir, dirname(src)) michael@0: os.makedirs(dst) michael@0: else: michael@0: dst = distDir michael@0: _run("cp %s %s" % (src, dst)) michael@0: michael@0: # Zip up the webdist contents. michael@0: ver = _get_project_version() michael@0: bit = abspath(join(bitsDir, "%s-%s.web" % (_project_name_, ver))) michael@0: if exists(bit): michael@0: os.remove(bit) michael@0: _run_in_dir("zip -r %s %s" % (bit, _project_name_), buildDir, log.info) michael@0: michael@0: michael@0: def target_install(): michael@0: """Use the setup.py script to install.""" michael@0: log.info("target: install") michael@0: _run("python setup.py install") michael@0: michael@0: michael@0: def target_upload_local(): michael@0: """Update release bits to *local* trentm.com bits-dir location. michael@0: michael@0: This is different from the "upload" target, which uploads release michael@0: bits remotely to trentm.com. michael@0: """ michael@0: log.info("target: upload_local") michael@0: assert sys.platform != "win32", "'upload_local' not implemented for win32" michael@0: michael@0: ver = _get_project_version() michael@0: localBitsDir = _get_local_bits_dir() michael@0: uploadDir = join(localBitsDir, _project_name_, ver) michael@0: michael@0: bitsPattern = join(_get_project_bits_dir(), michael@0: "%s-*%s*" % (_project_name_, ver)) michael@0: bits = glob.glob(bitsPattern) michael@0: if not bits: michael@0: log.info("no bits matching '%s' to upload", bitsPattern) michael@0: else: michael@0: if not exists(uploadDir): michael@0: os.makedirs(uploadDir) michael@0: for bit in bits: michael@0: _run("cp %s %s" % (bit, uploadDir), log.info) michael@0: michael@0: michael@0: def target_upload(): michael@0: """Upload binary and source distribution to trentm.com bits michael@0: directory. michael@0: """ michael@0: log.info("target: upload") michael@0: michael@0: ver = _get_project_version() michael@0: bitsDir = _get_project_bits_dir() michael@0: bitsPattern = join(bitsDir, "%s-*%s*" % (_project_name_, ver)) michael@0: bits = glob.glob(bitsPattern) michael@0: if not bits: michael@0: log.info("no bits matching '%s' to upload", bitsPattern) michael@0: return michael@0: michael@0: # Ensure have all the expected bits. michael@0: expectedBits = [ michael@0: re.compile("%s-.*\.zip$" % _project_name_), michael@0: re.compile("%s-.*\.web$" % _project_name_) michael@0: ] michael@0: for expectedBit in expectedBits: michael@0: for bit in bits: michael@0: if expectedBit.search(bit): michael@0: break michael@0: else: michael@0: raise Error("can't find expected bit matching '%s' in '%s' dir" michael@0: % (expectedBit.pattern, bitsDir)) michael@0: michael@0: # Upload the bits. michael@0: user = "trentm" michael@0: host = "trentm.com" michael@0: remoteBitsBaseDir = "~/data/bits" michael@0: remoteBitsDir = join(remoteBitsBaseDir, _project_name_, ver) michael@0: if sys.platform == "win32": michael@0: ssh = "plink" michael@0: scp = "pscp -unsafe" michael@0: else: michael@0: ssh = "ssh" michael@0: scp = "scp" michael@0: _run("%s %s@%s 'mkdir -p %s'" % (ssh, user, host, remoteBitsDir), log.info) michael@0: for bit in bits: michael@0: _run("%s %s %s@%s:%s" % (scp, bit, user, host, remoteBitsDir), michael@0: log.info) michael@0: michael@0: michael@0: def target_check_version(): michael@0: """grep for version strings in source code michael@0: michael@0: List all things that look like version strings in the source code. michael@0: Used for checking that versioning is updated across the board. michael@0: """ michael@0: sources = [ michael@0: "which.py", michael@0: "project-info.xml", michael@0: ] michael@0: pattern = r'[0-9]\+\(\.\|, \)[0-9]\+\(\.\|, \)[0-9]\+' michael@0: _run('grep -n "%s" %s' % (pattern, ' '.join(sources)), None) michael@0: michael@0: michael@0: michael@0: #---- mainline michael@0: michael@0: def build(targets=[]): michael@0: log.debug("build(targets=%r)" % targets) michael@0: available = _getTargets() michael@0: if not targets: michael@0: if available.has_key('default'): michael@0: return available['default']() michael@0: else: michael@0: log.warn("No default target available. Doing nothing.") michael@0: else: michael@0: for target in targets: michael@0: if available.has_key(target): michael@0: retval = available[target]() michael@0: if retval: michael@0: raise Error("Error running '%s' target: retval=%s"\ michael@0: % (target, retval)) michael@0: else: michael@0: raise Error("Unknown target: '%s'" % target) michael@0: michael@0: def main(argv): michael@0: _setup_logging() michael@0: michael@0: # Process options. michael@0: optlist, targets = getopt.getopt(argv[1:], 'ht', ['help', 'targets']) michael@0: for opt, optarg in optlist: michael@0: if opt in ('-h', '--help'): michael@0: sys.stdout.write(__doc__ + '\n') michael@0: return 0 michael@0: elif opt in ('-t', '--targets'): michael@0: return _listTargets(_getTargets()) michael@0: michael@0: return build(targets) michael@0: michael@0: if __name__ == "__main__": michael@0: sys.exit( main(sys.argv) ) michael@0: