"""Combine per-branch changelog entries into a package CHANGELOG.md. Usage: python scripts/combine-changelogs.py PACKAGE Reads all .md files from packages//changelogs/, combines them by subsection (Enhancements / Features / Fixes), prepends the result to packages//CHANGELOG.md, and deletes the source entries. """ from __future__ import annotations import os import sys import warnings from collections import OrderedDict from pathlib import Path import tomlkit REPO_ROOT = Path(__file__).resolve().parent.parent PACKAGES_DIR = REPO_ROOT / "packages" SUBSECTION_TYPES = ["### Enhancements", "### Features", "### Fixes"] def ensure_changelog_folder_purity(folder: Path) -> None: """Raise if the changelogs folder contains non-.md files.""" if not folder.exists(): return for name in os.listdir(folder): if not name.endswith(".md"): msg = ( f"Found non-markdown file {name!r} in {folder}. " "Changelogs must be .md files." ) raise ValueError(msg) def parse_subsections(path: Path) -> dict[str, list[str]]: """Extract subsection -> lines mapping from a changelog entry.""" subsections: dict[str, list[str]] = {} current: str | None = None for line in path.read_text().splitlines(): if any(st in line for st in SUBSECTION_TYPES): current = line.strip() subsections[current] = [] elif line.strip() and current is not None: subsections[current].append(line.strip().lstrip("-").lstrip(" ")) return subsections def combine_files(folder: Path) -> dict[str, list[str]]: """Combine subsections from all changelog entries in a folder.""" ensure_changelog_folder_purity(folder) combined: dict[str, list[str]] = {} if not folder.exists(): return combined for name in sorted(os.listdir(folder)): if not name.endswith(".md"): warnings.warn( f"Ignoring non-markdown file {name!r} in {folder}.", stacklevel=2, ) continue for section, lines in parse_subsections(folder / name).items(): combined.setdefault(section, []).extend(lines) return combined def serialize(combined: dict[str, list[str]], version: str) -> str: """Format combined subsections as a markdown release block.""" out = f"## {version}" for section, lines in combined.items(): out += f"\n\n{section}" for line in lines: out += f"\n- {line}" out += "\n\n" return out def main() -> None: """Combine changelogs for a package and update CHANGELOG.md.""" if len(sys.argv) < 2: print("Usage: python scripts/combine-changelogs.py PACKAGE") sys.exit(1) package = sys.argv[1] pkg_dir = PACKAGES_DIR / package changelogs_dir = pkg_dir / "changelogs" changelog_md = pkg_dir / "CHANGELOG.md" pyproject = pkg_dir / "pyproject.toml" if not pyproject.exists(): print(f"No pyproject.toml at {pyproject}") sys.exit(1) data = tomlkit.parse(pyproject.read_text()) version = str(data["project"]["version"]) combined = OrderedDict(sorted(combine_files(changelogs_dir).items())) if not any(combined.values()): print(f"{package}: no changelog entries to combine.") return updates = serialize(combined, version) existing = changelog_md.read_text() if changelog_md.exists() else "" changelog_md.write_text(updates + existing) print(f"{package}: updated {changelog_md.relative_to(REPO_ROOT)}") # Clean up individual entries. if changelogs_dir.exists(): for entry in changelogs_dir.iterdir(): entry.unlink() print(f"{package}: cleaned up {changelogs_dir.relative_to(REPO_ROOT)}/") if __name__ == "__main__": main()