Visitar URL original
Render a release into the site instead of typing it · stonebig/winpython.github.io@eb7838d · GitHub
Skip to content

Commit eb7838d

Browse files
stonebigclaude
andcommitted
Render a release into the site instead of typing it
Publishing a cycle meant hand-writing a section of md5_sha1.txt -- a hash table per file -- and a <details> block of some thirty URLs in releases.html. Both are already exact somewhere else: the build uploads one hashes_<winpyver>.md per flavor, in precisely the table format md5_sha1.txt is made of. render_site.py takes those files and writes both. The hash rows go in verbatim, because wppm.hash pads to fixed widths, so nothing is re-rendered and the columns cannot drift; the generated section has the same (33, 42, 66, 35, 22, 65) geometry as the section written by hand for 2026-03. Flavors are read off the package index names, where flavor and version are separated, rather than out of the binary names, where they run together and slimf reads as slim. Both files accumulate rather than being owned: a section goes above the newest one, an entry after a marker in releases.html, and the entry that was open closes. The archive back to 2020 is a stated WinPython advantage, and a test says so. What no build can know -- the highlights line, the release notes URL, which interpreter each minor ships, the prose describing each flavor -- is site_content.toml, and is the whole of the per-cycle hand edit. It holds 2026-03 as a worked example rather than placeholders. Two workflows: one runs the tests on anything touching the generator, one takes a tag, downloads that release's hashes and opens a pull request. Betas are not dispatched -- the archive has never listed one in 32 entries, and only a single cycle ever put one in md5_sha1.txt. index.html is untouched here; its download cards are the next step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MBk5k7WdpPvx4SUFyEk3U3
1 parent 1c50c8c commit eb7838d

9 files changed

Lines changed: 697 additions & 0 deletions

File tree

20.4 KB
Binary file not shown.

‎.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-03, 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-03"
14+
# publication date; leave out to use the day the generator runs
15+
date = "2026-08-22"
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/issues/2026#issuecomment-5285140297"
20+
highlights = """Python-3.13.15, Python-3.14.7, Python-3.15.0rc1, numpy-2.5.2, pandas-3.0.5, scipy-1.18.0,
21+
polars-1.43.2, duckdb-1.5.5, numba-0.67.0, jupyterlab-4.6.2, spyder-6.1.6, wppm-17.12.20260816"""
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.15"
27+
"3.14" = "3.14.7"
28+
"3.15" = "3.15.0rc1"
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)