From b1b4825ccbcf86bba9c5456615ffd53d11cb3093 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Mon, 24 Oct 2022 15:07:13 -0400 Subject: [PATCH 1/4] tests: add ABI check Signed-off-by: Henry Schreiner --- .gitignore | 1 + noxfile.py | 13 +++++++++++++ tests/extra_abi/dog.cpp | 11 +++++++++++ tests/extra_abi/dog.hpp | 10 ++++++++++ tests/extra_abi/pet.cpp | 12 ++++++++++++ tests/extra_abi/pet.hpp | 6 ++++++ tests/extra_abi/setup.py | 20 ++++++++++++++++++++ tests/extra_abi/test_installed.py | 13 +++++++++++++ 8 files changed, 86 insertions(+) create mode 100644 tests/extra_abi/dog.cpp create mode 100644 tests/extra_abi/dog.hpp create mode 100644 tests/extra_abi/pet.cpp create mode 100644 tests/extra_abi/pet.hpp create mode 100644 tests/extra_abi/setup.py create mode 100755 tests/extra_abi/test_installed.py 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..ca1e6f923f 100644 --- a/noxfile.py +++ b/noxfile.py @@ -61,6 +61,19 @@ 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 abi checker. + """ + + session.install("pybind11==2.8.0") + session.install("tests/extra_abi", env={"EXAMPLE_NAME": "pet"}) + session.install(".") + session.install("tests/extra_abi", env={"EXAMPLE_NAME": "dog"}) + session.run("python", "tests/extra_abi/test_installed.py") + + @nox.session(reuse_venv=True, default=False) def docs(session: nox.Session) -> None: """ 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..809be19633 --- /dev/null +++ b/tests/extra_abi/setup.py @@ -0,0 +1,20 @@ +import os +import sys + +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=11, + extra_compile_args=["/d2FH4-"] if sys.platform.startswith("win32") else [], +) + +setup(name=name, version="0.0.0", ext_modules=[ext]) diff --git a/tests/extra_abi/test_installed.py b/tests/extra_abi/test_installed.py new file mode 100755 index 0000000000..ca8fe7fa80 --- /dev/null +++ b/tests/extra_abi/test_installed.py @@ -0,0 +1,13 @@ +try: + import dog + + raise RuntimeError("Broken! Dog must require Pet to be loaded") +except ImportError: + import pet # noqa: F401 + +import dog # noqa: F811 + +d = dog.Dog("Bluey") + +d.bark() +assert d.name == "Bluey" From ff752a964158dffc8921d5f2b68c027448ca6f3b Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Mon, 24 Oct 2022 16:54:01 -0400 Subject: [PATCH 2/4] tests: use pytest-virtualenv to test abi Signed-off-by: Henry Schreiner --- noxfile.py | 14 +++++++++----- tests/conftest.py | 4 ++++ .../{test_installed.py => check_installed.py} | 0 tests/extra_abi/test_venv_abi.py | 16 ++++++++++++++++ tests/requirements.txt | 1 + 5 files changed, 30 insertions(+), 5 deletions(-) rename tests/extra_abi/{test_installed.py => check_installed.py} (100%) create mode 100755 tests/extra_abi/test_venv_abi.py diff --git a/noxfile.py b/noxfile.py index ca1e6f923f..6e0d84e89e 100644 --- a/noxfile.py +++ b/noxfile.py @@ -67,11 +67,15 @@ def tests_abi(session: nox.Session) -> None: Run the abi checker. """ - session.install("pybind11==2.8.0") - session.install("tests/extra_abi", env={"EXAMPLE_NAME": "pet"}) - session.install(".") - session.install("tests/extra_abi", env={"EXAMPLE_NAME": "dog"}) - session.run("python", "tests/extra_abi/test_installed.py") + session.install("pybind11==2.8.0", "--no-build-isolation") + session.install( + "tests/extra_abi", "--no-build-isolation", env={"EXAMPLE_NAME": "pet"} + ) + session.install(".", "--no-build-isolation") + session.install( + "tests/extra_abi", "--no-build-isolation", env={"EXAMPLE_NAME": "dog"} + ) + session.run("python", "tests/extra_abi/check_installed.py") @nox.session(reuse_venv=True, default=False) 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/test_installed.py b/tests/extra_abi/check_installed.py similarity index 100% rename from tests/extra_abi/test_installed.py rename to tests/extra_abi/check_installed.py diff --git a/tests/extra_abi/test_venv_abi.py b/tests/extra_abi/test_venv_abi.py new file mode 100755 index 0000000000..95d368865d --- /dev/null +++ b/tests/extra_abi/test_venv_abi.py @@ -0,0 +1,16 @@ +from pathlib import Path + +DIR = Path(__file__).parent.resolve() + + +def test_build_import(virtualenv): + virtualenv.run('pip install "pybind11==2.8.0" --no-build-isolation') + virtualenv.env["EXAMPLE_NAME"] = "pet" + virtualenv.run(f"pip install {DIR} --no-build-isolation") + + virtualenv.run(f"pip install {DIR.parent.parent} --no-build-isolation") + virtualenv.env["EXAMPLE_NAME"] = "dog" + virtualenv.run(f"pip install {DIR} --no-build-isolation") + + script = DIR / "check_installed.py" + virtualenv.run(f"python {script}") diff --git a/tests/requirements.txt b/tests/requirements.txt index 1ba4026b46..d350ba2104 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -14,6 +14,7 @@ numpy>=2.3.0; platform_python_implementation=="CPython" and python_version>="3.1 numpy~=2.2.0; platform_python_implementation=="CPython" and python_version=="3.13" and platform_machine!="ARM64" pytest>=6 pytest-timeout +pytest-virtualenv scipy~=1.5.4; platform_python_implementation=="CPython" and python_version<"3.10" scipy~=1.8.0; platform_python_implementation=="CPython" and python_version=="3.10" and sys_platform!="win32" scipy~=1.11.1; platform_python_implementation=="CPython" and python_version>="3.11" and python_version<"3.13" and sys_platform!="win32" From ef7f288316749ed8e1f7750629490af5ae7a9d7b Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Mon, 24 Oct 2022 17:21:18 -0400 Subject: [PATCH 3/4] ci: enable abi test on some platforms Signed-off-by: Henry Schreiner --- .github/workflows/reusable-standard.yml | 4 ++++ 1 file changed, 4 insertions(+) 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 From 3468bf85e08d6c5f402c2251edf145b9f10b96d5 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Mon, 27 Jul 2026 11:18:32 -0400 Subject: [PATCH 4/4] tests: modernize cross-version ABI check Replace pytest-virtualenv with a plain venv, pin the expected PYBIND11_INTERNALS_VERSION so a bump cannot pass CI unnoticed, and update the baseline release to 3.0.4. Since master already moved to internals v12 and no release has it, the interop check currently asserts clean isolation instead of compatibility. Assisted-by: ClaudeCode:claude-fable-5 --- noxfile.py | 13 ++--- tests/extra_abi/check_installed.py | 32 +++++++++--- tests/extra_abi/setup.py | 12 ++--- tests/extra_abi/test_cross_version_abi.py | 64 +++++++++++++++++++++++ tests/extra_abi/test_venv_abi.py | 16 ------ tests/requirements.txt | 1 - 6 files changed, 96 insertions(+), 42 deletions(-) create mode 100644 tests/extra_abi/test_cross_version_abi.py delete mode 100755 tests/extra_abi/test_venv_abi.py diff --git a/noxfile.py b/noxfile.py index 6e0d84e89e..d0843bad66 100644 --- a/noxfile.py +++ b/noxfile.py @@ -64,18 +64,11 @@ def tests_packaging(session: nox.Session) -> None: @nox.session def tests_abi(session: nox.Session) -> None: """ - Run the abi checker. + Run the cross-version ABI check. """ - session.install("pybind11==2.8.0", "--no-build-isolation") - session.install( - "tests/extra_abi", "--no-build-isolation", env={"EXAMPLE_NAME": "pet"} - ) - session.install(".", "--no-build-isolation") - session.install( - "tests/extra_abi", "--no-build-isolation", env={"EXAMPLE_NAME": "dog"} - ) - session.run("python", "tests/extra_abi/check_installed.py") + session.install("pytest") + session.run("pytest", "tests/extra_abi", *session.posargs) @nox.session(reuse_venv=True, default=False) diff --git a/tests/extra_abi/check_installed.py b/tests/extra_abi/check_installed.py index ca8fe7fa80..1650d464dd 100755 --- a/tests/extra_abi/check_installed.py +++ b/tests/extra_abi/check_installed.py @@ -1,13 +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") - raise RuntimeError("Broken! Dog must require Pet to be loaded") -except ImportError: - import pet # noqa: F401 +import pet # noqa: E402 -import dog # noqa: F811 +try: + import dog +except Exception: + if expect_incompatible: + print("OK: incompatible internals are isolated, dog did not load") + sys.exit(0) + raise -d = dog.Dog("Bluey") +if expect_incompatible: + raise SystemExit("Broken: dog loaded against pet with mismatched internals") -d.bark() +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/setup.py b/tests/extra_abi/setup.py index 809be19633..caf17c5169 100644 --- a/tests/extra_abi/setup.py +++ b/tests/extra_abi/setup.py @@ -1,5 +1,6 @@ +from __future__ import annotations + import os -import sys from setuptools import setup @@ -8,13 +9,6 @@ name = os.environ["EXAMPLE_NAME"] assert name in {"pet", "dog"} - -ext = Pybind11Extension( - name, - [f"{name}.cpp"], - include_dirs=["."], - cxx_std=11, - extra_compile_args=["/d2FH4-"] if sys.platform.startswith("win32") else [], -) +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) diff --git a/tests/extra_abi/test_venv_abi.py b/tests/extra_abi/test_venv_abi.py deleted file mode 100755 index 95d368865d..0000000000 --- a/tests/extra_abi/test_venv_abi.py +++ /dev/null @@ -1,16 +0,0 @@ -from pathlib import Path - -DIR = Path(__file__).parent.resolve() - - -def test_build_import(virtualenv): - virtualenv.run('pip install "pybind11==2.8.0" --no-build-isolation') - virtualenv.env["EXAMPLE_NAME"] = "pet" - virtualenv.run(f"pip install {DIR} --no-build-isolation") - - virtualenv.run(f"pip install {DIR.parent.parent} --no-build-isolation") - virtualenv.env["EXAMPLE_NAME"] = "dog" - virtualenv.run(f"pip install {DIR} --no-build-isolation") - - script = DIR / "check_installed.py" - virtualenv.run(f"python {script}") diff --git a/tests/requirements.txt b/tests/requirements.txt index d350ba2104..1ba4026b46 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -14,7 +14,6 @@ numpy>=2.3.0; platform_python_implementation=="CPython" and python_version>="3.1 numpy~=2.2.0; platform_python_implementation=="CPython" and python_version=="3.13" and platform_machine!="ARM64" pytest>=6 pytest-timeout -pytest-virtualenv scipy~=1.5.4; platform_python_implementation=="CPython" and python_version<"3.10" scipy~=1.8.0; platform_python_implementation=="CPython" and python_version=="3.10" and sys_platform!="win32" scipy~=1.11.1; platform_python_implementation=="CPython" and python_version>="3.11" and python_version<"3.13" and sys_platform!="win32"