Skip to content

Commit 4515b69

Browse files
authored
Merge pull request #130 from stonebig/render-release-into-the-site
Render a release into the site instead of typing it
2 parents 1c50c8c + ad1402c commit 4515b69

10 files changed

Lines changed: 953 additions & 141 deletions

File tree

‎.github/scripts/render_site.py‎

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
"""Render the parts of the site that a release changes.
2+
3+
python .github/scripts/render_site.py --tag 2026-04 --hashes <dir> --site .
4+
5+
A release build uploads one `hashes_<winpyver>.md` per flavor, in exactly the
6+
table format `md5_sha1.txt` is made of. Those files, plus the handful of
7+
sentences a human writes per cycle, are everything the site needs:
8+
9+
* `md5_sha1.txt` gains a section -- the rows go in verbatim, because
10+
`wppm.hash` pads to fixed widths, so nothing has to be re-rendered and
11+
nothing can drift.
12+
* `releases.html` gains one `<details>` block at the top of the archive, and
13+
the block that was open closes.
14+
15+
Both files are edited between markers, so this owns those regions and touches
16+
nothing else. What it cannot know -- which Python leads, the highlights line,
17+
the release-notes URL, the prose describing each flavor -- comes from
18+
`site_content.toml`, and is the whole of the per-cycle hand edit.
19+
20+
Betas do not go on the site: the archive has never listed one, and only a
21+
single cycle ever put one in `md5_sha1.txt`. The caller decides; this renders
22+
whatever tag it is given.
23+
"""
24+
import argparse
25+
import datetime
26+
import re
27+
import sys
28+
import tomllib
29+
from pathlib import Path
30+
31+
# the header wppm.hash writes, reproduced exactly: its column widths are
32+
# constants there, which is what lets rows from different flavors be
33+
# concatenated without re-rendering any of them
34+
HASH_HEADER = (
35+
f"{'MD5':<32} | {'SHA-1':<40} | {'SHA-256':<64} | "
36+
f"{'Binary':<33} | {'Size':<20} | {'blake2b-256':<64}"
37+
)
38+
HASH_RULE = "|".join("-" * len(part) for part in HASH_HEADER.split("|"))
39+
40+
BINARY_SUFFIXES = (".exe", ".zip", ".7z")
41+
PACKAGE_SET_SUFFIXES = (".toml", ".txt")
42+
43+
# the order the download links have always been listed in: the installer
44+
# first, then whichever archive that flavor ships
45+
FORMAT_ORDER = ("exe", "zip", "7z")
46+
47+
# WinPythonslim-64bit-3.15.0.5b1.md -- the flavor is explicit here, which is
48+
# why the flavors are read off the changelogs rather than guessed out of the
49+
# binary names, where version and flavor run together
50+
CHANGELOG_ROW = re.compile(r"^WinPython(?P<flavor>[A-Za-z0-9]*)-(?P<arch>\d+)bit-(?P<version>.+)\.md$")
51+
52+
# Both files accumulate: a release adds an entry, it does not replace the last
53+
# one. So these are insertion points, not regions this script owns. md5_sha1.txt
54+
# is served as plain text and gets no marker at all -- the first "### " line is
55+
# unambiguous enough, and a marker there would be visible to every reader.
56+
RELEASES_MARKER = "<!-- newest release is inserted after this line -->"
57+
SECTION_PREFIX = "### "
58+
59+
60+
class Row:
61+
"""One line of a hash table, kept verbatim."""
62+
63+
__slots__ = ("name", "line")
64+
65+
def __init__(self, name: str, line: str):
66+
self.name = name
67+
self.line = line
68+
69+
70+
def read_hash_rows(hashes_dir: Path) -> list[Row]:
71+
"""Every data row of every hashes_*.md, unparsed apart from the name."""
72+
rows: list[Row] = []
73+
files = sorted(hashes_dir.glob("hashes_*.md"))
74+
if not files:
75+
raise SystemExit(f"no hashes_*.md in {hashes_dir}")
76+
for path in files:
77+
for line in path.read_text(encoding="utf-8").splitlines():
78+
if line.startswith(("MD5", "---")) or line.count("|") < 5:
79+
continue
80+
fields = line.split("|")
81+
rows.append(Row(fields[3].strip(), line.rstrip()))
82+
return rows
83+
84+
85+
def hash_tables(rows: list[Row]) -> str:
86+
"""The two tables of an md5_sha1.txt section.
87+
88+
Binaries first, then the lock files and requirements -- the order the file
89+
has always used. The package indexes are hashed by the build too, but have
90+
never been listed here, so they are dropped.
91+
"""
92+
binaries = sorted((r for r in rows if r.name.endswith(BINARY_SUFFIXES)), key=lambda r: r.name)
93+
package_sets = sorted(
94+
(r for r in rows if r.name.endswith(PACKAGE_SET_SUFFIXES)), key=lambda r: r.name
95+
)
96+
if not binaries:
97+
raise SystemExit("the hashes name no binaries; is this the right release?")
98+
99+
out = [HASH_HEADER, HASH_RULE]
100+
out += [r.line for r in binaries]
101+
out += ["", HASH_HEADER, HASH_RULE]
102+
out += [r.line for r in package_sets]
103+
return "\n".join(out)
104+
105+
106+
def md5_section(title: str, when: datetime.date, rows: list[Row]) -> str:
107+
"""### WinPython 2026-04 (September 6th 2026), then the two tables."""
108+
return f"### {title} ({format_date(when)})\n\n{hash_tables(rows)}\n"
109+
110+
111+
def ordinal(day: int) -> str:
112+
if 11 <= day % 100 <= 13:
113+
return f"{day}th"
114+
return f"{day}{ {1: 'st', 2: 'nd', 3: 'rd'}.get(day % 10, 'th') }"
115+
116+
117+
def format_date(when: datetime.date) -> str:
118+
return f"{when:%B} {ordinal(when.day)}, {when.year}"
119+
120+
121+
def builds_from(rows: list[Row]) -> dict:
122+
"""{python minor: {ver2: {flavor: [formats]}}} read off the file names."""
123+
flavors, versions = set(), {}
124+
for row in rows:
125+
match = CHANGELOG_ROW.match(row.name)
126+
if match:
127+
flavors.add(match.group("flavor"))
128+
if not flavors:
129+
raise SystemExit("the hashes name no package index, so no flavor is known")
130+
131+
# longest first, so "slimf" is not read as "slim" with a stray f
132+
pattern = re.compile(
133+
r"^WinPython(?P<arch>\d+)-(?P<ver2>\d[\d.]*?)"
134+
rf"(?P<flavor>{'|'.join(sorted(flavors, key=len, reverse=True))})"
135+
r"(?P<level>[a-z0-9]*)\.(?P<format>exe|zip|7z)$"
136+
)
137+
for row in rows:
138+
match = pattern.match(row.name)
139+
if not match:
140+
continue
141+
ver2 = match.group("ver2")
142+
entry = versions.setdefault(ver2, {})
143+
entry.setdefault(match.group("flavor"), []).append(match.group("format"))
144+
145+
by_minor: dict = {}
146+
for ver2, flavours in sorted(versions.items(), key=lambda kv: version_key(kv[0])):
147+
minor = ".".join(ver2.split(".")[:2])
148+
by_minor.setdefault(minor, {})[ver2] = {
149+
f: sorted(v, key=FORMAT_ORDER.index) for f, v in flavours.items()
150+
}
151+
return by_minor
152+
153+
154+
def version_key(ver: str) -> tuple:
155+
return tuple(int(p) for p in ver.split(".") if p.isdigit())
156+
157+
158+
def download_url(tag: str, name: str) -> str:
159+
return f"https://github.com/winpython/winpython/releases/download/{tag}/{name}"
160+
161+
162+
def changelog_url(name: str) -> str:
163+
return f"https://github.com/winpython/winpython/blob/master/changelogs/{name}"
164+
165+
166+
def releases_entry(tag: str, title: str, when: datetime.date, content: dict, rows: list[Row]) -> str:
167+
"""One <details> block, the shape every entry in the archive already has."""
168+
flavor_text = content["flavors"]
169+
release = content["release"]
170+
pythons = release.get("pythons", {})
171+
level = release.get("level", "")
172+
173+
lines = [
174+
'<details class="release" open>',
175+
f' <summary>{title} <span class="when">— {format_date(when)}</span></summary>',
176+
' <div class="body">',
177+
f' <p><a href="{release["notes_url"]}">Release notes and discussion</a>',
178+
' · <a href="index.html#download">download page</a></p>',
179+
f' <p class="hl">{release["highlights"]}</p>',
180+
]
181+
182+
tag_url = f"https://github.com/winpython/winpython/releases/tag/{tag.replace('/', '%2F')}"
183+
for minor, versions in builds_from(rows).items():
184+
for ver2, flavours in versions.items():
185+
python = pythons.get(minor, ver2.rsplit(".", 1)[0])
186+
forge = f"https://sourceforge.net/projects/winpython/files/WinPython_{minor}/{ver2}/"
187+
lines += [
188+
f' <p class="grp">WinPython <strong>{minor}</strong> — Python {python} · <a',
189+
f' href="{forge}">SourceForge</a> and <a',
190+
f' href="{tag_url}">Github</a></p>',
191+
" <ul>",
192+
]
193+
for flavor in sorted(flavours, key=lambda f: (len(f), f)):
194+
stem = f"WinPython64-{ver2}{flavor}{level}"
195+
index = f"WinPython{flavor}-64bit-{ver2}{level}.md"
196+
pylock = f"pylock.64-{ver2.replace('.', '_')}{flavor}{level}.toml"
197+
requir = f"requir.64-{ver2.replace('.', '_')}{flavor}{level}.txt"
198+
links = [
199+
f'<a href="{download_url(tag, stem + "." + fmt)}">{fmt}</a>'
200+
for fmt in flavours[flavor]
201+
]
202+
links += [
203+
f'<a href="{changelog_url(index)}">packages</a>',
204+
f'<a href="{changelog_url(pylock)}">pylock</a>',
205+
f'<a href="{changelog_url(requir)}">requirements</a>',
206+
]
207+
described = flavor_text.get(flavor, {}).get("archive", flavor)
208+
lines.append(
209+
f" <li>WinPython64-<strong>{ver2}</strong>{flavor}{level}"
210+
f" — {described} :"
211+
)
212+
lines += [f" {link}," for link in links[:-1]]
213+
lines.append(f" {links[-1]}</li>")
214+
lines.append(" </ul>")
215+
216+
lines += [" </div>", "</details>"]
217+
return "\n".join(lines)
218+
219+
220+
def insert_after(text: str, marker: str, block: str) -> str:
221+
"""Put a block just after a marker line, which stays where it is."""
222+
at = text.find(marker)
223+
if at < 0:
224+
raise SystemExit(f"marker not found: {marker}")
225+
after = at + len(marker)
226+
return f"{text[:after]}\n\n{block}\n{text[after:].lstrip(chr(10))}"
227+
228+
229+
def insert_before_first_section(text: str, block: str) -> str:
230+
"""Put a section above the newest one, which is the file's first."""
231+
at = text.find(SECTION_PREFIX)
232+
if at < 0:
233+
raise SystemExit(f"no {SECTION_PREFIX.strip()!r} section to insert above")
234+
return f"{text[:at]}{block}\n\n{text[at:]}"
235+
236+
237+
def close_open_entry(text: str) -> str:
238+
"""Only the newest entry stands open, so the last newest one closes."""
239+
return text.replace('<details class="release" open>', '<details class="release">', 1)
240+
241+
242+
def main(argv: list[str]) -> None:
243+
parser = argparse.ArgumentParser(description=__doc__)
244+
parser.add_argument("--tag", required=True, help="release tag, e.g. 2026-04")
245+
parser.add_argument("--hashes", required=True, type=Path, help="directory of hashes_*.md")
246+
parser.add_argument("--site", default=Path("."), type=Path, help="the site checkout")
247+
parser.add_argument("--content", type=Path, help="site_content.toml (default: beside this script)")
248+
parser.add_argument("--date", help="release date as YYYY-MM-DD (default: today)")
249+
args = parser.parse_args(argv[1:])
250+
251+
content_path = args.content or Path(__file__).with_name("site_content.toml")
252+
with content_path.open("rb") as fh:
253+
content = tomllib.load(fh)
254+
when = (
255+
datetime.date.fromisoformat(args.date) if args.date
256+
else datetime.date.fromisoformat(content["release"]["date"])
257+
if content["release"].get("date") else datetime.date.today()
258+
)
259+
title = content["release"].get("title") or f"WinPython {args.tag}"
260+
261+
rows = read_hash_rows(args.hashes)
262+
263+
md5_path = args.site / "md5_sha1.txt"
264+
md5_path.write_text(
265+
insert_before_first_section(
266+
md5_path.read_text(encoding="utf-8"), md5_section(title, when, rows)
267+
),
268+
encoding="utf-8", newline="\n",
269+
)
270+
print(f"md5_sha1.txt + section {title}")
271+
272+
releases_path = args.site / "releases.html"
273+
text = close_open_entry(releases_path.read_text(encoding="utf-8"))
274+
releases_path.write_text(
275+
insert_after(text, RELEASES_MARKER,
276+
releases_entry(args.tag, title, when, content, rows)),
277+
encoding="utf-8", newline="\n",
278+
)
279+
print(f"releases.html + entry {title}")
280+
281+
282+
if __name__ == "__main__":
283+
main(sys.argv)

