#!/usr/bin/env python3 """changelog-assemble.py: the changelog's entries, one a file, rendered into its Unreleased section for a release. A change adds its entry as a file of `wai/changes/` instead of editing the head of `wai/CHANGELOG.md`, so that two changes' entries never edit the same line. For a release this puts every entry at the head of `## Unreleased`, in a stable order, and removes the files. changelog-assemble.py [--changelog wai/CHANGELOG.md] [--fragments wai/changes] [--check | --write] With neither flag it prints the Unreleased section as it would be, changing nothing. --check checks every file and exits 1 with one line a finding. --write writes the changelog and removes the files it took. A file is named `-.md` (the date it was written and a few words of its title, lowercase, joined by hyphens) and holds exactly one entry in the changelog's form: a `### ` title line, a blank line, and the entry's text, with no other heading outside a code block. The entries go newest date first and, within a date, by file name, each separated from the next by one blank line; the same files give the same bytes. `README.md` is not an entry. Standard library only. """ from __future__ import annotations import datetime import os import re import sys NAME = re.compile(r"^(\d{4})-(\d{2})-(\d{2})-([a-z0-9]+(?:-[a-z0-9]+)*)\.md$") UNRELEASED = "## Unreleased" def fragment_files(directory): """The entry files of `directory`, by name: every `*.md` but README.md.""" if not os.path.isdir(directory): return [] return sorted(f for f in os.listdir(directory) if f.endswith(".md") and f != "README.md") def check_fragment(name, text): """Every finding about one entry file: its name and its form.""" out = [] m = NAME.match(name) if not m: out.append(f"{name}: not named -.md (a date, then lowercase words joined by hyphens)") else: try: datetime.date(int(m.group(1)), int(m.group(2)), int(m.group(3))) except ValueError: out.append(f"{name}: {m.group(1)}-{m.group(2)}-{m.group(3)} is not a date") if "\r" in text: out.append(f"{name}: has carriage returns; lines end with \\n alone") if not text.endswith("\n") or text.endswith("\n\n"): out.append(f"{name}: does not end with exactly one newline") lines = text.rstrip("\n").split("\n") if not lines[0].startswith("### ") or not lines[0][4:].strip(): out.append(f"{name}: its first line is not the entry's title (`### `)") if len(lines) < 3 or lines[1] != "" or not "".join(lines[2:]).strip(): out.append(f"{name}: the title is not followed by a blank line and the entry's text") fence = False for n, line in enumerate(lines[1:], 2): if line.lstrip().startswith("```"): fence = not fence continue if not fence and re.match(r"^#{1,6} ", line): out.append(f"{name}: line {n}: a heading inside the entry; an entry is one `### ` title and its text") if line != line.rstrip(): out.append(f"{name}: line {n}: trailing white space") if fence: out.append(f"{name}: a code block is not closed") return out def order_key(name): """Newest date first; within a date, by name.""" m = NAME.match(name) date = (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else (0, 0, 0) return (tuple(-x for x in date), name) def entries(directory): """[(name, text)] of the entry files, in the order they are rendered, and the findings.""" found, bad = [], [] for name in fragment_files(directory): with open(os.path.join(directory, name), encoding="utf-8", newline="") as fh: text = fh.read() bad += check_fragment(name, text) found.append((name, text)) found.sort(key=lambda e: order_key(e[0])) return found, bad def assemble(changelog_text, items): """The changelog with the entries at the head of its Unreleased section.""" lines = changelog_text.split("\n") try: at = lines.index(UNRELEASED) except ValueError: raise SystemExit(f"changelog-assemble: the changelog has no `{UNRELEASED}` line") if not items: return changelog_text head = "\n".join(lines[: at + 1]) rest = "\n".join(lines[at + 1:]).lstrip("\n") block = "\n".join(text.rstrip("\n") + "\n" for _, text in items) return head + "\n\n" + block + ("\n" + rest if rest else "") def main(argv): args = list(argv) def opt(name, default): if name in args: i = args.index(name) v = args[i + 1] del args[i:i + 2] return v return default changelog = opt("--changelog", "wai/CHANGELOG.md") directory = opt("--fragments", "wai/changes") mode = args[0] if args else "print" if mode not in ("print", "--check", "--write") or len(args) > 1: print(__doc__) return 2 items, bad = entries(directory) if mode == "--check" or bad: for b in bad: print(f"{directory}/{b}") if bad: print(f"changelog-assemble: {len(bad)} finding(s); nothing was written") return 1 print(f"changelog-assemble: every entry under {directory}/ is one entry in the changelog's form.") return 0 with open(changelog, encoding="utf-8", newline="") as fh: text = fh.read() out = assemble(text, items) if mode == "print": lines = out.split("\n") at = lines.index(UNRELEASED) end = next((i for i in range(at + 1, len(lines)) if lines[i].startswith("## ")), len(lines)) sys.stdout.write("\n".join(lines[at:end]).rstrip("\n") + "\n") return 0 with open(changelog, "w", encoding="utf-8", newline="") as fh: fh.write(out) for name, _ in items: os.remove(os.path.join(directory, name)) print(f"changelog-assemble: {len(items)} entr{'y' if len(items) == 1 else 'ies'} written to {changelog}, and their files removed") return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))