Skip to content
Draft
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
4 changes: 4 additions & 0 deletions .github/workflows/reusable-standard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ pybind11Targets.cmake
/docs/_build/*
.ipynb_checkpoints/
tests/main.cpp
tests/*/temp*
CMakeUserPresents.json

/Python
Expand Down
10 changes: 10 additions & 0 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down
4 changes: 4 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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."
)
Expand Down
33 changes: 33 additions & 0 deletions tests/extra_abi/check_installed.py
Original file line number Diff line number Diff line change
@@ -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")
11 changes: 11 additions & 0 deletions tests/extra_abi/dog.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#include <pybind11/pybind11.h>

#include "dog.hpp"

#include <string>

namespace py = pybind11;

PYBIND11_MODULE(dog, m) {
py::class_<Dog, Pet>(m, "Dog").def(py::init<const std::string &>()).def("bark", &Dog::bark);
}
10 changes: 10 additions & 0 deletions tests/extra_abi/dog.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#include <pybind11/pybind11.h>

#include "pet.hpp"

#include <string>

struct PYBIND11_EXPORT Dog : Pet {
explicit Dog(const std::string &name) : Pet(name) {}
std::string bark() const { return "woof!"; }
};
12 changes: 12 additions & 0 deletions tests/extra_abi/pet.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#include <pybind11/pybind11.h>

#include "pet.hpp"

#include <string>

namespace py = pybind11;

PYBIND11_MODULE(pet, m) {
py::class_<Pet> pet(m, "Pet");
pet.def(py::init<const std::string &>()).def_readwrite("name", &Pet::name);
}
6 changes: 6 additions & 0 deletions tests/extra_abi/pet.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#include <string>

struct Pet {
explicit Pet(const std::string &name) : name(name) {}
std::string name;
};
14 changes: 14 additions & 0 deletions tests/extra_abi/setup.py
Original file line number Diff line number Diff line change
@@ -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])
64 changes: 64 additions & 0 deletions tests/extra_abi/test_cross_version_abi.py
Original file line number Diff line number Diff line change
@@ -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)
Loading