From 1266cbc07ab056470c1126ccd0cfee37b7f849ae Mon Sep 17 00:00:00 2001
From: stonebig
Date: Sun, 6 Sep 2026 20:41:08 +0200
Subject: [PATCH 1/3] 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\n
old \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 \n
b '
+ 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
From 8b89bdb02e7747ac1f707a824ce3c5c31d3afe32 Mon Sep 17 00:00:00 2001
From: stonebig
Date: Tue, 27 Oct 2026 23:38:15 +0100
Subject: [PATCH 2/3] Publish WinPython 2026-04
Co-Authored-By: Claude Opus 5.5
---
.github/scripts/site_content.toml | 18 +--
index.html | 258 ++++++++++++++----------------
md5_sha1.txt | 49 ++++++
releases.html | 84 ++++++++++
4 files changed, 259 insertions(+), 150 deletions(-)
diff --git a/.github/scripts/site_content.toml b/.github/scripts/site_content.toml
index d89cddc..6e22b92 100644
--- a/.github/scripts/site_content.toml
+++ b/.github/scripts/site_content.toml
@@ -4,28 +4,28 @@
# 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
+# It currently holds 2026-04, 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"
+title = "WinPython 2026-04"
# publication date; leave out to use the day the generator runs
-date = "2026-08-22"
+date = "2026-10-27"
# 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"""
+notes_url = "https://github.com/winpython/winpython/releases/tag/2026-04"
+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,
+ polars-2.1.0, duckdb-2.0.0, numba-0.68.0, jupyterlab-4.6.4, spyder-6.1.7, wppm-17.14.20261027"""
# 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"
+"3.13" = "3.13.18"
+"3.14" = "3.14.10"
+"3.15" = "3.15.2"
# How each flavor is described. Stable between cycles; a new flavor needs a line.
[flavors.dot]
diff --git a/index.html b/index.html
index 1f51c6e..2f49f75 100644
--- a/index.html
+++ b/index.html
@@ -29,12 +29,12 @@
Python, Spyder, JupyterLab and the scientific stack — running from a folder.
WinPython is a portable Python distribution for Windows 10/11. Unzip it anywhere — a disk, a network share, a USB key — and it runs. Nothing is installed, nothing is written to the registry, and you can keep as many of them side by side as you like.
-
- Latest: 2026-03 · 22 August 2026 · release notes →
+
+ Latest: 2026-04 · 27 October 2026 · release notes →
@@ -43,45 +43,45 @@ Python, Spyder, JupyterLab and the scientific stack — running from a folde
- Download — WinPython 2026-03
- Released 22 August 2026, built entirely in GitHub Actions. 64-bit only. Every installer is a self-extracting 7-Zip archive: there is no setup wizard, it just unpacks a folder.
+ Download — WinPython 2026-04
+ Released 27 October 2026, built entirely in GitHub Actions. 64-bit only. Every installer is a self-extracting 7-Zip archive: there is no setup wizard, it just unpacks a folder.
- Python 3.13.15, 3.14.7 and 3.15.0rc1 · what changed · the exact version of every package is in the packages link on each build below.
+ Python 3.13.18, 3.14.10 and 3.15.2 · what changed · the exact version of every package is in the packages link on each build below.
Python 3.14 Recommended
-
WinPython 3.14.7.0 — Python 3.14.7. This is the one we will keep updating over the coming year, so start here unless you have a reason not to.
+
WinPython 3.14.10.0 — Python 3.14.10. This is the one we will keep updating over the coming year, so start here unless you have a reason not to.
-
slim — 672 MB
+
slim — 682 MB
The full scientific environment: Spyder, JupyterLab, the Qt stack, the data and AI libraries.
-
slimf — 431 MB · free-threading
+
slimf — 432 MB · free-threading
Free-threaded (no GIL) build. No Qt stack, and a number of binary packages are still missing upstream.
@@ -89,13 +89,13 @@
Python 3.14 Recommended
dot — 18 MB
Bare portable Python 3.14.
@@ -103,13 +103,13 @@
Python 3.14 Recommended
dotf — 18 MB · free-threading
Bare free-threaded Python 3.14.
@@ -117,21 +117,21 @@ Python 3.14 Recommended
Python 3.13 Previous
-
WinPython 3.13.15.0 — Python 3.13.15. Still built, and the safest bet if a dependency you need has no 3.14 wheel yet.
+
WinPython 3.13.18.0 — Python 3.13.18. Still built, and the safest bet if a dependency you need has no 3.14 wheel yet.
-
slim — 666 MB
+
slim — 674 MB
The full scientific environment, on Python 3.13.
@@ -139,51 +139,81 @@
Python 3.13 Previous
dot — 17 MB
Bare portable Python plus pip and wppm. Start here and add exactly what you need.
-
Python 3.15 Preview
-
WinPython 3.15.0.4 — Python 3.15.0rc1. For trying the next Python early.
+
Python 3.15 New
+
WinPython 3.15.2.0 — Python 3.15.2. The first full set of 3.15 builds. Wheels are still arriving, so expect gaps.
+
+
+
slim — 539 MB
+
Most of the scientific stack and the Qt stack. No Spyder or JupyterLab yet: they still wait on 3.15 wheels.
+
+
+
+
+
slimf — 311 MB · free-threading
+
Free-threaded, no Qt stack. The smallest package set of the release.
+
+
dot — 19 MB
-
Bare portable Python 3.15.0rc1.
+
Bare portable Python 3.15.
-
slim, slimf, dotf — lock files only
-
On 3.15.0rc1 only about 60 % of the usual slim package set has wheels yet, so shipping a 400 MB half-empty installer would mislead more than it helps. The package sets were still resolved and locked — build them yourself and you get exactly what our builder got.
+
dotf — 19 MB · free-threading
+
Bare free-threaded Python 3.15.
@@ -191,7 +221,7 @@ Python 3.15 Preview
@@ -211,112 +241,58 @@ Which build should I take?
- | dot |
- ~18 MB |
- Python, pip, wppm, the launchers. Nothing else. |
- You install your own dependencies, or you want a small base to ship to others. |
+ 3.13.18.0 slim |
+ pylock |
+ requir |
+ list |
- | slim |
- ~670 MB |
- Around 1 000 packages: the scientific stack, Spyder, JupyterLab, the Qt stack, data and LLM libraries. |
- You want to open Spyder or JupyterLab five minutes from now with nothing left to install. |
-
-
- | dotf / slimf |
- ~18 MB / ~430 MB |
- The same, on the free-threaded (no-GIL) interpreter. slimf has no Qt stack yet. |
- You are testing free-threading. Expect gaps: many packages still have no free-threaded wheels. |
-
-
- | whl |
- — |
- A dot build shipping an offline wheelhouse instead of an installed environment. |
- Not built in 2026-03. Present in earlier releases; the lock files now cover the same need. |
-
-
-
-
-
-
-
-
-
-
-
-
- The package set, without the 670 MB
- Every WinPython build now publishes the exact package set it was made of, twice: as a pylock.toml (PEP 751) and as a hash-pinned requirements file. Both are a few hundred kilobytes, both pin every wheel by hash. If you already have a Python, you do not need our installer at all.
-
-
-
Install the 2026-03 slim set into any Python 3.14 — hash-pinned requirements, works with pip today:
-
pip install --no-deps --require-hashes --no-warn-script-location ^
- -r https://raw.githubusercontent.com/winpython/winpython/17.12.20260522/WinPython/changelogs/requir.64-3_14_7_0slim.txt
-
-
Or from the lock file, once your installer speaks PEP 751:
-
pip install --no-deps --require-hashes --no-warn-script-location ^
- -r pylock.64-3_14_7_0slim.toml
-
-
Or stage an offline wheelhouse first, to install on machines with no internet:
-
pip download --dest C:\Wheelhouse --no-deps --require-hashes -r requir.64-3_14_7_0slim.txt
-pip install --no-deps --no-index --find-links=C:\Wheelhouse --require-hashes -r requir.64-3_14_7_0slim.txt
-
-
- This is what makes a WinPython release reproducible rather than merely downloadable, and it is the direction we are moving in: a build is now defined by its lock file, and the binary is one convenient way — not the only way — to obtain it. The 3.15 slim set above is the first that exists purely as a lock file.
-
-
-
+
@@ -403,7 +379,7 @@ Ask, report, follow
diff --git a/md5_sha1.txt b/md5_sha1.txt
index ffbde83..8c98683 100644
--- a/md5_sha1.txt
+++ b/md5_sha1.txt
@@ -1,3 +1,52 @@
+### WinPython 2026-04 (October 27th, 2026)
+
+MD5 | SHA-1 | SHA-256 | Binary | Size | blake2b-256
+---------------------------------|------------------------------------------|------------------------------------------------------------------|-----------------------------------|----------------------|-----------------------------------------------------------------
+66e3c45e67645c7486e16c922aa18d2b | ae50dcd112307c3a42fbb0d225a038f44890da40 | 25589c5dc41dbce7154f0815ab438833948310cbeb1d12ba906117fae7d224a9 | WinPython64-3.13.18.0dot.exe | 16 739 371 Bytes | be24c42ee806576140fc554d81362d7ee8be44756275254bc93dc0ad0939dc4d
+ac27b4cf65a839cfcf56c789e6caf707 | 8f0d9fb3a3fd754748c793e370da495ebc366313 | 50c4f17809457e411c2db30959c8fb25a958ff3a6f624080044992809509bc37 | WinPython64-3.13.18.0dot.zip | 27 838 192 Bytes | ed1348c1001951d3ec3e4c213dc1f4ee3fb245350b191fd01d4c81bd217167c0
+33c3dc2656fbfa7c030168b0d797fd8b | e714150a7917c5f8dbd480589d1674bd53b5932c | 41708849c273cb053ec676294aaed39fd385cff6c100164b6226f55e38b011b9 | WinPython64-3.13.18.0slim.7z | 673 534 389 Bytes | 585bcc3758c85723bfb40f30e5524a95b98a9dc944951de8bed8785e0fd2a605
+10bbf9cbac67c7a362478faa248d29e2 | 9b8dcb388f38d687cfb7b5a26cf21310ae8308a2 | a47a3a5a68635930085ccc3841658633c3ac851081873bdc48341527a61f11ab | WinPython64-3.13.18.0slim.exe | 673 750 232 Bytes | a0cf43073ce9cbb27b58083c836151f6f91c4023bf6841604a5e8678495d9dbc
+2f56e3f6f279b1574124b492d312c677 | 04f0cb408f941e253c31f0d37992fe6ddf0b14c1 | 27b7400ea21faf4530384381d26f0a379c758e445e05c504c0e3dd52330e9871 | WinPython64-3.14.10.0dot.exe | 18 248 659 Bytes | 126eefda8f1774c967dc6df0db318defcbf79f7368ca4226b833ddf9a7f700c3
+f0c3f6c062721300c769982e4d46674a | 9825516726dfb06e0657a42871b34fe87096d83e | 9f54ca38f54b4ce8213925af4d7cf5b13cef724b2853408e010e5ee44e0bcc54 | WinPython64-3.14.10.0dot.zip | 28 829 421 Bytes | 2487bd3159e5365fd93971dd4b8444a82108f1b97bb82b7a6e3713bcc28cd144
+e22509bda496b5f71b050c6abc5a3a6d | a85b18462479af44ea8b2be33c5c1c106b0beea5 | 87a880d4c476b63ffaa22bf59de5f2b5b211adc6e6d5e57ca94c6f0a25c840d0 | WinPython64-3.14.10.0dotf.exe | 18 332 072 Bytes | 851b0217eef81682ae068aff4694265579d60c47d11c473f57649aa2e934d6e2
+0cb972e0fc8f4bfb55e81a46aed05c7f | 49124fea27d46230c9a78b20f8d3a5c785f05e38 | 9c63dacae921ef8250704d910c9d04bfacf37e962c52b13e5737fc3695ec1132 | WinPython64-3.14.10.0dotf.zip | 29 066 048 Bytes | 01332b59efd04466acc8ca5b1db14462a923425b240755ebb73189d2d0773d37
+a130fa33164991a66882f3976be78319 | 169908b34c644868985b39f77429b8db8c1aa6ef | 0188dfddeaea19a42ddd6e41ac22711b8790364eed7990f0d6c38ee586838cd0 | WinPython64-3.14.10.0slim.7z | 682 187 410 Bytes | 195b715282ae6dd61e57ae49119805a2b74d07fce1eb293c9d7e23b121665fc2
+739ebce1a0376877633314d66088412e | ae55a85589fd8b19804bb9e31641cb418558313d | d164a73c9f3e202a9d78e819ea4cadf7d75db0fc719a502cf4eb49bfc0293c31 | WinPython64-3.14.10.0slim.exe | 682 403 379 Bytes | 56ba100aebd6c818c80cda7b51407a803352f7a587991e01ca0f4a114c69a2c2
+f7228cfd12fbe54558675b6832ca50a7 | acdeee5fc3aed1805de156eb093c4bb7f4dbdb0b | e86c9a83b286689ceb121e799e2be2033f4454b41e6e17b7a23331285301c670 | WinPython64-3.14.10.0slimf.7z | 431 609 058 Bytes | 41cb2fb15cfe7b4ff969bd933e0bc00afd7318ea7c7245e8665795a1a4096753
+ef3f86795f0566ed64c3eaed9276a8b0 | 03ae88438ce1e0415dd3ad75af0e1afd0676b596 | f6642d708da42974ecfdcf0e6e71f033f2b60eb42c2779f0f7b4a5b93930cf7c | WinPython64-3.14.10.0slimf.exe | 431 824 652 Bytes | 97c60eefd7e69e54a24a0e313daf9a39ace0fbde4212f6f76ed23a97ced1b803
+f094e1778957744edbbcbf0a652dc7cf | ecca0932fded05b57cc74e4e55361ce924516aed | b1f27e22b795c902087f28d1f716a484fb59f9452fe40844b54d81fdb7680d39 | WinPython64-3.15.2.0dot.exe | 19 031 887 Bytes | b34c64a950a9edd654d7249fe5e37a43f659c1b28dd4a927f3f92a78b5f9741d
+9baed0dd1e9a456a954e552bc4f5a63d | 9b59c0f2ec378a5dc3415becc7a3fb4d8f388fba | 6728ce28222d8d585a5984746768f7d37b0e17bac88de0fd7e4207c2656e680f | WinPython64-3.15.2.0dot.zip | 29 982 984 Bytes | 0cd89f10be83c8779cb7908b682a79d6a7c58b28e5585a14d3d621e415db3158
+e6728377e0975772b5a2b9f56ccb382b | 0e4b70be5c0411e0f6ef16567c20d45ddf0864aa | 042be98592bf0446ec257c490814d915edd8f9987c8a63879582715f18fc7d50 | WinPython64-3.15.2.0dotf.exe | 19 072 621 Bytes | 93f8e9512d61e9170b4244ef8cf8f1fd416d4cc8867717474d438cc6775cfa0b
+d3741a096891708c75b12911985ae949 | 6f2c3fd6d0fd82dfe3ab8bc0e65a9b6c896a5bd9 | ebef1c113244d49f429db66454c57bb0a7538deb4eb0c647e220319e8379e619 | WinPython64-3.15.2.0dotf.zip | 30 103 590 Bytes | 8a31524e04c6626a336a702e19bec2c122b1b90be5c99ba02753ff82739242cf
+648023f2153086fe0726f6bf16696665 | e3af1de113f73a186bb6b321b0262667be440325 | 567596ca306ea8f1162c6c681777ca1f342f8b0ab071d7eca22b63f2eb0c6760 | WinPython64-3.15.2.0slim.7z | 538 907 332 Bytes | 540b181e4fd1e2536624b727792b77e8a9b343aa9e8baede73157ed2d2131b7a
+6540c6ded602c8522d0008adf4c4e0eb | b4f93433e8bc8164f71d2576eaccf62a65d2e760 | 20769ff2e8b91653913e8cceb875f87b65905f67536f0b3a147ec65b7e68f448 | WinPython64-3.15.2.0slim.exe | 539 122 799 Bytes | 04c3ab8230afb87f52feb11c202492fefdd10422594ab6b0d492c00bc8189e40
+707cdcdd9514df5b7dba778c4050cd78 | 0c9f4e8dd043eb1afa7508bc2bc65570dbbfc4aa | 77989212ebc0bd39b202f71a5941bd4e2a7897887586b2e1216ca553b38ae13f | WinPython64-3.15.2.0slimf.7z | 310 351 294 Bytes | d46d86dbc648b2a1f18576c32e5346c6f108932f5b692ca4607daf20a40eb775
+0b7cd528f051043578965792de47d011 | ebab912dc73afc3558015fc59cccd13357379fef | c3e83f71a357956aa8d9a98d852e7f51bee43d98c662a38925d521d95b9753c5 | WinPython64-3.15.2.0slimf.exe | 310 566 930 Bytes | 573752fba9a47874b3ea8f9a0f93065a9adc391f02afb6917d5630ca49af64f9
+
+MD5 | SHA-1 | SHA-256 | Binary | Size | blake2b-256
+---------------------------------|------------------------------------------|------------------------------------------------------------------|-----------------------------------|----------------------|-----------------------------------------------------------------
+d7933836c16931d2db62b1b6fa14fe7c | 2674501a91a1e30d82bd3f5d475e508e08abc846 | a71c8603e1a1ab51f06475ffd16b1e55d75b1f8a1d4a62d2acafed8f0b569418 | pylock.64-3_13_18_0dot.toml | 3 062 Bytes | 8e420ded8548ab32a6a909ffc876ec08aae656df9ccd53ce635928d02db7ca60
+9123e32c0bde06b662f53b04ce65ebb3 | 473bbbc98897f2b56eba20145f5cb4092bca9139 | c08412e66eb198d4464a37f5ce8d5ad2e6cc0d08f9ce2423862d3e0699c82d71 | pylock.64-3_13_18_0slim.toml | 199 999 Bytes | 8706371f179b9804add5b5bd7da4d74ba4ef9980ba77d3887b91649d1528a44f
+d7933836c16931d2db62b1b6fa14fe7c | 2674501a91a1e30d82bd3f5d475e508e08abc846 | a71c8603e1a1ab51f06475ffd16b1e55d75b1f8a1d4a62d2acafed8f0b569418 | pylock.64-3_14_10_0dot.toml | 3 062 Bytes | 8e420ded8548ab32a6a909ffc876ec08aae656df9ccd53ce635928d02db7ca60
+d7933836c16931d2db62b1b6fa14fe7c | 2674501a91a1e30d82bd3f5d475e508e08abc846 | a71c8603e1a1ab51f06475ffd16b1e55d75b1f8a1d4a62d2acafed8f0b569418 | pylock.64-3_14_10_0dotf.toml | 3 062 Bytes | 8e420ded8548ab32a6a909ffc876ec08aae656df9ccd53ce635928d02db7ca60
+0e9afce865fd5f2666b02a13aa465b8b | a4617fd860136516bbefb1a398384666d35eb0db | 57240682d61f8ae514d457c15c20a679f344d0089d92bc9d6f71540022edbcac | pylock.64-3_14_10_0slim.toml | 210 287 Bytes | 9e0bddfed22469d4c8cde6d5e23aaf8faede172323e4752bd930cabb6c40c666
+9d92799f52316f97d42a23332e694aa1 | 5d69eaac91a41e028f5216e8594f6f46027093f9 | 989148c42740c9d828654b164650495da880989d9378e14bbe2128accc28abba | pylock.64-3_14_10_0slimf.toml | 177 514 Bytes | 313358f7c5ab719a6ea56456149d87726fb91931912dc58bbf4aaa4b4dc5a3fc
+d7933836c16931d2db62b1b6fa14fe7c | 2674501a91a1e30d82bd3f5d475e508e08abc846 | a71c8603e1a1ab51f06475ffd16b1e55d75b1f8a1d4a62d2acafed8f0b569418 | pylock.64-3_15_2_0dot.toml | 3 062 Bytes | 8e420ded8548ab32a6a909ffc876ec08aae656df9ccd53ce635928d02db7ca60
+d7933836c16931d2db62b1b6fa14fe7c | 2674501a91a1e30d82bd3f5d475e508e08abc846 | a71c8603e1a1ab51f06475ffd16b1e55d75b1f8a1d4a62d2acafed8f0b569418 | pylock.64-3_15_2_0dotf.toml | 3 062 Bytes | 8e420ded8548ab32a6a909ffc876ec08aae656df9ccd53ce635928d02db7ca60
+be295a85f57f937d11da4ef89e6cd20d | 5a66a229ef7efbb0e064a931d148d47ce909773d | 1224f58aa47a0339d0ce90cc71506f32587a49ca09f763476cede70436a3f409 | pylock.64-3_15_2_0slim.toml | 179 316 Bytes | b8cf00492f08462f40f90478b39aabdb7216c6b36ef84b511ab1609f2399eb80
+8a3b68f9a9155c0c577a6560601abb8d | 9930935e50ad079ec77e0c2f97745e0ee56f9697 | 4147b9fd7ccb466e8562f49a5e72c347ba2e94327a16a51b84da60c1c791572a | pylock.64-3_15_2_0slimf.toml | 155 335 Bytes | dac5ef7cd9e8909d60adcffe3f60cc27c2c32f6e1ead099552634f21b9c33f21
+b07c2406db39392bf612207b17a8b7dd | 93a515d2a3ade580965fa69ab97b477a676cc738 | 3374bb78e1a5f947f5a83cc851cb0267169ff18dad673cb043f790af7f71b7b6 | requir.64-3_13_18_0dot.txt | 836 Bytes | 39b912da1f4ebf4b749a8c047c7647e819e6820becce9c97fbda7da40d9a52c5
+09be08047c51ff68b7068d37fcb02bd9 | d96003d8b699a5d36853c45cb829b10ed92ca5b0 | fef1766e3f5f5837abca76019bef00d7c48f2c84893ff69efa45797b44d6ed97 | requir.64-3_13_18_0slim.txt | 54 914 Bytes | e32843746abf18ea6fc21526e0d8aa6c390093007df6dbb2e540c7f5c229ad80
+b07c2406db39392bf612207b17a8b7dd | 93a515d2a3ade580965fa69ab97b477a676cc738 | 3374bb78e1a5f947f5a83cc851cb0267169ff18dad673cb043f790af7f71b7b6 | requir.64-3_14_10_0dot.txt | 836 Bytes | 39b912da1f4ebf4b749a8c047c7647e819e6820becce9c97fbda7da40d9a52c5
+b07c2406db39392bf612207b17a8b7dd | 93a515d2a3ade580965fa69ab97b477a676cc738 | 3374bb78e1a5f947f5a83cc851cb0267169ff18dad673cb043f790af7f71b7b6 | requir.64-3_14_10_0dotf.txt | 836 Bytes | 39b912da1f4ebf4b749a8c047c7647e819e6820becce9c97fbda7da40d9a52c5
+0fbb565cceaffa86a1109118755bbd8c | 1e0fcc4599c84bc45e186ebe6234535f947130b8 | 7f974186f719bbf1a8b4bef813ddeee4e424278d08b7f687a838443fc5b3d260 | requir.64-3_14_10_0slim.txt | 57 786 Bytes | 480710d5c727a6332c2109621e1fc123ad32e124d6d89725985fe2ecbe1a0a6e
+44f66ac76ac86d20b8389ee7ab2ca23d | 01875465f7e040ff7002b2792f70fa3fc1b908fa | ff63920d075cf5b0ce975b265491104ecb87d73be568b049220b4b9996ecad0f | requir.64-3_14_10_0slimf.txt | 48 791 Bytes | f469f0f5e296164ec5f0fd8563afa038c918db9abcde678ba49eb4c4d4c17deb
+b07c2406db39392bf612207b17a8b7dd | 93a515d2a3ade580965fa69ab97b477a676cc738 | 3374bb78e1a5f947f5a83cc851cb0267169ff18dad673cb043f790af7f71b7b6 | requir.64-3_15_2_0dot.txt | 836 Bytes | 39b912da1f4ebf4b749a8c047c7647e819e6820becce9c97fbda7da40d9a52c5
+b07c2406db39392bf612207b17a8b7dd | 93a515d2a3ade580965fa69ab97b477a676cc738 | 3374bb78e1a5f947f5a83cc851cb0267169ff18dad673cb043f790af7f71b7b6 | requir.64-3_15_2_0dotf.txt | 836 Bytes | 39b912da1f4ebf4b749a8c047c7647e819e6820becce9c97fbda7da40d9a52c5
+d809427d604bb7ec5fbe926f44fedc05 | ea92e994616517a257fcc82f05693ed5e4980645 | 3f374149e31eaa4b3b6e33bbb464ed31892aa00ec98215d085c4cce1c2f40600 | requir.64-3_15_2_0slim.txt | 49 304 Bytes | 83faaea4f3823f07a33a6be40cba337cbf1590c22fda9be55c6720454940f6b7
+0204ce22c52879e634e69eee5da4aafa | ef205d78eb98a905630ceb51a99d9a7e92f56bdd | fc7efae9ffcd0170e1f0bb27a04b958fd310d24870e795ad66faa961cf630983 | requir.64-3_15_2_0slimf.txt | 42 727 Bytes | d53a395c088b0257b5cf413e65986e094385909e7d2e3bf3c19e80913c470dac
+
+
### WinPython 2026-03 (August 22nd, 2026)
MD5 | SHA-1 | SHA-256 | Binary | Size | blake2b-256
diff --git a/releases.html b/releases.html
index 1e15f98..417f572 100644
--- a/releases.html
+++ b/releases.html
@@ -43,6 +43,90 @@ Release archive
+ WinPython 2026-04 — October 27th, 2026
+
+
Release notes and discussion
+ · download page
+
Python-3.13.18, Python-3.14.10, Python-3.15.2, numpy-2.5.3, pandas-3.0.5, scipy-1.18.1,
+ polars-2.1.0, duckdb-2.0.0, numba-0.68.0, jupyterlab-4.6.4, spyder-6.1.7, wppm-17.14.20261027
+
WinPython 3.13 — Python 3.13.18 · SourceForge and Github
+
+
WinPython 3.14 — Python 3.14.10 · SourceForge and Github
+
+ - WinPython64-3.14.10.0dot — bare portable Python :
+ exe,
+ zip,
+ packages,
+ pylock,
+ requirements
+ - WinPython64-3.14.10.0dotf — bare portable Python, free-threading :
+ exe,
+ zip,
+ packages,
+ pylock,
+ requirements
+ - WinPython64-3.14.10.0slim — full scientific environment :
+ exe,
+ 7z,
+ packages,
+ pylock,
+ requirements
+ - WinPython64-3.14.10.0slimf — free-threading, no Qt stack :
+ exe,
+ 7z,
+ packages,
+ pylock,
+ requirements
+
+
WinPython 3.15 — Python 3.15.2 · SourceForge and Github
+
+ - WinPython64-3.15.2.0dot — bare portable Python :
+ exe,
+ zip,
+ packages,
+ pylock,
+ requirements
+ - WinPython64-3.15.2.0dotf — bare portable Python, free-threading :
+ exe,
+ zip,
+ packages,
+ pylock,
+ requirements
+ - WinPython64-3.15.2.0slim — full scientific environment :
+ exe,
+ 7z,
+ packages,
+ pylock,
+ requirements
+ - WinPython64-3.15.2.0slimf — free-threading, no Qt stack :
+ exe,
+ 7z,
+ packages,
+ pylock,
+ requirements
+
+
+
+
WinPython 2026-03 — August 22nd, 2026
Release notes and discussion
From ad1402cb891976751364eefa610c2c0153809af1 Mon Sep 17 00:00:00 2001
From: stonebig
Date: Tue, 27 Oct 2026 23:42:09 +0100
Subject: [PATCH 3/3] Test: read the release title from site_content.toml
Co-Authored-By: Claude Opus 5.5
---
tests/test_render_site.py | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/tests/test_render_site.py b/tests/test_render_site.py
index 5b9840b..62aa650 100644
--- a/tests/test_render_site.py
+++ b/tests/test_render_site.py
@@ -13,6 +13,7 @@
import datetime
import importlib.util
import shutil
+import tomllib
from pathlib import Path
import pytest
@@ -190,13 +191,15 @@ def test_a_release_lands_in_both_files(self, render, one_cycle, content, tmp_pat
site = tmp_path / "site"
shutil.copytree(SITE, site, ignore=shutil.ignore_patterns(".git"))
before = (site / "releases.html").read_text(encoding="utf-8")
+ newest = (site / "md5_sha1.txt").read_text(encoding="utf-8").lstrip().splitlines()[0]
+ title = tomllib.loads(content.read_text(encoding="utf-8"))["release"]["title"]
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"
+ assert md5.lstrip().startswith(f"### {title} (September 6th, 2026)")
+ assert newest in md5, "history must survive"
after = (site / "releases.html").read_text(encoding="utf-8")
assert after.count("