"""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\d+\.\d+\.\d+)\] - " r"(?P\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 == "
" or line == "---" or (line.startswith("")) ) 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())