From 70a7c6aa20814e7da918b6ed77bd3c5adc1439c7 Mon Sep 17 00:00:00 2001 From: David Rebbe Date: Tue, 15 Sep 2026 23:38:00 -0400 Subject: [PATCH] fix(build): avoid locking existing extension --- .github/workflows/tests.yml | 11 ++++- generate_icsneo40_structs.py | 45 ++++++++++++------- pyproject.toml | 4 +- tests/test_generator_validation.py | 69 ++++++++++++++++++++++++++++++ uv.lock | 29 ++++++++++++- 5 files changed, 140 insertions(+), 18 deletions(-) create mode 100644 tests/test_generator_validation.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4a585c0f..635e1198 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -49,6 +49,15 @@ jobs: run: | uv sync --group test + - name: Rebuild extension in place twice (Windows) + if: runner.os == 'Windows' + run: | + uv pip install setuptools wheel + uv run --no-sync python setup.py build_ext --inplace --force + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + uv run --no-sync python setup.py build_ext --inplace --force + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Run tests run: | - uv run pytest tests/ --verbose \ No newline at end of file + uv run pytest tests/ --verbose diff --git a/generate_icsneo40_structs.py b/generate_icsneo40_structs.py index 4a466dea..288b36d2 100644 --- a/generate_icsneo40_structs.py +++ b/generate_icsneo40_structs.py @@ -737,24 +737,39 @@ def generate(filename="include/ics/icsnVC40.h"): f.write(f' "ics.structures.{fname}",\n') f.write("]\n\n") - # Verify We can at least import all of the modules - quick check to make sure parser worked. - ics_module_path = GEN_ICS_DIR.parent.resolve() - # Add the module to the eval sys.path. - eval("""sys.path.insert(0, f"{ics_module_path}")""") - for file_name in file_names: - if file_name.startswith("__"): - continue - import_line = "from ics.structures import {}".format( - re.sub(r'(\.py)', '', file_name)) - try: - print(f"Importing / Verifying {output_dir / file_name}...{' '*20}", end="\r") - exec(import_line) - except Exception as ex: - print(f"""\nERROR: {ex} IMPORT LINE: '{import_line}'""") - raise ex + validate_generated_modules(file_names) print("\nDone.") +def validate_generated_modules(file_names): + """Check imports without keeping an existing native extension loaded in setup.""" + import json + + # Importing ics also loads ics.ics when it exists. Windows cannot replace a + # loaded .pyd, so wait for a separate interpreter to exit before building. + # Pass paths and names as data, including paths containing spaces/backslashes. + validation_code = """ +import importlib +import json +import sys + +package_path, file_names = json.load(sys.stdin) +sys.path.insert(0, package_path) +for file_name in file_names: + if file_name.startswith("__"): + continue + module_name = "ics.structures." + file_name.removesuffix(".py") + print(f"Importing / Verifying {module_name}...", flush=True) + importlib.import_module(module_name) +""" + run( + [sys.executable, "-c", validation_code], + input=json.dumps([str(GEN_ICS_DIR.parent.resolve()), file_names]), + text=True, + check=True, + ) + + def _write_c_object(f, c_object): # Write the header if c_object.data_type == DataType.Struct: diff --git a/pyproject.toml b/pyproject.toml index faea49e5..29dd3e4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,10 +31,12 @@ Issues = "https://github.com/intrepidcs/python_ics/issues" [dependency-groups] test = [ "pytest>=8.4.2", + "dunamai", + "setuptools", ] [tool.setuptools.dynamic] -version = { attr = "ics.__version__" } +version = { attr = "ics.__version.__version__" } [build-system] requires = [ diff --git a/tests/test_generator_validation.py b/tests/test_generator_validation.py new file mode 100644 index 00000000..efea858d --- /dev/null +++ b/tests/test_generator_validation.py @@ -0,0 +1,69 @@ +"""Build-time import validation must not load ics into the build process.""" +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +import generate_icsneo40_structs as generator + + +@pytest.fixture +def generated_package(tmp_path, monkeypatch): + package = tmp_path / "generated package" / "ics" + structures = package / "structures" + structures.mkdir(parents=True) + (package / "__init__.py").write_text("", encoding="utf-8") + (structures / "__init__.py").write_text("", encoding="utf-8") + monkeypatch.setattr(generator, "GEN_ICS_DIR", package) + return structures + + +def test_validation_imports_in_child_and_leaves_parent_unchanged(generated_package): + marker = generated_package / "import-pid.txt" + (generated_package / "sample.py").write_text( + "import os\nfrom pathlib import Path\n" + f"Path({str(marker)!r}).write_text(str(os.getpid()))\n", + encoding="utf-8", + ) + original_path = sys.path[:] + original_modules = {k: v for k, v in sys.modules.items() if k == "ics" or k.startswith("ics.")} + + generator.validate_generated_modules(["sample.py"]) + + assert int(marker.read_text()) != os.getpid() + assert sys.path == original_path + assert {k: v for k, v in sys.modules.items() if k == "ics" or k.startswith("ics.")} == original_modules + + +@pytest.mark.parametrize("source", ["raise RuntimeError('invalid structure')\n", "invalid syntax !\n", None]) +def test_validation_propagates_import_errors(generated_package, source): + if source is not None: + (generated_package / "broken.py").write_text(source, encoding="utf-8") + + with pytest.raises(subprocess.CalledProcessError): + generator.validate_generated_modules(["broken.py"]) + + +def test_validation_reads_regenerated_modules(generated_package): + module = generated_package / "sample.py" + module.write_text("value = 1\n", encoding="utf-8") + generator.validate_generated_modules(["sample.py"]) + module.write_text("raise RuntimeError('regenerated invalid structure')\n", encoding="utf-8") + + with pytest.raises(subprocess.CalledProcessError): + generator.validate_generated_modules(["sample.py"]) + + +def test_build_version_is_read_without_importing_package(generated_package): + from setuptools.config.expand import read_attr + from setuptools.config.pyprojecttoml import load_file + + package = generated_package.parent + (package / "__init__.py").write_text("raise RuntimeError('package must not be imported')\n") + (package / "__version.py").write_text('__version__ = "1.2.3"\n') + config = load_file(Path(__file__).resolve().parents[1] / "pyproject.toml") + version_attr = config["tool"]["setuptools"]["dynamic"]["version"]["attr"] + + assert read_attr(version_attr, package_dir={"ics": str(package)}) == "1.2.3" diff --git a/uv.lock b/uv.lock index ae0894e8..0ec21654 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "dunamai" +version = "1.26.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/18/020d3b27a10450ddb11429f637404e8ea67ecf4d9fd999d4f1d553f25506/dunamai-1.26.2.tar.gz", hash = "sha256:84ea45eddf9bb4b40df7610b1b22a03137365e6257dbf9d7b72128fdccca564c", size = 46536, upload-time = "2026-08-02T03:32:50.276Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/31/2aaabe7d03f395c7b6d955f09a4d1440a2c49920abea42fde88cacc2ef97/dunamai-1.26.2-py3-none-any.whl", hash = "sha256:4234be3a90c3ec13ecf75d94a2248068e5a3018e8112892fcd9f3a1f287c9c32", size = 27478, upload-time = "2026-08-02T03:32:49.348Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -83,13 +95,28 @@ source = { editable = "." } [package.dev-dependencies] test = [ + { name = "dunamai" }, { name = "pytest" }, + { name = "setuptools" }, ] [package.metadata] [package.metadata.requires-dev] -test = [{ name = "pytest", specifier = ">=8.4.2" }] +test = [ + { name = "dunamai" }, + { name = "pytest", specifier = ">=8.4.2" }, + { name = "setuptools" }, +] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] [[package]] name = "tomli"