Skip to content

Commit 13ac3a6

Browse files
committed
update to latest cutarelease tool
1 parent a61dd4b commit 13ac3a6

File tree

1 file changed

+152
-51
lines changed

1 file changed

+152
-51
lines changed

tools/cutarelease.py

+152-51
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,14 @@
1313
- XXX
1414
"""
1515

16-
__version_info__ = (1, 0, 4)
16+
__version_info__ = (1, 0, 6)
1717
__version__ = '.'.join(map(str, __version_info__))
1818

1919
import sys
2020
import os
2121
from os.path import join, dirname, normpath, abspath, exists, basename, splitext
2222
from glob import glob
23+
from pprint import pprint
2324
import re
2425
import codecs
2526
import logging
@@ -31,7 +32,7 @@
3132
#---- globals and config
3233

3334
log = logging.getLogger("cutarelease")
34-
35+
3536
class Error(Exception):
3637
pass
3738

@@ -41,42 +42,44 @@ class Error(Exception):
4142

4243
def cutarelease(project_name, version_files, dry_run=False):
4344
"""Cut a release.
44-
45+
4546
@param project_name {str}
4647
@param version_files {list} List of paths to files holding the version
4748
info for this project.
48-
49+
4950
If none are given it attempts to guess the version file:
5051
package.json or VERSION.txt or VERSION or $project_name.py
5152
or lib/$project_name.py or $project_name.js or lib/$project_name.js.
52-
53+
5354
The version file can be in one of the following forms:
54-
55+
5556
- A .py file, in which case the file is expect to have a top-level
5657
global called "__version_info__" as follows. [1]
57-
58+
5859
__version_info__ = (0, 7, 6)
59-
60+
6061
Note that I typically follow that with the following to get a
6162
string version attribute on my modules:
62-
63+
6364
__version__ = '.'.join(map(str, __version_info__))
64-
65+
6566
- A .js file, in which case the file is expected to have a top-level
6667
global called "VERSION" as follows:
67-
68+
6869
ver VERSION = "1.2.3";
69-
70+
7071
- A "package.json" file, typical of a node.js npm-using project.
7172
The package.json file must have a "version" field.
72-
73+
7374
- TODO: A simple version file whose only content is a "1.2.3"-style version
7475
string.
75-
76+
7677
[1]: This is a convention I tend to follow in my projects.
7778
Granted it might not be your cup of tea. I should add support for
7879
just `__version__ = "1.2.3"`. I'm open to other suggestions too.
7980
"""
81+
dry_run_str = dry_run and " (dry-run)" or ""
82+
8083
if not version_files:
8184
log.info("guessing version file")
8285
candidates = [
@@ -112,43 +115,37 @@ def cutarelease(project_name, version_files, dry_run=False):
112115
if answer != "yes":
113116
log.info("user abort")
114117
return
115-
log.info("cutting a %s release", version)
118+
log.info("cutting a %s release%s", version, dry_run_str)
116119

117120
# Checks: Ensure there is a section in changes for this version.
121+
122+
123+
118124
changes_path = "CHANGES.md"
119-
if not exists(changes_path):
120-
raise Error("'%s' not found" % changes_path)
121-
changes_txt = changes_txt_before = codecs.open(changes_path, 'r', 'utf-8').read()
122-
123-
changes_parser = re.compile(r'^##\s+(?:.*?\s+)?v?(?P<ver>[\d\.abc]+)'
124-
r'(?P<nyr>\s+\(not yet released\))?'
125-
r'(?P<body>.*?)(?=^##|\Z)', re.M | re.S)
126-
changes_sections = changes_parser.findall(changes_txt)
127-
try:
128-
top_ver = changes_sections[0][0]
129-
except IndexError:
130-
raise Error("unexpected error parsing `%s': parsed=%r" % (
131-
changes_path, changes_sections))
125+
changes_txt, changes, nyr = parse_changelog(changes_path)
126+
#pprint(changes)
127+
top_ver = changes[0]["version"]
132128
if top_ver != version:
133-
raise Error("top section in `%s' is for "
129+
raise Error("changelog '%s' top section says "
134130
"version %r, expected version %r: aborting"
135131
% (changes_path, top_ver, version))
136-
top_nyr = changes_sections[0][1].strip()
137-
if not top_nyr:
132+
top_verline = changes[0]["verline"]
133+
if not top_verline.endswith(nyr):
138134
answer = query_yes_no("\n* * *\n"
139-
"The top section in `%s' doesn't have the expected\n"
140-
"'(not yet released)' marker. Has this been released already?"
141-
% changes_path, default="yes")
135+
"The changelog '%s' top section doesn't have the expected\n"
136+
"'%s' marker. Has this been released already?"
137+
% (changes_path, nyr), default="yes")
142138
print "* * *"
143139
if answer != "no":
144140
log.info("abort")
145141
return
146-
top_body = changes_sections[0][2]
142+
top_body = changes[0]["body"]
147143
if top_body.strip() == "(nothing yet)":
148144
raise Error("top section body is `(nothing yet)': it looks like "
149145
"nothing has been added to this release")
150146

151147
# Commits to prepare release.
148+
changes_txt_before = changes_txt
152149
changes_txt = changes_txt.replace(" (not yet released)", "", 1)
153150
if not dry_run and changes_txt != changes_txt_before:
154151
log.info("prepare `%s' for release", changes_path)
@@ -170,26 +167,34 @@ def cutarelease(project_name, version_files, dry_run=False):
170167
answer = query_yes_no("\n* * *\nPublish to npm?", default="yes")
171168
print "* * *"
172169
if answer == "yes":
173-
run('npm publish')
170+
if dry_run:
171+
log.info("skipping npm publish (dry-run)")
172+
else:
173+
run('npm publish')
174174
elif exists("setup.py"):
175175
answer = query_yes_no("\n* * *\nPublish to pypi?", default="yes")
176176
print "* * *"
177177
if answer == "yes":
178-
run("%spython setup.py sdist --formats zip upload"
179-
% _setup_command_prefix())
178+
if dry_run:
179+
log.info("skipping pypi publish (dry-run)")
180+
else:
181+
run("%spython setup.py sdist --formats zip upload"
182+
% _setup_command_prefix())
180183

181184
# Commits to prepare for future dev and push.
182185
# - update changelog file
183186
next_version_info = _get_next_version_info(version_info)
184187
next_version = _version_from_version_info(next_version_info)
185188
log.info("prepare for future dev (version %s)", next_version)
186-
marker = "## %s %s\n" % (project_name, version)
189+
marker = "## " + changes[0]["verline"]
190+
if marker.endswith(nyr):
191+
marker = marker[0:-len(nyr)]
187192
if marker not in changes_txt:
188193
raise Error("couldn't find `%s' marker in `%s' "
189194
"content: can't prep for subsequent dev" % (marker, changes_path))
190-
changes_txt = changes_txt.replace("## %s %s\n" % (project_name, version),
191-
"## %s %s (not yet released)\n\n(nothing yet)\n\n## %s %s\n" % (
192-
project_name, next_version, project_name, version))
195+
next_verline = "%s %s%s" % (marker.rsplit(None, 1)[0], next_version, nyr)
196+
changes_txt = changes_txt.replace(marker + '\n',
197+
"%s\n\n(nothing yet)\n\n\n%s\n" % (next_verline, marker))
193198
if not dry_run:
194199
f = codecs.open(changes_path, 'w', 'utf-8')
195200
f.write(changes_txt)
@@ -240,6 +245,9 @@ def cutarelease(project_name, version_files, dry_run=False):
240245

241246
#---- internal support routines
242247

248+
def _indent(s, indent=' '):
249+
return indent + indent.join(s.splitlines(True))
250+
243251
def _tuple_from_version(version):
244252
def _intify(s):
245253
try:
@@ -287,15 +295,15 @@ def _version_info_from_version(version):
287295

288296
def _parse_version_file(version_file):
289297
"""Get version info from the given file. It can be any of:
290-
298+
291299
Supported version file types (i.e. types of files from which we know
292300
how to parse the version string/number -- often by some convention):
293301
- json: use the "version" key
294302
- javascript: look for a `var VERSION = "1.2.3";`
295303
- python: Python script/module with `__version_info__ = (1, 2, 3)`
296304
- version: a VERSION.txt or VERSION file where the whole contents are
297305
the version string
298-
306+
299307
@param version_file {str} Can be a path or "type:path", where "type"
300308
is one of the supported types.
301309
"""
@@ -310,11 +318,11 @@ def _parse_version_file(version_file):
310318
}
311319
if version_file_type in aliases:
312320
version_file_type = aliases[version_file_type]
313-
321+
314322
f = codecs.open(version_file, 'r', 'utf-8')
315323
content = f.read()
316324
f.close()
317-
325+
318326
if not version_file_type:
319327
# Guess the type.
320328
base = basename(version_file)
@@ -328,7 +336,7 @@ def _parse_version_file(version_file):
328336
elif content.startswith("#!"):
329337
shebang = content.splitlines(False)[0]
330338
shebang_bits = re.split(r'[/ \t]', shebang)
331-
for name, typ in {"python": "python", "node": "javascript"}.items():
339+
for name, typ in {"python": "python", "node": "javascript"}.items():
332340
if name in shebang_bits:
333341
version_file_type = typ
334342
break
@@ -337,7 +345,7 @@ def _parse_version_file(version_file):
337345
if not version_file_type:
338346
raise RuntimeError("can't extract version from '%s': no idea "
339347
"what type of file it it" % version_file)
340-
348+
341349
if version_file_type == "json":
342350
obj = json.loads(content)
343351
version_info = _version_info_from_version(obj["version"])
@@ -355,6 +363,100 @@ def _parse_version_file(version_file):
355363
return version_file_type, version_info
356364

357365

366+
def parse_changelog(changes_path):
367+
"""Parse the given changelog path and return `(content, parsed, nyr)`
368+
where `nyr` is the ' (not yet released)' marker and `parsed` looks like:
369+
370+
[{'body': u'\n(nothing yet)\n\n',
371+
'verline': u'restify 1.0.1 (not yet released)',
372+
'version': u'1.0.1'}, # version is parsed out for top section only
373+
{'body': u'...',
374+
'verline': u'1.0.0'},
375+
{'body': u'...',
376+
'verline': u'1.0.0-rc2'},
377+
{'body': u'...',
378+
'verline': u'1.0.0-rc1'}]
379+
380+
A changelog (CHANGES.md) is expected to look like this:
381+
382+
# $project Changelog
383+
384+
## $next_version (not yet released)
385+
386+
...
387+
388+
## $version1
389+
390+
...
391+
392+
## $version2
393+
394+
... and so on
395+
396+
The version lines are enforced as follows:
397+
398+
- The top entry should have a " (not yet released)" suffix. "Should"
399+
because recovery from half-cutarelease failures is supported.
400+
- A version string must be extractable from there, but it tries to
401+
be loose (though strict "X.Y.Z" versioning is preferred). Allowed
402+
403+
## 1.0.0
404+
## my project 1.0.1
405+
## foo 1.2.3-rc2
406+
407+
Basically, (a) the " (not yet released)" is stripped, (b) the
408+
last token is the version, and (c) that version must start with
409+
a digit (sanity check).
410+
"""
411+
if not exists(changes_path):
412+
raise Error("changelog file '%s' not found" % changes_path)
413+
content = codecs.open(changes_path, 'r', 'utf-8').read()
414+
415+
parser = re.compile(
416+
r'^##\s*(?P<verline>[^\n]*?)\s*$(?P<body>.*?)(?=^##|\Z)',
417+
re.M | re.S)
418+
sections = parser.findall(content)
419+
420+
# Sanity checks on changelog format.
421+
if not sections:
422+
template = "## 1.0.0 (not yet released)\n\n(nothing yet)\n"
423+
raise Error("changelog '%s' must have at least one section, "
424+
"suggestion:\n\n%s" % (changes_path, _indent(template)))
425+
first_section_verline = sections[0][0]
426+
nyr = ' (not yet released)'
427+
#if not first_section_verline.endswith(nyr):
428+
# eg = "## %s%s" % (first_section_verline, nyr)
429+
# raise Error("changelog '%s' top section must end with %r, "
430+
# "naive e.g.: '%s'" % (changes_path, nyr, eg))
431+
432+
items = []
433+
for i, section in enumerate(sections):
434+
item = {
435+
"verline": section[0],
436+
"body": section[1]
437+
}
438+
if i == 0:
439+
# We only bother to pull out 'version' for the top section.
440+
verline = section[0]
441+
if verline.endswith(nyr):
442+
verline = verline[0:-len(nyr)]
443+
version = verline.split()[-1]
444+
try:
445+
int(version[0])
446+
except ValueError:
447+
msg = ''
448+
if version.endswith(')'):
449+
msg = " (cutarelease is picky about the trailing %r " \
450+
"on the top version line. Perhaps you misspelled " \
451+
"that?)" % nyr
452+
raise Error("changelog '%s' top section version '%s' is "
453+
"invalid: first char isn't a number%s"
454+
% (changes_path, version, msg))
455+
item["version"] = version
456+
items.append(item)
457+
458+
return content, items, nyr
459+
358460
## {{{ http://code.activestate.com/recipes/577058/ (r2)
359461
def query_yes_no(question, default="yes"):
360462
"""Ask a yes/no question via raw_input() and return their answer.
@@ -457,11 +559,11 @@ def main(argv):
457559
help='Do a dry-run', default=False)
458560
opts, args = parser.parse_args()
459561
log.setLevel(opts.log_level)
460-
562+
461563
cutarelease(opts.project_name, opts.version_files, dry_run=opts.dry_run)
462564

463565

464-
## {{{ http://code.activestate.com/recipes/577258/ (r5)
566+
## {{{ http://code.activestate.com/recipes/577258/ (r5+)
465567
if __name__ == "__main__":
466568
try:
467569
retval = main(sys.argv)
@@ -488,7 +590,6 @@ def main(argv):
488590
log.error(exc_info[0])
489591
if not skip_it:
490592
if log.isEnabledFor(logging.DEBUG):
491-
print()
492593
traceback.print_exception(*exc_info)
493594
sys.exit(1)
494595
else:

0 commit comments

Comments
 (0)