#!/usr/bin/env python3 """Extract one validated Keep a Changelog version section.""" from __future__ import annotations import argparse from datetime import date from pathlib import Path import re import sys class ChangelogError(ValueError): """A release cannot safely use the supplied changelog.""" _SEMVER = r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" _VERSION_HEADING = re.compile( rf"^## \[(?P{_SEMVER})\] - (?P.+)$", re.MULTILINE ) def extract_version_section(changelog: str, version: str) -> str: """Return *version*'s changelog section without altering its bytes. The section ends immediately before the next level-two heading. A release cannot use an absent, empty, or malformed version section. """ if not re.fullmatch(_SEMVER, version): raise ChangelogError(f"requested version is not bare semver: {version!r}") heading = next( (match for match in _VERSION_HEADING.finditer(changelog) if match.group("version") == version), None, ) if heading is None: if re.search(rf"^## \[{re.escape(version)}\].*$", changelog, re.MULTILINE): raise ChangelogError(f"version {version} has a malformed heading or date") raise ChangelogError(f"version {version} is missing from the changelog") heading_date = heading.group("date") if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", heading_date): raise ChangelogError(f"version {version} has a malformed release date") try: date.fromisoformat(heading_date) except ValueError as error: raise ChangelogError(f"version {version} has a malformed release date") from error next_heading = re.search(r"^## ", changelog[heading.end():], re.MULTILINE) section_end = heading.end() + next_heading.start() if next_heading else len(changelog) section = changelog[heading.start():section_end] if not re.search(r"^- \S", section[heading.end() - heading.start():], re.MULTILINE): raise ChangelogError(f"version {version} has an empty changelog section") return section def extract_changelog(path: Path, version: str) -> str: """Read and extract a requested version from a changelog file.""" try: return extract_version_section(path.read_text(encoding="utf-8"), version) except OSError as error: raise ChangelogError(f"cannot read changelog {path}: {error.strerror}") from error def assemble_release_body(section: str, footer: str) -> str: """Append standing guidance while preserving the extracted section verbatim.""" separator = "\n" if section.endswith("\n") else "\n\n" return f"{section}{separator}{footer}" def format_availability_section( available: list[str], withheld: list[str] ) -> str: """Generate a package formats section for release notes.""" if not available and not withheld: return "" lines = ["\n## Package formats\n"] if available: lines.append(f"Available: {', '.join(available)}") if withheld: lines.append(f"Withheld: {', '.join(withheld)}") return "\n".join(lines) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("changelog", type=Path) parser.add_argument("version") parser.add_argument( "--footer", type=Path, help="append this standing release guidance after the extracted section", ) parser.add_argument( "--available", action="append", default=[], help="format available for this release (can be repeated)", ) parser.add_argument( "--withheld", action="append", default=[], help="format withheld from this release (can be repeated)", ) args = parser.parse_args(argv) try: section = extract_changelog(args.changelog, args.version) if args.footer: try: footer = args.footer.read_text(encoding="utf-8") except OSError as error: raise ChangelogError( f"cannot read release footer {args.footer}: {error.strerror}" ) from error section = assemble_release_body(section, footer) if args.available or args.withheld: formats = format_availability_section(args.available, args.withheld) if formats: section = f"{section}\n{formats}" sys.stdout.write(section) except ChangelogError as error: print(f"::error::{error}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": raise SystemExit(main())