Skip to content

Commit 8a0cfee

Browse files
committed
Add changelog2md utility script
Makes running releases slightly simpler, as there's less manual text-munging to produce the GitHub release notes.
1 parent 5341df5 commit 8a0cfee

File tree

1 file changed

+115
-0
lines changed

1 file changed

+115
-0
lines changed

scripts/changelog2md.py

+115
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#!/usr/bin/env python
2+
"""
3+
Extract a changelog section from the full changelog contents, convert ReST and
4+
sphinx-issues syntaxes to GitHub-flavored Markdown, and print the results.
5+
6+
Defaults to selecting the most recent (topmost) changelog section.
7+
Can alternatively provide output for a specific version with `--target`.
8+
e.g.
9+
10+
./scripts/changelog2md.py --target 3.20.0
11+
"""
12+
from __future__ import annotations
13+
14+
import argparse
15+
import pathlib
16+
import re
17+
import typing as t
18+
19+
REPO_ROOT = pathlib.Path(__file__).parent.parent
20+
CHANGELOG_PATH = REPO_ROOT / "CHANGELOG.rst"
21+
22+
CHANGELOG_HEADER_PATTERN = re.compile(r"^(\d+\.\d+\.\d+).*$", re.MULTILINE)
23+
24+
H2_RST_PATTERN = re.compile(r"-+")
25+
H3_RST_PATTERN = re.compile(r"~+")
26+
27+
SPHINX_ISSUES_PR_PATTERN = re.compile(r":pr:`(\d+)`")
28+
SPHINX_ISSUES_ISSUE_PATTERN = re.compile(r":issue:`(\d+)`")
29+
SPHINX_ISSUES_USER_PATTERN = re.compile(r":user:`([^`]+)`")
30+
31+
32+
def _trim_empty_lines(lines: list[str]) -> None:
33+
if not lines:
34+
return
35+
while lines[0] == "":
36+
lines.pop(0)
37+
while lines[-1] == "":
38+
lines.pop()
39+
40+
41+
def _iter_target_section(target: str | None, changelog_content: str) -> t.Iterator[str]:
42+
started = False
43+
for line in changelog_content.split("\n"):
44+
if m := CHANGELOG_HEADER_PATTERN.match(line):
45+
if not started:
46+
if target is None or m.group(1) == target:
47+
started = True
48+
continue
49+
else:
50+
return
51+
if H2_RST_PATTERN.fullmatch(line):
52+
continue
53+
if started:
54+
yield line
55+
56+
57+
def get_last_changelog(changelog_content: str) -> list[str]:
58+
latest_changes = list(_iter_target_section(None, changelog_content))
59+
_trim_empty_lines(latest_changes)
60+
return latest_changes
61+
62+
63+
def get_changelog_section(target: str, changelog_content: str) -> list[str]:
64+
lines = list(_iter_target_section(target, changelog_content))
65+
_trim_empty_lines(lines)
66+
return lines
67+
68+
69+
def convert_rst_to_md(lines: list[str]) -> t.Iterator[str]:
70+
skip = False
71+
for i, line in enumerate(lines):
72+
if skip:
73+
skip = False
74+
continue
75+
76+
try:
77+
peek = lines[i + 1]
78+
except IndexError:
79+
peek = None
80+
81+
updated = line
82+
83+
if peek is not None and H3_RST_PATTERN.fullmatch(peek):
84+
skip = True
85+
updated = f"## {updated}"
86+
87+
updated = SPHINX_ISSUES_PR_PATTERN.sub(r"#\1", updated)
88+
updated = SPHINX_ISSUES_ISSUE_PATTERN.sub(r"#\1", updated)
89+
updated = SPHINX_ISSUES_USER_PATTERN.sub(r"@\1", updated)
90+
updated = updated.replace("``", "`")
91+
yield updated
92+
93+
94+
def main():
95+
parser = argparse.ArgumentParser(
96+
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
97+
)
98+
parser.add_argument(
99+
"--target", "-t", help="A target version to use. Defaults to latest."
100+
)
101+
args = parser.parse_args()
102+
103+
full_changelog = CHANGELOG_PATH.read_text()
104+
105+
if args.target:
106+
changelog_section = get_changelog_section(args.target, full_changelog)
107+
else:
108+
changelog_section = get_last_changelog(full_changelog)
109+
110+
for line in convert_rst_to_md(changelog_section):
111+
print(line)
112+
113+
114+
if __name__ == "__main__":
115+
main()

0 commit comments

Comments
 (0)