diff --git a/src/embed_tests/TestFloatToIntConversion.cs b/src/embed_tests/TestFloatToIntConversion.cs index b2802e7f7..5c3795f00 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,45 @@ public void NonIntegralFloat_IsRejected(string func) Assert.AreEqual("TypeError", ex.Type.Name); } + // 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) + { + 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); + } + + // __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) + { + 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); + } + + // __index__ types (e.g. numpy.int64) are integers, not float-like. + [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..048094bb9 100644 --- a/src/runtime/Converter.cs +++ b/src/runtime/Converter.cs @@ -907,6 +907,28 @@ internal static int ToInt32(BorrowedReference value) return checked((int)num); } + /// + /// 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) + { + // fast path for the common case: actual ints + 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 +940,17 @@ 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)) + // 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()) + { + // don't let a failed __float__ probe 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..cd2f1fd7a 100644 --- a/tests/test_conversion.py +++ b/tests/test_conversion.py @@ -267,6 +267,57 @@ def test_int64_conversion(): _ = System.Int64(-9223372036854775809) +def test_numpy_float_to_int_conversion(): + """Non-integral numpy floats are rejected for integer targets, not truncated.""" + 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