From c8fe8dac347503ab4d7c1607ee6e7b8a4fc79cc2 Mon Sep 17 00:00:00 2001 From: Trent Mick Date: Fri, 16 Mar 2012 11:19:54 -0700 Subject: [PATCH] tyop. update to latest cutarelease tool --- README.md | 2 +- tools/cutarelease.py | 203 ++++++++++++++++++++++++++++++++----------- 2 files changed, 153 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 05854c0..b15c311 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ request handling. See the changelog for node-bunyan 0.3.0 for details. ## serializers Bunyan has a concept of **"serializers" to produce a JSON-able object from a -JavaScript object**, so your can easily do the following: +JavaScript object**, so you can easily do the following: log.info({req: }, "something about handling this request"); diff --git a/tools/cutarelease.py b/tools/cutarelease.py index 4ecfaa7..5d2fb15 100755 --- a/tools/cutarelease.py +++ b/tools/cutarelease.py @@ -13,13 +13,14 @@ Conventions: - XXX """ -__version_info__ = (1, 0, 4) +__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 @@ -31,7 +32,7 @@ import json #---- globals and config log = logging.getLogger("cutarelease") - + class Error(Exception): pass @@ -41,42 +42,44 @@ class Error(Exception): 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 = [ @@ -112,43 +115,37 @@ def cutarelease(project_name, version_files, dry_run=False): if answer != "yes": log.info("user abort") return - log.info("cutting a %s release", version) + 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" - if not exists(changes_path): - raise Error("'%s' not found" % changes_path) - changes_txt = changes_txt_before = codecs.open(changes_path, 'r', 'utf-8').read() - - changes_parser = re.compile(r'^##\s+(?:.*?\s+)?v?(?P[\d\.abc]+)' - r'(?P\s+\(not yet released\))?' - r'(?P.*?)(?=^##|\Z)', re.M | re.S) - changes_sections = changes_parser.findall(changes_txt) - try: - top_ver = changes_sections[0][0] - except IndexError: - raise Error("unexpected error parsing `%s': parsed=%r" % ( - changes_path, changes_sections)) + changes_txt, changes, nyr = parse_changelog(changes_path) + #pprint(changes) + top_ver = changes[0]["version"] if top_ver != version: - raise Error("top section in `%s' is for " + raise Error("changelog '%s' top section says " "version %r, expected version %r: aborting" % (changes_path, top_ver, version)) - top_nyr = changes_sections[0][1].strip() - if not top_nyr: + top_verline = changes[0]["verline"] + if not top_verline.endswith(nyr): answer = query_yes_no("\n* * *\n" - "The top section in `%s' doesn't have the expected\n" - "'(not yet released)' marker. Has this been released already?" - % changes_path, default="yes") + "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_sections[0][2] + 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) @@ -170,26 +167,34 @@ def cutarelease(project_name, version_files, dry_run=False): answer = query_yes_no("\n* * *\nPublish to npm?", default="yes") print "* * *" if answer == "yes": - run('npm publish') + 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": - run("%spython setup.py sdist --formats zip upload" - % _setup_command_prefix()) + 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 = "## %s %s\n" % (project_name, 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)) - changes_txt = changes_txt.replace("## %s %s\n" % (project_name, version), - "## %s %s (not yet released)\n\n(nothing yet)\n\n## %s %s\n" % ( - project_name, next_version, project_name, version)) + 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) @@ -240,6 +245,9 @@ def cutarelease(project_name, version_files, dry_run=False): #---- internal support routines +def _indent(s, indent=' '): + return indent + indent.join(s.splitlines(True)) + def _tuple_from_version(version): def _intify(s): try: @@ -287,7 +295,7 @@ def _version_info_from_version(version): 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 @@ -295,7 +303,7 @@ def _parse_version_file(version_file): - 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. """ @@ -310,11 +318,11 @@ def _parse_version_file(version_file): } 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) @@ -328,7 +336,7 @@ def _parse_version_file(version_file): elif content.startswith("#!"): shebang = content.splitlines(False)[0] shebang_bits = re.split(r'[/ \t]', shebang) - for name, typ in {"python": "python", "node": "javascript"}.items(): + for name, typ in {"python": "python", "node": "javascript"}.items(): if name in shebang_bits: version_file_type = typ break @@ -337,7 +345,7 @@ def _parse_version_file(version_file): 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"]) @@ -355,6 +363,100 @@ def _parse_version_file(version_file): 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. @@ -457,11 +559,11 @@ def main(argv): 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) +## {{{ http://code.activestate.com/recipes/577258/ (r5+) if __name__ == "__main__": try: retval = main(sys.argv) @@ -488,7 +590,6 @@ if __name__ == "__main__": log.error(exc_info[0]) if not skip_it: if log.isEnabledFor(logging.DEBUG): - print() traceback.print_exception(*exc_info) sys.exit(1) else: