|
| 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) |
0 commit comments