‎.github/scripts/site_content.toml‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# The sentences a release needs that no build can know.
2+
#
3+
# Everything else the site says about a release -- versions, flavors, formats,
4+
# sizes, hashes, every download URL -- is read off the hashes_*.md the build
5+
# uploads. This file is the whole of the per-cycle hand edit.
6+
#
7+
# It currently holds 2026-04, the last final release, so it is a worked example
8+
# rather than a template of placeholders. Editing it for a cycle means: the
9+
# release block, and the [release.pythons] line for each minor.
10+
11+
[release]
12+
# title as it appears in the archive and in md5_sha1.txt
13+
title = "WinPython 2026-04"
14+
# publication date; leave out to use the day the generator runs
15+
date = "2026-10-27"
16+
# the suffix on file names, "" for a final. Betas do not go on the site, so
17+
# this is normally "" -- it exists because the file names carry it.
18+
level = ""
19+
notes_url = "https://github.com/winpython/winpython/releases/tag/2026-04"
20+
highlights = """Python-3.13.18, Python-3.14.10, Python-3.15.2, numpy-2.5.3, pandas-3.0.5, scipy-1.18.1,
21+
polars-2.1.0, duckdb-2.0.0, numba-0.68.0, jupyterlab-4.6.4, spyder-6.1.7, wppm-17.14.20261027"""
22+
23+
# The interpreter each WinPython minor ships. Not derivable: WinPython 3.15.0.4
24+
# shipped Python 3.15.0rc1, and the fourth number is WinPython's own.
25+
[release.pythons]
26+
"3.13" = "3.13.18"
27+
"3.14" = "3.14.10"
28+
"3.15" = "3.15.2"
29+
30+
# How each flavor is described. Stable between cycles; a new flavor needs a line.
31+
[flavors.dot]
32+
archive = "bare portable Python"
33+
34+
[flavors.slim]
35+
archive = "full scientific environment"
36+
37+
[flavors.dotf]
38+
archive = "bare portable Python, free-threading"
39+
40+
[flavors.slimf]
41+
archive = "free-threading, no Qt stack"
42+
43+
[flavors.whl]
44+
archive = "offline wheelhouse"

0 commit comments

Comments
 (0)