diff --git a/docs/advanced/functions.rst b/docs/advanced/functions.rst index ff00c9c8ac..c9092cdabc 100644 --- a/docs/advanced/functions.rst +++ b/docs/advanced/functions.rst @@ -437,10 +437,10 @@ Certain argument types may support conversion from one type to another. Some examples of conversions are: * :ref:`implicit_conversions` declared using ``py::implicitly_convertible()`` -* Calling a method accepting a double with an integer argument -* Calling a ``std::complex`` argument with a non-complex python type - (for example, with a float). (Requires the optional ``pybind11/complex.h`` - header). +* Passing an argument that implements ``__float__`` or ``__index__`` to ``float`` or ``double``. +* Passing an argument that implements ``__int__`` or ``__index__`` to ``int``. +* Passing an argument that implements ``__complex__``, ``__float__``, or ``__index__`` to ``std::complex``. + (Requires the optional ``pybind11/complex.h`` header). * Calling a function taking an Eigen matrix reference with a numpy array of the wrong type or of an incompatible data layout. (Requires the optional ``pybind11/eigen.h`` header). @@ -452,24 +452,37 @@ object, such as: .. code-block:: cpp - m.def("floats_only", [](double f) { return 0.5 * f; }, py::arg("f").noconvert()); - m.def("floats_preferred", [](double f) { return 0.5 * f; }, py::arg("f")); + m.def("supports_float", [](double f) { return 0.5 * f; }, py::arg("f")); + m.def("only_float", [](double f) { return 0.5 * f; }, py::arg("f").noconvert()); -Attempting the call the second function (the one without ``.noconvert()``) with -an integer will succeed, but attempting to call the ``.noconvert()`` version -will fail with a ``TypeError``: +``supports_float`` will accept any argument that implements ``__float__`` or ``__index__``. +``only_float`` will only accept a float or int argument. Anything else will fail with a ``TypeError``: + +.. note:: + + The noconvert behaviour of float, double and complex has changed to match PEP 484. + A float/double argument marked noconvert will accept float or int. + A std::complex argument will accept complex, float or int. .. code-block:: pycon - >>> floats_preferred(4) + class MyFloat: + def __init__(self, value: float) -> None: + self._value = float(value) + def __repr__(self) -> str: + return f"MyFloat({self._value})" + def __float__(self) -> float: + return self._value + + >>> supports_float(MyFloat(4)) 2.0 - >>> floats_only(4) + >>> only_float(MyFloat(4)) Traceback (most recent call last): File "", line 1, in - TypeError: floats_only(): incompatible function arguments. The following argument types are supported: + TypeError: only_float(): incompatible function arguments. The following argument types are supported: 1. (f: float) -> float - Invoked with: 4 + Invoked with: MyFloat(4) You may, of course, combine this with the :var:`_a` shorthand notation (see :ref:`keyword_args`) and/or :ref:`default_args`. It is also permitted to omit diff --git a/include/pybind11/cast.h b/include/pybind11/cast.h index 2f52479041..bd41a7f90f 100644 --- a/include/pybind11/cast.h +++ b/include/pybind11/cast.h @@ -244,29 +244,28 @@ struct type_caster::value && !is_std_char_t return false; } -#if !defined(PYPY_VERSION) - auto index_check = [](PyObject *o) { return PyIndex_Check(o); }; -#else - // In PyPy 7.3.3, `PyIndex_Check` is implemented by calling `__index__`, - // while CPython only considers the existence of `nb_index`/`__index__`. - auto index_check = [](PyObject *o) { return hasattr(o, "__index__"); }; -#endif - if (std::is_floating_point::value) { - if (convert || PyFloat_Check(src.ptr())) { + if (convert || PyFloat_Check(src.ptr()) || PYBIND11_LONG_CHECK(src.ptr())) { py_value = (py_type) PyFloat_AsDouble(src.ptr()); } else { return false; } } else if (PyFloat_Check(src.ptr()) - || (!convert && !PYBIND11_LONG_CHECK(src.ptr()) && !index_check(src.ptr()))) { + || !(convert || PYBIND11_LONG_CHECK(src.ptr()) + || PYBIND11_INDEX_CHECK(src.ptr()))) { + // Explicitly reject float → int conversion even in convert mode. + // This prevents silent truncation (e.g., 1.9 → 1). + // Only int → float conversion is allowed (widening, no precision loss). + // Also reject if none of the conversion conditions are met. 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 (!PYBIND11_LONG_CHECK(src.ptr())) { // So: index_check(src.ptr()) + // If not a PyLong, we need to call PyNumber_Index explicitly on PyPy. + // When convert is false, we only reach here if PYBIND11_INDEX_CHECK passed above. + if (!PYBIND11_LONG_CHECK(src.ptr())) { index = reinterpret_steal(PyNumber_Index(src.ptr())); if (!index) { PyErr_Clear(); @@ -286,8 +285,10 @@ struct type_caster::value && !is_std_char_t } } - // Python API reported an error - bool py_err = py_value == (py_type) -1 && PyErr_Occurred(); + bool py_err = (PyErr_Occurred() != nullptr); + if (py_err) { + assert(py_value == static_cast(-1)); + } // Check to see if the conversion is valid (integers should match exactly) // Signed/unsigned checks happen elsewhere diff --git a/include/pybind11/complex.h b/include/pybind11/complex.h index 0b6f49365d..8f285d778f 100644 --- a/include/pybind11/complex.h +++ b/include/pybind11/complex.h @@ -51,7 +51,9 @@ class type_caster> { if (!src) { return false; } - if (!convert && !PyComplex_Check(src.ptr())) { + if (!convert + && !(PyComplex_Check(src.ptr()) || PyFloat_Check(src.ptr()) + || PYBIND11_LONG_CHECK(src.ptr()))) { return false; } handle src_or_index = src; diff --git a/tests/test_builtin_casters.cpp b/tests/test_builtin_casters.cpp index 1aa9f89b42..92afecd980 100644 --- a/tests/test_builtin_casters.cpp +++ b/tests/test_builtin_casters.cpp @@ -363,9 +363,34 @@ TEST_SUBMODULE(builtin_casters, m) { m.def("complex_cast", [](float x) { return "{}"_s.format(x); }); m.def("complex_cast", [](std::complex x) { return "({}, {})"_s.format(x.real(), x.imag()); }); + m.def( + "complex_cast_strict", + [](std::complex x) { return "({}, {})"_s.format(x.real(), x.imag()); }, + py::arg{}.noconvert()); + m.def("complex_convert", [](std::complex x) { return x; }); m.def("complex_noconvert", [](std::complex x) { return x; }, py::arg{}.noconvert()); + // test_overload_resolution_float_int + // Test that float overload registered before int overload gets selected when passing int + // This documents the breaking change: int can now match float in strict mode + m.def("overload_resolution_test", [](float x) { return "float: " + std::to_string(x); }); + m.def("overload_resolution_test", [](int x) { return "int: " + std::to_string(x); }); + + // Test with noconvert (strict mode) - this is the key breaking change + m.def( + "overload_resolution_strict", + [](float x) { return "float_strict: " + std::to_string(x); }, + py::arg{}.noconvert()); + m.def("overload_resolution_strict", [](int x) { return "int_strict: " + std::to_string(x); }); + + // Test complex overload resolution: complex registered before float/int + m.def("overload_resolution_complex", [](std::complex x) { + return "complex: (" + std::to_string(x.real()) + ", " + std::to_string(x.imag()) + ")"; + }); + m.def("overload_resolution_complex", [](float x) { return "float: " + std::to_string(x); }); + m.def("overload_resolution_complex", [](int x) { return "int: " + std::to_string(x); }); + // test int vs. long (Python 2) m.def("int_cast", []() { return (int) 42; }); m.def("long_cast", []() { return (long) 42; }); diff --git a/tests/test_builtin_casters.py b/tests/test_builtin_casters.py index 23c191cec2..b232c087e0 100644 --- a/tests/test_builtin_casters.py +++ b/tests/test_builtin_casters.py @@ -315,6 +315,7 @@ def cant_convert(v): # Before Python 3.8, `PyLong_AsLong` does not pick up on `obj.__index__`, # but pybind11 "backports" this behavior. assert convert(Index()) == 42 + assert isinstance(convert(Index()), int) assert noconvert(Index()) == 42 assert convert(IntAndIndex()) == 0 # Fishy; `int(DoubleThought)` == 42 assert noconvert(IntAndIndex()) == 0 @@ -323,6 +324,50 @@ def cant_convert(v): assert convert(RaisingValueErrorOnIndex()) == 42 requires_conversion(RaisingValueErrorOnIndex()) + class IndexReturnsFloat: + def __index__(self): + return 3.14 # noqa: PLE0305 Wrong: should return int + + class IntReturnsFloat: + def __int__(self): + return 3.14 # Wrong: should return int + + class IndexFloatIntInt: + def __index__(self): + return 3.14 # noqa: PLE0305 Wrong: should return int + + def __int__(self): + return 42 # Correct: returns int + + class IndexIntIntFloat: + def __index__(self): + return 42 # Correct: returns int + + def __int__(self): + return 3.14 # Wrong: should return int + + class IndexFloatIntFloat: + def __index__(self): + return 3.14 # noqa: PLE0305 Wrong: should return int + + def __int__(self): + return 2.71 # Wrong: should return int + + cant_convert(IndexReturnsFloat()) + requires_conversion(IndexReturnsFloat()) + + cant_convert(IntReturnsFloat()) + requires_conversion(IntReturnsFloat()) + + assert convert(IndexFloatIntInt()) == 42 # convert: __index__ fails, uses __int__ + requires_conversion(IndexFloatIntInt()) # noconvert: __index__ fails, no fallback + + assert convert(IndexIntIntFloat()) == 42 # convert: __index__ succeeds + assert noconvert(IndexIntIntFloat()) == 42 # noconvert: __index__ succeeds + + cant_convert(IndexFloatIntFloat()) # convert mode rejects (both fail) + requires_conversion(IndexFloatIntFloat()) # noconvert mode also rejects + def test_float_convert(doc): class Int: @@ -356,7 +401,7 @@ def cant_convert(v): assert pytest.approx(convert(Index())) == -7.0 assert isinstance(convert(Float()), float) assert pytest.approx(convert(3)) == 3.0 - requires_conversion(3) + assert pytest.approx(noconvert(3)) == 3.0 cant_convert(Int()) @@ -505,6 +550,11 @@ def __index__(self) -> int: assert m.complex_cast(Complex()) == "(5.0, 4.0)" assert m.complex_cast(2j) == "(0.0, 2.0)" + assert m.complex_cast_strict(1) == "(1.0, 0.0)" + assert m.complex_cast_strict(3.0) == "(3.0, 0.0)" + assert m.complex_cast_strict(complex(5, 4)) == "(5.0, 4.0)" + assert m.complex_cast_strict(2j) == "(0.0, 2.0)" + convert, noconvert = m.complex_convert, m.complex_noconvert def requires_conversion(v): @@ -529,14 +579,127 @@ def cant_convert(v): assert convert(Index()) == 1 assert isinstance(convert(Index()), complex) - requires_conversion(1) - requires_conversion(2.0) + assert noconvert(1) == 1.0 + assert noconvert(2.0) == 2.0 assert noconvert(1 + 5j) == 1.0 + 5.0j requires_conversion(Complex()) requires_conversion(Float()) requires_conversion(Index()) +def test_complex_index_handling(): + """ + Test __index__ handling in complex caster (added with PR #5879). + + This test verifies that custom __index__ objects (not PyLong) work correctly + with complex conversion. The behavior should be consistent across CPython, + PyPy, and GraalPy. + + - Custom __index__ objects work with convert (non-strict mode) + - Custom __index__ objects do NOT work with noconvert (strict mode) + - Regular int (PyLong) works with both convert and noconvert + """ + + class CustomIndex: + """Custom class with __index__ but not __int__ or __float__""" + + def __index__(self) -> int: + return 42 + + class CustomIndexNegative: + """Custom class with negative __index__""" + + def __index__(self) -> int: + return -17 + + convert, noconvert = m.complex_convert, m.complex_noconvert + + # Test that regular int (PyLong) works + assert convert(5) == 5.0 + 0j + assert noconvert(5) == 5.0 + 0j + + # Test that custom __index__ objects work with convert (non-strict mode) + # This exercises the PyPy-specific path in complex.h + assert convert(CustomIndex()) == 42.0 + 0j + assert convert(CustomIndexNegative()) == -17.0 + 0j + + # With noconvert (strict mode), custom __index__ objects are NOT accepted + # Strict mode only accepts complex, float, or int (PyLong), not custom __index__ objects + def requires_conversion(v): + pytest.raises(TypeError, noconvert, v) + + requires_conversion(CustomIndex()) + requires_conversion(CustomIndexNegative()) + + # Verify the result is actually a complex + result = convert(CustomIndex()) + assert isinstance(result, complex) + assert result.real == 42.0 + assert result.imag == 0.0 + + +def test_overload_resolution_float_int(): + """ + Test overload resolution behavior when int can match float (added with PR #5879). + + This test documents the breaking change in PR #5879: when a float overload is + registered before an int overload, passing a Python int will now match the float + overload (because int can be converted to float in strict mode per PEP 484). + + Before PR #5879: int(42) would match int overload (if both existed) + After PR #5879: int(42) matches float overload (if registered first) + + This is a breaking change because existing code that relied on int matching + int overloads may now match float overloads instead. + """ + # Test 1: float overload registered first, int second + # When passing int(42), pybind11 tries overloads in order: + # 1. float overload - can int(42) be converted? Yes (with PR #5879 changes) + # 2. Match! Use float overload (int overload never checked) + result = m.overload_resolution_test(42) + assert result == "float: 42.000000", ( + f"Expected int(42) to match float overload, got: {result}. " + "This documents the breaking change: int now matches float overloads." + ) + assert m.overload_resolution_test(42.0) == "float: 42.000000" + + # Test 2: With noconvert (strict mode) - this is the KEY breaking change + # Before PR #5879: int(42) would NOT match float overload with noconvert, would match int overload + # After PR #5879: int(42) DOES match float overload with noconvert (because int->float is now allowed) + result_strict = m.overload_resolution_strict(42) + assert result_strict == "float_strict: 42.000000", ( + f"Expected int(42) to match float overload with noconvert, got: {result_strict}. " + "This is the key breaking change: int now matches float even in strict mode." + ) + assert m.overload_resolution_strict(42.0) == "float_strict: 42.000000" + + # Test 3: complex overload registered first, then float, then int + # When passing int(5), pybind11 tries overloads in order: + # 1. complex overload - can int(5) be converted? Yes (with PR #5879 changes) + # 2. Match! Use complex overload + assert m.overload_resolution_complex(5) == "complex: (5.000000, 0.000000)" + assert m.overload_resolution_complex(5.0) == "complex: (5.000000, 0.000000)" + assert ( + m.overload_resolution_complex(complex(3, 4)) == "complex: (3.000000, 4.000000)" + ) + + # Verify that the overloads are registered in the expected order + # The docstring should show float overload before int overload + doc = m.overload_resolution_test.__doc__ + assert doc is not None + # Check that float overload appears before int overload in docstring + # The docstring uses "typing.SupportsFloat" and "typing.SupportsInt" + float_pos = doc.find("SupportsFloat") + int_pos = doc.find("SupportsInt") + assert float_pos != -1, f"Could not find 'SupportsFloat' in docstring: {doc}" + assert int_pos != -1, f"Could not find 'SupportsInt' in docstring: {doc}" + assert float_pos < int_pos, ( + f"Float overload should appear before int overload in docstring. " + f"Found 'SupportsFloat' at {float_pos}, 'SupportsInt' at {int_pos}. " + f"Docstring: {doc}" + ) + + def test_bool_caster(): """Test bool caster implicit conversions.""" convert, noconvert = m.bool_passthrough, m.bool_passthrough_noconvert diff --git a/tests/test_custom_type_casters.py b/tests/test_custom_type_casters.py index 6ed1c564f0..0680e50504 100644 --- a/tests/test_custom_type_casters.py +++ b/tests/test_custom_type_casters.py @@ -55,17 +55,7 @@ def test_noconvert_args(msg): assert m.floats_preferred(4) == 2.0 assert m.floats_only(4.0) == 2.0 - with pytest.raises(TypeError) as excinfo: - m.floats_only(4) - assert ( - msg(excinfo.value) - == """ - floats_only(): incompatible function arguments. The following argument types are supported: - 1. (f: float) -> float - - Invoked with: 4 - """ - ) + assert m.floats_only(4) == 2.0 assert m.ints_preferred(4) == 2 assert m.ints_preferred(True) == 0 diff --git a/tests/test_factory_constructors.cpp b/tests/test_factory_constructors.cpp index e50494b33a..295fef2daa 100644 --- a/tests/test_factory_constructors.cpp +++ b/tests/test_factory_constructors.cpp @@ -405,11 +405,10 @@ TEST_SUBMODULE(factory_constructors, m) { pyNoisyAlloc.def(py::init([](double d, int) { return NoisyAlloc(d); })); // Old-style placement new init; requires preallocation ignoreOldStyleInitWarnings([&pyNoisyAlloc]() { - pyNoisyAlloc.def("__init__", - [](NoisyAlloc &a, double d, double) { new (&a) NoisyAlloc(d); }); + pyNoisyAlloc.def("__init__", [](NoisyAlloc &a, int i, double) { new (&a) NoisyAlloc(i); }); }); // Requires deallocation of previous overload preallocated value: - pyNoisyAlloc.def(py::init([](int i, double) { return new NoisyAlloc(i); })); + pyNoisyAlloc.def(py::init([](double d, double) { return new NoisyAlloc(d); })); // Regular again: requires yet another preallocation ignoreOldStyleInitWarnings([&pyNoisyAlloc]() { pyNoisyAlloc.def( diff --git a/tests/test_factory_constructors.py b/tests/test_factory_constructors.py index c6ae98c7fb..cdf16ec858 100644 --- a/tests/test_factory_constructors.py +++ b/tests/test_factory_constructors.py @@ -433,9 +433,10 @@ def test_reallocation_e(capture, msg): create_and_destroy(3.5, 4.5) assert msg(capture) == strip_comments( """ - noisy new # preallocation needed before invoking placement-new overload - noisy placement new # Placement new - NoisyAlloc(double 3.5) # construction + noisy new # preallocation needed before invoking factory pointer overload + noisy delete # deallocation of preallocated storage + noisy new # Factory pointer allocation + NoisyAlloc(double 3.5) # factory pointer construction --- ~NoisyAlloc() # Destructor noisy delete # operator delete @@ -450,9 +451,8 @@ def test_reallocation_f(capture, msg): assert msg(capture) == strip_comments( """ noisy new # preallocation needed before invoking placement-new overload - noisy delete # deallocation of preallocated storage - noisy new # Factory pointer allocation - NoisyAlloc(int 4) # factory pointer construction + noisy placement new # Placement new + NoisyAlloc(int 4) # construction --- ~NoisyAlloc() # Destructor noisy delete # operator delete diff --git a/tests/test_methods_and_attributes.cpp b/tests/test_methods_and_attributes.cpp index e324c8bdd4..f5fb02d121 100644 --- a/tests/test_methods_and_attributes.cpp +++ b/tests/test_methods_and_attributes.cpp @@ -240,33 +240,33 @@ TEST_SUBMODULE(methods_and_attributes, m) { #if defined(PYBIND11_OVERLOAD_CAST) .def("overloaded", py::overload_cast<>(&ExampleMandA::overloaded)) .def("overloaded", py::overload_cast(&ExampleMandA::overloaded)) + .def("overloaded", py::overload_cast(&ExampleMandA::overloaded)) .def("overloaded", py::overload_cast(&ExampleMandA::overloaded)) .def("overloaded", py::overload_cast(&ExampleMandA::overloaded)) - .def("overloaded", py::overload_cast(&ExampleMandA::overloaded)) .def("overloaded", py::overload_cast(&ExampleMandA::overloaded)) .def("overloaded_float", py::overload_cast(&ExampleMandA::overloaded)) .def("overloaded_const", py::overload_cast(&ExampleMandA::overloaded, py::const_)) + .def("overloaded_const", + py::overload_cast(&ExampleMandA::overloaded, py::const_)) .def("overloaded_const", py::overload_cast(&ExampleMandA::overloaded, py::const_)) .def("overloaded_const", py::overload_cast(&ExampleMandA::overloaded, py::const_)) - .def("overloaded_const", - py::overload_cast(&ExampleMandA::overloaded, py::const_)) .def("overloaded_const", py::overload_cast(&ExampleMandA::overloaded, py::const_)) #else // Use both the traditional static_cast method and the C++11 compatible overload_cast_ .def("overloaded", overload_cast_<>()(&ExampleMandA::overloaded)) .def("overloaded", overload_cast_()(&ExampleMandA::overloaded)) - .def("overloaded", overload_cast_()(&ExampleMandA::overloaded)) - .def("overloaded", static_cast(&ExampleMandA::overloaded)) - .def("overloaded", static_cast(&ExampleMandA::overloaded)) + .def("overloaded", overload_cast_()(&ExampleMandA::overloaded)) + .def("overloaded", static_cast(&ExampleMandA::overloaded)) + .def("overloaded", static_cast(&ExampleMandA::overloaded)) .def("overloaded", static_cast(&ExampleMandA::overloaded)) .def("overloaded_float", overload_cast_()(&ExampleMandA::overloaded)) .def("overloaded_const", overload_cast_()(&ExampleMandA::overloaded, py::const_)) - .def("overloaded_const", overload_cast_()(&ExampleMandA::overloaded, py::const_)) - .def("overloaded_const", static_cast(&ExampleMandA::overloaded)) - .def("overloaded_const", static_cast(&ExampleMandA::overloaded)) + .def("overloaded_const", overload_cast_()(&ExampleMandA::overloaded, py::const_)) + .def("overloaded_const", static_cast(&ExampleMandA::overloaded)) + .def("overloaded_const", static_cast(&ExampleMandA::overloaded)) .def("overloaded_const", static_cast(&ExampleMandA::overloaded)) #endif // test_no_mixed_overloads diff --git a/tests/test_stl.cpp b/tests/test_stl.cpp index 6084d517df..8bddbb1f38 100644 --- a/tests/test_stl.cpp +++ b/tests/test_stl.cpp @@ -528,7 +528,7 @@ TEST_SUBMODULE(stl, m) { m.def("load_variant", [](const variant &v) { return py::detail::visit_helper::call(visitor(), v); }); - m.def("load_variant_2pass", [](variant v) { + m.def("load_variant_2pass", [](variant v) { return py::detail::visit_helper::call(visitor(), v); }); m.def("cast_variant", []() {