fix: load every script under the 3.9 floor

A module-level alias written with a PEP 604 union compiled under 3.9 and
raised TypeError when check_release_assets.py, and check_release.py
through it, were loaded. The floor test now imports every module under
scripts/ with a real 3.9 interpreter, which CI gives pyyaml and
jsonschema.
This commit is contained in:
Abdessamad Derraz committed 2026-10-21 09:53:34 +02:00
1 parent 0242dc77ee
commit 8721dafd2a
3 files changed
+56 -22

No files matched your search

+7 -2
View File
@@ -195,8 +195,10 @@ jobs:
# install.py stays on Python 3.8: the suite runs it end to end under a
# real 3.8 interpreter, which import-only and grammar checks miss. 3.9
# is the floor pipeline.py states for the build scripts, compiled under
# a real 3.9. The last version listed is the default python.
# is the floor pipeline.py states for the build scripts: a real 3.9
# imports every module under scripts/, so it needs their dependencies.
# jsonschema 4.26 requires 3.10, 4.25.1 is the last release for 3.9.
# The last version listed is the default python.
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: |
@@ -204,6 +206,9 @@ jobs:
3.9
3.12
- name: Install the build scripts' dependencies for Python 3.9
run: python3.9 -m pip install pyyaml "jsonschema[format-nongpl]==4.25.1"
# markdown: the site validation test renders a wiki line to prove the
# heading guard's premise, and skipped on every run without it. The docs
# group builds the site as deploy-site.yml does, with the same bounds.
+15 -7
View File
@@ -22,6 +22,7 @@ import re
import subprocess
import sys
from collections.abc import Iterable
from typing import NamedTuple
sys.path.insert(0, os.path.dirname(__file__))
from common import composition_tier, load_database
@@ -32,7 +33,14 @@ from largefiles import (
registered_paths,
)
Finding = tuple[str, str, int, int | None]
class AssetFinding(NamedTuple):
"""A difference between the release and the collection."""
kind: str
path: str
size: int
published: int | None
def expected_assets(db: dict, gitignore_text: str) -> dict[str, int]:
@@ -61,7 +69,7 @@ def compare(
registered: Iterable[str] | None = None,
expected_digests: dict[str, str] | None = None,
asset_digests: dict[str, str] | None = None,
) -> list[Finding]:
) -> list[AssetFinding]:
"""Files the release lacks or serves at another size, sorted by kind.
At the same size, the sha256 the release lists for an asset is held
@@ -74,9 +82,9 @@ def compare(
matches what render_notes() would write is a finding of its own: the
page is what a reader checks a download against.
"""
findings: list[Finding] = []
findings: list[AssetFinding] = []
if not notes_current:
findings.append(("notes", "release description", 0, None))
findings.append(AssetFinding("notes", "release description", 0, None))
names = asset_names([*(registered or ()), *expected])
for path, size in sorted(expected.items()):
served = next(
@@ -84,14 +92,14 @@ def compare(
)
published = assets[served] if served else None
if published is None:
findings.append(("missing", path, size, None))
findings.append(AssetFinding("missing", path, size, None))
elif published != size:
findings.append(("size", path, size, published))
findings.append(AssetFinding("size", path, size, published))
else:
ours = (expected_digests or {}).get(path)
theirs = (asset_digests or {}).get(served)
if ours and theirs and ours != theirs:
findings.append(("content", path, size, published))
findings.append(AssetFinding("content", path, size, published))
return sorted(findings)
+34 -13
View File
@@ -110,11 +110,12 @@ class ReadmeRegressions(unittest.TestCase):
self.assertEqual(proc.returncode, 1)
self.assertIn("needs Python 3.9", proc.stderr)
def test_the_floor_interpreter_compiles_every_script(self):
"""The floor said 3.12 for a reason the code no longer carried: every
script compiled under 3.9, and only the guard refused it. Checked
with a real interpreter of the floor version, as the installer's 3.8
floor is."""
def test_the_floor_interpreter_imports_every_script(self):
"""Every module under scripts/ is loaded by a real interpreter of the
floor version, as the installer's 3.8 floor is proven by running it.
Compiling only checks the grammar: a module-level alias such as
`tuple[int, int | None]` compiles under 3.9 and raises TypeError
when the module loads."""
import shutil # noqa: PLC0415
floor = re.search(
@@ -128,21 +129,41 @@ class ReadmeRegressions(unittest.TestCase):
self.fail(f"CI must provide {name}")
self.skipTest(f"no {name} on PATH")
check = (
"import pathlib, sys\n"
"bad = []\n"
"import importlib, json, pathlib, sys\n"
"sys.path.insert(0, '.')\n"
"failed = {}\n"
"for p in sorted(pathlib.Path('scripts').rglob('*.py')):\n"
" if '__pycache__' in p.parts:\n"
" continue\n"
" parts = p.with_suffix('').parts\n"
" if parts[-1] == '__init__':\n"
" parts = parts[:-1]\n"
" try:\n"
" compile(p.read_text(encoding='utf-8'), str(p), 'exec')\n"
" except SyntaxError as e:\n"
" bad.append(f'{p}:{e.lineno}')\n"
"print(bad)\n"
"sys.exit(1 if bad else 0)\n"
" importlib.import_module('.'.join(parts))\n"
" except BaseException as e:\n"
" failed[str(p)] = [type(e).__name__, getattr(e, 'name', None), str(e)]\n"
"print(json.dumps(failed))\n"
)
proc = subprocess.run(
[interpreter, "-I", "-c", check], cwd=ROOT, capture_output=True,
text=True, timeout=300, check=False,
text=True, timeout=600, check=False,
env={**os.environ, "RETROBIOS_OFFLINE": "1"},
)
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
failed = json.loads(proc.stdout.strip().splitlines()[-1])
# pyyaml for every build script, jsonschema for validate_schemas:
# without them the floor interpreter cannot load the modules, which
# proves nothing either way.
absent = sorted({
err[1] for err in failed.values()
if err[0] == "ModuleNotFoundError" and err[1] in ("yaml", "jsonschema")
})
if absent:
message = f"{name} lacks {', '.join(absent)}"
if os.environ.get("CI"):
self.fail(message)
self.skipTest(message)
self.assertEqual(failed, {})
class ReleaseSigningRegressions(unittest.TestCase):