diff --git a/.github/workflows/reusable-standard.yml b/.github/workflows/reusable-standard.yml index 6081f801eb..0e79aacb42 100644 --- a/.github/workflows/reusable-standard.yml +++ b/.github/workflows/reusable-standard.yml @@ -101,3 +101,7 @@ jobs: run: | uv pip install --python=python --system setuptools pytest tests/extra_setuptools + + # This tests ABI compatibility + - name: ABI test + run: pytest tests/extra_abi diff --git a/.gitignore b/.gitignore index 798e05a7e6..3fc1c67972 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ pybind11Targets.cmake /docs/_build/* .ipynb_checkpoints/ tests/main.cpp +tests/*/temp* CMakeUserPresents.json /Python diff --git a/noxfile.py b/noxfile.py index 778025229c..d0843bad66 100644 --- a/noxfile.py +++ b/noxfile.py @@ -61,6 +61,16 @@ def tests_packaging(session: nox.Session) -> None: session.run("pytest", "tests/extra_python_package", *session.posargs) +@nox.session +def tests_abi(session: nox.Session) -> None: + """ + Run the cross-version ABI check. + """ + + session.install("pytest") + session.run("pytest", "tests/extra_abi", *session.posargs) + + @nox.session(reuse_venv=True, default=False) def docs(session: nox.Session) -> None: """ diff --git a/tests/conftest.py b/tests/conftest.py index 9d9815b88c..5e77f55d13 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,6 +24,8 @@ # Early diagnostic for failed imports try: import pybind11_tests +except ModuleNotFoundError: + pybind11_tests = None except Exception: # pytest does not show the traceback without this. traceback.print_exc() @@ -260,6 +262,8 @@ def pytest_configure(): def pytest_report_header(): + if pybind11_tests is None: + return None assert pybind11_tests.compiler_info is not None, ( "Please update pybind11_tests.cpp if this assert fails." ) diff --git a/tests/extra_abi/check_installed.py b/tests/extra_abi/check_installed.py new file mode 100755 index 0000000000..1650d464dd --- /dev/null +++ b/tests/extra_abi/check_installed.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import sys + +expect_incompatible = "--expect-incompatible" in sys.argv + +# dog alone must fail: it needs pet to register the Pet base class first. +# Otherwise this check would not be exercising cross-extension ABI at all. +try: + import dog +except Exception: + pass +else: + raise SystemExit("Broken: dog imported without pet; the check is not cross-module") + +import pet # noqa: E402 + +try: + import dog +except Exception: + if expect_incompatible: + print("OK: incompatible internals are isolated, dog did not load") + sys.exit(0) + raise + +if expect_incompatible: + raise SystemExit("Broken: dog loaded against pet with mismatched internals") + +d = dog.Dog("Bluey") +assert d.bark() == "woof!" +assert d.name == "Bluey" +assert isinstance(d, pet.Pet) +print("OK: cross-version ABI is compatible") diff --git a/tests/extra_abi/dog.cpp b/tests/extra_abi/dog.cpp new file mode 100644 index 0000000000..da40d5885e --- /dev/null +++ b/tests/extra_abi/dog.cpp @@ -0,0 +1,11 @@ +#include + +#include "dog.hpp" + +#include + +namespace py = pybind11; + +PYBIND11_MODULE(dog, m) { + py::class_(m, "Dog").def(py::init()).def("bark", &Dog::bark); +} diff --git a/tests/extra_abi/dog.hpp b/tests/extra_abi/dog.hpp new file mode 100644 index 0000000000..f604716a9a --- /dev/null +++ b/tests/extra_abi/dog.hpp @@ -0,0 +1,10 @@ +#include + +#include "pet.hpp" + +#include + +struct PYBIND11_EXPORT Dog : Pet { + explicit Dog(const std::string &name) : Pet(name) {} + std::string bark() const { return "woof!"; } +}; diff --git a/tests/extra_abi/pet.cpp b/tests/extra_abi/pet.cpp new file mode 100644 index 0000000000..7830b90e24 --- /dev/null +++ b/tests/extra_abi/pet.cpp @@ -0,0 +1,12 @@ +#include + +#include "pet.hpp" + +#include + +namespace py = pybind11; + +PYBIND11_MODULE(pet, m) { + py::class_ pet(m, "Pet"); + pet.def(py::init()).def_readwrite("name", &Pet::name); +} diff --git a/tests/extra_abi/pet.hpp b/tests/extra_abi/pet.hpp new file mode 100644 index 0000000000..37fa90b186 --- /dev/null +++ b/tests/extra_abi/pet.hpp @@ -0,0 +1,6 @@ +#include + +struct Pet { + explicit Pet(const std::string &name) : name(name) {} + std::string name; +}; diff --git a/tests/extra_abi/setup.py b/tests/extra_abi/setup.py new file mode 100644 index 0000000000..caf17c5169 --- /dev/null +++ b/tests/extra_abi/setup.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import os + +from setuptools import setup + +from pybind11.setup_helpers import Pybind11Extension + +name = os.environ["EXAMPLE_NAME"] +assert name in {"pet", "dog"} + +ext = Pybind11Extension(name, [f"{name}.cpp"], include_dirs=["."], cxx_std=17) + +setup(name=name, version="0.0.0", ext_modules=[ext]) diff --git a/tests/extra_abi/test_cross_version_abi.py b/tests/extra_abi/test_cross_version_abi.py new file mode 100644 index 0000000000..08a773bf5a --- /dev/null +++ b/tests/extra_abi/test_cross_version_abi.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import os +import re +import subprocess +import sys +import venv +from pathlib import Path + +DIR = Path(__file__).parent.resolve() +MAIN_DIR = DIR.parent.parent + +# The newest release used as the cross-version ABI baseline, and the +# PYBIND11_INTERNALS_VERSION it ships with. +BASELINE_VERSION = "3.0.4" +BASELINE_INTERNALS_VERSION = 11 + +# The internals version of this checkout. If test_internals_version_pinned +# fails, the ABI was bumped: make sure that was intentional, then update this +# number (and the baseline above once a compatible release exists). +EXPECTED_INTERNALS_VERSION = 12 + + +def read_internals_version() -> int: + header = MAIN_DIR / "include/pybind11/detail/internals.h" + match = re.search( + r"^#\s*define\s+PYBIND11_INTERNALS_VERSION\s+(\d+)", + header.read_text(encoding="utf-8"), + flags=re.MULTILINE, + ) + assert match, "PYBIND11_INTERNALS_VERSION not found in internals.h" + return int(match.group(1)) + + +def test_internals_version_pinned(): + """Any bump of PYBIND11_INTERNALS_VERSION must be a conscious decision.""" + assert read_internals_version() == EXPECTED_INTERNALS_VERSION + + +def test_cross_version_abi(tmp_path: Path) -> None: + venv_dir = tmp_path / "venv" + venv.create(venv_dir, with_pip=True) + bin_dir = "Scripts" if sys.platform.startswith("win") else "bin" + python = venv_dir / bin_dir / "python" + + def run(*args: str, name: str | None = None) -> None: + env = os.environ.copy() + if name is not None: + env["EXAMPLE_NAME"] = name + subprocess.run([os.fspath(python), *args], check=True, env=env) + + # Build pet against the baseline release; no build isolation, so that the + # pinned pybind11 (not the latest release) provides the headers. + run("-m", "pip", "install", f"pybind11=={BASELINE_VERSION}", "setuptools>=70.1") + run("-m", "pip", "install", os.fspath(DIR), "--no-build-isolation", name="pet") + + # Build dog against this checkout. + run("-m", "pip", "install", os.fspath(MAIN_DIR)) + run("-m", "pip", "install", os.fspath(DIR), "--no-build-isolation", name="dog") + + check_args = [] + if BASELINE_INTERNALS_VERSION != EXPECTED_INTERNALS_VERSION: + check_args.append("--expect-incompatible") + run(os.fspath(DIR / "check_installed.py"), *check_args)