Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
uv run pytest tests/ --verbose
45 changes: 30 additions & 15 deletions generate_icsneo40_structs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
69 changes: 69 additions & 0 deletions tests/test_generator_validation.py
Original file line number Diff line number Diff line change
@@ -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"
29 changes: 28 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading