#!/usr/bin/env python3 """A utility script for automating the beets release process. """ import click import os import re import subprocess from contextlib import contextmanager import datetime BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) CHANGELOG = os.path.join(BASE, 'docs', 'changelog.rst') @contextmanager def chdir(d): """A context manager that temporary changes the working directory. """ olddir = os.getcwd() os.chdir(d) yield os.chdir(olddir) @click.group() def release(): pass # Locations (filenames and patterns) of the version number. VERSION_LOCS = [ ( os.path.join(BASE, 'beets', '__init__.py'), [ ( r'__version__\s*=\s*[\'"]([0-9\.]+)[\'"]', "__version__ = '{version}'", ) ] ), ( os.path.join(BASE, 'docs', 'conf.py'), [ ( r'version\s*=\s*[\'"]([0-9\.]+)[\'"]', "version = '{minor}'", ), ( r'release\s*=\s*[\'"]([0-9\.]+)[\'"]', "release = '{version}'", ), ] ), ( os.path.join(BASE, 'setup.py'), [ ( r'version\s*=\s*[\'"]([0-9\.]+)[\'"]', " version='{minor}',", ) ] ), ] @release.command() @click.argument('version') def bump(version): """Update the version number in setup.py, docs config, changelog, and root module. """ version_parts = [int(p) for p in version.split('.')] assert len(version_parts) == 3, "invalid version number" minor = '{}.{}'.format(*version_parts) major = '{}'.format(*version_parts) # Replace the version each place where it lives. for filename, locations in VERSION_LOCS: # Read and transform the file. out_lines = [] with open(filename) as f: for line in f: for pattern, template in locations: match = re.match(pattern, line) if match: # Check that this version is actually newer. old_version = match.group(1) old_parts = [int(p) for p in old_version.split('.')] assert version_parts > old_parts, \ "version must be newer than {}".format( old_version ) # Insert the new version. out_lines.append(template.format( version=version, major=major, minor=minor, ) + '\n') break else: # Normal line. out_lines.append(line) # Write the file back. with open(filename, 'w') as f: f.write(''.join(out_lines)) # Generate bits to insert into changelog. header_line = '{} (in development)'.format(version) header = '\n\n' + header_line + '\n' + '-' * len(header_line) + '\n\n' header += 'Changelog goes here!\n' # Insert into the right place. with open(CHANGELOG) as f: contents = f.read() location = contents.find('\n\n') # First blank line. contents = contents[:location] + header + contents[location:] # Write back. with open(CHANGELOG, 'w') as f: f.write(contents) @release.command() def build(): """Use `setup.py` to build a source tarball. """ with chdir(BASE): subprocess.check_call(['python2', 'setup.py', 'sdist']) def get_latest_changelog(): """Extract the first section of the changelog. """ started = False lines = [] with open(CHANGELOG) as f: for line in f: if re.match(r'^--+$', line.strip()): # Section boundary. Start or end. if started: # Remove last line, which is the header of the next # section. del lines[-1] break else: started = True elif started: lines.append(line) return ''.join(lines).strip() def rst2md(text): """Use Pandoc to convert text from ReST to Markdown. """ pandoc = subprocess.Popen( ['pandoc', '--from=rst', '--to=markdown', '--no-wrap'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) stdout, _ = pandoc.communicate(text.encode('utf8')) md = stdout.decode('utf8').strip() # Fix up odd spacing in lists. return re.sub(r'^- ', '- ', md, flags=re.M) def changelog_as_markdown(): """Get the latest changelog entry as hacked up Markdown. """ rst = get_latest_changelog() # Replace plugin links with plugin names. rst = re.sub(r':doc:`/plugins/(\w+)`', r'``\1``', rst) # References with text. rst = re.sub(r':ref:`([^<]+)(<[^>]+>)`', r'\1', rst) # Other backslashes with verbatim ranges. rst = re.sub(r'(\s)`([^`]+)`([^_])', r'\1``\2``\3', rst) return rst2md(rst) @release.command() def changelog(): """Get the most recent version's changelog as Markdown. """ print(changelog_as_markdown()) @release.command() @click.argument('version') def upload(version): """Upload the release to PyPI. """ path = os.path.join(BASE, 'dist', 'beets-{}.tar.gz') subprocess.check_call(['twine', 'upload', path]) def get_version(): """Read the current version from the changelog. """ with open(CHANGELOG) as f: for line in f: match = re.search(r'\d+\.\d+\.\d+', line) if match: return match.group(0) @release.command() def version(): """Display the current version. """ print(get_version()) @release.command() def datestamp(): """Enter today's date as the release date in the changelog. """ dt = datetime.datetime.now() stamp = '({} {}, {})'.format(dt.strftime('%B'), dt.day, dt.year) marker = '(in development)' lines = [] underline_length = None with open(CHANGELOG) as f: for line in f: if marker in line: # The header line. line = line.replace(marker, stamp) lines.append(line) underline_length = len(line.strip()) elif underline_length: # This is the line after the header. Rewrite the dashes. lines.append('-' * underline_length + '\n') underline_length = None else: lines.append(line) with open(CHANGELOG, 'w') as f: for line in lines: f.write(line) if __name__ == '__main__': release()