From b7b27bf2787278fe7b0d6279d37958ad9e18a0bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NEDJAR?= Date: Tue, 14 Apr 2026 10:16:04 +0200 Subject: [PATCH 1/2] test: Verify each driver is frozen in the upstream firmware manifest. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a parametrized pytest test that generates one case per `lib/*/` and asserts the driver name is declared in the STEAM32_WB55RG manifest hosted in steamicc/micropython-steami. Catches regressions like the accidental removal of mcp23009e and the forgotten steami_screen. The upstream manifest is fetched at test time (no dependency on a local clone), with an env override for forks. A `lib//.not-frozen` marker opts a driver out with a one-line reason — used here for gc9a01 (pending freeze, #368) and im34dt05 (not yet integrated). Network failures skip cleanly so offline dev is unaffected. Closes #392. --- CONTRIBUTING.md | 1 + lib/gc9a01/.not-frozen | 1 + lib/im34dt05/.not-frozen | 1 + tests/test_frozen_manifest.py | 70 +++++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+) create mode 100644 lib/gc9a01/.not-frozen create mode 100644 lib/im34dt05/.not-frozen create mode 100644 tests/test_frozen_manifest.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb3f68c7..ffc41878 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,7 @@ lib// * The directory name must match the driver name (e.g. `mcp23009e`, `wsen-hids`) * The main class must be exposed in `__init__.py` * Drivers must be self-contained (no cross-driver dependencies) +* Every driver is automatically checked against the upstream firmware manifest by `tests/test_frozen_manifest.py`. If a driver is intentionally **not** frozen (experimental, not yet integrated, etc.), add an empty `lib//.not-frozen` file containing a one-line reason — the test will skip it with that reason displayed. ## Coding conventions diff --git a/lib/gc9a01/.not-frozen b/lib/gc9a01/.not-frozen new file mode 100644 index 00000000..196258f1 --- /dev/null +++ b/lib/gc9a01/.not-frozen @@ -0,0 +1 @@ +Pending freeze, see #368. diff --git a/lib/im34dt05/.not-frozen b/lib/im34dt05/.not-frozen new file mode 100644 index 00000000..b3f88e4a --- /dev/null +++ b/lib/im34dt05/.not-frozen @@ -0,0 +1 @@ +Not yet integrated into the firmware manifest. diff --git a/tests/test_frozen_manifest.py b/tests/test_frozen_manifest.py new file mode 100644 index 00000000..938d6429 --- /dev/null +++ b/tests/test_frozen_manifest.py @@ -0,0 +1,70 @@ +"""Verify each driver under lib/ is declared in the upstream frozen manifest. + +Catches silent regressions where a driver is accidentally removed from, or +forgotten in, the STEAM32_WB55RG board manifest in `steamicc/micropython-steami`. +""" + +import os +import re +from pathlib import Path +from urllib.error import URLError +from urllib.request import urlopen + +import pytest + +LIB_DIR = Path(__file__).parent.parent / "lib" + +# Keep this branch in sync with MICROPYTHON_BRANCH in env.mk. +MANIFEST_URL = os.environ.get( + "STEAMI_FIRMWARE_MANIFEST_URL", + "https://raw.githubusercontent.com/steamicc/micropython-steami/" + "stm32-steami-rev1d-final/ports/stm32/boards/STEAM32_WB55RG/manifest.py", +) + +REQUIRE_RE = re.compile( + r'require\(\s*"([^"]+)"\s*,\s*library\s*=\s*"micropython-steami-lib"\s*\)' +) + + +@pytest.fixture(scope="session") +def frozen_drivers(): + """Fetch the upstream manifest once per session and return the set of + driver names required from micropython-steami-lib.""" + try: + with urlopen(MANIFEST_URL, timeout=10) as resp: + content = resp.read().decode("utf-8") + except (URLError, TimeoutError, OSError) as exc: + pytest.skip(f"cannot fetch upstream manifest: {exc}") + return set(REQUIRE_RE.findall(content)) + + +def _discover_driver_dirs(): + return sorted(d for d in LIB_DIR.iterdir() if d.is_dir()) + + +_driver_dirs = _discover_driver_dirs() + + +@pytest.mark.parametrize( + "driver_dir", + _driver_dirs, + ids=[d.name for d in _driver_dirs], +) +def test_driver_is_frozen_in_firmware_mock(driver_dir, frozen_drivers): + """Each driver under lib/ must be required in the upstream firmware manifest. + + To intentionally ship a driver outside the firmware, add an empty + `lib//.not-frozen` marker (optionally containing a one-line reason). + """ + not_frozen = driver_dir / ".not-frozen" + if not_frozen.exists(): + reason = not_frozen.read_text(encoding="utf-8").strip() or "marked .not-frozen" + pytest.skip(f"{driver_dir.name}: {reason}") + + assert driver_dir.name in frozen_drivers, ( + f"{driver_dir.name} is not required in the frozen manifest " + f"({MANIFEST_URL}). Add " + f'require("{driver_dir.name}", library="micropython-steami-lib") ' + f"to the upstream manifest, or add a lib/{driver_dir.name}/.not-frozen " + f"marker if the driver is intentionally not shipped." + ) From 34281f737866a90afa35c5ba25aec105a66134a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NEDJAR?= Date: Tue, 14 Apr 2026 10:21:59 +0200 Subject: [PATCH 2/2] test: Address Copilot review on frozen-manifest check. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues raised on #393: 1. URLError catches HTTPError too, so a 404 (branch/path renamed, path moved) would silently skip the test and permanently disable the protection. Handle HTTPError separately and fail loudly with a message pointing to MANIFEST_URL. 2. The regex-based extraction was formatting-sensitive: single quotes, extra keyword arguments, or a different argument order would produce false failures. Replace with an ast-based walker that inspects require() calls and matches on the library= keyword — resilient to any legal Python formatting. 3. CONTRIBUTING.md contradicted itself ("empty file containing a one-line reason"). Reword per Copilot's suggestion: empty by default, optionally with a one-line reason. --- CONTRIBUTING.md | 2 +- tests/test_frozen_manifest.py | 40 +++++++++++++++++++++++++++++------ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ffc41878..7d91d1c8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ lib// * The directory name must match the driver name (e.g. `mcp23009e`, `wsen-hids`) * The main class must be exposed in `__init__.py` * Drivers must be self-contained (no cross-driver dependencies) -* Every driver is automatically checked against the upstream firmware manifest by `tests/test_frozen_manifest.py`. If a driver is intentionally **not** frozen (experimental, not yet integrated, etc.), add an empty `lib//.not-frozen` file containing a one-line reason — the test will skip it with that reason displayed. +* Every driver is automatically checked against the upstream firmware manifest by `tests/test_frozen_manifest.py`. If a driver is intentionally **not** frozen (experimental, not yet integrated, etc.), add a `lib//.not-frozen` marker file (it may be empty; optionally include a one-line reason) — the test will skip it and display the reason if present. ## Coding conventions diff --git a/tests/test_frozen_manifest.py b/tests/test_frozen_manifest.py index 938d6429..fa56890d 100644 --- a/tests/test_frozen_manifest.py +++ b/tests/test_frozen_manifest.py @@ -4,10 +4,10 @@ forgotten in, the STEAM32_WB55RG board manifest in `steamicc/micropython-steami`. """ +import ast import os -import re from pathlib import Path -from urllib.error import URLError +from urllib.error import HTTPError, URLError from urllib.request import urlopen import pytest @@ -21,9 +21,31 @@ "stm32-steami-rev1d-final/ports/stm32/boards/STEAM32_WB55RG/manifest.py", ) -REQUIRE_RE = re.compile( - r'require\(\s*"([^"]+)"\s*,\s*library\s*=\s*"micropython-steami-lib"\s*\)' -) +STEAMI_LIBRARY = "micropython-steami-lib" + + +def _extract_required_drivers(source): + """Parse a board manifest and return the set of driver names required + from `micropython-steami-lib`. Uses the AST so the check is resilient to + quoting, spacing, and extra keyword arguments.""" + tree = ast.parse(source) + required = set() + for node in ast.walk(tree): + if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)): + continue + if node.func.id != "require": + continue + library = None + for kw in node.keywords: + if kw.arg == "library" and isinstance(kw.value, ast.Constant): + library = kw.value.value + if library != STEAMI_LIBRARY: + continue + if node.args and isinstance(node.args[0], ast.Constant): + name = node.args[0].value + if isinstance(name, str): + required.add(name) + return required @pytest.fixture(scope="session") @@ -33,9 +55,15 @@ def frozen_drivers(): try: with urlopen(MANIFEST_URL, timeout=10) as resp: content = resp.read().decode("utf-8") + except HTTPError as exc: + pytest.fail( + f"unexpected HTTP {exc.code} while fetching {MANIFEST_URL}: " + f"{exc.reason}. The branch or path may have moved — update " + f"MANIFEST_URL in tests/test_frozen_manifest.py." + ) except (URLError, TimeoutError, OSError) as exc: pytest.skip(f"cannot fetch upstream manifest: {exc}") - return set(REQUIRE_RE.findall(content)) + return _extract_required_drivers(content) def _discover_driver_dirs():