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..6e22b92 --- /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-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-04" +# publication date; leave out to use the day the generator runs +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/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.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] +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/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.

@@ -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

- All files: GitHub release · + All files: GitHub release · SourceForge · checksums for every file (MD5, SHA-1, SHA-256, blake2b) in md5_sha1.txt.

@@ -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. - - - - - -

.exe and .7z / .zip hold identical content — the .exe is only a self-extracting archive, so use whichever your download policy allows.

-
- -
- - -
-

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.

- -
- - - - - - - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + +
Buildpylock.tomlrequirements + hashesPackage list
3.13.15.0 slimpylockrequirlist3.13.18.0 dotpylockrequirlist
3.13.15.0 dotpylockrequirlist3.14.10.0 slimpylockrequirlist
3.14.7.0 slimpylockrequirlist3.14.10.0 slimfpylockrequirlist
3.14.7.0 slimfpylockrequirlist3.14.10.0 dot / dotfpylock · pylock frequir · requir flist
3.14.7.0 dot / dotfpylock · pylock frequir · requir flist3.15.2.0 slimpylockrequirlist
3.15.0.4 dotpylockrequirlist3.15.2.0 slimfpylockrequirlist
3.15.0.4 slim / slimf (no binary)pylock · pylock frequir · requir flist3.15.2.0 dot / dotfpylock · pylock frequir · requir flist
-

Lock and requirement files for every build of the cycle, betas included, live in winpython/portable/cycle_2026_03. Want a different package set? Fork the repository, edit the requirements, and run the build workflow in your own GitHub Actions.

+

Lock and requirement files for every build of the cycle, betas included, live in winpython/portable/cycle_2026_04. Want a different package set? Fork the repository, edit the requirements, and run the build workflow in your own GitHub Actions.


@@ -403,7 +379,7 @@

Ask, report, follow

© 2009–2013 Pierre Raybaut; 2014–2026+ The WinPython Development Team. Released under the MIT licence.

-

Last updated 2026-08-22.

+

Last updated 2026-10-27.

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 89bcb18..417f572 100644 --- a/releases.html +++ b/releases.html @@ -40,7 +40,93 @@

Release archive

cod (with VS Code) and 32-bit builds.

+ +
+ 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

+ +

WinPython 3.15 — Python 3.15.2 · SourceForge and Github

+ +
+
+
WinPython 2026-03 — August 22nd, 2026

Release notes and discussion 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..62aa650 --- /dev/null +++ b/tests/test_render_site.py @@ -0,0 +1,216 @@ +# -*- 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 +import tomllib +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") + 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(f"### {title} (September 6th, 2026)") + assert newest 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