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