beets/extra/release.py
2024-06-07 09:01:44 +01:00

159 lines
4.2 KiB
Python
Executable file

#!/usr/bin/env python3
"""A utility script for automating the beets release process.
"""
from __future__ import annotations
import re
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable
import click
from packaging.version import Version, parse
BASE = Path(__file__).parent.parent.absolute()
BEETS_INIT = BASE / "beets" / "__init__.py"
CHANGELOG = BASE / "docs" / "changelog.rst"
MD_CHANGELOG_SECTION_LIST = re.compile(r"- .+?(?=\n\n###|$)", re.DOTALL)
version_header = r"\d+\.\d+\.\d+ \([^)]+\)"
RST_LATEST_CHANGES = re.compile(
rf"{version_header}\n--+\s+(.+?)\n\n+{version_header}", re.DOTALL
)
def update_docs_config(text: str, new: Version) -> str:
new_major_minor = f"{new.major}.{new.minor}"
text = re.sub(r"(?<=version = )[^\n]+", f'"{new_major_minor}"', text)
return re.sub(r"(?<=release = )[^\n]+", f'"{new}"', text)
def update_changelog(text: str, new: Version) -> str:
new_header = f"{new} ({datetime.now(timezone.utc).date():%B %d, %Y})"
return re.sub(
# do not match if the new version is already present
r"\nUnreleased\n--+\n",
rf"""
Unreleased
----------
Changelog goes here! Please add your entry to the bottom of one of the lists below!
{new_header}
{'-' * len(new_header)}
""",
text,
)
UpdateVersionCallable = Callable[[str, Version], str]
FILENAME_AND_UPDATE_TEXT: list[tuple[Path, UpdateVersionCallable]] = [
(
BEETS_INIT,
lambda text, new: re.sub(
r"(?<=__version__ = )[^\n]+", f'"{new}"', text
),
),
(CHANGELOG, update_changelog),
(BASE / "docs" / "conf.py", update_docs_config),
]
def validate_new_version(
ctx: click.Context, param: click.Argument, value: Version
) -> Version:
"""Validate the version is newer than the current one."""
with BEETS_INIT.open() as f:
contents = f.read()
m = re.search(r'(?<=__version__ = ")[^"]+', contents)
assert m, "Current version not found in __init__.py"
current = parse(m.group())
if not value > current:
msg = f"version must be newer than {current}"
raise click.BadParameter(msg)
return value
def bump_version(new: Version) -> None:
"""Update the version number in specified files."""
for path, perform_update in FILENAME_AND_UPDATE_TEXT:
with path.open("r+") as f:
contents = f.read()
f.seek(0)
f.write(perform_update(contents, new))
f.truncate()
def rst2md(text: str) -> str:
"""Use Pandoc to convert text from ReST to Markdown."""
# Other backslashes with verbatim ranges.
rst = re.sub(r"(?<=[\s(])`([^`]+)`(?=[^_])", r"``\1``", text)
# Bug numbers.
rst = re.sub(r":bug:`(\d+)`", r":bug: (#\1)", rst)
# Users.
rst = re.sub(r":user:`(\w+)`", r"@\1", rst)
return (
subprocess.check_output(
["/usr/bin/pandoc", "--from=rst", "--to=gfm", "--wrap=none"],
input=rst.encode(),
)
.decode()
.strip()
)
def changelog_as_markdown() -> str:
"""Get the latest changelog entry as hacked up Markdown."""
with CHANGELOG.open() as f:
contents = f.read()
m = RST_LATEST_CHANGES.search(contents)
rst = m.group(1) if m else ""
# Convert with Pandoc.
md = rst2md(rst)
# Make sections stand out
md = re.sub(r"^(\w.+?):$", r"### \1", md, flags=re.M)
# Highlight plugin names
md = re.sub(
r"^- `/?plugins/(\w+)`:?", r"- Plugin **`\1`**:", md, flags=re.M
)
# Highlights command names.
md = re.sub(r"^- `(\w+)-cmd`:?", r"- Command **`\1`**:", md, flags=re.M)
# sort list items alphabetically for each of the sections
return MD_CHANGELOG_SECTION_LIST.sub(
lambda m: "\n".join(sorted(m.group().splitlines())), md
)
@click.group()
def cli():
pass
@cli.command()
@click.argument("version", type=Version, callback=validate_new_version)
def bump(version: Version) -> None:
"""Bump the version in project files."""
bump_version(version)
@cli.command()
def changelog():
"""Get the most recent version's changelog as Markdown."""
print(changelog_as_markdown())
if __name__ == "__main__":
cli()