Thu, 22 Jan 2015 13:21:57 +0100
Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6
michael@0 | 1 | #!/usr/bin/env python |
michael@0 | 2 | # Copyright (c) 2002-2005 ActiveState |
michael@0 | 3 | # See LICENSE.txt for license details. |
michael@0 | 4 | |
michael@0 | 5 | """ |
michael@0 | 6 | which.py dev build script |
michael@0 | 7 | |
michael@0 | 8 | Usage: |
michael@0 | 9 | python build.py [<options>...] [<targets>...] |
michael@0 | 10 | |
michael@0 | 11 | Options: |
michael@0 | 12 | --help, -h Print this help and exit. |
michael@0 | 13 | --targets, -t List all available targets. |
michael@0 | 14 | |
michael@0 | 15 | This is the primary build script for the which.py project. It exists |
michael@0 | 16 | to assist in building, maintaining, and distributing this project. |
michael@0 | 17 | |
michael@0 | 18 | It is intended to have Makefile semantics. I.e. 'python build.py' |
michael@0 | 19 | will build execute the default target, 'python build.py foo' will |
michael@0 | 20 | build target foo, etc. However, there is no intelligent target |
michael@0 | 21 | interdependency tracking (I suppose I could do that with function |
michael@0 | 22 | attributes). |
michael@0 | 23 | """ |
michael@0 | 24 | |
michael@0 | 25 | import os |
michael@0 | 26 | from os.path import basename, dirname, splitext, isfile, isdir, exists, \ |
michael@0 | 27 | join, abspath, normpath |
michael@0 | 28 | import sys |
michael@0 | 29 | import getopt |
michael@0 | 30 | import types |
michael@0 | 31 | import getpass |
michael@0 | 32 | import shutil |
michael@0 | 33 | import glob |
michael@0 | 34 | import logging |
michael@0 | 35 | import re |
michael@0 | 36 | |
michael@0 | 37 | |
michael@0 | 38 | |
michael@0 | 39 | #---- exceptions |
michael@0 | 40 | |
michael@0 | 41 | class Error(Exception): |
michael@0 | 42 | pass |
michael@0 | 43 | |
michael@0 | 44 | |
michael@0 | 45 | |
michael@0 | 46 | #---- globals |
michael@0 | 47 | |
michael@0 | 48 | log = logging.getLogger("build") |
michael@0 | 49 | |
michael@0 | 50 | |
michael@0 | 51 | |
michael@0 | 52 | |
michael@0 | 53 | #---- globals |
michael@0 | 54 | |
michael@0 | 55 | _project_name_ = "which" |
michael@0 | 56 | |
michael@0 | 57 | |
michael@0 | 58 | |
michael@0 | 59 | #---- internal support routines |
michael@0 | 60 | |
michael@0 | 61 | def _get_trentm_com_dir(): |
michael@0 | 62 | """Return the path to the local trentm.com source tree.""" |
michael@0 | 63 | d = normpath(join(dirname(__file__), os.pardir, "trentm.com")) |
michael@0 | 64 | if not isdir(d): |
michael@0 | 65 | raise Error("could not find 'trentm.com' src dir at '%s'" % d) |
michael@0 | 66 | return d |
michael@0 | 67 | |
michael@0 | 68 | def _get_local_bits_dir(): |
michael@0 | 69 | import imp |
michael@0 | 70 | info = imp.find_module("tmconfig", [_get_trentm_com_dir()]) |
michael@0 | 71 | tmconfig = imp.load_module("tmconfig", *info) |
michael@0 | 72 | return tmconfig.bitsDir |
michael@0 | 73 | |
michael@0 | 74 | def _get_project_bits_dir(): |
michael@0 | 75 | d = normpath(join(dirname(__file__), "bits")) |
michael@0 | 76 | return d |
michael@0 | 77 | |
michael@0 | 78 | def _get_project_version(): |
michael@0 | 79 | import imp, os |
michael@0 | 80 | data = imp.find_module(_project_name_, [os.path.dirname(__file__)]) |
michael@0 | 81 | mod = imp.load_module(_project_name_, *data) |
michael@0 | 82 | return mod.__version__ |
michael@0 | 83 | |
michael@0 | 84 | |
michael@0 | 85 | # Recipe: run (0.5.1) in /Users/trentm/tm/recipes/cookbook |
michael@0 | 86 | _RUN_DEFAULT_LOGSTREAM = ("RUN", "DEFAULT", "LOGSTREAM") |
michael@0 | 87 | def __run_log(logstream, msg, *args, **kwargs): |
michael@0 | 88 | if not logstream: |
michael@0 | 89 | pass |
michael@0 | 90 | elif logstream is _RUN_DEFAULT_LOGSTREAM: |
michael@0 | 91 | try: |
michael@0 | 92 | log.debug(msg, *args, **kwargs) |
michael@0 | 93 | except NameError: |
michael@0 | 94 | pass |
michael@0 | 95 | else: |
michael@0 | 96 | logstream(msg, *args, **kwargs) |
michael@0 | 97 | |
michael@0 | 98 | def _run(cmd, logstream=_RUN_DEFAULT_LOGSTREAM): |
michael@0 | 99 | """Run the given command. |
michael@0 | 100 | |
michael@0 | 101 | "cmd" is the command to run |
michael@0 | 102 | "logstream" is an optional logging stream on which to log the command. |
michael@0 | 103 | If None, no logging is done. If unspecifed, this looks for a Logger |
michael@0 | 104 | instance named 'log' and logs the command on log.debug(). |
michael@0 | 105 | |
michael@0 | 106 | Raises OSError is the command returns a non-zero exit status. |
michael@0 | 107 | """ |
michael@0 | 108 | __run_log(logstream, "running '%s'", cmd) |
michael@0 | 109 | retval = os.system(cmd) |
michael@0 | 110 | if hasattr(os, "WEXITSTATUS"): |
michael@0 | 111 | status = os.WEXITSTATUS(retval) |
michael@0 | 112 | else: |
michael@0 | 113 | status = retval |
michael@0 | 114 | if status: |
michael@0 | 115 | #TODO: add std OSError attributes or pick more approp. exception |
michael@0 | 116 | raise OSError("error running '%s': %r" % (cmd, status)) |
michael@0 | 117 | |
michael@0 | 118 | def _run_in_dir(cmd, cwd, logstream=_RUN_DEFAULT_LOGSTREAM): |
michael@0 | 119 | old_dir = os.getcwd() |
michael@0 | 120 | try: |
michael@0 | 121 | os.chdir(cwd) |
michael@0 | 122 | __run_log(logstream, "running '%s' in '%s'", cmd, cwd) |
michael@0 | 123 | _run(cmd, logstream=None) |
michael@0 | 124 | finally: |
michael@0 | 125 | os.chdir(old_dir) |
michael@0 | 126 | |
michael@0 | 127 | |
michael@0 | 128 | # Recipe: rmtree (0.5) in /Users/trentm/tm/recipes/cookbook |
michael@0 | 129 | def _rmtree_OnError(rmFunction, filePath, excInfo): |
michael@0 | 130 | if excInfo[0] == OSError: |
michael@0 | 131 | # presuming because file is read-only |
michael@0 | 132 | os.chmod(filePath, 0777) |
michael@0 | 133 | rmFunction(filePath) |
michael@0 | 134 | def _rmtree(dirname): |
michael@0 | 135 | import shutil |
michael@0 | 136 | shutil.rmtree(dirname, 0, _rmtree_OnError) |
michael@0 | 137 | |
michael@0 | 138 | |
michael@0 | 139 | # Recipe: pretty_logging (0.1) in /Users/trentm/tm/recipes/cookbook |
michael@0 | 140 | class _PerLevelFormatter(logging.Formatter): |
michael@0 | 141 | """Allow multiple format string -- depending on the log level. |
michael@0 | 142 | |
michael@0 | 143 | A "fmtFromLevel" optional arg is added to the constructor. It can be |
michael@0 | 144 | a dictionary mapping a log record level to a format string. The |
michael@0 | 145 | usual "fmt" argument acts as the default. |
michael@0 | 146 | """ |
michael@0 | 147 | def __init__(self, fmt=None, datefmt=None, fmtFromLevel=None): |
michael@0 | 148 | logging.Formatter.__init__(self, fmt, datefmt) |
michael@0 | 149 | if fmtFromLevel is None: |
michael@0 | 150 | self.fmtFromLevel = {} |
michael@0 | 151 | else: |
michael@0 | 152 | self.fmtFromLevel = fmtFromLevel |
michael@0 | 153 | def format(self, record): |
michael@0 | 154 | record.levelname = record.levelname.lower() |
michael@0 | 155 | if record.levelno in self.fmtFromLevel: |
michael@0 | 156 | #XXX This is a non-threadsafe HACK. Really the base Formatter |
michael@0 | 157 | # class should provide a hook accessor for the _fmt |
michael@0 | 158 | # attribute. *Could* add a lock guard here (overkill?). |
michael@0 | 159 | _saved_fmt = self._fmt |
michael@0 | 160 | self._fmt = self.fmtFromLevel[record.levelno] |
michael@0 | 161 | try: |
michael@0 | 162 | return logging.Formatter.format(self, record) |
michael@0 | 163 | finally: |
michael@0 | 164 | self._fmt = _saved_fmt |
michael@0 | 165 | else: |
michael@0 | 166 | return logging.Formatter.format(self, record) |
michael@0 | 167 | |
michael@0 | 168 | def _setup_logging(): |
michael@0 | 169 | hdlr = logging.StreamHandler() |
michael@0 | 170 | defaultFmt = "%(name)s: %(levelname)s: %(message)s" |
michael@0 | 171 | infoFmt = "%(name)s: %(message)s" |
michael@0 | 172 | fmtr = _PerLevelFormatter(fmt=defaultFmt, |
michael@0 | 173 | fmtFromLevel={logging.INFO: infoFmt}) |
michael@0 | 174 | hdlr.setFormatter(fmtr) |
michael@0 | 175 | logging.root.addHandler(hdlr) |
michael@0 | 176 | log.setLevel(logging.INFO) |
michael@0 | 177 | |
michael@0 | 178 | |
michael@0 | 179 | def _getTargets(): |
michael@0 | 180 | """Find all targets and return a dict of targetName:targetFunc items.""" |
michael@0 | 181 | targets = {} |
michael@0 | 182 | for name, attr in sys.modules[__name__].__dict__.items(): |
michael@0 | 183 | if name.startswith('target_'): |
michael@0 | 184 | targets[ name[len('target_'):] ] = attr |
michael@0 | 185 | return targets |
michael@0 | 186 | |
michael@0 | 187 | def _listTargets(targets): |
michael@0 | 188 | """Pretty print a list of targets.""" |
michael@0 | 189 | width = 77 |
michael@0 | 190 | nameWidth = 15 # min width |
michael@0 | 191 | for name in targets.keys(): |
michael@0 | 192 | nameWidth = max(nameWidth, len(name)) |
michael@0 | 193 | nameWidth += 2 # space btwn name and doc |
michael@0 | 194 | format = "%%-%ds%%s" % nameWidth |
michael@0 | 195 | print format % ("TARGET", "DESCRIPTION") |
michael@0 | 196 | for name, func in sorted(targets.items()): |
michael@0 | 197 | doc = _first_paragraph(func.__doc__ or "", True) |
michael@0 | 198 | if len(doc) > (width - nameWidth): |
michael@0 | 199 | doc = doc[:(width-nameWidth-3)] + "..." |
michael@0 | 200 | print format % (name, doc) |
michael@0 | 201 | |
michael@0 | 202 | |
michael@0 | 203 | # Recipe: first_paragraph (1.0.1) in /Users/trentm/tm/recipes/cookbook |
michael@0 | 204 | def _first_paragraph(text, join_lines=False): |
michael@0 | 205 | """Return the first paragraph of the given text.""" |
michael@0 | 206 | para = text.lstrip().split('\n\n', 1)[0] |
michael@0 | 207 | if join_lines: |
michael@0 | 208 | lines = [line.strip() for line in para.splitlines(0)] |
michael@0 | 209 | para = ' '.join(lines) |
michael@0 | 210 | return para |
michael@0 | 211 | |
michael@0 | 212 | |
michael@0 | 213 | |
michael@0 | 214 | #---- build targets |
michael@0 | 215 | |
michael@0 | 216 | def target_default(): |
michael@0 | 217 | target_all() |
michael@0 | 218 | |
michael@0 | 219 | def target_all(): |
michael@0 | 220 | """Build all release packages.""" |
michael@0 | 221 | log.info("target: default") |
michael@0 | 222 | if sys.platform == "win32": |
michael@0 | 223 | target_launcher() |
michael@0 | 224 | target_sdist() |
michael@0 | 225 | target_webdist() |
michael@0 | 226 | |
michael@0 | 227 | |
michael@0 | 228 | def target_clean(): |
michael@0 | 229 | """remove all build/generated bits""" |
michael@0 | 230 | log.info("target: clean") |
michael@0 | 231 | if sys.platform == "win32": |
michael@0 | 232 | _run("nmake -f Makefile.win clean") |
michael@0 | 233 | |
michael@0 | 234 | ver = _get_project_version() |
michael@0 | 235 | dirs = ["dist", "build", "%s-%s" % (_project_name_, ver)] |
michael@0 | 236 | for d in dirs: |
michael@0 | 237 | print "removing '%s'" % d |
michael@0 | 238 | if os.path.isdir(d): _rmtree(d) |
michael@0 | 239 | |
michael@0 | 240 | patterns = ["*.pyc", "*~", "MANIFEST", |
michael@0 | 241 | os.path.join("test", "*~"), |
michael@0 | 242 | os.path.join("test", "*.pyc"), |
michael@0 | 243 | ] |
michael@0 | 244 | for pattern in patterns: |
michael@0 | 245 | for file in glob.glob(pattern): |
michael@0 | 246 | print "removing '%s'" % file |
michael@0 | 247 | os.unlink(file) |
michael@0 | 248 | |
michael@0 | 249 | |
michael@0 | 250 | def target_launcher(): |
michael@0 | 251 | """Build the Windows launcher executable.""" |
michael@0 | 252 | log.info("target: launcher") |
michael@0 | 253 | assert sys.platform == "win32", "'launcher' target only supported on Windows" |
michael@0 | 254 | _run("nmake -f Makefile.win") |
michael@0 | 255 | |
michael@0 | 256 | |
michael@0 | 257 | def target_docs(): |
michael@0 | 258 | """Regenerate some doc bits from project-info.xml.""" |
michael@0 | 259 | log.info("target: docs") |
michael@0 | 260 | _run("projinfo -f project-info.xml -R -o README.txt --force") |
michael@0 | 261 | _run("projinfo -f project-info.xml --index-markdown -o index.markdown --force") |
michael@0 | 262 | |
michael@0 | 263 | |
michael@0 | 264 | def target_sdist(): |
michael@0 | 265 | """Build a source distribution.""" |
michael@0 | 266 | log.info("target: sdist") |
michael@0 | 267 | target_docs() |
michael@0 | 268 | bitsDir = _get_project_bits_dir() |
michael@0 | 269 | _run("python setup.py sdist -f --formats zip -d %s" % bitsDir, |
michael@0 | 270 | log.info) |
michael@0 | 271 | |
michael@0 | 272 | |
michael@0 | 273 | def target_webdist(): |
michael@0 | 274 | """Build a web dist package. |
michael@0 | 275 | |
michael@0 | 276 | "Web dist" packages are zip files with '.web' package. All files in |
michael@0 | 277 | the zip must be under a dir named after the project. There must be a |
michael@0 | 278 | webinfo.xml file at <projname>/webinfo.xml. This file is "defined" |
michael@0 | 279 | by the parsing in trentm.com/build.py. |
michael@0 | 280 | """ |
michael@0 | 281 | assert sys.platform != "win32", "'webdist' not implemented for win32" |
michael@0 | 282 | log.info("target: webdist") |
michael@0 | 283 | bitsDir = _get_project_bits_dir() |
michael@0 | 284 | buildDir = join("build", "webdist") |
michael@0 | 285 | distDir = join(buildDir, _project_name_) |
michael@0 | 286 | if exists(buildDir): |
michael@0 | 287 | _rmtree(buildDir) |
michael@0 | 288 | os.makedirs(distDir) |
michael@0 | 289 | |
michael@0 | 290 | target_docs() |
michael@0 | 291 | |
michael@0 | 292 | # Copy the webdist bits to the build tree. |
michael@0 | 293 | manifest = [ |
michael@0 | 294 | "project-info.xml", |
michael@0 | 295 | "index.markdown", |
michael@0 | 296 | "LICENSE.txt", |
michael@0 | 297 | "which.py", |
michael@0 | 298 | "logo.jpg", |
michael@0 | 299 | ] |
michael@0 | 300 | for src in manifest: |
michael@0 | 301 | if dirname(src): |
michael@0 | 302 | dst = join(distDir, dirname(src)) |
michael@0 | 303 | os.makedirs(dst) |
michael@0 | 304 | else: |
michael@0 | 305 | dst = distDir |
michael@0 | 306 | _run("cp %s %s" % (src, dst)) |
michael@0 | 307 | |
michael@0 | 308 | # Zip up the webdist contents. |
michael@0 | 309 | ver = _get_project_version() |
michael@0 | 310 | bit = abspath(join(bitsDir, "%s-%s.web" % (_project_name_, ver))) |
michael@0 | 311 | if exists(bit): |
michael@0 | 312 | os.remove(bit) |
michael@0 | 313 | _run_in_dir("zip -r %s %s" % (bit, _project_name_), buildDir, log.info) |
michael@0 | 314 | |
michael@0 | 315 | |
michael@0 | 316 | def target_install(): |
michael@0 | 317 | """Use the setup.py script to install.""" |
michael@0 | 318 | log.info("target: install") |
michael@0 | 319 | _run("python setup.py install") |
michael@0 | 320 | |
michael@0 | 321 | |
michael@0 | 322 | def target_upload_local(): |
michael@0 | 323 | """Update release bits to *local* trentm.com bits-dir location. |
michael@0 | 324 | |
michael@0 | 325 | This is different from the "upload" target, which uploads release |
michael@0 | 326 | bits remotely to trentm.com. |
michael@0 | 327 | """ |
michael@0 | 328 | log.info("target: upload_local") |
michael@0 | 329 | assert sys.platform != "win32", "'upload_local' not implemented for win32" |
michael@0 | 330 | |
michael@0 | 331 | ver = _get_project_version() |
michael@0 | 332 | localBitsDir = _get_local_bits_dir() |
michael@0 | 333 | uploadDir = join(localBitsDir, _project_name_, ver) |
michael@0 | 334 | |
michael@0 | 335 | bitsPattern = join(_get_project_bits_dir(), |
michael@0 | 336 | "%s-*%s*" % (_project_name_, ver)) |
michael@0 | 337 | bits = glob.glob(bitsPattern) |
michael@0 | 338 | if not bits: |
michael@0 | 339 | log.info("no bits matching '%s' to upload", bitsPattern) |
michael@0 | 340 | else: |
michael@0 | 341 | if not exists(uploadDir): |
michael@0 | 342 | os.makedirs(uploadDir) |
michael@0 | 343 | for bit in bits: |
michael@0 | 344 | _run("cp %s %s" % (bit, uploadDir), log.info) |
michael@0 | 345 | |
michael@0 | 346 | |
michael@0 | 347 | def target_upload(): |
michael@0 | 348 | """Upload binary and source distribution to trentm.com bits |
michael@0 | 349 | directory. |
michael@0 | 350 | """ |
michael@0 | 351 | log.info("target: upload") |
michael@0 | 352 | |
michael@0 | 353 | ver = _get_project_version() |
michael@0 | 354 | bitsDir = _get_project_bits_dir() |
michael@0 | 355 | bitsPattern = join(bitsDir, "%s-*%s*" % (_project_name_, ver)) |
michael@0 | 356 | bits = glob.glob(bitsPattern) |
michael@0 | 357 | if not bits: |
michael@0 | 358 | log.info("no bits matching '%s' to upload", bitsPattern) |
michael@0 | 359 | return |
michael@0 | 360 | |
michael@0 | 361 | # Ensure have all the expected bits. |
michael@0 | 362 | expectedBits = [ |
michael@0 | 363 | re.compile("%s-.*\.zip$" % _project_name_), |
michael@0 | 364 | re.compile("%s-.*\.web$" % _project_name_) |
michael@0 | 365 | ] |
michael@0 | 366 | for expectedBit in expectedBits: |
michael@0 | 367 | for bit in bits: |
michael@0 | 368 | if expectedBit.search(bit): |
michael@0 | 369 | break |
michael@0 | 370 | else: |
michael@0 | 371 | raise Error("can't find expected bit matching '%s' in '%s' dir" |
michael@0 | 372 | % (expectedBit.pattern, bitsDir)) |
michael@0 | 373 | |
michael@0 | 374 | # Upload the bits. |
michael@0 | 375 | user = "trentm" |
michael@0 | 376 | host = "trentm.com" |
michael@0 | 377 | remoteBitsBaseDir = "~/data/bits" |
michael@0 | 378 | remoteBitsDir = join(remoteBitsBaseDir, _project_name_, ver) |
michael@0 | 379 | if sys.platform == "win32": |
michael@0 | 380 | ssh = "plink" |
michael@0 | 381 | scp = "pscp -unsafe" |
michael@0 | 382 | else: |
michael@0 | 383 | ssh = "ssh" |
michael@0 | 384 | scp = "scp" |
michael@0 | 385 | _run("%s %s@%s 'mkdir -p %s'" % (ssh, user, host, remoteBitsDir), log.info) |
michael@0 | 386 | for bit in bits: |
michael@0 | 387 | _run("%s %s %s@%s:%s" % (scp, bit, user, host, remoteBitsDir), |
michael@0 | 388 | log.info) |
michael@0 | 389 | |
michael@0 | 390 | |
michael@0 | 391 | def target_check_version(): |
michael@0 | 392 | """grep for version strings in source code |
michael@0 | 393 | |
michael@0 | 394 | List all things that look like version strings in the source code. |
michael@0 | 395 | Used for checking that versioning is updated across the board. |
michael@0 | 396 | """ |
michael@0 | 397 | sources = [ |
michael@0 | 398 | "which.py", |
michael@0 | 399 | "project-info.xml", |
michael@0 | 400 | ] |
michael@0 | 401 | pattern = r'[0-9]\+\(\.\|, \)[0-9]\+\(\.\|, \)[0-9]\+' |
michael@0 | 402 | _run('grep -n "%s" %s' % (pattern, ' '.join(sources)), None) |
michael@0 | 403 | |
michael@0 | 404 | |
michael@0 | 405 | |
michael@0 | 406 | #---- mainline |
michael@0 | 407 | |
michael@0 | 408 | def build(targets=[]): |
michael@0 | 409 | log.debug("build(targets=%r)" % targets) |
michael@0 | 410 | available = _getTargets() |
michael@0 | 411 | if not targets: |
michael@0 | 412 | if available.has_key('default'): |
michael@0 | 413 | return available['default']() |
michael@0 | 414 | else: |
michael@0 | 415 | log.warn("No default target available. Doing nothing.") |
michael@0 | 416 | else: |
michael@0 | 417 | for target in targets: |
michael@0 | 418 | if available.has_key(target): |
michael@0 | 419 | retval = available[target]() |
michael@0 | 420 | if retval: |
michael@0 | 421 | raise Error("Error running '%s' target: retval=%s"\ |
michael@0 | 422 | % (target, retval)) |
michael@0 | 423 | else: |
michael@0 | 424 | raise Error("Unknown target: '%s'" % target) |
michael@0 | 425 | |
michael@0 | 426 | def main(argv): |
michael@0 | 427 | _setup_logging() |
michael@0 | 428 | |
michael@0 | 429 | # Process options. |
michael@0 | 430 | optlist, targets = getopt.getopt(argv[1:], 'ht', ['help', 'targets']) |
michael@0 | 431 | for opt, optarg in optlist: |
michael@0 | 432 | if opt in ('-h', '--help'): |
michael@0 | 433 | sys.stdout.write(__doc__ + '\n') |
michael@0 | 434 | return 0 |
michael@0 | 435 | elif opt in ('-t', '--targets'): |
michael@0 | 436 | return _listTargets(_getTargets()) |
michael@0 | 437 | |
michael@0 | 438 | return build(targets) |
michael@0 | 439 | |
michael@0 | 440 | if __name__ == "__main__": |
michael@0 | 441 | sys.exit( main(sys.argv) ) |
michael@0 | 442 |