Skip to content

Commit 1d5bffb

Browse files
committed
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.
1 parent c080337 commit 1d5bffb

3 files changed

Lines changed: 174 additions & 6 deletions

File tree

‎src/embed_tests/TestFloatToIntConversion.cs‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,43 @@ def overloaded_named(value):
4141
4242
def single_params(value):
4343
return IntTaker(0).ComputeScaled(value)
44+
45+
class FloatSubclass(float):
46+
# numpy.float64-like: a float subclass
47+
pass
48+
49+
class FloatLike:
50+
# numpy.float32-like: float and (truncating) int conversions, no __index__
51+
def __init__(self, v):
52+
self._v = v
53+
def __float__(self):
54+
return float(self._v)
55+
def __int__(self):
56+
return int(self._v)
57+
58+
class IndexLike:
59+
# numpy.int64-like: a true integer type advertising __index__
60+
def __init__(self, v):
61+
self._v = v
62+
def __index__(self):
63+
return int(self._v)
64+
def __float__(self):
65+
return float(self._v)
66+
67+
def single_ctor_float_subclass(value):
68+
return IntTaker(FloatSubclass(value)).Value
69+
70+
def overloaded_ctor_float_subclass(value):
71+
return OverloadedIntTaker(FloatSubclass(value)).Value
72+
73+
def single_ctor_float_like(value):
74+
return IntTaker(FloatLike(value)).Value
75+
76+
def overloaded_ctor_float_like(value):
77+
return OverloadedIntTaker(FloatLike(value)).Value
78+
79+
def single_ctor_index_like(value):
80+
return IntTaker(IndexLike(value)).Value
4481
";
4582

4683
[OneTimeSetUp]
@@ -87,6 +124,50 @@ public void NonIntegralFloat_IsRejected(string func)
87124
Assert.AreEqual("TypeError", ex.Type.Name);
88125
}
89126

127+
// A float subclass (e.g. numpy.float64) follows the same rule as a plain
128+
// float: integral values convert, fractional ones are rejected instead of
129+
// being silently truncated through __int__.
130+
[TestCase("single_ctor_float_subclass")]
131+
[TestCase("overloaded_ctor_float_subclass")]
132+
public void IntegralFloatSubclass_IsAccepted(string func)
133+
{
134+
Assert.AreEqual(5, Call(func, 5.0));
135+
}
136+
137+
[TestCase("single_ctor_float_subclass")]
138+
[TestCase("overloaded_ctor_float_subclass")]
139+
public void NonIntegralFloatSubclass_IsRejected(string func)
140+
{
141+
var ex = Assert.Throws<PythonException>(() => Call(func, 5.5));
142+
Assert.AreEqual("TypeError", ex.Type.Name);
143+
}
144+
145+
// A number that defines __float__ but no __index__ (e.g. numpy.float32) is
146+
// float-like: integral values convert, fractional ones are rejected instead
147+
// of being silently truncated through __int__.
148+
[TestCase("single_ctor_float_like")]
149+
[TestCase("overloaded_ctor_float_like")]
150+
public void IntegralFloatLike_IsAccepted(string func)
151+
{
152+
Assert.AreEqual(5, Call(func, 5.0));
153+
}
154+
155+
[TestCase("single_ctor_float_like")]
156+
[TestCase("overloaded_ctor_float_like")]
157+
public void NonIntegralFloatLike_IsRejected(string func)
158+
{
159+
var ex = Assert.Throws<PythonException>(() => Call(func, 5.5));
160+
Assert.AreEqual("TypeError", ex.Type.Name);
161+
}
162+
163+
// A true integer type advertising __index__ (e.g. numpy.int64) is not
164+
// float-like and keeps converting even though it also defines __float__.
165+
[Test]
166+
public void IndexLike_IsAccepted()
167+
{
168+
Assert.AreEqual(5, Call("single_ctor_index_like", 5.0));
169+
}
170+
90171
// When no overload matches, the error should hint the expected signature(s).
91172
[Test]
92173
public void ErrorMessage_SingleOverload_ShowsExpectedSignature()

‎src/runtime/Converter.cs‎

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -907,6 +907,31 @@ internal static int ToInt32(BorrowedReference value)
907907
return checked((int)num);
908908
}
909909

