Skip to content

Commit 181d5a7

Browse files
committed
squash-merge PR pybind#5879 InvincibleRMC→expand-float-strict
1 parent 2448bc5 commit 181d5a7

10 files changed

Lines changed: 253 additions & 60 deletions

docs/advanced/functions.rst

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -437,10 +437,10 @@ Certain argument types may support conversion from one type to another. Some
437437
examples of conversions are:
438438

439439
* :ref:`implicit_conversions` declared using ``py::implicitly_convertible<A,B>()``
440-
* Calling a method accepting a double with an integer argument
441-
* Calling a ``std::complex<float>`` argument with a non-complex python type
442-
(for example, with a float). (Requires the optional ``pybind11/complex.h``
443-
header).
440+
* Passing an argument that implements ``__float__`` or ``__index__`` to ``float`` or ``double``.
441+
* Passing an argument that implements ``__int__`` or ``__index__`` to ``int``.
442+
* Passing an argument that implements ``__complex__``, ``__float__``, or ``__index__`` to ``std::complex<float>``.
443+
(Requires the optional ``pybind11/complex.h`` header).
444444
* Calling a function taking an Eigen matrix reference with a numpy array of the
445445
wrong type or of an incompatible data layout. (Requires the optional
446446
``pybind11/eigen.h`` header).
@@ -452,24 +452,37 @@ object, such as:
452452

453453
.. code-block:: cpp
454454
455-
m.def("floats_only", [](double f) { return 0.5 * f; }, py::arg("f").noconvert());
456-
m.def("floats_preferred", [](double f) { return 0.5 * f; }, py::arg("f"));
455+
m.def("supports_float", [](double f) { return 0.5 * f; }, py::arg("f"));
456+
m.def("only_float", [](double f) { return 0.5 * f; }, py::arg("f").noconvert());
457457
458-
Attempting the call the second function (the one without ``.noconvert()``) with
459-
an integer will succeed, but attempting to call the ``.noconvert()`` version
460-
will fail with a ``TypeError``:
458+
``supports_float`` will accept any argument that implements ``__float__`` or ``__index__``.
459+
``only_float`` will only accept a float or int argument. Anything else will fail with a ``TypeError``:
460+
461+
.. note::
462+
463+
The noconvert behaviour of float, double and complex has changed to match PEP 484.
464+
A float/double argument marked noconvert will accept float or int.
465+
A std::complex<float> argument will accept complex, float or int.
461466

462467
.. code-block:: pycon
463468
464-
>>> floats_preferred(4)
469+
class MyFloat:
470+
def __init__(self, value: float) -> None:
471+
self._value = float(value)
472+
def __repr__(self) -> str:
473+
return f"MyFloat({self._value})"
474+
def __float__(self) -> float:
475+
return self._value
476+
477+
>>> supports_float(MyFloat(4))
465478
2.0
466-
>>> floats_only(4)
479+
>>> only_float(MyFloat(4))
467480
Traceback (most recent call last):
468481
File "<stdin>", line 1, in <module>
469-
TypeError: floats_only(): incompatible function arguments. The following argument types are supported:
482+
TypeError: only_float(): incompatible function arguments. The following argument types are supported:
470483
1. (f: float) -> float
471484
472-
Invoked with: 4
485+
Invoked with: MyFloat(4)
473486
474487
You may, of course, combine this with the :var:`_a` shorthand notation (see
475488
:ref:`keyword_args`) and/or :ref:`default_args`. It is also permitted to omit

include/pybind11/cast.h

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -244,29 +244,28 @@ struct type_caster<T, enable_if_t<std::is_arithmetic<T>::value && !is_std_char_t
244244
return false;
245245
}
246246

