Skip to content

Commit d6fdb1f

Browse files
committed
ci_scripts: update_doc.py: add
Add a new version ofthe update_doc.py script from wheel_builder, converted using Claude. Compared to the old one, we just generate, parse, and update Markdown files directly for each package, and do it in docs/packages/<package>.md instead of the longer docs/source/packages/<package>.yaml. AI-Generated: Uses Claude Code Sonnet 5 Signed-off-by: Trevor Gamblin <tgamblin@baylibre.com>
1 parent b23b017 commit d6fdb1f

1 file changed

Lines changed: 304 additions & 0 deletions

File tree

ci_scripts/update_doc.py

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
#!/usr/bin/env python3
2+
# SPDX-FileCopyrightText: 2025 BayLibre, SAS
3+
# SPDX-FileCopyrightText: 2026 The RISE Project
4+
# SPDX-License-Identifier: MIT
5+
"""
6+
Extract metadata from a just-built riscv64 wheel, add or update the
7+
corresponding docs/packages/<name>.md page with the new version, and open a
8+
pull request with the change.
9+
10+
Based on the script at:
11+
https://gitlab.com/riseproject/python/wheel_builder/-/blob/main/ci_scripts/update_doc.py
12+
13+
That script wrote a small YAML file per package, which a separate stage later
14+
rendered into a Sphinx page. Since python-wheels' docs/packages/*.md pages are
15+
hand-off Markdown with no such intermediate source, this script edits the
16+
rendered page directly instead of re-introducing a YAML layer.
17+
"""
18+
19+
import os
20+
import re
21+
import string
22+
import subprocess
23+
import sys
24+
import zipfile
25+
from email.message import Message
26+
from email.parser import Parser
27+
from pathlib import Path
28+
29+
REPO = "riseproject-dev/python-wheels"
30+
REGISTRY_URL = "https://pypi.riseproject.dev/simple/"
31+
DOCS_DIR = Path("docs/packages")
32+
PACKAGES_FILE = Path("ci_scripts/packages.txt")
33+
ARTIFACTS_PATH = os.environ.get("ARTIFACTS_PATH", "dist")
34+
35+
36+
def find_wheel_file(path):
37+
for file in Path(path).glob("*.whl"):
38+
return file
39+
return None
40+
41+
42+
def normalize_name(name):
43+
"""
44+
https://packaging.python.org/en/latest/specifications/name-normalization/#name-normalization
45+
"""
46+
return re.sub(r"[-_.]+", "-", name).lower()
47+
48+
49+
def normalize_label(label):
50+
"""
51+
https://packaging.python.org/en/latest/specifications/well-known-project-urls/#label-normalization
52+
"""
53+
chars_to_remove = string.punctuation + string.whitespace
54+
removal_map = str.maketrans("", "", chars_to_remove)
55+
return label.translate(removal_map).lower()
56+
57+
58+
def extract_license(message):
59+
license = message.get("License-Expression")
60+
if not license:
61+
license = message.get("License", "Unknown")
62+
return license
63+
64+
65+
def extract_source_code_url(message):
66+
# Collect all "Project-URL" lines
67+
project_urls = message.get_all("Project-URL", [])
68+
well_known_labels = ["source", "repository", "sourcecode", "github"]
69+
70+
for entry in project_urls:
71+
try:
72+
label, url = map(str.strip, entry.split(",", 1))
73+
if normalize_label(label) in well_known_labels:
74+
return url
75+
except ValueError:
76+
continue # skip malformed lines
77+
78+
# A lot of projects use homepage as source code url. Done in a second
79+
# loop so a homepage entry appearing before a well-known source label
80+
# doesn't win by accident.
81+
for entry in project_urls:
82+
try:
83+
label, url = map(str.strip, entry.split(",", 1))
84+
if normalize_label(label) == "homepage":
85+
return url
86+
except ValueError:
87+
continue
88+
89+
return message.get("Home-page") # deprecated fallback, may be None
90+
91+
92+
def extract_metadata_from_whl(whl_path):
93+
"""
94+
Extract metadata according to https://packaging.python.org/en/latest/specifications/core-metadata/
95+
"""
96+
with zipfile.ZipFile(whl_path, "r") as z:
97+
metadata_file = next(f for f in z.namelist() if f.endswith("METADATA"))
98+
content = z.read(metadata_file).decode()
99+
message: Message = Parser().parsestr(content)
100+
return {
101+
"name": message.get("Name"),
102+
"version": message.get("Version"),
103+
"license": extract_license(message),
104+
"source_code": extract_source_code_url(message),
105+
}
106+
107+
108+
def find_patch_dir(slug, version):
109+
"""
110+
Look for a `patches/<slug>/<version_tag>` directory as described in
111+
docs/development.md, trying both a `v`-prefixed and bare version tag.
112+
"""
113+
for tag in (f"v{version}", version):
114+
candidate = Path("patches") / slug / tag
115+
if candidate.exists():
116+
return candidate
117+
return None
118+
119+
120+
def render_version_block(slug, version, license, patch_dir, *, latest):
121+
label = f"{version} (latest)" if latest else version
122+
open_attr = " open" if latest else ""
123+
install_target = slug if latest else f"{slug}=={version}"
124+
125+
lines = [
126+
f'<details markdown="1"{open_attr}>',
127+
f"<summary><strong>{label}</strong></summary>",
128+
"",
129+
"```bash",
130+
f"pip install {install_target} --index-url {REGISTRY_URL}",
131+
"```",
132+
"",
133+
f"- **License:** {license}",
134+
]
135+
136+
if patch_dir is not None:
137+
patch_url = f"https://github.com/{REPO}/tree/main/{patch_dir}"
138+
lines.append(f"- **Patch applied for this version:** [{patch_url}]({patch_url})")
139+
140+
lines += ["</details>", ""]
141+
return "\n".join(lines)
142+
143+
144+
def render_new_page(slug, display_name, version, license, source_code, patch_dir):
145+
lines = [
146+
"---",
147+
f"title: {display_name}",
148+
"layout: default",
149+
"parent: Supported Packages",
150+
"---",
151+
"",
152+
"<!-- Auto-generated by generate_packages_doc.py. Do not edit manually. -->",
153+
"",
154+
f"# {display_name}",
155+
"",
156+
]
157+
if source_code:
158+
lines.append(f"- **Source Code:** [{source_code}]({source_code})")
159+
lines += ["- **Supported versions:**", ""]
160+
lines.append(render_version_block(slug, version, license, patch_dir, latest=True))
161+
return "\n".join(lines).rstrip() + "\n"
162+
163+
164+
LATEST_DETAILS_RE = re.compile(
165+
r'<details markdown="1" open>\n<summary><strong>([^<]+?)</strong></summary>'
166+
)
167+
168+
169+
def insert_new_version(content, slug, version, license, patch_dir):
170+
"""
171+
Insert a new version block ahead of the current latest block, demoting the
172+
previous latest block to a plain (non-open, non-"(latest)") entry.
173+
174+
Returns None if this exact version is already documented.
175+
"""
176+
already_documented = re.search(
177+
rf'<summary><strong>{re.escape(version)}( \(latest\))?</strong></summary>', content
178+
)
179+
if already_documented:
180+
return None
181+
182+
match = LATEST_DETAILS_RE.search(content)
183+
if match is None:
184+
raise ValueError(f"No existing version blocks found for {slug}")
185+
186+
demoted_label = match.group(1).removesuffix(" (latest)")
187+
demoted_header = f'<details markdown="1">\n<summary><strong>{demoted_label}</strong></summary>'
188+
new_block = render_version_block(slug, version, license, patch_dir, latest=True)
189+
190+
# The demoted block's install command was rendered unpinned (it used to be
191+
# latest); pin it to its own version now that it no longer is.
192+
rest = content[match.end() :]
193+
old_install = f"pip install {slug} --index-url {REGISTRY_URL}"
194+
new_install = f"pip install {slug}=={demoted_label} --index-url {REGISTRY_URL}"
195+
rest = rest.replace(old_install, new_install, 1)
196+
197+
return content[: match.start()] + new_block + "\n" + demoted_header + rest
198+
199+
200+
def add_index_entry(slug):
201+
"""Add a new package to the alphabetically sorted list in docs/packages/index.md."""
202+
index_path = DOCS_DIR / "index.md"
203+
lines = index_path.read_text().splitlines()
204+
header_end = next(i for i, line in enumerate(lines) if line.startswith("- ["))
205+
header, entries = lines[:header_end], lines[header_end:]
206+
entries.append(f"- [{slug}]({slug}.html)")
207+
entries = sorted(set(entries), key=lambda line: line.split("[", 1)[1].split("]", 1)[0].lower())
208+
index_path.write_text("\n".join(header + entries) + "\n")
209+
210+
211+
def add_to_packages_file(slug):
212+
lines = PACKAGES_FILE.read_text().splitlines()
213+
header_end = next(i for i, line in enumerate(lines) if line and not line.startswith("#"))
214+
header, entries = lines[:header_end], [line for line in lines[header_end:] if line]
215+
entries = sorted(set(entries) | {slug}, key=str.casefold)
216+
PACKAGES_FILE.write_text("\n".join(header + entries) + "\n")
217+
218+
219+
def git_run(*args):
220+
subprocess.run(["git", *args], check=True)
221+
222+
223+
def configure_git_identity():
224+
git_run("config", "user.name", "github-actions[bot]")
225+
git_run("config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com")
226+
227+
228+
def extract_pr_url(stdout):
229+
for line in stdout.split("\n"):
230+
line = line.strip()
231+
if "github.com" in line and "/pull/" in line:
232+
return line
233+
return None
234+
235+
236+
def main():
237+
whl_file = find_wheel_file(ARTIFACTS_PATH)
238+
if not whl_file:
239+
print(f"No .whl file found in {ARTIFACTS_PATH}")
240+
sys.exit(1)
241+
242+
metadata = extract_metadata_from_whl(whl_file)
243+
display_name = metadata["name"]
244+
version = metadata["version"]
245+
license = metadata["license"]
246+
source_code = metadata["source_code"]
247+
248+
if not display_name or not version:
249+
print("Name or version could not be extracted")
250+
sys.exit(1)
251+
252+
slug = normalize_name(display_name)
253+
patch_dir = find_patch_dir(slug, version)
254+
page_path = DOCS_DIR / f"{slug}.md"
255+
is_new = not page_path.exists()
256+
257+
if is_new:
258+
page_path.write_text(
259+
render_new_page(slug, display_name, version, license, source_code, patch_dir)
260+
)
261+
else:
262+
updated = insert_new_version(page_path.read_text(), slug, version, license, patch_dir)
263+
if updated is None:
264+
print(f"{slug} {version} is already documented; nothing to do")
265+
return
266+
page_path.write_text(updated)
267+
268+
configure_git_identity()
269+
270+
branch = f"github-actions/{'add' if is_new else 'update'}-doc-for-{slug}"
271+
git_run("switch", "-c", branch)
272+
git_run("add", str(page_path))
273+
274+
if is_new:
275+
add_index_entry(slug)
276+
add_to_packages_file(slug)
277+
git_run("add", str(DOCS_DIR / "index.md"), str(PACKAGES_FILE))
278+
git_run("commit", "-s", "-m", f"docs: add {slug}\n\nAdd version {version}")
279+
else:
280+
git_run("commit", "-s", "-m", f"docs: update {slug}\n\nAdd version {version}")
281+
282+
git_run("push", "origin", branch)
283+
284+
result = subprocess.run(
285+
[
286+
"gh", "pr", "create", "--draft",
287+
"--repo", REPO,
288+
"--base", "main",
289+
"--head", branch,
290+
"--reviewer", "threexc,justeph",
291+
"--title", f"docs: {'add' if is_new else 'update'} {slug}",
292+
"--body",
293+
"Automatically generated PR to document a newly published wheel. "
294+
"Please review it carefully before merging.\n\n"
295+
"If necessary, force-push this branch.",
296+
],
297+
capture_output=True, text=True, check=True,
298+
)
299+
pr_url = extract_pr_url(result.stdout)
300+
print(f"[+] Opened PR: {pr_url or '(URL not found in output)'}")
301+
302+
303+
if __name__ == "__main__":
304+
main()

0 commit comments

Comments
 (0)