910+
/// <summary>
911+
/// Determines whether a Python value is a floating-point number: a Python
912+
/// float (including subclasses such as numpy.float64) or a number that
913+
/// defines a float conversion but no lossless integer conversion
914+
/// (__float__ without __index__, e.g. numpy.float32). Integer values,
915+
/// including bools and types with __index__ such as numpy.int64, are not
916+
/// float-like.
917+
/// </summary>
918+
private static bool IsFloatLike(BorrowedReference value)
919+
{
920+
// The common case for integer parameters is an actual int; exit fast.
921+
if (Runtime.PyInt_Check(value) || Runtime.PyBool_Check(value))
922+
{
923+
return false;
924+
}
925+
926+
if (Runtime.PyObject_TypeCheck(value, Runtime.PyFloatType))
927+
{
928+
return true;
929+
}
930+
931+
return Runtime.PyObject_HasAttrString(value, "__float__") != 0
932+
&& Runtime.PyObject_HasAttrString(value, "__index__") == 0;
933+
}
934+
910935
/// <summary>
911936
/// Convert a Python value to an instance of a primitive managed type.
912937
/// </summary>
@@ -918,14 +943,23 @@ internal static bool ToPrimitive(BorrowedReference value, Type obType, out objec
918943

919944
TypeCode tc = Type.GetTypeCode(obType);
920945

921-
// A Python float with a fractional part must not be silently truncated
922-
// into an integer parameter. Integral-valued floats (e.g. 5.0) are still
923-
// accepted. This keeps single- and multi-overload binding consistent:
924-
// MethodBinder only treats integral floats as candidates for integer
925-
// parameters, and this guard enforces the same rule at conversion time.
926-
if (tc.IsInteger() && Runtime.PyFloat_Check(value))
946+
// A float-like value with a fractional part must not be silently truncated
947+
// into an integer parameter. Integral-valued ones (e.g. 5.0) are still
948+
// accepted. Besides Python floats this covers float subclasses such as
949+
// numpy.float64 and __float__-only numbers such as numpy.float32, which
950+
// would otherwise be truncated below through PyNumber_Long/__int__.
951+
// This keeps single- and multi-overload binding consistent: MethodBinder
952+
// only treats integral floats as candidates for integer parameters, and
953+
// this guard enforces the same rule at conversion time.
954+
if (tc.IsInteger() && IsFloatLike(value))
927955
{
928956
double dbl = Runtime.PyFloat_AsDouble(value);
957+
if (dbl == -1.0 && Exceptions.ErrorOccurred())
958+
{
959+
// __float__ itself failed; don't let the probe error leak
960+
Exceptions.Clear();
961+
goto type_error;
962+
}
929963
if (double.IsNaN(dbl) || double.IsInfinity(dbl) || Math.Truncate(dbl) != dbl)
930964
{
931965
goto type_error;

‎tests/test_conversion.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,59 @@ def test_int64_conversion():
267267
_ = System.Int64(-9223372036854775809)
268268

269269

270+
def test_numpy_float_to_int_conversion():
271+
"""A numpy float with a fractional value is rejected for integer targets
272+
instead of being silently truncated; integral-valued numpy floats convert.
273+
Numpy integer scalars are unaffected."""
274+
np = pytest.importorskip("numpy")
275+
276+
ob = ConversionTest()
277+
278+
# integral-valued numpy floats convert
279+
ob.Int32Field = np.float64(20.0)
280+
assert ob.Int32Field == 20
281+
282+
ob.Int32Field = np.float32(21.0)
283+
assert ob.Int32Field == 21
284+
285+
ob.Int64Field = np.float64(22.0)
286+
assert ob.Int64Field == 22
287+
288+
# non-integral numpy floats are rejected, not truncated
289+
with pytest.raises(TypeError):
290+
ConversionTest().Int32Field = np.float64(20.5)
291+
292+
with pytest.raises(TypeError):
293+
ConversionTest().Int32Field = np.float32(20.5)
294+
295+
with pytest.raises(TypeError):
296+
ConversionTest().Int64Field = np.float64(20.5)
297+
298+
# numpy integer scalars keep converting
299+
ob.Int32Field = np.int32(7)
300+
assert ob.Int32Field == 7
301+
302+
ob.Int32Field = np.int64(8)
303+
assert ob.Int32Field == 8
304+
305+
ob.Int64Field = np.int64(9)
306+
assert ob.Int64Field == 9
307+
308+
# plain float behavior is unchanged
309+
ob.Int32Field = 23.0
310+
assert ob.Int32Field == 23
311+
312+
with pytest.raises(TypeError):
313+
ConversionTest().Int32Field = 23.5
314+
315+
# method binding applies the same rule
316+
from Python.Test import MethodTest
317+
assert MethodTest.TestOverloadedNoObject(np.float64(5.0)) == "Got int"
318+
319+
with pytest.raises(TypeError):
320+
MethodTest.TestOverloadedNoObject(np.float64(5.5))
321+
322+
270323
def test_uint16_conversion():
271324
"""Test uint16 conversion."""
272325
assert System.UInt16.MaxValue == 65535

0 commit comments

Comments
 (0)