From 9c1ae5208ad60afde443a8bb2d1aca2d5f3ea68d Mon Sep 17 00:00:00 2001 From: Trent Mick Date: Tue, 14 Feb 2012 22:58:25 -0800 Subject: [PATCH] add https://github.com/trentm/cutarelease 1.0.6 and switch to using that --- CHANGES.md | 1 + Makefile | 8 + Makefile.py | 205 --------------- tools/cutarelease.py | 597 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 606 insertions(+), 205 deletions(-) create mode 100644 Makefile delete mode 100644 Makefile.py create mode 100755 tools/cutarelease.py diff --git a/CHANGES.md b/CHANGES.md index fbd3d08..8a1133c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,7 @@ ## restdown 1.2.16 (not yet released) +- Move to 'cutarelease.py' separate tool - [issue #9] Fix breakage if there are no TOC sections, i.e. no second h1 in the input restdown content. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..abc9667 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ + +.PHONY: all +all: + +.PHONY: cutarelease +cutarelease: + ./support/cutarelease.py -f bin/restdown + diff --git a/Makefile.py b/Makefile.py deleted file mode 100644 index 5001630..0000000 --- a/Makefile.py +++ /dev/null @@ -1,205 +0,0 @@ -# -*- coding: utf-8 -*- - -import sys -import os -from os.path import join, dirname, normpath, abspath -from glob import glob -import re -import codecs - - -from mklib.common import MkError -from mklib import Task, mk -from mklib import sh - - -class examples(Task): - """render examples/*.restdown""" - def make(self): - pats = ["*.restdown", "*.md", "*.rd"] - for pat in pats: - for path in glob(join(self.dir, "examples", "*", pat)): - os.system("bin/restdown -D mediaroot=media %s" % path) - -class cut_a_release(Task): - """automate the steps for cutting a release""" - proj_name = "restdown" - version_py_path = "bin/restdown" - _changes_parser = re.compile(r'^## restdown (?P[\d\.abc]+)' - r'(?P\s+\(not yet released\))?' - r'(?P.*?)(?=^##|\Z)', re.M | re.S) - - def make(self): - DRY_RUN = False - version_info = self._get_version_info() - version = self._version_from_version_info(version_info) - - # Confirm - if not DRY_RUN: - answer = query_yes_no("* * *\n" - "Are you sure you want cut a %s release?\n" - "This will involved commits and a push." % version, - default="no") - print "* * *" - if answer != "yes": - self.log.info("user abort") - return - self.log.info("cutting a %s release", version) - - # Checks: Ensure there is a section in changes for this version. - changes_path = join(self.dir, "CHANGES.md") - changes_txt = changes_txt_before = codecs.open(changes_path, 'r', 'utf-8').read() - changes_sections = self._changes_parser.findall(changes_txt) - top_ver = changes_sections[0][0] - if top_ver != version: - raise MkError("top section in `CHANGES.md' is for " - "version %r, expected version %r: aborting" - % (top_ver, version)) - top_nyr = changes_sections[0][1] - if not top_nyr: - answer = query_yes_no("\n* * *\n" - "The top section in `CHANGES.md' doesn't have the expected\n" - "'(not yet released)' marker. Has this been released already?", - default="yes") - print "* * *" - if answer != "no": - self.log.info("abort") - return - top_body = changes_sections[0][2] - if top_body.strip() == "(nothing yet)": - raise MkError("top section body is `(nothing yet)': it looks like " - "nothing has been added to this release") - - # Commits to prepare release. - changes_txt = changes_txt.replace(" (not yet released)", "", 1) - if not DRY_RUN and changes_txt != changes_txt_before: - self.log.info("prepare `CHANGES.md' for release") - f = codecs.open(changes_path, 'w', 'utf-8') - f.write(changes_txt) - f.close() - sh.run('git commit %s -m "prepare for %s release"' - % (changes_path, version), self.log.debug) - - # Tag version and push. - curr_tags = set(t for t in _capture_stdout(["git", "tag", "-l"]).split('\n') if t) - if not DRY_RUN and version not in curr_tags: - self.log.info("tag the release") - sh.run('git tag -a "%s" -m "version %s"' % (version, version), - self.log.debug) - sh.run('git push --tags', self.log.debug) - - # Commits to prepare for future dev and push. - next_version_info = self._get_next_version_info(version_info) - next_version = self._version_from_version_info(next_version_info) - self.log.info("prepare for future dev (version %s)", next_version) - marker = "## %s %s\n" % (self.proj_name, version) - if marker not in changes_txt: - raise MkError("couldn't find `%s' marker in `%s' " - "content: can't prep for subsequent dev" % (marker, changes_path)) - changes_txt = changes_txt.replace("## %s %s\n" % (self.proj_name, version), - "## %s %s (not yet released)\n\n(nothing yet)\n\n## %s %s\n" % ( - self.proj_name, next_version, self.proj_name, version)) - if not DRY_RUN: - f = codecs.open(changes_path, 'w', 'utf-8') - f.write(changes_txt) - f.close() - - ver_path = join(self.dir, normpath(self.version_py_path)) - ver_content = codecs.open(ver_path, 'r', 'utf-8').read() - next_version_tuple = self._tuple_from_version(next_version) - marker = "__version_info__ = %r" % (version_info,) - if marker not in ver_content: - raise MkError("couldn't find `%s' version marker in `%s' " - "content: can't prep for subsequent dev" % (marker, ver_path)) - ver_content = ver_content.replace(marker, - "__version_info__ = %r" % (next_version_tuple,)) - if not DRY_RUN: - f = codecs.open(ver_path, 'w', 'utf-8') - f.write(ver_content) - f.close() - - if not DRY_RUN: - sh.run('git commit %s %s -m "prep for future dev"' % ( - changes_path, ver_path)) - sh.run('git push') - - def _tuple_from_version(self, version): - def _intify(s): - try: - return int(s) - except ValueError: - return s - return tuple(_intify(b) for b in version.split('.')) - - def _get_next_version_info(self, version_info): - next = list(version_info[:]) - next[-1] += 1 - return tuple(next) - - def _version_from_version_info(self, version_info): - v = str(version_info[0]) - state_dot_join = True - for i in version_info[1:]: - if state_dot_join: - try: - int(i) - except ValueError: - state_dot_join = False - else: - pass - if state_dot_join: - v += "." + str(i) - else: - v += str(i) - return v - - def _get_version_info(self): - f = codecs.open(self.version_py_path, 'r', 'utf-8') - content = f.read() - f.close() - m = re.search(r'^__version_info__\s*=\s*(.*?)\s*$', content, re.M) - version_info = eval(m.group(1)) - return version_info - - -#---- internal support stuff - -## {{{ http://code.activestate.com/recipes/577058/ (r2) -def query_yes_no(question, default="yes"): - """Ask a yes/no question via raw_input() and return their answer. - - "question" is a string that is presented to the user. - "default" is the presumed answer if the user just hits . - It must be "yes" (the default), "no" or None (meaning - an answer is required of the user). - - The "answer" return value is one of "yes" or "no". - """ - valid = {"yes":"yes", "y":"yes", "ye":"yes", - "no":"no", "n":"no"} - if default == None: - prompt = " [y/n] " - elif default == "yes": - prompt = " [Y/n] " - elif default == "no": - prompt = " [y/N] " - else: - raise ValueError("invalid default answer: '%s'" % default) - - while 1: - sys.stdout.write(question + prompt) - choice = raw_input().lower() - if default is not None and choice == '': - return default - elif choice in valid.keys(): - return valid[choice] - else: - sys.stdout.write("Please respond with 'yes' or 'no' "\ - "(or 'y' or 'n').\n") -## end of http://code.activestate.com/recipes/577058/ }}} - -def _capture_stdout(argv): - import subprocess - p = subprocess.Popen(argv, stdout=subprocess.PIPE) - return p.communicate()[0] - diff --git a/tools/cutarelease.py b/tools/cutarelease.py new file mode 100755 index 0000000..5d2fb15 --- /dev/null +++ b/tools/cutarelease.py @@ -0,0 +1,597 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Copyright (c) 2009-2012 Trent Mick + +"""cutarelease -- Cut a release of your project. + +A script that will help cut a release for a git-based project that follows +a few conventions. It'll update your changelog (CHANGES.md), add a git +tag, push those changes, update your version to the next patch level release +and create a new changelog section for that new version. + +Conventions: +- XXX +""" + +__version_info__ = (1, 0, 6) +__version__ = '.'.join(map(str, __version_info__)) + +import sys +import os +from os.path import join, dirname, normpath, abspath, exists, basename, splitext +from glob import glob +from pprint import pprint +import re +import codecs +import logging +import optparse +import json + + + +#---- globals and config + +log = logging.getLogger("cutarelease") + +class Error(Exception): + pass + + + +#---- main functionality + +def cutarelease(project_name, version_files, dry_run=False): + """Cut a release. + + @param project_name {str} + @param version_files {list} List of paths to files holding the version + info for this project. + + If none are given it attempts to guess the version file: + package.json or VERSION.txt or VERSION or $project_name.py + or lib/$project_name.py or $project_name.js or lib/$project_name.js. + + The version file can be in one of the following forms: + + - A .py file, in which case the file is expect to have a top-level + global called "__version_info__" as follows. [1] + + __version_info__ = (0, 7, 6) + + Note that I typically follow that with the following to get a + string version attribute on my modules: + + __version__ = '.'.join(map(str, __version_info__)) + + - A .js file, in which case the file is expected to have a top-level + global called "VERSION" as follows: + + ver VERSION = "1.2.3"; + + - A "package.json" file, typical of a node.js npm-using project. + The package.json file must have a "version" field. + + - TODO: A simple version file whose only content is a "1.2.3"-style version + string. + + [1]: This is a convention I tend to follow in my projects. + Granted it might not be your cup of tea. I should add support for + just `__version__ = "1.2.3"`. I'm open to other suggestions too. + """ + dry_run_str = dry_run and " (dry-run)" or "" + + if not version_files: + log.info("guessing version file") + candidates = [ + "package.json", + "VERSION.txt", + "VERSION", + "%s.py" % project_name, + "lib/%s.py" % project_name, + "%s.js" % project_name, + "lib/%s.js" % project_name, + ] + for candidate in candidates: + if exists(candidate): + version_files = [candidate] + break + else: + raise Error("could not find a version file: specify its path or " + "add one of the following to your project: '%s'" + % "', '".join(candidates)) + log.info("using '%s' as version file", version_files[0]) + + parsed_version_files = [_parse_version_file(f) for f in version_files] + version_file_type, version_info = parsed_version_files[0] + version = _version_from_version_info(version_info) + + # Confirm + if not dry_run: + answer = query_yes_no("* * *\n" + "Are you sure you want cut a %s release?\n" + "This will involved commits and a push." % version, + default="no") + print "* * *" + if answer != "yes": + log.info("user abort") + return + log.info("cutting a %s release%s", version, dry_run_str) + + # Checks: Ensure there is a section in changes for this version. + + + + changes_path = "CHANGES.md" + changes_txt, changes, nyr = parse_changelog(changes_path) + #pprint(changes) + top_ver = changes[0]["version"] + if top_ver != version: + raise Error("changelog '%s' top section says " + "version %r, expected version %r: aborting" + % (changes_path, top_ver, version)) + top_verline = changes[0]["verline"] + if not top_verline.endswith(nyr): + answer = query_yes_no("\n* * *\n" + "The changelog '%s' top section doesn't have the expected\n" + "'%s' marker. Has this been released already?" + % (changes_path, nyr), default="yes") + print "* * *" + if answer != "no": + log.info("abort") + return + top_body = changes[0]["body"] + if top_body.strip() == "(nothing yet)": + raise Error("top section body is `(nothing yet)': it looks like " + "nothing has been added to this release") + + # Commits to prepare release. + changes_txt_before = changes_txt + changes_txt = changes_txt.replace(" (not yet released)", "", 1) + if not dry_run and changes_txt != changes_txt_before: + log.info("prepare `%s' for release", changes_path) + f = codecs.open(changes_path, 'w', 'utf-8') + f.write(changes_txt) + f.close() + run('git commit %s -m "prepare for %s release"' + % (changes_path, version)) + + # Tag version and push. + curr_tags = set(t for t in _capture_stdout(["git", "tag", "-l"]).split('\n') if t) + if not dry_run and version not in curr_tags: + log.info("tag the release") + run('git tag -a "%s" -m "version %s"' % (version, version)) + run('git push --tags') + + # Optionally release. + if exists("package.json"): + answer = query_yes_no("\n* * *\nPublish to npm?", default="yes") + print "* * *" + if answer == "yes": + if dry_run: + log.info("skipping npm publish (dry-run)") + else: + run('npm publish') + elif exists("setup.py"): + answer = query_yes_no("\n* * *\nPublish to pypi?", default="yes") + print "* * *" + if answer == "yes": + if dry_run: + log.info("skipping pypi publish (dry-run)") + else: + run("%spython setup.py sdist --formats zip upload" + % _setup_command_prefix()) + + # Commits to prepare for future dev and push. + # - update changelog file + next_version_info = _get_next_version_info(version_info) + next_version = _version_from_version_info(next_version_info) + log.info("prepare for future dev (version %s)", next_version) + marker = "## " + changes[0]["verline"] + if marker.endswith(nyr): + marker = marker[0:-len(nyr)] + if marker not in changes_txt: + raise Error("couldn't find `%s' marker in `%s' " + "content: can't prep for subsequent dev" % (marker, changes_path)) + next_verline = "%s %s%s" % (marker.rsplit(None, 1)[0], next_version, nyr) + changes_txt = changes_txt.replace(marker + '\n', + "%s\n\n(nothing yet)\n\n\n%s\n" % (next_verline, marker)) + if not dry_run: + f = codecs.open(changes_path, 'w', 'utf-8') + f.write(changes_txt) + f.close() + + # - update version file + next_version_tuple = _tuple_from_version(next_version) + for i, ver_file in enumerate(version_files): + ver_content = codecs.open(ver_file, 'r', 'utf-8').read() + ver_file_type, ver_info = parsed_version_files[i] + if ver_file_type == "json": + marker = '"version": "%s"' % version + if marker not in ver_content: + raise Error("couldn't find `%s' version marker in `%s' " + "content: can't prep for subsequent dev" % (marker, ver_file)) + ver_content = ver_content.replace(marker, + '"version": "%s"' % next_version) + elif ver_file_type == "javascript": + marker = 'var VERSION = "%s";' % version + if marker not in ver_content: + raise Error("couldn't find `%s' version marker in `%s' " + "content: can't prep for subsequent dev" % (marker, ver_file)) + ver_content = ver_content.replace(marker, + 'var VERSION = "%s";' % next_version) + elif ver_file_type == "python": + marker = "__version_info__ = %r" % (version_info,) + if marker not in ver_content: + raise Error("couldn't find `%s' version marker in `%s' " + "content: can't prep for subsequent dev" % (marker, ver_file)) + ver_content = ver_content.replace(marker, + "__version_info__ = %r" % (next_version_tuple,)) + elif ver_file_type == "version": + ver_content = next_version + else: + raise Error("unknown ver_file_type: %r" % ver_file_type) + if not dry_run: + log.info("update version to '%s' in '%s'", next_version, ver_file) + f = codecs.open(ver_file, 'w', 'utf-8') + f.write(ver_content) + f.close() + + if not dry_run: + run('git commit %s %s -m "prep for future dev"' % ( + changes_path, ' '.join(version_files))) + run('git push') + + + +#---- internal support routines + +def _indent(s, indent=' '): + return indent + indent.join(s.splitlines(True)) + +def _tuple_from_version(version): + def _intify(s): + try: + return int(s) + except ValueError: + return s + return tuple(_intify(b) for b in version.split('.')) + +def _get_next_version_info(version_info): + next = list(version_info[:]) + next[-1] += 1 + return tuple(next) + +def _version_from_version_info(version_info): + v = str(version_info[0]) + state_dot_join = True + for i in version_info[1:]: + if state_dot_join: + try: + int(i) + except ValueError: + state_dot_join = False + else: + pass + if state_dot_join: + v += "." + str(i) + else: + v += str(i) + return v + +_version_re = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+)([abc](\d+)?)?)?$") +def _version_info_from_version(version): + m = _version_re.match(version) + if not m: + raise Error("could not convert '%s' version to version info" % version) + version_info = [] + for g in m.groups(): + if g is None: + break + try: + version_info.append(int(g)) + except ValueError: + version_info.append(g) + return tuple(version_info) + +def _parse_version_file(version_file): + """Get version info from the given file. It can be any of: + + Supported version file types (i.e. types of files from which we know + how to parse the version string/number -- often by some convention): + - json: use the "version" key + - javascript: look for a `var VERSION = "1.2.3";` + - python: Python script/module with `__version_info__ = (1, 2, 3)` + - version: a VERSION.txt or VERSION file where the whole contents are + the version string + + @param version_file {str} Can be a path or "type:path", where "type" + is one of the supported types. + """ + # Get version file *type*. + version_file_type = None + match = re.compile("^([a-z]+):(.*)$").search(version_file) + if match: + version_file = match.group(2) + version_file_type = match.group(1) + aliases = { + "js": "javascript" + } + if version_file_type in aliases: + version_file_type = aliases[version_file_type] + + f = codecs.open(version_file, 'r', 'utf-8') + content = f.read() + f.close() + + if not version_file_type: + # Guess the type. + base = basename(version_file) + ext = splitext(base)[1] + if ext == ".json": + version_file_type = "json" + elif ext == ".py": + version_file_type = "python" + elif ext == ".js": + version_file_type = "javascript" + elif content.startswith("#!"): + shebang = content.splitlines(False)[0] + shebang_bits = re.split(r'[/ \t]', shebang) + for name, typ in {"python": "python", "node": "javascript"}.items(): + if name in shebang_bits: + version_file_type = typ + break + elif base in ("VERSION", "VERSION.txt"): + version_file_type = "version" + if not version_file_type: + raise RuntimeError("can't extract version from '%s': no idea " + "what type of file it it" % version_file) + + if version_file_type == "json": + obj = json.loads(content) + version_info = _version_info_from_version(obj["version"]) + elif version_file_type == "python": + m = re.search(r'^__version_info__ = (.*?)$', content, re.M) + version_info = eval(m.group(1)) + elif version_file_type == "javascript": + m = re.search(r'^var VERSION = "(.*?)";$', content, re.M) + version_info = _version_info_from_version(m.group(1)) + elif version_file_type == "version": + version_info = _version_info_from_version(content.strip()) + else: + raise RuntimeError("unexpected version_file_type: %r" + % version_file_type) + return version_file_type, version_info + + +def parse_changelog(changes_path): + """Parse the given changelog path and return `(content, parsed, nyr)` + where `nyr` is the ' (not yet released)' marker and `parsed` looks like: + + [{'body': u'\n(nothing yet)\n\n', + 'verline': u'restify 1.0.1 (not yet released)', + 'version': u'1.0.1'}, # version is parsed out for top section only + {'body': u'...', + 'verline': u'1.0.0'}, + {'body': u'...', + 'verline': u'1.0.0-rc2'}, + {'body': u'...', + 'verline': u'1.0.0-rc1'}] + + A changelog (CHANGES.md) is expected to look like this: + + # $project Changelog + + ## $next_version (not yet released) + + ... + + ## $version1 + + ... + + ## $version2 + + ... and so on + + The version lines are enforced as follows: + + - The top entry should have a " (not yet released)" suffix. "Should" + because recovery from half-cutarelease failures is supported. + - A version string must be extractable from there, but it tries to + be loose (though strict "X.Y.Z" versioning is preferred). Allowed + + ## 1.0.0 + ## my project 1.0.1 + ## foo 1.2.3-rc2 + + Basically, (a) the " (not yet released)" is stripped, (b) the + last token is the version, and (c) that version must start with + a digit (sanity check). + """ + if not exists(changes_path): + raise Error("changelog file '%s' not found" % changes_path) + content = codecs.open(changes_path, 'r', 'utf-8').read() + + parser = re.compile( + r'^##\s*(?P[^\n]*?)\s*$(?P.*?)(?=^##|\Z)', + re.M | re.S) + sections = parser.findall(content) + + # Sanity checks on changelog format. + if not sections: + template = "## 1.0.0 (not yet released)\n\n(nothing yet)\n" + raise Error("changelog '%s' must have at least one section, " + "suggestion:\n\n%s" % (changes_path, _indent(template))) + first_section_verline = sections[0][0] + nyr = ' (not yet released)' + #if not first_section_verline.endswith(nyr): + # eg = "## %s%s" % (first_section_verline, nyr) + # raise Error("changelog '%s' top section must end with %r, " + # "naive e.g.: '%s'" % (changes_path, nyr, eg)) + + items = [] + for i, section in enumerate(sections): + item = { + "verline": section[0], + "body": section[1] + } + if i == 0: + # We only bother to pull out 'version' for the top section. + verline = section[0] + if verline.endswith(nyr): + verline = verline[0:-len(nyr)] + version = verline.split()[-1] + try: + int(version[0]) + except ValueError: + msg = '' + if version.endswith(')'): + msg = " (cutarelease is picky about the trailing %r " \ + "on the top version line. Perhaps you misspelled " \ + "that?)" % nyr + raise Error("changelog '%s' top section version '%s' is " + "invalid: first char isn't a number%s" + % (changes_path, version, msg)) + item["version"] = version + items.append(item) + + return content, items, nyr + +## {{{ http://code.activestate.com/recipes/577058/ (r2) +def query_yes_no(question, default="yes"): + """Ask a yes/no question via raw_input() and return their answer. + + "question" is a string that is presented to the user. + "default" is the presumed answer if the user just hits . + It must be "yes" (the default), "no" or None (meaning + an answer is required of the user). + + The "answer" return value is one of "yes" or "no". + """ + valid = {"yes":"yes", "y":"yes", "ye":"yes", + "no":"no", "n":"no"} + if default == None: + prompt = " [y/n] " + elif default == "yes": + prompt = " [Y/n] " + elif default == "no": + prompt = " [y/N] " + else: + raise ValueError("invalid default answer: '%s'" % default) + + while 1: + sys.stdout.write(question + prompt) + choice = raw_input().lower() + if default is not None and choice == '': + return default + elif choice in valid.keys(): + return valid[choice] + else: + sys.stdout.write("Please respond with 'yes' or 'no' "\ + "(or 'y' or 'n').\n") +## end of http://code.activestate.com/recipes/577058/ }}} + +def _capture_stdout(argv): + import subprocess + p = subprocess.Popen(argv, stdout=subprocess.PIPE) + return p.communicate()[0] + +class _NoReflowFormatter(optparse.IndentedHelpFormatter): + """An optparse formatter that does NOT reflow the description.""" + def format_description(self, description): + return description or "" + +def run(cmd): + """Run the given command. + + Raises OSError is the command returns a non-zero exit status. + """ + log.debug("running '%s'", cmd) + fixed_cmd = cmd + if sys.platform == "win32" and cmd.count('"') > 2: + fixed_cmd = '"' + cmd + '"' + retval = os.system(fixed_cmd) + if hasattr(os, "WEXITSTATUS"): + status = os.WEXITSTATUS(retval) + else: + status = retval + if status: + raise OSError(status, "error running '%s'" % cmd) + +def _setup_command_prefix(): + prefix = "" + if sys.platform == "darwin": + # http://forums.macosxhints.com/archive/index.php/t-43243.html + # This is an Apple customization to `tar` to avoid creating + # '._foo' files for extended-attributes for archived files. + prefix = "COPY_EXTENDED_ATTRIBUTES_DISABLE=1 " + return prefix + + +#---- mainline + +def main(argv): + logging.basicConfig(format="%(name)s: %(levelname)s: %(message)s") + log.setLevel(logging.INFO) + + # Parse options. + parser = optparse.OptionParser(prog="cutarelease", usage='', + version="%prog " + __version__, description=__doc__, + formatter=_NoReflowFormatter()) + parser.add_option("-v", "--verbose", dest="log_level", + action="store_const", const=logging.DEBUG, + help="more verbose output") + parser.add_option("-q", "--quiet", dest="log_level", + action="store_const", const=logging.WARNING, + help="quieter output (just warnings and errors)") + parser.set_default("log_level", logging.INFO) + parser.add_option("--test", action="store_true", + help="run self-test and exit (use 'eol.py -v --test' for verbose test output)") + parser.add_option("-p", "--project-name", metavar="NAME", + help='the name of this project (default is the base dir name)', + default=basename(os.getcwd())) + parser.add_option("-f", "--version-file", metavar="[TYPE:]PATH", + action='append', dest="version_files", + help='The path to the project file holding the version info. Can be ' + 'specified multiple times if more than one file should be updated ' + 'with new version info. If excluded, it will be guessed.') + parser.add_option("-n", "--dry-run", action="store_true", + help='Do a dry-run', default=False) + opts, args = parser.parse_args() + log.setLevel(opts.log_level) + + cutarelease(opts.project_name, opts.version_files, dry_run=opts.dry_run) + + +## {{{ http://code.activestate.com/recipes/577258/ (r5+) +if __name__ == "__main__": + try: + retval = main(sys.argv) + except KeyboardInterrupt: + sys.exit(1) + except SystemExit: + raise + except: + import traceback, logging + if not log.handlers and not logging.root.handlers: + logging.basicConfig() + skip_it = False + exc_info = sys.exc_info() + if hasattr(exc_info[0], "__name__"): + exc_class, exc, tb = exc_info + if isinstance(exc, IOError) and exc.args[0] == 32: + # Skip 'IOError: [Errno 32] Broken pipe': often a cancelling of `less`. + skip_it = True + if not skip_it: + tb_path, tb_lineno, tb_func = traceback.extract_tb(tb)[-1][:3] + log.error("%s (%s:%s in %s)", exc_info[1], tb_path, + tb_lineno, tb_func) + else: # string exception + log.error(exc_info[0]) + if not skip_it: + if log.isEnabledFor(logging.DEBUG): + traceback.print_exception(*exc_info) + sys.exit(1) + else: + sys.exit(retval) +## end of http://code.activestate.com/recipes/577258/ }}}