13
13
- XXX
14
14
"""
15
15
16
- __version_info__ = (1 , 0 , 4 )
16
+ __version_info__ = (1 , 0 , 6 )
17
17
__version__ = '.' .join (map (str , __version_info__ ))
18
18
19
19
import sys
20
20
import os
21
21
from os .path import join , dirname , normpath , abspath , exists , basename , splitext
22
22
from glob import glob
23
+ from pprint import pprint
23
24
import re
24
25
import codecs
25
26
import logging
31
32
#---- globals and config
32
33
33
34
log = logging .getLogger ("cutarelease" )
34
-
35
+
35
36
class Error (Exception ):
36
37
pass
37
38
@@ -41,42 +42,44 @@ class Error(Exception):
41
42
42
43
def cutarelease (project_name , version_files , dry_run = False ):
43
44
"""Cut a release.
44
-
45
+
45
46
@param project_name {str}
46
47
@param version_files {list} List of paths to files holding the version
47
48
info for this project.
48
-
49
+
49
50
If none are given it attempts to guess the version file:
50
51
package.json or VERSION.txt or VERSION or $project_name.py
51
52
or lib/$project_name.py or $project_name.js or lib/$project_name.js.
52
-
53
+
53
54
The version file can be in one of the following forms:
54
-
55
+
55
56
- A .py file, in which case the file is expect to have a top-level
56
57
global called "__version_info__" as follows. [1]
57
-
58
+
58
59
__version_info__ = (0, 7, 6)
59
-
60
+
60
61
Note that I typically follow that with the following to get a
61
62
string version attribute on my modules:
62
-
63
+
63
64
__version__ = '.'.join(map(str, __version_info__))
64
-
65
+
65
66
- A .js file, in which case the file is expected to have a top-level
66
67
global called "VERSION" as follows:
67
-
68
+
68
69
ver VERSION = "1.2.3";
69
-
70
+
70
71
- A "package.json" file, typical of a node.js npm-using project.
71
72
The package.json file must have a "version" field.
72
-
73
+
73
74
- TODO: A simple version file whose only content is a "1.2.3"-style version
74
75
string.
75
-
76
+
76
77
[1]: This is a convention I tend to follow in my projects.
77
78
Granted it might not be your cup of tea. I should add support for
78
79
just `__version__ = "1.2.3"`. I'm open to other suggestions too.
79
80
"""
81
+ dry_run_str = dry_run and " (dry-run)" or ""
82
+
80
83
if not version_files :
81
84
log .info ("guessing version file" )
82
85
candidates = [
@@ -112,43 +115,37 @@ def cutarelease(project_name, version_files, dry_run=False):
112
115
if answer != "yes" :
113
116
log .info ("user abort" )
114
117
return
115
- log .info ("cutting a %s release" , version )
118
+ log .info ("cutting a %s release%s " , version , dry_run_str )
116
119
117
120
# Checks: Ensure there is a section in changes for this version.
121
+
122
+
123
+
118
124
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" ]
132
128
if top_ver != version :
133
- raise Error ("top section in ` %s' is for "
129
+ raise Error ("changelog ' %s' top section says "
134
130
"version %r, expected version %r: aborting"
135
131
% (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 ) :
138
134
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" )
142
138
print "* * *"
143
139
if answer != "no" :
144
140
log .info ("abort" )
145
141
return
146
- top_body = changes_sections [0 ][2 ]
142
+ top_body = changes [0 ]["body" ]
147
143
if top_body .strip () == "(nothing yet)" :
148
144
raise Error ("top section body is `(nothing yet)': it looks like "
149
145
"nothing has been added to this release" )
150
146
151
147
# Commits to prepare release.
148
+ changes_txt_before = changes_txt
152
149
changes_txt = changes_txt .replace (" (not yet released)" , "" , 1 )
153
150
if not dry_run and changes_txt != changes_txt_before :
154
151
log .info ("prepare `%s' for release" , changes_path )
@@ -170,26 +167,34 @@ def cutarelease(project_name, version_files, dry_run=False):
170
167
answer = query_yes_no ("\n * * *\n Publish to npm?" , default = "yes" )
171
168
print "* * *"
172
169
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' )
174
174
elif exists ("setup.py" ):
175
175
answer = query_yes_no ("\n * * *\n Publish to pypi?" , default = "yes" )
176
176
print "* * *"
177
177
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 ())
180
183
181
184
# Commits to prepare for future dev and push.
182
185
# - update changelog file
183
186
next_version_info = _get_next_version_info (version_info )
184
187
next_version = _version_from_version_info (next_version_info )
185
188
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 )]
187
192
if marker not in changes_txt :
188
193
raise Error ("couldn't find `%s' marker in `%s' "
189
194
"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 ))
193
198
if not dry_run :
194
199
f = codecs .open (changes_path , 'w' , 'utf-8' )
195
200
f .write (changes_txt )
@@ -240,6 +245,9 @@ def cutarelease(project_name, version_files, dry_run=False):
240
245
241
246
#---- internal support routines
242
247
248
+ def _indent (s , indent = ' ' ):
249
+ return indent + indent .join (s .splitlines (True ))
250
+
243
251
def _tuple_from_version (version ):
244
252
def _intify (s ):
245
253
try :
@@ -287,15 +295,15 @@ def _version_info_from_version(version):
287
295
288
296
def _parse_version_file (version_file ):
289
297
"""Get version info from the given file. It can be any of:
290
-
298
+
291
299
Supported version file types (i.e. types of files from which we know
292
300
how to parse the version string/number -- often by some convention):
293
301
- json: use the "version" key
294
302
- javascript: look for a `var VERSION = "1.2.3";`
295
303
- python: Python script/module with `__version_info__ = (1, 2, 3)`
296
304
- version: a VERSION.txt or VERSION file where the whole contents are
297
305
the version string
298
-
306
+
299
307
@param version_file {str} Can be a path or "type:path", where "type"
300
308
is one of the supported types.
301
309
"""
@@ -310,11 +318,11 @@ def _parse_version_file(version_file):
310
318
}
311
319
if version_file_type in aliases :
312
320
version_file_type = aliases [version_file_type ]
313
-
321
+
314
322
f = codecs .open (version_file , 'r' , 'utf-8' )
315
323
content = f .read ()
316
324
f .close ()
317
-
325
+
318
326
if not version_file_type :
319
327
# Guess the type.
320
328
base = basename (version_file )
@@ -328,7 +336,7 @@ def _parse_version_file(version_file):
328
336
elif content .startswith ("#!" ):
329
337
shebang = content .splitlines (False )[0 ]
330
338
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 ():
332
340
if name in shebang_bits :
333
341
version_file_type = typ
334
342
break
@@ -337,7 +345,7 @@ def _parse_version_file(version_file):
337
345
if not version_file_type :
338
346
raise RuntimeError ("can't extract version from '%s': no idea "
339
347
"what type of file it it" % version_file )
340
-
348
+
341
349
if version_file_type == "json" :
342
350
obj = json .loads (content )
343
351
version_info = _version_info_from_version (obj ["version" ])
@@ -355,6 +363,100 @@ def _parse_version_file(version_file):
355
363
return version_file_type , version_info
356
364
357
365
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
+
358
460
## {{{ http://code.activestate.com/recipes/577058/ (r2)
359
461
def query_yes_no (question , default = "yes" ):
360
462
"""Ask a yes/no question via raw_input() and return their answer.
@@ -457,11 +559,11 @@ def main(argv):
457
559
help = 'Do a dry-run' , default = False )
458
560
opts , args = parser .parse_args ()
459
561
log .setLevel (opts .log_level )
460
-
562
+
461
563
cutarelease (opts .project_name , opts .version_files , dry_run = opts .dry_run )
462
564
463
565
464
- ## {{{ http://code.activestate.com/recipes/577258/ (r5)
566
+ ## {{{ http://code.activestate.com/recipes/577258/ (r5+ )
465
567
if __name__ == "__main__" :
466
568
try :
467
569
retval = main (sys .argv )
@@ -488,7 +590,6 @@ def main(argv):
488
590
log .error (exc_info [0 ])
489
591
if not skip_it :
490
592
if log .isEnabledFor (logging .DEBUG ):
491
- print ()
492
593
traceback .print_exception (* exc_info )
493
594
sys .exit (1 )
494
595
else :
0 commit comments