From ab537f036fdbef9e95c1ff5517a84b07d79bc560 Mon Sep 17 00:00:00 2001 From: VDSim dev Date: Fri, 18 Sep 2026 22:22:29 +0900 Subject: [PATCH] ci(wheels): import every shipped module from the installed wheel The wheel test command imported vdsim and built Sim twice; it never touched vdsim_render, so a wheel that shipped vdsim_render.py without the vdsim_preset.py it imports passed the whole 6-wheel matrix plus the verify job (measured 2026-09-18, pre-flight C-2). tools/wheel_import_smoke.py reads the install list out of python/CMakeLists.txt - the file that decides what ships - and imports every listed module, asserting each resolves to the installed distribution rather than to the checkout. Names are never restated in the workflow, so the check follows the list when it changes. matplotlib/pillow join CIBW_TEST_REQUIRES because vdsim_render imports them at module scope; they are the declared [plot] extra. --- .github/workflows/wheels.yml | 7 +- tools/wheel_import_smoke.py | 123 +++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 2 deletions(-) create mode 100755 tools/wheel_import_smoke.py diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 5434c7f9..7522745c 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -24,11 +24,14 @@ jobs: CIBW_ARCHS_WINDOWS: AMD64 CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_28 CIBW_BUILD_VERBOSITY: "1" - CIBW_TEST_REQUIRES: "numpy pyyaml" + # matplotlib/pillow are the [plot] extra; vdsim_render imports them at + # module scope, so the import closure check below needs them present. + CIBW_TEST_REQUIRES: "numpy pyyaml matplotlib pillow" CIBW_TEST_COMMAND: >- python -c "import vdsim; print(vdsim.__file__)" && python -c "from vdsim_lab import Sim; Sim(vehicle='sedan', level='L2'); print('sedan ok')" && - python -c "from vdsim_lab import Sim; Sim(vehicle='ioniq5_awd', tire='ioniq5_pac2002', level='L2'); print('ioniq5 ok')" + python -c "from vdsim_lab import Sim; Sim(vehicle='ioniq5_awd', tire='ioniq5_pac2002', level='L2'); print('ioniq5 ok')" && + python "{project}/tools/wheel_import_smoke.py" - uses: actions/upload-artifact@v4 with: name: wheels-${{ matrix.os }} diff --git a/tools/wheel_import_smoke.py b/tools/wheel_import_smoke.py new file mode 100755 index 00000000..744f9bba --- /dev/null +++ b/tools/wheel_import_smoke.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Import every first-party module the wheel claims to ship, from the wheel. + +Run by ``CIBW_TEST_COMMAND`` inside cibuildwheel's test environment, where the +wheel is installed and the source tree is present but not on ``sys.path``. + +Why this exists +--------------- +Measured 2026-09-18 (pre-flight C-2): the wheel shipped ``vdsim_render.py`` but +not ``vdsim_preset.py``, which it imports, so ``import vdsim_render`` raised +``ModuleNotFoundError`` for every pip user. The whole wheel matrix was green: +the old test command imported ``vdsim`` and built ``Sim`` twice, and +``tools/verify_wheel_packaging.py`` compares zip members against a hand-written +list that never named either module. + +The module list is read from ``python/CMakeLists.txt`` — the file that decides +what ships — so the workflow never restates names that can drift. + +Two properties are checked per module: + +1. it imports at all; +2. it resolves to the installed distribution, not to the source tree. Without + (2) this check would pass on a broken wheel whenever the working directory + happened to be the repository. +""" +from __future__ import annotations + +import importlib +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +MANIFEST = REPO / "python" / "CMakeLists.txt" + + +def shipped_modules(): + """Top-level importable names listed under ``if(DEFINED SKBUILD)``. + + ``install(FILES ...)`` entries ending in ``.py`` become module names; + ``install(DIRECTORY .../python/ ...)`` entries become package names. + Config directories (``configs/...``) are data, not importable, and are + skipped. + """ + text = MANIFEST.read_text() + if "if(DEFINED SKBUILD)" not in text: + raise SystemExit("FAIL: no SKBUILD install block in %s" % MANIFEST) + body = text.split("if(DEFINED SKBUILD)", 1)[1].split("endif()", 1)[0] + names = ["vdsim"] # install(TARGETS vdsim_py ...) -> the extension module + for rel in re.findall(r"\$\{CMAKE_SOURCE_DIR\}/([^\s)]+)", body): + parts = rel.split("/") + if rel.endswith(".py"): + names.append(parts[-1][:-3]) + elif parts[0] == "python": + names.append(parts[-1]) + out = [] + for n in names: # stable order, no duplicates + if n not in out: + out.append(n) + return out + + +def strip_source_tree_from_path(): + """Drop anything that could satisfy an import from the checkout. + + Returns the removed entries so the report can show what was excluded. + """ + removed = [] + kept = [] + for entry in sys.path: + try: + resolved = Path(entry or ".").resolve() + except OSError: + kept.append(entry) + continue + if resolved == REPO or REPO in resolved.parents: + removed.append(str(resolved)) + else: + kept.append(entry) + sys.path[:] = kept + return removed + + +def main(): + removed = strip_source_tree_from_path() + modules = shipped_modules() + print("manifest: %s" % MANIFEST) + print("removed from sys.path (source tree): %s" % (removed or "none")) + print("modules to import (%d): %s" % (len(modules), ", ".join(modules))) + + failures = [] + for name in modules: + try: + mod = importlib.import_module(name) + except Exception as exc: # noqa: BLE001 - report any + failures.append("%s: %s: %s" % (name, type(exc).__name__, exc)) + print("FAIL %-16s %s: %s" % (name, type(exc).__name__, exc)) + continue + origin = getattr(mod, "__file__", None) + if origin is None: + failures.append("%s: imported with no __file__" % name) + print("FAIL %-16s no __file__" % name) + continue + origin_path = Path(origin).resolve() + if origin_path == REPO or REPO in origin_path.parents: + failures.append("%s: resolved to the source tree (%s)" % (name, origin_path)) + print("FAIL %-16s from source tree %s" % (name, origin_path)) + continue + print("ok %-16s %s" % (name, origin_path)) + + if failures: + print("\n%d module(s) failed:" % len(failures)) + for f in failures: + print(" - %s" % f) + print("The wheel does not ship a working copy of every module listed in " + "python/CMakeLists.txt. Add the missing file to that install list.") + return 1 + print("\nall %d shipped modules import from the installed wheel" % len(modules)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())