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
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
', + "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 @@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.
+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.
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.
The full scientific environment: Spyder, JupyterLab, the Qt stack, the data and AI libraries.
Free-threaded (no GIL) build. No Qt stack, and a number of binary packages are still missing upstream.
Bare portable Python 3.14.
Bare free-threaded Python 3.14.
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.
The full scientific environment, on Python 3.13.
Bare portable Python plus pip and wppm. Start here and add exactly what you need.
WinPython 3.15.0.4 — Python 3.15.0rc1. For trying the next Python early.
+WinPython 3.15.2.0 — Python 3.15.2. The first full set of 3.15 builds. Wheels are still arriving, so expect gaps.
+ +Most of the scientific stack and the Qt stack. No Spyder or JupyterLab yet: they still wait on 3.15 wheels.
+ +Free-threaded, no Qt stack. The smallest package set of the release.
+ +Bare portable Python 3.15.0rc1.
+Bare portable Python 3.15.
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.
+Bare free-threaded Python 3.15.
- 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 @@pip, wppm, the launchers. Nothing else.slimf has no Qt stack yet.dot build shipping an offline wheelhouse instead of an installed environment..exe and .7z / .zip hold identical content — the .exe is only a self-extracting archive, so use whichever your download policy allows.
-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.
| Build | pylock.toml | requirements + hashes | Package list | ||||
|---|---|---|---|---|---|---|---|
| 3.13.15.0 slim | -pylock | -requir | -list | +3.13.18.0 dot | +pylock | +requir | +list |
| 3.13.15.0 dot | -pylock | -requir | -list | +3.14.10.0 slim | +pylock | +requir | +list |
| 3.14.7.0 slim | -pylock | -requir | -list | +3.14.10.0 slimf | +pylock | +requir | +list |
| 3.14.7.0 slimf | -pylock | -requir | -list | +3.14.10.0 dot / dotf | +pylock · pylock f | +requir · requir f | +list |
| 3.14.7.0 dot / dotf | -pylock · pylock f | -requir · requir f | -list | +3.15.2.0 slim | +pylock | +requir | +list |
| 3.15.0.4 dot | -pylock | -requir | -list | +3.15.2.0 slimf | +pylock | +requir | +list |
| 3.15.0.4 slim / slimf (no binary) | -pylock · pylock f | -requir · requir f | -list | +3.15.2.0 dot / dotf | +pylock · pylock f | +requir · requir f | +list |
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.
© 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.
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
+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") == before + 1