fix: nl_mkimage wieder Python-3.8-tauglich machen

CMake ruft nl_mkimage.py bei jedem Firmware-Build ueber das plain
"python3" im Docker-Build-Container auf (Python 3.8.10), unabhaengig von
den Werkzeugen auf Mac/Pi, die Python >= 3.11 voraussetzen. Die zuletzt
hinzugekommene Annotation "expect_device: str | None" wird dort zur
Laufzeit ausgewertet und wirft "TypeError: unsupported operand type(s)
for |: 'type' and 'NoneType'", was jeden Firmware-Build abbrechen liess.

"from __future__ import annotations" (wie in tools/station/server.py)
sorgt dafuer, dass Annotationen nur noch als Text existieren und nie
ausgewertet werden - der Rest der Datei kam ohne weitere 3.8-Stolpersteine
aus.

test: zwei Regressionstests ergaenzt, die ohne installiertes Python 3.8
auskommen: einer prueft per AST, dass der future-Import direkt nach dem
Docstring steht, der andere laesst vermin (dev-Dependency) die
Mindestversion aus Syntax/Stdlib-Nutzung ableiten. vermins
"union-types"-Feature wird bewusst nicht aktiviert, weil es "str | None"
unabhaengig vom future-Import als 3.10-Anforderung meldet - das wuerde
ausgerechnet den korrekten Fix durchfallen lassen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Michael Sauer
2026-09-12 13:12:54 +02:00
co-authored by Claude Sonnet 5
parent d23b0d4e02
commit 4a8a5d658f
4 changed files with 95 additions and 2 deletions
+68
View File
@@ -208,3 +208,71 @@ def test_verify_with_build_arguments_is_an_error(tmp_path):
)
assert r.returncode != 0
assert "cannot be combined" in r.stderr
# --- Python 3.8 compatibility -------------------------------------------------------------
#
# CMake calls this script via plain `python3` inside the build container on every firmware
# build (see docker/Dockerfile, cmake/run_mkimage.cmake), and that container is stuck on
# Python 3.8.10. This is the opposite constraint from the rest of the repo's tooling (which
# needs Python >= 3.11) - see the module docstring in nl_mkimage.py for the full story, and
# git history around the "unsupported operand type(s) for |" break for what happens when it
# regresses.
#
# Two checks, because neither alone covers the failure mode we actually hit:
# 1. `from __future__ import annotations` must be the first statement after the module
# docstring. As long as it's there, PEP 604 unions in annotations (`str | None`) are
# never evaluated at runtime, so they can't raise on 3.8/3.9 no matter how many more of
# them get added later.
# 2. vermin's *default* analysis (annotations not evaluated - matching point 1) catches
# everything else 3.9+/3.10+/3.11+ that could sneak in: walrus in odd positions, match
# statements, dict `|=`, new stdlib members, etc. We deliberately do NOT enable vermin's
# `union-types` feature: it flags `str | None` as needing 3.10 unconditionally, without
# accounting for `from __future__ import annotations` deferring evaluation - which would
# make this test fail on the very fix that makes the file 3.8-safe. Check 1 above is
# what actually guards against that pattern.
def test_future_annotations_import_is_present():
"""Guards the PEP 604 union pattern that broke the Docker build (see module docstring)."""
import ast
tree = ast.parse(SCRIPT.read_text())
body = tree.body
# Skip a leading module docstring, if any.
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) \
and isinstance(body[0].value.value, str):
body = body[1:]
assert body, "nl_mkimage.py is unexpectedly empty"
first = body[0]
assert isinstance(first, ast.ImportFrom) and first.module == "__future__" \
and any(alias.name == "annotations" for alias in first.names), (
"nl_mkimage.py must start with `from __future__ import annotations` right after its "
"module docstring - without it, PEP 604 unions like `str | None` in annotations are "
"evaluated eagerly and crash under the Python 3.8.10 in the Docker build container "
"(TypeError: unsupported operand type(s) for |: 'type' and 'NoneType')"
)
def test_stays_python38_compatible():
"""Static check that nl_mkimage.py needs nothing newer than Python 3.8 to run.
Runs without a Python 3.8 interpreter installed: vermin infers the minimum required
version from syntax/stdlib usage instead of actually executing the file.
"""
vermin = __import__("vermin")
config = vermin.Config()
config.set_quiet(True)
# Match nl_mkimage.py's own strategy: with `from __future__ import annotations` present
# (checked separately above), annotations are never evaluated, so vermin shouldn't assume
# they need runtime support either. This is vermin's default, spelled out here so it's
# obvious why `union-types` (see the block comment above) is intentionally not enabled.
config.set_eval_annotations(False)
source = SCRIPT.read_text()
py2_min, py3_min = vermin.detect(source, config, str(SCRIPT))
assert py3_min is not None and py3_min <= (3, 8), (
f"nl_mkimage.py now needs Python {py3_min} - it must keep working on the "
"Python 3.8.10 in the Docker build container (see module docstring)"
)