fix: keep release checks importable on python 3.9

check_release_assets.py built a tuple[..., int | None] alias at import, a TypeError on the 3.9 floor tools.md states, and check_release.py imports it. A test reads every script for unions and match statements evaluated at runtime.
This commit is contained in:
Abdessamad Derraz committed 2026-10-21 09:51:33 +02:00
1 parent 0242dc77ee
commit 9bb3dfbd1b
2 files changed
+53 -1

No files matched your search

+3 -1
View File
@@ -22,6 +22,7 @@ import re
import subprocess
import sys
from collections.abc import Iterable
from typing import Optional, Tuple
sys.path.insert(0, os.path.dirname(__file__))
from common import composition_tier, load_database
@@ -32,7 +33,8 @@ from largefiles import (
registered_paths,
)
Finding = tuple[str, str, int, int | None]
# Evaluated at import, where `int | None` needs Python 3.10.
Finding = Tuple[str, str, int, Optional[int]]
def expected_assets(db: dict, gitignore_text: str) -> dict[str, int]:
+50
View File
@@ -416,5 +416,55 @@ class PythonFloorOnRetroPie(unittest.TestCase):
self.assertIn("3.7.3", changing)
class ToolsRunOnTheStatedFloor(unittest.TestCase):
"""tools.md and testing-guide.md state Python 3.9 for every script, and
check_release_assets.py built `int | None` at import: a TypeError on 3.9
before any argument was read, in check_release.py too. CI runs 3.12 only,
so this test reads the source for what 3.9 cannot evaluate."""
TYPES = {"int", "str", "float", "bytes", "bool", "dict", "list", "tuple", "set", "type"}
def _type_like(self, node: ast.AST) -> bool:
if isinstance(node, ast.Constant) and node.value is None:
return True
if isinstance(node, ast.Subscript):
node = node.value
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
return self._type_like(node.left) or self._type_like(node.right)
return isinstance(node, ast.Name) and node.id in self.TYPES
def _runtime_unions(self, tree: ast.AST) -> list[int]:
annotations = set()
for node in ast.walk(tree):
for field in ("annotation", "returns"):
value = getattr(node, field, None)
if value is not None:
annotations.update(id(n) for n in ast.walk(value))
return [
node.lineno for node in ast.walk(tree)
if id(node) not in annotations
and (isinstance(node, ast.Match)
or isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr)
and (self._type_like(node.left) or self._type_like(node.right)))
]
def test_pages_state_the_floor(self):
self.assertIn("Python 3.9 or newer", _page("tools.md"))
self.assertIn("Python 3.9 or newer", _page("testing-guide.md"))
def test_no_script_evaluates_310_syntax(self):
offenders = [
f"{path.relative_to(ROOT)}:{line}"
for path in sorted((ROOT / "scripts").rglob("*.py"))
for line in self._runtime_unions(ast.parse(path.read_text(encoding="utf-8")))
]
self.assertEqual(offenders, [], "\n".join(offenders))
def test_the_check_catches_the_alias(self):
self.assertTrue(self._runtime_unions(ast.parse("F = tuple[str, int | None]")))
self.assertFalse(self._runtime_unions(ast.parse("def f(x: int | None) -> str | None: ...")))
self.assertFalse(self._runtime_unions(ast.parse("mask = flags | other")))
if __name__ == "__main__":
unittest.main()