From 082bcacacd29d097aace3e03cd1e180694e5dde8 Mon Sep 17 00:00:00 2001 From: Quinn Cheong Date: Mon, 5 Oct 2026 19:14:46 +0000 Subject: [PATCH 1/3] 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 2/3] 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 de150abd072c9467d15e678275b17b3d8087ff43 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 7 Oct 2026 15:10:27 -0400 Subject: [PATCH 3/3] 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)