From 1d5bffb92b5a3edcd4091df2c171b948f1eb4d33 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 11 Aug 2026 11:56:45 -0400 Subject: [PATCH 1/2] Reject non-integral float-like values for integer parameters The non-integral rejection added in fb5cce6 only covered exact Python floats: Runtime.PyFloat_Check compares the type pointer, so float subclasses such as numpy.float64 and __float__-only numbers such as numpy.float32 bypassed the guard in Converter.ToPrimitive and fell through to PyNumber_Long/__int__, silently truncating the value (e.g. SimpleMovingAverage(np.float64(20.5)) built a period-20 indicator). Extend the guard to any float-like value: Python floats including subclasses, and numbers that define __float__ but no __index__. True integer types advertising __index__ (numpy.int64/int32) and plain ints are unaffected, and integral-valued floats (20.0) keep converting. Adds embed tests with float-subclass / __float__-only / __index__ fixtures and a numpy-backed python test over ConversionTest fields and method binding. --- src/embed_tests/TestFloatToIntConversion.cs | 81 +++++++++++++++++++++ src/runtime/Converter.cs | 46 ++++++++++-- tests/test_conversion.py | 53 ++++++++++++++ 3 files changed, 174 insertions(+), 6 deletions(-) diff --git a/src/embed_tests/TestFloatToIntConversion.cs b/src/embed_tests/TestFloatToIntConversion.cs index b2802e7f7..1be262155 100644 --- a/src/embed_tests/TestFloatToIntConversion.cs +++ b/src/embed_tests/TestFloatToIntConversion.cs @@ -41,6 +41,43 @@ def overloaded_named(value): def single_params(value): return IntTaker(0).ComputeScaled(value) + +class FloatSubclass(float): + # numpy.float64-like: a float subclass + pass + +class FloatLike: + # numpy.float32-like: float and (truncating) int conversions, no __index__ + def __init__(self, v): + self._v = v + def __float__(self): + return float(self._v) + def __int__(self): + return int(self._v) + +class IndexLike: + # numpy.int64-like: a true integer type advertising __index__ + def __init__(self, v): + self._v = v + def __index__(self): + return int(self._v) + def __float__(self): + return float(self._v) + +def single_ctor_float_subclass(value): + return IntTaker(FloatSubclass(value)).Value + +def overloaded_ctor_float_subclass(value): + return OverloadedIntTaker(FloatSubclass(value)).Value + +def single_ctor_float_like(value): + return IntTaker(FloatLike(value)).Value + +def overloaded_ctor_float_like(value): + return OverloadedIntTaker(FloatLike(value)).Value + +def single_ctor_index_like(value): + return IntTaker(IndexLike(value)).Value "; [OneTimeSetUp] @@ -87,6 +124,50 @@ public void NonIntegralFloat_IsRejected(string func) Assert.AreEqual("TypeError", ex.Type.Name); } + // A float subclass (e.g. numpy.float64) follows the same rule as a plain + // float: integral values convert, fractional ones are rejected instead of + // being silently truncated through __int__. + [TestCase("single_ctor_float_subclass")] + [TestCase("overloaded_ctor_float_subclass")] + public void IntegralFloatSubclass_IsAccepted(string func) + { + Assert.AreEqual(5, Call(func, 5.0)); + } + + [TestCase("single_ctor_float_subclass")] + [TestCase("overloaded_ctor_float_subclass")] + public void NonIntegralFloatSubclass_IsRejected(string func) + { + var ex = Assert.Throws(() => Call(func, 5.5)); + Assert.AreEqual("TypeError", ex.Type.Name); + } + + // A number that defines __float__ but no __index__ (e.g. numpy.float32) is + // float-like: integral values convert, fractional ones are rejected instead + // of being silently truncated through __int__. + [TestCase("single_ctor_float_like")] + [TestCase("overloaded_ctor_float_like")] + public void IntegralFloatLike_IsAccepted(string func) + { + Assert.AreEqual(5, Call(func, 5.0)); + } + + [TestCase("single_ctor_float_like")] + [TestCase("overloaded_ctor_float_like")] + public void NonIntegralFloatLike_IsRejected(string func) + { + var ex = Assert.Throws(() => Call(func, 5.5)); + Assert.AreEqual("TypeError", ex.Type.Name); + } + + // A true integer type advertising __index__ (e.g. numpy.int64) is not + // float-like and keeps converting even though it also defines __float__. + [Test] + public void IndexLike_IsAccepted() + { + Assert.AreEqual(5, Call("single_ctor_index_like", 5.0)); + } + // When no overload matches, the error should hint the expected signature(s). [Test] public void ErrorMessage_SingleOverload_ShowsExpectedSignature() diff --git a/src/runtime/Converter.cs b/src/runtime/Converter.cs index 51dbed7fe..cb9cb4f82 100644 --- a/src/runtime/Converter.cs +++ b/src/runtime/Converter.cs @@ -907,6 +907,31 @@ internal static int ToInt32(BorrowedReference value) return checked((int)num); } + /// + /// Determines whether a Python value is a floating-point number: a Python + /// float (including subclasses such as numpy.float64) or a number that + /// defines a float conversion but no lossless integer conversion + /// (__float__ without __index__, e.g. numpy.float32). Integer values, + /// including bools and types with __index__ such as numpy.int64, are not + /// float-like. + /// + private static bool IsFloatLike(BorrowedReference value) + { + // The common case for integer parameters is an actual int; exit fast. + if (Runtime.PyInt_Check(value) || Runtime.PyBool_Check(value)) + { + return false; + } + + if (Runtime.PyObject_TypeCheck(value, Runtime.PyFloatType)) + { + return true; + } + + return Runtime.PyObject_HasAttrString(value, "__float__") != 0 + && Runtime.PyObject_HasAttrString(value, "__index__") == 0; + } + /// /// Convert a Python value to an instance of a primitive managed type. /// @@ -918,14 +943,23 @@ internal static bool ToPrimitive(BorrowedReference value, Type obType, out objec TypeCode tc = Type.GetTypeCode(obType); - // A Python float with a fractional part must not be silently truncated - // into an integer parameter. Integral-valued floats (e.g. 5.0) are still - // accepted. This keeps single- and multi-overload binding consistent: - // MethodBinder only treats integral floats as candidates for integer - // parameters, and this guard enforces the same rule at conversion time. - if (tc.IsInteger() && Runtime.PyFloat_Check(value)) + // A float-like value with a fractional part must not be silently truncated + // into an integer parameter. Integral-valued ones (e.g. 5.0) are still + // accepted. Besides Python floats this covers float subclasses such as + // numpy.float64 and __float__-only numbers such as numpy.float32, which + // would otherwise be truncated below through PyNumber_Long/__int__. + // This keeps single- and multi-overload binding consistent: MethodBinder + // only treats integral floats as candidates for integer parameters, and + // this guard enforces the same rule at conversion time. + if (tc.IsInteger() && IsFloatLike(value)) { double dbl = Runtime.PyFloat_AsDouble(value); + if (dbl == -1.0 && Exceptions.ErrorOccurred()) + { + // __float__ itself failed; don't let the probe error leak + Exceptions.Clear(); + goto type_error; + } if (double.IsNaN(dbl) || double.IsInfinity(dbl) || Math.Truncate(dbl) != dbl) { goto type_error; diff --git a/tests/test_conversion.py b/tests/test_conversion.py index ae2b0f18a..d21974626 100644 --- a/tests/test_conversion.py +++ b/tests/test_conversion.py @@ -267,6 +267,59 @@ def test_int64_conversion(): _ = System.Int64(-9223372036854775809) +def test_numpy_float_to_int_conversion(): + """A numpy float with a fractional value is rejected for integer targets + instead of being silently truncated; integral-valued numpy floats convert. + Numpy integer scalars are unaffected.""" + np = pytest.importorskip("numpy") + + ob = ConversionTest() + + # integral-valued numpy floats convert + ob.Int32Field = np.float64(20.0) + assert ob.Int32Field == 20 + + ob.Int32Field = np.float32(21.0) + assert ob.Int32Field == 21 + + ob.Int64Field = np.float64(22.0) + assert ob.Int64Field == 22 + + # non-integral numpy floats are rejected, not truncated + with pytest.raises(TypeError): + ConversionTest().Int32Field = np.float64(20.5) + + with pytest.raises(TypeError): + ConversionTest().Int32Field = np.float32(20.5) + + with pytest.raises(TypeError): + ConversionTest().Int64Field = np.float64(20.5) + + # numpy integer scalars keep converting + ob.Int32Field = np.int32(7) + assert ob.Int32Field == 7 + + ob.Int32Field = np.int64(8) + assert ob.Int32Field == 8 + + ob.Int64Field = np.int64(9) + assert ob.Int64Field == 9 + + # plain float behavior is unchanged + ob.Int32Field = 23.0 + assert ob.Int32Field == 23 + + with pytest.raises(TypeError): + ConversionTest().Int32Field = 23.5 + + # method binding applies the same rule + from Python.Test import MethodTest + assert MethodTest.TestOverloadedNoObject(np.float64(5.0)) == "Got int" + + with pytest.raises(TypeError): + MethodTest.TestOverloadedNoObject(np.float64(5.5)) + + def test_uint16_conversion(): """Test uint16 conversion.""" assert System.UInt16.MaxValue == 65535 From 2fde197c807a5f33aa2e854fd8014cb23b8ff68f Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 11 Aug 2026 13:07:25 -0400 Subject: [PATCH 2/2] Tighten comments in float-like integer conversion guard and tests --- src/embed_tests/TestFloatToIntConversion.cs | 11 +++------- src/runtime/Converter.cs | 23 +++++++-------------- tests/test_conversion.py | 4 +--- 3 files changed, 11 insertions(+), 27 deletions(-) diff --git a/src/embed_tests/TestFloatToIntConversion.cs b/src/embed_tests/TestFloatToIntConversion.cs index 1be262155..5c3795f00 100644 --- a/src/embed_tests/TestFloatToIntConversion.cs +++ b/src/embed_tests/TestFloatToIntConversion.cs @@ -124,9 +124,7 @@ public void NonIntegralFloat_IsRejected(string func) Assert.AreEqual("TypeError", ex.Type.Name); } - // A float subclass (e.g. numpy.float64) follows the same rule as a plain - // float: integral values convert, fractional ones are rejected instead of - // being silently truncated through __int__. + // Float subclasses (e.g. numpy.float64) follow the plain-float rule. [TestCase("single_ctor_float_subclass")] [TestCase("overloaded_ctor_float_subclass")] public void IntegralFloatSubclass_IsAccepted(string func) @@ -142,9 +140,7 @@ public void NonIntegralFloatSubclass_IsRejected(string func) Assert.AreEqual("TypeError", ex.Type.Name); } - // A number that defines __float__ but no __index__ (e.g. numpy.float32) is - // float-like: integral values convert, fractional ones are rejected instead - // of being silently truncated through __int__. + // __float__-only numbers (e.g. numpy.float32) follow the plain-float rule. [TestCase("single_ctor_float_like")] [TestCase("overloaded_ctor_float_like")] public void IntegralFloatLike_IsAccepted(string func) @@ -160,8 +156,7 @@ public void NonIntegralFloatLike_IsRejected(string func) Assert.AreEqual("TypeError", ex.Type.Name); } - // A true integer type advertising __index__ (e.g. numpy.int64) is not - // float-like and keeps converting even though it also defines __float__. + // __index__ types (e.g. numpy.int64) are integers, not float-like. [Test] public void IndexLike_IsAccepted() { diff --git a/src/runtime/Converter.cs b/src/runtime/Converter.cs index cb9cb4f82..048094bb9 100644 --- a/src/runtime/Converter.cs +++ b/src/runtime/Converter.cs @@ -908,16 +908,13 @@ internal static int ToInt32(BorrowedReference value) } /// - /// Determines whether a Python value is a floating-point number: a Python - /// float (including subclasses such as numpy.float64) or a number that - /// defines a float conversion but no lossless integer conversion - /// (__float__ without __index__, e.g. numpy.float32). Integer values, - /// including bools and types with __index__ such as numpy.int64, are not - /// float-like. + /// True for Python floats (including subclasses like numpy.float64) and for + /// numbers with __float__ but no __index__ (like numpy.float32); __index__ + /// marks a type as losslessly int-convertible, so those are not float-like. /// private static bool IsFloatLike(BorrowedReference value) { - // The common case for integer parameters is an actual int; exit fast. + // fast path for the common case: actual ints if (Runtime.PyInt_Check(value) || Runtime.PyBool_Check(value)) { return false; @@ -943,20 +940,14 @@ internal static bool ToPrimitive(BorrowedReference value, Type obType, out objec TypeCode tc = Type.GetTypeCode(obType); - // A float-like value with a fractional part must not be silently truncated - // into an integer parameter. Integral-valued ones (e.g. 5.0) are still - // accepted. Besides Python floats this covers float subclasses such as - // numpy.float64 and __float__-only numbers such as numpy.float32, which - // would otherwise be truncated below through PyNumber_Long/__int__. - // This keeps single- and multi-overload binding consistent: MethodBinder - // only treats integral floats as candidates for integer parameters, and - // this guard enforces the same rule at conversion time. + // Reject non-integral float-like values (incl. numpy floats) for integer + // targets; the PyNumber_Long path below would silently truncate them. if (tc.IsInteger() && IsFloatLike(value)) { double dbl = Runtime.PyFloat_AsDouble(value); if (dbl == -1.0 && Exceptions.ErrorOccurred()) { - // __float__ itself failed; don't let the probe error leak + // don't let a failed __float__ probe leak Exceptions.Clear(); goto type_error; } diff --git a/tests/test_conversion.py b/tests/test_conversion.py index d21974626..cd2f1fd7a 100644 --- a/tests/test_conversion.py +++ b/tests/test_conversion.py @@ -268,9 +268,7 @@ def test_int64_conversion(): def test_numpy_float_to_int_conversion(): - """A numpy float with a fractional value is rejected for integer targets - instead of being silently truncated; integral-valued numpy floats convert. - Numpy integer scalars are unaffected.""" + """Non-integral numpy floats are rejected for integer targets, not truncated.""" np = pytest.importorskip("numpy") ob = ConversionTest()