#!/usr/bin/env python3 """A utility script for automating the beets release process. """ import datetime import os import re import subprocess from contextlib import contextmanager import click 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'\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(f"No pattern found in {filename}") # Write the file back. with open(filename, "w") as f: f.write("".join(out_lines)) # Generate bits to insert into changelog. header_line = f"{version} (in development)" 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_call(["git", "tag", f"v{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 = ".".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", f"beets-{version}.tar.gz") 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", f"{GITHUB_REPO} {version}", "--description", cl_md, ] ) # Attach the release tarball. tarball = os.path.join(BASE, "dist", f"beets-{version}.tar.gz") 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()