247-
#if !defined(PYPY_VERSION)
248-
auto index_check = [](PyObject *o) { return PyIndex_Check(o); };
249-
#else
250-
// In PyPy 7.3.3, `PyIndex_Check` is implemented by calling `__index__`,
251-
// while CPython only considers the existence of `nb_index`/`__index__`.
252-
auto index_check = [](PyObject *o) { return hasattr(o, "__index__"); };
253-
#endif
254-
255247
if (std::is_floating_point<T>::value) {
256-
if (convert || PyFloat_Check(src.ptr())) {
248+
if (convert || PyFloat_Check(src.ptr()) || PYBIND11_LONG_CHECK(src.ptr())) {
257249
py_value = (py_type) PyFloat_AsDouble(src.ptr());
258250
} else {
259251
return false;
260252
}
261253
} else if (PyFloat_Check(src.ptr())
262-
|| (!convert && !PYBIND11_LONG_CHECK(src.ptr()) && !index_check(src.ptr()))) {
254+
|| !(convert || PYBIND11_LONG_CHECK(src.ptr())
255+
|| PYBIND11_INDEX_CHECK(src.ptr()))) {
256+
// Explicitly reject float → int conversion even in convert mode.
257+
// This prevents silent truncation (e.g., 1.9 → 1).
258+
// Only int → float conversion is allowed (widening, no precision loss).
259+
// Also reject if none of the conversion conditions are met.
263260
return false;
264261
} else {
265262
handle src_or_index = src;
266263
// PyPy: 7.3.7's 3.8 does not implement PyLong_*'s __index__ calls.
267264
#if defined(PYPY_VERSION)
268265
object index;
269-
if (!PYBIND11_LONG_CHECK(src.ptr())) { // So: index_check(src.ptr())
266+
// If not a PyLong, we need to call PyNumber_Index explicitly on PyPy.
267+
// When convert is false, we only reach here if PYBIND11_INDEX_CHECK passed above.
268+
if (!PYBIND11_LONG_CHECK(src.ptr())) {
270269
index = reinterpret_steal<object>(PyNumber_Index(src.ptr()));
271270
if (!index) {
272271
PyErr_Clear();
@@ -286,8 +285,10 @@ struct type_caster<T, enable_if_t<std::is_arithmetic<T>::value && !is_std_char_t
286285
}
287286
}
288287

289-
// Python API reported an error
290-
bool py_err = py_value == (py_type) -1 && PyErr_Occurred();
288+
bool py_err = (PyErr_Occurred() != nullptr);
289+
if (py_err) {
290+
assert(py_value == static_cast<py_type>(-1));
291+
}
291292

292293
// Check to see if the conversion is valid (integers should match exactly)
293294
// Signed/unsigned checks happen elsewhere

include/pybind11/complex.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ class type_caster<std::complex<T>> {
5151
if (!src) {
5252
return false;
5353
}
54-
if (!convert && !PyComplex_Check(src.ptr())) {
54+
if (!convert
55+
&& !(PyComplex_Check(src.ptr()) || PyFloat_Check(src.ptr())
56+
|| PYBIND11_LONG_CHECK(src.ptr()))) {
5557
return false;
5658
}
5759
handle src_or_index = src;

tests/test_builtin_casters.cpp

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,9 +363,34 @@ TEST_SUBMODULE(builtin_casters, m) {
363363
m.def("complex_cast", [](float x) { return "{}"_s.format(x); });
364364
m.def("complex_cast",
365365
[](std::complex<float> x) { return "({}, {})"_s.format(x.real(), x.imag()); });
366+
m.def(
367+
"complex_cast_strict",
368+
[](std::complex<float> x) { return "({}, {})"_s.format(x.real(), x.imag()); },
369+
py::arg{}.noconvert());
370+
366371
m.def("complex_convert", [](std::complex<float> x) { return x; });
367372
m.def("complex_noconvert", [](std::complex<float> x) { return x; }, py::arg{}.noconvert());
368373

374+
// test_overload_resolution_float_int
375+
// Test that float overload registered before int overload gets selected when passing int
376+
// This documents the breaking change: int can now match float in strict mode
377+
m.def("overload_resolution_test", [](float x) { return "float: " + std::to_string(x); });
378+
m.def("overload_resolution_test", [](int x) { return "int: " + std::to_string(x); });
379+
380+
// Test with noconvert (strict mode) - this is the key breaking change
381+
m.def(
382+
"overload_resolution_strict",
383+
[](float x) { return "float_strict: " + std::to_string(x); },
384+
py::arg{}.noconvert());
385+
m.def("overload_resolution_strict", [](int x) { return "int_strict: " + std::to_string(x); });
386+
387+
// Test complex overload resolution: complex registered before float/int
388+
m.def("overload_resolution_complex", [](std::complex<float> x) {
389+
return "complex: (" + std::to_string(x.real()) + ", " + std::to_string(x.imag()) + ")";
390+
});
391+
m.def("overload_resolution_complex", [](float x) { return "float: " + std::to_string(x); });
392+
m.def("overload_resolution_complex", [](int x) { return "int: " + std::to_string(x); });
393+
369394
// test int vs. long (Python 2)
370395
m.def("int_cast", []() { return 42; });
371396
m.def("long_cast", []() { return (long) 42; });

tests/test_builtin_casters.py

Lines changed: 166 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ def cant_convert(v):
315315
# Before Python 3.8, `PyLong_AsLong` does not pick up on `obj.__index__`,
316316
# but pybind11 "backports" this behavior.
317317
assert convert(Index()) == 42
318+
assert isinstance(convert(Index()), int)
318319
assert noconvert(Index()) == 42
319320
assert convert(IntAndIndex()) == 0 # Fishy; `int(DoubleThought)` == 42
320321
assert noconvert(IntAndIndex()) == 0
@@ -323,6 +324,50 @@ def cant_convert(v):
323324
assert convert(RaisingValueErrorOnIndex()) == 42
324325
requires_conversion(RaisingValueErrorOnIndex())
325326

327+
class IndexReturnsFloat:
328+
def __index__(self):
329+
return 3.14 # noqa: PLE0305 Wrong: should return int
330+
331+
class IntReturnsFloat:
332+
def __int__(self):
333+
return 3.14 # Wrong: should return int
334+
335+
class IndexFloatIntInt:
336+
def __index__(self):
337+
return 3.14 # noqa: PLE0305 Wrong: should return int
338+
339+
def __int__(self):
340+
return 42 # Correct: returns int
341+
342+
class IndexIntIntFloat:
343+
def __index__(self):
344+
return 42 # Correct: returns int
345+
346+
def __int__(self):
347+
return 3.14 # Wrong: should return int
348+
349+
class IndexFloatIntFloat:
350+
def __index__(self):
351+
return 3.14 # noqa: PLE0305 Wrong: should return int
352+
353+
def __int__(self):
354+
return 2.71 # Wrong: should return int
355+
356+
cant_convert(IndexReturnsFloat())
357+
requires_conversion(IndexReturnsFloat())
358+
359+
cant_convert(IntReturnsFloat())
360+
requires_conversion(IntReturnsFloat())
361+
362+
assert convert(IndexFloatIntInt()) == 42 # convert: __index__ fails, uses __int__
363+
requires_conversion(IndexFloatIntInt()) # noconvert: __index__ fails, no fallback
364+
365+
assert convert(IndexIntIntFloat()) == 42 # convert: __index__ succeeds
366+
assert noconvert(IndexIntIntFloat()) == 42 # noconvert: __index__ succeeds
367+
368+
cant_convert(IndexFloatIntFloat()) # convert mode rejects (both fail)
369+
requires_conversion(IndexFloatIntFloat()) # noconvert mode also rejects
370+
326371

327372
def test_float_convert(doc):
328373
class Int:
@@ -356,7 +401,7 @@ def cant_convert(v):
356401
assert pytest.approx(convert(Index())) == -7.0
357402
assert isinstance(convert(Float()), float)
358403
assert pytest.approx(convert(3)) == 3.0
359-
requires_conversion(3)
404+
assert pytest.approx(noconvert(3)) == 3.0
360405
cant_convert(Int())
361406

362407

@@ -505,6 +550,11 @@ def __index__(self) -> int:
505550
assert m.complex_cast(Complex()) == "(5.0, 4.0)"
506551
assert m.complex_cast(2j) == "(0.0, 2.0)"
507552

553+
assert m.complex_cast_strict(1) == "(1.0, 0.0)"
554+
assert m.complex_cast_strict(3.0) == "(3.0, 0.0)"
555+
assert m.complex_cast_strict(complex(5, 4)) == "(5.0, 4.0)"
556+
assert m.complex_cast_strict(2j) == "(0.0, 2.0)"
557+
508558
convert, noconvert = m.complex_convert, m.complex_noconvert
509559

510560
def requires_conversion(v):
@@ -529,14 +579,127 @@ def cant_convert(v):
529579
assert convert(Index()) == 1
530580
assert isinstance(convert(Index()), complex)
531581

532-
requires_conversion(1)
533-
requires_conversion(2.0)
582+
assert noconvert(1) == 1.0
583+
assert noconvert(2.0) == 2.0
534584
assert noconvert(1 + 5j) == 1.0 + 5.0j
535585
requires_conversion(Complex())
536586
requires_conversion(Float())
537587
requires_conversion(Index())
538588

539589

590+
def test_complex_index_handling():
591+
"""
592+
Test __index__ handling in complex caster (added with PR #5879).
593+
594+
This test verifies that custom __index__ objects (not PyLong) work correctly
595+
with complex conversion. The behavior should be consistent across CPython,
596+
PyPy, and GraalPy.
597+
598+
- Custom __index__ objects work with convert (non-strict mode)
599+
- Custom __index__ objects do NOT work with noconvert (strict mode)
600+
- Regular int (PyLong) works with both convert and noconvert
601+
"""
602+
603+
class CustomIndex:
604+
"""Custom class with __index__ but not __int__ or __float__"""
605+
606+
def __index__(self) -> int:
607+
return 42
608+
609+
class CustomIndexNegative:
610+
"""Custom class with negative __index__"""
611+
612+
def __index__(self) -> int:
613+
return -17
614+
615+
convert, noconvert = m.complex_convert, m.complex_noconvert
616+
617+
# Test that regular int (PyLong) works
618+
assert convert(5) == 5.0 + 0j
619+
assert noconvert(5) == 5.0 + 0j
620+
621+
# Test that custom __index__ objects work with convert (non-strict mode)
622+
# This exercises the PyPy-specific path in complex.h
623+
assert convert(CustomIndex()) == 42.0 + 0j
624+
assert convert(CustomIndexNegative()) == -17.0 + 0j
625+
626+
# With noconvert (strict mode), custom __index__ objects are NOT accepted
627+
# Strict mode only accepts complex, float, or int (PyLong), not custom __index__ objects
628+
def requires_conversion(v):
629+
pytest.raises(TypeError, noconvert, v)
630+
631+
requires_conversion(CustomIndex())
632+
requires_conversion(CustomIndexNegative())
633+
634+
# Verify the result is actually a complex
635+
result = convert(CustomIndex())
636+
assert isinstance(result, complex)
637+
assert result.real == 42.0
638+
assert result.imag == 0.0
639+
640+
641+
def test_overload_resolution_float_int():
642+
"""
643+
Test overload resolution behavior when int can match float (added with PR #5879).
644+
645+
This test documents the breaking change in PR #5879: when a float overload is
646+
registered before an int overload, passing a Python int will now match the float
647+
overload (because int can be converted to float in strict mode per PEP 484).
648+
649+
Before PR #5879: int(42) would match int overload (if both existed)
650+
After PR #5879: int(42) matches float overload (if registered first)
651+
652+
This is a breaking change because existing code that relied on int matching
653+
int overloads may now match float overloads instead.
654+
"""
655+
# Test 1: float overload registered first, int second
656+
# When passing int(42), pybind11 tries overloads in order:
657+
# 1. float overload - can int(42) be converted? Yes (with PR #5879 changes)
658+
# 2. Match! Use float overload (int overload never checked)
659+
result = m.overload_resolution_test(42)
660+
assert result == "float: 42.000000", (
661+
f"Expected int(42) to match float overload, got: {result}. "
662+
"This documents the breaking change: int now matches float overloads."
663+
)
664+
assert m.overload_resolution_test(42.0) == "float: 42.000000"
665+
666+
# Test 2: With noconvert (strict mode) - this is the KEY breaking change
667+
# Before PR #5879: int(42) would NOT match float overload with noconvert, would match int overload
668+
# After PR #5879: int(42) DOES match float overload with noconvert (because int->float is now allowed)
669+
result_strict = m.overload_resolution_strict(42)
670+
assert result_strict == "float_strict: 42.000000", (
671+
f"Expected int(42) to match float overload with noconvert, got: {result_strict}. "
672+
"This is the key breaking change: int now matches float even in strict mode."
673+
)
674+
assert m.overload_resolution_strict(42.0) == "float_strict: 42.000000"
675+
676+
# Test 3: complex overload registered first, then float, then int
677+
# When passing int(5), pybind11 tries overloads in order:
678+
# 1. complex overload - can int(5) be converted? Yes (with PR #5879 changes)
679+
# 2. Match! Use complex overload
680+
assert m.overload_resolution_complex(5) == "complex: (5.000000, 0.000000)"
681+
assert m.overload_resolution_complex(5.0) == "complex: (5.000000, 0.000000)"
682+
assert (
683+
m.overload_resolution_complex(complex(3, 4)) == "complex: (3.000000, 4.000000)"
684+
)
685+
686+
# Verify that the overloads are registered in the expected order
687+
# The docstring should show float overload before int overload
688+
doc = m.overload_resolution_test.__doc__
689+
assert doc is not None
690+
# Check that float overload appears before int overload in docstring
691+
# The docstring uses "typing.SupportsFloat" and "typing.SupportsInt"
692+
float_pos = doc.find("SupportsFloat")
693+
int_pos = doc.find("SupportsInt")
694+
assert float_pos != -1, f"Could not find 'SupportsFloat' in docstring: {doc}"
695+
assert int_pos != -1, f"Could not find 'SupportsInt' in docstring: {doc}"
696+
assert float_pos < int_pos, (
697+
f"Float overload should appear before int overload in docstring. "
698+
f"Found 'SupportsFloat' at {float_pos}, 'SupportsInt' at {int_pos}. "
699+
f"Docstring: {doc}"
700+
)
701+
702+
540703
def test_bool_caster():
541704
"""Test bool caster implicit conversions."""
542705
convert, noconvert = m.bool_passthrough, m.bool_passthrough_noconvert

tests/test_custom_type_casters.py

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,17 +55,7 @@ def test_noconvert_args(msg):
5555

5656
assert m.floats_preferred(4) == 2.0
5757
assert m.floats_only(4.0) == 2.0
58-
with pytest.raises(TypeError) as excinfo:
59-
m.floats_only(4)
60-
assert (
61-
msg(excinfo.value)
62-
== """
63-
floats_only(): incompatible function arguments. The following argument types are supported:
64-
1. (f: float) -> float
65-
66-
Invoked with: 4
67-
"""
68-
)
58+
assert m.floats_only(4) == 2.0
6959

7060
assert m.ints_preferred(4) == 2
7161
assert m.ints_preferred(True) == 0

0 commit comments

Comments
 (0)