mirror of
https://github.com/beetbox/beets.git
synced 2025-12-06 08:39:17 +01:00
354 lines
9.4 KiB
Python
Executable file
354 lines
9.4 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
|
|
"""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*u[\'"]([0-9\.]+)[\'"]',
|
|
"__version__ = u'{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'\s*version\s*=\s*[\'"]([0-9\.]+)[\'"]',
|
|
" version='{version}',",
|
|
)
|
|
]
|
|
),
|
|
]
|
|
|
|
GITHUB_USER = 'beetbox'
|
|
GITHUB_REPO = 'beets'
|
|
|
|
|
|
def bump_version(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:
|
|
found = False
|
|
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')
|
|
|
|
found = True
|
|
break
|
|
|
|
else:
|
|
# Normal line.
|
|
out_lines.append(line)
|
|
|
|
if not found:
|
|
print("No pattern found in {}".format(filename))
|
|
|
|
# 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()
|
|
@click.argument('version')
|
|
def bump(version):
|
|
"""Bump the version number.
|
|
"""
|
|
bump_version(version)
|
|
|
|
|
|
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', '--wrap=none'],
|
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
|
)
|
|
stdout, _ = pandoc.communicate(text.encode('utf-8'))
|
|
md = stdout.decode('utf-8').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)
|
|
|
|
# Command links with command names.
|
|
rst = re.sub(r':ref:`(\w+)-cmd`', r'``\1``', rst)
|
|
|
|
# Bug numbers.
|
|
rst = re.sub(r':bug:`(\d+)`', r'#\1', rst)
|
|
|
|
# Users.
|
|
rst = re.sub(r':user:`(\w+)`', r'@\1', rst)
|
|
|
|
# Convert with Pandoc.
|
|
md = rst2md(rst)
|
|
|
|
# Restore escaped issue numbers.
|
|
md = re.sub(r'\\#(\d+)\b', r'#\1', md)
|
|
|
|
return md
|
|
|
|
|
|
@release.command()
|
|
def changelog():
|
|
"""Get the most recent version's changelog as Markdown.
|
|
"""
|
|
print(changelog_as_markdown())
|
|
|
|
|
|
def get_version(index=0):
|
|
"""Read the current version from the changelog.
|
|
"""
|
|
with open(CHANGELOG) as f:
|
|
cur_index = 0
|
|
for line in f:
|
|
match = re.search(r'^\d+\.\d+\.\d+', line)
|
|
if match:
|
|
if cur_index == index:
|
|
return match.group(0)
|
|
else:
|
|
cur_index += 1
|
|
|
|
|
|
@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)
|
|
|
|
|
|
@release.command()
|
|
def prep():
|
|
"""Run all steps to prepare a release.
|
|
|
|
- Tag the commit.
|
|
- Build the sdist package.
|
|
- Generate the Markdown changelog to ``changelog.md``.
|
|
- Bump the version number to the next version.
|
|
"""
|
|
cur_version = get_version()
|
|
|
|
# Tag.
|
|
subprocess.check_output(['git', 'tag', 'v{}'.format(cur_version)])
|
|
|
|
# Build.
|
|
with chdir(BASE):
|
|
subprocess.check_call(['python', 'setup.py', 'sdist'])
|
|
|
|
# Generate Markdown changelog.
|
|
cl = changelog_as_markdown()
|
|
with open(os.path.join(BASE, 'changelog.md'), 'w') as f:
|
|
f.write(cl)
|
|
|
|
# Version number bump.
|
|
# FIXME It should be possible to specify this as an argument.
|
|
version_parts = [int(n) for n in cur_version.split('.')]
|
|
version_parts[-1] += 1
|
|
next_version = u'.'.join(map(str, version_parts))
|
|
bump_version(next_version)
|
|
|
|
|
|
@release.command()
|
|
def publish():
|
|
"""Unleash a release unto the world.
|
|
|
|
- Push the tag to GitHub.
|
|
- Upload to PyPI.
|
|
"""
|
|
version = get_version(1)
|
|
|
|
# Push to GitHub.
|
|
with chdir(BASE):
|
|
subprocess.check_call(['git', 'push'])
|
|
subprocess.check_call(['git', 'push', '--tags'])
|
|
|
|
# Upload to PyPI.
|
|
path = os.path.join(BASE, 'dist', 'beets-{}.tar.gz'.format(version))
|
|
subprocess.check_call(['twine', 'upload', path])
|
|
|
|
|
|
@release.command()
|
|
def ghrelease():
|
|
"""Create a GitHub release using the `github-release` command-line
|
|
tool.
|
|
|
|
Reads the changelog to upload from `changelog.md`. Uploads the
|
|
tarball from the `dist` directory.
|
|
"""
|
|
version = get_version(1)
|
|
tag = 'v' + version
|
|
|
|
# Load the changelog.
|
|
with open(os.path.join(BASE, 'changelog.md')) as f:
|
|
cl_md = f.read()
|
|
|
|
# Create the release.
|
|
subprocess.check_call([
|
|
'github-release', 'release',
|
|
'-u', GITHUB_USER, '-r', GITHUB_REPO,
|
|
'--tag', tag,
|
|
'--name', '{} {}'.format(GITHUB_REPO, version),
|
|
'--description', cl_md,
|
|
])
|
|
|
|
# Attach the release tarball.
|
|
tarball = os.path.join(BASE, 'dist', 'beets-{}.tar.gz'.format(version))
|
|
subprocess.check_call([
|
|
'github-release', 'upload',
|
|
'-u', GITHUB_USER, '-r', GITHUB_REPO,
|
|
'--tag', tag,
|
|
'--name', os.path.basename(tarball),
|
|
'--file', tarball,
|
|
])
|
|
|
|
|
|
if __name__ == '__main__':
|
|
release()
|