kscrape / src /kscrape_dev /packaging /release_notes.py
kscrape
chore: restore repository maintenance standards
69c00d1 unverified
Raw History Blame Contribute Delete
10 kB
"""Build release notes from kscrape's curated changelog."""
from __future__ import annotations
import argparse
import re
from collections.abc import Sequence
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
VERSION_RE = re.compile(r"^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$")
RELEASE_HEADING_RE = re.compile(
r"^# \[(?P<version>\d+\.\d+\.\d+)\] - "
r"(?P<date>\d{4}-\d{2}-\d{2})$",
re.MULTILINE,
)
UNRELEASED_HEADING = "# [Unreleased]"
UNRELEASED_SECTIONS = (
"### 💥 Breaking Change Summary",
"### ➕ Added",
"### 💔 Changed",
"### ⚠️ Deprecated",
"### 🗑️ Removed",
"### 🔨 Fixed",
)
def build_release_notes(markdown: str, version: str) -> str:
"""Extract one released changelog section for an annotated tag.
:param markdown: Complete changelog text.
:param version: Release version without a leading ``v``.
:return: Markdown body for the release tag.
"""
validate_version(version)
validate_unreleased_section(markdown)
match = _release_heading(markdown, version)
_validate_toc_entry(markdown, match)
next_heading = re.search(r"^# \[", markdown[match.end() :], re.MULTILINE)
section_end = (
match.end() + next_heading.start()
if next_heading is not None
else len(markdown)
)
notes = _clean_release_markdown(markdown[match.end() : section_end])
if not any(line.lstrip().startswith("- ") for line in notes.splitlines()):
raise ValueError(f"Release {version} has no changelog entries.")
return f"{notes}\n"
def validate_version(version: str) -> None:
"""Reject versions that do not use three non-zero-padded components.
:param version: Candidate release version without a leading ``v``.
:return: None.
"""
if VERSION_RE.fullmatch(version) is None:
raise ValueError(
f"Invalid release version {version!r}; expected MAJOR.MINOR.PATCH."
)
def validate_tag_version(tag: str, project_version: str) -> None:
"""Require an annotated tag to name the package version exactly.
:param tag: Git tag expected to use the ``vMAJOR.MINOR.PATCH`` form.
:param project_version: Version declared in package metadata.
:return: None.
"""
validate_version(project_version)
expected_tag = f"v{project_version}"
if tag != expected_tag:
raise ValueError(
f"Release tag {tag!r} does not match project version "
f"{project_version!r}; expected {expected_tag!r}."
)
def validate_next_version(current_version: str, next_version: str) -> None:
"""Require a prepared changelog release to advance package metadata.
:param current_version: Version currently declared in package metadata.
:param next_version: Version prepared in the changelog.
:return: None.
"""
validate_version(current_version)
validate_version(next_version)
current_key = tuple(int(part) for part in current_version.split("."))
next_key = tuple(int(part) for part in next_version.split("."))
if next_key <= current_key:
raise ValueError(
f"Prepared release {next_version!r} must be newer than "
f"the current project version {current_version!r}."
)
def latest_release_version(markdown: str) -> str:
"""Return the highest version declared in the changelog.
:param markdown: Complete changelog text.
:return: Highest released semantic version.
"""
matches = list(RELEASE_HEADING_RE.finditer(markdown))
if not matches:
raise ValueError("Changelog has no versioned release heading.")
return max(
(match.group("version") for match in matches),
key=lambda value: tuple(int(part) for part in value.split(".")),
)
def validate_unreleased_section(markdown: str) -> None:
"""Require a complete and empty ``[Unreleased]`` release template.
:param markdown: Complete changelog text.
:return: None.
"""
if markdown.count(UNRELEASED_HEADING) != 1:
raise ValueError(
"Changelog must contain exactly one [Unreleased] heading."
)
start = markdown.index(UNRELEASED_HEADING) + len(UNRELEASED_HEADING)
next_release = RELEASE_HEADING_RE.search(markdown, start)
if next_release is None:
raise ValueError(
"Changelog has no released section after [Unreleased]."
)
section = markdown[start : next_release.start()]
for heading in UNRELEASED_SECTIONS:
if section.count(heading) != 1:
raise ValueError(
"The [Unreleased] section must contain each release category "
f"exactly once; invalid heading: {heading!r}."
)
content_lines = [
line for line in _content_lines(section) if not line.startswith("### ")
]
if content_lines:
raise ValueError(
"The [Unreleased] section still contains release entries. Move "
"them into the prepared version before tagging."
)
def write_release_notes(markdown: str, version: str, output: Path) -> None:
"""Write a released changelog section to a Markdown file.
:param markdown: Complete changelog text.
:param version: Release version without a leading ``v``.
:param output: Destination Markdown path.
:return: None.
"""
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(build_release_notes(markdown, version), encoding="utf-8")
def _release_heading(markdown: str, version: str) -> re.Match[str]:
"""Find the unique heading for one version.
:param markdown: Complete changelog text.
:param version: Release version without a leading ``v``.
:return: Matched version heading.
"""
matches = [
match
for match in RELEASE_HEADING_RE.finditer(markdown)
if match.group("version") == version
]
if len(matches) != 1:
raise ValueError(
f"Changelog must contain exactly one release heading for {version}; "
f"found {len(matches)}."
)
return matches[0]
def _validate_toc_entry(markdown: str, match: re.Match[str]) -> None:
"""Require a release heading to appear once in the changelog table of contents.
:param markdown: Complete changelog text.
:param match: Release heading whose table-of-contents entry is required.
:return: None.
"""
version = match.group("version")
date = match.group("date")
entry = f"[\\[{version}\\] - {date}](#{version.replace('.', '')}---{date})"
if markdown.count(entry) != 1:
raise ValueError(
f"Changelog table of contents must contain exactly one {entry!r} entry."
)
def _clean_release_markdown(section: str) -> str:
"""Remove layout markers while retaining headings and release entries.
:param section: Changelog text after a version heading.
:return: Compact release-note Markdown.
"""
cleaned: list[str] = []
previous_blank = True
for line in section.splitlines():
stripped = line.strip()
if _is_layout_line(stripped):
continue
if line.startswith("### "):
line = line[1:]
if not stripped:
if not previous_blank:
cleaned.append("")
previous_blank = True
continue
cleaned.append(line)
previous_blank = False
while cleaned and not cleaned[-1]:
cleaned.pop()
return "\n".join(cleaned)
def _content_lines(section: str) -> list[str]:
"""Return non-empty changelog lines that are not layout markers.
:param section: One changelog section.
:return: Content lines stripped of surrounding whitespace.
"""
return [
line.strip()
for line in section.splitlines()
if line.strip() and not _is_layout_line(line.strip())
]
def _is_layout_line(line: str) -> bool:
"""Identify a Markdown line used only for changelog spacing.
:param line: Stripped Markdown line.
:return: Whether the line contains no release content.
"""
return (
line == "<br>"
or line == "---"
or (line.startswith("<!--") and line.endswith("-->"))
)
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
"""Parse release-note extraction arguments.
:param argv: Optional argument vector used by tests.
:return: Parsed command arguments.
"""
parser = argparse.ArgumentParser(
description="Extract one release body from CHANGELOG.md."
)
parser.add_argument("version", help="Release version without a leading v.")
parser.add_argument(
"--changelog",
type=Path,
default=ROOT / "CHANGELOG.md",
help="Changelog source path.",
)
parser.add_argument(
"--output", type=Path, required=True, help="Destination path."
)
parser.add_argument("--tag", help="Optional tag to validate.")
parser.add_argument(
"--project-version", help="Optional package version to validate."
)
return parser.parse_args(argv)
def main(argv: Sequence[str] | None = None) -> int:
"""Validate release state and write a release-note file.
:param argv: Optional argument vector used by tests.
:return: Process exit code.
"""
args = parse_args(argv)
if (args.tag is None) != (args.project_version is None):
raise ValueError(
"--tag and --project-version must be provided together."
)
if args.tag is not None:
validate_tag_version(args.tag, args.project_version)
if args.version != args.project_version:
raise ValueError(
f"Requested release version {args.version!r} does not match "
f"project version {args.project_version!r}."
)
write_release_notes(
args.changelog.read_text(encoding="utf-8"), args.version, args.output
)
return 0
if __name__ == "__main__":
raise SystemExit(main())