From 082bcacacd29d097aace3e03cd1e180694e5dde8 Mon Sep 17 00:00:00 2001 From: Quinn Cheong Date: Mon, 5 Oct 2026 19:14:46 +0000 Subject: [PATCH 01/12] fix: accept __index__ objects for unsigned integers without conversion Since #5887 an enum with an unsigned underlying type compares unequal to numpy integers, because the no-convert pass rejects __index__ objects for unsigned targets and the catch-all overload returns false. Closes #6192. --- include/pybind11/cast.h | 11 +++++++---- tests/test_enum.cpp | 6 ++++++ tests/test_enum.py | 20 ++++++++++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/include/pybind11/cast.h b/include/pybind11/cast.h index 1d857a0ed5..bf2eb18898 100644 --- a/include/pybind11/cast.h +++ b/include/pybind11/cast.h @@ -259,12 +259,16 @@ struct type_caster::value && !is_std_char_t return false; } else { handle src_or_index = src; - // PyPy: 7.3.7's 3.8 does not implement PyLong_*'s __index__ calls. -#if defined(PYPY_VERSION) object index; - // If not a PyLong, we need to call PyNumber_Index explicitly on PyPy. + // If not a PyLong, call PyNumber_Index explicitly where the PyLong_As* call below + // does not: PyLong_AsUnsignedLong[Long] never calls __index__, and PyPy 7.3.7's 3.8 + // does not implement PyLong_*'s __index__ calls at all. // When convert is false, we only reach here if PYBIND11_INDEX_CHECK passed above. +#if defined(PYPY_VERSION) if (!PYBIND11_LONG_CHECK(src.ptr())) { +#else + if (std::is_unsigned::value && !PYBIND11_LONG_CHECK(src.ptr())) { +#endif index = reinterpret_steal(PyNumber_Index(src.ptr())); if (!index) { PyErr_Clear(); @@ -274,7 +278,6 @@ struct type_caster::value && !is_std_char_t src_or_index = index; } } -#endif if (std::is_unsigned::value) { py_value = as_unsigned(src_or_index.ptr()); } else { // signed integer: diff --git a/tests/test_enum.cpp b/tests/test_enum.cpp index 2a7fa0fda0..186bc922cb 100644 --- a/tests/test_enum.cpp +++ b/tests/test_enum.cpp @@ -59,6 +59,12 @@ TEST_SUBMODULE(enums, m) { .value("ESecondMode", ClassWithUnscopedEnum::ESecondMode) .export_values(); + // test_scalar_comparison_with_index_object + enum UnscopedInt32Enum : std::int32_t { I32Three = 3 }; + py::enum_(m, "UnscopedInt32Enum").value("Three", I32Three); + enum UnscopedUInt32Enum : std::uint32_t { U32Three = 3 }; + py::enum_(m, "UnscopedUInt32Enum").value("Three", U32Three); + // test_enum_to_int m.def("test_enum_to_int", [](int) {}); m.def("test_enum_to_uint", [](uint32_t) {}); diff --git a/tests/test_enum.py b/tests/test_enum.py index 81170c91dd..451807365c 100644 --- a/tests/test_enum.py +++ b/tests/test_enum.py @@ -230,6 +230,26 @@ def test_enum_to_int(): m.test_enum_to_long_long(m.ScopedBoolEnum.TRUE) +@pytest.mark.parametrize("enum_name", ["UnscopedInt32Enum", "UnscopedUInt32Enum"]) +def test_scalar_comparison_with_index_object(enum_name): + # An unsigned underlying type must compare like a signed one against objects + # that only implement __index__ (e.g. numpy integers). + class Index: + def __index__(self): + return 3 + + three = getattr(m, enum_name).Three + assert three == 3 + assert three == Index() + assert not three != Index() + assert three != 4 + np = pytest.importorskip("numpy") + assert three == np.int32(3) + assert three == np.uint64(3) + assert not three != np.int64(3) + assert three != np.int32(4) + + def test_duplicate_enum_name(): with pytest.raises(ValueError) as excinfo: m.register_bad_enum() From 2fa7d31e7c40f630c16134b8f163221cbef74145 Mon Sep 17 00:00:00 2001 From: Quinn Cheong Date: Tue, 6 Oct 2026 02:55:20 +0000 Subject: [PATCH 02/12] style: brace the early return now compiled on CPython The no-convert early return was previously PyPy-only, so clang-tidy never saw it; readability-braces-around-statements now flags it. --- include/pybind11/cast.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/include/pybind11/cast.h b/include/pybind11/cast.h index bf2eb18898..9665ebf76d 100644 --- a/include/pybind11/cast.h +++ b/include/pybind11/cast.h @@ -272,8 +272,9 @@ struct type_caster::value && !is_std_char_t index = reinterpret_steal(PyNumber_Index(src.ptr())); if (!index) { PyErr_Clear(); - if (!convert) + if (!convert) { return false; + } } else { src_or_index = index; } From 93457e597b75e0a61ea41541f43e4938cd089ea5 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 7 Oct 2026 12:24:22 -0400 Subject: [PATCH 03/12] ci: test PyPy 8.0.0 (3.11 and 3.12) Fix two problems found with PyPy 3.12: - Deallocating a py::dynamic_attr() instance aborted, because PyPy 3.12's _PyObject_GetDictPtr() cannot be called from tp_dealloc. Read the dict slot from tp_dictoffset on PyPy instead. - memoryview::from_buffer() passed temporary shape/strides arrays, which PyPy keeps without copying. Use PyPy's inline _shape/_strides arrays. Assisted-by: ClaudeCode:claude-opus-5-5 --- .github/workflows/ci.yml | 10 ++++++++-- include/pybind11/detail/class-inl.h | 17 ++++++++++++++--- include/pybind11/detail/class.h | 3 +++ include/pybind11/pytypes-inl.h | 13 +++++++++++++ tests/test_multiple_inheritance.py | 6 +++++- 5 files changed, 43 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b300bd13c..c9a508333e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,8 +48,11 @@ jobs: python-version: '3.14t' cmake-args: -DCMAKE_CXX_STANDARD=17 -DPYBIND11_TEST_SMART_HOLDER=ON - runs-on: ubuntu-latest - python-version: 'pypy-3.11-v7.3.23' + python-version: 'pypy-3.11-v8.0.0' cmake-args: -DCMAKE_CXX_STANDARD=17 + - runs-on: ubuntu-latest + python-version: 'pypy-3.12-v8.0.0' + cmake-args: -DCMAKE_CXX_STANDARD=20 - runs-on: ubuntu-latest python-version: 'graalpy-25.0' cmake-args: -DCMAKE_CXX_STANDARD=20 @@ -138,7 +141,7 @@ jobs: python-version: '3.15' cmake-args: -DCMAKE_CXX_STANDARD=17 - runs-on: macos-latest - python-version: 'pypy-3.11-v7.3.23' + python-version: 'pypy-3.12-v8.0.0' - runs-on: macos-latest python-version: 'graalpy-25.0' - runs-on: macos-latest @@ -172,6 +175,9 @@ jobs: - runs-on: windows-latest python-version: 'pypy3.11' cmake-args: -DCMAKE_CXX_STANDARD=20 + - runs-on: windows-latest + python-version: 'pypy3.12' + cmake-args: -DCMAKE_CXX_STANDARD=17 # The setup-python action currently doesn't have graalpy for windows # See https://github.com/actions/setup-python/pull/880 diff --git a/include/pybind11/detail/class-inl.h b/include/pybind11/detail/class-inl.h index 57d12fee44..47746f77d8 100644 --- a/include/pybind11/detail/class-inl.h +++ b/include/pybind11/detail/class-inl.h @@ -407,6 +407,17 @@ PYBIND11_INLINE void clear_patients(PyObject *self) { } } +PYBIND11_INLINE PyObject **instance_dict_ptr(PyObject *self) { +#if defined(PYPY_VERSION) + // PyPy 3.12's _PyObject_GetDictPtr() aborts if called from tp_dealloc. + auto offset = Py_TYPE(self)->tp_dictoffset; + return offset > 0 ? reinterpret_cast(reinterpret_cast(self) + offset) + : nullptr; +#else + return _PyObject_GetDictPtr(self); +#endif +} + PYBIND11_INLINE void clear_instance(PyObject *self) { auto *instance = reinterpret_cast(self); @@ -436,7 +447,7 @@ PYBIND11_INLINE void clear_instance(PyObject *self) { PyObject_ClearWeakRefs(self); } - PyObject **dict_ptr = _PyObject_GetDictPtr(self); + PyObject **dict_ptr = instance_dict_ptr(self); if (dict_ptr) { Py_CLEAR(*dict_ptr); } @@ -525,7 +536,7 @@ extern "C" PYBIND11_INLINE int pybind11_traverse(PyObject *self, visitproc visit return ret; } #else - PyObject *&dict = *_PyObject_GetDictPtr(self); + PyObject *&dict = *instance_dict_ptr(self); Py_VISIT(dict); #endif // https://docs.python.org/3/c-api/typeobj.html#c.PyTypeObject.tp_traverse @@ -537,7 +548,7 @@ extern "C" PYBIND11_INLINE int pybind11_clear(PyObject *self) { #if PY_VERSION_HEX >= 0x030D0000 PyObject_ClearManagedDict(self); #else - PyObject *&dict = *_PyObject_GetDictPtr(self); + PyObject *&dict = *instance_dict_ptr(self); Py_CLEAR(dict); #endif return 0; diff --git a/include/pybind11/detail/class.h b/include/pybind11/detail/class.h index 74e5ef0593..8716223d68 100644 --- a/include/pybind11/detail/class.h +++ b/include/pybind11/detail/class.h @@ -122,6 +122,9 @@ void add_patient(PyObject *nurse, PyObject *patient); void clear_patients(PyObject *self); +/// Returns the address of the instance `__dict__` slot, or nullptr if there is none. +PyObject **instance_dict_ptr(PyObject *self); + /// Clears all internal data from the instance and removes it from registered instances in /// preparation for deallocation. void clear_instance(PyObject *self); diff --git a/include/pybind11/pytypes-inl.h b/include/pybind11/pytypes-inl.h index f2eea1070f..f603c0b673 100644 --- a/include/pybind11/pytypes-inl.h +++ b/include/pybind11/pytypes-inl.h @@ -265,8 +265,21 @@ PYBIND11_INLINE memoryview memoryview::from_buffer(void *ptr, view.itemsize = itemsize; view.format = const_cast(format); view.ndim = static_cast(ndim); +#if defined(PYPY_VERSION) + // PyPy (at least up to 8.0.0) only copies shape and strides that point to its inline arrays. + if (ndim > PyBUF_MAX_NDIM) { + pybind11_fail("memoryview: too many dimensions"); + } + for (size_t i = 0; i < ndim; ++i) { + view._shape[i] = (*shape)[i]; + view._strides[i] = (*strides)[i]; + } + view.shape = view._shape; + view.strides = view._strides; +#else view.shape = shape->data(); view.strides = strides->data(); +#endif view.suboffsets = nullptr; view.internal = nullptr; PyObject *obj = PyMemoryView_FromBuffer(&view); diff --git a/tests/test_multiple_inheritance.py b/tests/test_multiple_inheritance.py index e413d32fa0..10932b5dd8 100644 --- a/tests/test_multiple_inheritance.py +++ b/tests/test_multiple_inheritance.py @@ -266,9 +266,13 @@ def test_mi_static_properties(): def test_mi_dynamic_attributes(): """Mixing bases with and without dynamic attribute support""" - for d in (m.VanillaDictMix1(), m.VanillaDictMix2()): + for cls in (m.VanillaDictMix1, m.VanillaDictMix2): + d = cls() d.dynamic = 1 assert d.dynamic == 1 + del d + # The dict must be cleared without crashing (PyPy 3.12) + pytest.gc_collect() def test_mi_unaligned_base(): From 1a1ebe9e8d69654d089ec19d8b12156ad26c3598 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 7 Oct 2026 13:35:08 -0400 Subject: [PATCH 04/12] tests: collect PyPrintDestructor inside its test A later PyPy GC at an arbitrary point can run the Python-calling destructor and abort. Assisted-by: ClaudeCode:claude-opus-5-5 --- tests/test_class.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_class.py b/tests/test_class.py index 638053b494..81bf67e6f7 100644 --- a/tests/test_class.py +++ b/tests/test_class.py @@ -622,6 +622,8 @@ class PyNonFinalFinalChild(m.IsNonFinalFinal): def test_exception_rvalue_abort(): with pytest.raises(RuntimeError): m.PyPrintDestructor().throw_something() + # The destructor calls Python; on PyPy, a later GC at an arbitrary point can abort + pytest.gc_collect() # https://github.com/pybind/pybind11/issues/1568 From de150abd072c9467d15e678275b17b3d8087ff43 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 7 Oct 2026 15:10:27 -0400 Subject: [PATCH 05/12] test: cover the unsigned integer caster directly Add uint_passthrough(_noconvert) and run the existing int conversion matrix against it, which also pins __index__ taking precedence over __int__ for unsigned targets. Gate the explicit PyNumber_Index call on PYBIND11_INDEX_CHECK so objects without __index__ no longer raise and clear an extra TypeError in convert mode, and select the PyPy/unsigned condition with a constexpr bool instead of two #if'd if-heads. Assisted-by: ClaudeCode:claude-fable-5-1 --- include/pybind11/cast.h | 12 ++++++------ tests/test_builtin_casters.cpp | 2 ++ tests/test_builtin_casters.py | 16 ++++++++++------ 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/include/pybind11/cast.h b/include/pybind11/cast.h index 9665ebf76d..b9c9caaca7 100644 --- a/include/pybind11/cast.h +++ b/include/pybind11/cast.h @@ -260,15 +260,15 @@ struct type_caster::value && !is_std_char_t } else { handle src_or_index = src; object index; - // If not a PyLong, call PyNumber_Index explicitly where the PyLong_As* call below - // does not: PyLong_AsUnsignedLong[Long] never calls __index__, and PyPy 7.3.7's 3.8 - // does not implement PyLong_*'s __index__ calls at all. - // When convert is false, we only reach here if PYBIND11_INDEX_CHECK passed above. + // Call PyNumber_Index explicitly where the PyLong_As* call below does not: + // PyLong_AsUnsignedLong[Long] never calls __index__, and old PyPy (7.3.7's 3.8) + // did not implement PyLong_*'s __index__ calls at all (7.3.16+ does). #if defined(PYPY_VERSION) - if (!PYBIND11_LONG_CHECK(src.ptr())) { + constexpr bool call_index = true; #else - if (std::is_unsigned::value && !PYBIND11_LONG_CHECK(src.ptr())) { + constexpr bool call_index = std::is_unsigned::value; #endif + if (call_index && !PYBIND11_LONG_CHECK(src.ptr()) && PYBIND11_INDEX_CHECK(src.ptr())) { index = reinterpret_steal(PyNumber_Index(src.ptr())); if (!index) { PyErr_Clear(); diff --git a/tests/test_builtin_casters.cpp b/tests/test_builtin_casters.cpp index 40803fbf8a..b7599615e3 100644 --- a/tests/test_builtin_casters.cpp +++ b/tests/test_builtin_casters.cpp @@ -235,6 +235,8 @@ TEST_SUBMODULE(builtin_casters, m) { // test_int_convert m.def("int_passthrough", [](int arg) { return arg; }); m.def("int_passthrough_noconvert", [](int arg) { return arg; }, py::arg{}.noconvert()); + m.def("uint_passthrough", [](unsigned arg) { return arg; }); + m.def("uint_passthrough_noconvert", [](unsigned arg) { return arg; }, py::arg{}.noconvert()); // test_float_convert m.def("float_passthrough", [](float arg) { return arg; }); diff --git a/tests/test_builtin_casters.py b/tests/test_builtin_casters.py index a08ac410b6..3879e0ad4a 100644 --- a/tests/test_builtin_casters.py +++ b/tests/test_builtin_casters.py @@ -247,7 +247,8 @@ def test_integer_casting(): assert "incompatible function arguments" in str(excinfo.value) -def test_int_convert(doc): +@pytest.mark.parametrize("prefix", ["int", "uint"]) +def test_int_convert(doc, prefix): class Int: def __int__(self): return 42 @@ -284,13 +285,14 @@ def __index__(self): def __int__(self): return 42 - convert, noconvert = m.int_passthrough, m.int_passthrough_noconvert + convert = getattr(m, f"{prefix}_passthrough") + noconvert = getattr(m, f"{prefix}_passthrough_noconvert") assert ( doc(convert) - == "int_passthrough(arg0: typing.SupportsInt | typing.SupportsIndex) -> int" + == f"{prefix}_passthrough(arg0: typing.SupportsInt | typing.SupportsIndex) -> int" ) - assert doc(noconvert) == "int_passthrough_noconvert(arg0: int) -> int" + assert doc(noconvert) == f"{prefix}_passthrough_noconvert(arg0: int) -> int" def requires_conversion(v): pytest.raises(TypeError, noconvert, v) @@ -402,10 +404,12 @@ def cant_convert(v): cant_convert(Int()) -def test_numpy_int_convert(): +@pytest.mark.parametrize("prefix", ["int", "uint"]) +def test_numpy_int_convert(prefix): np = pytest.importorskip("numpy") - convert, noconvert = m.int_passthrough, m.int_passthrough_noconvert + convert = getattr(m, f"{prefix}_passthrough") + noconvert = getattr(m, f"{prefix}_passthrough_noconvert") def require_implicit(v): pytest.raises(TypeError, noconvert, v) From 2c519ef0a1e997c058bfd1bdb71ac7983b47344c Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 8 Oct 2026 00:51:39 -0400 Subject: [PATCH 06/12] test: only expect the 3.9 __int__ DeprecationWarning for signed ints PyLong_AsUnsignedLong does not call __int__, so the unsigned caster falls back to PyNumber_Long and does not warn. Assisted-by: ClaudeCode:claude-opus-5-5 --- tests/test_builtin_casters.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_builtin_casters.py b/tests/test_builtin_casters.py index 3879e0ad4a..29ab6070ee 100644 --- a/tests/test_builtin_casters.py +++ b/tests/test_builtin_casters.py @@ -304,7 +304,7 @@ def cant_convert(v): assert noconvert(7) == 7 cant_convert(3.14159) # TODO: Avoid DeprecationWarning in `PyLong_AsLong` (and similar) - if sys.version_info < (3, 10) and env.CPYTHON: + if sys.version_info < (3, 10) and env.CPYTHON and prefix == "int": with pytest.deprecated_call(): assert convert(Int()) == 42 else: @@ -421,7 +421,7 @@ def require_implicit(v): # The implicit conversion from np.float32 is undesirable but currently accepted. # TODO: Avoid DeprecationWarning in `PyLong_AsLong` (and similar) # https://github.com/pybind/pybind11/issues/3408 - if sys.version_info < (3, 10) and env.CPYTHON: + if sys.version_info < (3, 10) and env.CPYTHON and prefix == "int": with pytest.deprecated_call(): assert convert(np.float32(3.14159)) == 3 else: From 9bbda4a14468842c69a9247df9717276e7682975 Mon Sep 17 00:00:00 2001 From: Quinn Cheong Date: Thu, 8 Oct 2026 23:56:55 +0000 Subject: [PATCH 07/12] refactor: drop the PyPy-only __index__ branch in the integer caster PyPy below the supported 7.3.17 is not a target, and supported PyPy calls __index__ in PyLong_AsLong[Long] like CPython, so only unsigned targets need the explicit PyNumber_Index call. Also drops the complex.h comment that pointed at the removed branch. Assisted-by: ClaudeCode:claude-opus-5-5 --- include/pybind11/cast.h | 12 +++--------- include/pybind11/complex.h | 1 - 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/include/pybind11/cast.h b/include/pybind11/cast.h index b9c9caaca7..f6622511ca 100644 --- a/include/pybind11/cast.h +++ b/include/pybind11/cast.h @@ -260,15 +260,9 @@ struct type_caster::value && !is_std_char_t } else { handle src_or_index = src; object index; - // Call PyNumber_Index explicitly where the PyLong_As* call below does not: - // PyLong_AsUnsignedLong[Long] never calls __index__, and old PyPy (7.3.7's 3.8) - // did not implement PyLong_*'s __index__ calls at all (7.3.16+ does). -#if defined(PYPY_VERSION) - constexpr bool call_index = true; -#else - constexpr bool call_index = std::is_unsigned::value; -#endif - if (call_index && !PYBIND11_LONG_CHECK(src.ptr()) && PYBIND11_INDEX_CHECK(src.ptr())) { + // PyLong_AsUnsignedLong[Long] never calls __index__, so call PyNumber_Index here. + if (std::is_unsigned::value && !PYBIND11_LONG_CHECK(src.ptr()) + && PYBIND11_INDEX_CHECK(src.ptr())) { index = reinterpret_steal(PyNumber_Index(src.ptr())); if (!index) { PyErr_Clear(); diff --git a/include/pybind11/complex.h b/include/pybind11/complex.h index 9d0812f09f..ec02ba3ae4 100644 --- a/include/pybind11/complex.h +++ b/include/pybind11/complex.h @@ -58,7 +58,6 @@ class type_caster> { } handle src_or_index = src; // PyPy: 7.3.7's 3.8 does not implement PyLong_*'s __index__ calls. - // The same logic is used in numeric_caster for ints and floats #if defined(PYPY_VERSION) object index; if (PYBIND11_INDEX_CHECK(src.ptr())) { From f584dca185822c525f5dce3b798a502579c3f08c Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Sat, 17 Oct 2026 13:58:10 -0700 Subject: [PATCH 08/12] fix: limit PyPy dict deallocation workaround to 8.0.x PyPy 8.0.1 fixes the deallocation bug, but 8.0.x releases share an extension ABI. Retain the workaround throughout that minor series so extensions built with newer headers can still run on 8.0.0. Register PYPY_VERSION_NUM with the inline-header configuration checker. --- include/pybind11/detail/class-inl.h | 7 +++++-- tools/check_inl_headers.py | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/include/pybind11/detail/class-inl.h b/include/pybind11/detail/class-inl.h index 47746f77d8..5cbaee50c3 100644 --- a/include/pybind11/detail/class-inl.h +++ b/include/pybind11/detail/class-inl.h @@ -408,8 +408,11 @@ PYBIND11_INLINE void clear_patients(PyObject *self) { } PYBIND11_INLINE PyObject **instance_dict_ptr(PyObject *self) { -#if defined(PYPY_VERSION) - // PyPy 3.12's _PyObject_GetDictPtr() aborts if called from tp_dealloc. +#if defined(PYPY_VERSION_NUM) && (PYPY_VERSION_NUM & 0xFFFF0000) == 0x08000000 + // PyPy 8.0.0's _PyObject_GetDictPtr() can abort when called from tp_dealloc. + // The bug was fixed in 8.0.1, but all 8.0.x releases share an extension ABI, + // so an extension built against a fixed release can still run on 8.0.0. + // Keep this workaround for the entire 8.0.x series. auto offset = Py_TYPE(self)->tp_dictoffset; return offset > 0 ? reinterpret_cast(reinterpret_cast(self) + offset) : nullptr; diff --git a/tools/check_inl_headers.py b/tools/check_inl_headers.py index 6787fb740b..1de93b4768 100755 --- a/tools/check_inl_headers.py +++ b/tools/check_inl_headers.py @@ -23,6 +23,7 @@ "PYBIND11_HAS_STRING_VIEW", "PYBIND11_PRECOMPILED", "PYPY_VERSION", + "PYPY_VERSION_NUM", "PY_MAJOR_VERSION", "PY_MINOR_VERSION", "PY_VERSION_HEX", From 9f74660f41859bf04f1c4a6251071e2d6a75a303 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Sat, 17 Oct 2026 14:02:27 -0700 Subject: [PATCH 09/12] ci: test PyPy 8.0.1 while retaining 8.0.0 regression coverage Update the Linux Python 3.11 and macOS Python 3.12 jobs to PyPy 8.0.1. Keep Linux Python 3.12 on 8.0.0 to exercise the deallocation and memoryview workarounds without adding jobs. --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9a508333e..1248b96629 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,8 +48,9 @@ jobs: python-version: '3.14t' cmake-args: -DCMAKE_CXX_STANDARD=17 -DPYBIND11_TEST_SMART_HOLDER=ON - runs-on: ubuntu-latest - python-version: 'pypy-3.11-v8.0.0' + python-version: 'pypy-3.11-v8.0.1' cmake-args: -DCMAKE_CXX_STANDARD=17 + # Keep 8.0.0 to test the deallocation and memoryview workarounds (PR #6197). - runs-on: ubuntu-latest python-version: 'pypy-3.12-v8.0.0' cmake-args: -DCMAKE_CXX_STANDARD=20 @@ -141,7 +142,7 @@ jobs: python-version: '3.15' cmake-args: -DCMAKE_CXX_STANDARD=17 - runs-on: macos-latest - python-version: 'pypy-3.12-v8.0.0' + python-version: 'pypy-3.12-v8.0.1' - runs-on: macos-latest python-version: 'graalpy-25.0' - runs-on: macos-latest From b5fc618235d3af30123a486433411029a6927bc3 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Sun, 18 Oct 2026 12:34:54 -0700 Subject: [PATCH 10/12] fix: preserve complex conversion precedence on PyPy Keep complex instances and __complex__ methods ahead of the legacy __index__ fallback. Retain support for older PyPy versions and update the workaround comment. Add regression coverage for mixed protocols, complex subclasses, and failing __complex__ methods. Assisted-by: Codex --- include/pybind11/complex.h | 6 ++++-- tests/test_builtin_casters.py | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/include/pybind11/complex.h b/include/pybind11/complex.h index ec02ba3ae4..f35732cb7a 100644 --- a/include/pybind11/complex.h +++ b/include/pybind11/complex.h @@ -57,10 +57,12 @@ class type_caster> { return false; } handle src_or_index = src; - // PyPy: 7.3.7's 3.8 does not implement PyLong_*'s __index__ calls. + // Older PyPy versions need explicit __index__ conversion before PyComplex_AsCComplex(). + // Preserve complex values and __complex__ precedence. #if defined(PYPY_VERSION) object index; - if (PYBIND11_INDEX_CHECK(src.ptr())) { + if (!PyComplex_Check(src.ptr()) && !hasattr(type::handle_of(src), "__complex__") + && PYBIND11_INDEX_CHECK(src.ptr())) { index = reinterpret_steal(PyNumber_Index(src.ptr())); if (!index) { PyErr_Clear(); diff --git a/tests/test_builtin_casters.py b/tests/test_builtin_casters.py index 29ab6070ee..474a4a5eb2 100644 --- a/tests/test_builtin_casters.py +++ b/tests/test_builtin_casters.py @@ -638,6 +638,45 @@ def requires_conversion(v): assert result.imag == 0.0 +@pytest.mark.parametrize("base", [object, int, float]) +def test_complex_method_precedence(base): + class ComplexAndIndex(base): + def __complex__(self): + return 3.0 + 4.0j + + def __index__(self): + return 42 + + value = ComplexAndIndex() + assert m.complex_convert(value) == 3.0 + 4.0j + if base is object: + with pytest.raises(TypeError): + m.complex_noconvert(value) + else: + assert m.complex_noconvert(value) == 3.0 + 4.0j + + +@pytest.mark.parametrize("function", [m.complex_convert, m.complex_noconvert]) +def test_complex_subclass_with_index(function): + class ComplexWithIndex(complex): + def __index__(self): + return 42 + + assert function(ComplexWithIndex(3.0, 4.0)) == 3.0 + 4.0j + + +def test_complex_method_error_with_index(): + class ComplexAndIndex: + def __complex__(self): + raise ValueError("complex conversion failed") + + def __index__(self): + return 42 + + with pytest.raises(TypeError): + m.complex_convert(ComplexAndIndex()) + + def test_overload_resolution_float_int(): """ Test overload resolution behavior when int can match float (added with PR #5879). From 78c91af8396e9db3de71c29e7f37961635cb8d81 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Sun, 18 Oct 2026 15:26:16 -0700 Subject: [PATCH 11/12] Make PyPy PyComplex_AsCComplex workaround specific to PyPy < 8.0 --- include/pybind11/complex.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/pybind11/complex.h b/include/pybind11/complex.h index f35732cb7a..2d37a12277 100644 --- a/include/pybind11/complex.h +++ b/include/pybind11/complex.h @@ -59,7 +59,7 @@ class type_caster> { handle src_or_index = src; // Older PyPy versions need explicit __index__ conversion before PyComplex_AsCComplex(). // Preserve complex values and __complex__ precedence. -#if defined(PYPY_VERSION) +#if defined(PYPY_VERSION_NUM) && PYPY_VERSION_NUM < 0x08000000 object index; if (!PyComplex_Check(src.ptr()) && !hasattr(type::handle_of(src), "__complex__") && PYBIND11_INDEX_CHECK(src.ptr())) { From 3d087e14196e3d8226c36aeed65316cc98d149a7 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Sun, 18 Oct 2026 16:00:43 -0700 Subject: [PATCH 12/12] ci: retain a PyPy 7 Linux job Keep a pinned PyPy 7.3.23 / Python 3.11 job alongside the PyPy 8 matrix while PyPy 7 remains supported. Assisted-by: Codex --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b7e373318..607f612a68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,10 @@ jobs: - runs-on: ubuntu-latest python-version: '3.14t' cmake-args: -DCMAKE_CXX_STANDARD=17 -DPYBIND11_TEST_SMART_HOLDER=ON + # Keep one PyPy 7 job while that runtime remains supported. + - runs-on: ubuntu-latest + python-version: 'pypy-3.11-v7.3.23' + cmake-args: -DCMAKE_CXX_STANDARD=17 - runs-on: ubuntu-latest python-version: 'pypy-3.11-v8.0.1' cmake-args: -DCMAKE_CXX_STANDARD=17