Skip to content

Commit 776ceec

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 version, we update documentation 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 0e47bdd commit 776ceec

1 file changed

Lines changed: 254 additions & 0 deletions

File tree

ci_scripts/update_doc.py

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
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>.yaml entry with the new version, and open
8+
a pull request with the change.
9+
10+
docs/packages/generate_packages_doc.py renders this YAML into the published
11+
Markdown pages, so this script only needs to maintain the YAML source of
12+
truth; it never touches docs/packages/*.md or index.md directly.
13+
"""
14+
15+
import os
16+
import re
17+
import string
18+
import subprocess
19+
import sys
20+
import zipfile
21+
from email.message import Message
22+
from email.parser import Parser
23+
from pathlib import Path
24+
25+
import yaml
26+
27+
REPO = "riseproject-dev/python-wheels"
28+
DOCS_DIR = Path("docs/packages")
29+
PACKAGES_FILE = Path("ci_scripts/packages.txt")
30+
ARTIFACTS_PATH = os.environ.get("ARTIFACTS_PATH", "dist")
31+
32+
33+
def find_wheel_file(path):
34+
for file in Path(path).glob("*.whl"):
35+
return file
36+
return None
37+
38+
39+
def normalize_name(name):
40+
"""
41+
https://packaging.python.org/en/latest/specifications/name-normalization/#name-normalization
42+
"""
43+
return re.sub(r"[-_.]+", "-", name).lower()
44+
45+
46+
def normalize_label(label):
47+
"""
48+
https://packaging.python.org/en/latest/specifications/well-known-project-urls/#label-normalization
49+
"""
50+
chars_to_remove = string.punctuation + string.whitespace
51+
removal_map = str.maketrans("", "", chars_to_remove)
52+
return label.translate(removal_map).lower()
53+
54+
55+
def extract_license(message):
56+
license = message.get("License-Expression")
57+
if not license:
58+
license = message.get("License", "Unknown")
59+
return license
60+
61+
62+
def extract_source_code_url(message):
63+
# Collect all "Project-URL" lines
64+
project_urls = message.get_all("Project-URL", [])
65+
well_known_labels = ["source", "repository", "sourcecode", "github"]
66+
67+
for entry in project_urls:
68+
try:
69+
label, url = map(str.strip, entry.split(",", 1))
70+
if normalize_label(label) in well_known_labels:
71+
return url
72+
except ValueError:
73+
continue # skip malformed lines
74+
75+
# A lot of projects use homepage as source code url. Done in a second
76+
# loop so a homepage entry appearing before a well-known source label
77+
# doesn't win by accident.
78+
for entry in project_urls:
79+
try:
80+
label, url = map(str.strip, entry.split(",", 1))
81+
if normalize_label(label) == "homepage":
82+
return url
83+
except ValueError:
84+
continue
85+
86+
return message.get("Home-page") # deprecated fallback, may be None
87+
88+
89+
def extract_metadata_from_whl(whl_path):
90+
"""
91+
Extract metadata according to https://packaging.python.org/en/latest/specifications/core-metadata/
92+
"""
93+
with zipfile.ZipFile(whl_path, "r") as z:
94+
metadata_file = next(f for f in z.namelist() if f.endswith("METADATA"))
95+
content = z.read(metadata_file).decode()
96+
message: Message = Parser().parsestr(content)
97+
return {
98+
"name": message.get("Name"),
99+
"version": message.get("Version"),
100+
"license": extract_license(message),
101+
"source_code": extract_source_code_url(message),
102+
}
103+
104+
105+
def find_patch_dir(slug, version):
106+
"""
107+
Look for a `patches/<slug>/<version_tag>` directory as described in
108+
docs/development.md, trying both a `v`-prefixed and bare version tag.
109+
"""
110+
for tag in (f"v{version}", version):
111+
candidate = Path("patches") / slug / tag
112+
if candidate.exists():
113+
return candidate
114+
return None
115+
116+
117+
def yaml_line(key, value):
118+
"""Render a single `key: value` YAML mapping line, quoted as needed."""
119+
return yaml.safe_dump(
120+
{key: value}, default_flow_style=False, allow_unicode=True
121+
).rstrip("\n")
122+
123+
124+
def render_new_yaml(slug, source_code, license, version, patch_dir):
125+
"""Render a brand-new docs/packages/<slug>.yaml for a package's first version."""
126+
lines = [yaml_line("package-name", slug)]
127+
if source_code:
128+
lines.append(yaml_line("source-code", source_code))
129+
lines.append(yaml_line("license", license))
130+
lines.append("versions:")
131+
lines.append(f" - {yaml_line('version', version)}")
132+
if patch_dir is not None:
133+
lines.append(" patched:")
134+
return "\n".join(lines) + "\n"
135+
136+
137+
def append_version(content, package_data, version, license, patch_dir):
138+
"""
139+
Append a new version entry to the end of an existing package YAML file's
140+
`versions:` list, preserving the rest of the file byte-for-byte.
141+
142+
Returns None if this exact version is already documented.
143+
"""
144+
existing_versions = {
145+
str(v.get("version")) for v in (package_data.get("versions") or [])
146+
}
147+
if str(version) in existing_versions:
148+
return None
149+
150+
top_level_license = package_data.get("license")
151+
lines = [f" - {yaml_line('version', version)}"]
152+
if patch_dir is not None:
153+
lines.append(" patched:")
154+
if license and license != top_level_license:
155+
lines.append(f" {yaml_line('license', license)}")
156+
157+
return content.rstrip("\n") + "\n" + "\n".join(lines) + "\n"
158+
159+
160+
def add_to_packages_file(slug):
161+
lines = PACKAGES_FILE.read_text().splitlines()
162+
header_end = next(i for i, line in enumerate(lines) if line and not line.startswith("#"))
163+
header, entries = lines[:header_end], [line for line in lines[header_end:] if line]
164+
entries = sorted(set(entries) | {slug}, key=str.casefold)
165+
PACKAGES_FILE.write_text("\n".join(header + entries) + "\n")
166+
167+
168+
def git_run(*args):
169+
subprocess.run(["git", *args], check=True)
170+
171+
172+
def configure_git_identity():
173+
git_run("config", "user.name", "github-actions[bot]")
174+
git_run("config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com")
175+
176+
177+
def extract_pr_url(stdout):
178+
for line in stdout.split("\n"):
179+
line = line.strip()
180+
if "github.com" in line and "/pull/" in line:
181+
return line
182+
return None
183+
184+
185+
def main():
186+
whl_file = find_wheel_file(ARTIFACTS_PATH)
187+
if not whl_file:
188+
print(f"No .whl file found in {ARTIFACTS_PATH}")
189+
sys.exit(1)
190+
191+
metadata = extract_metadata_from_whl(whl_file)
192+
display_name = metadata["name"]
193+
version = metadata["version"]
194+
license = metadata["license"]
195+
source_code = metadata["source_code"]
196+
197+
if not display_name or not version:
198+
print("Name or version could not be extracted")
199+
sys.exit(1)
200+
201+
slug = normalize_name(display_name)
202+
patch_dir = find_patch_dir(slug, version)
203+
yaml_path = DOCS_DIR / f"{slug}.yaml"
204+
is_new = not yaml_path.exists()
205+
206+
if is_new:
207+
yaml_path.write_text(
208+
render_new_yaml(slug, source_code, license, version, patch_dir)
209+
)
210+
else:
211+
content = yaml_path.read_text()
212+
package_data = yaml.safe_load(content) or {}
213+
updated = append_version(content, package_data, version, license, patch_dir)
214+
if updated is None:
215+
print(f"{slug} {version} is already documented; nothing to do")
216+
return
217+
yaml_path.write_text(updated)
218+
219+
configure_git_identity()
220+
221+
branch = f"github-actions/{'add' if is_new else 'update'}-doc-for-{slug}"
222+
git_run("switch", "-c", branch)
223+
git_run("add", str(yaml_path))
224+
225+
if is_new:
226+
add_to_packages_file(slug)
227+
git_run("add", str(PACKAGES_FILE))
228+
git_run("commit", "-s", "-m", f"docs: add {slug}\n\nAdd version {version}")
229+
else:
230+
git_run("commit", "-s", "-m", f"docs: update {slug}\n\nAdd version {version}")
231+
232+
git_run("push", "origin", branch)
233+
234+
result = subprocess.run(
235+
[
236+
"gh", "pr", "create", "--draft",
237+
"--repo", REPO,
238+
"--base", "main",
239+
"--head", branch,
240+
"--reviewer", "threexc,justeph",
241+
"--title", f"docs: {'add' if is_new else 'update'} {slug}",
242+
"--body",
243+
"Automatically generated PR to document a newly published wheel. "
244+
"Please review it carefully before merging.\n\n"
245+
"If necessary, force-push this branch.",
246+
],
247+
capture_output=True, text=True, check=True,
248+
)
249+
pr_url = extract_pr_url(result.stdout)
250+
print(f"[+] Opened PR: {pr_url or '(URL not found in output)'}")
251+
252+
253+
if __name__ == "__main__":
254+
main()

0 commit comments

Comments
 (0)