From 1266cbc07ab056470c1126ccd0cfee37b7f849ae Mon Sep 17 00:00:00 2001
From: stonebig
Date: Sun, 6 Sep 2026 20:41:08 +0200
Subject: [PATCH] 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 block of some thirty URLs in
releases.html. Both are already exact somewhere else: the build uploads
one hashes_.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
Claude-Session: https://claude.ai/code/session_01MBk5k7WdpPvx4SUFyEk3U3
---
.github/scripts/render_site.py | 283 ++++++++++++++++++++++++++
.github/scripts/site_content.toml | 44 ++++
.github/workflows/publish_release.yml | 96 +++++++++
.github/workflows/test_site.yml | 47 +++++
.gitignore | 3 +
releases.html | 2 +
tests/requirements.txt | 12 ++
tests/test_render_site.py | 213 +++++++++++++++++++
8 files changed, 700 insertions(+)
create mode 100644 .github/scripts/render_site.py
create mode 100644 .github/scripts/site_content.toml
create mode 100644 .github/workflows/publish_release.yml
create mode 100644 .github/workflows/test_site.yml
create mode 100644 tests/requirements.txt
create mode 100644 tests/test_render_site.py
diff --git a/.github/scripts/render_site.py b/.github/scripts/render_site.py
new file mode 100644
index 0000000..f0969d9
--- /dev/null
+++ b/.github/scripts/render_site.py
@@ -0,0 +1,283 @@
+"""Render the parts of the site that a release changes.
+
+ python .github/scripts/render_site.py --tag 2026-04 --hashes --site .
+
+A release build uploads one `hashes_.md` per flavor, in exactly the
+table format `md5_sha1.txt` is made of. Those files, plus the handful of
+sentences a human writes per cycle, are everything the site needs:
+
+ * `md5_sha1.txt` gains a section -- the rows go in verbatim, because
+ `wppm.hash` pads to fixed widths, so nothing has to be re-rendered and
+ nothing can drift.
+ * `releases.html` gains one `` block at the top of the archive, and
+ the block that was open closes.
+
+Both files are edited between markers, so this owns those regions and touches
+nothing else. What it cannot know -- which Python leads, the highlights line,
+the release-notes URL, the prose describing each flavor -- comes from
+`site_content.toml`, and is the whole of the per-cycle hand edit.
+
+Betas do not go on the site: the archive has never listed one, and only a
+single cycle ever put one in `md5_sha1.txt`. The caller decides; this renders
+whatever tag it is given.
+"""
+import argparse
+import datetime
+import re
+import sys
+import tomllib
+from pathlib import Path
+
+# the header wppm.hash writes, reproduced exactly: its column widths are
+# constants there, which is what lets rows from different flavors be
+# concatenated without re-rendering any of them
+HASH_HEADER = (
+ f"{'MD5':<32} | {'SHA-1':<40} | {'SHA-256':<64} | "
+ f"{'Binary':<33} | {'Size':<20} | {'blake2b-256':<64}"
+)
+HASH_RULE = "|".join("-" * len(part) for part in HASH_HEADER.split("|"))
+
+BINARY_SUFFIXES = (".exe", ".zip", ".7z")
+PACKAGE_SET_SUFFIXES = (".toml", ".txt")
+
+# the order the download links have always been listed in: the installer
+# first, then whichever archive that flavor ships
+FORMAT_ORDER = ("exe", "zip", "7z")
+
+# WinPythonslim-64bit-3.15.0.5b1.md -- the flavor is explicit here, which is
+# why the flavors are read off the changelogs rather than guessed out of the
+# binary names, where version and flavor run together
+CHANGELOG_ROW = re.compile(r"^WinPython(?P[A-Za-z0-9]*)-(?P\d+)bit-(?P.+)\.md$")
+
+# Both files accumulate: a release adds an entry, it does not replace the last
+# one. So these are insertion points, not regions this script owns. md5_sha1.txt
+# is served as plain text and gets no marker at all -- the first "### " line is
+# unambiguous enough, and a marker there would be visible to every reader.
+RELEASES_MARKER = ""
+SECTION_PREFIX = "### "
+
+
+class Row:
+ """One line of a hash table, kept verbatim."""
+
+ __slots__ = ("name", "line")
+
+ def __init__(self, name: str, line: str):
+ self.name = name
+ self.line = line
+
+
+def read_hash_rows(hashes_dir: Path) -> list[Row]:
+ """Every data row of every hashes_*.md, unparsed apart from the name."""
+ rows: list[Row] = []
+ files = sorted(hashes_dir.glob("hashes_*.md"))
+ if not files:
+ raise SystemExit(f"no hashes_*.md in {hashes_dir}")
+ for path in files:
+ for line in path.read_text(encoding="utf-8").splitlines():
+ if line.startswith(("MD5", "---")) or line.count("|") < 5:
+ continue
+ fields = line.split("|")
+ rows.append(Row(fields[3].strip(), line.rstrip()))
+ return rows
+
+
+def hash_tables(rows: list[Row]) -> str:
+ """The two tables of an md5_sha1.txt section.
+
+ Binaries first, then the lock files and requirements -- the order the file
+ has always used. The package indexes are hashed by the build too, but have
+ never been listed here, so they are dropped.
+ """
+ binaries = sorted((r for r in rows if r.name.endswith(BINARY_SUFFIXES)), key=lambda r: r.name)
+ package_sets = sorted(
+ (r for r in rows if r.name.endswith(PACKAGE_SET_SUFFIXES)), key=lambda r: r.name
+ )
+ if not binaries:
+ raise SystemExit("the hashes name no binaries; is this the right release?")
+
+ out = [HASH_HEADER, HASH_RULE]
+ out += [r.line for r in binaries]
+ out += ["", HASH_HEADER, HASH_RULE]
+ out += [r.line for r in package_sets]
+ return "\n".join(out)
+
+
+def md5_section(title: str, when: datetime.date, rows: list[Row]) -> str:
+ """### WinPython 2026-04 (September 6th 2026), then the two tables."""
+ return f"### {title} ({format_date(when)})\n\n{hash_tables(rows)}\n"
+
+
+def ordinal(day: int) -> str:
+ if 11 <= day % 100 <= 13:
+ return f"{day}th"
+ return f"{day}{ {1: 'st', 2: 'nd', 3: 'rd'}.get(day % 10, 'th') }"
+
+
+def format_date(when: datetime.date) -> str:
+ return f"{when:%B} {ordinal(when.day)}, {when.year}"
+
+
+def builds_from(rows: list[Row]) -> dict:
+ """{python minor: {ver2: {flavor: [formats]}}} read off the file names."""
+ flavors, versions = set(), {}
+ for row in rows:
+ match = CHANGELOG_ROW.match(row.name)
+ if match:
+ flavors.add(match.group("flavor"))
+ if not flavors:
+ raise SystemExit("the hashes name no package index, so no flavor is known")
+
+ # longest first, so "slimf" is not read as "slim" with a stray f
+ pattern = re.compile(
+ r"^WinPython(?P\d+)-(?P\d[\d.]*?)"
+ rf"(?P{'|'.join(sorted(flavors, key=len, reverse=True))})"
+ r"(?P[a-z0-9]*)\.(?Pexe|zip|7z)$"
+ )
+ for row in rows:
+ match = pattern.match(row.name)
+ if not match:
+ continue
+ ver2 = match.group("ver2")
+ entry = versions.setdefault(ver2, {})
+ entry.setdefault(match.group("flavor"), []).append(match.group("format"))
+
+ by_minor: dict = {}
+ for ver2, flavours in sorted(versions.items(), key=lambda kv: version_key(kv[0])):
+ minor = ".".join(ver2.split(".")[:2])
+ by_minor.setdefault(minor, {})[ver2] = {
+ f: sorted(v, key=FORMAT_ORDER.index) for f, v in flavours.items()
+ }
+ return by_minor
+
+
+def version_key(ver: str) -> tuple:
+ return tuple(int(p) for p in ver.split(".") if p.isdigit())
+
+
+def download_url(tag: str, name: str) -> str:
+ return f"https://github.com/winpython/winpython/releases/download/{tag}/{name}"
+
+
+def changelog_url(name: str) -> str:
+ return f"https://github.com/winpython/winpython/blob/master/changelogs/{name}"
+
+
+def releases_entry(tag: str, title: str, when: datetime.date, content: dict, rows: list[Row]) -> str:
+ """One block, the shape every entry in the archive already has."""
+ flavor_text = content["flavors"]
+ release = content["release"]
+ pythons = release.get("pythons", {})
+ level = release.get("level", "")
+
+ lines = [
+ '',
+ f' {title} — {format_date(when)}
',
+ ' ',
+ f'
Release notes and discussion',
+ ' · download page
',
+ f'
{release["highlights"]}
',
+ ]
+
+ tag_url = f"https://github.com/winpython/winpython/releases/tag/{tag.replace('/', '%2F')}"
+ for minor, versions in builds_from(rows).items():
+ for ver2, flavours in versions.items():
+ python = pythons.get(minor, ver2.rsplit(".", 1)[0])
+ forge = f"https://sourceforge.net/projects/winpython/files/WinPython_{minor}/{ver2}/"
+ lines += [
+ f'
WinPython {minor} — Python {python} · SourceForge and Github
',
+ "
",
+ ]
+ for flavor in sorted(flavours, key=lambda f: (len(f), f)):
+ stem = f"WinPython64-{ver2}{flavor}{level}"
+ index = f"WinPython{flavor}-64bit-{ver2}{level}.md"
+ pylock = f"pylock.64-{ver2.replace('.', '_')}{flavor}{level}.toml"
+ requir = f"requir.64-{ver2.replace('.', '_')}{flavor}{level}.txt"
+ links = [
+ f'{fmt}'
+ for fmt in flavours[flavor]
+ ]
+ links += [
+ f'packages',
+ f'pylock',
+ f'requirements',
+ ]
+ described = flavor_text.get(flavor, {}).get("archive", flavor)
+ lines.append(
+ f" - WinPython64-{ver2}{flavor}{level}"
+ f" — {described} :"
+ )
+ lines += [f" {link}," for link in links[:-1]]
+ lines.append(f" {links[-1]}
")
+ lines.append("
")
+
+ lines += ["
", " "]
+ return "\n".join(lines)
+
+
+def insert_after(text: str, marker: str, block: str) -> str:
+ """Put a block just after a marker line, which stays where it is."""
+ at = text.find(marker)
+ if at < 0:
+ raise SystemExit(f"marker not found: {marker}")
+ after = at + len(marker)
+ return f"{text[:after]}\n\n{block}\n{text[after:].lstrip(chr(10))}"
+
+
+def insert_before_first_section(text: str, block: str) -> str:
+ """Put a section above the newest one, which is the file's first."""
+ at = text.find(SECTION_PREFIX)
+ if at < 0:
+ raise SystemExit(f"no {SECTION_PREFIX.strip()!r} section to insert above")
+ return f"{text[:at]}{block}\n\n{text[at:]}"
+
+
+def close_open_entry(text: str) -> str:
+ """Only the newest entry stands open, so the last newest one closes."""
+ return text.replace('', '', 1)
+
+
+def main(argv: list[str]) -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--tag", required=True, help="release tag, e.g. 2026-04")
+ parser.add_argument("--hashes", required=True, type=Path, help="directory of hashes_*.md")
+ parser.add_argument("--site", default=Path("."), type=Path, help="the site checkout")
+ parser.add_argument("--content", type=Path, help="site_content.toml (default: beside this script)")
+ parser.add_argument("--date", help="release date as YYYY-MM-DD (default: today)")
+ args = parser.parse_args(argv[1:])
+
+ content_path = args.content or Path(__file__).with_name("site_content.toml")
+ with content_path.open("rb") as fh:
+ content = tomllib.load(fh)
+ when = (
+ datetime.date.fromisoformat(args.date) if args.date
+ else datetime.date.fromisoformat(content["release"]["date"])
+ if content["release"].get("date") else datetime.date.today()
+ )
+ title = content["release"].get("title") or f"WinPython {args.tag}"
+
+ rows = read_hash_rows(args.hashes)
+
+ md5_path = args.site / "md5_sha1.txt"
+ md5_path.write_text(
+ insert_before_first_section(
+ md5_path.read_text(encoding="utf-8"), md5_section(title, when, rows)
+ ),
+ encoding="utf-8", newline="\n",
+ )
+ print(f"md5_sha1.txt + section {title}")
+
+ releases_path = args.site / "releases.html"
+ text = close_open_entry(releases_path.read_text(encoding="utf-8"))
+ releases_path.write_text(
+ insert_after(text, RELEASES_MARKER,
+ releases_entry(args.tag, title, when, content, rows)),
+ encoding="utf-8", newline="\n",
+ )
+ print(f"releases.html + entry {title}")
+
+
+if __name__ == "__main__":
+ main(sys.argv)
diff --git a/.github/scripts/site_content.toml b/.github/scripts/site_content.toml
new file mode 100644
index 0000000..d89cddc
--- /dev/null
+++ b/.github/scripts/site_content.toml
@@ -0,0 +1,44 @@
+# The sentences a release needs that no build can know.
+#
+# Everything else the site says about a release -- versions, flavors, formats,
+# sizes, hashes, every download URL -- is read off the hashes_*.md the build
+# uploads. This file is the whole of the per-cycle hand edit.
+#
+# It currently holds 2026-03, the last final release, so it is a worked example
+# rather than a template of placeholders. Editing it for a cycle means: the
+# release block, and the [release.pythons] line for each minor.
+
+[release]
+# title as it appears in the archive and in md5_sha1.txt
+title = "WinPython 2026-03"
+# publication date; leave out to use the day the generator runs
+date = "2026-08-22"
+# the suffix on file names, "" for a final. Betas do not go on the site, so
+# this is normally "" -- it exists because the file names carry it.
+level = ""
+notes_url = "https://github.com/winpython/winpython/issues/2026#issuecomment-5285140297"
+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,
+ polars-1.43.2, duckdb-1.5.5, numba-0.67.0, jupyterlab-4.6.2, spyder-6.1.6, wppm-17.12.20260816"""
+
+# The interpreter each WinPython minor ships. Not derivable: WinPython 3.15.0.4
+# shipped Python 3.15.0rc1, and the fourth number is WinPython's own.
+[release.pythons]
+"3.13" = "3.13.15"
+"3.14" = "3.14.7"
+"3.15" = "3.15.0rc1"
+
+# How each flavor is described. Stable between cycles; a new flavor needs a line.
+[flavors.dot]
+archive = "bare portable Python"
+
+[flavors.slim]
+archive = "full scientific environment"
+
+[flavors.dotf]
+archive = "bare portable Python, free-threading"
+
+[flavors.slimf]
+archive = "free-threading, no Qt stack"
+
+[flavors.whl]
+archive = "offline wheelhouse"
diff --git a/.github/workflows/publish_release.yml b/.github/workflows/publish_release.yml
new file mode 100644
index 0000000..3370a46
--- /dev/null
+++ b/.github/workflows/publish_release.yml
@@ -0,0 +1,96 @@
+name: Publish a release on the site
+# Takes a released tag, reads that release's hashes_*.md from
+# winpython/winpython, and opens a pull request adding the release to
+# md5_sha1.txt and releases.html.
+#
+# Dispatched by hand, for finals. Betas do not go on the site: the archive has
+# never listed one in 32 entries, and only a single cycle ever put one in
+# md5_sha1.txt. Their checksums stay available as assets on their own release.
+#
+# The sentences a build cannot know -- the highlights line, the release notes
+# URL, which interpreter each minor ships -- come from
+# .github/scripts/site_content.toml, which is edited before dispatching.
+
+on:
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: 'Release tag in winpython/winpython, e.g. 2026-04'
+ required: true
+ type: string
+ date:
+ description: 'Publication date as YYYY-MM-DD; blank uses site_content.toml, then today'
+ required: false
+ default: ''
+ type: string
+
+permissions: {}
+
+jobs:
+ render:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+ steps:
+ - name: Checkout the site
+ # credentials are kept: this job pushes a branch
+ uses: actions/checkout@v6
+
+ - name: Install Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: '3.13'
+
+ - name: Install pinned test dependencies
+ run: python -m pip install --no-deps --require-hashes -r tests/requirements.txt
+
+ - name: Test the generator before letting it write
+ run: python -m pytest -q
+
+ - name: Download the release's hashes
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ inputs.tag }}
+ run: |
+ mkdir -p release_hashes
+ gh release download "$TAG" --repo winpython/winpython \
+ --dir release_hashes --pattern 'hashes_*.md'
+ ls -1 release_hashes
+
+ - name: Render the release into the site
+ env:
+ TAG: ${{ inputs.tag }}
+ WHEN: ${{ inputs.date }}
+ run: |
+ python .github/scripts/render_site.py \
+ --tag "$TAG" --hashes release_hashes --site . \
+ ${WHEN:+--date "$WHEN"}
+ rm -rf release_hashes
+
+ - name: Open the pull request
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ TAG: ${{ inputs.tag }}
+ BASE: ${{ github.event.repository.default_branch }}
+ run: |
+ branch="release/$TAG"
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git switch -c "$branch"
+ git add md5_sha1.txt releases.html
+ if git diff --cached --quiet; then
+ echo "the site already carries $TAG; nothing to open"
+ exit 0
+ fi
+ git commit -m "Publish WinPython $TAG on the site"
+ # the branch is generated wholly by this job, so a re-run replaces it
+ git push --force origin "$branch"
+ if gh pr view "$branch" --json number >/dev/null 2>&1; then
+ echo "pull request for $branch is open; the push updated it"
+ else
+ gh pr create --base "$BASE" --head "$branch" \
+ --title "Publish WinPython $TAG on the site" \
+ --body "Adds \`$TAG\` to \`md5_sha1.txt\` and the \`releases.html\` archive, rendered from the \`hashes_*.md\` of that release. The prose came from \`site_content.toml\`. Check the highlights line and the interpreter versions before merging; everything else is derived."
+ fi
diff --git a/.github/workflows/test_site.yml b/.github/workflows/test_site.yml
new file mode 100644
index 0000000..c7f88db
--- /dev/null
+++ b/.github/workflows/test_site.yml
@@ -0,0 +1,47 @@
+name: test_site
+# The generator writes into md5_sha1.txt and releases.html, which people read
+# directly, so it gets the same treatment as the winpython test suite:
+# hash-pinned dependencies, run on anything that touches it.
+#
+# The pins are the same set winpython/tests/requir.wppmtest.txt carries. To
+# refresh them, regenerate there and copy the file across.
+
+on:
+ push:
+ paths:
+ - '.github/scripts/**'
+ - '.github/workflows/test_site.yml'
+ - 'tests/**'
+ - 'md5_sha1.txt'
+ - 'releases.html'
+ pull_request:
+ paths:
+ - '.github/scripts/**'
+ - '.github/workflows/test_site.yml'
+ - 'tests/**'
+ - 'md5_sha1.txt'
+ - 'releases.html'
+ workflow_dispatch:
+
+permissions: {}
+
+jobs:
+ pytest:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Install Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: '3.13'
+
+ - name: Install pinned test dependencies
+ run: python -m pip install --no-deps --require-hashes -r tests/requirements.txt
+
+ - name: Run the tests
+ run: python -m pytest -q
diff --git a/.gitignore b/.gitignore
index b72f9be..7b519bb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,5 @@
*~
*.swp
+__pycache__/
+*.py[cod]
+.pytest_cache/
diff --git a/releases.html b/releases.html
index 89bcb18..1e15f98 100644
--- a/releases.html
+++ b/releases.html
@@ -40,6 +40,8 @@ Release archive
cod (with VS Code) and 32-bit builds.
+
+
WinPython 2026-03 — August 22nd, 2026
diff --git a/tests/requirements.txt b/tests/requirements.txt
new file mode 100644
index 0000000..a625f6e
--- /dev/null
+++ b/tests/requirements.txt
@@ -0,0 +1,12 @@
+colorama==0.4.6 \
+ --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
+iniconfig==2.3.0 \
+ --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
+packaging==26.3 \
+ --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
+pluggy==1.6.0 \
+ --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
+pygments==2.20.0 \
+ --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
+pytest==9.1.1 \
+ --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c
diff --git a/tests/test_render_site.py b/tests/test_render_site.py
new file mode 100644
index 0000000..5b9840b
--- /dev/null
+++ b/tests/test_render_site.py
@@ -0,0 +1,213 @@
+# -*- coding: utf-8 -*-
+"""The generator writes into two files people read directly.
+
+`md5_sha1.txt` is a checksum record going back to 2019 and `releases.html` is
+the download archive back to 2020, so the danger here is not a crash but a
+quiet change of shape: a column that stops lining up, a link order that flips,
+an entry that lands in the wrong place. Those are what this pins.
+
+The hash rows are deliberately never re-rendered -- `wppm.hash` pads to fixed
+widths, so the build's own lines go in verbatim -- and the tests check the
+geometry that assumption rests on.
+"""
+import datetime
+import importlib.util
+import shutil
+from pathlib import Path
+
+import pytest
+
+SITE = Path(__file__).resolve().parents[1]
+SCRIPT = SITE / ".github/scripts/render_site.py"
+
+
+@pytest.fixture(scope="module")
+def render():
+ spec = importlib.util.spec_from_file_location("render_site", SCRIPT)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def hash_line(name: str, size: int = 1234) -> str:
+ """A row in exactly the shape wppm.hash writes."""
+ md5, sha1 = "0" * 32, "1" * 40
+ sha256, blake = "2" * 64, "3" * 64
+ return (
+ f"{md5} | {sha1} | {sha256} | {name.ljust(33)} | "
+ f"{f'{size:,} Bytes'.replace(',', ' ').rjust(20)} | {blake}"
+ )
+
+
+def write_hashes(directory: Path, winpyver: str, names: list[str]) -> None:
+ header = (
+ f"{'MD5':<32} | {'SHA-1':<40} | {'SHA-256':<64} | "
+ f"{'Binary':<33} | {'Size':<20} | {'blake2b-256':<64}"
+ )
+ rule = "|".join("-" * len(part) for part in header.split("|"))
+ body = "\n".join([header, rule] + [hash_line(n) for n in names])
+ (directory / f"hashes_{winpyver}.md").write_text(body + "\n", encoding="utf-8")
+
+
+@pytest.fixture
+def one_cycle(tmp_path):
+ """Two Pythons, four flavors, the formats each really ships."""
+ d = tmp_path / "hashes"
+ d.mkdir()
+ write_hashes(d, "3.14.7.0dot", [
+ "WinPython64-3.14.7.0dot.exe", "WinPython64-3.14.7.0dot.zip",
+ "WinPythondot-64bit-3.14.7.0.md",
+ "pylock.64-3_14_7_0dot.toml", "requir.64-3_14_7_0dot.txt"])
+ write_hashes(d, "3.14.7.0slimf", [
+ "WinPython64-3.14.7.0slimf.7z", "WinPython64-3.14.7.0slimf.exe",
+ "WinPythonslimf-64bit-3.14.7.0.md",
+ "pylock.64-3_14_7_0slimf.toml", "requir.64-3_14_7_0slimf.txt"])
+ write_hashes(d, "3.15.0.4dot", [
+ "WinPython64-3.15.0.4dot.exe", "WinPython64-3.15.0.4dot.zip",
+ "WinPythondot-64bit-3.15.0.4.md",
+ "pylock.64-3_15_0_4dot.toml", "requir.64-3_15_0_4dot.txt"])
+ return d
+
+
+class TestHashTables:
+ def test_binaries_and_package_sets_are_separate_tables(self, render, one_cycle):
+ rows = render.read_hash_rows(one_cycle)
+ tables = render.hash_tables(rows).split("\n\n")
+ assert len(tables) == 2
+ first = [l.split("|")[3].strip() for l in tables[0].splitlines()[2:]]
+ second = [l.split("|")[3].strip() for l in tables[1].splitlines()[2:]]
+ assert all(n.endswith((".exe", ".zip", ".7z")) for n in first)
+ assert all(n.endswith((".toml", ".txt")) for n in second)
+
+ def test_the_package_indexes_are_not_listed(self, render, one_cycle):
+ """The build hashes them; md5_sha1.txt has never carried them."""
+ rows = render.read_hash_rows(one_cycle)
+ assert ".md" not in render.hash_tables(rows)
+
+ def test_rows_go_in_verbatim(self, render, one_cycle):
+ """Re-rendering is what would let the columns drift."""
+ rows = render.read_hash_rows(one_cycle)
+ table = render.hash_tables(rows)
+ for row in rows:
+ if row.name.endswith((".exe", ".zip", ".7z", ".toml", ".txt")):
+ assert row.line in table
+
+ def test_the_column_geometry_is_the_one_the_file_already_has(self, render, one_cycle):
+ """(33, 42, 66, 35, 22, 65) -- measured from the 2026-03 section."""
+ rows = render.read_hash_rows(one_cycle)
+ data = [l for l in render.hash_tables(rows).splitlines()
+ if l.count("|") >= 5 and not l.startswith(("MD5", "---"))]
+ assert {tuple(len(f) for f in l.split("|")) for l in data} == {(33, 42, 66, 35, 22, 65)}
+
+ def test_an_empty_directory_is_an_error(self, render, tmp_path):
+ with pytest.raises(SystemExit):
+ render.read_hash_rows(tmp_path)
+
+ def test_metadata_without_binaries_is_an_error(self, render, tmp_path):
+ """A half-finished release must not quietly produce an empty section."""
+ write_hashes(tmp_path, "3.14.7.0dot", ["pylock.64-3_14_7_0dot.toml"])
+ with pytest.raises(SystemExit):
+ render.hash_tables(render.read_hash_rows(tmp_path))
+
+
+class TestBuildsFrom:
+ def test_flavors_versions_and_formats(self, render, one_cycle):
+ builds = render.builds_from(render.read_hash_rows(one_cycle))
+ assert list(builds) == ["3.14", "3.15"]
+ assert builds["3.14"]["3.14.7.0"] == {"dot": ["exe", "zip"], "slimf": ["exe", "7z"]}
+ assert builds["3.15"]["3.15.0.4"] == {"dot": ["exe", "zip"]}
+
+ def test_slimf_is_not_read_as_slim(self, render, one_cycle):
+ """The flavors overlap; the longest has to win or a build vanishes."""
+ builds = render.builds_from(render.read_hash_rows(one_cycle))
+ assert "slim" not in builds["3.14"]["3.14.7.0"]
+
+ def test_the_installer_is_listed_first(self, render, one_cycle):
+ """exe, then zip or 7z -- the order every entry in the archive uses."""
+ builds = render.builds_from(render.read_hash_rows(one_cycle))
+ for versions in builds.values():
+ for flavours in versions.values():
+ for formats in flavours.values():
+ assert formats[0] == "exe"
+
+ def test_flavors_come_from_the_package_indexes(self, render, tmp_path):
+ """Binary names run version and flavor together; the indexes do not."""
+ write_hashes(tmp_path, "3.14.7.0dot", ["WinPython64-3.14.7.0dot.exe"])
+ with pytest.raises(SystemExit):
+ render.builds_from(render.read_hash_rows(tmp_path))
+
+
+class TestDates:
+ @pytest.mark.parametrize("day,expected", [
+ (1, "1st"), (2, "2nd"), (3, "3rd"), (11, "11th"), (12, "12th"),
+ (13, "13th"), (22, "22nd"), (31, "31st"),
+ ])
+ def test_ordinals(self, render, day, expected):
+ assert render.ordinal(day) == expected
+
+ def test_the_format_the_files_already_use(self, render):
+ """"August 22nd, 2026" -- md5_sha1.txt and the archive both read so."""
+ assert render.format_date(datetime.date(2026, 8, 22)) == "August 22nd, 2026"
+
+
+class TestInsertion:
+ def test_a_section_goes_above_the_newest(self, render):
+ text = "preamble\n\n### WinPython 2026-03 (August 22nd, 2026)\n\nrows\n"
+ out = render.insert_before_first_section(text, "### NEW\n\ntable")
+ assert out.index("### NEW") < out.index("### WinPython 2026-03")
+ assert "preamble" in out and "rows" in out
+
+ def test_a_file_with_no_section_is_an_error(self, render):
+ with pytest.raises(SystemExit):
+ render.insert_before_first_section("nothing here", "### NEW")
+
+ def test_an_entry_goes_after_the_marker(self, render):
+ text = f"top\n{render.RELEASES_MARKER}\n\nold \n"
+ out = render.insert_after(text, render.RELEASES_MARKER, "new ")
+ assert out.index("new") < out.index("old")
+ assert render.RELEASES_MARKER in out
+
+ def test_a_missing_marker_is_an_error(self, render):
+ with pytest.raises(SystemExit):
+ render.insert_after("no marker", render.RELEASES_MARKER, "x")
+
+ def test_only_the_newest_entry_stays_open(self, render):
+ text = 'a \nb '
+ assert render.close_open_entry(text).count('class="release" open') == 0
+
+
+class TestAgainstTheRealSite:
+ """Run it over the checked-in files, which is what CI will do."""
+
+ @pytest.fixture
+ def content(self):
+ path = SITE / ".github/scripts/site_content.toml"
+ if not path.is_file():
+ pytest.skip("site_content.toml is gone")
+ return path
+
+ def test_a_release_lands_in_both_files(self, render, one_cycle, content, tmp_path):
+ site = tmp_path / "site"
+ shutil.copytree(SITE, site, ignore=shutil.ignore_patterns(".git"))
+ before = (site / "releases.html").read_text(encoding="utf-8")
+
+ render.main(["render_site.py", "--tag", "2026-04", "--hashes", str(one_cycle),
+ "--site", str(site), "--content", str(content), "--date", "2026-09-06"])
+
+ md5 = (site / "md5_sha1.txt").read_text(encoding="utf-8")
+ assert md5.lstrip().startswith("### WinPython 2026-03 (September 6th, 2026)")
+ assert "### WinPython 2026-03 (August 22nd, 2026)" in md5, "history must survive"
+
+ after = (site / "releases.html").read_text(encoding="utf-8")
+ assert after.count("")
+ render.main(["render_site.py", "--tag", "2026-04", "--hashes", str(one_cycle),
+ "--site", str(site), "--content", str(content), "--date", "2026-09-06"])
+ assert (site / "releases.html").read_text(encoding="utf-8").count("") == before + 1